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

Commit 04e3745

Browse files
feat: auto-refund subscription cancellation within grace period
1 parent 8945e37 commit 04e3745

File tree

2 files changed

+146
-8
lines changed

2 files changed

+146
-8
lines changed

services/billing.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
22
import re
33
from abc import ABC, abstractmethod
4+
from datetime import datetime
45

56
import stripe
67
from django.conf import settings
@@ -155,18 +156,37 @@ def delete_subscription(self, owner: Owner):
155156
f"Downgrade to basic plan from user plan for owner {owner.ownerid} by user #{self.requesting_user.ownerid}",
156157
extra=dict(ownerid=owner.ownerid),
157158
)
159+
158160
if subscription_schedule_id:
159161
log.info(
160162
f"Releasing subscription from schedule for owner {owner.ownerid} by user #{self.requesting_user.ownerid}",
161163
extra=dict(ownerid=owner.ownerid),
162164
)
163165
stripe.SubscriptionSchedule.release(subscription_schedule_id)
164166

165-
stripe.Subscription.modify(
166-
owner.stripe_subscription_id,
167-
cancel_at_period_end=True,
168-
proration_behavior="none",
169-
)
167+
# we give a auto-refund grace period of 24 hours for a monthly subscription or 72 hours for a yearly subscription
168+
current_subscription_timestamp = subscription["current_period_start"]
169+
difference = datetime.now() - datetime.fromtimestamp(current_subscription_timestamp)
170+
subscription_plan_interval = subscription.plan.interval if subscription.plan is not None else None
171+
should_refund_grace_period = (subscription_plan_interval == "month" and difference.days < 1) or (subscription_plan_interval == "year" and difference.days < 3)
172+
if should_refund_grace_period:
173+
stripe.Subscription.cancel(owner.stripe_subscription_id)
174+
175+
invoices_list = stripe.Invoice.list(subscription=owner.stripe_subscription_id, status="paid")
176+
charge = invoices_list["data"][0]["charge"] if len(invoices_list["data"]) >= 1 and invoices_list["data"][0]["charge"] else None
177+
if charge:
178+
stripe.Refund.create(charge=charge)
179+
stripe.Customer.modify(
180+
owner.stripe_customer_id,
181+
balance=0,
182+
)
183+
else:
184+
# outside of the grace period, we schedule a cancellation at the end of the period with no refund
185+
stripe.Subscription.modify(
186+
owner.stripe_subscription_id,
187+
cancel_at_period_end=True,
188+
proration_behavior="none",
189+
)
170190

171191
@_log_stripe_error
172192
def get_subscription(self, owner: Owner):

services/tests/test_billing.py

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
from unittest.mock import MagicMock, patch
3+
from freezegun import freeze_time
34

45
import requests
56
from django.conf import settings
@@ -128,18 +129,23 @@
128129
}
129130
]
130131

132+
class MockSubscriptionPlan(object):
133+
def __init__(self, params):
134+
self.id = params["new_plan"]
135+
self.interval = 'year'
131136

132137
class MockSubscription(object):
133138
def __init__(self, subscription_params):
134139
self.schedule = subscription_params["schedule_id"]
135140
self.current_period_start = subscription_params["start_date"]
136141
self.current_period_end = subscription_params["end_date"]
142+
self.plan = MockSubscriptionPlan(subscription_params["plan"]) if subscription_params.get("plan") is not None else None
137143
self.items = {
138144
"data": [
139145
{
140146
"quantity": subscription_params["quantity"],
141147
"id": subscription_params["id"],
142-
"plan": {"id": subscription_params["name"]},
148+
"plan": {"id": subscription_params["name"], "interval": subscription_params.get("plan", {}).get("interval", "month")},
143149
}
144150
]
145151
}
@@ -157,7 +163,6 @@ def __init__(self, subscription_params):
157163
def __getitem__(self, key):
158164
return getattr(self, key)
159165

160-
161166
class StripeServiceTests(TestCase):
162167
def setUp(self):
163168
self.user = OwnerFactory()
@@ -306,11 +311,13 @@ def test_delete_subscription_without_schedule_modifies_subscription_to_delete_at
306311
assert owner.plan_activated_users == [4, 6, 3]
307312
assert owner.plan_user_count == 9
308313

