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

Commit e1389b3

Browse files
wip
1 parent b66b68e commit e1389b3

File tree

4 files changed

+240
-22
lines changed

4 files changed

+240
-22
lines changed

api/internal/owner/serializers.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@ class AccountDetailsSerializer(serializers.ModelSerializer):
271271
activated_user_count = serializers.SerializerMethodField()
272272
delinquent = serializers.SerializerMethodField()
273273
uses_invoice = serializers.SerializerMethodField()
274+
unverified_payment_methods = serializers.SerializerMethodField()
274275

275276
class Meta:
276277
model = Owner
@@ -296,6 +297,7 @@ class Meta:
296297
"student_count",
297298
"subscription_detail",
298299
"uses_invoice",
300+
"unverified_payment_methods",
299301
)
300302

301303
def _get_billing(self) -> BillingService:
@@ -335,6 +337,9 @@ def get_uses_invoice(self, owner: Owner) -> bool:
335337
return owner.account.invoice_billing.filter(is_active=True).exists()
336338
return owner.uses_invoice
337339

340+
def get_unverified_payment_methods(self, owner: Owner) -> list[Dict[str, Any]]:
341+
return self._get_billing().get_unverified_payment_methods(owner)
342+
338343
def update(self, instance: Owner, validated_data: Dict[str, Any]) -> object:
339344
if "pretty_plan" in validated_data:
340345
desired_plan = validated_data.pop("pretty_plan")

billing/constants.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,11 @@ 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",
23-
"setup_intent.succeeded",
24-
"setup_intent.payment_method_attached",
25-
"setup_intent.payment_method_automatically_updated",
26-
"setup_intent.payment_method_changed",
27-
"setup_intent.payment_method_expired",
28-
"setup_intent.payment_method_removed",
29-
"setup_intent.setup_future_usage_updated",
30-
"setup_intent.setup_future_usage_expired",
31-
"setup_intent.setup_future_usage_automatically_updated",
3225
)
3326

3427

billing/views.py

Lines changed: 136 additions & 1 deletion
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
@@ -36,6 +37,11 @@ def _log_updated(self, updated: List[Owner]) -> None:
3637
)
3738

3839
def invoice_payment_succeeded(self, invoice: stripe.Invoice) -> None:
40+
"""
41+
Stripe invoice.payment_succeeded is called when an invoice is paid. This happens
42+
when an initial checkout session is completed (first upgrade from free to paid) or
43+
upon a recurring schedule for the subscription (e.g., monthly or annually)
44+
"""
3945
log.info(
4046
"Invoice Payment Succeeded - Setting delinquency status False",
4147
extra=dict(
@@ -82,7 +88,39 @@ def invoice_payment_succeeded(self, invoice: stripe.Invoice) -> None:
8288
**template_vars,
8389
)
8490

91+
# handler for Stripe event invoice.payment_failed
8592
def invoice_payment_failed(self, invoice: stripe.Invoice) -> None:
93+
"""
94+
Stripe invoice.payment_failed is called when an invoice is not paid. This happens
95+
when a recurring schedule for the subscription (e.g., monthly or annually) fails to pay.
96+
Or when the initial checkout session fails to pay.
97+
"""
98+
if invoice.status == "open":
99+
if invoice.default_payment_method is None:
100+
# check if customer has any pending payment methods
101+
unverified_payment_methods = get_unverified_payment_methods(
102+
self, invoice.customer
103+
)
104+
if unverified_payment_methods:
105+
log.info(
106+
"Invoice payment failed but customer has pending payment methods",
107+
extra=dict(
108+
stripe_customer_id=invoice.customer,
109+
stripe_subscription_id=invoice.subscription,
110+
pending_payment_methods=len(unverified_payment_methods),
111+
),
112+
)
113+
return
114+
# reach here because ach is still pending
115+
log.info(
116+
"Invoice payment failed but requires action - skipping delinquency",
117+
extra=dict(
118+
stripe_customer_id=invoice.customer,
119+
stripe_subscription_id=invoice.subscription,
120+
),
121+
)
122+
return
123+
86124
log.info(
87125
"Invoice Payment Failed - Setting Delinquency status True",
88126
extra=dict(
@@ -137,6 +175,7 @@ def invoice_payment_failed(self, invoice: stripe.Invoice) -> None:
137175
**template_vars,
138176
)
139177

178+
# handler for Stripe event customer.subscription.deleted
140179
def customer_subscription_deleted(self, subscription: stripe.Subscription) -> None:
141180
log.info(
142181
"Customer Subscription Deleted - Setting free plan and deactivating repos for stripe customer",
@@ -183,6 +222,7 @@ def subscription_schedule_created(
183222
),
184223
)
185224

225+
# handler for Stripe event subscription_schedule.updated
186226
def subscription_schedule_updated(
187227
self, schedule: stripe.SubscriptionSchedule
188228
) -> None:
@@ -207,6 +247,7 @@ def subscription_schedule_updated(
207247
),
208248
)
209249

250+
# handler for Stripe event subscription_schedule.released
210251
def subscription_schedule_released(
211252
self, schedule: stripe.SubscriptionSchedule
212253
) -> None:
@@ -245,13 +286,15 @@ def subscription_schedule_released(
245286
),
246287
)
247288

289+
# handler for Stripe event customer.created
248290
def customer_created(self, customer: stripe.Customer) -> None:
249291
# Based on what stripe doesn't gives us (an ownerid!)
250292
# in this event we cannot reliably create a customer,
251293
# so we're just logging that we created the event and
252294
# relying on customer.subscription.created to handle sub creation
253295
log.info("Customer created", extra=dict(stripe_customer_id=customer.id))
254296

