Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Handle webhooks for ACH microdeposits lifecycle #1116

Merged
merged 14 commits into from
Feb 4, 2025
2 changes: 2 additions & 0 deletions billing/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ class StripeWebhookEvents:
"customer.updated",
"invoice.payment_failed",
"invoice.payment_succeeded",
"payment_intent.succeeded",
"setup_intent.succeeded",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO - these need to be turned on in Stripe dashboard

"subscription_schedule.created",
"subscription_schedule.released",
"subscription_schedule.updated",
Expand Down
153 changes: 153 additions & 0 deletions billing/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -83,6 +84,26 @@
)

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(

Check warning on line 96 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L96

Added line #L96 was not covered by tests
"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

Check warning on line 105 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L105

Added line #L105 was not covered by tests

log.info(
"Invoice Payment Failed - Setting Delinquency status True",
extra=dict(
Expand Down Expand Up @@ -138,6 +159,22 @@
)

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(

Check warning on line 169 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L169

Added line #L169 was not covered by tests
"Customer Subscription Deleted - Ignoring incomplete subscription",
extra=dict(
stripe_subscription_id=subscription.id,
stripe_customer_id=subscription.customer,
),
)
return

Check warning on line 176 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L176

Added line #L176 was not covered by tests

log.info(
"Customer Subscription Deleted - Setting free plan and deactivating repos for stripe customer",
extra=dict(
Expand Down Expand Up @@ -253,6 +290,10 @@
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:
Expand Down Expand Up @@ -289,11 +330,22 @@
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(

Check warning on line 340 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L340

Added line #L340 was not covered by tests
"Subscription has pending initial payment verification - will upgrade plan after initial invoice payment",
extra=dict(
subscription_id=subscription.id,
customer_id=subscription.customer,
),
)
return

Check warning on line 347 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L347

Added line #L347 was not covered by tests

plan_service = PlanService(current_org=owner)
plan_service.expire_trial_when_upgrading()

Expand All @@ -311,7 +363,30 @@

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(

Check warning on line 375 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L374-L375

Added lines #L374 - L375 were not covered by tests
latest_invoice.payment_intent
)
return (

Check warning on line 378 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L378

Added line #L378 was not covered by tests
payment_intent is not None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not: don't need the is not None

and payment_intent.status == "requires_action"
)
return False

Check warning on line 382 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L382

Added line #L382 was not covered by tests

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,
Expand All @@ -327,6 +402,16 @@
)
return

if self._has_unverified_initial_payment_method(subscription):
log.info(

Check warning on line 406 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L406

Added line #L406 was not covered by tests
"Subscription has pending initial payment verification - will upgrade plan after initial invoice payment",
extra=dict(
subscription_id=subscription.id,
customer_id=subscription.customer,
),
)
return

Check warning on line 413 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L413

Added line #L413 was not covered by tests

indication_of_payment_failure = getattr(subscription, "pending_update", None)
if indication_of_payment_failure:
# payment failed, raise this to user by setting as delinquent
Expand Down Expand Up @@ -445,6 +530,74 @@

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)

Check warning on line 542 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L541-L542

Added lines #L541 - L542 were not covered by tests

is_us_bank_account = payment_method.type == "us_bank_account" and hasattr(

Check warning on line 544 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L544

Added line #L544 was not covered by tests
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jw, why do we need both of these conditions?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought it'd be safer to check both as I haven't read through all scenarios where Stripe may populate one but not the other. When we reach this point in the code we expect both of these to be in this state in order to proceed

payment_method, "us_bank_account"
)

should_set_as_default = is_us_bank_account

Check warning on line 548 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L548

Added line #L548 was not covered by tests

if should_set_as_default:

Check warning on line 550 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L550

Added line #L550 was not covered by tests
# attach the payment method + set as default on the invoice and subscription
stripe.PaymentMethod.attach(

Check warning on line 552 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L552

Added line #L552 was not covered by tests
payment_method, customer=owner.stripe_customer_id
)
stripe.Customer.modify(

Check warning on line 555 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L555

Added line #L555 was not covered by tests
owner.stripe_customer_id,
invoice_settings={"default_payment_method": payment_method},
)
stripe.Subscription.modify(

Check warning on line 559 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L559

Added line #L559 was not covered by tests
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(

Check warning on line 569 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L569

Added line #L569 was not covered by tests
"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(

Check warning on line 578 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L578

Added line #L578 was not covered by tests
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(

Check warning on line 588 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L588

Added line #L588 was not covered by tests
"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(

Check warning on line 597 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L597

Added line #L597 was not covered by tests
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(
Expand Down
112 changes: 99 additions & 13 deletions services/billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,17 +534,45 @@
"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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: could we move this to a separate helpers file

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried it out but ending up leaving it here for now as I was thinking spinning off a helpers file just because this file is unmanageable seems not great imo. Ideally we can split this mega file out instead.. I think creating a new kitchen sink could make it worse so left it here status quo

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah okay fair enough, originally I thought this was the webhooks file but its not. I actually meant to add this comment to the new functions added in views.py

payment_method = stripe.PaymentMethod.retrieve(payment_method_id)

is_us_bank_account = payment_method.type == "us_bank_account" and hasattr(

Check warning on line 551 in services/billing.py

View check run for this annotation

Codecov Notifications / codecov/patch

services/billing.py#L551

Added line #L551 was not covered by tests
payment_method, "us_bank_account"
)
if is_us_bank_account:
setup_intents = stripe.SetupIntent.list(

Check warning on line 555 in services/billing.py

View check run for this annotation

Codecov Notifications / codecov/patch

services/billing.py#L554-L555

Added lines #L554 - L555 were not covered by tests
payment_method=payment_method_id, limit=1
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this default to the most recent?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should only ever have 1 setup intent to 1 paymentMethodId but the only available api to fetch this from stripe is this list with filter, it seemed. I don't think it's possible on their end for it to be more than 1. I'd guess if it is, it'd take the most recent

)
if (

Check warning on line 558 in services/billing.py

View check run for this annotation

Codecov Notifications / codecov/patch

services/billing.py#L558

Added line #L558 was not covered by tests
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd try and see if we can get away with an eager try catch here

Maybe something like

Try

latest_intent = setup_intents.data[0]

if latest_intent.status blah blah
except NotFound:

return False

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 (

Check warning on line 565 in services/billing.py

View check run for this annotation

Codecov Notifications / codecov/patch

services/billing.py#L564-L565

Added lines #L564 - L565 were not covered by tests
latest_intent.status == "requires_action"
and latest_intent.next_action
and latest_intent.next_action.type == "verify_with_microdeposits"
):
return True

Check warning on line 570 in services/billing.py

View check run for this annotation

Codecov Notifications / codecov/patch

services/billing.py#L570

Added line #L570 was not covered by tests

return False

Check warning on line 572 in services/billing.py

View check run for this annotation

Codecov Notifications / codecov/patch

services/billing.py#L572

Added line #L572 was not covered by tests

@_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(
Expand All @@ -564,15 +592,21 @@
),
)
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(

Check warning on line 600 in services/billing.py

View check run for this annotation

Codecov Notifications / codecov/patch

services/billing.py#L599-L600

Added lines #L599 - L600 were not covered by tests
payment_method, customer=owner.stripe_customer_id
)
stripe.Customer.modify(

Check warning on line 603 in services/billing.py

View check run for this annotation

Codecov Notifications / codecov/patch

services/billing.py#L603

Added line #L603 was not covered by tests
owner.stripe_customer_id,
invoice_settings={"default_payment_method": payment_method},
)
stripe.Subscription.modify(

Check warning on line 607 in services/billing.py

View check run for this annotation

Codecov Notifications / codecov/patch

services/billing.py#L607

Added line #L607 was not covered by tests
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(
Expand Down Expand Up @@ -802,8 +836,18 @@
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(

Check warning on line 843 in services/billing.py

View check run for this annotation

Codecov Notifications / codecov/patch

services/billing.py#L842-L843

Added lines #L842 - L843 were not covered by tests
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(
Expand Down Expand Up @@ -852,3 +896,45 @@
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.get("latest_invoice")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar thought here with the Try

try:
payment_intent_id = subscription["latest_invoice"]["payment_intent"]
except NotFound:
return None

if not latest_invoice:
return None
payment_intent_id = latest_invoice.get("payment_intent")
if not payment_intent_id:
return None

Check warning on line 906 in services/billing.py

View check run for this annotation

Codecov Notifications / codecov/patch

services/billing.py#L901-L906

Added lines #L901 - L906 were not covered by tests

payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id)
if payment_intent.status == "requires_action":
log.info(

Check warning on line 910 in services/billing.py

View check run for this annotation

Codecov Notifications / codecov/patch

services/billing.py#L908-L910

Added lines #L908 - L910 were not covered by tests
"Subscription has pending payment verification",
extra=dict(
subscription_id=subscription.id,
payment_intent_id=payment_intent.id,
payment_intent_status=payment_intent.status,
),
)
try:

Check warning on line 918 in services/billing.py

View check run for this annotation

Codecov Notifications / codecov/patch

services/billing.py#L918

Added line #L918 was not covered by tests
# Delete the subscription, which also removes the
# pending payment method and unverified payment intent
stripe.Subscription.delete(subscription.id)
log.info(

Check warning on line 922 in services/billing.py

View check run for this annotation

Codecov Notifications / codecov/patch

services/billing.py#L921-L922

Added lines #L921 - L922 were not covered by tests
"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(

Check warning on line 932 in services/billing.py

View check run for this annotation

Codecov Notifications / codecov/patch

services/billing.py#L929-L932

Added lines #L929 - L932 were not covered by tests
"Failed to delete subscription",
extra=dict(
subscription_id=subscription.id,
payment_intent_id=payment_intent.id,
error=str(e),
),
)
return None

Check warning on line 940 in services/billing.py

View check run for this annotation

Codecov Notifications / codecov/patch

services/billing.py#L940

Added line #L940 was not covered by tests
suejung-sentry marked this conversation as resolved.
Show resolved Hide resolved
Loading