314+
@freeze_time("2021-12-22T00:00:00")
315+
@patch("services.billing.stripe.Refund.create")
309316
@patch("services.billing.stripe.Subscription.modify")
310317
@patch("services.billing.stripe.Subscription.retrieve")
311318
@patch("services.billing.stripe.SubscriptionSchedule.release")
312319
def test_delete_subscription_with_schedule_releases_schedule_and_cancels_subscription_at_end_of_billing_cycle_if_valid_plan(
313-
self, schedule_release_mock, retrieve_subscription_mock, modify_mock
320+
self, schedule_release_mock, retrieve_subscription_mock, modify_mock, create_refund_mock
314321
):
315322
plan = PlanName.CODECOV_PRO_YEARLY.value
316323
stripe_subscription_id = "sub_1K77Y5GlVGuVgOrkJrLjRnne"
@@ -338,6 +345,117 @@ def test_delete_subscription_with_schedule_releases_schedule_and_cancels_subscri
338345
cancel_at_period_end=True,
339346
proration_behavior="none",
340347
)
348+
create_refund_mock.assert_not_called()
349+
owner.refresh_from_db()
350+
assert owner.stripe_subscription_id == stripe_subscription_id
351+
assert owner.plan == plan
352+
assert owner.plan_activated_users == [4, 6, 3]
353+
assert owner.plan_user_count == 9
354+
355+
@freeze_time("2021-12-17T00:00:00")
356+
@patch("services.billing.stripe.Subscription.modify")
357+
@patch("services.billing.stripe.Customer.modify")
358+
@patch("services.billing.stripe.Refund.create")
359+
@patch("services.billing.stripe.Invoice.list")
360+
@patch("services.billing.stripe.Subscription.cancel")
361+
@patch("services.billing.stripe.Subscription.retrieve")
362+
@patch("services.billing.stripe.SubscriptionSchedule.release")
363+
def test_delete_subscription_with_schedule_releases_schedule_and_cancels_subscription_with_grace_month_refund_if_valid_plan(
364+
self, schedule_release_mock, retrieve_subscription_mock, cancel_sub_mock, list_invoice_mock, create_refund_mock, modify_customer_mock, modify_sub_mock
365+
):
366+
with open("./services/tests/samples/stripe_invoice.json") as f:
367+
stripe_invoice_response = json.load(f)
368+
stripe_invoice_response["data"] = stripe_invoice_response["data"] * 2
369+
list_invoice_mock.return_value = stripe_invoice_response
370+
plan = PlanName.CODECOV_PRO_YEARLY.value
371+
stripe_subscription_id = "sub_1K77Y5GlVGuVgOrkJrLjRnne"
372+
stripe_schedule_id = "sub_sched_sch1K77Y5GlVGuVgOrkJrLjRnne"
373+
charge = "ch_19yUQN2eZvKYlo2CQf7aWpSX"
374+
owner = OwnerFactory(
375+
stripe_subscription_id=stripe_subscription_id,
376+
plan=plan,
377+
plan_activated_users=[4, 6, 3],
378+
plan_user_count=9,
379+
)
380+
subscription_params = {
381+
"schedule_id": stripe_schedule_id,
382+
"start_date": 1639628096,
383+
"end_date": 1644107871,
384+
"quantity": 10,
385+
"name": plan,
386+
"id": 215,
387+
"plan": {
388+
"new_plan": "plan_H6P3KZXwmAbqPS",
389+
"new_quantity": 7,
390+
"subscription_id": "sub_123",
391+
"interval": "month",
392+
}
393+
}
394+
395+
retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
396+
self.stripe.delete_subscription(owner)
397+
schedule_release_mock.assert_called_once_with(stripe_schedule_id)
398+
cancel_sub_mock.assert_called_once_with(stripe_subscription_id)
399+
list_invoice_mock.assert_called_once_with(subscription=stripe_subscription_id, status="paid")
400+
create_refund_mock.assert_called_once_with(charge=charge)
401+
modify_customer_mock.assert_called_once_with(owner.stripe_customer_id, balance=0)
402+
modify_sub_mock.assert_not_called()
403+
404+
owner.refresh_from_db()
405+
assert owner.stripe_subscription_id == stripe_subscription_id
406+
assert owner.plan == plan
407+
assert owner.plan_activated_users == [4, 6, 3]
408+
assert owner.plan_user_count == 9
409+
410+
@freeze_time("2021-12-19T00:00:00")
411+
@patch("services.billing.stripe.Subscription.modify")
412+
@patch("services.billing.stripe.Customer.modify")
413+
@patch("services.billing.stripe.Refund.create")
414+
@patch("services.billing.stripe.Invoice.list")
415+
@patch("services.billing.stripe.Subscription.cancel")
416+
@patch("services.billing.stripe.Subscription.retrieve")
417+
@patch("services.billing.stripe.SubscriptionSchedule.release")
418+
def test_delete_subscription_with_schedule_releases_schedule_and_cancels_subscription_with_grace_year_refund_if_valid_plan(
419+
self, schedule_release_mock, retrieve_subscription_mock, cancel_sub_mock, list_invoice_mock, create_refund_mock, modify_customer_mock, modify_sub_mock
420+
):
421+
with open("./services/tests/samples/stripe_invoice.json") as f:
422+
stripe_invoice_response = json.load(f)
423+
stripe_invoice_response["data"] = stripe_invoice_response["data"] * 2
424+
list_invoice_mock.return_value = stripe_invoice_response
425+
plan = PlanName.CODECOV_PRO_YEARLY.value
426+
stripe_subscription_id = "sub_1K77Y5GlVGuVgOrkJrLjRnne"
427+
stripe_schedule_id = "sub_sched_sch1K77Y5GlVGuVgOrkJrLjRnne"
428+
charge = "ch_19yUQN2eZvKYlo2CQf7aWpSX"
429+
owner = OwnerFactory(
430+
stripe_subscription_id=stripe_subscription_id,
431+
plan=plan,
432+
plan_activated_users=[4, 6, 3],
433+
plan_user_count=9,
434+
)
435+
subscription_params = {
436+
"schedule_id": stripe_schedule_id,
437+
"start_date": 1639628096,
438+
"end_date": 1644107871,
439+
"quantity": 10,
440+
"name": plan,
441+
"id": 215,
442+
"plan": {
443+
"new_plan": "plan_H6P3KZXwmAbqPS",
444+
"new_quantity": 7,
445+
"subscription_id": "sub_123",
446+
"interval": "year",
447+
}
448+
}
449+
450+
retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
451+
self.stripe.delete_subscription(owner)
452+
schedule_release_mock.assert_called_once_with(stripe_schedule_id)
453+
cancel_sub_mock.assert_called_once_with(stripe_subscription_id)
454+
list_invoice_mock.assert_called_once_with(subscription=stripe_subscription_id, status="paid")
455+
create_refund_mock.assert_called_once_with(charge=charge)
456+
modify_customer_mock.assert_called_once_with(owner.stripe_customer_id, balance=0)
457+
modify_sub_mock.assert_not_called()
458+
341459
owner.refresh_from_db()
342460
assert owner.stripe_subscription_id == stripe_subscription_id
343461
assert owner.plan == plan

0 commit comments

Comments
 (0)