297+
# handler for Stripe event customer.subscription.created
255298
def customer_subscription_created(self, subscription: stripe.Subscription) -> None:
256299
sub_item_plan_id = subscription.plan.id
257300

@@ -311,6 +354,7 @@ def customer_subscription_created(self, subscription: stripe.Subscription) -> No
311354

312355
self._log_updated([owner])
313356

357+
# handler for Stripe event customer.subscription.updated
314358
def customer_subscription_updated(self, subscription: stripe.Subscription) -> None:
315359
owners: QuerySet[Owner] = Owner.objects.filter(
316360
stripe_subscription_id=subscription.id,
@@ -341,7 +385,6 @@ def customer_subscription_updated(self, subscription: stripe.Subscription) -> No
341385
),
342386
)
343387
return
344-
345388
# Properly attach the payment method on the customer
346389
# This hook will be called after a checkout session completes,
347390
# updating the subscription created with it
@@ -407,6 +450,7 @@ def customer_subscription_updated(self, subscription: stripe.Subscription) -> No
407450
),
408451
)
409452

453+
# handler for Stripe event customer.updated
410454
def customer_updated(self, customer: stripe.Customer) -> None:
411455
new_default_payment_method = customer["invoice_settings"][
412456
"default_payment_method"
@@ -428,6 +472,7 @@ def customer_updated(self, customer: stripe.Customer) -> None:
428472
subscription["id"], default_payment_method=new_default_payment_method
429473
)
430474

475+
# handler for Stripe event checkout.session.completed
431476
def checkout_session_completed(
432477
self, checkout_session: stripe.checkout.Session
433478
) -> None:
@@ -445,6 +490,58 @@ def checkout_session_completed(
445490

446491
self._log_updated([owner])
447492

493+
def _check_and_handle_delayed_notification_payment_methods(
494+
self, customer_id: str, payment_method_id: str
495+
):
496+
owner = Owner.objects.get(stripe_customer_id=customer_id)
497+
payment_method = stripe.PaymentMethod.retrieve(payment_method_id)
498+
499+
if payment_method.type == "us_bank_account" and hasattr(
500+
payment_method, "us_bank_account"
501+
):
502+
# attach the payment method + set as default on the invoice and subscription
503+
stripe.PaymentMethod.attach(
504+
payment_method, customer=owner.stripe_customer_id
505+
)
506+
stripe.Customer.modify(
507+
owner.stripe_customer_id,
508+
invoice_settings={"default_payment_method": payment_method},
509+
)
510+
stripe.Subscription.modify(
511+
owner.stripe_subscription_id, default_payment_method=payment_method
512+
)
513+
514+
# handler for Stripe event payment_intent.succeeded
515+
def payment_intent_succeeded(self, payment_intent: stripe.PaymentIntent) -> None:
516+
"""
517+
Stripe payment intent is used for the initial checkout session
518+
"""
519+
log.info(
520+
"Payment intent succeeded",
521+
extra=dict(
522+
payment_method_id=payment_intent.id,
523+
),
524+
)
525+
526+
self._check_and_handle_delayed_notification_payment_methods(
527+
payment_intent.customer, payment_intent.payment_method
528+
)
529+
530+
# handler for Stripe event setup_intent.succeeded
531+
def setup_intent_succeeded(self, setup_intent: stripe.SetupIntent) -> None:
532+
"""
533+
Stripe setup intent is used for subsequent edits to payment methods.
534+
See our createSetupIntent api which is called from the UI Stripe Payment Element
535+
"""
536+
log.info(
537+
"Setup intent succeeded",
538+
extra=dict(setup_intent_id=setup_intent.id),
539+
)
540+
541+
self._check_and_handle_delayed_notification_payment_methods(
542+
setup_intent.customer, setup_intent.payment_method
543+
)
544+
448545
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Response:
449546
if settings.STRIPE_ENDPOINT_SECRET is None:
450547
log.critical(
@@ -476,3 +573,41 @@ def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Response:
476573
getattr(self, self.event.type.replace(".", "_"))(self.event.data.object)
477574

478575
return Response(status=status.HTTP_204_NO_CONTENT)
576+
577+
578+
# TODO - move this
579+
def get_unverified_payment_methods(self, stripe_customer_id: str):
580+
581+
unverified_payment_methods = []
582+
583+
# Check payment intents
584+
payment_intents = stripe.PaymentIntent.list(customer=stripe_customer_id, limit=100)
585+
for intent in payment_intents.data:
586+
if (
587+
hasattr(intent, "next_action")
588+
and intent.next_action
589+
and intent.next_action.type == "verify_with_microdeposits"
590+
):
591+
unverified_payment_methods.append(
592+
{
593+
"payment_method_id": intent.payment_method,
594+
"hosted_verification_link": intent.next_action.verify_with_microdeposits.hosted_verification_url,
595+
}
596+
)
597+
598+
# Check setup intents
599+
setup_intents = stripe.SetupIntent.list(customer=stripe_customer_id, limit=100)
600+
for intent in setup_intents.data:
601+
if (
602+
hasattr(intent, "next_action")
603+
and intent.next_action
604+
and intent.next_action.type == "verify_with_microdeposits"
605+
):
606+
unverified_payment_methods.append(
607+
{
608+
"payment_method_id": intent.payment_method,
609+
"hosted_verification_link": intent.next_action.verify_with_microdeposits.hosted_verification_url,
610+
}
611+
)
612+
613+
return unverified_payment_methods

0 commit comments

Comments
 (0)