diff --git a/billing/views.py b/billing/views.py index 969d9dfb75..540b12ba95 100644 --- a/billing/views.py +++ b/billing/views.py @@ -37,11 +37,6 @@ def _log_updated(self, updated: List[Owner]) -> None: ) def invoice_payment_succeeded(self, invoice: stripe.Invoice) -> None: - """ - Stripe invoice.payment_succeeded is called when an invoice is paid. This happens - when an initial checkout session is completed (first upgrade from free to paid) or - upon a recurring schedule for the subscription (e.g., monthly or annually) - """ log.info( "Invoice Payment Succeeded - Setting delinquency status False", extra=dict( @@ -90,35 +85,24 @@ def invoice_payment_succeeded(self, invoice: stripe.Invoice) -> None: def invoice_payment_failed(self, invoice: stripe.Invoice) -> None: """ - Stripe invoice.payment_failed is called when an invoice is not paid. This happens - when a recurring schedule for the subscription (e.g., monthly or annually) fails to pay. - Or when the initial checkout session fails to pay. + Stripe invoice.payment_failed webhook event is emitted when an invoice payment fails + (initial or recurring). Note that delayed payment methods (including ACH with + microdeposits) may have a failed initial invoice until the account is verified. """ - if invoice.status == "open": - if invoice.default_payment_method is None: - # check if customer has any pending payment methods - unverified_payment_methods = get_unverified_payment_methods( - self, invoice.customer - ) - if unverified_payment_methods: + if invoice.default_payment_method is None: + if invoice.payment_intent: + payment_intent = stripe.PaymentIntent.retrieve(invoice.payment_intent) + if payment_intent.status == "requires_action": log.info( - "Invoice payment failed but customer has pending payment methods", + "Invoice payment failed but still awaiting known customer action, skipping Delinquency actions", extra=dict( stripe_customer_id=invoice.customer, stripe_subscription_id=invoice.subscription, - pending_payment_methods=len(unverified_payment_methods), + payment_intent_status=payment_intent.status, + next_action=payment_intent.next_action, ), ) return - # reach here because ach is still pending - log.info( - "Invoice payment failed but requires action - skipping delinquency", - extra=dict( - stripe_customer_id=invoice.customer, - stripe_subscription_id=invoice.subscription, - ), - ) - return log.info( "Invoice Payment Failed - Setting Delinquency status True", @@ -176,9 +160,21 @@ def invoice_payment_failed(self, invoice: stripe.Invoice) -> None: def customer_subscription_deleted(self, subscription: stripe.Subscription) -> None: """ - Stripe customer.subscription.deleted is called when a subscription is deleted. - This happens when an org goes from paid to free. + Stripe customer.subscription.deleted webhook event is emitted when a subscription is deleted. + This happens when an org goes from paid to free (see payment_service.delete_subscription) + or when cleaning up an incomplete subscription that never activated (e.g., abandoned async + ACH microdeposits verification). """ + if subscription.status == "incomplete": + log.info( + "Customer Subscription Deleted - Ignoring incomplete subscription", + extra=dict( + stripe_subscription_id=subscription.id, + stripe_customer_id=subscription.customer, + ), + ) + return + log.info( "Customer Subscription Deleted - Setting free plan and deactivating repos for stripe customer", extra=dict( @@ -224,7 +220,6 @@ def subscription_schedule_created( ), ) - # handler for Stripe event subscription_schedule.updated def subscription_schedule_updated( self, schedule: stripe.SubscriptionSchedule ) -> None: @@ -249,7 +244,6 @@ def subscription_schedule_updated( ), ) - # handler for Stripe event subscription_schedule.released def subscription_schedule_released( self, schedule: stripe.SubscriptionSchedule ) -> None: @@ -289,24 +283,17 @@ def subscription_schedule_released( ) def customer_created(self, customer: stripe.Customer) -> None: - """ - Stripe customer.created is called when a customer is created. - This happens when an owner completes a CheckoutSession for the first time. - """ # Based on what stripe doesn't gives us (an ownerid!) # in this event we cannot reliably create a customer, # so we're just logging that we created the event and # relying on customer.subscription.created to handle sub creation log.info("Customer created", extra=dict(stripe_customer_id=customer.id)) - # handler for Stripe event customer.subscription.created def customer_subscription_created(self, subscription: stripe.Subscription) -> None: - log.info( - "Customer subscription created", - extra=dict( - customer_id=subscription["customer"], subscription_id=subscription["id"] - ), - ) + """ + Stripe customer.subscription.created webhook event is emitted when a subscription is created. + This happens when an owner completes a CheckoutSession for a new subscription. + """ sub_item_plan_id = subscription.plan.id if not sub_item_plan_id: @@ -349,24 +336,15 @@ def customer_subscription_created(self, subscription: stripe.Subscription) -> No owner.stripe_customer_id = subscription.customer owner.save() - # check if the subscription has a pending_update attribute, if so, don't upgrade the plan yet - print("subscription what are you", subscription) - # Check if subscription has a default payment method - has_default_payment = subscription.default_payment_method is not None - - # If no default payment, check for any pending verification methods - if not has_default_payment: - payment_methods = get_unverified_payment_methods(subscription.customer) - if payment_methods: - log.info( - "Subscription has pending payment verification", - extra=dict( - subscription_id=subscription.id, - customer_id=subscription.customer, - payment_methods=payment_methods, - ), - ) - return + if self._has_unverified_initial_payment_method(subscription): + log.info( + "Subscription has pending initial payment verification - will upgrade plan after initial invoice payment", + extra=dict( + subscription_id=subscription.id, + customer_id=subscription.customer, + ), + ) + return plan_service = PlanService(current_org=owner) plan_service.expire_trial_when_upgrading() @@ -385,15 +363,30 @@ def customer_subscription_created(self, subscription: stripe.Subscription) -> No self._log_updated([owner]) - # handler for Stripe event customer.subscription.updated - def customer_subscription_updated(self, subscription: stripe.Subscription) -> None: - log.info( - "Customer subscription updated", - extra=dict( - customer_id=subscription["customer"], subscription_id=subscription["id"] - ), - ) + def _has_unverified_initial_payment_method( + self, subscription: stripe.Subscription + ) -> bool: + """ + Helper method to check if a subscription's latest invoice has a payment intent + that requires verification (e.g. ACH microdeposits) + """ + latest_invoice = stripe.Invoice.retrieve(subscription.latest_invoice) + if latest_invoice and latest_invoice.payment_intent: + payment_intent = stripe.PaymentIntent.retrieve( + latest_invoice.payment_intent + ) + return ( + payment_intent is not None + and payment_intent.status == "requires_action" + ) + return False + def customer_subscription_updated(self, subscription: stripe.Subscription) -> None: + """ + Stripe customer.subscription.updated webhook event is emitted when a subscription is updated. + This can happen when an owner updates the subscription's default payment method using our + update_payment_method api + """ owners: QuerySet[Owner] = Owner.objects.filter( stripe_subscription_id=subscription.id, stripe_customer_id=subscription.customer, @@ -409,24 +402,15 @@ def customer_subscription_updated(self, subscription: stripe.Subscription) -> No ) return - # check if the subscription has a pending_update attribute, if so, don't upgrade the plan yet - print("subscription what are you", subscription) - # Check if subscription has a default payment method - has_default_payment = subscription.default_payment_method is not None - - # If no default payment, check for any pending verification methods - if not has_default_payment: - payment_methods = get_unverified_payment_methods(subscription.customer) - if payment_methods: - log.info( - "Subscription has pending payment verification", - extra=dict( - subscription_id=subscription.id, - customer_id=subscription.customer, - payment_methods=payment_methods, - ), - ) - return + if self._has_unverified_initial_payment_method(subscription): + log.info( + "Subscription has pending initial payment verification - will upgrade plan after initial invoice payment", + extra=dict( + subscription_id=subscription.id, + customer_id=subscription.customer, + ), + ) + return indication_of_payment_failure = getattr(subscription, "pending_update", None) if indication_of_payment_failure: @@ -442,6 +426,7 @@ def customer_subscription_updated(self, subscription: stripe.Subscription) -> No ), ) return + # Properly attach the payment method on the customer # This hook will be called after a checkout session completes, # updating the subscription created with it @@ -507,7 +492,6 @@ def customer_subscription_updated(self, subscription: stripe.Subscription) -> No ), ) - # handler for Stripe event customer.updated def customer_updated(self, customer: stripe.Customer) -> None: new_default_payment_method = customer["invoice_settings"][ "default_payment_method" @@ -529,7 +513,6 @@ def customer_updated(self, customer: stripe.Customer) -> None: subscription["id"], default_payment_method=new_default_payment_method ) - # handler for Stripe event checkout.session.completed def checkout_session_completed( self, checkout_session: stripe.checkout.Session ) -> None: @@ -550,12 +533,21 @@ def checkout_session_completed( def _check_and_handle_delayed_notification_payment_methods( self, customer_id: str, payment_method_id: str ): + """ + Helper method to handle payment methods that require delayed verification (like ACH). + When verification succeeds, this attaches the payment method to the customer and sets + it as the default payment method for both the customer and subscription. + """ owner = Owner.objects.get(stripe_customer_id=customer_id) payment_method = stripe.PaymentMethod.retrieve(payment_method_id) - if payment_method.type == "us_bank_account" and hasattr( + is_us_bank_account = payment_method.type == "us_bank_account" and hasattr( payment_method, "us_bank_account" - ): + ) + + should_set_as_default = is_us_bank_account + + if should_set_as_default: # attach the payment method + set as default on the invoice and subscription stripe.PaymentMethod.attach( payment_method, customer=owner.stripe_customer_id @@ -570,13 +562,16 @@ def _check_and_handle_delayed_notification_payment_methods( def payment_intent_succeeded(self, payment_intent: stripe.PaymentIntent) -> None: """ - Stripe payment intent is used for the initial checkout session. - Success is emitted when the payment intent goes to a success state. + Stripe payment_intent.succeeded webhook event is emitted when a + payment intent goes to a success state. + We create a Stripe PaymentIntent for the initial checkout session. """ log.info( "Payment intent succeeded", extra=dict( - payment_method_id=payment_intent.id, + stripe_customer_id=payment_intent.customer, + payment_intent_id=payment_intent.id, + payment_method_type=payment_intent.payment_method, ), ) @@ -586,12 +581,17 @@ def payment_intent_succeeded(self, payment_intent: stripe.PaymentIntent) -> None def setup_intent_succeeded(self, setup_intent: stripe.SetupIntent) -> None: """ - Stripe setup intent is used for subsequent edits to payment methods. - See our createSetupIntent api which is called from the UI Stripe Payment Element + Stripe setup_intent.succeeded webhook event is emitted when a setup intent + goes to a success state. We create a Stripe SetupIntent for the gazebo UI + PaymentElement to modify payment methods. """ log.info( "Setup intent succeeded", - extra=dict(setup_intent_id=setup_intent.id), + extra=dict( + stripe_customer_id=setup_intent.customer, + setup_intent_id=setup_intent.id, + payment_method_type=setup_intent.payment_method, + ), ) self._check_and_handle_delayed_notification_payment_methods( @@ -629,41 +629,3 @@ def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Response: getattr(self, self.event.type.replace(".", "_"))(self.event.data.object) return Response(status=status.HTTP_204_NO_CONTENT) - - -# TODO - move this -def get_unverified_payment_methods(self, stripe_customer_id: str): - - unverified_payment_methods = [] - - # Check payment intents - payment_intents = stripe.PaymentIntent.list(customer=stripe_customer_id, limit=100) - for intent in payment_intents.data: - if ( - hasattr(intent, "next_action") - and intent.next_action - and intent.next_action.type == "verify_with_microdeposits" - ): - unverified_payment_methods.append( - { - "payment_method_id": intent.payment_method, - "hosted_verification_link": intent.next_action.verify_with_microdeposits.hosted_verification_url, - } - ) - - # Check setup intents - setup_intents = stripe.SetupIntent.list(customer=stripe_customer_id, limit=100) - for intent in setup_intents.data: - if ( - hasattr(intent, "next_action") - and intent.next_action - and intent.next_action.type == "verify_with_microdeposits" - ): - unverified_payment_methods.append( - { - "payment_method_id": intent.payment_method, - "hosted_verification_link": intent.next_action.verify_with_microdeposits.hosted_verification_url, - } - ) - - return unverified_payment_methods diff --git a/services/billing.py b/services/billing.py index 093e6a1276..0147b2d387 100644 --- a/services/billing.py +++ b/services/billing.py @@ -545,26 +545,31 @@ def create_checkout_session(self, owner: Owner, desired_plan): ) return session["id"] - def _should_set_as_default_payment_method(self, payment_method_id: str) -> bool: + def _is_unverified_payment_method(self, payment_method_id: str) -> bool: payment_method = stripe.PaymentMethod.retrieve(payment_method_id) - if ( - hasattr(payment_method, "us_bank_account") - and getattr(payment_method.us_bank_account.status_details, "status", None) - != "verified" - ): - return False - return True - def _can_attach_payment_method(self, payment_method_id: str) -> bool: - payment_method = stripe.PaymentMethod.retrieve(payment_method_id) - print("HERE I am", payment_method) - if ( - hasattr(payment_method, "us_bank_account") - and getattr(payment_method.us_bank_account.status_details, "status", None) - != "verified" - ): - return False - return True + is_us_bank_account = payment_method.type == "us_bank_account" and hasattr( + payment_method, "us_bank_account" + ) + if is_us_bank_account: + setup_intents = stripe.SetupIntent.list( + payment_method=payment_method_id, limit=1 + ) + if ( + setup_intents + and hasattr(setup_intents, "data") + and isinstance(setup_intents.data, list) + and len(setup_intents.data) > 0 + ): + latest_intent = setup_intents.data[0] + if ( + latest_intent.status == "requires_action" + and latest_intent.next_action + and latest_intent.next_action.type == "verify_with_microdeposits" + ): + return True + + return False @_log_stripe_error def update_payment_method(self, owner: Owner, payment_method: str) -> None: @@ -588,18 +593,13 @@ def update_payment_method(self, owner: Owner, payment_method: str) -> None: ) return None - # skip setting as default payment method on invoice and subscription if ACH is not verified - - # attach the payment method + set as default on the invoice and subscription + # do not set as default if the new payment method is unverified (e.g., awaiting microdeposits) + should_set_as_default = not self._is_unverified_payment_method(payment_method) - # can only attach to the customer if it's verified (otherwise error - # "PaymentMethods of type us_bank_account must be verified before they can be attached to a customer."") - if self._can_attach_payment_method(payment_method): + if should_set_as_default: stripe.PaymentMethod.attach( payment_method, customer=owner.stripe_customer_id ) - - if self._should_set_as_default_payment_method(payment_method): stripe.Customer.modify( owner.stripe_customer_id, invoice_settings={"default_payment_method": payment_method}, @@ -752,7 +752,7 @@ def create_setup_intent(self, owner: Owner) -> stripe.SetupIntent: customer=owner.stripe_customer_id, ) - def get_unverified_payment_methods(self, owner): + def _get_unverified_payment_methods(self, owner): log.info( "Getting unverified payment methods", extra=dict(owner_id=owner.ownerid) ) @@ -871,7 +871,7 @@ def list_filtered_invoices(self, owner, limit=10): return self.payment_service.list_filtered_invoices(owner, limit) def get_unverified_payment_methods(self, owner): - return self.payment_service.get_unverified_payment_methods(owner) + return self.payment_service._get_unverified_payment_methods(owner) def update_plan(self, owner, desired_plan): """ @@ -887,56 +887,18 @@ def update_plan(self, owner, desired_plan): plan_service.set_default_plan_data() elif desired_plan["value"] in PAID_PLANS: if owner.stripe_subscription_id is not None: - # If there's already a pending subscription, return its checkout session + # if the existing subscription is incomplete, clean it up and create a new checkout session subscription = self.payment_service.get_subscription(owner) if subscription and subscription.status == "incomplete": - # Get the latest invoice and payment intent for this subscription - latest_invoice = subscription.latest_invoice - if latest_invoice and latest_invoice.payment_intent: - payment_intent = stripe.PaymentIntent.retrieve( - latest_invoice.payment_intent - ) - # Check if payment intent requires verification - if payment_intent.status == "requires_action": - log.info( - "Subscription has pending payment verification", - extra=dict( - subscription_id=subscription.id, - payment_intent_id=payment_intent.id, - payment_intent_status=payment_intent.status, - ), - ) - - try: - # Note that when the subscription is deleted, - # the pending payment method / unverified payment intent are deleted - stripe.Subscription.delete(subscription.id) - log.info( - "Deleted incomplete subscription", - extra=dict( - subscription_id=subscription.id, - payment_intent_id=payment_intent.id, - ), - ) - owner.stripe_subscription_id = None - owner.save() - except Exception as e: - log.error( - "Failed to delete subscription", - extra=dict( - subscription_id=subscription.id, - payment_intent_id=payment_intent.id, - error=str(e), - ), - ) - return None - - return self.payment_service.create_checkout_session( - owner, desired_plan - ) + self._cleanup_incomplete_subscription(subscription, owner) + return self.payment_service.create_checkout_session( + owner, desired_plan + ) + # if the existing subscription is complete, modify the plan self.payment_service.modify_subscription(owner, desired_plan) else: + # if the owner has no subscription, create a new checkout session return self.payment_service.create_checkout_session(owner, desired_plan) else: log.warning( @@ -985,3 +947,42 @@ def create_setup_intent(self, owner: Owner): See https://docs.stripe.com/api/setup_intents/create """ return self.payment_service.create_setup_intent(owner) + + def _cleanup_incomplete_subscription(self, subscription, owner): + latest_invoice = subscription.latest_invoice + if not latest_invoice or not latest_invoice.payment_intent: + return None + + payment_intent = stripe.PaymentIntent.retrieve(latest_invoice.payment_intent) + if payment_intent.status == "requires_action": + log.info( + "Subscription has pending payment verification", + extra=dict( + subscription_id=subscription.id, + payment_intent_id=payment_intent.id, + payment_intent_status=payment_intent.status, + ), + ) + try: + # Delete the subscription, which also removes the + # pending payment method and unverified payment intent + stripe.Subscription.delete(subscription.id) + log.info( + "Deleted incomplete subscription", + extra=dict( + subscription_id=subscription.id, + payment_intent_id=payment_intent.id, + ), + ) + owner.stripe_subscription_id = None + owner.save() + except Exception as e: + log.error( + "Failed to delete subscription", + extra=dict( + subscription_id=subscription.id, + payment_intent_id=payment_intent.id, + error=str(e), + ), + ) + return None