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 3 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
189 changes: 188 additions & 1 deletion 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 All @@ -36,6 +37,11 @@
)

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

# handler for Stripe event invoice.payment_failed
def invoice_payment_failed(self, invoice: stripe.Invoice) -> None:
"""
Stripe invoice.payment_failed is called when an invoice is not paid. This happens
when a recurring schedule for the subscription (e.g., monthly or annually) fails to pay.
Or when the initial checkout session fails to pay.
"""
if invoice.status == "open":
if invoice.default_payment_method is None:

Check warning on line 99 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L99

Added line #L99 was not covered by tests
# check if customer has any pending payment methods
unverified_payment_methods = get_unverified_payment_methods(

Check warning on line 101 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L101

Added line #L101 was not covered by tests
self, invoice.customer
)
if unverified_payment_methods:
log.info(

Check warning on line 105 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L104-L105

Added lines #L104 - L105 were not covered by tests
"Invoice payment failed but customer has pending payment methods",
extra=dict(
stripe_customer_id=invoice.customer,
stripe_subscription_id=invoice.subscription,
pending_payment_methods=len(unverified_payment_methods),
),
)
return

Check warning on line 113 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L113

Added line #L113 was not covered by tests
# reach here because ach is still pending
log.info(

Check warning on line 115 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L115

Added line #L115 was not covered by tests
"Invoice payment failed but requires action - skipping delinquency",
extra=dict(
stripe_customer_id=invoice.customer,
stripe_subscription_id=invoice.subscription,
),
)
return

Check warning on line 122 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L122

Added line #L122 was not covered by tests

log.info(
"Invoice Payment Failed - Setting Delinquency status True",
extra=dict(
Expand Down Expand Up @@ -137,6 +175,7 @@
**template_vars,
)

# handler for Stripe event customer.subscription.deleted
def customer_subscription_deleted(self, subscription: stripe.Subscription) -> None:
log.info(
"Customer Subscription Deleted - Setting free plan and deactivating repos for stripe customer",
Expand Down Expand Up @@ -183,6 +222,7 @@
),
)

# handler for Stripe event subscription_schedule.updated
def subscription_schedule_updated(
self, schedule: stripe.SubscriptionSchedule
) -> None:
Expand All @@ -207,6 +247,7 @@
),
)

# handler for Stripe event subscription_schedule.released
def subscription_schedule_released(
self, schedule: stripe.SubscriptionSchedule
) -> None:
Expand Down Expand Up @@ -245,14 +286,22 @@
),
)

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

# handler for Stripe event customer.subscription.created
def customer_subscription_created(self, subscription: stripe.Subscription) -> None:
log.info(
"Customer subscription created",
extra=dict(
customer_id=subscription["customer"], subscription_id=subscription["id"]
),
)
sub_item_plan_id = subscription.plan.id

if not sub_item_plan_id:
Expand Down Expand Up @@ -289,11 +338,31 @@
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()

# check if the subscription has a pending_update attribute, if so, don't upgrade the plan yet
print("subscription what are you", subscription)
# Check if subscription has a default payment method
has_default_payment = subscription.default_payment_method is not None

# If no default payment, check for any pending verification methods
if not has_default_payment:
payment_methods = get_unverified_payment_methods(subscription.customer)
if payment_methods:
log.info(

Check warning on line 356 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L354-L356

Added lines #L354 - L356 were not covered by tests
"Subscription has pending payment verification",
extra=dict(
subscription_id=subscription.id,
customer_id=subscription.customer,
payment_methods=payment_methods,
),
)
return

Check warning on line 364 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L364

Added line #L364 was not covered by tests

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

Expand All @@ -311,7 +380,15 @@

self._log_updated([owner])

# handler for Stripe event customer.subscription.updated
def customer_subscription_updated(self, subscription: stripe.Subscription) -> None:
log.info(
"Customer subscription updated",
extra=dict(
customer_id=subscription["customer"], subscription_id=subscription["id"]
),
)

owners: QuerySet[Owner] = Owner.objects.filter(
stripe_subscription_id=subscription.id,
stripe_customer_id=subscription.customer,
Expand All @@ -327,6 +404,25 @@
)
return

# check if the subscription has a pending_update attribute, if so, don't upgrade the plan yet
print("subscription what are you", subscription)
# Check if subscription has a default payment method
has_default_payment = subscription.default_payment_method is not None

