Skip to content

Commit

Permalink
Stripe Proof-of-concept.
Browse files Browse the repository at this point in the history
ENT-10018
  • Loading branch information
pwnage101 committed Feb 21, 2025
1 parent 545c22e commit afc47e3
Show file tree
Hide file tree
Showing 16 changed files with 418 additions and 2 deletions.
1 change: 1 addition & 0 deletions enterprise_access/apps/api/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
AssignmentConfigurationResponseSerializer,
AssignmentConfigurationUpdateRequestSerializer
)
from .customer_billing import CustomerBillingCreatePlanRequestSerializer
from .subsidy_access_policy import (
GroupMemberWithAggregatesRequestSerializer,
GroupMemberWithAggregatesResponseSerializer,
Expand Down
16 changes: 16 additions & 0 deletions enterprise_access/apps/api/serializers/customer_billing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""
customer billing serializers
"""

from rest_framework import serializers


# pylint: disable=abstract-method
class CustomerBillingCreatePlanRequestSerializer(serializers.Serializer):
"""
Request serializer for body of POST requests to /api/v1/customer-billing/create-plan
"""
email = serializers.EmailField(required=True)
slug = serializers.SlugField(required=True)
num_licneses = serializers.IntegerField(required=True, min_value=1)
stripe_price_id = serializers.CharField(required=True)
12 changes: 12 additions & 0 deletions enterprise_access/apps/api/v1/urls.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
""" API v1 URLs. """

from django.conf import settings
from django.urls import path
from rest_framework.routers import DefaultRouter

Expand All @@ -26,6 +27,8 @@
views.LearnerContentAssignmentViewSet,
'assignments',
)
if settings.ENABLE_CUSTOMER_BILLING_API:
router.register("customer-billing", views.CustomerBillingViewSet, 'customer-billing')

# BFFs
router.register('bffs/learner', views.LearnerPortalBFFViewSet, 'learner-portal-bff')
Expand All @@ -39,4 +42,13 @@
),
]

if settings.ENABLE_CUSTOMER_BILLING_API:
urlpatterns += [
path(
'customer-billing/stripe-webhook',
views.CustomerBillingStripeWebHookView.as_view({'post': 'stripe_webhook'}),
name='stripe-webhook'
),
]

urlpatterns += router.urls
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 @@ -12,6 +12,7 @@
from .content_assignments.assignment_configuration import AssignmentConfigurationViewSet
from .content_assignments.assignments import LearnerContentAssignmentViewSet
from .content_assignments.assignments_admin import LearnerContentAssignmentAdminViewSet
from .customer_billing import CustomerBillingStripeWebHookView, CustomerBillingViewSet
from .subsidy_access_policy import (
SubsidyAccessPolicyAllocateViewset,
SubsidyAccessPolicyGroupViewset,
Expand Down
178 changes: 178 additions & 0 deletions enterprise_access/apps/api/v1/views/customer_billing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
"""
REST API views for the Stripe PoC.
"""
import json
import logging

import requests
import stripe
from django.views.decorators.csrf import csrf_exempt
from django.conf import settings
from drf_spectacular.utils import extend_schema
from edx_rbac.decorators import permission_required
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response

from enterprise_access.apps.api import serializers
from enterprise_access.apps.api_client.lms_client import LmsApiClient
from enterprise_access.apps.core.constants import (
CUSTOMER_BILLING_CREATE_PLAN_PERMISSION,
CUSTOMER_BILLING_CREATE_PORTAL_SESSION_PERMISSION
)

stripe.api_key = settings.STRIPE_API_KEY
logger = logging.getLogger(__name__)

CUSTOMER_BILLING_API_TAG = 'Customer Billing'


class CustomerBillingStripeWebHookView(viewsets.ViewSet):
"""
Viewset supporting the Stripe WebHook to receive events.
"""
# This unauthenticated endpoint will rely on view logic to perform authentication via signature validation.
permission_classes = (permissions.AllowAny,)

@extend_schema(
tags=[CUSTOMER_BILLING_API_TAG],
summary='Listen for events from Stripe.',
)
@action(detail=False, methods=['post'])
@csrf_exempt
def stripe_webhook(self, request, *args, **kwargs):
"""
Listen for events from Stripe, and take specific actions. Typically the action is to send a confirmation email.
PoC Notes:
* For a real production implementation we should implement signature validation:
- https://docs.stripe.com/webhooks/signature
- This endpoint is un-authenticated, so the only defense we have against spoofed events is signature
validation.
* For a real production implementation we should implement event de-duplication:
- https://docs.stripe.com/webhooks/process-undelivered-events
- This is a safeguard against the remote possibility that an event is sent twice. This could happen if the
network connection cuts out at the exact moment between successfully processing an event and responding with
HTTP 200, in which case Stripe will attemt to re-send the event since it does not know we successfully
received it.
"""
payload = request.body
event = None

try:
event = stripe.Event.construct_from(json.loads(payload), stripe.api_key)
except ValueError:
return Response(
'Stripe WebHook event payload was invalid.',
status=status.HTTP_400_BAD_REQUEST,
)

event_type = event["type"]
logger.info(f'Received Stripe event: {event_type}')

if event_type == 'invoice.paid':
pass
elif event_type == 'customer.subscription.trial_will_end':
pass
elif event_type == 'payment_method.attached':
pass
elif event_type == 'customer.subscription.deleted':
pass

return Response(status=status.HTTP_200_OK)


class CustomerBillingViewSet(viewsets.ViewSet):
"""
Viewset supporting all operations pertaining to customer billing.
"""
permission_classes = (permissions.IsAuthenticated,)
authentication_classes = (JwtAuthentication,)

@extend_schema(
tags=[CUSTOMER_BILLING_API_TAG],
summary='Create a new billing plan given form data from a prospective customer, and return an invoice.',
request=serializers.CustomerBillingCreatePlanRequestSerializer,
)
@action(detail=False, methods=['post'])
@permission_required(CUSTOMER_BILLING_CREATE_PLAN_PERMISSION)
def create_plan(self, request, *args, **kwargs):
"""
Create a new billing plan (as a free trial). Response dict is a pass-through Stripe Checkout Session object.
Response structure defined here: https://docs.stripe.com/api/checkout/sessions/create
"""
form_email = request.data.get('email')
form_slug = request.data.get('slug')
form_num_licenses = request.data.get('num_licenses')
form_stripe_price_id = request.data.get('stripe_price_id')
lms_client = LmsApiClient()

# First, try to get the enterprise customer data. For this PoC, I'm not prepared to support existing customers,
# so block the request if that happens.
try:
lms_client.get_enterprise_customer_data(enterprise_customer_slug=form_slug)
except requests.exceptions.HTTPError:
logger.info(f'No existing customer found for slug {form_slug}. Creating plan.')
else:
message = f'Existing customer found for slug {form_slug}. Cannot create plan.'
logger.warning(message)
return Response(message, status=status.HTTP_403_FORBIDDEN)

# Eagerly find an existing Stripe customer if one already exists with the same email.
stripe_customer_search_result = stripe.Customer.search(query=f"email: '{form_email}'")
found_stripe_customer_by_email = next(iter(stripe_customer_search_result['data']), None)

checkout_session = stripe.checkout.Session.create(
# Passing None to ``customer`` causes Stripe to create a new one, so try first to use an existing customer.
customer=found_stripe_customer_by_email['id'] if found_stripe_customer_by_email else None,
mode="subscription",
# Avoid needing to create custom frontends for PoC by using a hosted checkout page.
ui_mode="hosted",
# This normally wouldn't work because the customer doesn't exist yet --- I'd propose we modify the admin
# portal to support an empty state with a message like "turning cogs, check back later." if there's no
# Enterprise Customer but there is a Stripe Customer.
#return_url=f"https://portal.edx.org/{form_slug}",
line_items=[{
"price": form_stripe_price_id,
"quantity": form_num_licenses,
}],
# Defer payment collection until the last moment, then cancel
# the subscription if payment info has not been submitted.
subscription_data={
"trial_period_days": 7,
"trial_settings": {
"end_behavior": {"missing_payment_method": "cancel"},
},
},
)
return Response(checkout_session, status=status.HTTP_201_CREATED)

@extend_schema(
tags=[CUSTOMER_BILLING_API_TAG],
summary='Create a new Customer Portal Session.',
)
@action(detail=True, methods=['get'])
@permission_required(CUSTOMER_BILLING_CREATE_PORTAL_SESSION_PERMISSION, fn=lambda request, pk: pk)
def create_portal_session(self, request, pk=None, **kwargs):
"""
Create a new Customer Portal Session. Response dict contains "url" key
that should be attached to a button that the customer clicks.
Response structure defined here: https://docs.stripe.com/api/customer_portal/sessions/create
"""
lms_client = LmsApiClient()
# First, fetch the enterprise customer data.
try:
enterprise_customer_data = lms_client.get_enterprise_customer_data(pk)
except requests.exceptions.HTTPError:
return Response(None, status=status.HTTP_404_NOT_FOUND)

# Next, create a stripe customer portal session.
customer_portal_session = stripe.billing_portal.Session.create(
customer=enterprise_customer_data['stripe_customer_id'],
return_url=f"https://portal.edx.org/{enterprise_customer_data['slug']}",
)

return Response(customer_portal_session, status=status.HTTP_200_OK)
6 changes: 4 additions & 2 deletions enterprise_access/apps/api_client/lms_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,10 @@ def get_enterprise_customer_data(self, enterprise_customer_uuid=None, enterprise
response = self.client.get(endpoint, timeout=settings.LMS_CLIENT_TIMEOUT)
response.raise_for_status()
payload = response.json()
if results := payload.get('results'):
return results[0]
if 'results' in payload:
if len(payload['results']) == 0:
raise requests.exceptions.HTTPError()
return payload['results'][0]
return payload
except requests.exceptions.HTTPError as exc:
logger.exception(exc)
Expand Down
5 changes: 5 additions & 0 deletions enterprise_access/apps/core/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@
BFF_OPERATOR_ROLE = 'enterprise_access_bff_operator'
BFF_READ_PERMISSION = 'bff.has_read_access'

CUSTOMER_BILLING_OPERATOR_ROLE = 'enterprise_access_customer_billing_operator'
CUSTOMER_BILLING_ADMIN_ROLE = 'enterprise_access_customer_billing_admin'
CUSTOMER_BILLING_CREATE_PLAN_PERMISSION = 'customer_billing.create_plan'
CUSTOMER_BILLING_CREATE_PORTAL_SESSION_PERMISSION = 'customer_billing.create_portal_session'

ALL_ACCESS_CONTEXT = '*'


Expand Down
69 changes: 69 additions & 0 deletions enterprise_access/apps/core/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,53 @@ def has_explicit_access_to_bff_operator(user, enterprise_customer_uuid):
return _has_explicit_access_to_role(user, enterprise_customer_uuid, constants.BFF_OPERATOR_ROLE)


# Customer Billing rule predicates:
@rules.predicate
def has_implicit_access_to_customer_billing_operator(_, enterprise_customer_uuid):
"""
Check that if request user has implicit access to the given enterprise UUID for the
`CUSTOMER_BILLING_OPERATOR_ROLE` feature role.
Returns:
boolean: whether the request user has access.
"""
return _has_implicit_access_to_role(_, enterprise_customer_uuid, constants.CUSTOMER_BILLING_OPERATOR_ROLE)


@rules.predicate
def has_explicit_access_to_customer_billing_operator(user, enterprise_customer_uuid):
"""
Check that if request user has explicit access to `CUSTOMER_BILLING_OPERATOR_ROLE` feature role.
Returns:
boolean: whether the request user has access.
"""
return _has_explicit_access_to_role(user, enterprise_customer_uuid, constants.CUSTOMER_BILLING_OPERATOR_ROLE)


@rules.predicate
def has_implicit_access_to_customer_billing_admin(_, enterprise_customer_uuid):
"""
Check that if request user has implicit access to the given enterprise UUID for the
`CUSTOMER_BILLING_ADMIN_ROLE` feature role.
Returns:
boolean: whether the request user has access.
"""
return _has_implicit_access_to_role(_, enterprise_customer_uuid, constants.CUSTOMER_BILLING_ADMIN_ROLE)


@rules.predicate
def has_explicit_access_to_customer_billing_admin(user, enterprise_customer_uuid):
"""
Check that if request user has explicit access to `CUSTOMER_BILLING_ADMIN_ROLE` feature role.
Returns:
boolean: whether the request user has access.
"""
return _has_explicit_access_to_role(user, enterprise_customer_uuid, constants.CUSTOMER_BILLING_ADMIN_ROLE)


######################################################
# Consolidate implicit and explicit rule predicates. #
######################################################
Expand Down Expand Up @@ -329,6 +376,16 @@ def has_explicit_access_to_bff_operator(user, enterprise_customer_uuid):
)


has_customer_billing_operator_access = (
has_implicit_access_to_customer_billing_operator | has_explicit_access_to_customer_billing_operator
)


has_customer_billing_admin_access = (
has_implicit_access_to_customer_billing_admin | has_explicit_access_to_customer_billing_admin
)


###############################################
# Map permissions to consolidated predicates. #
###############################################
Expand Down Expand Up @@ -434,3 +491,15 @@ def has_explicit_access_to_bff_operator(user, enterprise_customer_uuid):
has_bff_operator_access
),
)

# Grants billing plan creation permissions to operators only.
rules.add_perm(
constants.CUSTOMER_BILLING_CREATE_PLAN_PERMISSION,
has_customer_billing_operator_access,
)

# Grants billing plan "create portal session" permissions to operators+admins.
rules.add_perm(
constants.CUSTOMER_BILLING_CREATE_PORTAL_SESSION_PERMISSION,
has_customer_billing_operator_access | has_customer_billing_admin_access,
)
4 changes: 4 additions & 0 deletions enterprise_access/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,3 +534,7 @@ def root(*path_fragments):

# Settings for creation of enterprise customers
DEFAULT_CUSTOMER_SITE = 'example.com'

# Enable the customer billing API endpoints under /api/v1/customer-billing/*
ENABLE_CUSTOMER_BILLING_API = True
STRIPE_API_KEY = None
2 changes: 2 additions & 0 deletions requirements/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,8 @@ stevedore==5.4.0
# code-annotations
# edx-django-utils
# edx-opaque-keys
stripe==11.5.0
# via -r requirements/base.in
text-unidecode==1.3
# via python-slugify
typing-extensions==4.12.2
Expand Down
2 changes: 2 additions & 0 deletions requirements/dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,8 @@ stevedore==5.4.0
# code-annotations
# edx-django-utils
# edx-opaque-keys
stripe==11.5.0
# via -r /Users/tsankey/tmp/edx/enterprise-access/requirements/validation.txt
text-unidecode==1.3
# via
# -r /home/runner/work/enterprise-access/enterprise-access/requirements/validation.txt
Expand Down
2 changes: 2 additions & 0 deletions requirements/production.txt
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,8 @@ stevedore==5.4.0
# code-annotations
# edx-django-utils
# edx-opaque-keys
stripe==11.5.0
# via -r /Users/tsankey/tmp/edx/enterprise-access/requirements/base.txt
text-unidecode==1.3
# via
# -r /home/runner/work/enterprise-access/enterprise-access/requirements/base.txt
Expand Down
2 changes: 2 additions & 0 deletions requirements/quality.txt
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,8 @@ stevedore==5.4.0
# code-annotations
# edx-django-utils
# edx-opaque-keys
stripe==11.5.0
# via -r /Users/tsankey/tmp/edx/enterprise-access/requirements/test.txt
text-unidecode==1.3
# via
# -r /home/runner/work/enterprise-access/enterprise-access/requirements/test.txt
Expand Down
Loading

0 comments on commit afc47e3

Please sign in to comment.