Skip to content

Commit ac8c765

Browse files
feat: make CheckoutIntent.ssp_product non-nullable
1 parent df8a336 commit ac8c765

11 files changed

Lines changed: 893 additions & 80 deletions

File tree

enterprise_access/apps/api/serializers/customer_billing.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,12 @@ class CheckoutIntentCreateRequestSerializer(CountryFieldMixin, serializers.Model
193193
class Meta:
194194
model = CheckoutIntent
195195
fields = '__all__'
196+
extra_kwargs = {
197+
'ssp_product': {
198+
'required': False,
199+
'allow_null': False,
200+
},
201+
}
196202
read_only_fields = [
197203
field.name for field in CheckoutIntent._meta.get_fields()
198204
if field.name not in [
@@ -201,6 +207,7 @@ class Meta:
201207
'quantity',
202208
'country',
203209
'terms_metadata',
210+
'ssp_product',
204211
]
205212
]
206213

@@ -237,13 +244,18 @@ def create(self, validated_data):
237244
Creates a new CheckoutIntent.
238245
"""
239246
try:
247+
# Pass through any provided `ssp_product`; model-level creation
248+
# resolves a default when omitted for backwards compatibility.
249+
ssp_product = validated_data.get('ssp_product')
250+
240251
return CheckoutIntent.create_intent(
241252
user=self.context['request'].user,
242253
quantity=validated_data['quantity'],
243254
slug=validated_data.get('enterprise_slug'),
244255
name=validated_data.get('enterprise_name'),
245256
country=validated_data.get('country'),
246257
terms_metadata=validated_data.get('terms_metadata'),
258+
ssp_product=ssp_product,
247259
)
248260

249261
# Catch exceptions that should return 422:

enterprise_access/apps/api/tests/test_serializers.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
from django.test import TestCase
1212
from freezegun import freeze_time
1313

14+
from enterprise_access.apps.api.serializers.customer_billing import (
15+
CheckoutIntentCreateRequestSerializer,
16+
RecordConflictError
17+
)
1418
from enterprise_access.apps.api.serializers.subsidy_access_policy import (
1519
SubsidyAccessPolicyAggregatesSerializer,
1620
SubsidyAccessPolicyCreditsAvailableResponseSerializer,
@@ -24,6 +28,7 @@
2428
AssignmentConfigurationFactory,
2529
LearnerContentAssignmentFactory
2630
)
31+
from enterprise_access.apps.customer_billing.models import FailedCheckoutIntentConflict, SlugReservationConflict
2732
from enterprise_access.apps.subsidy_access_policy.tests.factories import (
2833
AssignedLearnerCreditAccessPolicyFactory,
2934
PerLearnerEnrollmentCapLearnerCreditAccessPolicyFactory
@@ -574,3 +579,95 @@ def test_missing_uuids_field(self):
574579
serializer = LearnerCreditRequestBulkCancelSerializer(data=data)
575580
self.assertFalse(serializer.is_valid())
576581
self.assertIn('learner_credit_request_uuids', serializer.errors)
582+
583+
584+
class TestCheckoutIntentCreateRequestSerializer(TestCase):
585+
"""Tests for CheckoutIntentCreateRequestSerializer behavior."""
586+
587+
def setUp(self):
588+
self.user = mock.Mock()
589+
self.request = mock.Mock(user=self.user)
590+
591+
def _make_serializer(self):
592+
return CheckoutIntentCreateRequestSerializer(context={'request': self.request})
593+
594+
@mock.patch('enterprise_access.apps.api.serializers.customer_billing.CheckoutIntent.create_intent')
595+
def test_create_passes_none_ssp_product_when_omitted(self, mock_create_intent):
596+
"""Serializer create should pass ssp_product=None when not supplied."""
597+
serializer = self._make_serializer()
598+
mock_create_intent.return_value = mock.Mock()
599+
600+
serializer.create({
601+
'quantity': 10,
602+
'enterprise_slug': 'acme-enterprise',
603+
'enterprise_name': 'Acme Enterprise',
604+
})
605+
606+
mock_create_intent.assert_called_once_with(
607+
user=self.user,
608+
quantity=10,
609+
slug='acme-enterprise',
610+
name='Acme Enterprise',
611+
country=None,
612+
terms_metadata=None,
613+
ssp_product=None,
614+
)
615+
616+
@mock.patch('enterprise_access.apps.api.serializers.customer_billing.CheckoutIntent.create_intent')
617+
def test_create_passes_explicit_ssp_product(self, mock_create_intent):
618+
"""Serializer create should pass through explicitly provided ssp_product."""
619+
serializer = self._make_serializer()
620+
mock_create_intent.return_value = mock.Mock()
621+
product = mock.Mock()
622+
623+
serializer.create({
624+
'quantity': 10,
625+
'enterprise_slug': 'acme-enterprise',
626+
'enterprise_name': 'Acme Enterprise',
627+
'ssp_product': product,
628+
})
629+
630+
mock_create_intent.assert_called_once()
631+
self.assertEqual(mock_create_intent.call_args.kwargs['ssp_product'], product)
632+
633+
@mock.patch('enterprise_access.apps.api.serializers.customer_billing.CheckoutIntent.create_intent')
634+
def test_create_translates_slug_conflict(self, mock_create_intent):
635+
"""SlugReservationConflict should be translated to RecordConflictError."""
636+
serializer = self._make_serializer()
637+
mock_create_intent.side_effect = SlugReservationConflict()
638+
639+
with self.assertRaises(RecordConflictError) as exc:
640+
serializer.create({
641+
'quantity': 10,
642+
'enterprise_slug': 'acme-enterprise',
643+
'enterprise_name': 'Acme Enterprise',
644+
})
645+
646+
self.assertIn('already been reserved', str(exc.exception.detail))
647+
648+
@mock.patch('enterprise_access.apps.api.serializers.customer_billing.CheckoutIntent.create_intent')
649+
def test_create_translates_failed_conflict(self, mock_create_intent):
650+
"""FailedCheckoutIntentConflict should be translated to RecordConflictError."""
651+
serializer = self._make_serializer()
652+
mock_create_intent.side_effect = FailedCheckoutIntentConflict()
653+
654+
with self.assertRaises(RecordConflictError) as exc:
655+
serializer.create({
656+
'quantity': 10,
657+
'enterprise_slug': 'acme-enterprise',
658+
'enterprise_name': 'Acme Enterprise',
659+
})
660+
661+
self.assertIn('failed CheckoutIntent', str(exc.exception.detail))
662+
663+
def test_validate_terms_metadata_rejects_list(self):
664+
"""Serializer validation should reject terms_metadata when it is not an object."""
665+
serializer = CheckoutIntentCreateRequestSerializer(data={
666+
'quantity': 10,
667+
'enterprise_slug': 'acme-enterprise',
668+
'enterprise_name': 'Acme Enterprise',
669+
'terms_metadata': ['not', 'a', 'dict'],
670+
})
671+
672+
self.assertFalse(serializer.is_valid())
673+
self.assertIn('terms_metadata', serializer.errors)

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

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from enterprise_access.apps.core.constants import SYSTEM_ENTERPRISE_LEARNER_ROLE
2727
from enterprise_access.apps.customer_billing.constants import CheckoutIntentState
2828
from enterprise_access.apps.customer_billing.models import CheckoutIntent
29+
from enterprise_access.apps.customer_billing.tests.factories import CheckoutIntentFactory
2930
from enterprise_access.apps.customer_billing.tests.utils import AttrDict
3031
from test_utils import APITest
3132

@@ -74,14 +75,27 @@ def test_context_endpoint_unauthenticated_access(self):
7475
# For unauthenticated users, checkout_intent should be None
7576
self.assertIsNone(response.data['checkout_intent'])
7677

77-
@mock.patch('enterprise_access.apps.customer_billing.models.CheckoutIntent.objects.filter')
78-
def test_context_endpoint_authenticated_access(self, mock_filter):
78+
@mock.patch('enterprise_access.apps.bffs.checkout.handlers.get_ssp_product_pricing')
79+
@mock.patch('enterprise_access.apps.bffs.checkout.handlers.transform_enterprise_customer_users_data')
80+
@mock.patch('enterprise_access.apps.bffs.checkout.handlers.get_and_cache_enterprise_customer_users')
81+
@mock.patch('enterprise_access.apps.customer_billing.models.CheckoutIntent.for_user')
82+
def test_context_endpoint_authenticated_access(
83+
self,
84+
mock_for_user,
85+
mock_get_customers,
86+
mock_transform,
87+
mock_get_pricing,
88+
):
7989
"""
8090
Test that authenticated users can access the context endpoint.
8191
"""
92+
mock_get_pricing.return_value = {}
93+
mock_get_customers.return_value = {'results': []}
94+
mock_transform.return_value = {'all_linked_enterprise_customer_users': []}
95+
8296
# Set up a mock checkout intent for the authenticated user
8397
mock_intent = CheckoutIntent(**self.mock_checkout_intent_data)
84-
mock_filter.return_value.first.return_value = mock_intent
98+
mock_for_user.return_value = mock_intent
8599

86100
self.set_jwt_cookie([{
87101
'system_wide_role': SYSTEM_ENTERPRISE_LEARNER_ROLE,
@@ -556,8 +570,8 @@ def test_success_endpoint_handler_exception( # pylint: disable=unused-argument
556570
@mock.patch('enterprise_access.apps.bffs.checkout.handlers.CheckoutIntent.for_user')
557571
def test_success_endpoint_no_checkout_session(self, mock_for_user):
558572
"""Test the success endpoint when no checkout session id is present."""
559-
mock_checkout_intent = CheckoutIntent.objects.create(
560-
user_id=self.user.id,
573+
mock_checkout_intent = CheckoutIntentFactory(
574+
user=self.user,
561575
state=CheckoutIntentState.CREATED,
562576
quantity=10,
563577
enterprise_name='Test Enterprise',
@@ -598,8 +612,8 @@ def test_success_endpoint_full_stripe_data( # pylint: disable=unused-argument
598612
mock_get_customer, mock_get_pricing,
599613
):
600614
"""Test the success endpoint with full Stripe data integration."""
601-
mock_checkout_intent = CheckoutIntent.objects.create(
602-
user_id=self.user.id,
615+
mock_checkout_intent = CheckoutIntentFactory(
616+
user=self.user,
603617
state=CheckoutIntentState.CREATED,
604618
quantity=10,
605619
enterprise_name='Test Enterprise',

enterprise_access/apps/bffs/tests/test_checkout_handlers.py

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -311,26 +311,6 @@ def test_load_checkout_intent_for_authenticated_user(self, mock_filter, mock_get
311311
self.assertEqual(context.checkout_intent, context.checkout_intent or {} | mock_intent_data)
312312
mock_filter.assert_called_once_with(user=self.user)
313313

314-
@mock.patch('enterprise_access.apps.bffs.checkout.handlers.get_ssp_product_pricing')
315-
@mock.patch('enterprise_access.apps.customer_billing.models.CheckoutIntent.objects.filter')
316-
def test_load_checkout_intent_no_intent_exists(self, mock_filter, mock_get_pricing):
317-
"""
318-
Test that load_and_process handles case where authenticated user has no checkout intent.
319-
"""
320-
# Setup
321-
mock_get_pricing.return_value = {}
322-
mock_filter.return_value.first.return_value = None
323-
324-
context = self._create_context()
325-
handler = CheckoutContextHandler(context)
326-
327-
# Execute
328-
handler.load_and_process()
329-
330-
# Assert
331-
self.assertIsNone(context.checkout_intent)
332-
mock_filter.assert_called_once_with(user=self.user)
333-
334314
@mock.patch('enterprise_access.apps.bffs.checkout.handlers.get_ssp_product_pricing')
335315
@mock.patch('enterprise_access.apps.customer_billing.models.CheckoutIntent.objects.filter')
336316
def test_load_checkout_intent_for_unauthenticated_user(self, mock_filter, mock_get_pricing):

enterprise_access/apps/customer_billing/api.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
from enterprise_access.apps.customer_billing.models import (
1818
CheckoutIntent,
1919
FailedCheckoutIntentConflict,
20-
SlugReservationConflict
20+
SlugReservationConflict,
21+
SspProduct
2122
)
2223
from enterprise_access.apps.customer_billing.pricing_api import get_ssp_product_pricing
2324
from enterprise_access.apps.customer_billing.stripe_api import create_subscription_checkout_session
@@ -423,13 +424,53 @@ def create_free_trial_checkout_session(
423424

424425
user = input_data['user']
425426

427+
# Determine SSP product for this checkout. This is required now that
428+
# CheckoutIntent.ssp_product is non-nullable.
429+
ssp_product = None
430+
try:
431+
ssp_pricing = get_ssp_product_pricing()
432+
matching_price = None
433+
for price_data in ssp_pricing.values():
434+
if price_data.get('id') == input_data.get('stripe_price_id'):
435+
matching_price = price_data
436+
break
437+
if matching_price:
438+
lookup_key = matching_price.get('lookup_key')
439+
if lookup_key:
440+
ssp_product = SspProduct.objects.filter(stripe_price_lookup_key=lookup_key).first()
441+
except Exception: # pylint: disable=broad-exception-caught
442+
logger.exception(
443+
'Failed to determine SSP product mapping for stripe_price_id=%s',
444+
input_data.get('stripe_price_id'),
445+
)
446+
# Preserve backwards compatibility if pricing lookup is temporarily unavailable.
447+
ssp_product = None
448+
449+
if not ssp_product:
450+
ssp_product = SspProduct.objects.filter(slug='teams-yearly').first()
451+
if not ssp_product:
452+
error_code, developer_message = CHECKOUT_SESSION_ERROR_CODES['stripe_price_id']['DOES_NOT_EXIST']
453+
raise CreateCheckoutSessionValidationError(
454+
validation_errors_by_field={
455+
'stripe_price_id': {
456+
'error_code': error_code,
457+
'developer_message': (
458+
f'{developer_message} No SspProduct configured for '
459+
f'stripe_price_id={input_data.get("stripe_price_id")}. '
460+
'Ensure stripe_price_lookup_key maps to an existing SspProduct.'
461+
),
462+
}
463+
}
464+
)
465+
426466
# Create checkout intent, which reserves the enterprise name & slug.
427467
try:
428468
intent = CheckoutIntent.create_intent(
429469
user=user,
430470
quantity=input_data.get('quantity'),
431471
slug=input_data.get('enterprise_slug'),
432472
name=input_data.get('company_name'),
473+
ssp_product=ssp_product,
433474
)
434475
except SlugReservationConflict as exc:
435476
raise CreateCheckoutSessionSlugReservationConflict() from exc
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""
2+
Make CheckoutIntent.ssp_product non-nullable, change user to ForeignKey, and add unique constraint on (user, ssp_product).
3+
"""
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
import django.db.models.deletion
7+
8+
9+
def check_no_nulls(apps, schema_editor):
10+
CheckoutIntent = apps.get_model('customer_billing', 'CheckoutIntent')
11+
null_count = CheckoutIntent.objects.filter(ssp_product__isnull=True).count()
12+
if null_count > 0:
13+
raise Exception(
14+
f"Cannot proceed: {null_count} CheckoutIntent rows still have NULL ssp_product. "
15+
"Run the backfill migration (0036_backfill_checkoutintent_ssp_product_teams_yearly) first."
16+
)
17+
18+
19+
class Migration(migrations.Migration):
20+
21+
dependencies = [
22+
('customer_billing', '0036_backfill_checkoutintent_ssp_product_teams_yearly'),
23+
]
24+
25+
operations = [
26+
migrations.RunPython(check_no_nulls, migrations.RunPython.noop),
27+
migrations.AlterField(
28+
model_name='checkoutintent',
29+
name='user',
30+
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
31+
),
32+
migrations.AlterField(
33+
model_name='checkoutintent',
34+
name='ssp_product',
35+
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='customer_billing.sspproduct', null=False),
36+
),
37+
migrations.AddConstraint(
38+
model_name='checkoutintent',
39+
constraint=models.UniqueConstraint(fields=('user', 'ssp_product'), name='unique_user_ssp_product'),
40+
),
41+
]

0 commit comments

Comments
 (0)