Skip to content

Commit c3d72e9

Browse files
committed
feat: checkout context BFF skeleton
ENT-10629
1 parent 55cadea commit c3d72e9

File tree

6 files changed

+293
-0
lines changed

6 files changed

+293
-0
lines changed
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""
2+
Serializers for the Checkout BFF endpoints.
3+
"""
4+
from rest_framework import serializers
5+
6+
7+
# pylint: disable=abstract-method
8+
class EnterpriseCustomerSerializer(serializers.Serializer):
9+
"""
10+
Serializer for enterprise customer data in checkout context.
11+
"""
12+
customer_uuid = serializers.CharField()
13+
customer_name = serializers.CharField()
14+
customer_slug = serializers.CharField()
15+
stripe_customer_id = serializers.CharField()
16+
is_self_service = serializers.BooleanField(default=False)
17+
admin_portal_url = serializers.CharField()
18+
19+
20+
class PriceSerializer(serializers.Serializer):
21+
"""
22+
Serializer for Stripe price objects in checkout context.
23+
"""
24+
id = serializers.CharField(help_text="Stripe Price ID")
25+
product = serializers.CharField(help_text="Stripe Product ID")
26+
lookup_key = serializers.CharField(help_text="Lookup key for this price")
27+
recurring = serializers.DictField(
28+
help_text="Recurring billing configuration"
29+
)
30+
currency = serializers.CharField(help_text="Currency code (e.g. 'usd')")
31+
unit_amount = serializers.IntegerField(help_text="Price amount in cents")
32+
unit_amount_decimal = serializers.CharField(help_text="Price amount as decimal string")
33+
34+
35+
class PricingDataSerializer(serializers.Serializer):
36+
"""
37+
Serializer for pricing data in checkout context.
38+
"""
39+
default_by_lookup_key = serializers.CharField(
40+
help_text="Lookup key for the default price option"
41+
)
42+
prices = PriceSerializer(many=True, help_text="Available price options")
43+
44+
45+
class QuantityConstraintSerializer(serializers.Serializer):
46+
"""
47+
Serializer for quantity constraints.
48+
"""
49+
min = serializers.IntegerField(help_text="Minimum allowed quantity")
50+
max = serializers.IntegerField(help_text="Maximum allowed quantity")
51+
52+
53+
class SlugConstraintSerializer(serializers.Serializer):
54+
"""
55+
Serializer for enterprise slug constraints.
56+
"""
57+
min_length = serializers.IntegerField(help_text="Minimum slug length")
58+
max_length = serializers.IntegerField(help_text="Maximum slug length")
59+
pattern = serializers.CharField(help_text="Regex pattern for valid slugs")
60+
61+
62+
class FieldConstraintsSerializer(serializers.Serializer):
63+
"""
64+
Serializer for field constraints in checkout context.
65+
66+
TODO: the field constraints should be expanded to more closely match the mins/maxes within this code block:
67+
https://github.com/edx/frontend-app-enterprise-checkout/blob/main/src/constants.ts#L13-L39
68+
"""
69+
quantity = QuantityConstraintSerializer(help_text="Constraints for license quantity")
70+
enterprise_slug = SlugConstraintSerializer(help_text="Constraints for enterprise slug")
71+
72+
73+
class CheckoutContextResponseSerializer(serializers.Serializer):
74+
"""
75+
Serializer for the checkout context response.
76+
"""
77+
existing_customers_for_authenticated_user = EnterpriseCustomerSerializer(
78+
many=True,
79+
help_text="Enterprise customers associated with the authenticated user (empty for unauthenticated users)"
80+
)
81+
pricing = PricingDataSerializer(help_text="Available pricing options")
82+
field_constraints = FieldConstraintsSerializer(help_text="Constraints for form fields")
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""
2+
Tests for the Checkout BFF ViewSet.
3+
"""
4+
import uuid
5+
6+
from django.urls import reverse
7+
from rest_framework import status
8+
9+
from enterprise_access.apps.api.serializers.checkout_bff import (
10+
CheckoutContextResponseSerializer,
11+
EnterpriseCustomerSerializer,
12+
PriceSerializer
13+
)
14+
from enterprise_access.apps.core.constants import SYSTEM_ENTERPRISE_LEARNER_ROLE
15+
from test_utils import APITest
16+
17+
18+
class CheckoutBFFViewSetTests(APITest):
19+
"""
20+
Tests for the Checkout BFF ViewSet.
21+
"""
22+
23+
def setUp(self):
24+
super().setUp()
25+
self.url = reverse('api:v1:checkout-bff-context')
26+
27+
def test_context_endpoint_unauthenticated_access(self):
28+
"""
29+
Test that unauthenticated users can access the context endpoint.
30+
"""
31+
response = self.client.post(self.url, {}, format='json')
32+
self.assertEqual(response.status_code, status.HTTP_200_OK)
33+
34+
# Verify response structure matches our expectations
35+
self.assertIn('existing_customers_for_authenticated_user', response.data)
36+
self.assertIn('pricing', response.data)
37+
self.assertIn('field_constraints', response.data)
38+
39+
# For unauthenticated users, existing_customers should be empty
40+
self.assertEqual(len(response.data['existing_customers_for_authenticated_user']), 0)
41+
# TODO: remove
42+
self.assertIsNone(response.data['user_id'])
43+
44+
def test_context_endpoint_authenticated_access(self):
45+
"""
46+
Test that authenticated users can access the context endpoint.
47+
"""
48+
self.set_jwt_cookie([{
49+
'system_wide_role': SYSTEM_ENTERPRISE_LEARNER_ROLE,
50+
'context': str(uuid.uuid4()),
51+
}])
52+
53+
response = self.client.post(self.url, {}, format='json')
54+
self.assertEqual(response.status_code, status.HTTP_200_OK)
55+
56+
# Verify response structure matches our expectations
57+
self.assertIn('existing_customers_for_authenticated_user', response.data)
58+
self.assertIn('pricing', response.data)
59+
self.assertIn('field_constraints', response.data)
60+
# TODO: remove
61+
self.assertEqual(response.data['user_id'], self.user.id)
62+
63+
def test_response_serializer_validation(self):
64+
"""
65+
Test that our response serializer validates the expected response structure.
66+
"""
67+
# Create sample data matching our expected response structure
68+
sample_data = {
69+
'existing_customers_for_authenticated_user': [],
70+
'pricing': {
71+
'default_by_lookup_key': 'b2b_enterprise_self_service_yearly',
72+
'prices': []
73+
},
74+
'field_constraints': {
75+
'quantity': {'min': 5, 'max': 30},
76+
'enterprise_slug': {
77+
'min_length': 3,
78+
'max_length': 30,
79+
'pattern': '^[a-z0-9-]+$'
80+
}
81+
}
82+
}
83+
84+
# Validate using our serializer
85+
serializer = CheckoutContextResponseSerializer(data=sample_data)
86+
self.assertTrue(serializer.is_valid(), serializer.errors)
87+
88+
def test_enterprise_customer_serializer(self):
89+
"""
90+
Test that EnterpriseCustomerSerializer correctly validates data.
91+
"""
92+
sample_data = {
93+
'customer_uuid': 'abc123',
94+
'customer_name': 'Test Enterprise',
95+
'customer_slug': 'test-enterprise',
96+
'stripe_customer_id': 'cus_123ABC',
97+
'is_self_service': True,
98+
'admin_portal_url': 'https://example.com/enterprise/test-enterprise'
99+
}
100+
101+
serializer = EnterpriseCustomerSerializer(data=sample_data)
102+
self.assertTrue(serializer.is_valid(), serializer.errors)
103+
104+
def test_price_serializer(self):
105+
"""
106+
Test that PriceSerializer correctly validates data.
107+
"""
108+
sample_data = {
109+
'id': 'price_123ABC',
110+
'product': 'prod_123ABC',
111+
'lookup_key': 'b2b_enterprise_self_service_yearly',
112+
'recurring': {
113+
'interval': 'month',
114+
'interval_count': 12,
115+
'trial_period_days': 14,
116+
},
117+
'currency': 'usd',
118+
'unit_amount': 10000,
119+
'unit_amount_decimal': '10000'
120+
}
121+
122+
serializer = PriceSerializer(data=sample_data)
123+
self.assertTrue(serializer.is_valid(), serializer.errors)

