Skip to content

Commit a1c81b0

Browse files
committed
feat: use SSSRenewal models to understand which future plans to deactivate
ENT-10761 ENT-11197
1 parent d2fbf85 commit a1c81b0

File tree

6 files changed

+63
-321
lines changed

6 files changed

+63
-321
lines changed

enterprise_access/apps/api_client/license_manager_client.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,11 @@ def update_subscription_plan(self, subscription_uuid, salesforce_opportunity_lin
7070
"""
7171
endpoint = f"{self.subscription_provisioning_endpoint}{subscription_uuid}/"
7272
payload = {
73-
'salesforce_opportunity_line_item': salesforce_opportunity_line_item,
7473
'change_reason': OTHER_SUBSCRIPTION_CHANGE_REASON,
7574
}
7675
payload.update(kwargs)
76+
if salesforce_opportunity_line_item:
77+
payload['salesforce_opportunity_line_item'] = salesforce_opportunity_line_item
7778

7879
try:
7980
response = self.client.patch(

enterprise_access/apps/api_client/tests/test_license_manager_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ def test_create_subscription_plan(self, mock_oauth_client):
178178
)
179179

180180
@mock.patch('enterprise_access.apps.api_client.base_oauth.OAuthAPIClient', autospec=True)
181-
def test_update_subscription_plan(self, mock_oauth_client):
181+
def test_update_subscription_plan_oli(self, mock_oauth_client):
182182
mock_patch = mock_oauth_client.return_value.patch
183183
subs_plan_uuid = uuid.uuid4()
184184
new_oli_value = '1234512345'

enterprise_access/apps/customer_billing/stripe_event_handlers.py

Lines changed: 11 additions & 240 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
import logging
55
from collections.abc import Callable
66
from functools import wraps
7+
from uuid import UUID
78

89
import stripe
9-
from django.apps import apps
1010

1111
from enterprise_access.apps.api_client.license_manager_client import LicenseManagerApiClient
1212
from 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

enterprise_access/apps/customer_billing/tasks.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212

1313
from enterprise_access.apps.api_client.braze_client import BrazeApiClient
1414
from enterprise_access.apps.api_client.lms_client import LmsApiClient
15-
from enterprise_access.apps.customer_billing.api import create_stripe_billing_portal_session
1615
from enterprise_access.apps.customer_billing.constants import BRAZE_TIMESTAMP_FORMAT
1716
from enterprise_access.apps.customer_billing.models import CheckoutIntent, StripeEventSummary
1817
from enterprise_access.apps.customer_billing.stripe_api import get_stripe_subscription, get_stripe_trialing_subscription
@@ -256,7 +255,7 @@ def send_billing_error_email_task(checkout_intent_id: int):
256255
)
257256

258257
braze_trigger_properties = {
259-
"restart_subscription_url": portal_url,
258+
"restart_subscription_url": f'{settings.ENTERPRISE_ADMIN_PORTAL_URL}/{enterprise_slug}',
260259
}
261260

262261
send_campaign_message(
@@ -408,7 +407,7 @@ def send_trial_ending_reminder_email_task(checkout_intent_id):
408407
def send_trial_end_and_subscription_started_email_task(
409408
subscription_id: str,
410409
checkout_intent_id: int,
411-
): # pylint: disable=too-many-statements
410+
):
412411
"""
413412
Send an email to all enterprise admins notifying about trial end and subscription start.
414413
@@ -475,6 +474,7 @@ def send_trial_end_and_subscription_started_email_task(
475474
)
476475

477476

477+
@shared_task(base=LoggedTaskWithRetry)
478478
def send_payment_receipt_email(
479479
invoice_data,
480480
subscription_data,

enterprise_access/apps/customer_billing/tests/factories.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@
1111

1212
from enterprise_access.apps.core.tests.factories import UserFactory
1313
from enterprise_access.apps.customer_billing.constants import CheckoutIntentState
14-
from enterprise_access.apps.customer_billing.models import CheckoutIntent, StripeEventData, StripeEventSummary
14+
from enterprise_access.apps.customer_billing.models import (
15+
CheckoutIntent,
16+
SelfServiceSubscriptionRenewal,
17+
StripeEventData,
18+
StripeEventSummary
19+
)
1520

1621
FAKER = Faker()
1722

@@ -151,3 +156,16 @@ class Meta:
151156

152157
# Stripe identifiers
153158
stripe_subscription_id = factory.LazyFunction(lambda: f'sub_{FAKER.bothify("?" * 24)}')
159+
160+
161+
class SelfServiceSubscriptionRenewalFactory(DjangoModelFactory):
162+
"""
163+
Factory for creating StripeEventSummary instances for testing.
164+
"""
165+
class Meta:
166+
model = SelfServiceSubscriptionRenewal
167+
168+
checkout_intent = factory.SubFactory(CheckoutIntentFactory)
169+
subscription_plan_renewal_id = factory.Faker('random_int', min=1, max=10000)
170+
stripe_event_data = factory.SubFactory(StripeEventDataFactory)
171+
stripe_subscription_id = factory.LazyFunction(lambda: f'sub_{FAKER.bothify("?" * 24)}')

0 commit comments

Comments
 (0)