Skip to content

Commit 1225033

Browse files
committed
fix: cache explicitly on validator object
1 parent 4c3fcc7 commit 1225033

File tree

4 files changed

+61
-7
lines changed

4 files changed

+61
-7
lines changed
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Stripe Billing Architecture
2+
3+
## Overview
4+
5+
Enterprise Access acts as the **billing orchestrator** in the edX enterprise ecosystem, translating Stripe payment events into business actions across multiple services.
6+
7+
## Architecture Flow
8+
9+
```
10+
Stripe Billing → Enterprise Access → License Manager → Other Services
11+
Events CheckoutIntents SubscriptionPlans
12+
+ Webhooks + Licenses
13+
```
14+
15+
## Key Components
16+
17+
### Enterprise Access (This Service)
18+
- **CheckoutIntent**: Tracks self-service checkout lifecycle (CREATED → PAID → FULFILLED)
19+
- **StripeEventHandler**: Processes Stripe webhooks and updates CheckoutIntent state
20+
- **StripeEventData**: Persists all Stripe events for audit trail
21+
- **Provisioning Workflows**: Multi-step processes that create business records
22+
23+
### Stripe Integration
24+
- Webhooks send events to Enterprise Access (`/webhooks/stripe/`)
25+
- Events like `invoice.paid`, `customer.subscription.updated` trigger handlers
26+
- Each event is persisted and linked to relevant CheckoutIntent
27+
28+
### License Manager Integration
29+
- Enterprise Access makes REST API calls to License Manager
30+
- `CheckoutIntent.quantity` maps 1:1 to `SubscriptionPlan.num_licenses`
31+
- `CheckoutIntent.enterprise_uuid` links to License Manager's `CustomerAgreement`
32+
33+
## Typical Flow
34+
35+
1. **Customer pays** → Stripe sends `invoice.paid` webhook
36+
2. **Enterprise Access** receives webhook, marks CheckoutIntent as PAID
37+
3. **Provisioning workflow** triggered, creates enterprise customer
38+
4. **API calls** to License Manager create SubscriptionPlan + Licenses
39+
5. **Customer** can now assign licenses to learners
40+
41+
## Key Models
42+
43+
- **CheckoutIntent**: Central billing state machine
44+
- **StripeEventData**: Complete audit trail of Stripe events
45+
- **ProvisioningWorkflow**: Orchestrates cross-service business record creation
46+
47+
## Why This Design?
48+
49+
- **Single source of truth** for billing events
50+
- **Reliable event processing** with persistence and retry capabilities
51+
- **Clean separation** between billing (Enterprise Access) and licensing (License Manager)
52+
- **Audit trail** for compliance and debugging

enterprise_access/apps/customer_billing/api.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
"""
44
import logging
55
from collections.abc import Mapping
6-
from functools import cache
76
from typing import TypedDict, Unpack, cast
87

98
import stripe
@@ -56,8 +55,6 @@ class FieldValidationResult(TypedDict):
5655
developer_message: str | None
5756

5857

59-
# Basic in-memory cache to prevent multiple API calls within a single request.
60-
@cache
6158
def _get_lms_user_id(email: str | None) -> int | None:
6259
"""
6360
Return the LMS user ID for an existing user with a specific email, or None if no user with that email exists.
@@ -80,6 +77,11 @@ class CheckoutSessionInputValidator():
8077
https://github.com/openedx/edx-platform/blob/f90e59e5/openedx/core/djangoapps/user_authn/views/register.py#L727
8178
"""
8279

80+
def get_lms_user_id(self, email):
81+
if not hasattr(self, '_cached_lms_user_id'):
82+
self._cached_lms_user_id = _get_lms_user_id(email) # pylint: disable=attribute-defined-outside-init
83+
return self._cached_lms_user_id
84+
8385
def handle_admin_email(self, input_data: CheckoutSessionInputValidatorData) -> FieldValidationResult:
8486
"""
8587
Ensure the provided email is registered.
@@ -103,7 +105,7 @@ def handle_admin_email(self, input_data: CheckoutSessionInputValidatorData) -> F
103105
return {'error_code': error_code, 'developer_message': developer_message}
104106

105107
# Given email must be registered.
106-
lms_user_id = _get_lms_user_id(email=admin_email)
108+
lms_user_id = self.get_lms_user_id(email=admin_email)
107109
if not lms_user_id:
108110
error_code, developer_message = CHECKOUT_SESSION_ERROR_CODES['admin_email']['NOT_REGISTERED']
109111
logger.info(f'admin_email invalid: "{admin_email}". {developer_message}')
@@ -276,7 +278,7 @@ def handle_user(self, input_data: CheckoutSessionInputValidatorData) -> FieldVal
276278
**and** the lms_user_id is known and present in the User table.
277279
"""
278280
if not (user_record := input_data.get('user')):
279-
lms_user_id = _get_lms_user_id(input_data.get('admin_email'))
281+
lms_user_id = self.get_lms_user_id(input_data.get('admin_email'))
280282
user_record = User.objects.filter(lms_user_id=lms_user_id).first()
281283
if not user_record:
282284
error_code, developer_message = CHECKOUT_SESSION_ERROR_CODES['user']['IS_NULL']

enterprise_access/apps/customer_billing/apps.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44

55

66
class CustomerBillingConfig(AppConfig):
7+
""" App config for customer_billing. """
78
default_auto_field = 'django.db.models.BigAutoField'
89
name = 'enterprise_access.apps.customer_billing'
910

1011
def ready(self):
11-
import enterprise_access.apps.customer_billing.signals # noqa
12+
import enterprise_access.apps.customer_billing.signals # pylint: disable=import-outside-toplevel,unused-import

enterprise_access/apps/customer_billing/tests/test_api.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,6 @@ def setUp(self):
7676
self.other_user = UserFactory()
7777

7878
def tearDown(self):
79-
customer_billing_api._get_lms_user_id.cache_clear() # pylint: disable=protected-access
8079
# Clean up any intents created during tests
8180
CheckoutIntent.objects.all().delete()
8281

0 commit comments

Comments
 (0)