From a09b66cb8eb1a33b5425ee0900d4a77b75ee3ca0 Mon Sep 17 00:00:00 2001 From: Kaustubh Maske Patil <37668193+nikochiko@users.noreply.github.com> Date: Thu, 1 Aug 2024 20:50:04 +0530 Subject: [PATCH 01/10] Store Stripe billing info + auto recharge without subscription --- app_users/signals.py | 73 +++++++++++++++++++++-------- daras_ai_v2/billing.py | 42 ++++++++++------- payments/models.py | 103 +++++++++++++++++++++++++---------------- payments/webhooks.py | 75 +++++++++++++++++++----------- 4 files changed, 192 insertions(+), 101 deletions(-) diff --git a/app_users/signals.py b/app_users/signals.py index 8e6b32dcf..bee42c391 100644 --- a/app_users/signals.py +++ b/app_users/signals.py @@ -1,19 +1,54 @@ -# from django.db import transaction -# from django.db.models.signals import post_delete -# from django.dispatch import receiver -# from firebase_admin import auth -# -# from app_users.models import AppUser -# -# -# @receiver(post_delete, sender=AppUser) -# def profile_post_delete(instance: AppUser, **kwargs): -# if not instance.uid: -# return -# -# @transaction.on_commit -# def _(): -# try: -# auth.delete_user(instance.uid) -# except auth.UserNotFoundError: -# pass +import stripe +from loguru import logger +from django.db.models.signals import post_save +from django.dispatch import receiver + +from app_users.models import AppUserTransaction, PaymentProvider, TransactionReason +from payments.plans import PricingPlan +from payments.webhooks import set_user_subscription + + +@receiver(post_save, sender=AppUserTransaction) +def after_stripe_addon(instance: AppUserTransaction, **kwargs): + if not ( + instance.payment_provider == PaymentProvider.STRIPE + and instance.reason == TransactionReason.ADDON + ): + return + + set_default_payment_method(instance) + set_free_subscription_on_user(instance) + + +def set_default_payment_method(instance: AppUserTransaction): + # update customer's defualt payment method + # note... that if a customer has an active subscription, the payment method attached there will be preferred + # see `stripe_get_default_payment_method` in payments/models.py module + invoice = stripe.Invoice.retrieve(instance.invoice_id, expand=["payment_intent"]) + if ( + invoice.payment_intent + and invoice.payment_intent.status == "succeeded" + and invoice.payment_intent.payment_method + ): + logger.info( + f"Updating default payment method for customer {invoice.customer} to {invoice.payment_intent.payment_method}" + ) + stripe.Customer.modify( + invoice.customer, + invoice_settings={ + "default_payment_method": invoice.payment_intent.payment_method + }, + ) + + +def set_free_subscription_on_user(instance: AppUserTransaction): + user = instance.user + if user.subscription: + return + + set_user_subscription( + provider=PaymentProvider.STRIPE, + plan=PricingPlan.STARTER, + uid=user.uid, + external_id=None, + ) diff --git a/daras_ai_v2/billing.py b/daras_ai_v2/billing.py index 09b48d375..ce8084b10 100644 --- a/daras_ai_v2/billing.py +++ b/daras_ai_v2/billing.py @@ -23,7 +23,7 @@ def billing_page(user: AppUser): render_payments_setup() - if user.subscription: + if user.subscription and user.subscription.plan != PricingPlan.STARTER.db_value: render_current_plan(user) with gui.div(className="my-5"): @@ -35,7 +35,7 @@ def billing_page(user: AppUser): with gui.div(className="my-5"): render_addon_section(user, selected_payment_provider) - if user.subscription and user.subscription.payment_provider: + if user.subscription: if user.subscription.payment_provider == PaymentProvider.STRIPE: with gui.div(className="my-5"): render_auto_recharge_section(user) @@ -131,7 +131,7 @@ def render_all_plans(user: AppUser) -> PaymentProvider: plans_div = gui.div(className="mb-1") if user.subscription and user.subscription.payment_provider: - selected_payment_provider = None + selected_payment_provider = user.subscription.payment_provider else: with gui.div(): selected_payment_provider = PaymentProvider[ @@ -149,7 +149,10 @@ def _render_plan(plan: PricingPlan): ): _render_plan_details(plan) _render_plan_action_button( - user, plan, current_plan, selected_payment_provider + user=user, + plan=plan, + current_plan=current_plan, + payment_provider=selected_payment_provider, ) with plans_div: @@ -198,7 +201,9 @@ def _render_plan_action_button( className=btn_classes + " btn btn-theme btn-primary", ): gui.html("Contact Us") - elif user.subscription and not user.subscription.payment_provider: + elif ( + user.subscription and user.subscription.plan == PricingPlan.ENTERPRISE.db_value + ): # don't show upgrade/downgrade buttons for enterprise customers # assumption: anyone without a payment provider attached is admin/enterprise return @@ -208,7 +213,7 @@ def _render_plan_action_button( else: label, btn_type = ("Downgrade", "secondary") - if user.subscription and user.subscription.payment_provider: + if user.subscription and user.subscription.external_id: # subscription exists, show upgrade/downgrade button _render_update_subscription_button( label, @@ -322,7 +327,6 @@ def change_subscription(user: AppUser, new_plan: PricingPlan, **kwargs): if new_plan == PricingPlan.STARTER: user.subscription.cancel() - user.subscription.delete() raise gui.RedirectException( get_app_route_url(payment_processing_route), status_code=303 ) @@ -383,7 +387,7 @@ def render_addon_section(user: AppUser, selected_payment_provider: PaymentProvid gui.write("# Purchase Credits") gui.caption(f"Buy more credits. $1 per {settings.ADDON_CREDITS_PER_DOLLAR} credits") - if user.subscription: + if user.subscription and user.subscription.payment_provider: provider = PaymentProvider(user.subscription.payment_provider) else: provider = selected_payment_provider @@ -423,7 +427,7 @@ def render_stripe_addon_button(dollat_amt: int, user: AppUser): "Confirm Purchase", key=f"confirm-purchase-{dollat_amt}" ) if gui.button(f"${dollat_amt:,}", type="primary"): - if user.subscription: + if user.subscription and user.subscription.payment_provider: confirm_purchase_modal.open() else: stripe_addon_checkout_redirect(user, dollat_amt) @@ -503,7 +507,7 @@ def stripe_subscription_checkout_redirect(user: AppUser, plan: PricingPlan): from routers.account import account_route from routers.account import payment_processing_route - if user.subscription: + if user.subscription and user.subscription.plan == plan.db_value: # already subscribed to some plan return @@ -544,24 +548,28 @@ def render_paypal_subscription_button( def render_payment_information(user: AppUser): - assert user.subscription + if not user.subscription: + return + + pm_summary = gui.run_in_thread( + user.subscription.get_payment_method_summary, cache=True + ) + if not pm_summary: + return gui.write("## Payment Information", id="payment-information", className="d-block") col1, col2, col3 = gui.columns(3, responsive=False) with col1: gui.write("**Pay via**") with col2: - provider = PaymentProvider(user.subscription.payment_provider) + provider = PaymentProvider( + user.subscription.payment_provider or PaymentProvider.STRIPE + ) gui.write(provider.label) with col3: if gui.button(f"{icons.edit} Edit", type="link", key="manage-payment-provider"): raise gui.RedirectException(user.subscription.get_external_management_url()) - pm_summary = gui.run_in_thread( - user.subscription.get_payment_method_summary, cache=True - ) - if not pm_summary: - return pm_summary = PaymentMethodSummary(*pm_summary) if pm_summary.card_brand and pm_summary.card_last4: col1, col2, col3 = gui.columns(3, responsive=False) diff --git a/payments/models.py b/payments/models.py index a7deadc5e..daa397e8a 100644 --- a/payments/models.py +++ b/payments/models.py @@ -5,6 +5,7 @@ import stripe from django.db import models +from django.db.models import Q from django.utils import timezone from app_users.models import PaymentProvider @@ -72,10 +73,14 @@ class Subscription(models.Model): objects = SubscriptionQuerySet.as_manager() class Meta: - unique_together = ("payment_provider", "external_id") - indexes = [ - models.Index(fields=["plan"]), + constraints = [ + models.UniqueConstraint( + fields=["payment_provider", "external_id"], + condition=Q(plan__ne=PricingPlan.STARTER.db_value), + name="unique_provider_and_subscription_id", + ) ] + indexes = [models.Index(fields=["plan"])] def __str__(self): ret = f"{self.get_plan_display()} | {self.get_payment_provider_display()}" @@ -126,6 +131,9 @@ def has_user(self) -> bool: return True def cancel(self): + if not self.external_id: + return + match self.payment_provider: case PaymentProvider.STRIPE: stripe.Subscription.cancel(self.external_id) @@ -156,16 +164,6 @@ def get_next_invoice_timestamp(self) -> float | None: def get_payment_method_summary(self) -> PaymentMethodSummary | None: match self.payment_provider: - case PaymentProvider.STRIPE: - pm = self.stripe_get_default_payment_method() - if not pm: - return None - return PaymentMethodSummary( - payment_method_type=pm.type, - card_brand=pm.card and pm.card.brand, - card_last4=pm.card and pm.card.last4, - billing_email=(pm.billing_details and pm.billing_details.email), - ) case PaymentProvider.PAYPAL: subscription = paypal.Subscription.retrieve(self.external_id) subscriber = subscription.subscriber @@ -178,17 +176,32 @@ def get_payment_method_summary(self) -> PaymentMethodSummary | None: card_last4=source.get("card", {}).get("last_digits"), billing_email=subscriber.email_address, ) + case PaymentProvider.STRIPE | None: + # None is for the case when user doesn't have a subscription, but has their payment + # method on Stripe. we can use this to autopay for their addons or in autorecharge + pm = self.stripe_get_default_payment_method() + if not pm: + return None + return PaymentMethodSummary( + payment_method_type=pm.type, + card_brand=pm.card and pm.card.brand, + card_last4=pm.card and pm.card.last4, + billing_email=(pm.billing_details and pm.billing_details.email), + ) def stripe_get_default_payment_method(self) -> stripe.PaymentMethod | None: - if self.payment_provider != PaymentProvider.STRIPE: - raise ValueError("Invalid Payment Provider") - - subscription = stripe.Subscription.retrieve(self.external_id) - if subscription.default_payment_method: - return stripe.PaymentMethod.retrieve(subscription.default_payment_method) + if self.payment_provider == PaymentProvider.STRIPE and self.external_id: + subscription = stripe.Subscription.retrieve(self.external_id) + if subscription.default_payment_method: + return stripe.PaymentMethod.retrieve( + subscription.default_payment_method + ) - customer = stripe.Customer.retrieve(subscription.customer) - if customer.invoice_settings.default_payment_method: + customer = self.stripe_get_customer() + if ( + customer.invoice_settings + and customer.invoice_settings.default_payment_method + ): return stripe.PaymentMethod.retrieve( customer.invoice_settings.default_payment_method ) @@ -208,12 +221,16 @@ def stripe_get_or_create_auto_invoice( - Fetch a `metadata_key` invoice that was recently paid - Create an invoice with amount=`amount_in_dollars` and `metadata_key` set to true """ - customer_id = self.stripe_get_customer_id() + customer = self.stripe_get_customer() invoices = stripe.Invoice.list( - customer=customer_id, + customer=customer.id, collection_method="charge_automatically", ) - invoices = [inv for inv in invoices.data if metadata_key in inv.metadata] + invoices = [ + inv + for inv in invoices.data + if inv.metadata and metadata_key in inv.metadata + ] open_invoice = next((inv for inv in invoices if inv.status == "open"), None) if open_invoice: @@ -232,16 +249,16 @@ def stripe_get_or_create_auto_invoice( ) def stripe_create_auto_invoice(self, *, amount_in_dollars: int, metadata_key: str): - customer_id = self.stripe_get_customer_id() + customer = self.stripe_get_customer() invoice = stripe.Invoice.create( - customer=customer_id, + customer=customer.id, collection_method="charge_automatically", metadata={metadata_key: True}, auto_advance=False, pending_invoice_items_behavior="exclude", ) stripe.InvoiceItem.create( - customer=customer_id, + customer=customer.id, invoice=invoice, price_data={ "currency": "usd", @@ -257,11 +274,15 @@ def stripe_create_auto_invoice(self, *, amount_in_dollars: int, metadata_key: st invoice.finalize_invoice(auto_advance=True) return invoice - def stripe_get_customer_id(self) -> str: - if self.payment_provider == PaymentProvider.STRIPE: - subscription = stripe.Subscription.retrieve(self.external_id) + def stripe_get_customer(self) -> stripe.Customer: + if self.payment_provider == PaymentProvider.STRIPE and self.external_id: + subscription = stripe.Subscription.retrieve( + self.external_id, expand=["customer"] + ) return subscription.customer - raise ValueError("Invalid Payment Provider") + + assert self.has_user + return self.user.get_or_create_stripe_customer() def stripe_attempt_addon_purchase(self, amount_in_dollars: int) -> bool: from payments.webhooks import StripeWebhookHandler @@ -286,12 +307,6 @@ def get_external_management_url(self) -> str: from routers.account import account_route match self.payment_provider: - case PaymentProvider.STRIPE: - portal = stripe.billing_portal.Session.create( - customer=self.stripe_get_customer_id(), - return_url=get_app_route_url(account_route), - ) - return portal.url case PaymentProvider.PAYPAL: return str( settings.PAYPAL_WEB_BASE_URL @@ -300,6 +315,12 @@ def get_external_management_url(self) -> str: / "connect" / self.external_id ) + case PaymentProvider.STRIPE | None: + portal = stripe.billing_portal.Session.create( + customer=self.stripe_get_customer().id, + return_url=get_app_route_url(account_route), + ) + return portal.url case _: raise NotImplementedError( f"Can't get management URL for subscription with provider {self.payment_provider}" @@ -329,5 +350,9 @@ def should_send_monthly_spending_notification(self) -> bool: def nearest_choice(choices: list[int], value: int) -> int: - # nearest value in choices that is less than or equal to value - return min(filter(lambda x: x <= value, choices), key=lambda x: abs(x - value)) + # nearest choice that is less than or equal to the value (or the minimum choice if value is the least) + le_choices = [choice for choice in choices if choice <= value] + if not le_choices: + return min(choices) + else: + return min(le_choices, key=lambda x: abs(value - x)) diff --git a/payments/webhooks.py b/payments/webhooks.py index b30cae120..741499e16 100644 --- a/payments/webhooks.py +++ b/payments/webhooks.py @@ -59,7 +59,7 @@ def handle_subscription_updated(cls, pp_sub: paypal.Subscription): ) return - _set_user_subscription( + set_user_subscription( provider=cls.PROVIDER, plan=plan, uid=pp_sub.custom_id, @@ -109,20 +109,25 @@ def handle_invoice_paid(cls, uid: str, invoice: stripe.Invoice): @classmethod def handle_checkout_session_completed(cls, uid: str, session_data): - if setup_intent_id := session_data.get("setup_intent") is None: + setup_intent_id = session_data.get("setup_intent") + if not setup_intent_id: # not a setup mode checkout -- do nothing return - setup_intent = stripe.SetupIntent.retrieve(setup_intent_id) - - # subscription_id was passed to metadata when creating the session - sub_id = setup_intent.metadata["subscription_id"] - assert ( - sub_id - ), f"subscription_id is missing in setup_intent metadata {setup_intent}" - stripe.Subscription.modify( - sub_id, default_payment_method=setup_intent.payment_method - ) + setup_intent = stripe.SetupIntent.retrieve(setup_intent_id) + if sub_id := setup_intent.metadata.get("subscription_id"): + # subscription_id was passed to metadata when creating the session + stripe.Subscription.modify( + sub_id, default_payment_method=setup_intent.payment_method + ) + elif customer_id := session_data.get("customer"): + # no subscription_id, so update the customer's default payment method instead + stripe.Customer.modify( + customer_id, + invoice_settings={ + "default_payment_method": setup_intent.payment_method + }, + ) @classmethod def handle_subscription_updated(cls, uid: str, stripe_sub: stripe.Subscription): @@ -146,7 +151,7 @@ def handle_subscription_updated(cls, uid: str, stripe_sub: stripe.Subscription): ) return - _set_user_subscription( + set_user_subscription( provider=cls.PROVIDER, plan=plan, uid=uid, @@ -190,22 +195,37 @@ def add_balance_for_payment( send_monthly_spending_notification_email.delay(user.id) -def _set_user_subscription( - *, provider: PaymentProvider, plan: PricingPlan, uid: str, external_id: str +def set_user_subscription( + *, + provider: PaymentProvider, + plan: PricingPlan, + uid: str, + external_id: str | None, ): + user = AppUser.objects.get_or_create_from_uid(uid)[0] + existing = user.subscription + if existing: + defaults = { + "auto_recharge_enabled": user.subscription.auto_recharge_enabled, + "auto_recharge_topup_amount": user.subscription.auto_recharge_topup_amount, + "auto_recharge_balance_threshold": user.subscription.auto_recharge_balance_threshold, + "monthly_spending_budget": user.subscription.monthly_spending_budget, + "monthly_spending_notification_threshold": user.subscription.monthly_spending_notification_threshold, + } + else: + defaults = {} + with transaction.atomic(): subscription, created = Subscription.objects.get_or_create( payment_provider=provider, external_id=external_id, - defaults=dict(plan=plan.db_value), + defaults=dict(plan=plan.db_value, **defaults), ) + subscription.plan = plan.db_value subscription.full_clean() subscription.save() - user = AppUser.objects.get_or_create_from_uid(uid)[0] - existing = user.subscription - user.subscription = subscription user.save(update_fields=["subscription"]) @@ -224,10 +244,13 @@ def _set_user_subscription( def _remove_subscription_for_user( *, uid: str, provider: PaymentProvider, external_id: str ): - AppUser.objects.filter( - uid=uid, - subscription__payment_provider=provider, - subscription__external_id=external_id, - ).update( - subscription=None, - ) + try: + user = AppUser.objects.get(uid=uid) + except AppUser.DoesNotExist: + logger.warning(f"User {uid} not found") + return + + user.subscription.plan = PricingPlan.STARTER.db_value + user.subscription.payment_provider = None + user.subscription.external_id = None + user.subscription.save(update_fields=["plan", "payment_provider", "external_id"]) From 2d3005820b9e144f7363cdde8284f952e6dc941c Mon Sep 17 00:00:00 2001 From: Kaustubh Maske Patil <37668193+nikochiko@users.noreply.github.com> Date: Thu, 15 Aug 2024 00:32:08 +0530 Subject: [PATCH 02/10] bugfix: allow new paypal subscription if user has free subscription --- routers/paypal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routers/paypal.py b/routers/paypal.py index 797812fae..933a2a9a7 100644 --- a/routers/paypal.py +++ b/routers/paypal.py @@ -126,7 +126,7 @@ def create_subscription(request: Request, payload: dict = fastapi_request_json): if plan.deprecated: return JSONResponse({"error": "Deprecated plan"}, status_code=400) - if request.user.subscription: + if request.user.subscription and request.user.subscription.is_paid(): return JSONResponse( {"error": "User already has an active subscription"}, status_code=400 ) From 9195356b95a6be5b9ee3f14ca9246544b19a9ae1 Mon Sep 17 00:00:00 2001 From: Kaustubh Maske Patil <37668193+nikochiko@users.noreply.github.com> Date: Thu, 15 Aug 2024 00:33:03 +0530 Subject: [PATCH 03/10] Add subscription.is_paid() method --- payments/models.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/payments/models.py b/payments/models.py index daa397e8a..9755d0ea8 100644 --- a/payments/models.py +++ b/payments/models.py @@ -130,8 +130,11 @@ def has_user(self) -> bool: else: return True + def is_paid(self) -> bool: + return self.plan != PricingPlan.STARTER.db_value + def cancel(self): - if not self.external_id: + if not self.is_paid(): return match self.payment_provider: @@ -176,9 +179,7 @@ def get_payment_method_summary(self) -> PaymentMethodSummary | None: card_last4=source.get("card", {}).get("last_digits"), billing_email=subscriber.email_address, ) - case PaymentProvider.STRIPE | None: - # None is for the case when user doesn't have a subscription, but has their payment - # method on Stripe. we can use this to autopay for their addons or in autorecharge + case PaymentProvider.STRIPE: pm = self.stripe_get_default_payment_method() if not pm: return None @@ -190,7 +191,7 @@ def get_payment_method_summary(self) -> PaymentMethodSummary | None: ) def stripe_get_default_payment_method(self) -> stripe.PaymentMethod | None: - if self.payment_provider == PaymentProvider.STRIPE and self.external_id: + if self.payment_provider == PaymentProvider.STRIPE and self.is_paid(): subscription = stripe.Subscription.retrieve(self.external_id) if subscription.default_payment_method: return stripe.PaymentMethod.retrieve( @@ -275,7 +276,7 @@ def stripe_create_auto_invoice(self, *, amount_in_dollars: int, metadata_key: st return invoice def stripe_get_customer(self) -> stripe.Customer: - if self.payment_provider == PaymentProvider.STRIPE and self.external_id: + if self.payment_provider == PaymentProvider.STRIPE and self.is_paid(): subscription = stripe.Subscription.retrieve( self.external_id, expand=["customer"] ) @@ -315,7 +316,7 @@ def get_external_management_url(self) -> str: / "connect" / self.external_id ) - case PaymentProvider.STRIPE | None: + case PaymentProvider.STRIPE: portal = stripe.billing_portal.Session.create( customer=self.stripe_get_customer().id, return_url=get_app_route_url(account_route), From 571b3d7a0a177fd54a2f2f82a6e1f707bdaa1042 Mon Sep 17 00:00:00 2001 From: Kaustubh Maske Patil <37668193+nikochiko@users.noreply.github.com> Date: Thu, 15 Aug 2024 00:33:25 +0530 Subject: [PATCH 04/10] Refactor and set free subscription on cancellation --- payments/webhooks.py | 59 +++++++++++++++++++------------------------- 1 file changed, 25 insertions(+), 34 deletions(-) diff --git a/payments/webhooks.py b/payments/webhooks.py index 741499e16..6a8a15b99 100644 --- a/payments/webhooks.py +++ b/payments/webhooks.py @@ -69,9 +69,7 @@ def handle_subscription_updated(cls, pp_sub: paypal.Subscription): @classmethod def handle_subscription_cancelled(cls, pp_sub: paypal.Subscription): assert pp_sub.custom_id, f"PayPal subscription {pp_sub.id} is missing uid" - _remove_subscription_for_user( - provider=cls.PROVIDER, uid=pp_sub.custom_id, external_id=pp_sub.id - ) + set_free_subscription_for_user(uid=pp_sub.custom_id) class StripeWebhookHandler: @@ -161,9 +159,7 @@ def handle_subscription_updated(cls, uid: str, stripe_sub: stripe.Subscription): @classmethod def handle_subscription_cancelled(cls, uid: str, stripe_sub): logger.info(f"Stripe subscription cancelled: {stripe_sub.id}") - _remove_subscription_for_user( - provider=cls.PROVIDER, uid=uid, external_id=stripe_sub.id - ) + set_free_subscription_for_user(uid=uid) def add_balance_for_payment( @@ -197,29 +193,30 @@ def add_balance_for_payment( def set_user_subscription( *, - provider: PaymentProvider, - plan: PricingPlan, uid: str, + plan: PricingPlan, + provider: PaymentProvider, external_id: str | None, -): +) -> Subscription: user = AppUser.objects.get_or_create_from_uid(uid)[0] existing = user.subscription + + defaults = {"plan": plan.db_value} if existing: - defaults = { + # transfer old auto recharge settings - whatever they are + defaults |= { "auto_recharge_enabled": user.subscription.auto_recharge_enabled, "auto_recharge_topup_amount": user.subscription.auto_recharge_topup_amount, "auto_recharge_balance_threshold": user.subscription.auto_recharge_balance_threshold, "monthly_spending_budget": user.subscription.monthly_spending_budget, "monthly_spending_notification_threshold": user.subscription.monthly_spending_notification_threshold, } - else: - defaults = {} with transaction.atomic(): subscription, created = Subscription.objects.get_or_create( payment_provider=provider, external_id=external_id, - defaults=dict(plan=plan.db_value, **defaults), + defaults=defaults, ) subscription.plan = plan.db_value @@ -229,28 +226,22 @@ def set_user_subscription( user.subscription = subscription user.save(update_fields=["subscription"]) - if not existing: - return + if existing: + # cancel existing subscription if it's not the same as the new one + if existing.external_id != external_id: + existing.cancel() - # cancel existing subscription if it's not the same as the new one - if existing.external_id != external_id: - existing.cancel() + # delete old db record if it exists + if existing.id != subscription.pk: + existing.delete() - # delete old db record if it exists - if existing.id != subscription.id: - existing.delete() + return subscription -def _remove_subscription_for_user( - *, uid: str, provider: PaymentProvider, external_id: str -): - try: - user = AppUser.objects.get(uid=uid) - except AppUser.DoesNotExist: - logger.warning(f"User {uid} not found") - return - - user.subscription.plan = PricingPlan.STARTER.db_value - user.subscription.payment_provider = None - user.subscription.external_id = None - user.subscription.save(update_fields=["plan", "payment_provider", "external_id"]) +def set_free_subscription_for_user(*, uid: str) -> Subscription: + return set_user_subscription( + uid=uid, + plan=PricingPlan.STARTER, + provider=PaymentProvider.STRIPE, + external_id=None, + ) From a5e4ec29db443e5849b629716092fc1c28a2319d Mon Sep 17 00:00:00 2001 From: Kaustubh Maske Patil <37668193+nikochiko@users.noreply.github.com> Date: Thu, 15 Aug 2024 00:33:58 +0530 Subject: [PATCH 05/10] Render auto recharge subscription for free users --- daras_ai_v2/billing.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/daras_ai_v2/billing.py b/daras_ai_v2/billing.py index ce8084b10..0cbc4abf0 100644 --- a/daras_ai_v2/billing.py +++ b/daras_ai_v2/billing.py @@ -23,7 +23,7 @@ def billing_page(user: AppUser): render_payments_setup() - if user.subscription and user.subscription.plan != PricingPlan.STARTER.db_value: + if user.subscription and user.subscription.is_paid(): render_current_plan(user) with gui.div(className="my-5"): @@ -36,9 +36,10 @@ def billing_page(user: AppUser): render_addon_section(user, selected_payment_provider) if user.subscription: - if user.subscription.payment_provider == PaymentProvider.STRIPE: + if user.subscription.payment_provider != PaymentProvider.PAYPAL: with gui.div(className="my-5"): render_auto_recharge_section(user) + with gui.div(className="my-5"): render_payment_information(user) @@ -213,7 +214,7 @@ def _render_plan_action_button( else: label, btn_type = ("Downgrade", "secondary") - if user.subscription and user.subscription.external_id: + if user.subscription and user.subscription.is_paid(): # subscription exists, show upgrade/downgrade button _render_update_subscription_button( label, @@ -644,10 +645,7 @@ def render_billing_history(user: AppUser, limit: int = 50): def render_auto_recharge_section(user: AppUser): - assert ( - user.subscription - and user.subscription.payment_provider == PaymentProvider.STRIPE - ) + assert user.subscription subscription = user.subscription gui.write("## Auto Recharge & Limits") From d3c489beec63f05da4aa5d2eb737831c7fc8d241 Mon Sep 17 00:00:00 2001 From: Kaustubh Maske Patil <37668193+nikochiko@users.noreply.github.com> Date: Thu, 15 Aug 2024 00:34:51 +0530 Subject: [PATCH 06/10] Move logic to add free subscription after addon to celery task --- app_users/signals.py | 46 +++++--------------------------- app_users/tasks.py | 62 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 40 deletions(-) create mode 100644 app_users/tasks.py diff --git a/app_users/signals.py b/app_users/signals.py index bee42c391..1717f7392 100644 --- a/app_users/signals.py +++ b/app_users/signals.py @@ -1,54 +1,20 @@ -import stripe from loguru import logger from django.db.models.signals import post_save from django.dispatch import receiver from app_users.models import AppUserTransaction, PaymentProvider, TransactionReason -from payments.plans import PricingPlan -from payments.webhooks import set_user_subscription +from app_users.tasks import after_stripe_addon @receiver(post_save, sender=AppUserTransaction) -def after_stripe_addon(instance: AppUserTransaction, **kwargs): +def transaction_post_save(instance: AppUserTransaction, **kwargs): if not ( instance.payment_provider == PaymentProvider.STRIPE and instance.reason == TransactionReason.ADDON ): return - set_default_payment_method(instance) - set_free_subscription_on_user(instance) - - -def set_default_payment_method(instance: AppUserTransaction): - # update customer's defualt payment method - # note... that if a customer has an active subscription, the payment method attached there will be preferred - # see `stripe_get_default_payment_method` in payments/models.py module - invoice = stripe.Invoice.retrieve(instance.invoice_id, expand=["payment_intent"]) - if ( - invoice.payment_intent - and invoice.payment_intent.status == "succeeded" - and invoice.payment_intent.payment_method - ): - logger.info( - f"Updating default payment method for customer {invoice.customer} to {invoice.payment_intent.payment_method}" - ) - stripe.Customer.modify( - invoice.customer, - invoice_settings={ - "default_payment_method": invoice.payment_intent.payment_method - }, - ) - - -def set_free_subscription_on_user(instance: AppUserTransaction): - user = instance.user - if user.subscription: - return - - set_user_subscription( - provider=PaymentProvider.STRIPE, - plan=PricingPlan.STARTER, - uid=user.uid, - external_id=None, - ) + try: + after_stripe_addon.delay(instance.pk) + except Exception as e: + logger.exception(e) diff --git a/app_users/tasks.py b/app_users/tasks.py new file mode 100644 index 000000000..2db46ae1f --- /dev/null +++ b/app_users/tasks.py @@ -0,0 +1,62 @@ +import stripe +from loguru import logger + +from app_users.models import AppUserTransaction +from celeryapp.celeryconfig import app +from payments.webhooks import set_free_subscription_for_user + + +@app.task +def after_stripe_addon(txn_id: int): + txn = AppUserTransaction.objects.get(id=txn_id) + + default_pm = stripe_set_default_payment_method(txn) + if default_pm and not txn.user.subscription: + # user is not on an existing subscription... add one so that user can configure auto recharge + subscription = set_free_subscription_for_user(uid=txn.user.uid) + if ( + subscription.auto_recharge_enabled + and not subscription.monthly_spending_budget + ): + subscription.monthly_spending_budget = int(3 * txn.charged_amount / 100) + subscription.monthly_spending_notification_threshold = int( + 0.8 * subscription.monthly_spending_budget + ) + subscription.save( + update_fields=[ + "monthly_spending_budget", + "monthly_spending_notification_threshold", + ] + ) + + +def stripe_set_default_payment_method( + instance: AppUserTransaction, +) -> stripe.PaymentMethod | None: + # update customer's defualt payment method + # note... that if a customer has an active subscription, the payment method attached there will be preferred + # see `stripe_get_default_payment_method` in payments/models.py module + invoice = stripe.Invoice.retrieve( + instance.invoice_id, expand=["payment_intent", "payment_intent.payment_method"] + ) + if ( + invoice.payment_intent + and invoice.payment_intent.status == "succeeded" + and invoice.payment_intent.payment_method + ): + if not invoice.payment_intent.payment_method.customer: + logger.info( + "Can't save payment method because it's not attached to a customer" + ) + return + + logger.info( + f"Updating default payment method for customer {invoice.customer} to {invoice.payment_intent.payment_method}" + ) + stripe.Customer.modify( + invoice.customer, + invoice_settings={ + "default_payment_method": invoice.payment_intent.payment_method + }, + ) + return invoice.payment_intent.payment_method From 2c8ab553ac6b665ab201a20c088cf0fa29923729 Mon Sep 17 00:00:00 2001 From: Dev Aggarwal Date: Sat, 17 Aug 2024 18:55:58 +0530 Subject: [PATCH 07/10] Improve error handling for Stripe payment method attachment - Fix payment method not being saved after addon purchase - enable setup_future_usage - Update subscription creation logic to avoid unnecessary Stripe checkout session when a default payment method exists. - Revert handle_checkout_session_completed() - don't save default payment method until invoice is paid - Simplify and consolidate default payment method save in `app_users.tasks`. - Handle Stripe subscription cancellation 404 - Fix billing logic to check for default payment method, add warning for missing method on auto-recharge - Add new utility functions in `Subscription` model to streamline default auto-recharge parameter settings - Modify admin view to include readonly fields for `created_at`, `updated_at`, and `get_payment_method_summary`. - Remove non-explicit `set_free_subscription_for_user` function. - update Subscription.is_paid to use PricingPlan.monthly_charge - Update unique constraint condition in Subscription model to check for monthly charge greater than zero instead of specific plan --- app_users/apps.py | 5 -- app_users/signals.py | 20 ------ app_users/tasks.py | 88 ++++++++++++-------------- daras_ai_v2/billing.py | 66 +++++++++++-------- payments/admin.py | 11 +++- payments/auto_recharge.py | 3 + payments/models.py | 129 +++++++++++++++++++++----------------- payments/webhooks.py | 112 ++++++++++++++++++--------------- routers/stripe.py | 2 +- 9 files changed, 227 insertions(+), 209 deletions(-) delete mode 100644 app_users/signals.py diff --git a/app_users/apps.py b/app_users/apps.py index 023d4ae99..e8d750128 100644 --- a/app_users/apps.py +++ b/app_users/apps.py @@ -5,8 +5,3 @@ class AppUsersConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "app_users" verbose_name = "App Users" - - def ready(self): - from . import signals - - assert signals diff --git a/app_users/signals.py b/app_users/signals.py deleted file mode 100644 index 1717f7392..000000000 --- a/app_users/signals.py +++ /dev/null @@ -1,20 +0,0 @@ -from loguru import logger -from django.db.models.signals import post_save -from django.dispatch import receiver - -from app_users.models import AppUserTransaction, PaymentProvider, TransactionReason -from app_users.tasks import after_stripe_addon - - -@receiver(post_save, sender=AppUserTransaction) -def transaction_post_save(instance: AppUserTransaction, **kwargs): - if not ( - instance.payment_provider == PaymentProvider.STRIPE - and instance.reason == TransactionReason.ADDON - ): - return - - try: - after_stripe_addon.delay(instance.pk) - except Exception as e: - logger.exception(e) diff --git a/app_users/tasks.py b/app_users/tasks.py index 2db46ae1f..0327ac423 100644 --- a/app_users/tasks.py +++ b/app_users/tasks.py @@ -1,62 +1,54 @@ import stripe from loguru import logger -from app_users.models import AppUserTransaction +from app_users.models import PaymentProvider, TransactionReason from celeryapp.celeryconfig import app -from payments.webhooks import set_free_subscription_for_user +from payments.models import Subscription +from payments.plans import PricingPlan +from payments.webhooks import set_user_subscription @app.task -def after_stripe_addon(txn_id: int): - txn = AppUserTransaction.objects.get(id=txn_id) - - default_pm = stripe_set_default_payment_method(txn) - if default_pm and not txn.user.subscription: - # user is not on an existing subscription... add one so that user can configure auto recharge - subscription = set_free_subscription_for_user(uid=txn.user.uid) - if ( - subscription.auto_recharge_enabled - and not subscription.monthly_spending_budget - ): - subscription.monthly_spending_budget = int(3 * txn.charged_amount / 100) - subscription.monthly_spending_notification_threshold = int( - 0.8 * subscription.monthly_spending_budget - ) - subscription.save( - update_fields=[ - "monthly_spending_budget", - "monthly_spending_notification_threshold", - ] - ) - +def save_stripe_default_payment_method( + *, + payment_intent_id: str, + uid: str, + amount: int, + charged_amount: int, + reason: TransactionReason, +): + pi = stripe.PaymentIntent.retrieve(payment_intent_id, expand=["payment_method"]) + pm = pi.payment_method + if not (pm and pm.customer): + logger.error( + f"Failed to retrieve payment method for payment intent {payment_intent_id}" + ) + return -def stripe_set_default_payment_method( - instance: AppUserTransaction, -) -> stripe.PaymentMethod | None: # update customer's defualt payment method - # note... that if a customer has an active subscription, the payment method attached there will be preferred + # note: if a customer has an active subscription, the payment method attached there will be preferred # see `stripe_get_default_payment_method` in payments/models.py module - invoice = stripe.Invoice.retrieve( - instance.invoice_id, expand=["payment_intent", "payment_intent.payment_method"] + logger.info( + f"Updating default payment method for customer {pm.customer} to {pm.id}" + ) + stripe.Customer.modify( + pm.customer, + invoice_settings=dict(default_payment_method=pm), ) + + # if user doesn't already have a active billing/autorecharge info, so we don't need to do anything + # set user's subscription to the free plan if ( - invoice.payment_intent - and invoice.payment_intent.status == "succeeded" - and invoice.payment_intent.payment_method + reason == TransactionReason.ADDON + and not Subscription.objects.filter( + user__uid=uid, payment_provider__isnull=False + ).exists() ): - if not invoice.payment_intent.payment_method.customer: - logger.info( - "Can't save payment method because it's not attached to a customer" - ) - return - - logger.info( - f"Updating default payment method for customer {invoice.customer} to {invoice.payment_intent.payment_method}" - ) - stripe.Customer.modify( - invoice.customer, - invoice_settings={ - "default_payment_method": invoice.payment_intent.payment_method - }, + set_user_subscription( + uid=uid, + plan=PricingPlan.STARTER, + provider=PaymentProvider.STRIPE, + external_id=None, + amount=amount, + charged_amount=charged_amount, ) - return invoice.payment_intent.payment_method diff --git a/daras_ai_v2/billing.py b/daras_ai_v2/billing.py index 0cbc4abf0..1c1a1846d 100644 --- a/daras_ai_v2/billing.py +++ b/daras_ai_v2/billing.py @@ -36,7 +36,7 @@ def billing_page(user: AppUser): render_addon_section(user, selected_payment_provider) if user.subscription: - if user.subscription.payment_provider != PaymentProvider.PAYPAL: + if user.subscription.payment_provider == PaymentProvider.STRIPE: with gui.div(className="my-5"): render_auto_recharge_section(user) @@ -60,11 +60,10 @@ def render_payments_setup(): def render_current_plan(user: AppUser): plan = PricingPlan.from_sub(user.subscription) - provider = ( - PaymentProvider(user.subscription.payment_provider) - if user.subscription.payment_provider - else None - ) + if user.subscription.payment_provider: + provider = PaymentProvider(user.subscription.payment_provider) + else: + provider = None with gui.div(className=f"{rounded_border} border-dark"): # ROW 1: Plan title and next invoice date @@ -102,7 +101,10 @@ def render_current_plan(user: AppUser): with left: gui.write(f"# {plan.pricing_title()}", className="no-margin") if plan.monthly_charge: - provider_text = f" **via {provider.label}**" if provider else "" + if provider: + provider_text = f" **via {provider.label}**" + else: + provider_text = "" gui.caption("per month" + provider_text) with right, gui.div(className="text-end"): @@ -206,7 +208,6 @@ def _render_plan_action_button( user.subscription and user.subscription.plan == PricingPlan.ENTERPRISE.db_value ): # don't show upgrade/downgrade buttons for enterprise customers - # assumption: anyone without a payment provider attached is admin/enterprise return else: if plan.credits > current_plan.credits: @@ -428,7 +429,7 @@ def render_stripe_addon_button(dollat_amt: int, user: AppUser): "Confirm Purchase", key=f"confirm-purchase-{dollat_amt}" ) if gui.button(f"${dollat_amt:,}", type="primary"): - if user.subscription and user.subscription.payment_provider: + if user.subscription and user.subscription.stripe_get_default_payment_method(): confirm_purchase_modal.open() else: stripe_addon_checkout_redirect(user, dollat_amt) @@ -478,9 +479,8 @@ def stripe_addon_checkout_redirect(user: AppUser, dollat_amt: int): customer=user.get_or_create_stripe_customer(), invoice_creation={"enabled": True}, allow_promotion_codes=True, - saved_payment_method_options={ - "payment_method_save": "enabled", - }, + saved_payment_method_options={"payment_method_save": "enabled"}, + payment_intent_data={"setup_future_usage": "off_session"}, ) raise gui.RedirectException(checkout_session.url, status_code=303) @@ -512,21 +512,35 @@ def stripe_subscription_checkout_redirect(user: AppUser, plan: PricingPlan): # already subscribed to some plan return + pm = user.subscription and user.subscription.stripe_get_default_payment_method() metadata = {settings.STRIPE_USER_SUBSCRIPTION_METADATA_FIELD: plan.key} - checkout_session = stripe.checkout.Session.create( - line_items=[(plan.get_stripe_line_item())], - mode="subscription", - success_url=get_app_route_url(payment_processing_route), - cancel_url=get_app_route_url(account_route), - customer=user.get_or_create_stripe_customer(), - metadata=metadata, - subscription_data={"metadata": metadata}, - allow_promotion_codes=True, - saved_payment_method_options={ - "payment_method_save": "enabled", - }, - ) - raise gui.RedirectException(checkout_session.url, status_code=303) + line_items = [plan.get_stripe_line_item()] + if pm: + # directly create the subscription without checkout + stripe.Subscription.create( + customer=pm.customer, + items=line_items, + metadata=metadata, + default_payment_method=pm.id, + proration_behavior="none", + ) + raise gui.RedirectException( + get_app_route_url(payment_processing_route), status_code=303 + ) + else: + checkout_session = stripe.checkout.Session.create( + mode="subscription", + success_url=get_app_route_url(payment_processing_route), + cancel_url=get_app_route_url(account_route), + allow_promotion_codes=True, + customer=user.get_or_create_stripe_customer(), + line_items=line_items, + metadata=metadata, + subscription_data={"metadata": metadata}, + saved_payment_method_options={"payment_method_save": "enabled"}, + payment_intent_data={"setup_future_usage": "off_session"}, + ) + raise gui.RedirectException(checkout_session.url, status_code=303) def render_paypal_subscription_button( diff --git a/payments/admin.py b/payments/admin.py index c889bc109..014375af8 100644 --- a/payments/admin.py +++ b/payments/admin.py @@ -5,4 +5,13 @@ @admin.register(Subscription) class SubscriptionAdmin(admin.ModelAdmin): - search_fields = ["plan", "payment_provider", "external_id"] + search_fields = [ + "plan", + "payment_provider", + "external_id", + ] + readonly_fields = [ + "created_at", + "updated_at", + "get_payment_method_summary", + ] diff --git a/payments/auto_recharge.py b/payments/auto_recharge.py index 1aa414cfb..14d6ba49d 100644 --- a/payments/auto_recharge.py +++ b/payments/auto_recharge.py @@ -107,6 +107,9 @@ def _auto_recharge_user(user: AppUser): # get default payment method and attempt payment assert invoice.status == "open" # sanity check pm = user.subscription.stripe_get_default_payment_method() + if not pm: + logger.warning(f"{user} has no default payment method, cannot auto-recharge") + return try: invoice_data = invoice.pay(payment_method=pm) diff --git a/payments/models.py b/payments/models.py index 9755d0ea8..3d16640d9 100644 --- a/payments/models.py +++ b/payments/models.py @@ -76,11 +76,13 @@ class Meta: constraints = [ models.UniqueConstraint( fields=["payment_provider", "external_id"], - condition=Q(plan__ne=PricingPlan.STARTER.db_value), + condition=Q(plan__monthly_charge__gt=0), name="unique_provider_and_subscription_id", ) ] - indexes = [models.Index(fields=["plan"])] + indexes = [ + models.Index(fields=["plan"]), + ] def __str__(self): ret = f"{self.get_plan_display()} | {self.get_payment_provider_display()}" @@ -90,37 +92,39 @@ def __str__(self): ret = f"Auto | {ret}" return ret - def full_clean(self, *args, **kwargs): + def full_clean( + self, amount: int = None, charged_amount: int = None, *args, **kwargs + ): + if self.auto_recharge_enabled: + if amount is None: + amount = PricingPlan.from_sub(self).credits + if charged_amount is None: + charged_amount = PricingPlan.from_sub(self).monthly_charge * 100 + self.ensure_default_auto_recharge_params( + amount=amount, charged_amount=charged_amount + ) + return super().full_clean(*args, **kwargs) + + def ensure_default_auto_recharge_params(self, *, amount: int, charged_amount: int): + if amount <= 0 or charged_amount <= 0: + return + if not self.auto_recharge_balance_threshold: - self.auto_recharge_balance_threshold = ( - self._get_default_auto_recharge_balance_threshold() + # 25% of the credits + self.auto_recharge_balance_threshold = nearest_choice( + settings.AUTO_RECHARGE_BALANCE_THRESHOLD_CHOICES, 0.25 * amount ) if not self.monthly_spending_budget: - self.monthly_spending_budget = self._get_default_monthly_spending_budget() + # 3x the charged amount + self.monthly_spending_budget = 3 * charged_amount / 100 # in dollars if not self.monthly_spending_notification_threshold: - self.monthly_spending_notification_threshold = ( - self._get_default_monthly_spending_notification_threshold() + # 80% of the monthly budget + self.monthly_spending_notification_threshold = int( + 0.8 * self.monthly_spending_budget ) - return super().full_clean(*args, **kwargs) - - def _get_default_auto_recharge_balance_threshold(self): - # 25% of the monthly credit subscription - threshold = int(PricingPlan.from_sub(self).credits * 0.25) - return nearest_choice( - settings.AUTO_RECHARGE_BALANCE_THRESHOLD_CHOICES, threshold - ) - - def _get_default_monthly_spending_budget(self): - # 3x the monthly subscription charge - return 3 * PricingPlan.from_sub(self).monthly_charge - - def _get_default_monthly_spending_notification_threshold(self): - # 80% of the monthly budget - return int(0.8 * self._get_default_monthly_spending_budget()) - @property def has_user(self) -> bool: try: @@ -131,15 +135,25 @@ def has_user(self) -> bool: return True def is_paid(self) -> bool: - return self.plan != PricingPlan.STARTER.db_value + return PricingPlan.from_sub(self).monthly_charge > 0 def cancel(self): + from payments.webhooks import StripeWebhookHandler + if not self.is_paid(): return match self.payment_provider: case PaymentProvider.STRIPE: - stripe.Subscription.cancel(self.external_id) + try: + stripe.Subscription.cancel(self.external_id) + except stripe.error.InvalidRequestError as e: + if e.code == "resource_missing": + StripeWebhookHandler.handle_subscription_cancelled( + self.user.uid + ) + else: + raise case PaymentProvider.PAYPAL: paypal.Subscription.retrieve(self.external_id).cancel() case _: @@ -191,21 +205,25 @@ def get_payment_method_summary(self) -> PaymentMethodSummary | None: ) def stripe_get_default_payment_method(self) -> stripe.PaymentMethod | None: - if self.payment_provider == PaymentProvider.STRIPE and self.is_paid(): - subscription = stripe.Subscription.retrieve(self.external_id) + if self.payment_provider != PaymentProvider.STRIPE: + return None + + if self.external_id: + subscription = stripe.Subscription.retrieve( + self.external_id, expand=["default_payment_method"] + ) if subscription.default_payment_method: - return stripe.PaymentMethod.retrieve( - subscription.default_payment_method - ) + return subscription.default_payment_method - customer = self.stripe_get_customer() + customer_id = self.stripe_get_customer_id() + customer = stripe.Customer.retrieve( + customer_id, expand=["invoice_settings.default_payment_method"] + ) if ( customer.invoice_settings and customer.invoice_settings.default_payment_method ): - return stripe.PaymentMethod.retrieve( - customer.invoice_settings.default_payment_method - ) + return customer.invoice_settings.default_payment_method return None @@ -222,9 +240,9 @@ def stripe_get_or_create_auto_invoice( - Fetch a `metadata_key` invoice that was recently paid - Create an invoice with amount=`amount_in_dollars` and `metadata_key` set to true """ - customer = self.stripe_get_customer() + customer_id = self.stripe_get_customer_id() invoices = stripe.Invoice.list( - customer=customer.id, + customer=customer_id, collection_method="charge_automatically", ) invoices = [ @@ -250,16 +268,16 @@ def stripe_get_or_create_auto_invoice( ) def stripe_create_auto_invoice(self, *, amount_in_dollars: int, metadata_key: str): - customer = self.stripe_get_customer() + customer_id = self.stripe_get_customer_id() invoice = stripe.Invoice.create( - customer=customer.id, + customer=customer_id, collection_method="charge_automatically", metadata={metadata_key: True}, auto_advance=False, pending_invoice_items_behavior="exclude", ) stripe.InvoiceItem.create( - customer=customer.id, + customer=customer_id, invoice=invoice, price_data={ "currency": "usd", @@ -275,15 +293,12 @@ def stripe_create_auto_invoice(self, *, amount_in_dollars: int, metadata_key: st invoice.finalize_invoice(auto_advance=True) return invoice - def stripe_get_customer(self) -> stripe.Customer: - if self.payment_provider == PaymentProvider.STRIPE and self.is_paid(): - subscription = stripe.Subscription.retrieve( - self.external_id, expand=["customer"] - ) + def stripe_get_customer_id(self) -> str: + if self.payment_provider == PaymentProvider.STRIPE and self.external_id: + subscription = stripe.Subscription.retrieve(self.external_id) return subscription.customer - - assert self.has_user - return self.user.get_or_create_stripe_customer() + else: + return self.user.get_or_create_stripe_customer().id def stripe_attempt_addon_purchase(self, amount_in_dollars: int) -> bool: from payments.webhooks import StripeWebhookHandler @@ -295,6 +310,8 @@ def stripe_attempt_addon_purchase(self, amount_in_dollars: int) -> bool: if invoice.status != "open": return False pm = self.stripe_get_default_payment_method() + if not pm: + return False invoice = invoice.pay(payment_method=pm) if not invoice.paid: return False @@ -318,7 +335,7 @@ def get_external_management_url(self) -> str: ) case PaymentProvider.STRIPE: portal = stripe.billing_portal.Session.create( - customer=self.stripe_get_customer().id, + customer=self.stripe_get_customer_id(), return_url=get_app_route_url(account_route), ) return portal.url @@ -350,10 +367,10 @@ def should_send_monthly_spending_notification(self) -> bool: ) -def nearest_choice(choices: list[int], value: int) -> int: +def nearest_choice(choices: list[int], value: float) -> int: # nearest choice that is less than or equal to the value (or the minimum choice if value is the least) - le_choices = [choice for choice in choices if choice <= value] - if not le_choices: - return min(choices) - else: - return min(le_choices, key=lambda x: abs(value - x)) + return min( + filter(lambda x: x <= value, choices), + key=lambda x: abs(x - value), + default=min(choices), + ) diff --git a/payments/webhooks.py b/payments/webhooks.py index 6a8a15b99..0b822cfe7 100644 --- a/payments/webhooks.py +++ b/payments/webhooks.py @@ -1,8 +1,14 @@ +from copy import copy + import stripe from django.db import transaction from loguru import logger -from app_users.models import AppUser, PaymentProvider, TransactionReason +from app_users.models import ( + AppUser, + PaymentProvider, + TransactionReason, +) from daras_ai_v2 import paypal from .models import Subscription from .plans import PricingPlan @@ -69,7 +75,12 @@ def handle_subscription_updated(cls, pp_sub: paypal.Subscription): @classmethod def handle_subscription_cancelled(cls, pp_sub: paypal.Subscription): assert pp_sub.custom_id, f"PayPal subscription {pp_sub.id} is missing uid" - set_free_subscription_for_user(uid=pp_sub.custom_id) + set_user_subscription( + uid=pp_sub.custom_id, + plan=PricingPlan.STARTER, + provider=None, + external_id=None, + ) class StripeWebhookHandler: @@ -77,6 +88,8 @@ class StripeWebhookHandler: @classmethod def handle_invoice_paid(cls, uid: str, invoice: stripe.Invoice): + from app_users.tasks import save_stripe_default_payment_method + kwargs = {} if invoice.subscription: kwargs["plan"] = PricingPlan.get_by_key( @@ -95,16 +108,27 @@ def handle_invoice_paid(cls, uid: str, invoice: stripe.Invoice): reason = TransactionReason.AUTO_RECHARGE else: reason = TransactionReason.ADDON + + amount = invoice.lines.data[0].quantity + charged_amount = invoice.lines.data[0].amount add_balance_for_payment( uid=uid, - amount=invoice.lines.data[0].quantity, + amount=amount, invoice_id=invoice.id, payment_provider=cls.PROVIDER, - charged_amount=invoice.lines.data[0].amount, + charged_amount=charged_amount, reason=reason, **kwargs, ) + save_stripe_default_payment_method.delay( + payment_intent_id=invoice.payment_intent, + uid=uid, + amount=amount, + charged_amount=charged_amount, + reason=reason, + ) + @classmethod def handle_checkout_session_completed(cls, uid: str, session_data): setup_intent_id = session_data.get("setup_intent") @@ -122,9 +146,9 @@ def handle_checkout_session_completed(cls, uid: str, session_data): # no subscription_id, so update the customer's default payment method instead stripe.Customer.modify( customer_id, - invoice_settings={ - "default_payment_method": setup_intent.payment_method - }, + invoice_settings=dict( + default_payment_method=setup_intent.payment_method + ), ) @classmethod @@ -157,9 +181,13 @@ def handle_subscription_updated(cls, uid: str, stripe_sub: stripe.Subscription): ) @classmethod - def handle_subscription_cancelled(cls, uid: str, stripe_sub): - logger.info(f"Stripe subscription cancelled: {stripe_sub.id}") - set_free_subscription_for_user(uid=uid) + def handle_subscription_cancelled(cls, uid: str): + set_user_subscription( + uid=uid, + plan=PricingPlan.STARTER, + provider=PaymentProvider.STRIPE, + external_id=None, + ) def add_balance_for_payment( @@ -195,53 +223,33 @@ def set_user_subscription( *, uid: str, plan: PricingPlan, - provider: PaymentProvider, + provider: PaymentProvider | None, external_id: str | None, + amount: int = None, + charged_amount: int = None, ) -> Subscription: - user = AppUser.objects.get_or_create_from_uid(uid)[0] - existing = user.subscription - - defaults = {"plan": plan.db_value} - if existing: - # transfer old auto recharge settings - whatever they are - defaults |= { - "auto_recharge_enabled": user.subscription.auto_recharge_enabled, - "auto_recharge_topup_amount": user.subscription.auto_recharge_topup_amount, - "auto_recharge_balance_threshold": user.subscription.auto_recharge_balance_threshold, - "monthly_spending_budget": user.subscription.monthly_spending_budget, - "monthly_spending_notification_threshold": user.subscription.monthly_spending_notification_threshold, - } - with transaction.atomic(): - subscription, created = Subscription.objects.get_or_create( - payment_provider=provider, - external_id=external_id, - defaults=defaults, - ) - - subscription.plan = plan.db_value - subscription.full_clean() - subscription.save() - - user.subscription = subscription - user.save(update_fields=["subscription"]) + user = AppUser.objects.get_or_create_from_uid(uid)[0] - if existing: - # cancel existing subscription if it's not the same as the new one - if existing.external_id != external_id: - existing.cancel() + old_sub = user.subscription + if old_sub: + new_sub = copy(old_sub) + else: + old_sub = None + new_sub = Subscription() - # delete old db record if it exists - if existing.id != subscription.pk: - existing.delete() + new_sub.plan = plan.db_value + new_sub.payment_provider = provider + new_sub.external_id = external_id + new_sub.full_clean(amount=amount, charged_amount=charged_amount) + new_sub.save() - return subscription + if not old_sub: + user.subscription = new_sub + user.save(update_fields=["subscription"]) + # cancel previous subscription if it's not the same as the new one + if old_sub and old_sub.external_id != external_id: + old_sub.cancel() -def set_free_subscription_for_user(*, uid: str) -> Subscription: - return set_user_subscription( - uid=uid, - plan=PricingPlan.STARTER, - provider=PaymentProvider.STRIPE, - external_id=None, - ) + return new_sub diff --git a/routers/stripe.py b/routers/stripe.py index 3d481d47b..04538bd55 100644 --- a/routers/stripe.py +++ b/routers/stripe.py @@ -46,6 +46,6 @@ def webhook_received(request: Request, payload: bytes = fastapi_request_body): case "customer.subscription.created" | "customer.subscription.updated": StripeWebhookHandler.handle_subscription_updated(uid, data) case "customer.subscription.deleted": - StripeWebhookHandler.handle_subscription_cancelled(uid, data) + StripeWebhookHandler.handle_subscription_cancelled(uid) return JSONResponse({"status": "success"}) From 9c52e1410c4bb725c49ee272440bbe62192058e5 Mon Sep 17 00:00:00 2001 From: Dev Aggarwal Date: Fri, 23 Aug 2024 12:41:37 +0530 Subject: [PATCH 08/10] - Consistent confirmation dialogs across the website - Allow the user to choose if they want to save payment information when doing one time addon purchases - Handle payment info being removed from stripe by user - Check and restore existing stripe subscriptions if database is out of sync --- daras_ai_v2/base.py | 69 ++++------ daras_ai_v2/billing.py | 270 +++++++++++++++++++------------------ daras_ai_v2/gui_confirm.py | 42 ++++++ payments/admin.py | 1 + payments/models.py | 12 +- tests/test_checkout.py | 4 +- 6 files changed, 215 insertions(+), 183 deletions(-) create mode 100644 daras_ai_v2/gui_confirm.py diff --git a/daras_ai_v2/base.py b/daras_ai_v2/base.py index fbbc7e60b..9e1c679fb 100644 --- a/daras_ai_v2/base.py +++ b/daras_ai_v2/base.py @@ -52,6 +52,7 @@ from daras_ai_v2.exceptions import InsufficientCredits from daras_ai_v2.fastapi_tricks import get_route_path from daras_ai_v2.grid_layout_widget import grid_layout +from daras_ai_v2.gui_confirm import confirm_modal from daras_ai_v2.html_spinner_widget import html_spinner from daras_ai_v2.manage_api_keys_widget import manage_api_keys from daras_ai_v2.meta_preview_url import meta_preview_url @@ -697,10 +698,29 @@ def _render_options_modal( save_as_new_button = gui.button( f"{save_as_new_icon} Save as New", className="w-100" ) - delete_button = not published_run.is_root() and gui.button( - f' Delete', - className="w-100 text-danger", - ) + + if not published_run.is_root(): + confirm_delete_modal, confirmed = confirm_modal( + title="Are you sure?", + key="--delete-run-modal", + text=f""" +Are you sure you want to delete this published run? + +**{published_run.title}** + +This will also delete all the associated versions. + """, + button_label="Delete", + button_class="border-danger bg-danger text-white", + ) + if gui.button( + f' Delete', + className="w-100 text-danger", + ): + confirm_delete_modal.open() + if confirmed: + published_run.delete() + raise gui.RedirectException(self.app_url()) if duplicate_button: duplicate_pr = self.duplicate_published_run( @@ -730,47 +750,6 @@ def _render_options_modal( gui.write("#### Version History", className="mb-4") self._render_version_history() - confirm_delete_modal = gui.Modal("Confirm Delete", key="confirm-delete-modal") - if delete_button: - confirm_delete_modal.open() - if confirm_delete_modal.is_open(): - modal.empty() - with confirm_delete_modal.container(): - self._render_confirm_delete_modal( - published_run=published_run, - modal=confirm_delete_modal, - ) - - def _render_confirm_delete_modal( - self, - *, - published_run: PublishedRun, - modal: gui.Modal, - ): - gui.write( - "Are you sure you want to delete this published run? " - f"_({published_run.title})_" - ) - gui.caption("This will also delete all the associated versions.") - with gui.div(className="d-flex"): - confirm_button = gui.button( - 'Confirm', - type="secondary", - className="w-100", - ) - cancel_button = gui.button( - "Cancel", - type="secondary", - className="w-100", - ) - - if confirm_button: - published_run.delete() - raise gui.RedirectException(self.app_url()) - - if cancel_button: - modal.close() - def _render_admin_options(self, current_run: SavedRun, published_run: PublishedRun): if ( not self.is_current_user_admin() diff --git a/daras_ai_v2/billing.py b/daras_ai_v2/billing.py index 1c1a1846d..24adbbd39 100644 --- a/daras_ai_v2/billing.py +++ b/daras_ai_v2/billing.py @@ -1,5 +1,3 @@ -from typing import Literal - import gooey_gui as gui import stripe from django.core.exceptions import ValidationError @@ -8,18 +6,17 @@ from daras_ai_v2 import icons, settings, paypal from daras_ai_v2.fastapi_tricks import get_app_route_url from daras_ai_v2.grid_layout_widget import grid_layout +from daras_ai_v2.gui_confirm import confirm_modal from daras_ai_v2.settings import templates from daras_ai_v2.user_date_widgets import render_local_date_attrs from payments.models import PaymentMethodSummary from payments.plans import PricingPlan +from payments.webhooks import StripeWebhookHandler from scripts.migrate_existing_subscriptions import available_subscriptions rounded_border = "w-100 border shadow-sm rounded py-4 px-3" -PlanActionLabel = Literal["Upgrade", "Downgrade", "Contact Us", "Your Plan"] - - def billing_page(user: AppUser): render_payments_setup() @@ -210,25 +207,53 @@ def _render_plan_action_button( # don't show upgrade/downgrade buttons for enterprise customers return else: - if plan.credits > current_plan.credits: - label, btn_type = ("Upgrade", "primary") - else: - label, btn_type = ("Downgrade", "secondary") - if user.subscription and user.subscription.is_paid(): # subscription exists, show upgrade/downgrade button - _render_update_subscription_button( - label, - user=user, - current_plan=current_plan, - plan=plan, - className=f"{btn_classes} btn btn-theme btn-{btn_type}", - ) + if plan.credits > current_plan.credits: + modal, confirmed = confirm_modal( + title="Upgrade Plan", + key=f"--modal-{plan.key}", + text=f""" +Are you sure you want to upgrade from: **{current_plan.title} ({fmt_price(current_plan)})** to **{plan.title} ({fmt_price(plan)})**? + +This will charge you the full amount today, and every month thereafter. + +**{current_plan.credits:,} credits** will be added to your account. + """, + button_label="Buy", + ) + if gui.button( + "Upgrade", className="primary", key=f"--change-sub-{plan.key}" + ): + modal.open() + if confirmed: + change_subscription( + user, + plan, + # when upgrading, charge the full new amount today: https://docs.stripe.com/billing/subscriptions/billing-cycle#reset-the-billing-cycle-to-the-current-time + billing_cycle_anchor="now", + ) + else: + modal, confirmed = confirm_modal( + title="Downgrade Plan", + key=f"--modal-{plan.key}", + text=f""" +Are you sure you want to downgrade from: **{current_plan.title} ({fmt_price(current_plan)})** to **{plan.title} ({fmt_price(plan)})**? + +This will take effect from the next billing cycle. + """, + button_label="Downgrade", + button_class="border-danger bg-danger text-white", + ) + if gui.button( + "Downgrade", className="secondary", key=f"--change-sub-{plan.key}" + ): + modal.open() + if confirmed: + change_subscription(user, plan) else: assert payment_provider is not None # for sanity _render_create_subscription_button( - label, - btn_type=btn_type, user=user, plan=plan, payment_provider=payment_provider, @@ -236,81 +261,18 @@ def _render_plan_action_button( def _render_create_subscription_button( - label: PlanActionLabel, *, - btn_type: str, user: AppUser, plan: PricingPlan, payment_provider: PaymentProvider, ): match payment_provider: case PaymentProvider.STRIPE: - key = f"stripe-sub-{plan.key}" - render_stripe_subscription_button( - user=user, - label=label, - plan=plan, - btn_type=btn_type, - key=key, - ) + render_stripe_subscription_button(user=user, plan=plan) case PaymentProvider.PAYPAL: render_paypal_subscription_button(plan=plan) -def _render_update_subscription_button( - label: PlanActionLabel, - *, - user: AppUser, - current_plan: PricingPlan, - plan: PricingPlan, - className: str = "", -): - key = f"change-sub-{plan.key}" - match label: - case "Downgrade": - downgrade_modal = gui.Modal( - "Confirm downgrade", - key=f"downgrade-plan-modal-{plan.key}", - ) - if gui.button( - label, - className=className, - key=key, - ): - downgrade_modal.open() - - if downgrade_modal.is_open(): - with downgrade_modal.container(): - gui.write( - f""" - Are you sure you want to change from: - **{current_plan.title} ({fmt_price(current_plan)})** to **{plan.title} ({fmt_price(plan)})**? - """, - className="d-block py-4", - ) - with gui.div(className="d-flex w-100"): - if gui.button( - "Downgrade", - className="btn btn-theme bg-danger border-danger text-white", - key=f"{key}-confirm", - ): - change_subscription(user, plan) - if gui.button( - "Cancel", - className="border border-danger text-danger", - key=f"{key}-cancel", - ): - downgrade_modal.close() - case _: - if gui.button(label, className=className, key=key): - change_subscription( - user, - plan, - # when upgrading, charge the full new amount today: https://docs.stripe.com/billing/subscriptions/billing-cycle#reset-the-billing-cycle-to-the-current-time - billing_cycle_anchor="now", - ) - - def fmt_price(plan: PricingPlan) -> str: if plan.monthly_charge: return f"${plan.monthly_charge:,}/month" @@ -420,57 +382,66 @@ def render_paypal_addon_buttons(): def render_stripe_addon_buttons(user: AppUser): + if not (user.subscription and user.subscription.payment_provider): + save_pm = gui.checkbox( + "Save payment method for future purchases & auto-recharge", value=True + ) + else: + save_pm = True + for dollat_amt in settings.ADDON_AMOUNT_CHOICES: - render_stripe_addon_button(dollat_amt, user) + render_stripe_addon_button(dollat_amt, user, save_pm) + + error = gui.session_state.pop("--addon-purchase-error", None) + if error: + gui.error(error) + +def render_stripe_addon_button(dollat_amt: int, user: AppUser, save_pm: bool): + modal, confirmed = confirm_modal( + title="Purchase Credits", + key=f"--addon-modal-{dollat_amt}", + text=f""" +Please confirm your purchase: **{dollat_amt * settings.ADDON_CREDITS_PER_DOLLAR:,} credits for ${dollat_amt}**. -def render_stripe_addon_button(dollat_amt: int, user: AppUser): - confirm_purchase_modal = gui.Modal( - "Confirm Purchase", key=f"confirm-purchase-{dollat_amt}" +This is a one-time purchase. Your account will be credited immediately. + """, + button_label="Buy", + text_on_confirm="Processing Payment...", ) + if gui.button(f"${dollat_amt:,}", type="primary"): if user.subscription and user.subscription.stripe_get_default_payment_method(): - confirm_purchase_modal.open() + modal.open() else: - stripe_addon_checkout_redirect(user, dollat_amt) + stripe_addon_checkout_redirect(user, dollat_amt, save_pm) - if not confirm_purchase_modal.is_open(): - return - with confirm_purchase_modal.container(): - gui.write( - f""" - Please confirm your purchase: - **{dollat_amt * settings.ADDON_CREDITS_PER_DOLLAR:,} credits for ${dollat_amt}**. - """, - className="py-4 d-block text-center", + if confirmed: + success = gui.run_in_thread( + user.subscription.stripe_attempt_addon_purchase, + args=[dollat_amt], + placeholder="", ) - with gui.div(className="d-flex w-100 justify-content-end"): - if gui.session_state.get("--confirm-purchase"): - success = gui.run_in_thread( - user.subscription.stripe_attempt_addon_purchase, - args=[dollat_amt], - placeholder="Processing payment...", - ) - if success is None: - return - gui.session_state.pop("--confirm-purchase") - if success: - confirm_purchase_modal.close() - else: - gui.error("Payment failed... Please try again.") - return - - if gui.button("Cancel", className="border border-danger text-danger me-2"): - confirm_purchase_modal.close() - gui.button("Buy", type="primary", key="--confirm-purchase") + if success is None: + return + if not success: + gui.session_state["--addon-purchase-error"] = ( + "Payment failed... Please try again or contact us at support@gooey.ai" + ) + modal.close() -def stripe_addon_checkout_redirect(user: AppUser, dollat_amt: int): +def stripe_addon_checkout_redirect(user: AppUser, dollat_amt: int, save_pm: bool): from routers.account import account_route from routers.account import payment_processing_route line_item = available_subscriptions["addon"]["stripe"].copy() line_item["quantity"] = dollat_amt * settings.ADDON_CREDITS_PER_DOLLAR + kwargs = {} + if save_pm: + kwargs["payment_intent_data"] = {"setup_future_usage": "on_session"} + else: + kwargs["saved_payment_method_options"] = {"payment_method_save": "enabled"} checkout_session = stripe.checkout.Session.create( line_items=[line_item], mode="payment", @@ -479,32 +450,51 @@ def stripe_addon_checkout_redirect(user: AppUser, dollat_amt: int): customer=user.get_or_create_stripe_customer(), invoice_creation={"enabled": True}, allow_promotion_codes=True, - saved_payment_method_options={"payment_method_save": "enabled"}, - payment_intent_data={"setup_future_usage": "off_session"}, + **kwargs, ) raise gui.RedirectException(checkout_session.url, status_code=303) def render_stripe_subscription_button( *, - label: str, user: AppUser, plan: PricingPlan, - btn_type: str, - key: str, ): if not plan.supports_stripe(): gui.write("Stripe subscription not available") return + modal, confirmed = confirm_modal( + title="Upgrade Plan", + key=f"--modal-{plan.key}", + text=f""" +Are you sure you want to subscribe to **{plan.title} ({fmt_price(plan)})**? + +This will charge you the full amount today, and every month thereafter. + +**{plan.credits:,} credits** will be added to your account. + """, + button_label="Buy", + ) + # IMPORTANT: key=... is needed here to maintain uniqueness # of buttons with the same label. otherwise, all buttons # will be the same to the server - if gui.button(label, key=key, type=btn_type): - stripe_subscription_checkout_redirect(user=user, plan=plan) + if gui.button( + "Upgrade", + key=f"--change-sub-{plan.key}", + type="primary", + ): + if user.subscription and user.subscription.stripe_get_default_payment_method(): + modal.open() + else: + stripe_subscription_create(user=user, plan=plan) + + if confirmed: + stripe_subscription_create(user=user, plan=plan) -def stripe_subscription_checkout_redirect(user: AppUser, plan: PricingPlan): +def stripe_subscription_create(user: AppUser, plan: PricingPlan): from routers.account import account_route from routers.account import payment_processing_route @@ -528,17 +518,28 @@ def stripe_subscription_checkout_redirect(user: AppUser, plan: PricingPlan): get_app_route_url(payment_processing_route), status_code=303 ) else: + # check for existing subscriptions + customer = user.get_or_create_stripe_customer() + for sub in stripe.Subscription.list( + customer=customer, status="active", limit=1 + ).data: + StripeWebhookHandler.handle_subscription_updated( + uid=user.uid, stripe_sub=sub + ) + raise gui.RedirectException( + get_app_route_url(payment_processing_route), status_code=303 + ) + checkout_session = stripe.checkout.Session.create( mode="subscription", success_url=get_app_route_url(payment_processing_route), cancel_url=get_app_route_url(account_route), allow_promotion_codes=True, - customer=user.get_or_create_stripe_customer(), + customer=customer, line_items=line_items, metadata=metadata, subscription_data={"metadata": metadata}, saved_payment_method_options={"payment_method_save": "enabled"}, - payment_intent_data={"setup_future_usage": "off_session"}, ) raise gui.RedirectException(checkout_session.url, status_code=303) @@ -586,15 +587,18 @@ def render_payment_information(user: AppUser): raise gui.RedirectException(user.subscription.get_external_management_url()) pm_summary = PaymentMethodSummary(*pm_summary) - if pm_summary.card_brand and pm_summary.card_last4: + if pm_summary.card_brand: col1, col2, col3 = gui.columns(3, responsive=False) with col1: gui.write("**Payment Method**") with col2: - gui.write( - f"{format_card_brand(pm_summary.card_brand)} ending in {pm_summary.card_last4}", - unsafe_allow_html=True, - ) + if pm_summary.card_last4: + gui.write( + f"{format_card_brand(pm_summary.card_brand)} ending in {pm_summary.card_last4}", + unsafe_allow_html=True, + ) + else: + gui.write(pm_summary.card_brand) with col3: if gui.button(f"{icons.edit} Edit", type="link", key="edit-payment-method"): change_payment_method(user) diff --git a/daras_ai_v2/gui_confirm.py b/daras_ai_v2/gui_confirm.py new file mode 100644 index 000000000..dd719d7a5 --- /dev/null +++ b/daras_ai_v2/gui_confirm.py @@ -0,0 +1,42 @@ +import gooey_gui as gui + +from daras_ai_v2.html_spinner_widget import html_spinner + + +def confirm_modal( + *, + title: str, + key: str, + text: str, + button_label: str, + button_class: str = "", + text_on_confirm: str | None = None, +) -> tuple[gui.Modal, bool]: + modal = gui.Modal(title, key=key) + confirmed_key = f"{key}-confirmed" + if modal.is_open(): + with modal.container(): + with gui.div(className="pt-4 pb-3"): + gui.write(text) + with gui.div(className="d-flex w-100 justify-content-end"): + confirmed = bool(gui.session_state.get(confirmed_key, None)) + if confirmed and text_on_confirm: + html_spinner(text_on_confirm) + else: + if gui.button( + "Cancel", + type="tertiary", + className="me-2", + key=f"{key}-cancelled", + ): + modal.close() + confirmed = gui.button( + button_label, + type="primary", + key=confirmed_key, + className=button_class, + ) + return modal, confirmed + else: + gui.session_state.pop(confirmed_key, None) + return modal, False diff --git a/payments/admin.py b/payments/admin.py index 014375af8..9fd8f910f 100644 --- a/payments/admin.py +++ b/payments/admin.py @@ -11,6 +11,7 @@ class SubscriptionAdmin(admin.ModelAdmin): "external_id", ] readonly_fields = [ + "user", "created_at", "updated_at", "get_payment_method_summary", diff --git a/payments/models.py b/payments/models.py index 3d16640d9..5a200ba1e 100644 --- a/payments/models.py +++ b/payments/models.py @@ -135,7 +135,7 @@ def has_user(self) -> bool: return True def is_paid(self) -> bool: - return PricingPlan.from_sub(self).monthly_charge > 0 + return PricingPlan.from_sub(self).monthly_charge > 0 and self.external_id def cancel(self): from payments.webhooks import StripeWebhookHandler @@ -196,11 +196,17 @@ def get_payment_method_summary(self) -> PaymentMethodSummary | None: case PaymentProvider.STRIPE: pm = self.stripe_get_default_payment_method() if not pm: + # clear the payment provider if the default payment method is missing + if self.payment_provider and not self.is_paid(): + self.payment_provider = None + self.save(update_fields=["payment_provider"]) return None return PaymentMethodSummary( payment_method_type=pm.type, - card_brand=pm.card and pm.card.brand, - card_last4=pm.card and pm.card.last4, + card_brand=( + (pm.type == "card" and pm.card and pm.card.brand) or pm.type + ), + card_last4=(pm.type == "card" and pm.card and pm.card.last4) or "", billing_email=(pm.billing_details and pm.billing_details.email), ) diff --git a/tests/test_checkout.py b/tests/test_checkout.py index 21a9a3291..a392c2cbc 100644 --- a/tests/test_checkout.py +++ b/tests/test_checkout.py @@ -3,7 +3,7 @@ from app_users.models import AppUser from daras_ai_v2 import settings -from daras_ai_v2.billing import stripe_subscription_checkout_redirect +from daras_ai_v2.billing import stripe_subscription_create from gooey_gui import RedirectException from payments.plans import PricingPlan from server import app @@ -20,4 +20,4 @@ def test_create_checkout_session( return with pytest.raises(RedirectException): - stripe_subscription_checkout_redirect(force_authentication, plan) + stripe_subscription_create(force_authentication, plan) From 3c0963d2115bc65aec71d2abe10fd87c4b32169c Mon Sep 17 00:00:00 2001 From: Dev Aggarwal Date: Fri, 23 Aug 2024 13:49:01 +0530 Subject: [PATCH 09/10] add cancel subscription button from https://github.com/GooeyAI/gooey-server/pull/442/files --- daras_ai_v2/billing.py | 101 ++++++++++++++++++++++++++++------------- 1 file changed, 70 insertions(+), 31 deletions(-) diff --git a/daras_ai_v2/billing.py b/daras_ai_v2/billing.py index 24adbbd39..25e607415 100644 --- a/daras_ai_v2/billing.py +++ b/daras_ai_v2/billing.py @@ -11,7 +11,7 @@ from daras_ai_v2.user_date_widgets import render_local_date_attrs from payments.models import PaymentMethodSummary from payments.plans import PricingPlan -from payments.webhooks import StripeWebhookHandler +from payments.webhooks import StripeWebhookHandler, set_user_subscription from scripts.migrate_existing_subscriptions import available_subscriptions rounded_border = "w-100 border shadow-sm rounded py-4 px-3" @@ -574,41 +574,80 @@ def render_payment_information(user: AppUser): return gui.write("## Payment Information", id="payment-information", className="d-block") - col1, col2, col3 = gui.columns(3, responsive=False) - with col1: - gui.write("**Pay via**") - with col2: - provider = PaymentProvider( - user.subscription.payment_provider or PaymentProvider.STRIPE - ) - gui.write(provider.label) - with col3: - if gui.button(f"{icons.edit} Edit", type="link", key="manage-payment-provider"): - raise gui.RedirectException(user.subscription.get_external_management_url()) - - pm_summary = PaymentMethodSummary(*pm_summary) - if pm_summary.card_brand: + with gui.div(className="ps-1"): col1, col2, col3 = gui.columns(3, responsive=False) with col1: - gui.write("**Payment Method**") + gui.write("**Pay via**") with col2: - if pm_summary.card_last4: - gui.write( - f"{format_card_brand(pm_summary.card_brand)} ending in {pm_summary.card_last4}", - unsafe_allow_html=True, - ) - else: - gui.write(pm_summary.card_brand) + provider = PaymentProvider( + user.subscription.payment_provider or PaymentProvider.STRIPE + ) + gui.write(provider.label) with col3: - if gui.button(f"{icons.edit} Edit", type="link", key="edit-payment-method"): - change_payment_method(user) + if gui.button( + f"{icons.edit} Edit", type="link", key="manage-payment-provider" + ): + raise gui.RedirectException( + user.subscription.get_external_management_url() + ) - if pm_summary.billing_email: - col1, col2, _ = gui.columns(3, responsive=False) - with col1: - gui.write("**Billing Email**") - with col2: - gui.html(pm_summary.billing_email) + pm_summary = PaymentMethodSummary(*pm_summary) + if pm_summary.card_brand: + col1, col2, col3 = gui.columns(3, responsive=False) + with col1: + gui.write("**Payment Method**") + with col2: + if pm_summary.card_last4: + gui.write( + f"{format_card_brand(pm_summary.card_brand)} ending in {pm_summary.card_last4}", + unsafe_allow_html=True, + ) + else: + gui.write(pm_summary.card_brand) + with col3: + if gui.button( + f"{icons.edit} Edit", type="link", key="edit-payment-method" + ): + change_payment_method(user) + + if pm_summary.billing_email: + col1, col2, _ = gui.columns(3, responsive=False) + with col1: + gui.write("**Billing Email**") + with col2: + gui.html(pm_summary.billing_email) + + from routers.account import payment_processing_route + + modal, confirmed = confirm_modal( + title="Delete Payment Information", + key="--delete-payment-method", + text=""" +Are you sure you want to delete your payment information? + +This will cancel your subscription and remove your saved payment method. + """, + button_label="Delete", + button_class="border-danger bg-danger text-white", + ) + if gui.button( + "Delete & Cancel Subscription", + className="border-danger text-danger", + ): + modal.open() + if confirmed: + set_user_subscription( + uid=user.uid, + plan=PricingPlan.STARTER, + provider=None, + external_id=None, + ) + pm = user.subscription and user.subscription.stripe_get_default_payment_method() + if pm: + pm.detach() + raise gui.RedirectException( + get_app_route_url(payment_processing_route), status_code=303 + ) def change_payment_method(user: AppUser): From 20f0fa9800f378305d9e1bd19d43ad4695ea128d Mon Sep 17 00:00:00 2001 From: Dev Aggarwal Date: Fri, 23 Aug 2024 13:53:43 +0530 Subject: [PATCH 10/10] fix max adjustable_quantity on addon purchases --- scripts/migrate_existing_subscriptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/migrate_existing_subscriptions.py b/scripts/migrate_existing_subscriptions.py index 967b5bf73..ceb7dc224 100644 --- a/scripts/migrate_existing_subscriptions.py +++ b/scripts/migrate_existing_subscriptions.py @@ -29,7 +29,7 @@ # "quantity": 1000, # number of credits (set by html) "adjustable_quantity": { "enabled": True, - "maximum": 50_000, + "maximum": 100_000, "minimum": 1_000, }, },