-
Notifications
You must be signed in to change notification settings - Fork 28
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
Changes from 1 commit
68fc2b1
ec35658
310c1f7
51b6ef4
262e1c3
4327e66
ce5a4cf
667cfba
27bcf9b
33054ff
0cf1a48
66d8e3a
a69d0aa
3809147
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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_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 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 @@ | |
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 @@ | |
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 @@ | |
|
||
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not: don't need the |
||
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 @@ | |
) | ||
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 @@ | |
|
||
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( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. jw, why do we need both of these conditions? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
||
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( | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: could we move this to a separate helpers file There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||
payment_method, "us_bank_account" | ||
) | ||
if is_us_bank_account: | ||
setup_intents = stripe.SetupIntent.list( | ||
payment_method=payment_method_id, limit=1 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. does this default to the most recent? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 ( | ||
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 @@ | |
), | ||
) | ||
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( | ||
|
@@ -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( | ||
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 +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") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Similar thought here with the Try try: |
||
if not latest_invoice: | ||
return None | ||
payment_intent_id = latest_invoice.get("payment_intent") | ||
if not payment_intent_id: | ||
return None | ||
|
||
payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id) | ||
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 | ||
suejung-sentry marked this conversation as resolved.
Show resolved
Hide resolved
|
There was a problem hiding this comment.
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