11import logging
22import re
33from abc import ABC , abstractmethod
4+ from datetime import datetime , timezone
45
56import stripe
7+ from dateutil .relativedelta import relativedelta
68from django .conf import settings
79
810from 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 ,
0 commit comments