feat: make CheckoutIntent.ssp_product non-nullable#195
feat: make CheckoutIntent.ssp_product non-nullable#195shravani-sonata-gottapu wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates the customer billing checkout flow to make CheckoutIntent.ssp_product required, adjust the CheckoutIntent.user relationship, and enforce uniqueness per (user, ssp_product) so checkout intents are product-scoped.
Changes:
- Adds a guard + schema migration to make
ssp_productnon-nullable, convertusertoForeignKey, and add a unique constraint on(user, ssp_product). - Updates intent creation logic and checkout-session creation to resolve/default an
SspProduct(notablyteams-yearly) when callers don’t provide one. - Updates/extends test helpers/factories and adjusts selected tests for the new non-null behavior.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| enterprise_access/apps/customer_billing/tests/test_models.py | Updates model tests around ssp_product handling/defaulting. |
| enterprise_access/apps/customer_billing/tests/test_migrations.py | Adjusts migration tests for rollback/forward re-runs. |
| enterprise_access/apps/customer_billing/tests/factories.py | Ensures CheckoutIntentFactory always provides an ssp_product. |
| enterprise_access/apps/customer_billing/models.py | Makes ssp_product non-null, changes user field type, adds uniqueness metadata, and updates intent lookup/defaulting logic. |
| enterprise_access/apps/customer_billing/migrations/0037_make_ssp_product_non_nullable_and_user_fk.py | Adds guard + alters fields + adds DB uniqueness constraint. |
| enterprise_access/apps/customer_billing/api.py | Resolves an SspProduct for free-trial checkout session creation. |
| enterprise_access/apps/api/serializers/customer_billing.py | Defaults ssp_product during checkout intent creation via API serializer. |
Codecov Report❌ Patch coverage is ❌ Your patch check has failed because the patch coverage (93.06%) is below the target coverage (95.00%). You can increase the patch coverage or adjust the target coverage. Additional details and impacted files@@ Coverage Diff @@
## main #195 +/- ##
==========================================
+ Coverage 86.17% 86.43% +0.25%
==========================================
Files 152 153 +1
Lines 12585 12742 +157
Branches 1200 1227 +27
==========================================
+ Hits 10845 11013 +168
+ Misses 1425 1408 -17
- Partials 315 321 +6 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| # Prefer an existing intent for the same user and SSP product when provided, | ||
| # otherwise fall back to any existing intent for the user (legacy behaviour). | ||
| if ssp_product: | ||
| existing_intent = cls.objects.filter(user=user, ssp_product=ssp_product).order_by('-created').first() | ||
| else: | ||
| existing_intent = cls.objects.filter(user=user).order_by('-created').first() |
| # Check if user already has a non-expired intent | ||
| existing_intent = CheckoutIntent.objects.filter( | ||
| existing_qs = CheckoutIntent.objects.filter( | ||
| user=self.user, | ||
| state__in=self.NON_EXPIRED_STATES | ||
| ).first() | ||
| state__in=self.NON_EXPIRED_STATES, | ||
| ) |
| qs = cls.objects.filter(user=user).order_by('-created') | ||
| try: | ||
| return qs[0] | ||
| except IndexError: | ||
| return None |
| ) | ||
| else: | ||
| existing_intent = ( | ||
| cls.objects.filter(user=user).order_by('-created').first() | ||
| ) |
| def save(self, *args, **kwargs): | ||
| """Ensure a valid ``ssp_product`` before persisting.""" | ||
| if not getattr(self, 'ssp_product', None): | ||
| SspProductModel = apps.get_model('customer_billing', 'SspProduct') | ||
| try: | ||
| self.ssp_product = SspProductModel.objects.get(slug='teams-yearly') | ||
| except SspProductModel.DoesNotExist as exc: | ||
| raise ValidationError({'ssp_product': 'Default ssp_product teams-yearly was not found.'}) from exc | ||
| except SspProductModel.MultipleObjectsReturned as exc: | ||
| raise ValidationError({'ssp_product': 'Multiple teams-yearly ssp_product records found.'}) from exc | ||
|
|
||
| return super().save(*args, **kwargs) |
| qs = StripeEventSummary.objects.filter( | ||
| checkout_intent=self, | ||
| stripe_event_created_at__lt=event_timestamp, | ||
| **filter_kwargs, | ||
| ).order_by('-stripe_event_created_at').first() | ||
| ).order_by('-stripe_event_created_at') |
| updated_count = CheckoutIntent.cleanup_expired() | ||
|
|
||
| # Verify it returns the correct count of updated intents | ||
| self.assertEqual(updated_count, 1, "Should have updated exactly one intent") | ||
|
|
| SspProductModel = apps.get_model('customer_billing', 'SspProduct') | ||
| original_get = SspProductModel.objects.get | ||
|
|
||
| def _raise(*args, **kwargs): | ||
| raise SspProductModel.MultipleObjectsReturned() | ||
|
|
||
| SspProductModel.objects.get = _raise | ||
| try: | ||
| with self.assertRaises(RuntimeError): | ||
| CheckoutIntent.create_intent( | ||
| user=cast(AbstractUser, self.user1), | ||
| quantity=1, | ||
| ) | ||
| finally: | ||
| SspProductModel.objects.get = original_get |
| extra_kwargs = { | ||
| 'ssp_product': { | ||
| 'required': False, | ||
| 'allow_null': False, | ||
| }, |
f37ccf0 to
ac8c765
Compare
| def save(self, *args, **kwargs): | ||
| """Ensure a valid ``ssp_product`` before persisting.""" | ||
| if not getattr(self, 'ssp_product', None): | ||
| SspProductModel = apps.get_model('customer_billing', 'SspProduct') |
| else: | ||
| existing_intent = ( | ||
| cls.objects.filter(user=user).order_by('-created').first() | ||
| ) |
| ).order_by('-stripe_event_created_at') | ||
| try: | ||
| return qs[0] | ||
| except IndexError: | ||
| return None |
| # Verify it returns the correct count of updated intents | ||
| self.assertEqual(updated_count, 1, "Should have updated exactly one intent") | ||
|
|
| self.assertEqual(context.checkout_intent, context.checkout_intent or {} | mock_intent_data) | ||
| mock_filter.assert_called_once_with(user=self.user) |
| @staticmethod | ||
| def _create_teams_yearly_product(): | ||
| return SspProduct.objects.get_or_create( | ||
| slug='teams-yearly', | ||
| defaults={ | ||
| 'stripe_price_lookup_key': 'teams_yearly_price', | ||
| 'catalog_query_uuid': '00000000-0000-0000-0000-000000000000', | ||
| 'is_active': True, | ||
| }, | ||
| )[0] |
| class Meta: | ||
| model = CheckoutIntent | ||
| fields = '__all__' | ||
| extra_kwargs = { | ||
| 'ssp_product': { | ||
| 'required': False, | ||
| 'allow_null': False, | ||
| }, | ||
| } |
| if not ssp_product: | ||
| ssp_product = SspProduct.objects.filter(slug='teams-yearly').first() | ||
| if not ssp_product: | ||
| error_code, developer_message = CHECKOUT_SESSION_ERROR_CODES['stripe_price_id']['DOES_NOT_EXIST'] | ||
| raise CreateCheckoutSessionValidationError( | ||
| validation_errors_by_field={ | ||
| 'stripe_price_id': { | ||
| 'error_code': error_code, | ||
| 'developer_message': ( | ||
| f'{developer_message} No SspProduct configured for ' | ||
| f'stripe_price_id={input_data.get("stripe_price_id")}. ' | ||
| 'Ensure stripe_price_lookup_key maps to an existing SspProduct.' | ||
| ), | ||
| } | ||
| } | ||
| ) |
6e53da6 to
29a3c49
Compare
| @@ -106,17 +119,67 @@ def test_cleanup_expired_without_mocks(self): | |||
| # Verify it returns the correct count of updated intents | |||
| self.assertEqual(updated_count, 1, "Should have updated exactly one intent") | |||
| SspProductModel = apps.get_model('customer_billing', 'SspProduct') | ||
| original_get = SspProductModel.objects.get | ||
|
|
||
| def _raise(*args, **kwargs): | ||
| raise SspProductModel.MultipleObjectsReturned() | ||
|
|
||
| SspProductModel.objects.get = _raise | ||
| try: | ||
| with self.assertRaises(RuntimeError): | ||
| CheckoutIntent.create_intent( | ||
| user=cast(AbstractUser, self.user1), | ||
| quantity=1, | ||
| ) | ||
| finally: | ||
| SspProductModel.objects.get = original_get |
| def save(self, *args, **kwargs): | ||
| """Ensure a valid ``ssp_product`` before persisting.""" | ||
| if not getattr(self, 'ssp_product', None): | ||
| SspProductModel = apps.get_model('customer_billing', 'SspProduct') | ||
| try: | ||
| self.ssp_product = SspProductModel.objects.get(slug='teams-yearly') | ||
| except SspProductModel.DoesNotExist as exc: | ||
| raise ValidationError({'ssp_product': 'Default ssp_product teams-yearly was not found.'}) from exc | ||
| except SspProductModel.MultipleObjectsReturned as exc: | ||
| raise ValidationError({'ssp_product': 'Multiple teams-yearly ssp_product records found.'}) from exc | ||
|
|
||
| return super().save(*args, **kwargs) |
| else: | ||
| SspProductModel = apps.get_model('customer_billing', 'SspProduct') | ||
| teams_yearly = SspProductModel.objects.filter(slug='teams-yearly').first() | ||
| if teams_yearly: | ||
| existing_intent = ( | ||
| cls.objects.filter( | ||
| user=user, | ||
| ssp_product=teams_yearly, | ||
| ).order_by('-created').first() | ||
| ) | ||
| else: | ||
| existing_intent = ( | ||
| cls.objects.filter(user=user).order_by('-created').first() | ||
| ) |
| extra_kwargs = { | ||
| 'ssp_product': { | ||
| 'required': False, | ||
| 'allow_null': False, | ||
| }, | ||
| } |
| @staticmethod | ||
| def _create_teams_yearly_product(): | ||
| return SspProduct.objects.get_or_create( | ||
| slug='teams-yearly', | ||
| defaults={ | ||
| 'stripe_price_lookup_key': 'teams_yearly_price', | ||
| 'catalog_query_uuid': '00000000-0000-0000-0000-000000000000', | ||
| 'is_active': True, | ||
| }, | ||
| )[0] |
| else: | ||
| SspProductModel = apps.get_model('customer_billing', 'SspProduct') | ||
| teams_yearly = SspProductModel.objects.filter(slug='teams-yearly').first() | ||
| if teams_yearly: | ||
| existing_intent = ( | ||
| cls.objects.filter( | ||
| user=user, | ||
| ssp_product=teams_yearly, | ||
| ).order_by('-created').first() | ||
| ) | ||
| else: | ||
| existing_intent = ( | ||
| cls.objects.filter(user=user).order_by('-created').first() | ||
| ) |
| @@ -106,17 +119,67 @@ def test_cleanup_expired_without_mocks(self): | |||
| # Verify it returns the correct count of updated intents | |||
| self.assertEqual(updated_count, 1, "Should have updated exactly one intent") | |||
| extra_kwargs = { | ||
| 'ssp_product': { | ||
| 'required': False, | ||
| 'allow_null': False, | ||
| }, | ||
| } |
8f0837e to
a99d404
Compare
a99d404 to
a115fac
Compare
Ticket - ENT-11902 https://2u-internal.atlassian.net/browse/ENT-11902
Description :