@@ -115,6 +115,117 @@ def link_event_data_to_checkout_intent(event, checkout_intent):
115115 event_data .save ()
116116
117117
118+ def handle_pending_update (subscription_id : str , checkout_intent_id : int , pending_update ):
119+ """
120+ Log pending update information for visibility.
121+ Assumes a pending_update is present.
122+ """
123+ # TODO: take necessary action on the actual SubscriptionPlan and update the CheckoutIntent.
124+ logger .warning (
125+ "Subscription %s has pending update: %s. checkout_intent_id: %s" ,
126+ subscription_id ,
127+ pending_update ,
128+ checkout_intent_id ,
129+ )
130+
131+
132+ def handle_trial_cancellation (checkout_intent : CheckoutIntent , checkout_intent_id : int , subscription_id : str , trial_end ):
133+ """
134+ Send cancellation email for a trial subscription that has just transitioned to canceled.
135+ Assumes caller validated status transition and presence of trial_end.
136+ """
137+ logger .info (
138+ f"Subscription { subscription_id } transitioned to 'canceled'. "
139+ f"Queuing trial cancellation email for checkout_intent_id={ checkout_intent_id } "
140+ )
141+
142+ send_trial_cancellation_email_task .delay (
143+ checkout_intent_id = checkout_intent .id ,
144+ trial_end_timestamp = trial_end ,
145+ )
146+
147+
148+ def future_plans_of_current (current_plan_uuid : str , plans : list [dict ]) -> list [dict ]:
149+ """
150+ Return plans that are future renewals of the current plan, based on prior_renewals linkage.
151+ """
152+ def is_future_of_current (plan_dict ):
153+ if str (plan_dict .get ('uuid' )) == current_plan_uuid :
154+ return False
155+ for renewal in plan_dict .get ('prior_renewals' , []) or []:
156+ if str (renewal .get ('prior_subscription_plan_id' )) == current_plan_uuid :
157+ return True
158+ return False
159+
160+ return [p for p in plans if is_future_of_current (p )]
161+
162+
163+ def cancel_all_future_plans (enterprise_uuid : str , reason : str = 'delayed_payment' , subscription_id_for_logs : str | None = None ) -> list [str ]:
164+ """
165+ Deactivate (cancel) all future plans for the current plan of the given enterprise.
166+
167+ Returns list of deactivated plan UUIDs. Logs warnings/info for observability.
168+ """
169+ from enterprise_access .apps .api_client .license_manager_client import LicenseManagerApiClient
170+
171+ client = LicenseManagerApiClient ()
172+ deactivated = []
173+ try :
174+ current_list = client .list_subscriptions (enterprise_uuid , current = True )
175+ current_results = (current_list or {}).get ('results' , [])
176+ current_plan = current_results [0 ] if current_results else None
177+ if not current_plan :
178+ logger .warning (
179+ "No current subscription plan found for enterprise %s when canceling future plans (subscription %s)" ,
180+ enterprise_uuid , subscription_id_for_logs ,
181+ )
182+ return deactivated
183+
184+ current_plan_uuid = str (current_plan .get ('uuid' ))
185+
186+ # Fetch all active plans for enterprise
187+ all_list = client .list_subscriptions (enterprise_uuid )
188+ all_plans = (all_list or {}).get ('results' , [])
189+
190+ future_plans = future_plans_of_current (current_plan_uuid , all_plans )
191+ if not future_plans :
192+ logger .info (
193+ "No future plans to deactivate for enterprise %s (current plan %s) (subscription %s)" ,
194+ enterprise_uuid , current_plan_uuid , subscription_id_for_logs ,
195+ )
196+ return deactivated
197+
198+ # Deactivate all future plans
199+ for future in future_plans :
200+ future_uuid = future .get ('uuid' )
201+ try :
202+ client .update_subscription_plan (
203+ future_uuid ,
204+ is_active = False ,
205+ change_reason = reason ,
206+ )
207+ deactivated .append (str (future_uuid ))
208+ logger .info (
209+ "Deactivated future plan %s for enterprise %s (reason=%s) (subscription %s)" ,
210+ future_uuid , enterprise_uuid , reason , subscription_id_for_logs ,
211+ )
212+ except Exception as exc : # pylint: disable=broad-except
213+ logger .exception (
214+ "Failed to deactivate future plan %s for enterprise %s (reason=%s): %s" ,
215+ future_uuid , enterprise_uuid , reason , exc ,
216+ )
217+ except Exception as exc : # pylint: disable=broad-except
218+ logger .exception (
219+ "Unexpected error canceling future plans for enterprise %s (subscription %s): %s" ,
220+ enterprise_uuid , subscription_id_for_logs , exc ,
221+ )
222+
223+ return deactivated
224+
225+
226+
227+
228+
118229class StripeEventHandler :
119230 """
120231 Container for Stripe event handler logic.
@@ -219,54 +330,40 @@ def subscription_updated(event: stripe.Event) -> None:
219330 Send cancellation notification email when a trial subscription is canceled.
220331 """
221332 subscription = event .data .object
222- pending_update = getattr (subscription , "pending_update" , None )
223-
224- checkout_intent_id = get_checkout_intent_id_from_subscription (
225- subscription
226- )
227- checkout_intent = get_checkout_intent_or_raise (
228- checkout_intent_id , event .id
229- )
333+ checkout_intent_id = get_checkout_intent_id_from_subscription (subscription )
334+ checkout_intent = get_checkout_intent_or_raise (checkout_intent_id , event .id )
230335 link_event_data_to_checkout_intent (event , checkout_intent )
231336
337+ # Pending update
338+ pending_update = getattr (subscription , "pending_update" , None )
232339 if pending_update :
233- # TODO: take necessary action on the actual SubscriptionPlan
234- # and update the CheckoutIntent.
235- logger .warning (
236- "Subscription %s has pending update: %s. checkout_intent_id: %s" ,
237- subscription .id ,
238- pending_update ,
239- get_checkout_intent_id_from_subscription (subscription ),
240- )
340+ handle_pending_update (subscription .id , checkout_intent_id , pending_update )
241341
242- # Handle trial subscription cancellation
243- # Check if status changed to canceled to avoid duplicate emails
342+ # Trial cancellation transition
244343 current_status = subscription .get ("status" )
245- if current_status == "canceled" :
246- prior_status = getattr (checkout_intent .previous_summary (event ), 'subscription_status' , None )
247-
248- # Only send email if status changed from non-canceled to canceled
249- if prior_status != 'canceled' :
250- trial_end = subscription .get ("trial_end" )
251- if trial_end :
252- logger .info (
253- f"Subscription { subscription .id } status changed from '{ prior_status } ' to 'canceled'. "
254- f"Queuing trial cancellation email for checkout_intent_id={ checkout_intent_id } "
255- )
256-
257- send_trial_cancellation_email_task .delay (
258- checkout_intent_id = checkout_intent .id ,
259- trial_end_timestamp = trial_end ,
260- )
261- else :
262- logger .info (
263- f"Subscription { subscription .id } canceled but has no trial_end, skipping cancellation email"
264- )
344+ prior_status = getattr (checkout_intent .previous_summary (event ), 'subscription_status' , None )
345+ if current_status == "canceled" and prior_status != "canceled" :
346+ trial_end = subscription .get ("trial_end" )
347+ if trial_end :
348+ handle_trial_cancellation (checkout_intent , checkout_intent_id , subscription .id , trial_end )
349+
350+ # Past due transition
351+ if current_status == "past_due" and prior_status != "past_due" :
352+ enterprise_uuid = checkout_intent .enterprise_uuid
353+ if enterprise_uuid :
354+ cancel_all_future_plans (
355+ enterprise_uuid = enterprise_uuid ,
356+ reason = 'delayed_payment' ,
357+ subscription_id_for_logs = subscription .id ,
358+ )
265359 else :
266- logger .info (
267- f"Subscription { subscription .id } already canceled (status unchanged), skipping cancellation email"
360+ logger .error (
361+ "Cannot deactivate future plans for subscription %s: missing enterprise_uuid on CheckoutIntent %s" ,
362+ subscription .id , checkout_intent .id ,
268363 )
269364
365+
366+
270367 @on_stripe_event ("customer.subscription.deleted" )
271368 @staticmethod
272369 def subscription_deleted (event : stripe .Event ) -> None :
0 commit comments