Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions enterprise_access/apps/api/v1/views/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
},
Expand All @@ -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,
),
Expand Down
16 changes: 11 additions & 5 deletions enterprise_access/apps/customer_billing/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'


Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
Comment on lines +71 to +79
Copy link
Contributor

@pwnage101 pwnage101 Nov 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can remove one of these duplicative messages throughout the code. I'm pretty sure logger already goes to stdout, and the stdout messages are not already designed to be machine-readable, so I think we can just remove the stdout.write() lines and keep the logger.info lines

Functionally, you're using stdout.write() as a logger, but it's weaker than logger because it lacks timestamps and other useful metadata.


if dry_run:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This implementation of a dry run isn't quite appropriate because it replaces too much business logic. A better approach would be to pass a dry run flag into a lower level function and implement dry run within.

# 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
)
Comment on lines +121 to +123
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably where dry_run needs to be passed.


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'
)
)
Comment on lines +146 to +150
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO, this is the only self.stdout.write() I'd keep, to leverage the success style.

logger.info('%smark_stalled_checkout_intents command completed', mode_label)
Loading
Loading