Skip to content
This repository was archived by the owner on Jun 13, 2025. It is now read-only.
Merged
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
122 changes: 122 additions & 0 deletions services/billing.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import logging
import re
from abc import ABC, abstractmethod
from datetime import datetime, timezone

import stripe
from dateutil.relativedelta import relativedelta
from django.conf import settings

from billing.constants import REMOVED_INVOICE_STATUSES
Expand Down Expand Up @@ -146,6 +148,87 @@ def list_filtered_invoices(self, owner: Owner, limit=10):
)
return list(invoices_filtered_by_status_and_total)

def cancel_and_refund(
self,
owner,
current_subscription_datetime,
subscription_plan_interval,
autorefunds_remaining,
):
# cancels a Stripe customer subscription immediately and attempts to refund their payments for the current period
stripe.Subscription.cancel(owner.stripe_subscription_id)

start_of_last_period = current_subscription_datetime - relativedelta(months=1)
invoice_grace_period_start = current_subscription_datetime - relativedelta(
days=1
)

if subscription_plan_interval == "year":
start_of_last_period = current_subscription_datetime - relativedelta(
years=1
)
invoice_grace_period_start = current_subscription_datetime - relativedelta(
days=3
)

invoices_list = stripe.Invoice.list(
subscription=owner.stripe_subscription_id,
status="paid",
created={
"created.gte": int(start_of_last_period.timestamp()),
"created.lt": int(current_subscription_datetime.timestamp()),
},
)

# we only want to refund the invoices PAID recently for the latest, current period. "invoices_list" gives us any invoice
# created over the last month/year based on what period length they are on but the customer could have possibly
# switched from monthly to yearly recently.
recently_paid_invoices_list = [
invoice
for invoice in invoices_list["data"]
if invoice["status_transitions"]["paid_at"] is not None
and invoice["status_transitions"]["paid_at"]
>= int(invoice_grace_period_start.timestamp())
]

created_refund = False
# there could be multiple invoices that need to be refunded such as if the user increased seats within the grace period
for invoice in recently_paid_invoices_list:
# refund if the invoice has a charge and it has been fully paid
if invoice["charge"] is not None and invoice["amount_remaining"] == 0:
stripe.Refund.create(invoice["charge"])
created_refund = True

if created_refund:
# update the customer's balance back to 0 in accordance to
# https://support.stripe.com/questions/refunding-credit-balance-to-customer-after-subscription-downgrade-or-cancellation
Copy link
Member

Choose a reason for hiding this comment

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

ty for the documentation!!

stripe.Customer.modify(
owner.stripe_customer_id,
balance=0,
metadata={"autorefunds_remaining": str(autorefunds_remaining - 1)},
)
log.info(
"Grace period cancelled a subscription and autorefunded associated invoices",
extra=dict(
owner_id=owner.ownerid,
user_id=self.requesting_user.ownerid,
subscription_id=owner.stripe_subscription_id,
customer_id=owner.stripe_customer_id,
autorefunds_remaining=autorefunds_remaining - 1,
),
)
else:
log.info(
"Grace period cancelled a subscription but did not find any appropriate invoices to autorefund",
extra=dict(
owner_id=owner.ownerid,
user_id=self.requesting_user.ownerid,
subscription_id=owner.stripe_subscription_id,
customer_id=owner.stripe_customer_id,
autorefunds_remaining=autorefunds_remaining,
),
)

Copy link
Member

Choose a reason for hiding this comment

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

great logs!

@_log_stripe_error
def delete_subscription(self, owner: Owner):
subscription = stripe.Subscription.retrieve(owner.stripe_subscription_id)
Expand All @@ -155,13 +238,52 @@ def delete_subscription(self, owner: Owner):
f"Downgrade to basic plan from user plan for owner {owner.ownerid} by user #{self.requesting_user.ownerid}",
extra=dict(ownerid=owner.ownerid),
)

if subscription_schedule_id:
log.info(
f"Releasing subscription from schedule for owner {owner.ownerid} by user #{self.requesting_user.ownerid}",
extra=dict(ownerid=owner.ownerid),
)
stripe.SubscriptionSchedule.release(subscription_schedule_id)

# we give an auto-refund grace period of 24 hours for a monthly subscription or 72 hours for a yearly subscription
current_subscription_datetime = datetime.fromtimestamp(
Copy link
Contributor

Choose a reason for hiding this comment

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

Just to confirm, are the timezones for both datetime.now() and datetime.fromtimestamp() the same?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's a great question. I just checked and both are in machine local so since we only care about the difference, we're good.

Copy link
Contributor

Choose a reason for hiding this comment

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

great! Thanks for checking

Copy link
Contributor Author

@calvin-codecov calvin-codecov Oct 25, 2024

Choose a reason for hiding this comment

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

I'm converting these to utc now actually because I realized depending on what the local time of the machine is, it could change the date. Like if february 29th was converted to march 1st due to timezone difference, subtracting 1 month would give february 1st instead of january 29th

subscription["current_period_start"], tz=timezone.utc
)
difference_from_now = datetime.now(timezone.utc) - current_subscription_datetime

subscription_plan_interval = getattr(
getattr(subscription, "plan", None), "interval", None
)
within_refund_grace_period = (
subscription_plan_interval == "month" and difference_from_now.days < 1
) or (subscription_plan_interval == "year" and difference_from_now.days < 3)

if within_refund_grace_period:
customer = stripe.Customer.retrieve(owner.stripe_customer_id)
# we are currently allowing customers 2 autorefund instances
autorefunds_remaining = int(
customer["metadata"].get("autorefunds_remaining", "2")
)
log.info(
"Deleting subscription with attempted immediate cancellation with autorefund within grace period",
extra=dict(
owner_id=owner.ownerid,
user_id=self.requesting_user.ownerid,
subscription_id=owner.stripe_subscription_id,
customer_id=owner.stripe_customer_id,
autorefunds_remaining=autorefunds_remaining,
),
)
if autorefunds_remaining > 0:
return self.cancel_and_refund(
owner,
current_subscription_datetime,
subscription_plan_interval,
autorefunds_remaining,
)

# schedule a cancellation at the end of the paid period with no refund
stripe.Subscription.modify(
owner.stripe_subscription_id,
cancel_at_period_end=True,
Expand Down
2 changes: 1 addition & 1 deletion services/tests/samples/stripe_invoice.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@
"total_tax_amounts": [],
"webhooks_delivered_at": 1489789437
},
{
{
"id": "in_19yTU92eZvKYlo2C7uDjvu69",
"object": "invoice",
"account_country": "US",
Expand Down
Loading
Loading