Skip to content

Commit 487a2b7

Browse files
committed
feat: persist renewal ids and state
1 parent 1e13816 commit 487a2b7

File tree

7 files changed

+453
-8
lines changed

7 files changed

+453
-8
lines changed

enterprise_access/apps/api/v1/tests/test_provisioning_views.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from enterprise_access.apps.core.tests.factories import UserFactory
2424
from enterprise_access.apps.customer_billing.constants import CheckoutIntentState
2525
from enterprise_access.apps.customer_billing.models import CheckoutIntent
26+
from enterprise_access.apps.customer_billing.tests.factories import StripeEventSummaryFactory
2627
from enterprise_access.apps.provisioning.models import (
2728
GetCreateCustomerStep,
2829
GetCreateEnterpriseAdminUsersStep,
@@ -159,7 +160,7 @@ class TestProvisioningAuth(APITest):
159160
"""
160161
def setUp(self):
161162
super().setUp()
162-
self._create_checkout_intent()
163+
self.checkout_intent = self._create_checkout_intent()
163164

164165
def tearDown(self):
165166
super().tearDown()
@@ -252,6 +253,8 @@ def test_provisioning_create_allowed_for_provisioning_admins(
252253
mock_create_agreement.return_value = DEFAULT_AGREEMENT_RECORD
253254
mock_create_renewal.return_value = EXPECTED_SUBSCRIPTION_PLAN_RENEWAL_RESPONSE
254255

256+
StripeEventSummaryFactory.create(checkout_intent=self.checkout_intent)
257+
255258
request_payload = {**DEFAULT_REQUEST_PAYLOAD}
256259
request_payload['pending_admins'] = [
257260
{
@@ -287,7 +290,7 @@ def setUp(self):
287290
'context': ALL_ACCESS_CONTEXT,
288291
},
289292
])
290-
self._create_checkout_intent()
293+
self.checkout_intent = self._create_checkout_intent()
291294

292295
def _create_checkout_intent(self):
293296
"""Helper to create a checkout intent for testing."""
@@ -349,6 +352,7 @@ def test_get_or_create_customer_and_admins_created(
349352
mock_client.get_enterprise_catalogs.return_value = [DEFAULT_CATALOG_RECORD]
350353
mock_create_agreement.return_value = DEFAULT_AGREEMENT_RECORD
351354
mock_create_renewal.return_value = EXPECTED_SUBSCRIPTION_PLAN_RENEWAL_RESPONSE
355+
StripeEventSummaryFactory.create(checkout_intent=self.checkout_intent)
352356

353357
request_payload = {**DEFAULT_REQUEST_PAYLOAD}
354358
request_payload['pending_admins'] = [
@@ -469,6 +473,7 @@ def test_customer_fetched_admins_fetched_or_created(
469473
mock_license_client = mock_license_manager_client.return_value
470474
mock_license_client.get_customer_agreement.return_value = DEFAULT_AGREEMENT_RECORD
471475
mock_license_client.create_subscription_plan_renewal.return_value = EXPECTED_SUBSCRIPTION_PLAN_RENEWAL_RESPONSE
476+
StripeEventSummaryFactory.create(checkout_intent=self.checkout_intent)
472477

473478
request_payload = {**DEFAULT_REQUEST_PAYLOAD}
474479
request_payload['pending_admins'] = [
@@ -567,6 +572,8 @@ def test_catalog_fetched_or_created(
567572
mock_create_agreement.return_value = DEFAULT_AGREEMENT_RECORD
568573
mock_create_renewal.return_value = EXPECTED_SUBSCRIPTION_PLAN_RENEWAL_RESPONSE
569574

575+
StripeEventSummaryFactory.create(checkout_intent=self.checkout_intent)
576+
570577
request_payload = {**DEFAULT_REQUEST_PAYLOAD}
571578
response = self.client.post(PROVISIONING_CREATE_ENDPOINT, data=request_payload)
572579

@@ -623,6 +630,8 @@ def test_catalog_created_with_generated_title_and_inferred_query_id(
623630
mock_create_agreement.return_value = DEFAULT_AGREEMENT_RECORD
624631
mock_create_renewal.return_value = EXPECTED_SUBSCRIPTION_PLAN_RENEWAL_RESPONSE
625632

633+
StripeEventSummaryFactory.create(checkout_intent=self.checkout_intent)
634+
626635
# Create request payload WITHOUT enterprise_catalog section
627636
request_payload = {**DEFAULT_REQUEST_PAYLOAD}
628637
request_payload.pop('enterprise_catalog')
@@ -690,6 +699,8 @@ def test_customer_agreement_fetched_or_created(
690699

691700
mock_license_client.create_subscription_plan_renewal.return_value = EXPECTED_SUBSCRIPTION_PLAN_RENEWAL_RESPONSE
692701

702+
StripeEventSummaryFactory.create(checkout_intent=self.checkout_intent)
703+
693704
request_payload = {**DEFAULT_REQUEST_PAYLOAD}
694705
if test_data['created_agreement']:
695706
request_payload['customer_agreement'] = {
@@ -748,6 +759,8 @@ def test_new_subscription_plan_created(
748759
mock_license_client.create_subscription_plan.side_effect = [trial_plan_record, first_paid_plan_record]
749760
mock_license_client.create_subscription_plan_renewal.return_value = EXPECTED_SUBSCRIPTION_PLAN_RENEWAL_RESPONSE
750761

762+
StripeEventSummaryFactory.create(checkout_intent=self.checkout_intent)
763+
751764
# Make the provisioning request
752765
response = self.client.post(PROVISIONING_CREATE_ENDPOINT, data=DEFAULT_REQUEST_PAYLOAD)
753766
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
@@ -855,6 +868,8 @@ def test_legacy_single_plan_request_transformation(
855868
legacy_request_payload.pop('first_paid_subscription_plan')
856869
legacy_request_payload['subscription_plan'] = legacy_request_payload.pop('trial_subscription_plan')
857870

871+
StripeEventSummaryFactory.create(checkout_intent=self.checkout_intent)
872+
858873
# Make the provisioning request.
859874
response = self.client.post(PROVISIONING_CREATE_ENDPOINT, data=legacy_request_payload)
860875

@@ -953,6 +968,7 @@ def test_checkout_intent_synchronized_on_success(
953968
Test that a fulfillable checkout intent is linked to workflow and marked as FULFILLED on success.
954969
"""
955970
checkout_intent = self._create_checkout_intent(state=intent_state)
971+
StripeEventSummaryFactory.create(checkout_intent=checkout_intent)
956972
self.assertEqual(checkout_intent.state, intent_state)
957973
self.assertIsNone(checkout_intent.workflow)
958974

@@ -1097,6 +1113,7 @@ def test_checkout_intent_different_slug_ignored(self, mock_lms_api_client, mock_
10971113
"""
10981114
# The checkout intent we expect to be updated.
10991115
main_checkout_intent = self._create_checkout_intent(state=CheckoutIntentState.PAID)
1116+
StripeEventSummaryFactory.create(checkout_intent=main_checkout_intent)
11001117

11011118
# Create a checkout intent with different enterprise slug. Later, test that this is NOT modified.
11021119
different_slug = 'different-enterprise-slug'
@@ -1105,6 +1122,7 @@ def test_checkout_intent_different_slug_ignored(self, mock_lms_api_client, mock_
11051122
state=CheckoutIntentState.PAID,
11061123
enterprise_slug=different_slug,
11071124
)
1125+
StripeEventSummaryFactory.create(checkout_intent=different_checkout_intent)
11081126

11091127
# Setup mocks for successful provisioning
11101128
mock_lms_client = mock_lms_api_client.return_value

enterprise_access/apps/api_client/license_manager_client.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,40 @@ def create_subscription_plan_renewal(
321321
)
322322
raise
323323

324+
def process_subscription_plan_renewal(self, renewal_id: int) -> dict:
325+
"""
326+
Process an existing subscription plan renewal via the license-manager service.
327+
328+
This triggers License Manager to process the renewal from the trial to paid
329+
subscription plan, typically called during the trial-to-paid transition.
330+
331+
Arguments:
332+
renewal_id (int): ID of the subscription plan renewal to process
333+
334+
Returns:
335+
dict: Response from the license manager API
336+
337+
Raises:
338+
APIClientException: If the API call fails
339+
"""
340+
endpoint = f'{self.subscription_plan_renewal_provisioning_endpoint}{renewal_id}/process/'
341+
342+
try:
343+
response = self.client.post(endpoint, timeout=settings.LICENSE_MANAGER_CLIENT_TIMEOUT)
344+
response.raise_for_status()
345+
return response.json()
346+
except requests.exceptions.HTTPError as exc:
347+
logger.exception(
348+
'Failed to process subscription plan renewal %s, response %s, exception: %s',
349+
renewal_id,
350+
safe_error_response_content(exc),
351+
exc,
352+
)
353+
raise APIClientException(
354+
f'Could not process subscription plan renewal {renewal_id}',
355+
exc,
356+
) from exc
357+
324358

325359
class LicenseManagerUserApiClient(BaseUserApiClient):
326360
"""
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"""
2+
Management command to backfill SelfServiceSubscriptionRenewal records from existing provisioning workflows.
3+
"""
4+
from django.core.management.base import BaseCommand
5+
from django.db import transaction
6+
7+
from enterprise_access.apps.customer_billing.models import (
8+
CheckoutIntent,
9+
SelfServiceSubscriptionRenewal,
10+
StripeEventSummary
11+
)
12+
from enterprise_access.apps.provisioning.models import (
13+
GetCreateSubscriptionPlanRenewalStep,
14+
ProvisionNewCustomerWorkflow
15+
)
16+
17+
18+
class Command(BaseCommand):
19+
"""
20+
Command to backfill SelfServiceSubscriptionRenewal records from existing provisioning workflows.
21+
22+
This command finds completed provisioning workflows that have subscription plan renewals
23+
but no corresponding SelfServiceSubscriptionRenewal tracking records, and creates them.
24+
"""
25+
help = 'Backfill SelfServiceSubscriptionRenewal records from existing provisioning workflows'
26+
27+
def add_arguments(self, parser):
28+
parser.add_argument(
29+
'--batch-size',
30+
type=int,
31+
default=100,
32+
help='Number of records to process in each batch (default: 100)',
33+
)
34+
parser.add_argument(
35+
'--dry-run',
36+
action='store_true',
37+
help='Show what would be processed without actually creating records',
38+
)
39+
parser.add_argument(
40+
'--workflow-uuid',
41+
type=str,
42+
help='Only process a specific workflow UUID',
43+
)
44+
45+
def handle(self, *args, **options):
46+
"""
47+
Execute the backfill command.
48+
"""
49+
batch_size = options['batch_size']
50+
dry_run = options['dry_run']
51+
workflow_uuid = options['workflow_uuid']
52+
53+
if dry_run:
54+
self.stdout.write(self.style.WARNING('DRY RUN MODE - No records will be created'))
55+
56+
# Find completed provisioning workflows that have subscription renewals
57+
workflows_queryset = ProvisionNewCustomerWorkflow.objects.filter(
58+
succeeded_at__isnull=False,
59+
).select_related('checkoutintent')
60+
61+
if workflow_uuid:
62+
workflows_queryset = workflows_queryset.filter(uuid=workflow_uuid)
63+
64+
total_workflows = workflows_queryset.count()
65+
self.stdout.write(f'Found {total_workflows} completed provisioning workflows')
66+
67+
created_count = 0
68+
skipped_count = 0
69+
error_count = 0
70+
71+
# Process workflows in batches
72+
for i in range(0, total_workflows, batch_size):
73+
batch_workflows = workflows_queryset[i:i + batch_size]
74+
75+
for workflow in batch_workflows:
76+
try:
77+
result = self._handle_workflow(workflow, dry_run)
78+
if result == 'created':
79+
created_count += 1
80+
elif result == 'skipped':
81+
skipped_count += 1
82+
except Exception as exc: # pylint: disable=broad-exception-caught
83+
error_count += 1
84+
self.stderr.write(
85+
self.style.ERROR(
86+
f'Error processing workflow {workflow.uuid}: {exc}'
87+
)
88+
)
89+
90+
# Progress update
91+
processed = min(i + batch_size, total_workflows)
92+
self.stdout.write(f'Processed {processed}/{total_workflows} workflows...')
93+
94+
# Summary
95+
self.stdout.write(
96+
self.style.SUCCESS(
97+
f'\nBackfill complete! Created: {created_count}, '
98+
f'Skipped: {skipped_count}, Errors: {error_count}'
99+
)
100+
)
101+
102+
def _handle_workflow(self, workflow: ProvisionNewCustomerWorkflow, dry_run: bool) -> str:
103+
"""
104+
Process a single workflow to create missing SelfServiceSubscriptionRenewal records.
105+
106+
Returns:
107+
str: 'created', 'skipped', or raises exception
108+
"""
109+
# Check if this workflow has a linked CheckoutIntent
110+
try:
111+
checkout_intent = workflow.checkoutintent
112+
except CheckoutIntent.DoesNotExist:
113+
return 'skipped'
114+
115+
# Find the renewal step for this workflow
116+
renewal_step = GetCreateSubscriptionPlanRenewalStep.objects.filter(
117+
workflow_record_uuid=workflow.uuid
118+
).first()
119+
120+
if not renewal_step or not renewal_step.output_object:
121+
return 'skipped'
122+
123+
renewal_id = renewal_step.output_object.id
124+
125+
# Check if SelfServiceSubscriptionRenewal already exists
126+
existing_renewal = SelfServiceSubscriptionRenewal.objects.filter(
127+
checkout_intent=checkout_intent,
128+
subscription_plan_renewal_id=renewal_id
129+
).first()
130+
131+
if existing_renewal:
132+
return 'skipped'
133+
134+
if dry_run:
135+
self.stdout.write(
136+
f'Would create SelfServiceSubscriptionRenewal for '
137+
f'checkout_intent {checkout_intent.id}, renewal {renewal_id}'
138+
)
139+
return 'created'
140+
141+
stripe_subscription_id = None
142+
latest_summary = StripeEventSummary.get_latest_for_checkout_intent(
143+
checkout_intent,
144+
stripe_subscription_id__isnull=False,
145+
)
146+
if latest_summary:
147+
stripe_subscription_id = latest_summary.stripe_subscription_id
148+
149+
with transaction.atomic():
150+
SelfServiceSubscriptionRenewal.objects.create(
151+
checkout_intent=checkout_intent,
152+
subscription_plan_renewal_id=renewal_id,
153+
stripe_subscription_id=stripe_subscription_id,
154+
)
155+
156+
self.stdout.write(
157+
f'Created SelfServiceSubscriptionRenewal for '
158+
f'checkout_intent {checkout_intent.id}, renewal {renewal_id}'
159+
)
160+
return 'created'

enterprise_access/apps/customer_billing/models.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -788,7 +788,7 @@ class StripeEventData(TimeStampedModel):
788788
)
789789

790790
def __str__(self):
791-
return f"<StripeEventData id={self.event_id}, event_type={self.event_type}>"
791+
return f"id={self.event_id}, event_type={self.event_type}"
792792

793793
def mark_as_handled(self):
794794
"""Mark this event as handled by setting handled_at to now."""
@@ -1073,6 +1073,22 @@ def _timestamp_to_datetime(timestamp):
10731073
return _datetime_from_timestamp(timestamp)
10741074
return None
10751075

1076+
@classmethod
1077+
def get_latest_for_checkout_intent(cls, checkout_intent, **filter_kwargs):
1078+
"""
1079+
Helper to get latest summary for the given CheckoutIntent.
1080+
"""
1081+
result = StripeEventSummary.objects.filter(
1082+
checkout_intent=checkout_intent,
1083+
**filter_kwargs,
1084+
).order_by('-stripe_event_created_at').first()
1085+
1086+
if result:
1087+
logger.info('Found Stripe event summary %s for checkout intent %s', result, checkout_intent.uuid)
1088+
else:
1089+
logger.warning('No Stripe event summary for checkout intent %s', checkout_intent.uuid)
1090+
return result
1091+
10761092
@classmethod
10771093
def get_latest_invoice_paid(cls, invoice_id):
10781094
"""

0 commit comments

Comments
 (0)