Skip to content
This repository was archived by the owner on Jun 13, 2025. It is now read-only.

Commit 68fc2b1

Browse files
cleanup
1 parent ae83c2f commit 68fc2b1

File tree

3 files changed

+254
-13
lines changed

3 files changed

+254
-13
lines changed

billing/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ class StripeWebhookEvents:
1717
"customer.updated",
1818
"invoice.payment_failed",
1919
"invoice.payment_succeeded",
20+
"payment_intent.succeeded",
21+
"setup_intent.succeeded",
2022
"subscription_schedule.created",
2123
"subscription_schedule.released",
2224
"subscription_schedule.updated",

billing/views.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from billing.helpers import get_all_admins_for_owners
1616
from codecov_auth.models import Owner
17+
from services.billing import BillingService
1718
from services.task.task import TaskService
1819

1920
from .constants import StripeHTTPHeaders, StripeWebhookEvents
@@ -83,6 +84,26 @@ def invoice_payment_succeeded(self, invoice: stripe.Invoice) -> None:
8384
)
8485

8586
def invoice_payment_failed(self, invoice: stripe.Invoice) -> None:
87+
"""
88+
Stripe invoice.payment_failed webhook event is emitted when an invoice payment fails
89+
(initial or recurring). Note that delayed payment methods (including ACH with
90+
microdeposits) may have a failed initial invoice until the account is verified.
91+
"""
92+
if invoice.default_payment_method is None:
93+
if invoice.payment_intent:
94+
payment_intent = stripe.PaymentIntent.retrieve(invoice.payment_intent)
95+
if payment_intent.status == "requires_action":
96+
log.info(
97+
"Invoice payment failed but still awaiting known customer action, skipping Delinquency actions",
98+
extra=dict(
99+
stripe_customer_id=invoice.customer,
100+
stripe_subscription_id=invoice.subscription,
101+
payment_intent_status=payment_intent.status,
102+
next_action=payment_intent.next_action,
103+
),
104+
)
105+
return
106+
86107
log.info(
87108
"Invoice Payment Failed - Setting Delinquency status True",
88109
extra=dict(
@@ -138,6 +159,22 @@ def invoice_payment_failed(self, invoice: stripe.Invoice) -> None:
138159
)
139160

140161
def customer_subscription_deleted(self, subscription: stripe.Subscription) -> None:
162+
"""
163+
Stripe customer.subscription.deleted webhook event is emitted when a subscription is deleted.
164+
This happens when an org goes from paid to free (see payment_service.delete_subscription)
165+
or when cleaning up an incomplete subscription that never activated (e.g., abandoned async
166+
ACH microdeposits verification).
167+
"""
168+
if subscription.status == "incomplete":
169+
log.info(
170+
"Customer Subscription Deleted - Ignoring incomplete subscription",
171+
extra=dict(
172+
stripe_subscription_id=subscription.id,
173+
stripe_customer_id=subscription.customer,
174+
),
175+
)
176+
return
177+
141178
log.info(
142179
"Customer Subscription Deleted - Setting free plan and deactivating repos for stripe customer",
143180
extra=dict(
@@ -253,6 +290,10 @@ def customer_created(self, customer: stripe.Customer) -> None:
253290
log.info("Customer created", extra=dict(stripe_customer_id=customer.id))
254291

255292
def customer_subscription_created(self, subscription: stripe.Subscription) -> None:
293+
"""
294+
Stripe customer.subscription.created webhook event is emitted when a subscription is created.
295+
This happens when an owner completes a CheckoutSession for a new subscription.
296+
"""
256297
sub_item_plan_id = subscription.plan.id
257298

258299
if not sub_item_plan_id:
@@ -289,11 +330,22 @@ def customer_subscription_created(self, subscription: stripe.Subscription) -> No
289330
quantity=subscription.quantity,
290331
),
291332
)
333+
# add the subscription_id and customer_id to the owner
292334
owner = Owner.objects.get(ownerid=subscription.metadata.get("obo_organization"))
293335
owner.stripe_subscription_id = subscription.id
294336
owner.stripe_customer_id = subscription.customer
295337
owner.save()
296338

339+
if self._has_unverified_initial_payment_method(subscription):
340+
log.info(
341+
"Subscription has pending initial payment verification - will upgrade plan after initial invoice payment",
342+
extra=dict(
343+
subscription_id=subscription.id,
344+
customer_id=subscription.customer,
345+
),
346+
)
347+
return
348+
297349
plan_service = PlanService(current_org=owner)
298350
plan_service.expire_trial_when_upgrading()
299351

@@ -311,7 +363,30 @@ def customer_subscription_created(self, subscription: stripe.Subscription) -> No
311363

312364
self._log_updated([owner])
313365

366+
def _has_unverified_initial_payment_method(
367+
self, subscription: stripe.Subscription
368+
) -> bool:
369+
"""
370+
Helper method to check if a subscription's latest invoice has a payment intent
371+
that requires verification (e.g. ACH microdeposits)
372+
"""
373+
latest_invoice = stripe.Invoice.retrieve(subscription.latest_invoice)
374+
if latest_invoice and latest_invoice.payment_intent:
375+
payment_intent = stripe.PaymentIntent.retrieve(
376+
latest_invoice.payment_intent
377+
)
378+
return (
379+
payment_intent is not None
380+
and payment_intent.status == "requires_action"
381+
)
382+
return False
383+
314384
def customer_subscription_updated(self, subscription: stripe.Subscription) -> None:
385+
"""
386+
Stripe customer.subscription.updated webhook event is emitted when a subscription is updated.
387+
This can happen when an owner updates the subscription's default payment method using our
388+
update_payment_method api
389+
"""
315390
owners: QuerySet[Owner] = Owner.objects.filter(
316391
stripe_subscription_id=subscription.id,
317392
stripe_customer_id=subscription.customer,
@@ -327,6 +402,16 @@ def customer_subscription_updated(self, subscription: stripe.Subscription) -> No
327402
)
328403
return
329404

405+
if self._has_unverified_initial_payment_method(subscription):
406+
log.info(
407+
"Subscription has pending initial payment verification - will upgrade plan after initial invoice payment",
408+
extra=dict(
409+
subscription_id=subscription.id,
410+
customer_id=subscription.customer,
411+
),
412+
)
413+
return
414+
330415
indication_of_payment_failure = getattr(subscription, "pending_update", None)
331416
if indication_of_payment_failure:
332417
# payment failed, raise this to user by setting as delinquent
@@ -445,6 +530,74 @@ def checkout_session_completed(
445530

446531
self._log_updated([owner])
447532

533+
def _check_and_handle_delayed_notification_payment_methods(
534+
self, customer_id: str, payment_method_id: str
535+
):
536+
"""
537+
Helper method to handle payment methods that require delayed verification (like ACH).
538+
When verification succeeds, this attaches the payment method to the customer and sets
539+
it as the default payment method for both the customer and subscription.
540+
"""
541+
owner = Owner.objects.get(stripe_customer_id=customer_id)
542+
payment_method = stripe.PaymentMethod.retrieve(payment_method_id)
543+
544+
is_us_bank_account = payment_method.type == "us_bank_account" and hasattr(
545+
payment_method, "us_bank_account"
546+
)
547+
548+
should_set_as_default = is_us_bank_account
549+
550+
if should_set_as_default:
551+
# attach the payment method + set as default on the invoice and subscription
552+
stripe.PaymentMethod.attach(
553+
payment_method, customer=owner.stripe_customer_id
554+
)
555+
stripe.Customer.modify(
556+
owner.stripe_customer_id,
557+
invoice_settings={"default_payment_method": payment_method},
558+
)
559+
stripe.Subscription.modify(
560+
owner.stripe_subscription_id, default_payment_method=payment_method
561+
)
562+
563+
def payment_intent_succeeded(self, payment_intent: stripe.PaymentIntent) -> None:
564+
"""
565+
Stripe payment_intent.succeeded webhook event is emitted when a
566+
payment intent goes to a success state.
567+
We create a Stripe PaymentIntent for the initial checkout session.
568+
"""
569+
log.info(
570+
"Payment intent succeeded",
571+
extra=dict(
572+
stripe_customer_id=payment_intent.customer,
573+
payment_intent_id=payment_intent.id,
574+
payment_method_type=payment_intent.payment_method,
575+
),
576+
)
577+
578+
self._check_and_handle_delayed_notification_payment_methods(
579+
payment_intent.customer, payment_intent.payment_method
580+
)
581+
582+
def setup_intent_succeeded(self, setup_intent: stripe.SetupIntent) -> None:
583+
"""
584+
Stripe setup_intent.succeeded webhook event is emitted when a setup intent
585+
goes to a success state. We create a Stripe SetupIntent for the gazebo UI
586+
PaymentElement to modify payment methods.
587+
"""
588+
log.info(
589+
"Setup intent succeeded",
590+
extra=dict(
591+
stripe_customer_id=setup_intent.customer,
592+
setup_intent_id=setup_intent.id,
593+
payment_method_type=setup_intent.payment_method,
594+
),
595+
)
596+
597+
self._check_and_handle_delayed_notification_payment_methods(
598+
setup_intent.customer, setup_intent.payment_method
599+
)
600+
448601
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Response:
449602
if settings.STRIPE_ENDPOINT_SECRET is None:
450603
log.critical(

services/billing.py

Lines changed: 99 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -534,17 +534,45 @@ def create_checkout_session(self, owner: Owner, desired_plan):
534534
"metadata": self._get_checkout_session_and_subscription_metadata(owner),
535535
},
536536
tax_id_collection={"enabled": True},
537-
customer_update={"name": "auto", "address": "auto"}
538-
if owner.stripe_customer_id
539-
else None,
537+
customer_update=(
538+
{"name": "auto", "address": "auto"}
539+
if owner.stripe_customer_id
540+
else None
541+
),
540542
)
541543
log.info(
542544
f"Stripe Checkout Session created successfully for owner {owner.ownerid} by user #{self.requesting_user.ownerid}"
543545
)
544546
return session["id"]
545547

548+
def _is_unverified_payment_method(self, payment_method_id: str) -> bool:
549+
payment_method = stripe.PaymentMethod.retrieve(payment_method_id)
550+
551+
is_us_bank_account = payment_method.type == "us_bank_account" and hasattr(
552+
payment_method, "us_bank_account"
553+
)
554+
if is_us_bank_account:
555+
setup_intents = stripe.SetupIntent.list(
556+
payment_method=payment_method_id, limit=1
557+
)
558+
if (
559+
setup_intents
560+
and hasattr(setup_intents, "data")
561+
and isinstance(setup_intents.data, list)
562+
and len(setup_intents.data) > 0
563+
):
564+
latest_intent = setup_intents.data[0]
565+
if (
566+
latest_intent.status == "requires_action"
567+
and latest_intent.next_action
568+
and latest_intent.next_action.type == "verify_with_microdeposits"
569+
):
570+
return True
571+
572+
return False
573+
546574
@_log_stripe_error
547-
def update_payment_method(self, owner: Owner, payment_method):
575+
def update_payment_method(self, owner: Owner, payment_method: str) -> None:
548576
log.info(
549577
"Stripe update payment method for owner",
550578
extra=dict(
@@ -564,15 +592,21 @@ def update_payment_method(self, owner: Owner, payment_method):
564592
),
565593
)
566594
return None
567-
# attach the payment method + set as default on the invoice and subscription
568-
stripe.PaymentMethod.attach(payment_method, customer=owner.stripe_customer_id)
569-
stripe.Customer.modify(
570-
owner.stripe_customer_id,
571-
invoice_settings={"default_payment_method": payment_method},
572-
)
573-
stripe.Subscription.modify(
574-
owner.stripe_subscription_id, default_payment_method=payment_method
575-
)
595+
596+
# do not set as default if the new payment method is unverified (e.g., awaiting microdeposits)
597+
should_set_as_default = not self._is_unverified_payment_method(payment_method)
598+
599+
if should_set_as_default:
600+
stripe.PaymentMethod.attach(
601+
payment_method, customer=owner.stripe_customer_id
602+
)
603+
stripe.Customer.modify(
604+
owner.stripe_customer_id,
605+
invoice_settings={"default_payment_method": payment_method},
606+
)
607+
stripe.Subscription.modify(
608+
owner.stripe_subscription_id, default_payment_method=payment_method
609+
)
576610
log.info(
577611
f"Successfully updated payment method for owner {owner.ownerid} by user #{self.requesting_user.ownerid}",
578612
extra=dict(
@@ -802,8 +836,18 @@ def update_plan(self, owner, desired_plan):
802836
plan_service.set_default_plan_data()
803837
elif desired_plan["value"] in PAID_PLANS:
804838
if owner.stripe_subscription_id is not None:
839+
# if the existing subscription is incomplete, clean it up and create a new checkout session
840+
subscription = self.payment_service.get_subscription(owner)
841+
if subscription and subscription.status == "incomplete":
842+
self._cleanup_incomplete_subscription(subscription, owner)
843+
return self.payment_service.create_checkout_session(
844+
owner, desired_plan
845+
)
846+
847+
# if the existing subscription is complete, modify the plan
805848
self.payment_service.modify_subscription(owner, desired_plan)
806849
else:
850+
# if the owner has no subscription, create a new checkout session
807851
return self.payment_service.create_checkout_session(owner, desired_plan)
808852
else:
809853
log.warning(
@@ -852,3 +896,45 @@ def create_setup_intent(self, owner: Owner):
852896
See https://docs.stripe.com/api/setup_intents/create
853897
"""
854898
return self.payment_service.create_setup_intent(owner)
899+
900+
def _cleanup_incomplete_subscription(self, subscription, owner):
901+
latest_invoice = subscription.get("latest_invoice")
902+
if not latest_invoice:
903+
return None
904+
payment_intent_id = latest_invoice.get("payment_intent")
905+
if not payment_intent_id:
906+
return None
907+
908+
payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id)
909+
if payment_intent.status == "requires_action":
910+
log.info(
911+
"Subscription has pending payment verification",
912+
extra=dict(
913+
subscription_id=subscription.id,
914+
payment_intent_id=payment_intent.id,
915+
payment_intent_status=payment_intent.status,
916+
),
917+
)
918+
try:
919+
# Delete the subscription, which also removes the
920+
# pending payment method and unverified payment intent
921+
stripe.Subscription.delete(subscription.id)
922+
log.info(
923+
"Deleted incomplete subscription",
924+
extra=dict(
925+
subscription_id=subscription.id,
926+
payment_intent_id=payment_intent.id,
927+
),
928+
)
929+
owner.stripe_subscription_id = None
930+
owner.save()
931+
except Exception as e:
932+
log.error(
933+
"Failed to delete subscription",
934+
extra=dict(
935+
subscription_id=subscription.id,
936+
payment_intent_id=payment_intent.id,
937+
error=str(e),
938+
),
939+
)
940+
return None

0 commit comments

Comments
 (0)