diff --git a/api/internal/owner/serializers.py b/api/internal/owner/serializers.py index 7672f61d64..8314ceed65 100644 --- a/api/internal/owner/serializers.py +++ b/api/internal/owner/serializers.py @@ -271,6 +271,7 @@ class AccountDetailsSerializer(serializers.ModelSerializer): activated_user_count = serializers.SerializerMethodField() delinquent = serializers.SerializerMethodField() uses_invoice = serializers.SerializerMethodField() + unverified_payment_methods = serializers.SerializerMethodField() class Meta: model = Owner @@ -296,6 +297,7 @@ class Meta: "student_count", "subscription_detail", "uses_invoice", + "unverified_payment_methods", ) def _get_billing(self) -> BillingService: @@ -335,6 +337,9 @@ def get_uses_invoice(self, owner: Owner) -> bool: return owner.account.invoice_billing.filter(is_active=True).exists() return owner.uses_invoice + def get_unverified_payment_methods(self, owner: Owner) -> list[Dict[str, Any]]: + return self._get_billing().get_unverified_payment_methods(owner) + def update(self, instance: Owner, validated_data: Dict[str, Any]) -> object: if "pretty_plan" in validated_data: desired_plan = validated_data.pop("pretty_plan") diff --git a/billing/constants.py b/billing/constants.py index 50edf67183..bc2f5b9116 100644 --- a/billing/constants.py +++ b/billing/constants.py @@ -17,6 +17,8 @@ class StripeWebhookEvents: "customer.updated", "invoice.payment_failed", "invoice.payment_succeeded", + "payment_intent.succeeded", + "setup_intent.succeeded", "subscription_schedule.created", "subscription_schedule.released", "subscription_schedule.updated", diff --git a/billing/views.py b/billing/views.py index 8060ee4b4c..540b12ba95 100644 --- a/billing/views.py +++ b/billing/views.py @@ -14,6 +14,7 @@ from billing.helpers import get_all_admins_for_owners from codecov_auth.models import Owner +from services.billing import BillingService from services.task.task import TaskService from .constants import StripeHTTPHeaders, StripeWebhookEvents @@ -83,6 +84,26 @@ def invoice_payment_succeeded(self, invoice: stripe.Invoice) -> None: ) def invoice_payment_failed(self, invoice: stripe.Invoice) -> None: + """ + 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.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 still awaiting known customer action, skipping Delinquency actions", + extra=dict( + stripe_customer_id=invoice.customer, + stripe_subscription_id=invoice.subscription, + payment_intent_status=payment_intent.status, + next_action=payment_intent.next_action, + ), + ) + return + log.info( "Invoice Payment Failed - Setting Delinquency status True", extra=dict( @@ -138,6 +159,22 @@ def invoice_payment_failed(self, invoice: stripe.Invoice) -> None: ) def customer_subscription_deleted(self, subscription: stripe.Subscription) -> None: + """ + 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( @@ -253,6 +290,10 @@ def customer_created(self, customer: stripe.Customer) -> None: log.info("Customer created", extra=dict(stripe_customer_id=customer.id)) def customer_subscription_created(self, subscription: stripe.Subscription) -> None: + """ + 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: @@ -289,11 +330,22 @@ def customer_subscription_created(self, subscription: stripe.Subscription) -> No quantity=subscription.quantity, ), ) + # add the subscription_id and customer_id to the owner owner = Owner.objects.get(ownerid=subscription.metadata.get("obo_organization")) owner.stripe_subscription_id = subscription.id owner.stripe_customer_id = subscription.customer owner.save() + 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() @@ -311,7 +363,30 @@ def customer_subscription_created(self, subscription: stripe.Subscription) -> No self._log_updated([owner]) + 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, @@ -327,6 +402,16 @@ def customer_subscription_updated(self, subscription: stripe.Subscription) -> No ) 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: # payment failed, raise this to user by setting as delinquent @@ -445,6 +530,74 @@ def checkout_session_completed( self._log_updated([owner]) + 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) + + 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 + ) + stripe.Customer.modify( + owner.stripe_customer_id, + invoice_settings={"default_payment_method": payment_method}, + ) + stripe.Subscription.modify( + owner.stripe_subscription_id, default_payment_method=payment_method + ) + + def payment_intent_succeeded(self, payment_intent: stripe.PaymentIntent) -> None: + """ + 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( + stripe_customer_id=payment_intent.customer, + payment_intent_id=payment_intent.id, + payment_method_type=payment_intent.payment_method, + ), + ) + + self._check_and_handle_delayed_notification_payment_methods( + payment_intent.customer, payment_intent.payment_method + ) + + def setup_intent_succeeded(self, setup_intent: stripe.SetupIntent) -> None: + """ + 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( + 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( + setup_intent.customer, setup_intent.payment_method + ) + def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Response: if settings.STRIPE_ENDPOINT_SECRET is None: log.critical( diff --git a/services/billing.py b/services/billing.py index 825cd7ccab..0147b2d387 100644 --- a/services/billing.py +++ b/services/billing.py @@ -534,17 +534,45 @@ def create_checkout_session(self, owner: Owner, desired_plan): "metadata": self._get_checkout_session_and_subscription_metadata(owner), }, tax_id_collection={"enabled": True}, - customer_update={"name": "auto", "address": "auto"} - if owner.stripe_customer_id - else None, + customer_update=( + {"name": "auto", "address": "auto"} + if owner.stripe_customer_id + else None + ), ) log.info( f"Stripe Checkout Session created successfully for owner {owner.ownerid} by user #{self.requesting_user.ownerid}" ) return session["id"] + def _is_unverified_payment_method(self, payment_method_id: str) -> bool: + payment_method = stripe.PaymentMethod.retrieve(payment_method_id) + + 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): + def update_payment_method(self, owner: Owner, payment_method: str) -> None: log.info( "Stripe update payment method for owner", extra=dict( @@ -564,15 +592,21 @@ def update_payment_method(self, owner: Owner, payment_method): ), ) return None - # attach the payment method + set as default on the invoice and subscription - stripe.PaymentMethod.attach(payment_method, customer=owner.stripe_customer_id) - stripe.Customer.modify( - owner.stripe_customer_id, - invoice_settings={"default_payment_method": payment_method}, - ) - stripe.Subscription.modify( - owner.stripe_subscription_id, default_payment_method=payment_method - ) + + # 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) + + if should_set_as_default: + stripe.PaymentMethod.attach( + payment_method, customer=owner.stripe_customer_id + ) + stripe.Customer.modify( + owner.stripe_customer_id, + invoice_settings={"default_payment_method": payment_method}, + ) + stripe.Subscription.modify( + owner.stripe_subscription_id, default_payment_method=payment_method + ) log.info( f"Successfully updated payment method for owner {owner.ownerid} by user #{self.requesting_user.ownerid}", extra=dict( @@ -718,6 +752,51 @@ def create_setup_intent(self, owner: Owner) -> stripe.SetupIntent: customer=owner.stripe_customer_id, ) + def _get_unverified_payment_methods(self, owner): + log.info( + "Getting unverified payment methods", extra=dict(owner_id=owner.ownerid) + ) + if not owner.stripe_customer_id: + return [] + + unverified_payment_methods = [] + + # Check payment intents + payment_intents = stripe.PaymentIntent.list( + customer=owner.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=owner.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 + class EnterprisePaymentService(AbstractPaymentService): # enterprise has no payments setup so these are all noops @@ -758,6 +837,9 @@ def apply_cancellation_discount(self, owner: Owner): def create_setup_intent(self, owner): pass + def get_unverified_payment_methods(self, owner): + return [] + class BillingService: payment_service = None @@ -788,6 +870,9 @@ def get_invoice(self, owner, invoice_id): 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) + def update_plan(self, owner, desired_plan): """ Takes an owner and desired plan, and updates the owner's plan. Depending @@ -802,8 +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 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": + 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( @@ -852,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