44import logging
55from collections .abc import Callable
66from functools import wraps
7+ from uuid import UUID
78
89import stripe
9- from django .apps import apps
1010
1111from enterprise_access .apps .api_client .license_manager_client import LicenseManagerApiClient
1212from enterprise_access .apps .customer_billing .models import (
@@ -137,235 +137,21 @@ def link_event_data_to_checkout_intent(event, checkout_intent):
137137 event_data .save () # this triggers a post_save signal that updates the related summary record
138138
139139
140- def handle_trial_cancellation (
141- checkout_intent : CheckoutIntent ,
142- checkout_intent_id : int ,
143- subscription_id : str ,
144- trial_end
145- ):
146- """
147- Send cancellation email for a trial subscription that has just transitioned to canceled.
148- Assumes caller validated status transition and presence of trial_end.
149- """
150- logger .info (
151- (
152- "Subscription %s transitioned to 'canceled'. "
153- "Sending cancellation email for checkout_intent_id=%s"
154- ),
155- subscription_id ,
156- checkout_intent_id ,
157- )
158-
159- send_trial_cancellation_email_task .delay (
160- checkout_intent_id = checkout_intent .id ,
161- trial_end_timestamp = trial_end ,
162- )
163-
164-
165- def future_plans_of_current (current_plan_uuid : str , plans : list [dict ]) -> list [dict ]:
166- """
167- Return plans that are future renewals of the current plan,
168- based on prior_renewals linkage.
169- """
170- def is_future_of_current (plan_dict ):
171- if str (plan_dict .get ('uuid' )) == current_plan_uuid :
172- return False
173- for renewal in plan_dict .get ('prior_renewals' , []) or []:
174- if str (renewal .get ('prior_subscription_plan_id' )) == current_plan_uuid :
175- return True
176- return False
177-
178- return [p for p in plans if is_future_of_current (p )]
179-
180-
181- def _get_subscription_plan_uuid_from_checkout_intent (checkout_intent : CheckoutIntent | None ) -> str | None :
182- """
183- Try to resolve the License Manager SubscriptionPlan UUID
184- associated with the given CheckoutIntent.
185-
186- 1) If the CheckoutIntent has a provisioning workflow,
187- read the GetCreateSubscriptionPlanStep output uuid.
188- 2) Otherwise, look up the most recent StripeEventSummary for this
189- CheckoutIntent that contains a subscription_plan_uuid and use that value.
190- """
191- if not checkout_intent :
192- return None
193-
194- # 1) From provisioning workflow step output
195- try :
196- workflow = checkout_intent .workflow
197- if workflow :
198- subscription_step_model = apps .get_model ('provisioning' , 'GetCreateSubscriptionPlanStep' )
199- step = subscription_step_model .objects .filter (
200- workflow_record_uuid = workflow .uuid ,
201- ).first ()
202- output_obj = getattr (step , 'output_object' , None )
203- if output_obj and getattr (output_obj , 'uuid' , None ):
204- return str (output_obj .uuid )
205- except Exception as exc : # pylint: disable=broad-exception-caught
206- logger .exception (
207- "Failed resolving subscription plan uuid from workflow for CheckoutIntent %s: %s" ,
208- checkout_intent .id , exc ,
209- )
210-
211- # 2) From StripeEventSummary records linked to this CheckoutIntent
212- try :
213- summary_with_uuid = (
214- StripeEventSummary .objects
215- .filter (checkout_intent = checkout_intent , subscription_plan_uuid__isnull = False )
216- .order_by ('-stripe_event_created_at' )
217- .first ()
218- )
219- if summary_with_uuid and summary_with_uuid .subscription_plan_uuid :
220- return str (summary_with_uuid .subscription_plan_uuid )
221- except Exception as exc : # pylint: disable=broad-exception-caught
222- logger .exception (
223- "Failed resolving subscription plan uuid from StripeEventSummary for CheckoutIntent %s: %s" ,
224- checkout_intent .id , exc ,
225- )
226-
227- return None
228-
229-
230- def _build_lineage_from_anchor (anchor_plan_uuid : str , plans : list [dict ]) -> set [str ]:
231- """
232- Return the anchor plan and all of its future renewals.
233- """
234- anchor = str (anchor_plan_uuid )
235-
236- # Index: parent_plan_uuid -> set(child_plan_uuid)
237- children_index : dict [str , set [str ]] = {}
238- for plan in plans :
239- child_uuid = str (plan .get ('uuid' ))
240- for renewal in plan .get ('prior_renewals' , []) or []:
241- parent_uuid = str (renewal .get ('prior_subscription_plan_id' ))
242- if parent_uuid :
243- children_index .setdefault (parent_uuid , set ()).add (child_uuid )
244-
245- # BFS/DFS from anchor through children links
246- lineage : set [str ] = {anchor }
247- stack = [anchor ]
248- while stack :
249- parent = stack .pop ()
250- for child in children_index .get (parent , ()): # empty tuple default avoids branch
251- if child not in lineage :
252- lineage .add (child )
253- stack .append (child )
254-
255- return lineage
256-
257-
258- def cancel_all_future_plans (
259- enterprise_uuid : str ,
260- reason : str = 'delayed_payment' ,
261- subscription_id_for_logs : str | None = None ,
262- checkout_intent : CheckoutIntent | None = None ,
263- ) -> list [str ]:
140+ def cancel_all_future_plans (checkout_intent ):
264141 """
265142 Deactivate (cancel) all future renewal plans descending from the
266143 anchor plan for this enterprise.
267-
268- Strict contract:
269- - We REQUIRE an anchor plan uuid resolvable from the provided
270- CheckoutIntent.
271- - If no anchor can be resolved, we perform NO cancellations
272- (safety: avoid wrong lineage).
273- - Only descendants (children, grandchildren, etc.) of the anchor
274- are canceled; the anchor/current plan is untouched.
275-
276- Returns list of deactivated descendant plan UUIDs (may be empty).
277144 """
145+ unprocessed_renewals = checkout_intent .renewals .filter (processed_at__isnull = True )
278146 client = LicenseManagerApiClient ()
279- deactivated : list [str ] = []
280- try :
281- anchor_uuid = _get_subscription_plan_uuid_from_checkout_intent (checkout_intent )
282- if not anchor_uuid :
283- logger .warning (
284- (
285- "Skipping future plan cancellation for enterprise %s (subscription %s): "
286- "no anchor SubscriptionPlan UUID could be resolved from CheckoutIntent."
287- ),
288- enterprise_uuid ,
289- subscription_id_for_logs ,
290- )
291- return deactivated
292-
293- all_list = client .list_subscriptions (enterprise_uuid )
294- all_plans = (all_list or {}).get ('results' , [])
147+ deactivated : list [UUID ] = []
295148
296- lineage_set = _build_lineage_from_anchor (str (anchor_uuid ), all_plans )
297- lineage_plans = [p for p in all_plans if str (p .get ('uuid' )) in lineage_set ]
298-
299- logger .debug (
300- (
301- "[cancel_all_future_plans] anchor=%s enterprise=%s subscription=%s "
302- "total_plans=%d lineage_size=%d lineage=%s"
303- ),
304- anchor_uuid ,
305- enterprise_uuid ,
306- subscription_id_for_logs ,
307- len (all_plans ),
308- len (lineage_set ),
309- list (lineage_set ),
310- )
311-
312- current_plan = next ((p for p in lineage_plans if p .get ('is_current' )), None )
313- if not current_plan :
314- logger .warning (
315- (
316- "No current subscription plan found within lineage for enterprise %s "
317- "when canceling future plans (subscription %s)"
318- ),
319- enterprise_uuid ,
320- subscription_id_for_logs ,
321- )
322- return deactivated
323-
324- current_plan_uuid = str (current_plan .get ('uuid' ))
325- future_plan_uuids = [str (uuid ) for uuid in lineage_set if str (uuid ) != current_plan_uuid ]
326-
327- logger .debug (
328- "[cancel_all_future_plans] current_plan=%s future_plan_uuids=%s" ,
329- current_plan_uuid ,
330- future_plan_uuids ,
331- )
332-
333- if not future_plan_uuids :
334- logger .info (
335- (
336- "No future plans (descendants) to deactivate for enterprise %s (current plan %s) "
337- "(subscription %s)"
338- ),
339- enterprise_uuid ,
340- current_plan_uuid ,
341- subscription_id_for_logs ,
342- )
343- return deactivated
344-
345- for future_uuid in future_plan_uuids :
346- try :
347- client .update_subscription_plan (
348- future_uuid ,
349- is_active = False ,
350- change_reason = reason ,
351- )
352- deactivated .append (str (future_uuid ))
353- logger .info (
354- "Deactivated future plan %s for enterprise %s (reason=%s) (subscription %s)" ,
355- future_uuid , enterprise_uuid , reason , subscription_id_for_logs ,
356- )
357- except Exception as exc : # pylint: disable=broad-except
358- logger .exception (
359- "Failed to deactivate future plan %s for enterprise %s (reason=%s): %s" ,
360- future_uuid , enterprise_uuid , reason , exc ,
361- )
362- except Exception as exc : # pylint: disable=broad-except
363- logger .exception (
364- "Unexpected error canceling future plans for enterprise %s (subscription %s): %s" ,
365- enterprise_uuid ,
366- subscription_id_for_logs ,
367- exc ,
149+ for renewal in unprocessed_renewals :
150+ client .update_subscription_plan (
151+ str (renewal .renewed_subscription_plan_uuid ),
152+ is_active = False ,
368153 )
154+ deactivated .append (renewal .renewed_subscription_plan_uuid )
369155
370156 return deactivated
371157
@@ -561,7 +347,6 @@ def subscription_updated(event: stripe.Event) -> None:
561347 )
562348 trial_end = subscription .get ("trial_end" )
563349 if trial_end :
564- handle_trial_cancellation (checkout_intent , checkout_intent_id , subscription .id , trial_end )
565350 logger .info (f"Queuing trial cancellation email for checkout_intent_id={ checkout_intent_id } " )
566351 send_trial_cancellation_email_task .delay (
567352 checkout_intent_id = checkout_intent .id ,
@@ -574,24 +359,9 @@ def subscription_updated(event: stripe.Event) -> None:
574359
575360 # Past due transition
576361 if current_status == "past_due" and prior_status != "past_due" :
577- # Fire billing error email to enterprise admins
578- try :
579- send_billing_error_email_task .delay (checkout_intent_id = checkout_intent .id )
580- except Exception as exc : # pylint: disable=broad-exception-caught
581- logger .exception (
582- "Failed to enqueue billing error email for CheckoutIntent %s: %s" ,
583- checkout_intent .id ,
584- str (exc ),
585- )
586-
587362 enterprise_uuid = checkout_intent .enterprise_uuid
588363 if enterprise_uuid :
589- cancel_all_future_plans (
590- enterprise_uuid = enterprise_uuid ,
591- reason = 'delayed_payment' ,
592- subscription_id_for_logs = subscription .id ,
593- checkout_intent = checkout_intent ,
594- )
364+ cancel_all_future_plans (checkout_intent )
595365 else :
596366 logger .error (
597367 (
@@ -601,6 +371,7 @@ def subscription_updated(event: stripe.Event) -> None:
601371 subscription .id ,
602372 checkout_intent .id ,
603373 )
374+ send_billing_error_email_task .delay (checkout_intent_id = checkout_intent .id )
604375
605376 @on_stripe_event ("customer.subscription.deleted" )
606377 @staticmethod
0 commit comments