diff --git a/enterprise_access/apps/api/serializers/checkout_bff.py b/enterprise_access/apps/api/serializers/checkout_bff.py new file mode 100644 index 000000000..b1ede7d24 --- /dev/null +++ b/enterprise_access/apps/api/serializers/checkout_bff.py @@ -0,0 +1,82 @@ +""" +Serializers for the Checkout BFF endpoints. +""" +from rest_framework import serializers + + +# pylint: disable=abstract-method +class EnterpriseCustomerSerializer(serializers.Serializer): + """ + Serializer for enterprise customer data in checkout context. + """ + customer_uuid = serializers.CharField() + customer_name = serializers.CharField() + customer_slug = serializers.CharField() + stripe_customer_id = serializers.CharField() + is_self_service = serializers.BooleanField(default=False) + admin_portal_url = serializers.CharField() + + +class PriceSerializer(serializers.Serializer): + """ + Serializer for Stripe price objects in checkout context. + """ + id = serializers.CharField(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" + ) + currency = serializers.CharField(help_text="Currency code (e.g. 'usd')") + unit_amount = serializers.IntegerField(help_text="Price amount in cents") + unit_amount_decimal = serializers.CharField(help_text="Price amount as decimal string") + + +class PricingDataSerializer(serializers.Serializer): + """ + Serializer for pricing data in checkout context. + """ + default_by_lookup_key = serializers.CharField( + help_text="Lookup key for the default price option" + ) + prices = PriceSerializer(many=True, help_text="Available price options") + + +class QuantityConstraintSerializer(serializers.Serializer): + """ + Serializer for quantity constraints. + """ + min = serializers.IntegerField(help_text="Minimum allowed quantity") + max = serializers.IntegerField(help_text="Maximum allowed quantity") + + +class SlugConstraintSerializer(serializers.Serializer): + """ + Serializer for enterprise slug constraints. + """ + min_length = serializers.IntegerField(help_text="Minimum slug length") + max_length = serializers.IntegerField(help_text="Maximum slug length") + pattern = serializers.CharField(help_text="Regex pattern for valid slugs") + + +class FieldConstraintsSerializer(serializers.Serializer): + """ + Serializer for field constraints in checkout context. + + TODO: the field constraints should be expanded to more closely match the mins/maxes within this code block: + https://github.com/edx/frontend-app-enterprise-checkout/blob/main/src/constants.ts#L13-L39 + """ + quantity = QuantityConstraintSerializer(help_text="Constraints for license quantity") + enterprise_slug = SlugConstraintSerializer(help_text="Constraints for enterprise slug") + + +class CheckoutContextResponseSerializer(serializers.Serializer): + """ + Serializer for the checkout context response. + """ + existing_customers_for_authenticated_user = EnterpriseCustomerSerializer( + many=True, + help_text="Enterprise customers associated with the authenticated user (empty for unauthenticated users)" + ) + pricing = PricingDataSerializer(help_text="Available pricing options") + field_constraints = FieldConstraintsSerializer(help_text="Constraints for form fields") diff --git a/enterprise_access/apps/api/v1/tests/test_checkout_bff_views.py b/enterprise_access/apps/api/v1/tests/test_checkout_bff_views.py new file mode 100644 index 000000000..3d06c2ae3 --- /dev/null +++ b/enterprise_access/apps/api/v1/tests/test_checkout_bff_views.py @@ -0,0 +1,123 @@ +""" +Tests for the Checkout BFF ViewSet. +""" +import uuid + +from django.urls import reverse +from rest_framework import status + +from enterprise_access.apps.api.serializers.checkout_bff import ( + CheckoutContextResponseSerializer, + EnterpriseCustomerSerializer, + PriceSerializer +) +from enterprise_access.apps.core.constants import SYSTEM_ENTERPRISE_LEARNER_ROLE +from test_utils import APITest + + +class CheckoutBFFViewSetTests(APITest): + """ + Tests for the Checkout BFF ViewSet. + """ + + def setUp(self): + super().setUp() + self.url = reverse('api:v1:checkout-bff-context') + + def test_context_endpoint_unauthenticated_access(self): + """ + Test that unauthenticated users can access the context endpoint. + """ + response = self.client.post(self.url, {}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Verify response structure matches our expectations + self.assertIn('existing_customers_for_authenticated_user', response.data) + self.assertIn('pricing', response.data) + self.assertIn('field_constraints', response.data) + + # For unauthenticated users, existing_customers should be empty + self.assertEqual(len(response.data['existing_customers_for_authenticated_user']), 0) + # TODO: remove + self.assertIsNone(response.data['user_id']) + + def test_context_endpoint_authenticated_access(self): + """ + Test that authenticated users can access the context endpoint. + """ + self.set_jwt_cookie([{ + 'system_wide_role': SYSTEM_ENTERPRISE_LEARNER_ROLE, + 'context': str(uuid.uuid4()), + }]) + + response = self.client.post(self.url, {}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Verify response structure matches our expectations + self.assertIn('existing_customers_for_authenticated_user', response.data) + self.assertIn('pricing', response.data) + self.assertIn('field_constraints', response.data) + # TODO: remove + self.assertEqual(response.data['user_id'], self.user.id) + + def test_response_serializer_validation(self): + """ + Test that our response serializer validates the expected response structure. + """ + # Create sample data matching our expected response structure + sample_data = { + 'existing_customers_for_authenticated_user': [], + 'pricing': { + 'default_by_lookup_key': 'b2b_enterprise_self_service_yearly', + 'prices': [] + }, + 'field_constraints': { + 'quantity': {'min': 5, 'max': 30}, + 'enterprise_slug': { + 'min_length': 3, + 'max_length': 30, + 'pattern': '^[a-z0-9-]+$' + } + } + } + + # Validate using our serializer + serializer = CheckoutContextResponseSerializer(data=sample_data) + self.assertTrue(serializer.is_valid(), serializer.errors) + + def test_enterprise_customer_serializer(self): + """ + Test that EnterpriseCustomerSerializer correctly validates data. + """ + sample_data = { + 'customer_uuid': 'abc123', + 'customer_name': 'Test Enterprise', + 'customer_slug': 'test-enterprise', + 'stripe_customer_id': 'cus_123ABC', + 'is_self_service': True, + 'admin_portal_url': 'https://example.com/enterprise/test-enterprise' + } + + serializer = EnterpriseCustomerSerializer(data=sample_data) + self.assertTrue(serializer.is_valid(), serializer.errors) + + def test_price_serializer(self): + """ + Test that PriceSerializer correctly validates data. + """ + sample_data = { + 'id': 'price_123ABC', + 'product': 'prod_123ABC', + 'lookup_key': 'b2b_enterprise_self_service_yearly', + 'recurring': { + 'interval': 'month', + 'interval_count': 12, + 'trial_period_days': 14, + }, + 'currency': 'usd', + 'unit_amount': 10000, + 'unit_amount_decimal': '10000' + } + + serializer = PriceSerializer(data=sample_data) + self.assertTrue(serializer.is_valid(), serializer.errors) diff --git a/enterprise_access/apps/api/v1/urls.py b/enterprise_access/apps/api/v1/urls.py index 9bf0f18e4..cccf5ccd5 100644 --- a/enterprise_access/apps/api/v1/urls.py +++ b/enterprise_access/apps/api/v1/urls.py @@ -39,6 +39,8 @@ # BFFs router.register('bffs/learner', views.LearnerPortalBFFViewSet, 'learner-portal-bff') router.register('bffs/health', views.PingViewSet, 'bff-health') +if settings.ENABLE_CUSTOMER_BILLING_API: + router.register('bffs/checkout', views.CheckoutBFFViewSet, 'checkout-bff') # Other endpoints urlpatterns = [ diff --git a/enterprise_access/apps/api/v1/views/__init__.py b/enterprise_access/apps/api/v1/views/__init__.py index 39b1cdfe4..5bc36023e 100644 --- a/enterprise_access/apps/api/v1/views/__init__.py +++ b/enterprise_access/apps/api/v1/views/__init__.py @@ -3,6 +3,7 @@ existing imports of browse and request AND access policy views. """ from .admin_portal_learner_profile import AdminLearnerProfileViewSet +from .bffs.checkout import CheckoutBFFViewSet from .bffs.common import PingViewSet from .bffs.learner_portal import LearnerPortalBFFViewSet from .browse_and_request import ( diff --git a/enterprise_access/apps/api/v1/views/bffs/checkout.py b/enterprise_access/apps/api/v1/views/bffs/checkout.py new file mode 100644 index 000000000..edcfea142 --- /dev/null +++ b/enterprise_access/apps/api/v1/views/bffs/checkout.py @@ -0,0 +1,84 @@ +""" +ViewSet for the Checkout BFF endpoints. +""" +import logging + +from drf_spectacular.utils import OpenApiResponse, extend_schema +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from rest_framework.decorators import action +from rest_framework.permissions import OR, AllowAny, IsAuthenticated +from rest_framework.response import Response + +from enterprise_access.apps.api.serializers.checkout_bff import CheckoutContextResponseSerializer +from enterprise_access.apps.api.v1.views.bffs.common import BaseUnauthenticatedBFFViewSet +from enterprise_access.apps.bffs.context import BaseHandlerContext + +logger = logging.getLogger(__name__) + + +class CheckoutBFFViewSet(BaseUnauthenticatedBFFViewSet): + """ + ViewSet for checkout-related BFF endpoints. + + These endpoints serve both authenticated and unauthenticated users. + """ + + authentication_classes = [JwtAuthentication] + + def get_permissions(self): + """ + Compose authenticated and unauthenticated permissions + to allow access to both user types, so that we can access ``request.user`` + and get either a hydrated user object from the JWT, or an AnonymousUser + if not authenticated. + """ + return [OR(IsAuthenticated(), AllowAny())] + + @extend_schema( + operation_id="checkout_context", + summary="Get checkout context", + description=( + "Provides context information for the checkout flow, including pricing options " + "and, for authenticated users, associated enterprise customers." + ), + responses={ + 200: OpenApiResponse( + description="Success response with checkout context data.", + response=CheckoutContextResponseSerializer, + ), + }, + tags=["Checkout BFF"], + ) + @action(detail=False, methods=["post"], url_path="context") + def context(self, request): + """ + Provides context information for the checkout flow. + + This includes pricing options for self-service subscription plans and, + for authenticated users, information about associated enterprise customers. + """ + # We'll eventually replace this with proper handler loading + # For now, let's return a skeleton response + context_data = { + # TODO: user_id is just here for testing purposes related to self.get_permissions(), + # remove once this view actually utilizes request.user + "user_id": request.user.id if request.user.is_authenticated else None, + "existing_customers_for_authenticated_user": [], + "pricing": { + "default_by_lookup_key": "b2b_enterprise_self_service_yearly", + "prices": [] + }, + "field_constraints": { + "quantity": {"min": 5, "max": 30}, + "enterprise_slug": {"min_length": 3, "max_length": 30, "pattern": "^[a-z0-9-]+$"} + } + } + + # Eventually we'll use the BFF pattern to load and process data + # context_data, status_code = self.load_route_data_and_build_response( + # request, + # CheckoutContextHandler, + # CheckoutContextResponseBuilder + # ) + + return Response(context_data) diff --git a/enterprise_access/settings/test.py b/enterprise_access/settings/test.py index fd0a3e27c..060759580 100644 --- a/enterprise_access/settings/test.py +++ b/enterprise_access/settings/test.py @@ -79,3 +79,4 @@ PRODUCT_ID_TO_CATALOG_QUERY_ID_MAPPING = { '1': 42, } +ENABLE_CUSTOMER_BILLING_API = True