freeleaps-service-hub/apps/payment/backend/business/stripe_manager.py

564 lines
22 KiB
Python

from datetime import datetime, timezone
from typing import Dict, Optional, Tuple
from backend.infra.payment.models import StripeTransactionDoc
from backend.infra.payment.constants import TransactionStatus
from common.config.app_settings import app_settings
import stripe
from stripe.error import SignatureVerificationError
from common.log.module_logger import ModuleLogger
from decimal import Decimal
import json
import httpx
stripe.api_key = app_settings.STRIPE_API_KEY
class StripeManager:
def __init__(self) -> None:
self.site_url_root = app_settings.SITE_URL_ROOT.rstrip("/")
self.module_logger = ModuleLogger(sender_id="StripeManager")
async def create_stripe_account(self) -> Optional[str]:
account = stripe.Account.create(type="standard")
return account.id
async def create_account_link(self, account_id: str, link_type: str = "account_onboarding") -> Optional[str]:
account = stripe.Account.retrieve(account_id)
# For account_update, try to show dashboard if TOS is accepted
self.module_logger.log_info("create_account_link urls",
{
"redirect_url": "{}/work".format(self.site_url_root),
"refresh_url": "{}/front-door".format(self.site_url_root),
"return_url": "{}/work".format(self.site_url_root)
}
)
if link_type == "account_update" and account.tos_acceptance.date:
login_link = stripe.Account.create_login_link(
account_id,
redirect_url="{}/work".format(self.site_url_root)
)
return login_link.url
# Otherwise show onboarding
account_link = stripe.AccountLink.create(
account=account_id,
refresh_url="{}/front-door".format(self.site_url_root),
return_url="{}/work".format(self.site_url_root),
type="account_onboarding",
)
return account_link.url
async def can_account_receive_payments(self, account_id: str) -> bool:
account = stripe.Account.retrieve(account_id)
if account.capabilities and account.capabilities["transfers"] == "active":
return True
else:
return False
async def __fetch_transaction_by_id(
self, transaction_id: str
) -> Optional[StripeTransactionDoc]:
transaction = await StripeTransactionDoc.get(transaction_id)
if transaction:
return transaction
return None
async def fetch_transaction_by_id(
self, transaction_id: str
) -> Optional[Dict[str, any]]:
transaction = await StripeTransactionDoc.get(transaction_id)
if transaction:
return transaction.model_dump()
return None
async def __fetch_transaction_by_session_id(
self, session_id: str
) -> Optional[StripeTransactionDoc]:
transactions = await StripeTransactionDoc.find(
StripeTransactionDoc.stripe_checkout_session_id == session_id
).to_list()
if len(transactions) > 1:
await self.module_logger.log_error(
error="More than one transaction found for session_id: {}".format(
session_id
),
properties={"session_id": session_id},
)
elif len(transactions) == 0:
await self.module_logger.log_error(
error="No transaction found for session_id: {}".format(session_id),
properties={"session_id": session_id},
)
return None
return transactions[0]
async def fetch_transaction_by_session_id(
self, session_id: str
) -> Optional[Dict[str, any]]:
transaction = await StripeTransactionDoc.find(
StripeTransactionDoc.stripe_checkout_session_id == session_id
).to_list()
if len(transaction) > 1:
await self.module_logger.log_error(
error="More than one transaction found for session_id: {}".format(
session_id
),
properties={"session_id": session_id},
)
elif len(transaction) == 0:
return None
return transaction[0].model_dump()
async def fetch_stripe_transaction_for_milestone(
self, project_id: str, milestone_index: int
) -> Optional[Dict[str, any]]:
transaction = await StripeTransactionDoc.find(
StripeTransactionDoc.project_id == project_id,
StripeTransactionDoc.milestone_index == milestone_index,
).to_list()
if len(transaction) > 1:
await self.module_logger.log_error(
error="More than one transaction found for project_id: {} and milestone_index: {}".format(
project_id, milestone_index
),
properties={
"project_id": project_id,
"milestone_index": milestone_index,
},
)
elif len(transaction) == 0:
return None
return transaction[0].model_dump()
async def create_stripe_transaction_for_milestone(
self,
project_id: str,
milestone_index: int,
currency: str,
expected_payment: Decimal,
from_user: str,
to_user: str,
to_stripe_account_id: str,
) -> Optional[str]:
transactions = await StripeTransactionDoc.find(
StripeTransactionDoc.project_id == project_id,
StripeTransactionDoc.milestone_index == milestone_index,
).to_list()
if len(transactions) == 0:
transaction_doc = StripeTransactionDoc(
project_id=project_id,
milestone_index=milestone_index,
currency=currency,
unit_amount=int(expected_payment * 100),
from_user=from_user,
to_user=to_user,
to_stripe_account_id=to_stripe_account_id,
created_time=datetime.now(timezone.utc),
updated_time=datetime.now(timezone.utc),
status=TransactionStatus.PENDING,
)
transaction = await transaction_doc.create()
return transaction.id
else:
await self.module_logger.log_error(
error="Transaction already exists for project_id: {} and milestone_index: {}".format(
project_id, milestone_index
),
properties={
"project_id": project_id,
"milestone_index": milestone_index,
},
)
res = transactions[0].id
return res
async def create_payment_link(self, transaction_id: str) -> Optional[str]:
transaction = await StripeTransactionDoc.get(transaction_id)
if transaction:
if transaction.stripe_payment_link:
return transaction.stripe_payment_link
if not transaction.stripe_product_id:
product = stripe.Product.create(
name="{}-{}".format(
transaction.project_id, transaction.milestone_index
)
)
transaction.stripe_product_id = product.id
await transaction.save()
if not transaction.stripe_price_id:
price = stripe.Price.create(
unit_amount=transaction.unit_amount,
currency=transaction.currency,
product=transaction.stripe_product_id,
)
transaction.stripe_price_id = price.id
await transaction.save()
# Prepare payment link parameters with conditional application_fee_amount
payment_link_params = {
"line_items": [
{
"price": transaction.stripe_price_id,
"quantity": 1,
}
],
"on_behalf_of": transaction.to_stripe_account_id,
"transfer_data": {
"destination": transaction.to_stripe_account_id,
},
}
# Only add application_fee_amount if it's greater than 0
if transaction.application_fee_amount and transaction.application_fee_amount > 0:
payment_link_params["application_fee_amount"] = transaction.application_fee_amount
payment_link = stripe.PaymentLink.create(**payment_link_params)
if payment_link:
transaction.stripe_payment_link = payment_link.url
transaction.updated_time = datetime.now(timezone.utc)
await transaction.save()
return payment_link.url
else:
return None
async def create_checkout_session(
self, transaction_id: str
) -> Tuple[Optional[str], Optional[str]]:
transaction = await StripeTransactionDoc.get(transaction_id)
if transaction:
if transaction.stripe_checkout_session_id:
session = stripe.checkout.Session.retrieve(
transaction.stripe_checkout_session_id
)
expires_at_timestamp = session.expires_at
expires_at_utc = datetime.fromtimestamp(
expires_at_timestamp, tz=timezone.utc
)
if datetime.now(timezone.utc) < expires_at_utc:
return (
transaction.stripe_checkout_session_id,
transaction.stripe_checkout_session_url,
)
# Check connected account capabilities
connected_account = stripe.Account.retrieve(
transaction.to_stripe_account_id
)
# if (
# connected_account.capabilities.get("card_payments") != "active"
# or connected_account.capabilities.get("transfers") != "active"
# ):
# raise Exception(
# f"Connected account {transaction.to_stripe_account_id} lacks required capabilities."
# )
if not transaction.stripe_product_id:
product = stripe.Product.create(
name="{}-{}".format(
transaction.project_id, transaction.milestone_index
)
)
transaction.stripe_product_id = product.id
await transaction.save()
if not transaction.stripe_price_id:
price = stripe.Price.create(
unit_amount=transaction.unit_amount,
currency=transaction.currency,
product=transaction.stripe_product_id,
)
transaction.stripe_price_id = price.id
await transaction.save()
# Prepare payment_intent_data with conditional application_fee_amount
payment_intent_data = {
"on_behalf_of": transaction.to_stripe_account_id,
"transfer_data": {
"destination": transaction.to_stripe_account_id,
},
}
# Only add application_fee_amount if it's greater than 0
if transaction.application_fee_amount and transaction.application_fee_amount > 0:
payment_intent_data["application_fee_amount"] = transaction.application_fee_amount
session_params = {
"payment_method_types": ["card"],
"line_items": [
{
"price": transaction.stripe_price_id,
"quantity": 1,
}
],
"payment_intent_data": payment_intent_data,
"mode": "payment",
"success_url": "{}/projects".format(self.site_url_root),
"cancel_url": "{}/projects".format(self.site_url_root),
}
session = stripe.checkout.Session.create(**session_params)
if session:
transaction.stripe_checkout_session_id = session.id
transaction.stripe_checkout_session_url = session.url
transaction.updated_time = datetime.now(timezone.utc)
await transaction.save()
return session.id, session.url
else:
return None, None
async def fetch_payment_link(self, transaction_id: str) -> Optional[str]:
transaction = await StripeTransactionDoc.get(transaction_id)
if transaction and transaction.stripe_payment_link:
return transaction.stripe_payment_link
return None
async def fetch_checkout_session_id(self, transaction_id: str) -> Optional[str]:
transaction = await StripeTransactionDoc.get(transaction_id)
if transaction and transaction.stripe_checkout_session_id:
return transaction.stripe_checkout_session_id
return None
async def fetch_checkout_session_url(self, transaction_id: str) -> Optional[str]:
transaction = await StripeTransactionDoc.get(transaction_id)
if transaction and transaction.stripe_checkout_session_url:
return transaction.stripe_checkout_session_url
return None
async def invoke_checkout_session_webhook(
self, event: dict
) -> Tuple[bool, Optional[str], Optional[str]]:
"""
Handle checkout.session.completed webhook events from Stripe.
Updates transaction status and saves payment method information for future use.
"""
if event["type"] == "checkout.session.completed":
session = event["data"]["object"]
# Find and validate the transaction
transaction = await self.__fetch_transaction_by_session_id(session["id"])
if not transaction:
await self.module_logger.log_error(
error="Transaction not found for session_id: {}".format(session["id"]),
properties={"session_id": session["id"]},
)
return False, None, None
# Update transaction status to completed
await self.__update_transaction_status(transaction)
# Process and save payment method information
await self.__process_payment_method(session, transaction)
return True, transaction.project_id, transaction.milestone_index
return False, None, None
async def __update_transaction_status(self, transaction: StripeTransactionDoc) -> None:
"""
Update transaction status to completed and save to database.
"""
transaction.status = TransactionStatus.COMPLETED
transaction.updated_time = datetime.now(timezone.utc)
await transaction.save()
async def __process_payment_method(self, session: dict, transaction: StripeTransactionDoc) -> None:
"""
Extract payment method details from Stripe session and save to database.
Creates or finds customer and attaches payment method for future use.
"""
try:
# Get payment method details from Stripe
payment_method_info = await self.__extract_payment_method_info(session)
if not payment_method_info:
return
payment_method_id, card_details = payment_method_info
# Get or create Stripe customer for the user
customer_id = await self.__get_or_create_customer(transaction.from_user)
if not customer_id:
return
# Attach payment method to customer and save to database
await self.__attach_and_save_payment_method(
payment_method_id, card_details, customer_id, transaction.from_user
)
except Exception as payment_method_error:
await self.module_logger.log_error(
error=f"Error processing payment method: {payment_method_error}",
properties={"session_id": session["id"], "user_id": transaction.from_user}
)
async def __extract_payment_method_info(self, session: dict) -> Optional[Tuple[str, dict]]:
"""
Extract payment method ID and card details from Stripe session.
Returns tuple of (payment_method_id, card_details) or None if not found.
"""
try:
# Get the Stripe session to extract payment method details
stripe_session = stripe.checkout.Session.retrieve(session["id"])
payment_intent_id = stripe_session.get('payment_intent')
if not payment_intent_id:
return None
payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id)
payment_method_id = payment_intent.get('payment_method')
if not payment_method_id:
return None
payment_method = stripe.PaymentMethod.retrieve(payment_method_id)
card_details = payment_method.get('card', {})
return payment_method_id, card_details
except Exception as e:
await self.module_logger.log_error(
error=f"Error extracting payment method info: {e}",
properties={"session_id": session["id"]}
)
return None
async def __get_or_create_customer(self, user_id: str) -> Optional[str]:
"""
Find existing Stripe customer by email or create new one.
Returns customer ID or None if creation fails.
"""
try:
# Generate email for user (fallback since we don't have access to user profile)
user_email = f"user_{user_id}@freeleaps.com"
# Search for existing customers by email
customers = stripe.Customer.list(email=user_email, limit=1)
if customers.data:
return customers.data[0].id
# Create new customer if not found
customer = stripe.Customer.create(
email=user_email,
metadata={"user_id": user_id}
)
return customer.id
except Exception as customer_error:
await self.module_logger.log_error(
error=f"Error getting/creating customer: {customer_error}",
properties={"user_id": user_id}
)
return None
async def __attach_and_save_payment_method(
self, payment_method_id: str, card_details: dict, customer_id: str, user_id: str
) -> None:
"""
Attach payment method to Stripe customer and save details to database.
Handles various error scenarios gracefully.
"""
try:
# Check if payment method is already attached to a customer
payment_method_obj = stripe.PaymentMethod.retrieve(payment_method_id)
if payment_method_obj.customer:
# Use the existing customer ID
customer_id = payment_method_obj.customer
else:
# Try to attach payment method to customer in Stripe
stripe.PaymentMethod.attach(
payment_method_id,
customer=customer_id
)
# Save to database
await self.__save_payment_method_to_db(
payment_method_id, card_details, customer_id, user_id
)
except stripe.error.InvalidRequestError as attach_error:
# Handle specific Stripe attachment errors
await self.__handle_attachment_error(
attach_error, payment_method_id, card_details, customer_id, user_id
)
except Exception as save_error:
await self.module_logger.log_error(
error=f"Error attaching payment method: {save_error}",
properties={"payment_method_id": payment_method_id, "user_id": user_id}
)
async def __save_payment_method_to_db(
self, payment_method_id: str, card_details: dict, customer_id: str, user_id: str
) -> None:
"""
Save payment method details to database if it doesn't already exist.
"""
from backend.infra.payment.models import StripePaymentMethodDoc
# Check if payment method already exists in our database
existing_payment_method = await StripePaymentMethodDoc.find_one(
StripePaymentMethodDoc.stripe_payment_method_id == payment_method_id
)
if existing_payment_method:
return # Already saved
# Save to our database
payment_method_doc = StripePaymentMethodDoc(
user_id=user_id,
stripe_customer_id=customer_id,
stripe_payment_method_id=payment_method_id,
card_last4=card_details.get('last4'),
card_brand=card_details.get('brand'),
card_exp_month=card_details.get('exp_month'),
card_exp_year=card_details.get('exp_year'),
created_time=datetime.now(timezone.utc),
updated_time=datetime.now(timezone.utc),
)
await payment_method_doc.save()
async def __handle_attachment_error(
self, attach_error: stripe.error.InvalidRequestError,
payment_method_id: str, card_details: dict, customer_id: str, user_id: str
) -> None:
"""
Handle specific Stripe attachment errors and still save to database when possible.
"""
error_message = str(attach_error).lower()
if "already attached" in error_message or "may not be used again" in error_message:
# Payment method can't be attached but we can still save to database
await self.__save_payment_method_to_db(
payment_method_id, card_details, customer_id, user_id
)
else:
# Log other attachment errors
await self.module_logger.log_error(
error=f"Error attaching payment method: {attach_error}",
properties={"payment_method_id": payment_method_id, "user_id": user_id}
)