import logging from typing import Optional import stripe from stripe import CardError from django.conf import settings from django.db import transaction from rest_framework.request import Request import stripe.error from labs.models import ( User as DBUser, CreditTransaction as DBUserCreditTransaction, UserCreditDetail as DBUserCreditDetail, UserCreditSummary as DBUserCreditSummary, BillingUserProfile as DBBillingUserProfile, BillingStripePaymentMethod as DBBillingStripePaymentMethod, BillingCreditOrder as DBBillingCreditOrder, BillingStripeCountryAccountMapping as DBBillingStripeCountryAccountMapping, LarkMessage as DBLarkMessage, ) from labs.billing.exceptions import ( StripeAttachPaymentMethodError, CheckoutOrderError, ) from labs.billing.consts import ( STRIPE_MAX_PAYMENT_METHODS, STRIPE_ALLOWED_PAYMENT_METHOD_TYPES, BILLING_ALLOWED_COUNTRIES, ) from labs.site_settings import SiteSettings logger = logging.getLogger(__name__) VALID_LOCALES = ["zh", "en", "ja"] DEFAULT_LOCALE = "en" def get_valid_locale(user: DBUser) -> str: if user.language_code in VALID_LOCALES: return user.language_code return DEFAULT_LOCALE class StripeBaseProvider: CURRENCY = "usd" class StripeProvider(StripeBaseProvider): def __init__(self, user: DBUser, request: Request): self.user = user self.request = request self.client = stripe.StripeClient( api_key=settings.STRIPE_API_KEY, max_network_retries=2, ) def get_or_create_stripe_customer(self) -> str: """ Get or create a stripe customer for the user. Returns: str: The stripe customer ID """ logger.info(f"Getting or creating stripe customer for user {self.user.id}") try: return self.user.billing_profile.stripe_customer_id except DBBillingUserProfile.DoesNotExist: pass customer = self.client.customers.create( params={ "email": self.user.email, "description": f"Lab user {self.user.id}", "metadata": { "platform": "lab", "user_id": self.user.id, }, }, ) logger.info(f"Created stripe customer {customer.id} for user {self.user.id}") DBBillingUserProfile.objects.create( user=self.user, stripe_customer_id=customer.id, ) return customer.id def _can_user_add_stripe_payment_method(self) -> tuple[bool, str]: """ Check if the user can add a new stripe payment method. Returns: tuple[bool, str]: A tuple containing a boolean indicating if the user can add a new payment method and a message. """ count = DBBillingStripePaymentMethod.objects.filter(user=self.user).count() if count >= STRIPE_MAX_PAYMENT_METHODS: return ( False, "Each user can only have up to 2 stripe payment methods, please remove one before adding a new one.", ) return True, "" def create_setup_intent( self, payment_method_types: list[str] = STRIPE_ALLOWED_PAYMENT_METHOD_TYPES, usage: str = "on_session", ) -> str: """ Create a setup intent for the user. Returns: str: The setup intent client secret Raises: StripeAttachPaymentMethodError: If the user has reached the maximum number of payment methods """ logger.info(f"Creating setup intent for user {self.user.id}") can_add, reason = self._can_user_add_stripe_payment_method() if not can_add: logger.info( f"User {self.user.id} cannot add a new payment method, reason: {reason}" ) raise StripeAttachPaymentMethodError(reason) stripe_customer_id = self.get_or_create_stripe_customer() try: setup_intent = self.client.setup_intents.create( params={ "customer": stripe_customer_id, "payment_method_types": payment_method_types, "usage": usage, }, ) except Exception as e: logger.error(f"Error creating setup intent for user {self.user.id}: {e}") raise e logger.info(f"Created setup intent {setup_intent.id} for user {self.user.id}") return setup_intent.client_secret def update_primary_billing_profile_if_needed( self, payment_method: stripe.PaymentMethod ) -> None: """ Update the primary billing profile if needed. """ if ( payment_method.billing_details.address.country not in BILLING_ALLOWED_COUNTRIES ): logger.info( f"Payment method {payment_method.id} is not allowed for user {self.user.id}" ) return profile = self.user.billing_profile if not profile.country: payment_method_address = payment_method.billing_details.address profile.country = payment_method_address.country profile.state = payment_method_address.state profile.city = payment_method_address.city profile.postal_code = payment_method_address.postal_code profile.line1 = payment_method_address.line1 profile.line2 = payment_method_address.line2 profile.save() def attach_payment_method_to_customer(self, payment_method_id: str) -> None: """ Attach a payment method to the user's stripe customer. """ logger.info( f"Attaching payment method {payment_method_id} to user {self.user.id}" ) can_add, reason = self._can_user_add_stripe_payment_method() if not can_add: logger.info( f"User {self.user.id} cannot attach a new payment method, reason: {reason}" ) raise StripeAttachPaymentMethodError(reason) if DBBillingStripePaymentMethod.objects.filter( stripe_pm_id=payment_method_id ).exists(): logger.info( f"Payment method {payment_method_id} already attached to user {self.user.id}, skip." ) return try: payment_method = self.client.payment_methods.retrieve(payment_method_id) except stripe.error.InvalidRequestError as e: logger.error( f"Error retrieving payment method {payment_method_id} for user {self.user.id}: {e}" ) raise StripeAttachPaymentMethodError( "This payment method may be invalid, please try again." ) if ( payment_method.billing_details.address.country not in BILLING_ALLOWED_COUNTRIES ): logger.info( f"Payment method {payment_method_id} is not allowed for user {self.user.id}" ) raise StripeAttachPaymentMethodError( "We're sorry, but the payment method you've selected is from a country that we don't currently support. " "At this time, we only accept payment methods from the following countries: %s" % ", ".join(BILLING_ALLOWED_COUNTRIES) ) stripe_customer_id = self.get_or_create_stripe_customer() self.update_primary_billing_profile_if_needed(payment_method) # TODO: validate payment method try: pm = self.client.payment_methods.attach( payment_method=payment_method_id, params={ "customer": stripe_customer_id, }, ) DBBillingStripePaymentMethod.objects.create( user=self.user, stripe_pm_id=pm.id, card_brand=pm.card.brand, last4=pm.card.last4, exp_month=pm.card.exp_month, exp_year=pm.card.exp_year, ) except CardError as e: logger.info( f"Failed to attach payment method {payment_method_id} to user {self.user.id}: {e}" ) raise StripeAttachPaymentMethodError(e.user_message) from e except Exception as e: logger.error( f"Error attaching payment method {payment_method_id} to user {self.user.id}: {e}" ) raise e logger.info( f"Attached payment method {payment_method_id} to user {self.user.id}" ) def detach_payment_method_from_customer(self, payment_method_id: str) -> None: """ Detach a payment method from the user's stripe customer. """ logger.info( f"Detaching payment method {payment_method_id} from user {self.user.id}" ) try: db_pm = DBBillingStripePaymentMethod.objects.get( stripe_pm_id=payment_method_id, user=self.user, ) db_pm.delete() self.client.payment_methods.detach(payment_method_id) except DBBillingStripePaymentMethod.DoesNotExist: logger.info( f"Payment method {payment_method_id} not found for user {self.user.id}, skip." ) return except Exception as e: logger.error( f"Error detaching payment method {payment_method_id} from user {self.user.id}: {e}" ) raise e logger.info( f"Detached payment method {payment_method_id} from user {self.user.id}" ) def _get_stripe_account( self, country: str ) -> Optional[DBBillingStripeCountryAccountMapping]: """ Get the stripe connect account for the user. """ try: return DBBillingStripeCountryAccountMapping.objects.get(country=country) except DBBillingStripeCountryAccountMapping.DoesNotExist: logger.error(f"No stripe connect account found for country {country}") return None def checkout_order( self, order: DBBillingCreditOrder, payment_method_id: str ) -> tuple[str, dict]: logger.info(f"Checking out order {order.id} for user {self.user.id}") with transaction.atomic(): try: order = DBBillingCreditOrder.objects.select_for_update().get( id=order.id ) except DBBillingCreditOrder.DoesNotExist: return if order.status != DBBillingCreditOrder.Status.CREATED: logger.info( f"Order {order.id} for user {self.user.id} is not in created status, skip." ) return stripe_account = self._get_stripe_account(self.user.billing_profile.country) stripe_connect_account_id = None if stripe_account and stripe_account.country != "US": stripe_connect_account_id = stripe_account.stripe_account_id logger.info( f"Using stripe connect account {stripe_account.stripe_account_id} for order {order.id}" ) try: logger.info(f"Creating payment intent for order {order.id}") payment_intent_params = { "amount": int(order.amount_total * 100), "currency": order.currency, "customer": self.get_or_create_stripe_customer(), "payment_method": payment_method_id, "confirm": True, "return_url": "https://labs.tidb.io/credits", "metadata": { "platform": "lab", "order_id": order.id, }, "setup_future_usage": "on_session", } if stripe_connect_account_id: payment_intent_params["on_behalf_of"] = stripe_connect_account_id payment_intent_params["transfer_data"] = { "destination": stripe_connect_account_id, } payment_intent = self.client.payment_intents.create( params=payment_intent_params, ) order.third_party_id = payment_intent.id order.third_party_type = ( DBBillingCreditOrder.ThirdPartyType.STRIPE_PAYMENT_INTENT ) order.save() logger.info( f"Created payment intent {payment_intent.id} for order {order.id}, status: {payment_intent.status}" ) except CardError as e: logger.info( f"Failed to create payment intent for order {order.id}: {e}" ) order.status = DBBillingCreditOrder.Status.FAILED order.status_details = {"detail": e.user_message} order.save() raise CheckoutOrderError(e.user_message) from e except Exception as e: logger.error(f"Error creating payment intent for order {order.id}: {e}") raise e if payment_intent.status == "succeeded": order_succeeded(order) order.refresh_from_db() logger.info(f"Order {order.id} for user {self.user.id} succeeded") elif payment_intent.status == "requires_action": order.status = DBBillingCreditOrder.Status.REQUIRES_ACTION order.status_details = payment_intent.next_action order.save() logger.info(f"Order {order.id} for user {self.user.id} requires action") else: order.status = DBBillingCreditOrder.Status.FAILED order.save() logger.info(f"Order {order.id} for user {self.user.id} failed") return order.status, order.status_details def stripe_webhook_payment_intent_succeeded(payment_intent_id: str) -> None: try: order = DBBillingCreditOrder.objects.get( third_party_id=payment_intent_id, third_party_type=DBBillingCreditOrder.ThirdPartyType.STRIPE_PAYMENT_INTENT, ) except DBBillingCreditOrder.DoesNotExist: logger.error(f"[Stripe webhook] Payment intent {payment_intent_id} not found") return order_succeeded(order) def order_succeeded(order: DBBillingCreditOrder) -> None: with transaction.atomic(): order = DBBillingCreditOrder.objects.select_for_update().get(id=order.id) if order.status == DBBillingCreditOrder.Status.SUCCEEDED: logger.info(f"Order {order.id} already succeeded, skip.") return order.status = DBBillingCreditOrder.Status.SUCCEEDED order.save() credit_summary = DBUserCreditSummary.objects.select_for_update().get( user=order.user ) previous = { "paid_credits": credit_summary.paid_credits, "free_credits": credit_summary.free_credits, } paid_credits_detail = DBUserCreditDetail.objects.get( user=order.user, credit_type=DBUserCreditDetail.CreditType.PAID, ) paid_credits_detail.amount += order.quantity paid_credits_detail.save() credit_summary.paid_credits = paid_credits_detail.amount credit_summary.save() DBUserCreditTransaction.objects.create( user=order.user, transaction_type=DBUserCreditTransaction.TransactionType.BUY_CREDITS, log={"previous": previous}, paid_credits=order.quantity, free_credits=0, ) lark_webhook = SiteSettings.billing_notification_lark_webhook if lark_webhook: DBLarkMessage.send( webhook=lark_webhook, message_type=DBLarkMessage.MessageType.MARKDOWN, content=f"User {order.user.email} has successfully purchased {order.quantity} credits.", title=f"We Got New Payment!", comment=f"Order ID: {order.id}", ) class StripeDummyProvider(StripeBaseProvider): """ StripeDummyProvider is a dummy provider that does not create any real requests to Stripe. It is used to test the billing flow. """ pass