Skip to content

Commit 71b1c50

Browse files
authored
Merge pull request #837 from openedx/pwnage101/ENT-10752-2
feat: tighten up CheckoutIntent state transitions
2 parents 0cdf332 + 31e7da0 commit 71b1c50

File tree

3 files changed

+59
-27
lines changed

3 files changed

+59
-27
lines changed

enterprise_access/apps/api/v1/views/customer_billing.py

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
CreateCheckoutSessionValidationError,
2525
create_free_trial_checkout_session
2626
)
27-
from enterprise_access.apps.customer_billing.constants import ALLOWED_CHECKOUT_INTENT_STATE_TRANSITIONS
2827
from enterprise_access.apps.customer_billing.models import CheckoutIntent
2928
from enterprise_access.apps.customer_billing.stripe_event_handlers import StripeEventHandler
3029

@@ -338,8 +337,8 @@ def partial_update(self, request, *args, **kwargs):
338337

339338
# Check if state is being updated
340339
new_state = request.data.get('state')
341-
if new_state and new_state != instance.state:
342-
if not self._is_valid_state_transition(instance.state, new_state):
340+
if new_state:
341+
if not CheckoutIntent.is_valid_state_transition(instance.state, new_state):
343342
raise ValidationError(detail={
344343
'state': f'Invalid state transition from {instance.state} to {new_state}'
345344
})
@@ -350,17 +349,3 @@ def partial_update(self, request, *args, **kwargs):
350349
)
351350

352351
return super().partial_update(request, *args, **kwargs)
353-
354-
def _is_valid_state_transition(self, current_state, new_state):
355-
"""
356-
Validate if the state transition is allowed.
357-
358-
Args:
359-
current_state: Current state of the CheckoutIntent
360-
new_state: Proposed new state
361-
362-
Returns:
363-
bool: True if transition is allowed, False otherwise
364-
"""
365-
allowed_transitions = ALLOWED_CHECKOUT_INTENT_STATE_TRANSITIONS.get(current_state, [])
366-
return new_state in allowed_transitions

