Skip to content
This repository was archived by the owner on Jun 13, 2025. It is now read-only.

Commit f93753d

Browse files
Stripe Auto-refund cancellation within Grace Period (#894)
Co-authored-by: Nora Shapiro <[email protected]>
1 parent 142a003 commit f93753d

File tree

3 files changed

+469
-8
lines changed

3 files changed

+469
-8
lines changed

services/billing.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import logging
22
import re
33
from abc import ABC, abstractmethod
4+
from datetime import datetime, timezone
45

56
import stripe
7+
from dateutil.relativedelta import relativedelta
68
from django.conf import settings
79

810
from billing.constants import REMOVED_INVOICE_STATUSES
@@ -146,6 +148,86 @@ def list_filtered_invoices(self, owner: Owner, limit=10):
146148
)
147149
return list(invoices_filtered_by_status_and_total)
148150

151+
# cancels a Stripe customer subscription immediately and attempts to refund their payments for the current period
152+
def cancel_and_refund(
153+
self,
154+
owner,
155+
current_subscription_datetime,
156+
subscription_plan_interval,
157+
autorefunds_remaining,
158+
):
159+
stripe.Subscription.cancel(owner.stripe_subscription_id)
160+
161+
start_of_last_period = current_subscription_datetime - relativedelta(months=1)
162+
invoice_grace_period_start = current_subscription_datetime - relativedelta(
163+
days=1
164+
)
165+
166+
if subscription_plan_interval == "year":
167+
start_of_last_period = current_subscription_datetime - relativedelta(
168+
years=1
169+
)
170+
invoice_grace_period_start = current_subscription_datetime - relativedelta(
171+
days=3
172+
)
173+
174+
invoices_list = stripe.Invoice.list(
175+
subscription=owner.stripe_subscription_id,
176+
status="paid",
177+
created={
178+
"created.gte": int(start_of_last_period.timestamp()),
179+
"created.lt": int(current_subscription_datetime.timestamp()),
180+
},
181+
)
182+
183+
# we only want to refund the invoices for the latest, current period
184+
recently_paid_invoices_list = [
185+
invoice
186+
for invoice in invoices_list["data"]
187+
if invoice["status_transitions"]["paid_at"] is not None
188+
and invoice["status_transitions"]["paid_at"]
189+
>= int(invoice_grace_period_start.timestamp())
190+
]
191+
192+
created_refund = False
193+
# there could be multiple invoices that need to be refunded such as if the user increased seats within the grace period
194+
for invoice in recently_paid_invoices_list:
195+
# refund if the invoice has a charge and it has been fully paid
196+
if invoice["charge"] is not None and invoice["amount_remaining"] == 0:
197+
stripe.Refund.create(invoice["charge"])
198+
created_refund = True
199+
200+
if created_refund:
201+
# update the customer's balance back to 0 in accordance to
202+
# https://support.stripe.com/questions/refunding-credit-balance-to-customer-after-subscription-downgrade-or-cancellation
203+
stripe.Customer.modify(
204+
owner.stripe_customer_id,
205+
balance=0,
206+
metadata={"autorefunds_remaining": str(autorefunds_remaining - 1)},
207+
)
208+
log.info(
209+
"Grace period cancelled a subscription and autorefunded associated invoices",
210+
extra=dict(
211+
owner_id=owner.ownerid,
212+
user_id=self.requesting_user.ownerid,
213+
subscription_id=owner.stripe_subscription_id,
214+
customer_id=owner.stripe_customer_id,
215+
autorefunds_remaining=autorefunds_remaining - 1,
216+
),
217+
)
218+
else:
219+
# log that we created no refunds but did cancel them
220+
log.info(
221+
"Grace period cancelled a subscription but did not find any appropriate invoices to autorefund",
222+
extra=dict(
223+
owner_id=owner.ownerid,
224+
user_id=self.requesting_user.ownerid,
225+
subscription_id=owner.stripe_subscription_id,
226+
customer_id=owner.stripe_customer_id,
227+
autorefunds_remaining=autorefunds_remaining,
228+
),
229+
)
230+
149231
@_log_stripe_error
150232
def delete_subscription(self, owner: Owner):
151233
subscription = stripe.Subscription.retrieve(owner.stripe_subscription_id)
@@ -155,13 +237,52 @@ def delete_subscription(self, owner: Owner):
155237
f"Downgrade to basic plan from user plan for owner {owner.ownerid} by user #{self.requesting_user.ownerid}",
156238
extra=dict(ownerid=owner.ownerid),
157239
)
240+
158241
if subscription_schedule_id:
159242
log.info(
160243
f"Releasing subscription from schedule for owner {owner.ownerid} by user #{self.requesting_user.ownerid}",
161244
extra=dict(ownerid=owner.ownerid),
162245
)
163246
stripe.SubscriptionSchedule.release(subscription_schedule_id)
164247

248+
# we give an auto-refund grace period of 24 hours for a monthly subscription or 72 hours for a yearly subscription
249+
current_subscription_datetime = datetime.fromtimestamp(
250+
subscription["current_period_start"], tz=timezone.utc
251+
)
252+
difference_from_now = datetime.now(timezone.utc) - current_subscription_datetime
253+
254+
subscription_plan_interval = getattr(
255+
getattr(subscription, "plan", None), "interval", None
256+
)
257+
within_refund_grace_period = (
258+
subscription_plan_interval == "month" and difference_from_now.days < 1
259+
) or (subscription_plan_interval == "year" and difference_from_now.days < 3)
260+
261+
if within_refund_grace_period:
262+
customer = stripe.Customer.retrieve(owner.stripe_customer_id)
263+
# we are currently allowing customers 2 autorefund instances
264+
autorefunds_remaining = int(
265+
customer["metadata"].get("autorefunds_remaining", "2")
266+
)
267+
log.info(
268+
"Deleting subscription with attempted immediate cancellation with autorefund within grace period",
269+
extra=dict(
270+
owner_id=owner.ownerid,
271+
user_id=self.requesting_user.ownerid,
272+
subscription_id=owner.stripe_subscription_id,
273+
customer_id=owner.stripe_customer_id,
274+
autorefunds_remaining=autorefunds_remaining,
275+
),
276+
)
277+
if autorefunds_remaining > 0:
278+
return self.cancel_and_refund(
279+
owner,
280+
current_subscription_datetime,
281+
subscription_plan_interval,
282+
autorefunds_remaining,
283+
)
284+
285+
# schedule a cancellation at the end of the paid period with no refund
165286
stripe.Subscription.modify(
166287
owner.stripe_subscription_id,
167288
cancel_at_period_end=True,

services/tests/samples/stripe_invoice.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@
120120
"total_tax_amounts": [],
121121
"webhooks_delivered_at": 1489789437
122122
},
123-
{
123+
{
124124
"id": "in_19yTU92eZvKYlo2C7uDjvu69",
125125
"object": "invoice",
126126
"account_country": "US",

0 commit comments

Comments
 (0)