Skip to content
Merged
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
82 changes: 82 additions & 0 deletions enterprise_access/apps/api/serializers/checkout_bff.py
Original file line number Diff line number Diff line change
@@ -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")
123 changes: 123 additions & 0 deletions enterprise_access/apps/api/v1/tests/test_checkout_bff_views.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions enterprise_access/apps/api/v1/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
1 change: 1 addition & 0 deletions enterprise_access/apps/api/v1/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
84 changes: 84 additions & 0 deletions enterprise_access/apps/api/v1/views/bffs/checkout.py
Original file line number Diff line number Diff line change
@@ -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
# )
Comment on lines +77 to +82
Copy link
Contributor

Choose a reason for hiding this comment

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

[Note] I wonder if request.user works with this BaseUnauthenticatedBFFViewSet? I think the answer is yes, but just wanted to note that subsequent PRs to actually implement this skeleton will depend on request.user getting set to the logged-in User in order to successfully extract lms_user_id to help calculate existing_customers_for_authenticated_user.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Django gives us anonymous users out of the box, here's a breakpoint on an un-auth'd request:

(Pdb) request.user
<django.contrib.auth.models.AnonymousUser object at 0xf040653af920>
(Pdb) request.user.is_authenticated
False

Copy link
Contributor

@pwnage101 pwnage101 Jul 14, 2025

Choose a reason for hiding this comment

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

Gotcha, I wonder if setting viewset auth vars like this would help:

  • authentication_classes = [JwtAuthentication]
  • permission_classes = [IsAuthenticated, AllowAny]

The idea is that unauthenticated requests would fail JwtAuthentication and get request.user = AnonymousUser but pass AllowAny permmission check, while authenticated requests would go through both JwtAuthentication -> IsAuthenticated.

Testing...

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh you only tested un-authed, so it might still work out of the box for authenticated users...I think we can address this in a separate PR.

Copy link
Contributor

@pwnage101 pwnage101 Jul 14, 2025

Choose a reason for hiding this comment

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

After local testing, it doesn't seem to work out of the box, but like I said I think we can address in a separate PR more specific to the BFF framework code.

I suspect that leveraging permission composability from DRF 3.9+ (we run 3.16 today) would make the solution quite simple with the | operator:

authentication_classes = [JwtAuthentication]
permission_classes = [IsAuthenticated | AllowAny]

But this currently breaks internal logic within edx_rest_framework_extensions:

  File "/Users/tsankey/.pyenv/versions/3.12.4/envs/enterprise-access/lib/python3.12/site-packages/edx_rest_framework_extensions/auth/jwt/middleware.py", line 333, in <genexpr>
    issubclass(current_class, base_class) for current_class in iter_classes
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: issubclass() arg 1 must be a class

Copy link
Contributor

Choose a reason for hiding this comment

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


return Response(context_data)
1 change: 1 addition & 0 deletions enterprise_access/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,4 @@
PRODUCT_ID_TO_CATALOG_QUERY_ID_MAPPING = {
'1': 42,
}
ENABLE_CUSTOMER_BILLING_API = True