enterprise_access/apps/customer_billing/models.py

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""
22
Models for customer billing app.
33
"""
4+
import logging
45
from datetime import timedelta
56
from typing import Self
67

@@ -16,10 +17,12 @@
1617
from simple_history.models import HistoricalRecords
1718
from simple_history.utils import bulk_update_with_history
1819

20+
from enterprise_access.apps.customer_billing.constants import ALLOWED_CHECKOUT_INTENT_STATE_TRANSITIONS
1921
from enterprise_access.apps.provisioning.models import ProvisionNewCustomerWorkflow
2022

2123
from .constants import INTENT_RESERVATION_DURATION_MINUTES, CheckoutIntentState
2224

25+
logger = logging.getLogger(__name__)
2326
User = get_user_model()
2427

2528

@@ -135,50 +138,92 @@ class StateChoices(models.TextChoices):
135138

136139
def __str__(self):
137140
return (
138-
f"{self.user.email}, slug: {self.enterprise_slug}, name: {self.enterprise_name}, "
139-
f"state: {self.state}, (expires {self.expires_at})"
141+
"<CheckoutIntent "
142+
f"id={self.id}, "
143+
f"email={self.user.email}, "
144+
f"enterprise_slug={self.enterprise_slug}, "
145+
f"enterprise_name={self.enterprise_name}, "
146+
f"state={self.state}, "
147+
f"expires_at={self.expires_at}>"
140148
)
141149

150+
@classmethod
151+
def is_valid_state_transition(
152+
cls,
153+
current_state: CheckoutIntentState,
154+
new_state: CheckoutIntentState,
155+
) -> bool:
156+
"""
157+
Validate if the state transition is allowed.
158+
159+
Args:
160+
current_state: Current state of the CheckoutIntent
161+
new_state: Proposed new state
162+
163+
Returns:
164+
bool: True if transition is allowed, False otherwise
165+
"""
166+
if current_state == new_state:
167+
return True
168+
allowed_transitions = ALLOWED_CHECKOUT_INTENT_STATE_TRANSITIONS.get(current_state, [])
169+
return new_state in allowed_transitions
170+
142171
def mark_as_paid(self, stripe_session_id=None):
143172
"""Mark the intent as paid after successful Stripe checkout."""
144-
if self.state not in (CheckoutIntentState.CREATED, CheckoutIntentState.PAID):
145-
raise ValueError(f"Cannot transition to PAID from {self.state}")
173+
if not self.is_valid_state_transition(CheckoutIntentState(self.state), CheckoutIntentState.PAID):
174+
raise ValueError(f"Cannot transition from {self.state} to {CheckoutIntentState.PAID}.")
146175

147176
if stripe_session_id:
148177
if self.state == CheckoutIntentState.PAID and stripe_session_id != self.stripe_checkout_session_id:
149-
raise ValueError("Cannot transition to PAID from PAID with a different stripe_checkout_session_id")
178+
raise ValueError("Cannot transition from PAID to PAID with a different stripe_checkout_session_id")
150179

151180
self.state = CheckoutIntentState.PAID
152181
if stripe_session_id:
153182
self.stripe_checkout_session_id = stripe_session_id
154183
self.save(update_fields=['state', 'stripe_checkout_session_id', 'modified'])
184+
logger.info(f'CheckoutIntent {self} marked as {CheckoutIntentState.PAID}.')
155185
return self
156186

157187
def mark_as_fulfilled(self, workflow=None):
158188
"""Mark the intent as fulfilled after successful provisioning."""
159-
if self.state != CheckoutIntentState.PAID:
160-
raise ValueError(f"Cannot transition to FULFILLED from {self.state}")
189+
if not self.is_valid_state_transition(CheckoutIntentState(self.state), CheckoutIntentState.FULFILLED):
190+
raise ValueError(f"Cannot transition from {self.state} to {CheckoutIntentState.FULFILLED}.")
161191

162192
self.state = CheckoutIntentState.FULFILLED
163193
if workflow:
164194
self.workflow = workflow
165195
self.save(update_fields=['state', 'workflow', 'modified'])
196+
logger.info(f'CheckoutIntent {self} marked as {CheckoutIntentState.FULFILLED}.')
166197
return self
167198

168199
def mark_checkout_error(self, error_message):
169200
"""Record a checkout error."""
201+
if not self.is_valid_state_transition(
202+
CheckoutIntentState(self.state),
203+
CheckoutIntentState.ERRORED_STRIPE_CHECKOUT,
204+
):
205+
raise ValueError(f"Cannot transition from {self.state} to {CheckoutIntentState.ERRORED_STRIPE_CHECKOUT}.")
206+
170207
self.state = CheckoutIntentState.ERRORED_STRIPE_CHECKOUT
171208
self.last_checkout_error = error_message
172209
self.save(update_fields=['state', 'last_checkout_error', 'modified'])
210+
logger.info(f'CheckoutIntent {self} marked as {CheckoutIntentState.ERRORED_STRIPE_CHECKOUT}.')
173211
return self
174212

175213
def mark_provisioning_error(self, error_message, workflow=None):
176214
"""Record a provisioning error."""
215+
if not self.is_valid_state_transition(
216+
CheckoutIntentState(self.state),
217+
CheckoutIntentState.ERRORED_PROVISIONING,
218+
):
219+
raise ValueError(f"Cannot transition from {self.state} to {CheckoutIntentState.ERRORED_PROVISIONING}.")
220+
177221
self.state = CheckoutIntentState.ERRORED_PROVISIONING
178222
self.last_provisioning_error = error_message
179223
if workflow:
180224
self.workflow = workflow
181225
self.save(update_fields=['state', 'last_provisioning_error', 'workflow', 'modified'])
226+
logger.info(f'CheckoutIntent {self} marked as {CheckoutIntentState.ERRORED_PROVISIONING}.')
182227
return self
183228

184229
@property

enterprise_access/apps/customer_billing/tests/test_models.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -259,10 +259,12 @@ def test_invalid_state_transitions(self):
259259
quantity=self.basic_data['quantity']
260260
)
261261

262-
# Mark as errored
263-
intent.mark_checkout_error('Payment failed')
262+
# Move to ERRORED_PROVISIONING.
263+
intent.mark_as_paid()
264+
intent.mark_provisioning_error('Provisioning failed')
264265

265-
# Should not be able to transition from error to paid
266+
# Should not be able to transition from ERRORED_PROVISIONING to PAID,
267+
# as this would require going back in time.
266268
with self.assertRaises(ValueError):
267269
intent.mark_as_paid()
268270

0 commit comments

Comments
 (0)