diff --git a/enterprise_access/apps/api/v1/views/constants.py b/enterprise_access/apps/api/v1/views/constants.py index 38cc3db82..1bc7e19bf 100644 --- a/enterprise_access/apps/api/v1/views/constants.py +++ b/enterprise_access/apps/api/v1/views/constants.py @@ -70,20 +70,20 @@ response_only=True, ), OpenApiExample( - 'Error State Example', + 'Error State Example - Provisioning Failed', summary='CheckoutIntent in error state', - description='Failed during payment or provisioning', + description='Failed during provisioning after successful payment', value={ 'uuid': '123e4567-e89b-12d3-a456-426614174003', 'user': 1, - 'state': CheckoutIntentState.ERRORED_STRIPE_CHECKOUT, + 'state': CheckoutIntentState.ERRORED_PROVISIONING, 'stripe_checkout_session_id': 'cs_test_d4e5f6g7h8i9j0k1l2m3', 'enterprise_customer_uuid': '987e6543-e21b-12d3-a456-426614174000', 'created': '2025-01-15T10:30:00.000Z', 'modified': '2025-01-15T10:35:00.000Z', - 'error_message': 'Payment failed: Card declined', + 'error_message': 'Provisioning failed: API timeout', 'metadata': { - 'failure_reason': 'card_declined', + 'failure_reason': 'api_timeout', 'attempt_count': 1 } }, @@ -103,11 +103,11 @@ ), OpenApiExample( 'Update to Error State with Message', - summary='Transition to error state', - description='Updates state to error with descriptive message', + summary='Transition to provisioning error state', + description='Updates state to error with descriptive message after provisioning failure', value={ - 'state': CheckoutIntentState.ERRORED_STRIPE_CHECKOUT, - 'error_message': 'Payment failed: Insufficient funds' + 'state': CheckoutIntentState.ERRORED_PROVISIONING, + 'error_message': 'Provisioning failed: Salesforce API error' }, request_only=True, ), diff --git a/enterprise_access/apps/customer_billing/constants.py b/enterprise_access/apps/customer_billing/constants.py index 3c50c031a..b0a85ca07 100644 --- a/enterprise_access/apps/customer_billing/constants.py +++ b/enterprise_access/apps/customer_billing/constants.py @@ -69,8 +69,9 @@ class CheckoutIntentState(StrEnum): CREATED = 'created' PAID = 'paid' FULFILLED = 'fulfilled' - ERRORED_STRIPE_CHECKOUT = 'errored_stripe_checkout' ERRORED_PROVISIONING = 'errored_provisioning' + ERRORED_FULFILLMENT_STALLED = 'errored_fulfillment_stalled' + ERRORED_BACKOFFICE = 'errored_backoffice' EXPIRED = 'expired' @@ -84,18 +85,23 @@ class CheckoutIntentSegmentEvents: ALLOWED_CHECKOUT_INTENT_STATE_TRANSITIONS = { CheckoutIntentState.CREATED: [ CheckoutIntentState.PAID, - CheckoutIntentState.ERRORED_STRIPE_CHECKOUT, CheckoutIntentState.EXPIRED, ], CheckoutIntentState.PAID: [ CheckoutIntentState.FULFILLED, CheckoutIntentState.ERRORED_PROVISIONING, - ], - CheckoutIntentState.ERRORED_STRIPE_CHECKOUT: [ - CheckoutIntentState.PAID, + CheckoutIntentState.ERRORED_FULFILLMENT_STALLED, ], CheckoutIntentState.ERRORED_PROVISIONING: [ CheckoutIntentState.FULFILLED, + CheckoutIntentState.ERRORED_BACKOFFICE, + ], + CheckoutIntentState.ERRORED_FULFILLMENT_STALLED: [ + CheckoutIntentState.FULFILLED, + CheckoutIntentState.ERRORED_BACKOFFICE, + ], + CheckoutIntentState.ERRORED_BACKOFFICE: [ + CheckoutIntentState.FULFILLED, ], CheckoutIntentState.EXPIRED: [ CheckoutIntentState.CREATED, diff --git a/enterprise_access/apps/customer_billing/management/commands/mark_stalled_checkout_intents.py b/enterprise_access/apps/customer_billing/management/commands/mark_stalled_checkout_intents.py new file mode 100644 index 000000000..572eea6b0 --- /dev/null +++ b/enterprise_access/apps/customer_billing/management/commands/mark_stalled_checkout_intents.py @@ -0,0 +1,151 @@ +""" +Management command to detect and mark CheckoutIntent records that have been +stuck in 'paid' state for too long, indicating stalled fulfillment. +""" +import logging +from datetime import timedelta + +from django.core.management.base import BaseCommand +from django.utils import timezone + +from enterprise_access.apps.customer_billing.constants import CheckoutIntentState +from enterprise_access.apps.customer_billing.models import CheckoutIntent + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Command to detect and transition CheckoutIntent records that have been + stuck in 'paid' state for a configurable duration. + + When a CheckoutIntent transitions to 'paid' state but fulfillment fails + without proper error handling, the intent can remain stuck indefinitely. + This command detects such cases and transitions them to + 'errored_fulfillment_stalled' to trigger alerts and display error UI. + + Usage: + ./manage.py mark_stalled_checkout_intents + ./manage.py mark_stalled_checkout_intents --threshold-seconds=300 + ./manage.py mark_stalled_checkout_intents --dry-run + """ + + help = ( + 'Detect and mark CheckoutIntent records stuck in paid state as ' + 'errored_fulfillment_stalled' + ) + + def add_arguments(self, parser): + """ + Add command-line arguments. + """ + parser.add_argument( + '--threshold-seconds', + type=int, + default=180, + help=( + 'Number of seconds a CheckoutIntent must be in paid state ' + 'before being considered stalled. Default: 180 (3 minutes). ' + 'This accounts for exponential backoff in Salesforce API calls ' + 'and the provisioning workflow. Should be long enough to avoid ' + 'false positives but short enough for timely error display.' + ), + ) + parser.add_argument( + '--dry-run', + action='store_true', + dest='dry_run', + default=False, + help='Show what would be updated without actually updating records.', + ) + + def handle(self, *args, **options): + """ + Find and mark stalled CheckoutIntent records. + """ + threshold_seconds = options['threshold_seconds'] + dry_run = options['dry_run'] + + mode_label = '[DRY RUN] ' if dry_run else '' + + self.stdout.write( + f'{mode_label}Checking for CheckoutIntent records stuck in paid state ' + f'for more than {threshold_seconds} seconds...' + ) + logger.info( + '%sStarting mark_stalled_checkout_intents command with threshold=%s seconds', + mode_label, + threshold_seconds, + ) + + if dry_run: + # Query stalled intents without updating + threshold_time = timezone.now() - timedelta(seconds=threshold_seconds) + stalled_intents = CheckoutIntent.objects.filter( + state=CheckoutIntentState.PAID, + modified__lte=threshold_time, + ).order_by('modified') + + count = stalled_intents.count() + + if count == 0: + self.stdout.write( + self.style.SUCCESS( + f'{mode_label}No stalled CheckoutIntent records found.' + ) + ) + logger.info('%sNo stalled CheckoutIntent records found', mode_label) + else: + self.stdout.write( + self.style.WARNING( + f'{mode_label}Found {count} stalled CheckoutIntent record(s):' + ) + ) + for intent in stalled_intents: + time_stalled = (timezone.now() - intent.modified).total_seconds() + self.stdout.write( + f' - ID: {intent.pk}, ' + f'User: {intent.user.email if intent.user else "N/A"}, ' + f'Enterprise: {intent.enterprise_name or intent.enterprise_slug}, ' + f'Time stalled: {int(time_stalled)}s, ' + f'Last modified: {intent.modified.isoformat()}' + ) + logger.info( + '%sWould mark CheckoutIntent %s as stalled (stalled for %s seconds)', + mode_label, + intent.pk, + int(time_stalled), + ) + else: + # Actually update records + updated_count, updated_uuids = CheckoutIntent.mark_stalled_fulfillment_intents( + stalled_threshold_seconds=threshold_seconds + ) + + if updated_count == 0: + self.stdout.write( + self.style.SUCCESS( + 'No stalled CheckoutIntent records found.' + ) + ) + logger.info('No stalled CheckoutIntent records found') + else: + self.stdout.write( + self.style.SUCCESS( + f'Successfully marked {updated_count} CheckoutIntent record(s) ' + f'as errored_fulfillment_stalled' + ) + ) + for intent_id in updated_uuids: + self.stdout.write(f' - Updated CheckoutIntent: {intent_id}') + logger.info( + 'Marked CheckoutIntent %s as errored_fulfillment_stalled', + intent_id, + ) + + self.stdout.write( + self.style.SUCCESS( + f'{mode_label}Command completed successfully' + ) + ) + logger.info('%smark_stalled_checkout_intents command completed', mode_label) diff --git a/enterprise_access/apps/customer_billing/management/commands/tests/test_mark_stalled_checkout_intents.py b/enterprise_access/apps/customer_billing/management/commands/tests/test_mark_stalled_checkout_intents.py new file mode 100644 index 000000000..1f3744119 --- /dev/null +++ b/enterprise_access/apps/customer_billing/management/commands/tests/test_mark_stalled_checkout_intents.py @@ -0,0 +1,322 @@ +""" +Tests for the mark_stalled_checkout_intents management command. +""" +from datetime import timedelta +from io import StringIO +from unittest import mock + +from django.core.management import call_command +from django.test import TestCase +from django.utils import timezone + +from enterprise_access.apps.core.tests.factories import UserFactory +from enterprise_access.apps.customer_billing.constants import CheckoutIntentState +from enterprise_access.apps.customer_billing.models import CheckoutIntent + + +class MarkStalledCheckoutIntentsCommandTests(TestCase): + """Tests for the mark_stalled_checkout_intents management command.""" + + def setUp(self): + self.now = timezone.now() + self.user_a = UserFactory() + self.user_b = UserFactory() + self.user_c = UserFactory() + self.user_d = UserFactory() + + # Create a stalled paid intent (5 minutes old) + self.stalled_intent = CheckoutIntent.objects.create( + user=self.user_a, + state=CheckoutIntentState.PAID, + enterprise_name="Stalled Enterprise", + enterprise_slug="stalled-enterprise", + quantity=10, + expires_at=self.now + timedelta(hours=1), + country='US', + terms_metadata={'version': '1.0'} + ) + # Manually set modified time to 5 minutes ago + CheckoutIntent.objects.filter(pk=self.stalled_intent.pk).update( + modified=self.now - timedelta(seconds=300) + ) + self.stalled_intent.refresh_from_db() + + # Create a recent paid intent (1 minute old, not stalled) + self.recent_paid_intent = CheckoutIntent.objects.create( + user=self.user_b, + state=CheckoutIntentState.PAID, + enterprise_name="Recent Enterprise", + enterprise_slug="recent-enterprise", + quantity=5, + expires_at=self.now + timedelta(hours=1), + country='CA', + terms_metadata={'version': '1.1'} + ) + CheckoutIntent.objects.filter(pk=self.recent_paid_intent.pk).update( + modified=self.now - timedelta(seconds=60) + ) + self.recent_paid_intent.refresh_from_db() + + # Create a fulfilled intent (should be ignored) + self.fulfilled_intent = CheckoutIntent.objects.create( + user=self.user_c, + state=CheckoutIntentState.FULFILLED, + enterprise_name="Fulfilled Enterprise", + enterprise_slug="fulfilled-enterprise", + quantity=15, + expires_at=self.now + timedelta(hours=1), + country='GB', + terms_metadata={'version': '2.0'} + ) + CheckoutIntent.objects.filter(pk=self.fulfilled_intent.pk).update( + modified=self.now - timedelta(seconds=300) + ) + self.fulfilled_intent.refresh_from_db() + + # Create a created state intent (should be ignored) + self.created_intent = CheckoutIntent.objects.create( + user=self.user_d, + state=CheckoutIntentState.CREATED, + enterprise_name="Created Enterprise", + enterprise_slug="created-enterprise", + quantity=20, + expires_at=self.now + timedelta(hours=1), + country='DE', + terms_metadata={'version': '3.0'} + ) + CheckoutIntent.objects.filter(pk=self.created_intent.pk).update( + modified=self.now - timedelta(seconds=300) + ) + self.created_intent.refresh_from_db() + + def test_no_stalled_intents(self): + """Test command when no stalled intents exist.""" + # Mark the stalled intent as recent + CheckoutIntent.objects.filter(pk=self.stalled_intent.pk).update( + modified=self.now - timedelta(seconds=60) + ) + + out = StringIO() + call_command('mark_stalled_checkout_intents', stdout=out) + output = out.getvalue() + + self.assertIn('No stalled CheckoutIntent records found', output) + + def test_marks_stalled_intent(self): + """Test that stalled intent is properly marked.""" + out = StringIO() + call_command('mark_stalled_checkout_intents', stdout=out) + output = out.getvalue() + + self.assertIn('Successfully marked 1 CheckoutIntent', output) + self.assertIn(str(self.stalled_intent.pk), output) + + # Verify state transition + self.stalled_intent.refresh_from_db() + self.assertEqual( + self.stalled_intent.state, + CheckoutIntentState.ERRORED_FULFILLMENT_STALLED + ) + self.assertIsNotNone(self.stalled_intent.last_provisioning_error) + self.assertIn('stalled', self.stalled_intent.last_provisioning_error.lower()) + + # Verify other intents not affected + self.recent_paid_intent.refresh_from_db() + self.assertEqual(self.recent_paid_intent.state, CheckoutIntentState.PAID) + + self.fulfilled_intent.refresh_from_db() + self.assertEqual(self.fulfilled_intent.state, CheckoutIntentState.FULFILLED) + + self.created_intent.refresh_from_db() + self.assertEqual(self.created_intent.state, CheckoutIntentState.CREATED) + + def test_custom_threshold(self): + """Test command with custom threshold.""" + # Mark the stalled intent from setUp as fulfilled so it doesn't interfere + self.stalled_intent.state = CheckoutIntentState.FULFILLED + self.stalled_intent.save() + + # Create intent that's 4 minutes old + user = UserFactory() + intent = CheckoutIntent.objects.create( + user=user, + state=CheckoutIntentState.PAID, + enterprise_name="Test Enterprise", + enterprise_slug="test-enterprise", + quantity=10, + expires_at=self.now + timedelta(hours=1), + country='US', + ) + CheckoutIntent.objects.filter(pk=intent.pk).update( + modified=self.now - timedelta(seconds=240) + ) + intent.refresh_from_db() + + # Should NOT be marked with 5-minute (300s) threshold + out = StringIO() + call_command('mark_stalled_checkout_intents', threshold_seconds=300, stdout=out) + output = out.getvalue() + self.assertIn('No stalled CheckoutIntent records found', output) + + intent.refresh_from_db() + self.assertEqual(intent.state, CheckoutIntentState.PAID) + + # SHOULD be marked with 3-minute (180s) threshold + out = StringIO() + call_command('mark_stalled_checkout_intents', threshold_seconds=180, stdout=out) + output = out.getvalue() + self.assertIn('Successfully marked', output) + + intent.refresh_from_db() + self.assertEqual( + intent.state, + CheckoutIntentState.ERRORED_FULFILLMENT_STALLED + ) + + def test_dry_run(self): + """Test that dry-run doesn't actually update records.""" + out = StringIO() + call_command('mark_stalled_checkout_intents', dry_run=True, stdout=out) + output = out.getvalue() + + self.assertIn('[DRY RUN]', output) + self.assertIn('Found 1 stalled CheckoutIntent', output) + self.assertIn(str(self.stalled_intent.pk), output) + self.assertIn('Stalled Enterprise', output) + + # Verify state NOT changed + self.stalled_intent.refresh_from_db() + self.assertEqual(self.stalled_intent.state, CheckoutIntentState.PAID) + + def test_ignores_non_paid_states(self): + """Test that only PAID state intents are considered.""" + # Update all intents to non-paid states + CheckoutIntent.objects.all().update(state=CheckoutIntentState.CREATED) + + out = StringIO() + call_command('mark_stalled_checkout_intents', stdout=out) + output = out.getvalue() + + self.assertIn('No stalled CheckoutIntent records found', output) + + def test_multiple_stalled_intents(self): + """Test marking multiple stalled intents.""" + # Create 2 more stalled intents + user_e = UserFactory() + user_f = UserFactory() + + intent_2 = CheckoutIntent.objects.create( + user=user_e, + state=CheckoutIntentState.PAID, + enterprise_name="Stalled 2", + enterprise_slug="stalled-2", + quantity=10, + expires_at=self.now + timedelta(hours=1), + country='FR', + ) + CheckoutIntent.objects.filter(pk=intent_2.pk).update( + modified=self.now - timedelta(seconds=400) + ) + + intent_3 = CheckoutIntent.objects.create( + user=user_f, + state=CheckoutIntentState.PAID, + enterprise_name="Stalled 3", + enterprise_slug="stalled-3", + quantity=10, + expires_at=self.now + timedelta(hours=1), + country='IT', + ) + CheckoutIntent.objects.filter(pk=intent_3.pk).update( + modified=self.now - timedelta(seconds=500) + ) + + out = StringIO() + call_command('mark_stalled_checkout_intents', stdout=out) + output = out.getvalue() + + self.assertIn('Successfully marked 3 CheckoutIntent', output) + + # Verify all were updated + for intent in [self.stalled_intent, intent_2, intent_3]: + intent.refresh_from_db() + self.assertEqual( + intent.state, + CheckoutIntentState.ERRORED_FULFILLMENT_STALLED + ) + + def test_handles_partial_failures(self): + """Test that failure on one intent doesn't stop processing others.""" + # Create a second stalled intent + user = UserFactory() + intent_2 = CheckoutIntent.objects.create( + user=user, + state=CheckoutIntentState.PAID, + enterprise_name="Stalled 2", + enterprise_slug="stalled-2", + quantity=10, + expires_at=self.now + timedelta(hours=1), + country='FR', + ) + CheckoutIntent.objects.filter(pk=intent_2.pk).update( + modified=self.now - timedelta(seconds=300) + ) + + # Mock mark_fulfillment_stalled to fail on first call, succeed on second + with mock.patch.object(CheckoutIntent, 'mark_fulfillment_stalled') as mock_mark: + mock_mark.side_effect = [Exception('Test error'), None] + + out = StringIO() + call_command('mark_stalled_checkout_intents', stdout=out) + output = out.getvalue() + + # Command should complete despite error + self.assertIn('completed', output.lower()) + + def test_error_message_content(self): + """Test that error message contains useful information.""" + out = StringIO() + call_command('mark_stalled_checkout_intents', stdout=out) + + self.stalled_intent.refresh_from_db() + + # Check error message contains key information + error_msg = self.stalled_intent.last_provisioning_error + self.assertIn('stalled', error_msg.lower()) + self.assertIn('threshold', error_msg.lower()) + self.assertIn('180', error_msg) # Default threshold + self.assertIn('seconds', error_msg.lower()) + + def test_dry_run_shows_details(self): + """Test that dry run shows detailed information about stalled intents.""" + out = StringIO() + call_command('mark_stalled_checkout_intents', dry_run=True, stdout=out) + output = out.getvalue() + + # Check that detailed information is shown + self.assertIn(str(self.stalled_intent.pk), output) + self.assertIn(self.stalled_intent.user.email, output) + self.assertIn(self.stalled_intent.enterprise_name, output) + self.assertIn('Time stalled:', output) + self.assertIn('Last modified:', output) + + def test_command_with_zero_threshold(self): + """Test command with zero threshold marks all paid intents.""" + out = StringIO() + call_command('mark_stalled_checkout_intents', threshold_seconds=0, stdout=out) + output = out.getvalue() + + # Both paid intents should be marked + self.assertIn('Successfully marked 2 CheckoutIntent', output) + + self.stalled_intent.refresh_from_db() + self.assertEqual( + self.stalled_intent.state, + CheckoutIntentState.ERRORED_FULFILLMENT_STALLED + ) + + self.recent_paid_intent.refresh_from_db() + self.assertEqual( + self.recent_paid_intent.state, + CheckoutIntentState.ERRORED_FULFILLMENT_STALLED + ) diff --git a/enterprise_access/apps/customer_billing/migrations/0009_add_fulfillment_stalled_states.py b/enterprise_access/apps/customer_billing/migrations/0009_add_fulfillment_stalled_states.py new file mode 100644 index 000000000..99bd1bafc --- /dev/null +++ b/enterprise_access/apps/customer_billing/migrations/0009_add_fulfillment_stalled_states.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.25 on 2025-10-29 13:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('customer_billing', '0008_stripe_event_model'), + ] + + operations = [ + migrations.AlterField( + model_name='checkoutintent', + name='state', + field=models.CharField(choices=[('created', 'Created'), ('paid', 'Paid'), ('fulfilled', 'Fulfilled'), ('errored_stripe_checkout', 'Errored (Stripe Checkout)'), ('errored_provisioning', 'Errored (Provisioning)'), ('errored_fulfillment_stalled', 'Errored (Fulfillment Stalled)'), ('errored_backoffice', 'Errored (Backoffice)'), ('expired', 'Expired')], default='created', max_length=255), + ), + migrations.AlterField( + model_name='historicalcheckoutintent', + name='state', + field=models.CharField(choices=[('created', 'Created'), ('paid', 'Paid'), ('fulfilled', 'Fulfilled'), ('errored_stripe_checkout', 'Errored (Stripe Checkout)'), ('errored_provisioning', 'Errored (Provisioning)'), ('errored_fulfillment_stalled', 'Errored (Fulfillment Stalled)'), ('errored_backoffice', 'Errored (Backoffice)'), ('expired', 'Expired')], default='created', max_length=255), + ), + ] diff --git a/enterprise_access/apps/customer_billing/migrations/0017_merge_20251030_1157.py b/enterprise_access/apps/customer_billing/migrations/0017_merge_20251030_1157.py new file mode 100644 index 000000000..9847c75c7 --- /dev/null +++ b/enterprise_access/apps/customer_billing/migrations/0017_merge_20251030_1157.py @@ -0,0 +1,14 @@ +# Generated by Django 4.2.24 on 2025-10-30 11:57 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('customer_billing', '0009_add_fulfillment_stalled_states'), + ('customer_billing', '0016_alter_checkoutintent_uuid_and_more'), + ] + + operations = [ + ] diff --git a/enterprise_access/apps/customer_billing/migrations/0018_remove_errored_stripe_checkout_state.py b/enterprise_access/apps/customer_billing/migrations/0018_remove_errored_stripe_checkout_state.py new file mode 100644 index 000000000..015617500 --- /dev/null +++ b/enterprise_access/apps/customer_billing/migrations/0018_remove_errored_stripe_checkout_state.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.24 on 2025-10-30 11:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('customer_billing', '0017_merge_20251030_1157'), + ] + + operations = [ + migrations.AlterField( + model_name='checkoutintent', + name='state', + field=models.CharField(choices=[('created', 'Created'), ('paid', 'Paid'), ('fulfilled', 'Fulfilled'), ('errored_provisioning', 'Errored (Provisioning)'), ('errored_fulfillment_stalled', 'Errored (Fulfillment Stalled)'), ('errored_backoffice', 'Errored (Backoffice)'), ('expired', 'Expired')], db_index=True, default='created', max_length=255), + ), + migrations.AlterField( + model_name='historicalcheckoutintent', + name='state', + field=models.CharField(choices=[('created', 'Created'), ('paid', 'Paid'), ('fulfilled', 'Fulfilled'), ('errored_provisioning', 'Errored (Provisioning)'), ('errored_fulfillment_stalled', 'Errored (Fulfillment Stalled)'), ('errored_backoffice', 'Errored (Backoffice)'), ('expired', 'Expired')], db_index=True, default='created', max_length=255), + ), + ] diff --git a/enterprise_access/apps/customer_billing/models.py b/enterprise_access/apps/customer_billing/models.py index 46d3bd366..a50d03272 100644 --- a/enterprise_access/apps/customer_billing/models.py +++ b/enterprise_access/apps/customer_billing/models.py @@ -54,8 +54,10 @@ class CheckoutIntent(TimeStampedModel): The model follows a state machine pattern with these key transitions: - CREATED → PAID → FULFILLED (happy path) - - CREATED → ERRORED_STRIPE_CHECKOUT (payment failures) - - PAID → ERRORED_PROVISIONING (provisioning failures) + - PAID → ERRORED_PROVISIONING (provisioning failures, can retry) + - PAID → ERRORED_FULFILLMENT_STALLED (stuck in paid state, needs investigation) + - ERRORED_PROVISIONING → ERRORED_BACKOFFICE (needs manual intervention) + - ERRORED_FULFILLMENT_STALLED → ERRORED_BACKOFFICE (escalation) - CREATED → EXPIRED (timeout) Example usage: @@ -84,18 +86,27 @@ class StateChoices(models.TextChoices): CREATED = (CheckoutIntentState.CREATED, 'Created') PAID = (CheckoutIntentState.PAID, 'Paid') FULFILLED = (CheckoutIntentState.FULFILLED, 'Fulfilled') - ERRORED_STRIPE_CHECKOUT = (CheckoutIntentState.ERRORED_STRIPE_CHECKOUT, 'Errored (Stripe Checkout)') ERRORED_PROVISIONING = (CheckoutIntentState.ERRORED_PROVISIONING, 'Errored (Provisioning)') + ERRORED_FULFILLMENT_STALLED = ( + CheckoutIntentState.ERRORED_FULFILLMENT_STALLED, + 'Errored (Fulfillment Stalled)' + ) + ERRORED_BACKOFFICE = (CheckoutIntentState.ERRORED_BACKOFFICE, 'Errored (Backoffice)') EXPIRED = (CheckoutIntentState.EXPIRED, 'Expired') SUCCESS_STATES = {CheckoutIntentState.PAID, CheckoutIntentState.FULFILLED} - FAILURE_STATES = {CheckoutIntentState.ERRORED_STRIPE_CHECKOUT, CheckoutIntentState.ERRORED_PROVISIONING} + FAILURE_STATES = { + CheckoutIntentState.ERRORED_PROVISIONING, + CheckoutIntentState.ERRORED_FULFILLMENT_STALLED, + CheckoutIntentState.ERRORED_BACKOFFICE, + } NON_EXPIRED_STATES = { CheckoutIntentState.CREATED, CheckoutIntentState.PAID, CheckoutIntentState.FULFILLED, - CheckoutIntentState.ERRORED_STRIPE_CHECKOUT, CheckoutIntentState.ERRORED_PROVISIONING, + CheckoutIntentState.ERRORED_FULFILLMENT_STALLED, + CheckoutIntentState.ERRORED_BACKOFFICE, } FULFILLABLE_STATES = { CheckoutIntentState.PAID, @@ -249,20 +260,6 @@ def mark_as_fulfilled(self, workflow=None): logger.info(f'CheckoutIntent {self} marked as {CheckoutIntentState.FULFILLED}.') return self - def mark_checkout_error(self, error_message): - """Record a checkout error.""" - if not self.is_valid_state_transition( - CheckoutIntentState(self.state), - CheckoutIntentState.ERRORED_STRIPE_CHECKOUT, - ): - raise ValueError(f"Cannot transition from {self.state} to {CheckoutIntentState.ERRORED_STRIPE_CHECKOUT}.") - - self.state = CheckoutIntentState.ERRORED_STRIPE_CHECKOUT - self.last_checkout_error = error_message - self.save(update_fields=['state', 'last_checkout_error', 'modified']) - logger.info(f'CheckoutIntent {self} marked as {CheckoutIntentState.ERRORED_STRIPE_CHECKOUT}.') - return self - def mark_provisioning_error(self, error_message, workflow=None): """Record a provisioning error.""" if not self.is_valid_state_transition( @@ -279,6 +276,43 @@ def mark_provisioning_error(self, error_message, workflow=None): logger.info(f'CheckoutIntent {self} marked as {CheckoutIntentState.ERRORED_PROVISIONING}.') return self + def mark_fulfillment_stalled(self, error_message=None): + """ + Mark the intent as having stalled fulfillment. + + This is called when a CheckoutIntent has been in 'paid' state for too long, + indicating that the provisioning workflow likely failed without proper error handling. + + Args: + error_message (str): Optional descriptive error message + """ + if not self.is_valid_state_transition( + CheckoutIntentState(self.state), + CheckoutIntentState.ERRORED_FULFILLMENT_STALLED, + ): + raise ValueError( + f'Cannot transition from {self.state} to {CheckoutIntentState.ERRORED_FULFILLMENT_STALLED}.' + ) + + self.state = CheckoutIntentState.ERRORED_FULFILLMENT_STALLED + if error_message: + self.last_provisioning_error = error_message + else: + self.last_provisioning_error = ( + f'Fulfillment has been stalled since {self.modified.isoformat()}. ' + f'The provisioning workflow may have failed without proper error handling.' + ) + + self.save(update_fields=['state', 'last_provisioning_error', 'modified']) + logger.warning( + 'CheckoutIntent %s marked as ERRORED_FULFILLMENT_STALLED. ' + 'Last modified: %s, Error: %s', + self.pk, + self.modified, + self.last_provisioning_error, + ) + return self + @property def admin_portal_url(self): if self.state == CheckoutIntentState.FULFILLED: @@ -299,6 +333,53 @@ def cleanup_expired(cls): expired_intent_records, cls, ['state', 'modified'], batch_size=100, ) + @classmethod + def mark_stalled_fulfillment_intents(cls, stalled_threshold_seconds=180): + """ + Find all CheckoutIntent records stuck in 'paid' state and transition them + to 'errored_fulfillment_stalled'. + + Args: + stalled_threshold_seconds (int): Number of seconds after which a 'paid' + CheckoutIntent is considered stalled. Default: 180 (3 minutes) + + Returns: + tuple: (updated_count, list_of_updated_uuids) + """ + threshold_time = timezone.now() - timedelta(seconds=stalled_threshold_seconds) + + with transaction.atomic(): + # Use select_for_update() to prevent race conditions + stalled_intents = list( + cls.objects.select_for_update().filter( + state=CheckoutIntentState.PAID, + modified__lte=threshold_time, + ).order_by('modified') + ) + + updated_uuids = [] + for intent in stalled_intents: + try: + time_stalled = (timezone.now() - intent.modified).total_seconds() + error_message = ( + f'Fulfillment stalled for {int(time_stalled)} seconds ' + f'(threshold: {stalled_threshold_seconds}s). ' + f'Last modified: {intent.modified.isoformat()}. ' + f'Provisioning workflow may have failed without proper error handling.' + ) + intent.mark_fulfillment_stalled(error_message=error_message) + updated_uuids.append(str(intent.pk)) + except Exception as exc: # pylint: disable=broad-except + logger.exception( + 'Failed to mark CheckoutIntent %s as stalled: %s', + intent.pk, + exc, + ) + # Continue processing other intents even if one fails + continue + + return len(updated_uuids), updated_uuids + def is_expired(self): """Check if this checkout intent has expired.""" if self.expires_at: diff --git a/enterprise_access/apps/customer_billing/tests/test_models.py b/enterprise_access/apps/customer_billing/tests/test_models.py index fd1e63a3d..97f5f5588 100644 --- a/enterprise_access/apps/customer_billing/tests/test_models.py +++ b/enterprise_access/apps/customer_billing/tests/test_models.py @@ -199,8 +199,9 @@ def test_create_intent_with_existing_failed_intent(self): quantity=5 ) - # Now put it in a failure state - intent.mark_checkout_error('Payment processing failed') + # Now put it in a failure state (mark as paid first, then provisioning error) + intent.mark_as_paid('stripe_session_123') + intent.mark_provisioning_error('Provisioning failed') self.assertIn(intent.state, CheckoutIntent.FAILURE_STATES) # Trying to create a new intent for the same user should fail @@ -220,7 +221,7 @@ def test_create_intent_with_existing_failed_intent(self): self.assertEqual(intent.enterprise_slug, 'original-slug') self.assertEqual(intent.enterprise_name, 'Original Enterprise') self.assertEqual(intent.quantity, 5) - self.assertEqual(intent.state, CheckoutIntentState.ERRORED_STRIPE_CHECKOUT) + self.assertEqual(intent.state, CheckoutIntentState.ERRORED_PROVISIONING) def test_state_transitions_happy_path(self): """Test the state transitions for the happy path.""" @@ -256,20 +257,8 @@ def test_state_transitions_error_paths(self): quantity=self.basic_data['quantity'] ) - # CREATED → ERRORED_STRIPE_CHECKOUT - intent.mark_checkout_error('Payment failed: card declined') - self.assertEqual(intent.state, CheckoutIntentState.ERRORED_STRIPE_CHECKOUT) - self.assertEqual(intent.last_checkout_error, 'Payment failed: card declined') - - # Reset for testing PAID → ERRORED_PROVISIONING - intent = CheckoutIntent.create_intent( - user=cast(AbstractUser, self.user2), - slug='another-slug', - name='Another Enterprise', - quantity=7 - ) - - intent.mark_as_paid('cs_test_456') + # Test PAID → ERRORED_PROVISIONING transition + intent.mark_as_paid('cs_test_123') workflow = ProvisionNewCustomerWorkflowFactory() intent.mark_provisioning_error('Provisioning failed: API error', workflow) self.assertEqual(intent.state, CheckoutIntentState.ERRORED_PROVISIONING) diff --git a/enterprise_access/apps/customer_billing/tests/test_segment_events.py b/enterprise_access/apps/customer_billing/tests/test_segment_events.py index 4d960952a..a626912b7 100644 --- a/enterprise_access/apps/customer_billing/tests/test_segment_events.py +++ b/enterprise_access/apps/customer_billing/tests/test_segment_events.py @@ -132,8 +132,8 @@ def test_transition_to_fulfilled_event(self, mock_track_event): self.assertEqual(properties['workflow'], workflow.uuid) @mock.patch('enterprise_access.apps.customer_billing.signals.track_event') - def test_transition_to_errored_stripe_checkout_event(self, mock_track_event): - """Test that transition to errored_stripe_checkout event is emitted.""" + def test_transition_to_errored_fulfillment_stalled_event(self, mock_track_event): + """Test that transition to errored_fulfillment_stalled event is emitted.""" intent = CheckoutIntent.create_intent( user=cast(AbstractUser, self.user), slug=self.basic_data['enterprise_slug'], @@ -141,12 +141,15 @@ def test_transition_to_errored_stripe_checkout_event(self, mock_track_event): quantity=self.basic_data['quantity'] ) - # Reset mock after creation + # Move to PAID state + intent.mark_as_paid('cs_test_123') + + # Reset mock after paid transition mock_track_event.reset_mock() - # Transition to ERRORED_STRIPE_CHECKOUT - error_message = 'Payment failed: card declined' - intent.mark_checkout_error(error_message) + # Transition to ERRORED_FULFILLMENT_STALLED + error_message = 'Fulfillment stalled for 300 seconds' + intent.mark_fulfillment_stalled(error_message) # Verify track_event was called once mock_track_event.assert_called_once() @@ -161,9 +164,9 @@ def test_transition_to_errored_stripe_checkout_event(self, mock_track_event): # Verify state transition properties properties = call_args.kwargs['properties'] - self.assertEqual(properties['previous_state'], CheckoutIntentState.CREATED) - self.assertEqual(properties['new_state'], CheckoutIntentState.ERRORED_STRIPE_CHECKOUT) - self.assertEqual(properties['last_checkout_error'], error_message) + self.assertEqual(properties['previous_state'], CheckoutIntentState.PAID) + self.assertEqual(properties['new_state'], CheckoutIntentState.ERRORED_FULFILLMENT_STALLED) + self.assertEqual(properties['last_provisioning_error'], error_message) @mock.patch('enterprise_access.apps.customer_billing.signals.track_event') def test_transition_to_errored_provisioning_event(self, mock_track_event):