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

Commit f484524

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

File tree

3 files changed

+470
-8
lines changed

3 files changed

+470
-8
lines changed

services/billing.py

Lines changed: 122 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,87 @@ def list_filtered_invoices(self, owner: Owner, limit=10):
146148
)
147149
return list(invoices_filtered_by_status_and_total)
148150

151+
def cancel_and_refund(
152+
self,
153+
owner,
154+
current_subscription_datetime,
155+
subscription_plan_interval,
156+
autorefunds_remaining,
157+
):
158+
# cancels a Stripe customer subscription immediately and attempts to refund their payments for the current period
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 PAID recently for the latest, current period. "invoices_list" gives us any invoice
184+
# created over the last month/year based on what period length they are on but the customer could have possibly
185+
# switched from monthly to yearly recently.
186+
recently_paid_invoices_list = [
187+
invoice
188+
for invoice in invoices_list["data"]
189+
if invoice["status_transitions"]["paid_at"] is not None
190+
and invoice["status_transitions"]["paid_at"]
191+
>= int(invoice_grace_period_start.timestamp())
192+
]
193+
194+
created_refund = False
195+
# there could be multiple invoices that need to be refunded such as if the user increased seats within the grace period
196+
for invoice in recently_paid_invoices_list:
197+
# refund if the invoice has a charge and it has been fully paid
198+
if invoice["charge"] is not None and invoice["amount_remaining"] == 0:
199+
stripe.Refund.create(invoice["charge"])
200+
created_refund = True
201+
202+
if created_refund:
203+
# update the customer's balance back to 0 in accordance to
204+
# https://support.stripe.com/questions/refunding-credit-balance-to-customer-after-subscription-downgrade-or-cancellation
205+
stripe.Customer.modify(
206+
owner.stripe_customer_id,
207+
balance=0,
208+
metadata={"autorefunds_remaining": str(autorefunds_remaining - 1)},
209+
)
210+
log.info(
211+
"Grace period cancelled a subscription and autorefunded associated invoices",
212+
extra=dict(
213+
owner_id=owner.ownerid,
214+
user_id=self.requesting_user.ownerid,
215+
subscription_id=owner.stripe_subscription_id,
216+
customer_id=owner.stripe_customer_id,
217+
autorefunds_remaining=autorefunds_remaining - 1,
218+
),
219+
)
220+
else:
221+
log.info(
222+
"Grace period cancelled a subscription but did not find any appropriate invoices to autorefund",
223+
extra=dict(
224+
owner_id=owner.ownerid,
225+
user_id=self.requesting_user.ownerid,
226+
subscription_id=owner.stripe_subscription_id,
227+
customer_id=owner.stripe_customer_id,
228+
autorefunds_remaining=autorefunds_remaining,
229+
),
230+
)
231+
149232
@_log_stripe_error
150233
def delete_subscription(self, owner: Owner):
151234
subscription = stripe.Subscription.retrieve(owner.stripe_subscription_id)
@@ -155,13 +238,52 @@ def delete_subscription(self, owner: Owner):
155238
f"Downgrade to basic plan from user plan for owner {owner.ownerid} by user #{self.requesting_user.ownerid}",
156239
extra=dict(ownerid=owner.ownerid),
157240
)
241+
158242
if subscription_schedule_id:
159243
log.info(
160244
f"Releasing subscription from schedule for owner {owner.ownerid} by user #{self.requesting_user.ownerid}",
161245
extra=dict(ownerid=owner.ownerid),
162246
)
163247
stripe.SubscriptionSchedule.release(subscription_schedule_id)
164248

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