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: Add ACH webhook flows #1106

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions api/internal/owner/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -296,6 +297,7 @@ class Meta:
"student_count",
"subscription_detail",
"uses_invoice",
"unverified_payment_methods",
)

def _get_billing(self) -> BillingService:
Expand Down Expand Up @@ -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")
Expand Down
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
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
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
Loading
Loading