Skip to content
Open
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
19 changes: 19 additions & 0 deletions enterprise_access/apps/api/serializers/customer_billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
CheckoutIntent,
FailedCheckoutIntentConflict,
SlugReservationConflict,
SspProduct,
StripeEventSummary
)

Expand Down Expand Up @@ -60,6 +61,10 @@ class CustomerBillingCreateCheckoutSessionRequestSerializer(serializers.Serializ
required=True,
help_text='The ID of the Stripe Price object representing the plan selection.',
)
ssp_product_slug = serializers.SlugField(
required=False,
help_text='The slug of the SSP product representing the plan selection.',
)


# pylint: disable=abstract-method
Expand Down Expand Up @@ -117,6 +122,10 @@ class CustomerBillingCreateCheckoutSessionValidationFailedResponseSerializer(ser
required=False,
help_text='Validation results for stripe_price_id if validation failed. Absent otherwise.',
)
ssp_product_slug = ErrorDetailSerializer(
required=False,
help_text='Validation results for ssp_product_slug if validation failed. Absent otherwise.',
)
company_name = ErrorDetailSerializer(
required=False,
help_text='Validation results for company_name if validation failed. Absent otherwise.',
Expand Down Expand Up @@ -194,6 +203,13 @@ class CheckoutIntentCreateRequestSerializer(CountryFieldMixin, serializers.Model
"""
A serializer intended for creating new CheckoutIntents.
"""

ssp_product = serializers.PrimaryKeyRelatedField(
queryset=SspProduct.objects.all(),
required=False,
allow_null=True,
)

class Meta:
model = CheckoutIntent
fields = '__all__'
Expand All @@ -205,6 +221,7 @@ class Meta:
'quantity',
'country',
'terms_metadata',
'ssp_product'
]
]