# If no default payment, check for any pending verification methods
if not has_default_payment:
payment_methods = get_unverified_payment_methods(subscription.customer)
if payment_methods:
log.info(

Check warning on line 416 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L414-L416

Added lines #L414 - L416 were not covered by tests
"Subscription has pending payment verification",
extra=dict(
subscription_id=subscription.id,
customer_id=subscription.customer,
payment_methods=payment_methods,
),
)
return

Check warning on line 424 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L424

Added line #L424 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 All @@ -341,7 +437,6 @@
),
)
return

# Properly attach the payment method on the customer
# This hook will be called after a checkout session completes,
# updating the subscription created with it
Expand Down Expand Up @@ -407,6 +502,7 @@
),
)

# handler for Stripe event customer.updated
def customer_updated(self, customer: stripe.Customer) -> None:
new_default_payment_method = customer["invoice_settings"][
"default_payment_method"
Expand All @@ -428,6 +524,7 @@
subscription["id"], default_payment_method=new_default_payment_method
)

# handler for Stripe event checkout.session.completed
def checkout_session_completed(
self, checkout_session: stripe.checkout.Session
) -> None:
Expand All @@ -445,6 +542,58 @@

self._log_updated([owner])

def _check_and_handle_delayed_notification_payment_methods(
self, customer_id: str, payment_method_id: str
):
owner = Owner.objects.get(stripe_customer_id=customer_id)
payment_method = stripe.PaymentMethod.retrieve(payment_method_id)

Check warning on line 549 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L548-L549

Added lines #L548 - L549 were not covered by tests

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

Check warning on line 551 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L551

Added line #L551 was not covered by tests
payment_method, "us_bank_account"
):
# attach the payment method + set as default on the invoice and subscription
stripe.PaymentMethod.attach(

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
payment_method, customer=owner.stripe_customer_id
)
stripe.Customer.modify(

Check warning on line 558 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L558

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

Check warning on line 562 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L562

Added line #L562 was not covered by tests
owner.stripe_subscription_id, default_payment_method=payment_method
)

# handler for Stripe event payment_intent.succeeded
def payment_intent_succeeded(self, payment_intent: stripe.PaymentIntent) -> None:
"""
Stripe payment intent is used for the initial checkout session
"""
log.info(

Check warning on line 571 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L571

Added line #L571 was not covered by tests
"Payment intent succeeded",
extra=dict(
payment_method_id=payment_intent.id,
),
)

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
)

# handler for Stripe event setup_intent.succeeded
def setup_intent_succeeded(self, setup_intent: stripe.SetupIntent) -> None:
"""
Stripe setup intent is used for subsequent edits to payment methods.
See our createSetupIntent api which is called from the UI Stripe Payment Element
"""
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(setup_intent_id=setup_intent.id),
)

self._check_and_handle_delayed_notification_payment_methods(

Check warning on line 593 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L593

Added line #L593 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 Expand Up @@ -476,3 +625,41 @@
getattr(self, self.event.type.replace(".", "_"))(self.event.data.object)

return Response(status=status.HTTP_204_NO_CONTENT)


# TODO - move this
def get_unverified_payment_methods(self, stripe_customer_id: str):

unverified_payment_methods = []

Check warning on line 633 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L633

Added line #L633 was not covered by tests

# Check payment intents
payment_intents = stripe.PaymentIntent.list(customer=stripe_customer_id, limit=100)
for intent in payment_intents.data:
if (

Check warning on line 638 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L636-L638

Added lines #L636 - L638 were not covered by tests
hasattr(intent, "next_action")
and intent.next_action
and intent.next_action.type == "verify_with_microdeposits"
):
unverified_payment_methods.append(

Check warning on line 643 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L643

Added line #L643 was not covered by tests
{
"payment_method_id": intent.payment_method,
"hosted_verification_link": intent.next_action.verify_with_microdeposits.hosted_verification_url,
}
)

# Check setup intents
setup_intents = stripe.SetupIntent.list(customer=stripe_customer_id, limit=100)
for intent in setup_intents.data:
if (

Check warning on line 653 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L651-L653

Added lines #L651 - L653 were not covered by tests
hasattr(intent, "next_action")
and intent.next_action
and intent.next_action.type == "verify_with_microdeposits"
):
unverified_payment_methods.append(

Check warning on line 658 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L658

Added line #L658 was not covered by tests
{
"payment_method_id": intent.payment_method,
"hosted_verification_link": intent.next_action.verify_with_microdeposits.hosted_verification_url,
}
)

return unverified_payment_methods

Check warning on line 665 in billing/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

billing/views.py#L665

Added line #L665 was not covered by tests
Loading
Loading