enterprise_access/apps/api/v1/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@
3939
# BFFs
4040
router.register('bffs/learner', views.LearnerPortalBFFViewSet, 'learner-portal-bff')
4141
router.register('bffs/health', views.PingViewSet, 'bff-health')
42+
if settings.ENABLE_CUSTOMER_BILLING_API:
43+
router.register('bffs/checkout', views.CheckoutBFFViewSet, 'checkout-bff')
4244

4345
# Other endpoints
4446
urlpatterns = [

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
existing imports of browse and request AND access policy views.
44
"""
55
from .admin_portal_learner_profile import AdminLearnerProfileViewSet
6+
from .bffs.checkout import CheckoutBFFViewSet
67
from .bffs.common import PingViewSet
78
from .bffs.learner_portal import LearnerPortalBFFViewSet
89
from .browse_and_request import (
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""
2+
ViewSet for the Checkout BFF endpoints.
3+
"""
4+
import logging
5+
6+
from drf_spectacular.utils import OpenApiResponse, extend_schema
7+
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
8+
from rest_framework.decorators import action
9+
from rest_framework.permissions import OR, AllowAny, IsAuthenticated
10+
from rest_framework.response import Response
11+
12+
from enterprise_access.apps.api.serializers.checkout_bff import CheckoutContextResponseSerializer
13+
from enterprise_access.apps.api.v1.views.bffs.common import BaseUnauthenticatedBFFViewSet
14+
from enterprise_access.apps.bffs.context import BaseHandlerContext
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
class CheckoutBFFViewSet(BaseUnauthenticatedBFFViewSet):
20+
"""
21+
ViewSet for checkout-related BFF endpoints.
22+
23+
These endpoints serve both authenticated and unauthenticated users.
24+
"""
25+
26+
authentication_classes = [JwtAuthentication]
27+
28+
def get_permissions(self):
29+
"""
30+
Compose authenticated and unauthenticated permissions
31+
to allow access to both user types, so that we can access ``request.user``
32+
and get either a hydrated user object from the JWT, or an AnonymousUser
33+
if not authenticated.
34+
"""
35+
return [OR(IsAuthenticated(), AllowAny())]
36+
37+
@extend_schema(
38+
operation_id="checkout_context",
39+
summary="Get checkout context",
40+
description=(
41+
"Provides context information for the checkout flow, including pricing options "
42+
"and, for authenticated users, associated enterprise customers."
43+
),
44+
responses={
45+
200: OpenApiResponse(
46+
description="Success response with checkout context data.",
47+
response=CheckoutContextResponseSerializer,
48+
),
49+
},
50+
tags=["Checkout BFF"],
51+
)
52+
@action(detail=False, methods=["post"], url_path="context")
53+
def context(self, request):
54+
"""
55+
Provides context information for the checkout flow.
56+
57+
This includes pricing options for self-service subscription plans and,
58+
for authenticated users, information about associated enterprise customers.
59+
"""
60+
# We'll eventually replace this with proper handler loading
61+
# For now, let's return a skeleton response
62+
context_data = {
63+
# TODO: user_id is just here for testing purposes related to self.get_permissions(),
64+
# remove once this view actually utilizes request.user
65+
"user_id": request.user.id if request.user.is_authenticated else None,
66+
"existing_customers_for_authenticated_user": [],
67+
"pricing": {
68+
"default_by_lookup_key": "b2b_enterprise_self_service_yearly",
69+
"prices": []
70+
},
71+
"field_constraints": {
72+
"quantity": {"min": 5, "max": 30},
73+
"enterprise_slug": {"min_length": 3, "max_length": 30, "pattern": "^[a-z0-9-]+$"}
74+
}
75+
}
76+
77+
# Eventually we'll use the BFF pattern to load and process data
78+
# context_data, status_code = self.load_route_data_and_build_response(
79+
# request,
80+
# CheckoutContextHandler,
81+
# CheckoutContextResponseBuilder
82+
# )
83+
84+
return Response(context_data)

enterprise_access/settings/test.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,4 @@
7979
PRODUCT_ID_TO_CATALOG_QUERY_ID_MAPPING = {
8080
'1': 42,
8181
}
82+
ENABLE_CUSTOMER_BILLING_API = True

0 commit comments

Comments
 (0)