Expand Down Expand Up @@ -241,13 +258,15 @@ def create(self, validated_data):
Creates a new CheckoutIntent.
"""
try:
ssp_product = validated_data.pop('ssp_product', None)
return CheckoutIntent.create_intent(
user=self.context['request'].user,
quantity=validated_data['quantity'],
slug=validated_data.get('enterprise_slug'),
name=validated_data.get('enterprise_name'),
country=validated_data.get('country'),
terms_metadata=validated_data.get('terms_metadata'),
ssp_product=ssp_product,
)

# Catch exceptions that should return 422:
Expand Down
56 changes: 43 additions & 13 deletions enterprise_access/apps/api/v1/tests/test_checkout_intent_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from enterprise_access.apps.core.constants import SYSTEM_ENTERPRISE_LEARNER_ROLE
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
from enterprise_access.apps.customer_billing.models import CheckoutIntent, SspProduct
from test_utils import APITest

User = get_user_model()
Expand Down Expand Up @@ -42,6 +42,15 @@ def setUpTestData(cls):
email='test4@example.com',
password='testpass123'
)
# Ensure the default SSP product exists for class-level CheckoutIntent creation
SspProduct.objects.get_or_create(
slug='teams-yearly',
defaults={
'stripe_price_lookup_key': 'teams_subscription_license_yearly',
'is_active': True,
'catalog_query_uuid': uuid.uuid4(),
}
)
cls.checkout_intent_2 = CheckoutIntent.objects.create(
user=cls.user_2,
enterprise_name="Active Enterprise 2",
Expand All @@ -51,7 +60,8 @@ def setUpTestData(cls):
expires_at=timezone.now() + timedelta(minutes=30),
stripe_checkout_session_id='cs_test_456',
country='US',
terms_metadata={'version': '1.0', 'accepted_at': '2024-01-15T10:30:00Z'}
terms_metadata={'version': '1.0', 'accepted_at': '2024-01-15T10:30:00Z'},
ssp_product_id='teams-yearly',
)
cls.checkout_intent_4 = CheckoutIntent.objects.create(
user=cls.user_4,
Expand All @@ -62,12 +72,21 @@ def setUpTestData(cls):
expires_at=timezone.now() + timedelta(minutes=30),
stripe_checkout_session_id='cs_test_987',
country='US',
terms_metadata={'version': '1.0', 'accepted_at': '2024-01-15T10:30:00Z'}
terms_metadata={'version': '1.0', 'accepted_at': '2024-01-15T10:30:00Z'},
ssp_product_id='teams-yearly',
)

def setUp(self):
"""Set up test data."""
super().setUp()
SspProduct.objects.get_or_create(
slug='teams-yearly',
defaults={
'stripe_price_lookup_key': 'teams_subscription_license_yearly',
'is_active': True,
'catalog_query_uuid': uuid.uuid4(),
}
)

self.checkout_intent_1 = CheckoutIntent.objects.create(
user=self.user,
Expand All @@ -78,7 +97,8 @@ def setUp(self):
expires_at=timezone.now() + timedelta(minutes=30),
stripe_checkout_session_id='cs_test_123',
country='CA',
terms_metadata={'version': '1.1', 'test_mode': True}
terms_metadata={'version': '1.1', 'test_mode': True},
ssp_product_id='teams-yearly',
)
self.checkout_intent_3 = CheckoutIntent.objects.create(
user=self.user_3,
Expand All @@ -89,7 +109,8 @@ def setUp(self):
expires_at=timezone.now() + timedelta(minutes=30),
stripe_checkout_session_id='cs_test_789',
country='GB',
terms_metadata={'version': '2.0', 'features': ['analytics', 'reporting']}
terms_metadata={'version': '2.0', 'features': ['analytics', 'reporting']},
ssp_product_id='teams-yearly',
)

# URL patterns
Expand Down Expand Up @@ -267,7 +288,8 @@ def test_cannot_transition_from_fulfilled(self):
expires_at=timezone.now() + timedelta(minutes=30),
stripe_checkout_session_id='cs_test_78955',
country='FR',
terms_metadata={'version': '1.5', 'fulfilled': True}
terms_metadata={'version': '1.5', 'fulfilled': True},
ssp_product_id='teams-yearly',
)

detail_url = reverse(
Expand Down Expand Up @@ -338,7 +360,8 @@ def test_create_checkout_intent_success(self):
'enterprise_name': 'Test Enterprise post',
'quantity': 13,
'country': 'NZ',
'terms_metadata': {'version': '1.0', 'accepted_at': '2024-01-15T10:30:00Z'}
'terms_metadata': {'version': '1.0', 'accepted_at': '2024-01-15T10:30:00Z'},
'ssp_product': 'teams-yearly',
}

response = self.client.post(
Expand Down Expand Up @@ -368,7 +391,8 @@ def test_create_or_update_checkout_intent_success(self):
'enterprise_name': self.checkout_intent_1.enterprise_name,
'quantity': 33,
'country': 'IT',
'terms_metadata': {'version': '2.0', 'updated': True}
'terms_metadata': {'version': '2.0', 'updated': True},
'ssp_product': 'teams-yearly',
}

response = self.client.post(
Expand Down Expand Up @@ -412,7 +436,7 @@ def test_create_checkout_intent_invalid_field_values(self, **invalid_payload):

response = self.client.post(
self.list_url,
invalid_payload,
{**invalid_payload, 'ssp_product': 'teams-yearly'},
format='json'
)

Expand All @@ -434,7 +458,7 @@ def test_create_checkout_intent_missing_required_fields(self, **payload):
# Test missing enterprise_slug
response = self.client.post(
self.list_url,
payload,
{**payload, 'ssp_product': 'teams-yearly'},
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
error_detail = list(response.json().values())[0][0]
Expand All @@ -448,6 +472,7 @@ def test_create_checkout_intent_authentication_required(self):
'enterprise_slug': 'test-enterprise',
'enterprise_name': 'Test Enterprise',
'quantity': 10,
'ssp_product': 'teams-yearly',
},
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
Expand Down Expand Up @@ -518,7 +543,8 @@ def test_create_with_null_terms_metadata(self):
'enterprise_slug': 'test-enterprise-null',
'enterprise_name': 'Test Enterprise Null',
'quantity': 5,
'terms_metadata': None
'terms_metadata': None,
'ssp_product': 'teams-yearly',
}

response = self.client.post(
Expand All @@ -542,7 +568,8 @@ def test_create_with_empty_terms_metadata(self):
'enterprise_slug': 'test-enterprise-empty',
'enterprise_name': 'Test Enterprise Empty',
'quantity': 8,
'terms_metadata': {}
'terms_metadata': {},
'ssp_product': 'teams-yearly',
}

response = self.client.post(
Expand All @@ -567,7 +594,8 @@ def test_create_checkout_intent_without_slug_or_name_success(self):
request_data = {
'quantity': 13,
'country': 'NZ',
'terms_metadata': {'version': '1.0', 'accepted_at': '2024-01-15T10:30:00Z'}
'terms_metadata': {'version': '1.0', 'accepted_at': '2024-01-15T10:30:00Z'},
'ssp_product': 'teams-yearly',
}

response = self.client.post(
Expand Down Expand Up @@ -603,6 +631,7 @@ def test_create_checkout_intent_already_failed_returns_422(self):
'enterprise_slug': 'new-slug',
'enterprise_name': 'New Name',
'quantity': 7,
'ssp_product': 'teams-yearly',
}
response = self.client.post(self.list_url, request_data, format='json')
self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY)
Expand All @@ -627,6 +656,7 @@ def test_create_checkout_intent_slug_conflict_returns_422(self):
'enterprise_slug': 'active-enterprise',
'enterprise_name': 'Active Enterprise',
'quantity': 7,
'ssp_product': 'teams-yearly',
}
response = self.client.post(self.list_url, request_data, format='json')
self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY)
Expand Down
29 changes: 18 additions & 11 deletions enterprise_access/apps/api/v1/tests/test_customer_billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -3899,17 +3899,24 @@ def test_create_checkout_session_returns_client_secret_from_dict(
'client_secret': 'cs_test_abc_secret_xyz',
}

response = self.client.post(
self.url,
data={
'admin_email': self.user.email,
'enterprise_slug': 'test-slug',
'company_name': 'Test Co',
'quantity': 5,
'stripe_price_id': 'price_abc123',
},
format='json',
)
# Prevent live Stripe pricing lookups during checkout flow
with mock.patch('enterprise_access.apps.customer_billing.api.get_ssp_product_pricing') as mock_get_pricing:
mock_get_pricing.return_value = {
'quarterly_license_plan': {'id': 'price_test_quarterly', 'quantity_range': (5, 30)}
}

response = self.client.post(
self.url,
data={
'admin_email': self.user.email,
'enterprise_slug': 'test-slug',
'company_name': 'Test Co',
'quantity': 5,
'stripe_price_id': 'price_abc123',
'ssp_product': 'quarterly_license_plan',
},
format='json',
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is there a test anywhere that sends an ssp_product key in the request payload?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated test passing the payload


self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(
Expand Down
6 changes: 4 additions & 2 deletions enterprise_access/apps/api/v1/views/customer_billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,8 @@ def create_checkout_session(self, request, *args, **kwargs):
>>> "admin_email": "dr@evil.inc",
>>> "enterprise_slug": "my-sluggy"
>>> "quantity": 7,
>>> "stripe_price_id": "price_1MoBy5LkdIwHu7ixZhnattbh"
>>> "stripe_price_id": "price_1MoBy5LkdIwHu7ixZhnattbh",
>>> "ssp_product_slug": "ai-academy-yearly"
>>> }
HTTP 201 CREATED
>>> {
Expand Down Expand Up @@ -287,7 +288,8 @@ def create_checkout_session(self, request, *args, **kwargs):
'Handling request to create free trial plan. '
f'enterprise_slug="{validated_data["enterprise_slug"]}" '
f'quantity="{validated_data["quantity"]}" '
f'stripe_price_id="{validated_data["stripe_price_id"]}"'
f'stripe_price_id="{validated_data.get("stripe_price_id")}"'
f'ssp_product_slug="{validated_data.get("ssp_product_slug")}" '
)
try:
session = create_free_trial_checkout_session(
Expand Down
2 changes: 2 additions & 0 deletions enterprise_access/apps/bffs/checkout/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,10 @@ def _get_pricing_data(self) -> Dict:
for _, price_data in pricing_data.items():
prices.append({
'id': price_data.get('id'),
'stripe_price_id': price_data.get('stripe_price_id') or price_data.get('id'),
'product': price_data.get('product', {}).get('id'),
'lookup_key': price_data.get('lookup_key'),
'ssp_product_slug': price_data.get('ssp_product_slug'),
'recurring': price_data.get('recurring', {}),
'currency': price_data.get('currency'),
'unit_amount': price_data.get('unit_amount'),
Expand Down
10 changes: 10 additions & 0 deletions enterprise_access/apps/bffs/checkout/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,17 @@ class PriceSerializer(serializers.Serializer):
Serializer for Stripe price objects in checkout context.
"""
id = serializers.CharField(help_text="Stripe Price ID")
stripe_price_id = serializers.CharField(required=False, help_text="Stripe Price ID")
product = serializers.CharField(help_text="Stripe Product ID")
lookup_key = serializers.CharField(help_text="Lookup key for this price")
recurring = serializers.DictField(
help_text="Recurring billing configuration"
)
ssp_product_slug = serializers.CharField(
required=False,
allow_null=True,
help_text="SSP product slug"
)
currency = serializers.CharField(help_text="Currency code (e.g. 'usd')")
unit_amount = serializers.IntegerField(help_text="Price amount in cents")
unit_amount_decimal = serializers.DecimalField(
Expand Down Expand Up @@ -214,6 +220,10 @@ class CheckoutValidationRequestSerializer(serializers.Serializer):
enterprise_slug = serializers.SlugField(required=False, allow_blank=True, help_text="Desired enterprise slug")
quantity = serializers.IntegerField(required=False, allow_null=True, help_text="Number of licenses")
stripe_price_id = serializers.CharField(required=False, allow_blank=True, help_text="Stripe price ID")
ssp_product_slug = serializers.SlugField(
required=False, allow_blank=True,
help_text="SSP product slug for the selected plan",
)


class UserAuthInfoSerializer(serializers.Serializer):
Expand Down
Loading
Loading