Skip to content
This repository was archived by the owner on Jun 13, 2025. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all 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
Copy Markdown
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_succeeded(self, invoice: stripe.Invoice) -> None:
)

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(
Expand Down Expand Up @@ -138,6 +159,22 @@ def invoice_payment_failed(self, invoice: stripe.Invoice) -> None:
)

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(
Expand Down Expand Up @@ -253,6 +290,10 @@ def customer_created(self, customer: stripe.Customer) -> None:
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 @@ def customer_subscription_created(self, subscription: stripe.Subscription) -> No
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()

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

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
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,
Expand All @@ -327,6 +402,16 @@ def customer_subscription_updated(self, subscription: stripe.Subscription) -> No
)
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
Expand Down Expand Up @@ -445,6 +530,74 @@ def checkout_session_completed(

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(
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(
Expand Down
Loading