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,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 ,
0 commit comments