From 2b7b1c6fe9cb2d422444e5e7d3c79007fdfcf57c Mon Sep 17 00:00:00 2001 From: Petru Rares Sincraian Date: Wed, 5 Nov 2025 16:08:05 +0100 Subject: [PATCH 01/15] feat: first draft of business entity --- .../design-documents/business-entity.mdx | 418 ++++++++++++++++++ 1 file changed, 418 insertions(+) create mode 100644 engineering/design-documents/business-entity.mdx diff --git a/engineering/design-documents/business-entity.mdx b/engineering/design-documents/business-entity.mdx new file mode 100644 index 0000000..031e678 --- /dev/null +++ b/engineering/design-documents/business-entity.mdx @@ -0,0 +1,418 @@ + +**Status**: Draft +**Created**: November 5, 2025 +**Last Updated**: November 5, 2025 + +# Summary + +## Problem Statement + +The current seat-based billing implementation faces critical limitations in B2B multi-tenant scenarios: + +1. **Ambiguous Event Attribution**: When a customer belongs to multiple seat-based subscriptions from different organizations (e.g., Acme and Slack), events lack business context to determine which customer should be billed. +2. **Inflexible Billing Management**: Changing the billing manager becomes tricky and information is on one customer or another +3. **No Business-Level Aggregation**: When a business has multiple subscriptions from the same merchant, it's difficult to have business-level aggregation.s + +## Requirements +1. Attribute events when customer belongs to multiple businesses +2. Change billing manager without losing subscription history +3. Payment methods belong to business and not individuals +4. Both merchants and customers can creates businesses. +5. Enable business-level usage aggregation across different subscriptions +6. Support multiple billing managers per business + +--- + +# Current Architecture Analysis + +The current model is: +``` +Subscription +├─ customer_id (UUID) ───────────> Customer (Billing Manager) +├─ seats (int) # Total seats purchased +└─ customer_seats (List) + └─ CustomerSeat + ├─ customer_id (UUID) ───> Customer (Seat Holder) + ├─ status (SeatStatus) # pending, claimed, revoked + ├─ invitation_token + └─ claimed_at +``` + +**Key Files**: +- `server/polar/models/customer_seat.py` - Seat model with lifecycle +- `server/polar/models/subscription.py` - Subscription with seats field +- `server/polar/models/product_price.py` - ProductPriceSeatUnit with tiering +- `server/polar/customer_seat/service.py` - Seat assignment/claiming logic +- `server/polar/meter/service.py:424-445` - Metered billing routing to change map the meters to the billing_manager + +--- + +# Problem Statement + +## Problem 1: Multi-Tenant Event Attribution + +**Scenario**: +- Slack (merchant) sells a "Pro" product with per-message pricing +- Customer C is part of Acme's Slack workspace (10-seat subscription) +- Customer C is also part of Lolo's Slack workspace (5-seat subscription) +- Customer C sends a message → Which subscription should be billed? + +**Current Behavior**: +```python +# Event created without business context +Event( + name="message.sent", + customer_id="customer_c", + user_metadata={"count": 1} +) + +# Billing logic in meter/service.py:424-445 +customer_price = await repo.get_by_customer_and_meter(customer_c, meter) +# Returns FIRST matching subscription (arbitrary) +# ❌ Could bill Acme when message was sent in Lolo's workspace +``` + +**Required Solution**: Explicit business context in events to route billing correctly. + +--- + +## Problem 2: Inflexible Billing Manager Changes + +**Scenario**: +- Acme Corporation has a subscription with 50 seats +- Original billing manager: alice@acme.com (Customer ID: cust_123) +- New billing manager needed: finance@acme.com (Customer ID: cust_456) + +**Current Behavior**: +```python +# Subscription is directly tied to customer +Subscription( + id="sub_acme", + customer_id="cust_123", # Alice + seats=50 +) + +# To change billing manager, must: +# 1. Create new customer (finance@acme.com) +# 2. Migrate subscription to the new owner +# 3. Migrate pending billing entries to the customer +# ❌ First customer loses access to the history +# ❌ Loses historical billing data continuity +# ❌ Complex and error prone migration +``` + +**Required Solution**: move billing to the business entity + +--- + +## Problem 3: No Business-Level Metrics + +**Scenario**: +- Acme Corporation has 3 subscriptions (Pro Plan, Enterprise API, Storage) +- Each of the 3 subscriptions have a different billing manager. +- Merchant and Business wants to see total organizational spending and usage + +**Current behaviour** +- ❌ No way to group the billing managers under the same umbrella. + +**Required Solution**: Business-level aggregation queries and reporting. + +--- + +## Problem 4: Multiple billing_managers + +**Scenario**: +- Acme has 20 company, CEO, CFO, and workers. The CFO is in charge to pay the bills but the CEO wants to have access in managing the seats and permissions. + +**Current behaviour** +- ❌ No way to group the billing managers under the same umbrella. + +**Required Solution**: Business-level aggregation queries and reporting. + +--- + +# Tenets +> **Important!** review the tenets first! +> +> Tenets are principles that held true and guide the decision-making. They serve to clarify what is important when there is disagreement, drive alignement, and facilitate decision making. + +These are the tenets that I consider for the following document, sorted by priority, first is the highest priority. + +1. Billing accuracy: events must always bill the correct entity +2. Backward compatibility: existing customers and subscriptions continue working unchanged. +3. Customer experience: individual customers and business have a nice and seamless experience when purchasing or managing their subscriptions. +4. Merchant Developer experience: API should be intuitive with minimal conditional logic. +5. Operational Flexibility: support growth from individual -> startups -> enterprise + 1. Seamless WorkOS/Auth0 integration + 2. Business roles (billing manager, admin, member) + 3. Multi-manager suport +6. Performance: no degradation on subscription creation, event ingestion and processing. +7. Polar developer experience: polar engineers can understand, test, and extend the system. +# Solutions + +✅ 1 point, 🟡 0 points, ❌ -1 point + +| Option | Weight | Option 1: Business + BusinessCustomer | Option 2: Single Table Inheritance | Option 6: Synthetic Business | +| ----------------------- | ------ | ------------------------------------- | ---------------------------------- | ---------------------------- | +| Billing Accuracy | 7 | ✅ | ✅ | ✅ | +| Backward compatibility | 6 | ❌ | 🟡 | 🟡 | +| Customer experience | 5 | ✅ | ✅ | ✅ | +| Merchant dev experience | 4 | ❌ | ❌ | 🟡 | +| Operational flexibility | 3 | ✅ | ✅ | 🟡 | +| Performance | 2 | ✅ | ❌ | ❌ | +| Polar dev experience | 1 | 🟡 | 🟡 | ✅ | +| **Score** | - | 7 | 9 | 11 | +Solution 3 and 4 are discarded because they don't meet the requirements and don't fix the problem. + +Solution 5 is discarded because it's similar to solution 2 with worse polar experience and flexibility. + +## Option 1: Introduce Business and Business Customer +The idea of this architecture is to introduce 2 new concepts: + +- Business: that represents the legal entity who is purchasing the product +- Business Customer: it's an employee of the company. At the beggining it will have only the role to manage the business (changing subscription seats, payment methods, download invoices, etc) + +CustomerSeats will remain the same, as those are the employees who benefit the the product. + +### New Entity: Business + +The **Business** entity represents a billing organization that owns subscriptions and groups customers. + +```python +class Business(RecordModel): + """ + Represents a business entity that acts as a billing container. + + A Business: + - Can have multiple subscriptions and orders + - Future: Enables business-level usage tracking and reporting + """ + + id: UUID + organization_id: UUID + + # Business Identity. We define a default name for each customer seat. + name: str | None + external_id: str | None +``` + +### New Entity: BusinessCustomer + +Links employees to businesses with optional role (for the future). + +```python +class BusinessCustomer(RecordModel): + """ + Links a Customer to a Business. The business customer is in charge to manage the business, like adding payment methods, requesting invoices, etc. + + Represents membership in a business organization, allowing: + - Multiple customers per business + - Same customer in multiple businesses + - Future: Role-based access (member, admin, etc.) + """ + + id: UUID + business_id: UUID + customer_id: UUID + + # for the future to have multiple roles. + role: RoleType +``` + +### Modified Entity: BillingEntry, Order, Payment Method, Refund, Subscription + +Add optional `business_id` foreign key for business-owned entities. Entities that are used to manage the billing cycle should have a `business_id` if the buyer it's a business or a customer_id if it's an individual. + +```python +class Subscription(RecordModel): + # ... existing fields ... + + # EXISTING - for backward compatibility (direct-to-customer) + customer_id: UUID | None + + # NEW - for business-owned subscriptions + business_id: UUID | None +``` + +### Modified Entity: Event + +Add optional `business_id` for explicit business context in usage events. When the buyer it's a business, merchants should send the `business_id` on the events to avoid ambiguity when a Customer is on multiple businesses. + +```python +class Event(RecordModel): + # ... existing fields ... + + customer_id: UUID + + # NEW - for business-owned subscriptions + business_id: UUID | None +``` + +### Tenets +1. ✅ Billing accuracy: events are attributed to a single customer +2. ❌ Backward compatibility: customers that enabled seat-based billing will receive null customerIds in the API responses for some entities. +3. ✅ Customer experience: individual customers and business customers can have a tailored experience based on their needs. +4. ❌ Merchant Developer experience: the merchant will need to do branching depending if the entities and customer is a business or an individual customer. +5. ✅ Operational Flexibility: this maps to WorkOS and allows all features requested by our customers. +6. ✅ Performance: no degradation on the first place +7. 🟡 Polar developer experience: we need to be aware of the branching. +## Option 2: Single Table Inheritance +Instead of adding a `business_id` in each one of the entities that are used to manage the billing cycle, we can add a new concept that is a `BillingCustomerId` that holds who is the owner of that entity. + +### New Entity: BillingCustomerId + +```python +class BillingCustomerId(RecordModel): + """ + A holding entity that represents the original `id` of the customer or the new `business_id`. + """ + + id: UUID + business_id: UUID | None + customer_id: UUID | None +``` + +### Modified Entity: BillingEntry, Order, Payment Method, Refund, Subscription + +Add optional `business_id` foreign key for business-owned entities. Entities that are used to manage the billing cycle should have a `business_id` if the buyer it's a business or a customer_id if it's an individual. + +```python +class Subscription(RecordModel): + # ... existing fields ... + + # Existing but now points to BillingCustomerId + customer_id: UUID +``` + + +### Tenets +Same as option 1, but with: +1. ✅ Billing accuracy: events are attributed to a single customer +2. **🟡 Backward compatibility**: we will always return the customerId in the response, but now it can be a businessId or a customer itself. But business ids will not be available under `/v1/customers/{customerId}`. This will affect only orgs that enabled seat-based pricing. +3. ✅ Customer experience: individual customers and business customers can have a tailored experience based on their needs. +4. ❌ Merchant Developer experience: the merchant will need to do branching depending if the entities and customer is a business or an individual customer. +5. ✅ Operational Flexibility: this maps to WorkOS and allows all features requested by our customers. +6. ❌ Performance: one extra joinload on all queries that affect customers or businesses. +7. 🟡 Polar developer experience: we need to be aware of the branching. + +## Option 3: Continue with CustomerSeats only + +Currently, we have the problem of multi-tenant attribution. But this is not only related to business customers, it can also happen to invidividual customers that have 2 subscriptions and have a shared meter. + +For example, Customer C, has two subscriptions with meter "storage_usage" and we send usage events. We don't know where to attribute this usage. + +### Modified Entity: Event +Add mandatory `subscription_id` for subscriptions that have a metered pricing. This way, we can properly assign each event to the correct subscription and customer_seat. +```python +class Event(RecordModel): + # ... existing fields ... + + # NEW - explicit business context for multi-tenant scenarios + subscription_id: UUID | None +``` + +### Tenets + +1. ✅ Billing accuracy: events are attributed to a single customer +2. 🟡 Backward compatibility: this will only affect seat based subscriptions and only the merchants on beta will need to upgrade. +3. 🟡 Customer experience: customers will have the same experience as now. Business customers may want more features. +4. 🟡 Merchant Developer experience: the merchant will need to check if they should add the subscription_id to the events. Or always gather the subscription_id related to the customer. +5. ❌ Operational Flexibility: no way to have roles inplace. Difficult to work with the concept of organization. +6. ✅ Performance: no degradation on the first place +7. ✅ Polar developer experience + +## Option 4: don't allow same customer to have same meter + +**Discarded:** most important tenents are not feasible. + + We can add a validation that when a customer subscribes or claims a seat, we check if there is any meter clash on the products that bought or claimed. + +With this we can always infer the correct subscription to be charged for usaged based. + +### Tenets + +1. ✅ Billing accuracy: events are attributed to a single customer +2. ❌ Backward compatibility: it's a new validation that we want to add that will be blocked in the future. +3. ❌ Customer experience: we don't allow legitimate business cases +4. ❌ Merchant Developer experience: developers +5. ❌ Operational Flexibility: no way to have any of the features +6. ✅ Performance: no degradation on the first place +7. ✅ Polar developer experience + +## Option 5: Have different type of Customers + + We can have `individual` and `business` customers. We could use inheritance for that instead of composition. + + We will have the exact same problems as in 2, and our model will be more confusing, as we can grow Customers without clear definition. Like: + +- Why isn't a Seat holder a type of customer? +- Why isn't a Visitor a type of customer? + +### Tenets +Same as [[Business Entity Design for Multi-Tenant Seat-Based Billing#Option 2|Option 2]] except: +1. ✅ Billing accuracy: events are attributed to a single customer +2. **🟡 Backward compatibility**: we will always return the customerId in the response, but now it can be a businessId or a customer itself. But customer entities has types and depending on the types they will behave differently. Same semantic but a breaking change in the meaning. +3. ✅ Customer experience: individual customers and business customers can have a tailored experience based on their needs. +4. ❌ Merchant Developer experience: the merchant will need to do branching depending if the customer is a business or an individual customer. +5. 🟡 Operational Flexibility: this maps to WorkOS and allows all features requested by our customers. +6. ✅ Performance: no degradation on the first place +7. **❌ Polar developer experience: we need to be aware of the branching and boundaries of what is a Customer is less defined.** + +## Option 6: Synthetic Business for all + + We can introduce the concept of `Business` and `BusinessCustomer` as in [[Business Entity Design for Multi-Tenant Seat-Based Billing#Option 1 Introduce Business and Business Customer|Option 1]] but we have a synthetic business for all individual customers. So, when a new customer is created we create a business with a name "Birk's Personal Account" or similar. + +### New type: BusinessType + +```python +class BusinessType(StrEnum): + individual = "individual" # Synthetic business for 1 person + business = "business" # Real business entity + + +class Business(RecordModel): + """Billing entity - all subscriptions belong to businesses.""" + + id: UUID + organization_id: UUID + + type: BusinessType + + # Identity + name: str # "Birk's Personal Account" OR "Acme Corporation" + external_id: str | None + + # ... same fields as in Option 1 +``` +### Modified Entity: BillingEntry, Order, Payment Method, Refund, Subscription + +Add optional `business_id` foreign key for business-owned entities. Entities that are used to manage the billing cycle should have a `business_id` if the buyer it's a business or a customer_id if it's an individual. + + +```python +class Subscription(RecordModel): + # ... existing fields ... + + # No more customer_id. Only business_id to the billing related entities. + business_id: UUID # FK to Business (REQUIRED, not nullable) +``` +### Modified Entity: Event + +Add mandatory `business_id` for explicit business context in usage events. + +```python +class Event(RecordModel): + # ... existing fields ... + + # NEW - explicit business context for multi-tenant scenarios + business_id: UUID # FK to Business (REQUIRED, not nullable) +``` +### Tenets +Same as [[Business Entity Design for Multi-Tenant Seat-Based Billing#Option 2|Option 2]] except: +1. ✅ Billing accuracy: events are attributed to a single customer +2. **🟡 Backward compatibility**: business will be always a required parameter. We can create a v2 endpoints for that and only require if customers enable seat based billing. +3. ✅ Customer experience: individual customers and business customers can have a tailored experience based on their needs. For individual customers, we may present more an "enterprise" view when there may not be the need. +4. 🟡 Merchant Developer experience: merchant will treat all the customers the same way. But a first migration is needed. +5. 🟡 Operational Flexibility: this maps to WorkOS and allows all features requested by our customers. +6. ❌ Performance: one extra joined load on every customer query +7. **✅ Polar developer experience:** we need to be aware of the branching and boundaries of what is a Customer is less defined. From 525a653418004faa8c07ed5782a7a9ab33621af6 Mon Sep 17 00:00:00 2001 From: Petru Rares Sincraian Date: Wed, 5 Nov 2025 16:10:13 +0100 Subject: [PATCH 02/15] fix: update navigation --- docs.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs.json b/docs.json index d1c6fe4..c9c4fde 100644 --- a/docs.json +++ b/docs.json @@ -51,7 +51,8 @@ "engineering/design-documents/seat-based-pricing", "engineering/design-documents/prorations", "engineering/design-documents/subscription-retries", - "engineering/design-documents/wallets" + "engineering/design-documents/wallets", + "engineering/design-documents/business-entity" ] }, "engineering/tech-notes", From 958aec58cc46c2ff9835f87033a672f37851ac72 Mon Sep 17 00:00:00 2001 From: Petru Rares Sincraian Date: Wed, 5 Nov 2025 16:13:50 +0100 Subject: [PATCH 03/15] fix: headings --- .../design-documents/business-entity.mdx | 67 ++++++++++--------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/engineering/design-documents/business-entity.mdx b/engineering/design-documents/business-entity.mdx index 031e678..8f8f106 100644 --- a/engineering/design-documents/business-entity.mdx +++ b/engineering/design-documents/business-entity.mdx @@ -3,9 +3,10 @@ **Created**: November 5, 2025 **Last Updated**: November 5, 2025 -# Summary -## Problem Statement +## Summary + +### Problem Statement The current seat-based billing implementation faces critical limitations in B2B multi-tenant scenarios: @@ -13,7 +14,7 @@ The current seat-based billing implementation faces critical limitations in B2B 2. **Inflexible Billing Management**: Changing the billing manager becomes tricky and information is on one customer or another 3. **No Business-Level Aggregation**: When a business has multiple subscriptions from the same merchant, it's difficult to have business-level aggregation.s -## Requirements +### Requirements 1. Attribute events when customer belongs to multiple businesses 2. Change billing manager without losing subscription history 3. Payment methods belong to business and not individuals @@ -23,7 +24,7 @@ The current seat-based billing implementation faces critical limitations in B2B --- -# Current Architecture Analysis +## Current Architecture Analysis The current model is: ``` @@ -47,9 +48,9 @@ Subscription --- -# Problem Statement +## Problem Statement -## Problem 1: Multi-Tenant Event Attribution +### Problem 1: Multi-Tenant Event Attribution **Scenario**: - Slack (merchant) sells a "Pro" product with per-message pricing @@ -76,7 +77,7 @@ customer_price = await repo.get_by_customer_and_meter(customer_c, meter) --- -## Problem 2: Inflexible Billing Manager Changes +### Problem 2: Inflexible Billing Manager Changes **Scenario**: - Acme Corporation has a subscription with 50 seats @@ -105,7 +106,7 @@ Subscription( --- -## Problem 3: No Business-Level Metrics +### Problem 3: No Business-Level Metrics **Scenario**: - Acme Corporation has 3 subscriptions (Pro Plan, Enterprise API, Storage) @@ -119,7 +120,7 @@ Subscription( --- -## Problem 4: Multiple billing_managers +### Problem 4: Multiple billing_managers **Scenario**: - Acme has 20 company, CEO, CFO, and workers. The CFO is in charge to pay the bills but the CEO wants to have access in managing the seats and permissions. @@ -131,7 +132,7 @@ Subscription( --- -# Tenets +## Tenets > **Important!** review the tenets first! > > Tenets are principles that held true and guide the decision-making. They serve to clarify what is important when there is disagreement, drive alignement, and facilitate decision making. @@ -148,7 +149,7 @@ These are the tenets that I consider for the following document, sorted by prior 3. Multi-manager suport 6. Performance: no degradation on subscription creation, event ingestion and processing. 7. Polar developer experience: polar engineers can understand, test, and extend the system. -# Solutions +## Solutions ✅ 1 point, 🟡 0 points, ❌ -1 point @@ -166,7 +167,7 @@ Solution 3 and 4 are discarded because they don't meet the requirements and don' Solution 5 is discarded because it's similar to solution 2 with worse polar experience and flexibility. -## Option 1: Introduce Business and Business Customer +### Option 1: Introduce Business and Business Customer The idea of this architecture is to introduce 2 new concepts: - Business: that represents the legal entity who is purchasing the product @@ -174,7 +175,7 @@ The idea of this architecture is to introduce 2 new concepts: CustomerSeats will remain the same, as those are the employees who benefit the the product. -### New Entity: Business +#### New Entity: Business The **Business** entity represents a billing organization that owns subscriptions and groups customers. @@ -196,7 +197,7 @@ class Business(RecordModel): external_id: str | None ``` -### New Entity: BusinessCustomer +#### New Entity: BusinessCustomer Links employees to businesses with optional role (for the future). @@ -219,7 +220,7 @@ class BusinessCustomer(RecordModel): role: RoleType ``` -### Modified Entity: BillingEntry, Order, Payment Method, Refund, Subscription +#### Modified Entity: BillingEntry, Order, Payment Method, Refund, Subscription Add optional `business_id` foreign key for business-owned entities. Entities that are used to manage the billing cycle should have a `business_id` if the buyer it's a business or a customer_id if it's an individual. @@ -234,7 +235,7 @@ class Subscription(RecordModel): business_id: UUID | None ``` -### Modified Entity: Event +#### Modified Entity: Event Add optional `business_id` for explicit business context in usage events. When the buyer it's a business, merchants should send the `business_id` on the events to avoid ambiguity when a Customer is on multiple businesses. @@ -248,7 +249,7 @@ class Event(RecordModel): business_id: UUID | None ``` -### Tenets +#### Tenets 1. ✅ Billing accuracy: events are attributed to a single customer 2. ❌ Backward compatibility: customers that enabled seat-based billing will receive null customerIds in the API responses for some entities. 3. ✅ Customer experience: individual customers and business customers can have a tailored experience based on their needs. @@ -256,10 +257,10 @@ class Event(RecordModel): 5. ✅ Operational Flexibility: this maps to WorkOS and allows all features requested by our customers. 6. ✅ Performance: no degradation on the first place 7. 🟡 Polar developer experience: we need to be aware of the branching. -## Option 2: Single Table Inheritance +### Option 2: Single Table Inheritance Instead of adding a `business_id` in each one of the entities that are used to manage the billing cycle, we can add a new concept that is a `BillingCustomerId` that holds who is the owner of that entity. -### New Entity: BillingCustomerId +#### New Entity: BillingCustomerId ```python class BillingCustomerId(RecordModel): @@ -272,7 +273,7 @@ class BillingCustomerId(RecordModel): customer_id: UUID | None ``` -### Modified Entity: BillingEntry, Order, Payment Method, Refund, Subscription +#### Modified Entity: BillingEntry, Order, Payment Method, Refund, Subscription Add optional `business_id` foreign key for business-owned entities. Entities that are used to manage the billing cycle should have a `business_id` if the buyer it's a business or a customer_id if it's an individual. @@ -285,7 +286,7 @@ class Subscription(RecordModel): ``` -### Tenets +#### Tenets Same as option 1, but with: 1. ✅ Billing accuracy: events are attributed to a single customer 2. **🟡 Backward compatibility**: we will always return the customerId in the response, but now it can be a businessId or a customer itself. But business ids will not be available under `/v1/customers/{customerId}`. This will affect only orgs that enabled seat-based pricing. @@ -295,13 +296,13 @@ Same as option 1, but with: 6. ❌ Performance: one extra joinload on all queries that affect customers or businesses. 7. 🟡 Polar developer experience: we need to be aware of the branching. -## Option 3: Continue with CustomerSeats only +### Option 3: Continue with CustomerSeats only Currently, we have the problem of multi-tenant attribution. But this is not only related to business customers, it can also happen to invidividual customers that have 2 subscriptions and have a shared meter. For example, Customer C, has two subscriptions with meter "storage_usage" and we send usage events. We don't know where to attribute this usage. -### Modified Entity: Event +#### Modified Entity: Event Add mandatory `subscription_id` for subscriptions that have a metered pricing. This way, we can properly assign each event to the correct subscription and customer_seat. ```python class Event(RecordModel): @@ -311,7 +312,7 @@ class Event(RecordModel): subscription_id: UUID | None ``` -### Tenets +#### Tenets 1. ✅ Billing accuracy: events are attributed to a single customer 2. 🟡 Backward compatibility: this will only affect seat based subscriptions and only the merchants on beta will need to upgrade. @@ -321,7 +322,7 @@ class Event(RecordModel): 6. ✅ Performance: no degradation on the first place 7. ✅ Polar developer experience -## Option 4: don't allow same customer to have same meter +### Option 4: don't allow same customer to have same meter **Discarded:** most important tenents are not feasible. @@ -329,7 +330,7 @@ class Event(RecordModel): With this we can always infer the correct subscription to be charged for usaged based. -### Tenets +#### Tenets 1. ✅ Billing accuracy: events are attributed to a single customer 2. ❌ Backward compatibility: it's a new validation that we want to add that will be blocked in the future. @@ -339,7 +340,7 @@ With this we can always infer the correct subscription to be charged for usaged 6. ✅ Performance: no degradation on the first place 7. ✅ Polar developer experience -## Option 5: Have different type of Customers +### Option 5: Have different type of Customers We can have `individual` and `business` customers. We could use inheritance for that instead of composition. @@ -348,7 +349,7 @@ With this we can always infer the correct subscription to be charged for usaged - Why isn't a Seat holder a type of customer? - Why isn't a Visitor a type of customer? -### Tenets +#### Tenets Same as [[Business Entity Design for Multi-Tenant Seat-Based Billing#Option 2|Option 2]] except: 1. ✅ Billing accuracy: events are attributed to a single customer 2. **🟡 Backward compatibility**: we will always return the customerId in the response, but now it can be a businessId or a customer itself. But customer entities has types and depending on the types they will behave differently. Same semantic but a breaking change in the meaning. @@ -358,11 +359,11 @@ Same as [[Business Entity Design for Multi-Tenant Seat-Based Billing#Option 2|Op 6. ✅ Performance: no degradation on the first place 7. **❌ Polar developer experience: we need to be aware of the branching and boundaries of what is a Customer is less defined.** -## Option 6: Synthetic Business for all +### Option 6: Synthetic Business for all We can introduce the concept of `Business` and `BusinessCustomer` as in [[Business Entity Design for Multi-Tenant Seat-Based Billing#Option 1 Introduce Business and Business Customer|Option 1]] but we have a synthetic business for all individual customers. So, when a new customer is created we create a business with a name "Birk's Personal Account" or similar. -### New type: BusinessType +#### New type: BusinessType ```python class BusinessType(StrEnum): @@ -384,7 +385,7 @@ class Business(RecordModel): # ... same fields as in Option 1 ``` -### Modified Entity: BillingEntry, Order, Payment Method, Refund, Subscription +#### Modified Entity: BillingEntry, Order, Payment Method, Refund, Subscription Add optional `business_id` foreign key for business-owned entities. Entities that are used to manage the billing cycle should have a `business_id` if the buyer it's a business or a customer_id if it's an individual. @@ -396,7 +397,7 @@ class Subscription(RecordModel): # No more customer_id. Only business_id to the billing related entities. business_id: UUID # FK to Business (REQUIRED, not nullable) ``` -### Modified Entity: Event +#### Modified Entity: Event Add mandatory `business_id` for explicit business context in usage events. @@ -407,7 +408,7 @@ class Event(RecordModel): # NEW - explicit business context for multi-tenant scenarios business_id: UUID # FK to Business (REQUIRED, not nullable) ``` -### Tenets +#### Tenets Same as [[Business Entity Design for Multi-Tenant Seat-Based Billing#Option 2|Option 2]] except: 1. ✅ Billing accuracy: events are attributed to a single customer 2. **🟡 Backward compatibility**: business will be always a required parameter. We can create a v2 endpoints for that and only require if customers enable seat based billing. From a067a95852b3cda4ebe548f449b05a00d01347ee Mon Sep 17 00:00:00 2001 From: Petru Rares Sincraian Date: Wed, 5 Nov 2025 16:17:05 +0100 Subject: [PATCH 04/15] fix: table view --- engineering/design-documents/business-entity.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/engineering/design-documents/business-entity.mdx b/engineering/design-documents/business-entity.mdx index 8f8f106..01c06bf 100644 --- a/engineering/design-documents/business-entity.mdx +++ b/engineering/design-documents/business-entity.mdx @@ -163,6 +163,7 @@ These are the tenets that I consider for the following document, sorted by prior | Performance | 2 | ✅ | ❌ | ❌ | | Polar dev experience | 1 | 🟡 | 🟡 | ✅ | | **Score** | - | 7 | 9 | 11 | + Solution 3 and 4 are discarded because they don't meet the requirements and don't fix the problem. Solution 5 is discarded because it's similar to solution 2 with worse polar experience and flexibility. From d6e4501466ccb6d425b925237392781f4850b54f Mon Sep 17 00:00:00 2001 From: Petru Rares Sincraian Date: Wed, 5 Nov 2025 22:23:38 +0100 Subject: [PATCH 05/15] fix: scoring & comments --- engineering/design-documents/business-entity.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/engineering/design-documents/business-entity.mdx b/engineering/design-documents/business-entity.mdx index 01c06bf..b859529 100644 --- a/engineering/design-documents/business-entity.mdx +++ b/engineering/design-documents/business-entity.mdx @@ -156,13 +156,13 @@ These are the tenets that I consider for the following document, sorted by prior | Option | Weight | Option 1: Business + BusinessCustomer | Option 2: Single Table Inheritance | Option 6: Synthetic Business | | ----------------------- | ------ | ------------------------------------- | ---------------------------------- | ---------------------------- | | Billing Accuracy | 7 | ✅ | ✅ | ✅ | -| Backward compatibility | 6 | ❌ | 🟡 | 🟡 | +| Backward compatibility | 6 | 🟡 | 🟡 | 🟡 | | Customer experience | 5 | ✅ | ✅ | ✅ | | Merchant dev experience | 4 | ❌ | ❌ | 🟡 | | Operational flexibility | 3 | ✅ | ✅ | 🟡 | | Performance | 2 | ✅ | ❌ | ❌ | | Polar dev experience | 1 | 🟡 | 🟡 | ✅ | -| **Score** | - | 7 | 9 | 11 | +| **Score** | - | 13 | 9 | 11 | Solution 3 and 4 are discarded because they don't meet the requirements and don't fix the problem. @@ -252,7 +252,7 @@ class Event(RecordModel): #### Tenets 1. ✅ Billing accuracy: events are attributed to a single customer -2. ❌ Backward compatibility: customers that enabled seat-based billing will receive null customerIds in the API responses for some entities. +2. 🟡 Backward compatibility: customers that enabled seat-based billing will receive null customerIds in the API responses for some entities. 3. ✅ Customer experience: individual customers and business customers can have a tailored experience based on their needs. 4. ❌ Merchant Developer experience: the merchant will need to do branching depending if the entities and customer is a business or an individual customer. 5. ✅ Operational Flexibility: this maps to WorkOS and allows all features requested by our customers. @@ -417,4 +417,4 @@ Same as [[Business Entity Design for Multi-Tenant Seat-Based Billing#Option 2|Op 4. 🟡 Merchant Developer experience: merchant will treat all the customers the same way. But a first migration is needed. 5. 🟡 Operational Flexibility: this maps to WorkOS and allows all features requested by our customers. 6. ❌ Performance: one extra joined load on every customer query -7. **✅ Polar developer experience:** we need to be aware of the branching and boundaries of what is a Customer is less defined. +7. **✅ Polar developer experience:** no need to do branching as Customers are the ones using the product and Business entities are the ones managing the billing cycle. From 57e555edf204976d3cc921e7bab51dca490417a4 Mon Sep 17 00:00:00 2001 From: Petru Rares Sincraian Date: Fri, 7 Nov 2025 15:03:37 +0100 Subject: [PATCH 06/15] feat: add option 7 and 8 --- .../design-documents/business-entity.mdx | 449 +++++++++++++++++- 1 file changed, 435 insertions(+), 14 deletions(-) diff --git a/engineering/design-documents/business-entity.mdx b/engineering/design-documents/business-entity.mdx index b859529..afc58c0 100644 --- a/engineering/design-documents/business-entity.mdx +++ b/engineering/design-documents/business-entity.mdx @@ -12,13 +12,13 @@ The current seat-based billing implementation faces critical limitations in B2B 1. **Ambiguous Event Attribution**: When a customer belongs to multiple seat-based subscriptions from different organizations (e.g., Acme and Slack), events lack business context to determine which customer should be billed. 2. **Inflexible Billing Management**: Changing the billing manager becomes tricky and information is on one customer or another -3. **No Business-Level Aggregation**: When a business has multiple subscriptions from the same merchant, it's difficult to have business-level aggregation.s +3. **No Business-Level Aggregation**: When a business has multiple subscriptions from the same merchant, it's difficult to have business-level aggregation. ### Requirements 1. Attribute events when customer belongs to multiple businesses 2. Change billing manager without losing subscription history 3. Payment methods belong to business and not individuals -4. Both merchants and customers can creates businesses. +4. Both merchants and customers can create businesses 5. Enable business-level usage aggregation across different subscriptions 6. Support multiple billing managers per business @@ -153,20 +153,19 @@ These are the tenets that I consider for the following document, sorted by prior ✅ 1 point, 🟡 0 points, ❌ -1 point -| Option | Weight | Option 1: Business + BusinessCustomer | Option 2: Single Table Inheritance | Option 6: Synthetic Business | -| ----------------------- | ------ | ------------------------------------- | ---------------------------------- | ---------------------------- | -| Billing Accuracy | 7 | ✅ | ✅ | ✅ | -| Backward compatibility | 6 | 🟡 | 🟡 | 🟡 | -| Customer experience | 5 | ✅ | ✅ | ✅ | -| Merchant dev experience | 4 | ❌ | ❌ | 🟡 | -| Operational flexibility | 3 | ✅ | ✅ | 🟡 | -| Performance | 2 | ✅ | ❌ | ❌ | -| Polar dev experience | 1 | 🟡 | 🟡 | ✅ | -| **Score** | - | 13 | 9 | 11 | +| Option | Weight | Option 1: Business + BusinessCustomer | Option 6: Synthetic Business | Option 7: Member/Beneficiary | +|-------------------------|--------|---------------------------------------|------------------------------|------------------------------| +| Billing Accuracy | 7 | ✅ | ✅ | ✅ | +| Backward compatibility | 6 | 🟡 | 🟡 | 🟡 | +| Customer experience | 5 | ✅ | ✅ | ✅ | +| Merchant dev experience | 4 | ❌ | 🟡 | 🟡 | +| Operational flexibility | 3 | ✅ | 🟡 | ✅ | +| Performance | 2 | ✅ | ❌ | ❌ | +| Polar dev experience | 1 | 🟡 | ✅ | 🟡 | +| **Score** | - | 13 | 11 | **15** | Solution 3 and 4 are discarded because they don't meet the requirements and don't fix the problem. - -Solution 5 is discarded because it's similar to solution 2 with worse polar experience and flexibility. +Solution 2, 5, and 8 are discarded because they are the lowest score. ### Option 1: Introduce Business and Business Customer The idea of this architecture is to introduce 2 new concepts: @@ -418,3 +417,425 @@ Same as [[Business Entity Design for Multi-Tenant Seat-Based Billing#Option 2|Op 5. 🟡 Operational Flexibility: this maps to WorkOS and allows all features requested by our customers. 6. ❌ Performance: one extra joined load on every customer query 7. **✅ Polar developer experience:** no need to do branching as Customers are the ones using the product and Business entities are the ones managing the billing cycle. + +### Option 7: Member Layer + +**Core Insight**: Instead of adding a `Business` layer ABOVE customers, add a `Member` layer BELOW customers. This preserves the existing `Customer` as the billing entity while introducing granular product usage tracking. + +**Semantic Shift**: In this model, `Customer` represents the billing entity (the team/organization for seat-based, or individual for non-seat-based). Members are the people who use the product. + +``` +Customer("acme") = billing entity (the team) + ├── Member("alice", role="billing_manager") = product user + can manage + ├── Member("bob", role="admin") = product user + can manage + └── Member("charlie", role="member") = product user only + +Customer("dave") = billing entity (individual) + └── No Member - Dave is also the sole user +``` + +#### Why Keep CustomerSeat and Member Separate? + +They serve different purposes: +- **CustomerSeat**: Seat allocation and invitation lifecycle related to an Order or Subscription. +- **Member**: Role management, team structure, permissions (new). + +Customers can have multiple active subscriptions, and each one of those subscriptions can have different members assigned. Separation of entities would allow this flexibility. + +#### New Entity: Member + +The **Member** entity represents a person's membership in a team customer's organization. + +Members are linked to a Customer record (`user_customer_id`). This enables that the following features works out of the box: + +- Customer Portal +- Customer Portal API endpoints +- Webhooks events related to customers +- OAuth accounts. GitHub, Discord, etc. + +```python +class MemberRole(StrEnum): + billing_manager = "billing_manager" # Can manage billing & users + admin = "admin" # Can manage users + member = "member" # Can only use product + +class Member(RecordModel): + """ + Represents a person's membership in a team customer's organization. + + Relationship with CustomerSeat: + - If billable=True: Member must have a CustomerSeat (1:1 relationship) + - If billable=False: Member has no CustomerSeat (billing manager, admin) + + Relationship with Customer: + - team_customer_id: The team/organization (e.g., ACME Corp) + - user_customer_id: The individual person (e.g., Alice) + + Why user_customer_id is REQUIRED (not optional): + - Authentication: CustomerSession links to customer_id + - Benefits: BenefitGrant requires customer_id + - OAuth: Customer.oauth_accounts stores linked accounts + - Proven pattern: CustomerSeat.customer_id already works this way + """ + + id: UUID + team_customer_id: UUID # FK to Customer (the team/organization) + + # Identity - REQUIRED link to individual's Customer record + user_customer_id: UUID # FK to Customer (individual person, REQUIRED) + # Note: email/name are on the Customer record, not duplicated here + + # Access control + role: MemberRole + billable: bool # If True, requires a CustomerSeat + + # Link to seat allocation (if billable) + customer_seat_id: UUID | None # FK to CustomerSeat + + # Member lifecycle + status: MemberStatus # pending, active, revoked + invitation_token: str | None + claimed_at: datetime | None + revoked_at: datetime | None + +# Relationship diagram: +# Customer("acme", email="billing@acme.com") ← team +# ├── Member(role="billing_manager", billable=False, customer_seat_id=None) +# │ └──> Customer("alice", email="alice@acme.com") ← individual (user_customer_id) +# └── Member(role="member", billable=True, customer_seat_id="seat_1") +# ├──> Customer("bob", email="bob@acme.com") ← individual (user_customer_id) +# └──> CustomerSeat(id="seat_1", customer_id="cust_bob") +``` + +#### Existing Entity: CustomerSeat (Unchanged) + +The **CustomerSeat** model remains unchanged and continues to handle seat allocation: + +```python +class CustomerSeat(RecordModel): + """Existing model - no changes""" + + subscription_id: UUID | None + order_id: UUID | None + status: SeatStatus # pending, claimed, revoked + customer_id: UUID | None # Individual who claimed the seat + invitation_token: str | None + claimed_at: datetime | None + revoked_at: datetime | None + metadata: dict[str, Any] | None +``` + +#### Modified Entity: Subscription + +No changes needed! `Subscription.customer_id` remains but its meaning shifts: +- Non-seat-based: customer_id = individual customer (unchanged) +- Seat-based: customer_id = team customer (the organization/business) + +```python +class Subscription(RecordModel): + # ... existing fields ... + + customer_id: UUID # UNCHANGED + # For non-seat-based: points to individual customer + # For seat-based: points to team customer (semantic shift) + + seats: int | None # If set, subscription is seat-based +``` + +#### Modified Entity: Event + +Add optional `member_id` for explicit attribution in seat-based subscriptions with metered pricing. + +```python +class Event(RecordModel): + # ... existing fields ... + + customer_id: UUID # Who pays (unchanged) + + # NEW - explicit user attribution for seat-based scenarios + member_id: UUID | None # Who used (optional) +``` + + +#### Entity: BenefitGrant (unchanged) + +For seat-based subscriptions, will still work with CustomerSeats feature. + +```python +class BenefitGrant(RecordModel): + # ... existing fields ... + + # EXISTING - for non-seat-based & seat-based + customer_id: UUID +``` + +#### Migration Strategy + +**For existing non-seat-based subscriptions:** +- ✅ No migration needed +- ✅ Subscriptions continue working unchanged +- ✅ Customer remains billing entity and implicit user +- ✅ No CustomerSeat or Member records needed + +**For existing seat-based subscriptions (beta customers):** + +```python +# Step 1: Convert billing manager's customer to "team customer" +billing_manager_customer = get_customer(subscription.customer_id) # Alice (was billing manager) +billing_manager_customer.name = "ACME Corp" # Update to team name +billing_manager_customer.email = "billing@acme.com" # Update to team email + +# Step 2: Create individual Customer record for Alice +# Alice needs her own Customer record to authenticate and receive benefits +alice_customer = Customer( + email="alice@acme.com", + name="Alice", + organization_id=billing_manager_customer.organization_id +) + +# Step 3: Create Member record for billing manager (no seat consumption) +Member( + team_customer_id=billing_manager_customer.id, # Team customer (ACME) + user_customer_id=alice_customer.id, # Alice's individual customer record (REQUIRED) + role="billing_manager", + billable=False, # Doesn't consume a seat + customer_seat_id=None, # No CustomerSeat needed + status="active" +) + +# Step 4: For each CustomerSeat, ensure user has individual Customer record +for seat in subscription.customer_seats: + # CustomerSeat.customer_id should already point to individual Customer + # If not, create one (this shouldn't happen in current system) + individual_customer = get_or_create_customer( + email=seat.email, # From seat metadata or lookup + organization_id=subscription.organization_id + ) + + # Update CustomerSeat to point to individual Customer (if needed) + seat.customer_id = individual_customer.id + + # Create Member linking team to individual + Member( + team_customer_id=subscription.customer_id, # Team (ACME) + user_customer_id=individual_customer.id, # Individual (Bob, Charlie, etc.) + role="member", + billable=True, + customer_seat_id=seat.id, # Links to existing CustomerSeat + status=seat.status, # Mirrors seat status + claimed_at=seat.claimed_at + ) + +# Step 5: CustomerSeat table UNCHANGED +# Step 6: subscription.customer_id UNCHANGED +# But now points to "team customer" instead of individual + +# Result structure: +# Customer("acme", email="billing@acme.com") ← team +# | +# ├─ Subscription(customer_id="acme", seats=50) +# | +# ├─ Member(team="acme", user="alice", role="billing_manager", billable=False) +# | └─> Customer("alice", email="alice@acme.com") ← can authenticate! +# | +# └─ Member(team="acme", user="bob", role="member", billable=True, seat_id="seat_1") +# ├─> Customer("bob", email="bob@acme.com") ← can authenticate! +# └─> CustomerSeat(id="seat_1", customer_id="bob") ← unchanged +``` + +**Key Points**: +1. **CustomerSeat is NOT replaced** - Member is an additive layer that extends CustomerSeat with role management +2. **Every Member must link to individual Customer** - Required for authentication and benefits +3. **Team customer and individual customers coexist** - Within same organization +4. **CustomerSeat.customer_id stays unchanged** - Already points to individual Customer (current pattern) + +#### Tenets + +1. ✅ **Billing accuracy**: Events attributed via member_id for seat-based metered subscriptions +2. 🟡 **Backward compatibility**: + - ✅ Non-seat-based: Zero changes (90% of customers) + - ❌ Seat-based: Breaking changes to subscription queries, benefit grants, events + - `GET /v1/subscriptions?customer_id=alice` → empty (customer_id now points to team) + - `GET /v1/subscriptions?customer_id=acme` → returns subscriptions + - `GET /v1/benefits/grants?customer_id=bob` → works as before +3. ✅ **Customer experience**: Clear separation - Customer = payer, Member = user +4. 🟡 **Merchant Developer experience**: + - ✅ Non-seat-based: No changes + - ❌ Seat-based: Need to track customer_id as team or individuals +5. ✅ **Operational Flexibility**: + - ✅ Multiple billing managers (role="billing_manager", billable=False) + - ✅ Role-based access control via Member.role + - ✅ WorkOS/Auth0 integration (map SSO users → Members) +6. ✅ **Performance**: + - ✅ Non-seat-based: Zero overhead (no joins) + - 🟡 Seat-based: a join needed to get the members or team +7. ✅ **Polar developer experience**: + - Clear boundaries: Customer = billing, Member = usage + - Isolated to seat-based feature + +#### Comparison with Option 6 + +Both Option 6 and Option 7 solve the same problems but with inverted architectures: + +| Aspect | Option 6 (Business Above) | Option 7 (Member Below) | +|--------|---------------------------|-------------------------| +| **Non-seat-based** | Business(type="individual") wrapper | No changes (current architecture) | +| **Database changes** | Add business_id to 5+ tables | Add member table only | +| **API translation** | Required for all endpoints | Not required | +| **Performance** | +2 joins on every query | 0 joins for non-seat-based | +| **Migration** | Migrate ALL subscriptions | Migrate seat-based only | +| **Semantics** | Customer = user, Business = billing | Customer = billing, Member = user | +| **Backward compat** | 🟡 With translation layer | 🟡 Without translation layer | + + +### Option 8: Member Without Customer Link + +**Core Difference from Option 7**: Member does NOT link to individual Customer records. Instead, Member stores email/name directly. + +**Rationale**: Simplify data model by avoiding the need to create individual Customer records for each team member. + +``` +Customer("acme") = billing entity (the team) + ├── Member("alice", email="alice@acme.com", role="billing_manager") + └── Member("bob", email="bob@acme.com", role="member") + +# No individual Customer records for Alice/Bob +``` + +#### New Entity: Member (Email-based) + +```python +class Member(RecordModel): + """ + Represents team membership WITHOUT linking to individual Customer. + Identity stored directly on Member record. + """ + + id: UUID + team_customer_id: UUID # FK to Customer (team) + + # Identity stored directly (NO user_customer_id) + email: str # Primary identifier + name: str | None + + # Access control + role: MemberRole + billable: bool + customer_seat_id: UUID | None + + # Lifecycle + status: MemberStatus + invitation_token: str | None + claimed_at: datetime | None +``` + + +#### Required Infrastructure Changes + +**1. Dual Authentication System:** +- Keep `CustomerSession` for individual customers +- Add `MemberSession` for team members +- Portal endpoints need to support `AuthSubject[Customer | Member]` + +**2. Dual Benefit System:** +- `BenefitGrant.customer_id` for individual customers +- `BenefitGrant.member_id` for team members +- All benefit queries need to check both fields + +**3. Dual OAuth Storage:** +- Keep `Customer.oauth_accounts` for individual customers +- Add `Member.oauth_accounts` JSONB for team members + +**4. Email Validation:** +- Add unique constraint: `(team_customer_id, email)` on Members table +- Custom validation to prevent conflicts + +#### Tenets + +1. ✅ **Billing accuracy**: Events attributed via member_id +2. 🟡 **Backward compatibility**: Same as Option 7 (seat-based requires changes) +3. 🟡 **Customer experience**: Works but limited multi-org scenario. +4. 🟡 **Merchant Developer experience**: multiple but similar endpointsfor team members and individual customers +5. 🟡 **Operational Flexibility**: Limited - cannot support multi-org scenario +6. ❌ **Performance**: Dual code paths increase complexity +7. ❌ **Polar developer experience**: + - Must maintain parallel authentication systems (CustomerSession vs MemberSession) + - Must maintain parallel benefit grants (customer_id vs member_id) + - Must maintain parallel OAuth systems + - All customer portal endpoints need conditional logic + +#### Critical Limitations + +**1. Multi-Organization Scenario (BLOCKED):** +```python +# Alice is member of ACME AND has individual subscription +Member(team="acme", email="alice@acme.com") # Team member + +# Alice buys individual subscription +# ❌ Cannot authenticate as Customer("alice") - no Customer record! +# ❌ Same email exists in two contexts (Member and would-be Customer) +# ❌ Login ambiguity: Is alice@acme.com logging in as Member or Customer? +``` + +**2. Customer Portal Access Pattern:** +```python +# Current: All endpoints use AuthSubject[Customer] +async def list_subscriptions( + auth_subject: auth.CustomerPortalRead, # Customer only + ... +): + customer = auth_subject.subject # Customer object + subscriptions = query(Subscription.customer_id == customer.id) + +# Option 8: Need conditional logic everywhere +async def list_subscriptions( + auth_subject: auth.CustomerPortalRead | auth.MemberPortalRead, # NEW union type + ... +): + if isinstance(auth_subject.subject, Customer): + subscriptions = query(Subscription.customer_id == auth_subject.subject.id) + elif isinstance(auth_subject.subject, Member): + subscriptions = query(Subscription.customer_id == auth_subject.subject.team_customer_id) + # ^ This pattern repeated in XX endpoints! +``` + +**3. Benefit Grant:** +```python +# Current: Simple query +grants = BenefitGrant.query(customer_id=customer.id) + +# Option 8: Dual query everywhere +if isinstance(subject, Customer): + grants = BenefitGrant.query(customer_id=customer.id) +elif isinstance(subject, Member): + grants = BenefitGrant.query(member_id=member.id) +# ^ This pattern repeated in 9+ locations +``` + +#### Implementation Complexity + +**Files to Modify:** +- **New models**: `MemberSession`, modified `BenefitGrant` +- **Auth system**: Add `MemberSession` authenticator, `MemberPortalRead` dependency +- **All portal endpoints**: Support `Customer | Member` union type +- **All portal services**: Conditional logic for customer_id vs member_id +- **All portal repositories**: Dual query patterns +- **Benefit grant service**: Grant to customer_id OR member_id +- **OAuth system**: Add `Member.oauth_accounts` storage and linking + + +#### Comparison: Option 7 vs Option 8 + +| Aspect | Option 7 (Member→Customer) | Option 8 (Member Email Only) | +|--------|----------------------------|------------------------------| +| **Auth System** | ✅ CustomerSession works unchanged | ❌ Need MemberSession parallel system | +| **Benefit Grants** | ✅ BenefitGrant.customer_id unchanged | ❌ Need member_id field + dual queries | +| **Portal Endpoints** | ✅ All 44 endpoints work unchanged | ❌ All 44 need Customer\|Member conditional logic | +| **OAuth Accounts** | ✅ Customer.oauth_accounts unchanged | ❌ Need Member.oauth_accounts duplicate storage | +| **Multi-org Support** | ✅ Alice can be member AND individual customer | ❌ Blocked - cannot distinguish login context | +| **Email Changes** | ✅ Customer email update logic works | 🟡 Need custom Member email update logic | +| **Migration Complexity** | 🟡 Medium (create Customers) | ✅ Simple (copy emails) | +| **Ongoing Maintenance** | ✅ Single code path | ❌ Dual code paths everywhere | +| **Files Modified** | ✅ 5-10 files | ❌ 70-100 files | +| **Technical Debt** | ✅ Low | ❌ High (parallel systems) | + From 0ef3e6d3fc35b70d87906d6af9158dc3bfc52f8f Mon Sep 17 00:00:00 2001 From: Petru Rares Sincraian Date: Fri, 7 Nov 2025 15:23:50 +0100 Subject: [PATCH 07/15] docs: update comparison --- engineering/design-documents/business-entity.mdx | 3 --- 1 file changed, 3 deletions(-) diff --git a/engineering/design-documents/business-entity.mdx b/engineering/design-documents/business-entity.mdx index afc58c0..9fe85c4 100644 --- a/engineering/design-documents/business-entity.mdx +++ b/engineering/design-documents/business-entity.mdx @@ -833,9 +833,6 @@ elif isinstance(subject, Member): | **Portal Endpoints** | ✅ All 44 endpoints work unchanged | ❌ All 44 need Customer\|Member conditional logic | | **OAuth Accounts** | ✅ Customer.oauth_accounts unchanged | ❌ Need Member.oauth_accounts duplicate storage | | **Multi-org Support** | ✅ Alice can be member AND individual customer | ❌ Blocked - cannot distinguish login context | -| **Email Changes** | ✅ Customer email update logic works | 🟡 Need custom Member email update logic | | **Migration Complexity** | 🟡 Medium (create Customers) | ✅ Simple (copy emails) | | **Ongoing Maintenance** | ✅ Single code path | ❌ Dual code paths everywhere | | **Files Modified** | ✅ 5-10 files | ❌ 70-100 files | -| **Technical Debt** | ✅ Low | ❌ High (parallel systems) | - From 51ec351a384f0dd1457f8da3d2d4dff8ca96e333 Mon Sep 17 00:00:00 2001 From: Petru Rares Sincraian Date: Wed, 12 Nov 2025 21:03:56 +0100 Subject: [PATCH 08/15] feat: update business entity --- .../design-documents/business-entity.mdx | 1191 +++++++---------- 1 file changed, 461 insertions(+), 730 deletions(-) diff --git a/engineering/design-documents/business-entity.mdx b/engineering/design-documents/business-entity.mdx index 9fe85c4..0cc3d58 100644 --- a/engineering/design-documents/business-entity.mdx +++ b/engineering/design-documents/business-entity.mdx @@ -3,836 +3,567 @@ **Created**: November 5, 2025 **Last Updated**: November 5, 2025 - -## Summary - -### Problem Statement - -The current seat-based billing implementation faces critical limitations in B2B multi-tenant scenarios: - -1. **Ambiguous Event Attribution**: When a customer belongs to multiple seat-based subscriptions from different organizations (e.g., Acme and Slack), events lack business context to determine which customer should be billed. -2. **Inflexible Billing Management**: Changing the billing manager becomes tricky and information is on one customer or another -3. **No Business-Level Aggregation**: When a business has multiple subscriptions from the same merchant, it's difficult to have business-level aggregation. - -### Requirements -1. Attribute events when customer belongs to multiple businesses -2. Change billing manager without losing subscription history -3. Payment methods belong to business and not individuals -4. Both merchants and customers can create businesses -5. Enable business-level usage aggregation across different subscriptions -6. Support multiple billing managers per business - ---- - -## Current Architecture Analysis - -The current model is: -``` -Subscription -├─ customer_id (UUID) ───────────> Customer (Billing Manager) -├─ seats (int) # Total seats purchased -└─ customer_seats (List) - └─ CustomerSeat - ├─ customer_id (UUID) ───> Customer (Seat Holder) - ├─ status (SeatStatus) # pending, claimed, revoked - ├─ invitation_token - └─ claimed_at -``` - -**Key Files**: -- `server/polar/models/customer_seat.py` - Seat model with lifecycle -- `server/polar/models/subscription.py` - Subscription with seats field -- `server/polar/models/product_price.py` - ProductPriceSeatUnit with tiering -- `server/polar/customer_seat/service.py` - Seat assignment/claiming logic -- `server/polar/meter/service.py:424-445` - Metered billing routing to change map the meters to the billing_manager - ---- - ## Problem Statement -### Problem 1: Multi-Tenant Event Attribution - -**Scenario**: -- Slack (merchant) sells a "Pro" product with per-message pricing -- Customer C is part of Acme's Slack workspace (10-seat subscription) -- Customer C is also part of Lolo's Slack workspace (5-seat subscription) -- Customer C sends a message → Which subscription should be billed? - -**Current Behavior**: -```python -# Event created without business context -Event( - name="message.sent", - customer_id="customer_c", - user_metadata={"count": 1} -) - -# Billing logic in meter/service.py:424-445 -customer_price = await repo.get_by_customer_and_meter(customer_c, meter) -# Returns FIRST matching subscription (arbitrary) -# ❌ Could bill Acme when message was sent in Lolo's workspace -``` - -**Required Solution**: Explicit business context in events to route billing correctly. - ---- - -### Problem 2: Inflexible Billing Manager Changes - -**Scenario**: -- Acme Corporation has a subscription with 50 seats -- Original billing manager: alice@acme.com (Customer ID: cust_123) -- New billing manager needed: finance@acme.com (Customer ID: cust_456) - -**Current Behavior**: -```python -# Subscription is directly tied to customer -Subscription( - id="sub_acme", - customer_id="cust_123", # Alice - seats=50 -) - -# To change billing manager, must: -# 1. Create new customer (finance@acme.com) -# 2. Migrate subscription to the new owner -# 3. Migrate pending billing entries to the customer -# ❌ First customer loses access to the history -# ❌ Loses historical billing data continuity -# ❌ Complex and error prone migration -``` - -**Required Solution**: move billing to the business entity - ---- - -### Problem 3: No Business-Level Metrics - -**Scenario**: -- Acme Corporation has 3 subscriptions (Pro Plan, Enterprise API, Storage) -- Each of the 3 subscriptions have a different billing manager. -- Merchant and Business wants to see total organizational spending and usage - -**Current behaviour** -- ❌ No way to group the billing managers under the same umbrella. - -**Required Solution**: Business-level aggregation queries and reporting. - ---- - -### Problem 4: Multiple billing_managers - -**Scenario**: -- Acme has 20 company, CEO, CFO, and workers. The CFO is in charge to pay the bills but the CEO wants to have access in managing the seats and permissions. +### Current Limitations -**Current behaviour** -- ❌ No way to group the billing managers under the same umbrella. +1. **Ambiguous Event Attribution**: When a customer belongs to multiple seat-based subscriptions from different organizations (e.g., Alice works for both Acme and Slack), events lack business context to determine which customer should be billed. +2. **Inflexible Billing Management**: Changing the billing manager becomes tricky because billing information (payment methods, orders) is tied to a single Customer entity. +3. **No Business-Level Aggregation**: When a business has multiple subscriptions from the same merchant, it's difficult to aggregate usage or provide consolidated billing. +### Requirements -**Required Solution**: Business-level aggregation queries and reporting. +1. **Subscription Visibility:** Know what subscriptions a business customer has. +2. **Billing Transfer**: Allow transfer of subscriptions to another billing manager +3. **Clear Attribution**: Easily know who is the billing customer of an event ---- +### Ideal Workflows -## Tenets -> **Important!** review the tenets first! -> -> Tenets are principles that held true and guide the decision-making. They serve to clarify what is important when there is disagreement, drive alignement, and facilitate decision making. +#### Workflow A: Public Checkout (Selling themes) +1. Customer visits merchant site, clicks checkout link +2. Polar Checkout: Buyer enters email + card + team checkbox +3. Polar auto-creates business or individual billing accordingly +4. Post-purchase: Buyer invites team members, manages via Customer Portal -These are the tenets that I consider for the following document, sorted by priority, first is the highest priority. +#### Workflow B: Checkout Sessions (Startup case) +1. Customer creates account in merchant app, creates business team +2. Merchant app creates Polar customer + business via API +3. Billing manager from team clicks checkout in merchant app +4. Polar Checkout: Pre-filled business name + manager email, enters card +5. Returns to merchant app (optional: Customer Portal access) -1. Billing accuracy: events must always bill the correct entity -2. Backward compatibility: existing customers and subscriptions continue working unchanged. -3. Customer experience: individual customers and business have a nice and seamless experience when purchasing or managing their subscriptions. -4. Merchant Developer experience: API should be intuitive with minimal conditional logic. -5. Operational Flexibility: support growth from individual -> startups -> enterprise - 1. Seamless WorkOS/Auth0 integration - 2. Business roles (billing manager, admin, member) - 3. Multi-manager suport -6. Performance: no degradation on subscription creation, event ingestion and processing. -7. Polar developer experience: polar engineers can understand, test, and extend the system. -## Solutions +## Solution -✅ 1 point, 🟡 0 points, ❌ -1 point +### Tenets -| Option | Weight | Option 1: Business + BusinessCustomer | Option 6: Synthetic Business | Option 7: Member/Beneficiary | -|-------------------------|--------|---------------------------------------|------------------------------|------------------------------| -| Billing Accuracy | 7 | ✅ | ✅ | ✅ | -| Backward compatibility | 6 | 🟡 | 🟡 | 🟡 | -| Customer experience | 5 | ✅ | ✅ | ✅ | -| Merchant dev experience | 4 | ❌ | 🟡 | 🟡 | -| Operational flexibility | 3 | ✅ | 🟡 | ✅ | -| Performance | 2 | ✅ | ❌ | ❌ | -| Polar dev experience | 1 | 🟡 | ✅ | 🟡 | -| **Score** | - | 13 | 11 | **15** | +1. **Billing Accuracy** - Charge the right customer, events hit correct paying customer + 1. We are charing the right amount for customers that are in 2 business subscriptions. + 2. Events always hit the correct entities for both customers. +2. **Backward Compatibility** - Existing B2C customers continue working unchanged +3. **Simplicity** - Seamless checkout, straightforward queries, simple event ingestion + 1. Seamless checkout one that makes sense and creates a single customer with everything tied up correctly (subscription and payment methods) and there is only 1 entity created during the checkout. + 2. Easy query the data that we want for customers and business customers. + 3. Keep event ingestion API straightforward. The main id that we pass in the event will be the user who triggered the action. +4. **Minimal Changes for B2B** - Merchants make minimal changes when adding B2B +5. **Polar Developer Experience** - Avoid excessive filtering or complex queries +6. **WorkOS/Auth0 Integration** - Support enterprise identity providers -Solution 3 and 4 are discarded because they don't meet the requirements and don't fix the problem. -Solution 2, 5, and 8 are discarded because they are the lowest score. -### Option 1: Introduce Business and Business Customer -The idea of this architecture is to introduce 2 new concepts: +### Scoring Matrix -- Business: that represents the legal entity who is purchasing the product -- Business Customer: it's an employee of the company. At the beggining it will have only the role to manage the business (changing subscription seats, payment methods, download invoices, etc) +Scale: 🔴 1 (poor) | 🟡 2 (acceptable) | 🟢 3 (excellent) -CustomerSeats will remain the same, as those are the employees who benefit the the product. +| Tenet | Weight | Option 2: Member Layer (✅ CHOSEN) | Option 1: BillingAccount | +| ----------------------- | ------ | --------------------------------- | ------------------------ | +| Billing Accuracy | 7 | 🟡 2 (14) | 🟢 3 (21) | +| Backward compatibility | 6 | 🟢 3 (18) | 🟡 2 (12) | +| Simplicity | 5 | 🟡 2 (10) | 🟡 2 (10) | +| Minimal changes for B2B | 4 | 🟡 2 (8) | 🟡 2 (8) | +| Polar dev experience | 3 | 🔴 1 (3) | 🟢 3 (9) | +| WorkOS | 2 | 🟢 3 (6) | 🟢 3 (6) | +| **Total Score** | - | **59** | **66** | -#### New Entity: Business -The **Business** entity represents a billing organization that owns subscriptions and groups customers. +We will extend the existing `Customer` entity with a `type` field (individual or business) and introduce a `Member` junction table for team relationships. This provides: +- **Minimal schema changes**: Reuse existing Customer entity for both individuals and businesses +- **Simpler mental model**: Everything is a customer - individuals and businesses +- **Easier migration**: Just add `type` field and Member table, no entity splitting required -```python -class Business(RecordModel): - """ - Represents a business entity that acts as a billing container. +**Key Decision Rationale**: Option 2 scores lower (59/81 vs 66/81), we're prioritizing architectural simplicity over developer experience. Having a single Customer entity is conceptually simpler and avoids the complexity of splitting billing from usage across two entities. The developer experience trade-offs (type filtering) are manageable with proper tooling. We can migrate to BillingAccount if this became a need. - A Business: - - Can have multiple subscriptions and orders - - Future: Enables business-level usage tracking and reporting - """ +### Option 2: Member Layer ✅ RECOMMENDED - id: UUID - organization_id: UUID +**Summary**: Add `type` field to Customer entity: `individual` or `business`. Business customers have Members linking to individual customers. Use Customer for both billing and usage. - # Business Identity. We define a default name for each customer seat. - name: str | None - external_id: str | None -``` +**Why We Choose This**: +- **Simplicity (🟡 2)**: Single Customer entity for both individuals and businesses - simpler data model, easier to reason about +- **Backward Compatibility (🟢 3)**: Minimal migration - just add `type` field to existing customers, no entity splitting +- **Lower Risk**: Smaller change reduces risk +- **Conceptual Clarity**: "A customer is a customer" - whether individual or business, they're all customers -#### New Entity: BusinessCustomer +**Trade-offs & Mitigations**: -Links employees to businesses with optional role (for the future). +1. **Polar Developer Experience (🔴 1)**: Requires type filtering in queries + - **Problem**: Every billing query needs `WHERE customer.type = 'business'` filtering + - **Mitigations**: [Repository scopes + DB Views + Linting rules](#appendix-b-mitigation-strategies-option-2) + - **Our Take**: Trade-off is acceptable. With proper repository patterns, tooling, and documentation, merchants and ourselfs can write clean code. -```python -class BusinessCustomer(RecordModel): - """ - Links a Customer to a Business. The business customer is in charge to manage the business, like adding payment methods, requesting invoices, etc. +2. **Billing Accuracy (🟡 2)**: Customer entity does double duty + - **Problem**: Customer means both "user" and "business" (i know that upwards I said the opposite XD) + - **Mitigations**: [Explicit naming conventions + Type validation + Clear docs](#appendix-b-mitigation-strategies-option-2) + - **Our Take**: Clear naming (individual_customer_id vs business_customer_id) and good documentation mitigate confusion. - Represents membership in a business organization, allowing: - - Multiple customers per business - - Same customer in multiple businesses - - Future: Role-based access (member, admin, etc.) - """ - id: UUID - business_id: UUID - customer_id: UUID - - # for the future to have multiple roles. - role: RoleType +**Key Architecture**: ``` - -#### Modified Entity: BillingEntry, Order, Payment Method, Refund, Subscription - -Add optional `business_id` foreign key for business-owned entities. Entities that are used to manage the billing cycle should have a `business_id` if the buyer it's a business or a customer_id if it's an individual. - -```python -class Subscription(RecordModel): - # ... existing fields ... - - # EXISTING - for backward compatibility (direct-to-customer) - customer_id: UUID | None - - # NEW - for business-owned subscriptions - business_id: UUID | None +Customer (id, email, name, type: individual|business) + ├─ Subscriptions (for business customers) + ├─ Orders (for any customer) + ├─ PaymentMethods (for any customer) + ├─ Events (generates if individual, pays if business) + ├─ CustomerSeats (can claim if individual) + ├─ BenefitGrants (receives if individual) + └─ CustomerSessions (authenticates if individual) + +Member (business_customer_id, individual_customer_id, role) + ├─ Links business customers to individual members + └─ Defines roles (admin, billing_manager, member) ``` -#### Modified Entity: Event - -Add optional `business_id` for explicit business context in usage events. When the buyer it's a business, merchants should send the `business_id` on the events to avoid ambiguity when a Customer is on multiple businesses. +**See [Appendix A](#appendix-a-detailed-er-diagram-option-2) for detailed ER diagram and flows.** -```python -class Event(RecordModel): - # ... existing fields ... - - customer_id: UUID - - # NEW - for business-owned subscriptions - business_id: UUID | None -``` +### Option 1: BillingAccount Entity ❌ REJECTED -#### Tenets -1. ✅ Billing accuracy: events are attributed to a single customer -2. 🟡 Backward compatibility: customers that enabled seat-based billing will receive null customerIds in the API responses for some entities. -3. ✅ Customer experience: individual customers and business customers can have a tailored experience based on their needs. -4. ❌ Merchant Developer experience: the merchant will need to do branching depending if the entities and customer is a business or an individual customer. -5. ✅ Operational Flexibility: this maps to WorkOS and allows all features requested by our customers. -6. ✅ Performance: no degradation on the first place -7. 🟡 Polar developer experience: we need to be aware of the branching. -### Option 2: Single Table Inheritance -Instead of adding a `business_id` in each one of the entities that are used to manage the billing cycle, we can add a new concept that is a `BillingCustomerId` that holds who is the owner of that entity. +**Summary**: Introduce dedicated `BillingAccount` entity that owns all billing-related entities. Customers remain for usage tracking. Clean separation of concerns. -#### New Entity: BillingCustomerId +**Why We Rejected This**: +- **Too Many Entities**: Creates extra BillingAccount entity for every customer (even B2C) +- **Migration Complexity**: Requires splitting data between Customer and BillingAccount +- **Conceptual Overhead**: Developers must understand "Customer" (usage) vs "BillingAccount" (billing) distinction +- **Over-Engineering**: The separation of concerns is theoretically cleaner but adds complexity. -```python -class BillingCustomerId(RecordModel): - """ - A holding entity that represents the original `id` of the customer or the new `business_id`. - """ +**Why It Scores Well**: +- **Billing Accuracy (🟢 3)**: Crystal clear separation - no ambiguity about who pays vs who uses +- **Developer Experience (🟢 3)**: Clean queries without type filtering +- **Long-term Scalability**: Easier to extend with business-level features - id: UUID - business_id: UUID | None - customer_id: UUID | None -``` +**Rejection Rationale**: Option 1 scores 7 points higher (66 vs 59) due to superior developer experience (🟢 3 vs 🔴 1) and separation of concerns. But, in our assessment: +- Having ONE entity (Customer) is conceptually simpler than TWO entities (Customer + BillingAccount) +- Migration risk is lower (adding a field vs splitting entities) +- The filtering overhead is acceptable with proper repository patterns -#### Modified Entity: BillingEntry, Order, Payment Method, Refund, Subscription +Option 1's clean separation it's appealing, but we believe the simpler data model outweighs the query complexity trade-offs. See [Appendix C](#appendix-c-why-we-rejected-billingaccount) for detailed analysis. -Add optional `business_id` foreign key for business-owned entities. Entities that are used to manage the billing cycle should have a `business_id` if the buyer it's a business or a customer_id if it's an individual. - -```python -class Subscription(RecordModel): - # ... existing fields ... +--- - # Existing but now points to BillingCustomerId - customer_id: UUID +## Implementation Plan: Option 2 (Member Layer) + +Given that each feature will take 2-4 days to implement. I think we can deliver within 14 business days (optimistic) and 28 business days for a single dev. + +**Feature 1**: Extend Customer entity with type discrimination. Add Member table. +1. **Schema Changes** (add type to Customer entity) +**Feature 2**: Update logic to handle both individual and business customers. +2. **Update Event Model**. We should validate that the event model has a business_customer_id if the customer is on multiple businesses. +3. **Update billing entities logic**: we should make sure that the person who is updating the subscription/order/etc. has permissions to do it. +4. **Checkout Flow Updates**: if we are purchasing for a business, we should create 2 customers, one for the business, and another one for the individual customer +**Feature 3:** Member Management +5. **MemberEndpoints** implement the needed endpoints and frontend to manage the Members inside a business. +**Feature 4**: Metered Pricing & Events +6. **Update event ingestion**: we should make sure that we are charging a billable customer on B2B scenarios. +7. Update metering service: we should provide a global usage of meters per subscription +**Feature 5**: Customer Portal & API +8. Expose customer-portal to list members, add members, remove members, edit member permissions +9. Show customer type clearly in API and expose members for business customers. +**Feature 6**: Display Business Customers in Dashboard +10. Display the business customers with their members in the dashboard +11. Update the dashboard to allow to view all subscriptions of business customers and global metrics +**Feature 7**: Rollout & Monitoring +12. Create alerts & monitors +13. Beta test with new customers +14. Rollout to new customers + +## Appendices + +### Appendix A: Detailed ER Diagram (Option 2) + +```mermaid +erDiagram + Organization ||--o{ Customer : "has" + Organization ||--o{ Product : "sells" + + Customer ||--o{ Member : "business customers have" + Customer ||--o{ Member : "individuals belong to" + Customer ||--o{ Subscription : "owns" + Customer ||--o{ Order : "owns" + Customer ||--o{ PaymentMethod : "owns" + Customer ||--o{ Event : "generates or pays for" + Customer ||--o{ CustomerSeat : "can claim (individuals only)" + Customer ||--o{ BenefitGrant : "receives (individuals only)" + Customer ||--o{ CustomerSession : "can login (individuals only)" + + Subscription ||--o{ CustomerSeat : "allocates" + Subscription }o--|| Product : "is for" + Subscription }o--o| PaymentMethod : "pays with" + + Order }o--|| Product : "is for" + + Customer { + uuid id PK + uuid organization_id FK + string type "individual or business (NEW)" + string email "REQUIRED for individual, NULL for business" + string name "Person name OR Business name" + string external_id + string stripe_customer_id + jsonb user_metadata + } + + Member { + uuid id PK + uuid business_customer_id FK "Customer where type=business" + uuid individual_customer_id FK "Customer where type=individual" + string role "admin, billing_manager, member" + datetime invited_at + datetime joined_at + bool is_active + } + + Subscription { + uuid id PK + uuid customer_id FK "Individual (B2C) OR Business (B2B)" + uuid payment_method_id FK + int seats "NULL for non-seat, number for seat-based" + string status + } + + CustomerSeat { + uuid id PK + uuid subscription_id FK + uuid customer_id FK "ONLY type=individual receives benefits" + string status + } + + Event { + uuid id PK + uuid customer_id FK "Who did it: Individual" + uuid billing_customer_id FK "Who pays: Individual (B2C) OR Business (B2B)" + string name + } + + CustomerSession { + uuid id PK + uuid customer_id FK "ONLY type=individual can login" + string token + } + + BenefitGrant { + uuid id PK + uuid customer_id FK "ONLY type=individual receives benefits" + uuid subscription_id FK + } ``` +### Appendix B: Mitigation Strategies (Option 2) -#### Tenets -Same as option 1, but with: -1. ✅ Billing accuracy: events are attributed to a single customer -2. **🟡 Backward compatibility**: we will always return the customerId in the response, but now it can be a businessId or a customer itself. But business ids will not be available under `/v1/customers/{customerId}`. This will affect only orgs that enabled seat-based pricing. -3. ✅ Customer experience: individual customers and business customers can have a tailored experience based on their needs. -4. ❌ Merchant Developer experience: the merchant will need to do branching depending if the entities and customer is a business or an individual customer. -5. ✅ Operational Flexibility: this maps to WorkOS and allows all features requested by our customers. -6. ❌ Performance: one extra joinload on all queries that affect customers or businesses. -7. 🟡 Polar developer experience: we need to be aware of the branching. +This appendix details some solutions for Option 2's trade-offs. -### Option 3: Continue with CustomerSeats only +#### B.1: Developer Experience - Repository Scopes -Currently, we have the problem of multi-tenant attribution. But this is not only related to business customers, it can also happen to invidividual customers that have 2 subscriptions and have a shared meter. +**Problem**: Every query needs type filtering, causing boilerplate and bug risk. +**Solution**: Repository methods encapsulate type filtering. +**Implementation**: -For example, Customer C, has two subscriptions with meter "storage_usage" and we send usage events. We don't know where to attribute this usage. - -#### Modified Entity: Event -Add mandatory `subscription_id` for subscriptions that have a metered pricing. This way, we can properly assign each event to the correct subscription and customer_seat. ```python -class Event(RecordModel): - # ... existing fields ... - - # NEW - explicit business context for multi-tenant scenarios - subscription_id: UUID | None +# server/polar/customer/repository.py +class CustomerRepository: + """Centralizes Customer queries with type filtering.""" + + async def list_individuals( + self, + session: AsyncSession, + organization_id: UUID, + **filters, + ) -> list[Customer]: + """Get all individual customers. Type filter is built-in.""" + stmt = ( + select(Customer) + .where( + Customer.organization_id == organization_id, + Customer.type == CustomerType.individual, + ) + ) + # Apply additional filters + for key, value in filters.items(): + stmt = stmt.where(getattr(Customer, key) == value) + return await session.execute(stmt).scalars().all() + + async def list_businesses( + self, + session: AsyncSession, + organization_id: UUID, + **filters, + ) -> list[Customer]: + """Get all business customers. Type filter is built-in.""" + stmt = ( + select(Customer) + .where( + Customer.organization_id == organization_id, + Customer.type == CustomerType.business, + ) + ) + for key, value in filters.items(): + stmt = stmt.where(getattr(Customer, key) == value) + return await session.execute(stmt).scalars().all() + + async def get_individual( + self, session: AsyncSession, id: UUID + ) -> Customer: + """Get customer and validate type=individual.""" + customer = await session.get(Customer, id) + if not customer: + raise NotFound(f"Customer {id} not found") + if customer.type != CustomerType.individual: + raise ValueError(f"Customer {id} is not an individual") + return customer + + async def get_business( + self, session: AsyncSession, id: UUID + ) -> Customer: + """Get customer and validate type=business.""" + customer = await session.get(Customer, id) + if not customer: + raise NotFound(f"Customer {id} not found") + if customer.type != CustomerType.business: + raise ValueError(f"Customer {id} is not a business") + return customer ``` -#### Tenets - -1. ✅ Billing accuracy: events are attributed to a single customer -2. 🟡 Backward compatibility: this will only affect seat based subscriptions and only the merchants on beta will need to upgrade. -3. 🟡 Customer experience: customers will have the same experience as now. Business customers may want more features. -4. 🟡 Merchant Developer experience: the merchant will need to check if they should add the subscription_id to the events. Or always gather the subscription_id related to the customer. -5. ❌ Operational Flexibility: no way to have roles inplace. Difficult to work with the concept of organization. -6. ✅ Performance: no degradation on the first place -7. ✅ Polar developer experience - -### Option 4: don't allow same customer to have same meter - -**Discarded:** most important tenents are not feasible. - - We can add a validation that when a customer subscribes or claims a seat, we check if there is any meter clash on the products that bought or claimed. - -With this we can always infer the correct subscription to be charged for usaged based. - -#### Tenets - -1. ✅ Billing accuracy: events are attributed to a single customer -2. ❌ Backward compatibility: it's a new validation that we want to add that will be blocked in the future. -3. ❌ Customer experience: we don't allow legitimate business cases -4. ❌ Merchant Developer experience: developers -5. ❌ Operational Flexibility: no way to have any of the features -6. ✅ Performance: no degradation on the first place -7. ✅ Polar developer experience - -### Option 5: Have different type of Customers - - We can have `individual` and `business` customers. We could use inheritance for that instead of composition. - - We will have the exact same problems as in 2, and our model will be more confusing, as we can grow Customers without clear definition. Like: - -- Why isn't a Seat holder a type of customer? -- Why isn't a Visitor a type of customer? - -#### Tenets -Same as [[Business Entity Design for Multi-Tenant Seat-Based Billing#Option 2|Option 2]] except: -1. ✅ Billing accuracy: events are attributed to a single customer -2. **🟡 Backward compatibility**: we will always return the customerId in the response, but now it can be a businessId or a customer itself. But customer entities has types and depending on the types they will behave differently. Same semantic but a breaking change in the meaning. -3. ✅ Customer experience: individual customers and business customers can have a tailored experience based on their needs. -4. ❌ Merchant Developer experience: the merchant will need to do branching depending if the customer is a business or an individual customer. -5. 🟡 Operational Flexibility: this maps to WorkOS and allows all features requested by our customers. -6. ✅ Performance: no degradation on the first place -7. **❌ Polar developer experience: we need to be aware of the branching and boundaries of what is a Customer is less defined.** - -### Option 6: Synthetic Business for all - - We can introduce the concept of `Business` and `BusinessCustomer` as in [[Business Entity Design for Multi-Tenant Seat-Based Billing#Option 1 Introduce Business and Business Customer|Option 1]] but we have a synthetic business for all individual customers. So, when a new customer is created we create a business with a name "Birk's Personal Account" or similar. - -#### New type: BusinessType +**Benefit**: Service layer code never writes type filters: ```python -class BusinessType(StrEnum): - individual = "individual" # Synthetic business for 1 person - business = "business" # Real business entity - - -class Business(RecordModel): - """Billing entity - all subscriptions belong to businesses.""" - - id: UUID - organization_id: UUID - - type: BusinessType - - # Identity - name: str # "Birk's Personal Account" OR "Acme Corporation" - external_id: str | None - - # ... same fields as in Option 1 +# Service layer - clean and safe +async def get_business_subscriptions(business_id: UUID): + business = await customer_repo.get_business(session, business_id) + # Type validation already done by repository + return business.subscriptions ``` -#### Modified Entity: BillingEntry, Order, Payment Method, Refund, Subscription -Add optional `business_id` foreign key for business-owned entities. Entities that are used to manage the billing cycle should have a `business_id` if the buyer it's a business or a customer_id if it's an individual. +#### B.2: Billing Accuracy - Explicit Naming +**Problem**: `Event.customer_id` could mean "who did it" or "who pays" - ambiguous. +**Solution**: Use explicit field names that clarify intent. +**Implementation**: ```python -class Subscription(RecordModel): - # ... existing fields ... - - # No more customer_id. Only business_id to the billing related entities. - business_id: UUID # FK to Business (REQUIRED, not nullable) -``` -#### Modified Entity: Event - -Add mandatory `business_id` for explicit business context in usage events. - -```python +# server/polar/models/event.py class Event(RecordModel): - # ... existing fields ... - - # NEW - explicit business context for multi-tenant scenarios - business_id: UUID # FK to Business (REQUIRED, not nullable) -``` -#### Tenets -Same as [[Business Entity Design for Multi-Tenant Seat-Based Billing#Option 2|Option 2]] except: -1. ✅ Billing accuracy: events are attributed to a single customer -2. **🟡 Backward compatibility**: business will be always a required parameter. We can create a v2 endpoints for that and only require if customers enable seat based billing. -3. ✅ Customer experience: individual customers and business customers can have a tailored experience based on their needs. For individual customers, we may present more an "enterprise" view when there may not be the need. -4. 🟡 Merchant Developer experience: merchant will treat all the customers the same way. But a first migration is needed. -5. 🟡 Operational Flexibility: this maps to WorkOS and allows all features requested by our customers. -6. ❌ Performance: one extra joined load on every customer query -7. **✅ Polar developer experience:** no need to do branching as Customers are the ones using the product and Business entities are the ones managing the billing cycle. - -### Option 7: Member Layer - -**Core Insight**: Instead of adding a `Business` layer ABOVE customers, add a `Member` layer BELOW customers. This preserves the existing `Customer` as the billing entity while introducing granular product usage tracking. - -**Semantic Shift**: In this model, `Customer` represents the billing entity (the team/organization for seat-based, or individual for non-seat-based). Members are the people who use the product. - -``` -Customer("acme") = billing entity (the team) - ├── Member("alice", role="billing_manager") = product user + can manage - ├── Member("bob", role="admin") = product user + can manage - └── Member("charlie", role="member") = product user only - -Customer("dave") = billing entity (individual) - └── No Member - Dave is also the sole user -``` - -#### Why Keep CustomerSeat and Member Separate? - -They serve different purposes: -- **CustomerSeat**: Seat allocation and invitation lifecycle related to an Order or Subscription. -- **Member**: Role management, team structure, permissions (new). - -Customers can have multiple active subscriptions, and each one of those subscriptions can have different members assigned. Separation of entities would allow this flexibility. - -#### New Entity: Member - -The **Member** entity represents a person's membership in a team customer's organization. - -Members are linked to a Customer record (`user_customer_id`). This enables that the following features works out of the box: - -- Customer Portal -- Customer Portal API endpoints -- Webhooks events related to customers -- OAuth accounts. GitHub, Discord, etc. - -```python -class MemberRole(StrEnum): - billing_manager = "billing_manager" # Can manage billing & users - admin = "admin" # Can manage users - member = "member" # Can only use product - -class Member(RecordModel): """ - Represents a person's membership in a team customer's organization. - - Relationship with CustomerSeat: - - If billable=True: Member must have a CustomerSeat (1:1 relationship) - - If billable=False: Member has no CustomerSeat (billing manager, admin) - - Relationship with Customer: - - team_customer_id: The team/organization (e.g., ACME Corp) - - user_customer_id: The individual person (e.g., Alice) - - Why user_customer_id is REQUIRED (not optional): - - Authentication: CustomerSession links to customer_id - - Benefits: BenefitGrant requires customer_id - - OAuth: Customer.oauth_accounts stores linked accounts - - Proven pattern: CustomerSeat.customer_id already works this way + Event tracking for usage-based billing. + + Fields: + - customer_id: The individual customer who performed the action (usage actor) + - billing_customer_id: The customer who pays for this usage (billing payer) + Can be: + - Same as customer_id (B2C individual) + - Business customer (B2B) """ - id: UUID - team_customer_id: UUID # FK to Customer (the team/organization) - - # Identity - REQUIRED link to individual's Customer record - user_customer_id: UUID # FK to Customer (individual person, REQUIRED) - # Note: email/name are on the Customer record, not duplicated here - - # Access control - role: MemberRole - billable: bool # If True, requires a CustomerSeat - - # Link to seat allocation (if billable) - customer_seat_id: UUID | None # FK to CustomerSeat - - # Member lifecycle - status: MemberStatus # pending, active, revoked - invitation_token: str | None - claimed_at: datetime | None - revoked_at: datetime | None - -# Relationship diagram: -# Customer("acme", email="billing@acme.com") ← team -# ├── Member(role="billing_manager", billable=False, customer_seat_id=None) -# │ └──> Customer("alice", email="alice@acme.com") ← individual (user_customer_id) -# └── Member(role="member", billable=True, customer_seat_id="seat_1") -# ├──> Customer("bob", email="bob@acme.com") ← individual (user_customer_id) -# └──> CustomerSeat(id="seat_1", customer_id="cust_bob") + customer_id: UUID # Usage actor (always individual) + billing_customer_id: UUID # Billing payer (individual OR business) + + # Relationships with explicit naming + usage_customer = relationship( + "Customer", + foreign_keys=[customer_id], + backref="usage_events", + ) + billing_customer = relationship( + "Customer", + foreign_keys=[billing_customer_id], + backref="billing_events", + ) ``` +#### B.4: Developer Experience - Linting Rules -#### Existing Entity: CustomerSeat (Unchanged) +**Problem**: Easy to forget type filtering, causing bugs. +**Solution**: Pre-commit hooks that warn about missing type filters. Maybe we can add it to ruff. -The **CustomerSeat** model remains unchanged and continues to handle seat allocation: +**Implementation**: ```python -class CustomerSeat(RecordModel): - """Existing model - no changes""" - - subscription_id: UUID | None - order_id: UUID | None - status: SeatStatus # pending, claimed, revoked - customer_id: UUID | None # Individual who claimed the seat - invitation_token: str | None - claimed_at: datetime | None - revoked_at: datetime | None - metadata: dict[str, Any] | None +# scripts/lint_customer_queries.py +""" +Pre-commit hook to check Customer queries have type filters. We can implement something more sophisiticated if we get false positivies. +""" +import ast +import sys + +class CustomerQueryChecker(ast.NodeVisitor): + def __init__(self): + self.warnings = [] + + def visit_Call(self, node): + if self._is_customer_query(node): + if not self._has_type_filter(node): + self.warnings.append( + f"Line {node.lineno}: Customer query missing type filter" + ) + self.generic_visit(node) + + def _is_customer_query(self, node): + return "Customer" in ast.unparse(node) + + def _has_type_filter(self, node): + source = ast.unparse(node) + return "Customer.type" in source + +def check_file(filepath): + with open(filepath) as f: + tree = ast.parse(f.read()) + checker = CustomerQueryChecker() + checker.visit(tree) + return checker.warnings + +if __name__ == "__main__": + warnings = [] + for filepath in sys.argv[1:]: + warnings.extend(check_file(filepath)) + + if warnings: + print("⚠️ Customer query warnings:") + for w in warnings: + print(f" {w}") + print("\nUse repository scopes instead: customer_repo.list_individuals()") + sys.exit(1) ``` +#### B.5: Billing Accuracy - Type Validation -#### Modified Entity: Subscription - -No changes needed! `Subscription.customer_id` remains but its meaning shifts: -- Non-seat-based: customer_id = individual customer (unchanged) -- Seat-based: customer_id = team customer (the organization/business) +**Problem**: Runtime errors if wrong customer type is used. +**Solution**: Validation decorators and service-layer checks. +**Implementation**: ```python -class Subscription(RecordModel): - # ... existing fields ... - - customer_id: UUID # UNCHANGED - # For non-seat-based: points to individual customer - # For seat-based: points to team customer (semantic shift) - - seats: int | None # If set, subscription is seat-based +# server/polar/utils/validation.py +from functools import wraps + +def requires_business_customer(func): + """Decorator that validates customer is type='business'.""" + @wraps(func) + async def wrapper(self, customer: Customer, *args, **kwargs): + if customer.type != CustomerType.business: + raise ValueError( + f"Operation requires business customer, got {customer.type}" + ) + return await func(self, customer, *args, **kwargs) + return wrapper + +def requires_individual_customer(func): + """Decorator that validates customer is type='individual'.""" + @wraps(func) + async def wrapper(self, customer: Customer, *args, **kwargs): + if customer.type != CustomerType.individual: + raise ValueError( + f"Operation requires individual customer, got {customer.type}" + ) + return await func(self, customer, *args, **kwargs) + return wrapper ``` -#### Modified Entity: Event - -Add optional `member_id` for explicit attribution in seat-based subscriptions with metered pricing. +**Usage**: ```python -class Event(RecordModel): - # ... existing fields ... - - customer_id: UUID # Who pays (unchanged) - - # NEW - explicit user attribution for seat-based scenarios - member_id: UUID | None # Who used (optional) +# server/polar/benefit/service.py +class BenefitService: + @requires_individual_customer + async def grant_benefit( + self, + individual: Customer, + benefit: Benefit, + ) -> BenefitGrant: + pass ``` +### Appendix C: Why We Rejected BillingAccount -#### Entity: BenefitGrant (unchanged) - -For seat-based subscriptions, will still work with CustomerSeats feature. - -```python -class BenefitGrant(RecordModel): - # ... existing fields ... - - # EXISTING - for non-seat-based & seat-based - customer_id: UUID -``` +This appendix explains our rationale for rejecting Option 1. -#### Migration Strategy +#### Reason 1: Entity Segregation -**For existing non-seat-based subscriptions:** -- ✅ No migration needed -- ✅ Subscriptions continue working unchanged -- ✅ Customer remains billing entity and implicit user -- ✅ No CustomerSeat or Member records needed +**Problem**: BillingAccount creates extra entity for every customer. -**For existing seat-based subscriptions (beta customers):** +**Analysis**: +- B2C customer (Alice) → Creates both Customer AND BillingAccount (1:1) +- B2B business (Acme) → Creates BillingAccount + Members + Customers +- Total entities: 2N for N customers (vs 1N with Option 2) -```python -# Step 1: Convert billing manager's customer to "team customer" -billing_manager_customer = get_customer(subscription.customer_id) # Alice (was billing manager) -billing_manager_customer.name = "ACME Corp" # Update to team name -billing_manager_customer.email = "billing@acme.com" # Update to team email - -# Step 2: Create individual Customer record for Alice -# Alice needs her own Customer record to authenticate and receive benefits -alice_customer = Customer( - email="alice@acme.com", - name="Alice", - organization_id=billing_manager_customer.organization_id -) - -# Step 3: Create Member record for billing manager (no seat consumption) -Member( - team_customer_id=billing_manager_customer.id, # Team customer (ACME) - user_customer_id=alice_customer.id, # Alice's individual customer record (REQUIRED) - role="billing_manager", - billable=False, # Doesn't consume a seat - customer_seat_id=None, # No CustomerSeat needed - status="active" -) - -# Step 4: For each CustomerSeat, ensure user has individual Customer record -for seat in subscription.customer_seats: - # CustomerSeat.customer_id should already point to individual Customer - # If not, create one (this shouldn't happen in current system) - individual_customer = get_or_create_customer( - email=seat.email, # From seat metadata or lookup - organization_id=subscription.organization_id - ) +**Why This Matters**: +- More database tables to understand +- More joins in queries +- More entities to mock in tests +- Conceptual overhead: "What's the difference between Customer and BillingAccount?" - # Update CustomerSeat to point to individual Customer (if needed) - seat.customer_id = individual_customer.id - - # Create Member linking team to individual - Member( - team_customer_id=subscription.customer_id, # Team (ACME) - user_customer_id=individual_customer.id, # Individual (Bob, Charlie, etc.) - role="member", - billable=True, - customer_seat_id=seat.id, # Links to existing CustomerSeat - status=seat.status, # Mirrors seat status - claimed_at=seat.claimed_at - ) +**Our Take**: The theoretical purity of separation isn't pragmatic. -# Step 5: CustomerSeat table UNCHANGED -# Step 6: subscription.customer_id UNCHANGED -# But now points to "team customer" instead of individual - -# Result structure: -# Customer("acme", email="billing@acme.com") ← team -# | -# ├─ Subscription(customer_id="acme", seats=50) -# | -# ├─ Member(team="acme", user="alice", role="billing_manager", billable=False) -# | └─> Customer("alice", email="alice@acme.com") ← can authenticate! -# | -# └─ Member(team="acme", user="bob", role="member", billable=True, seat_id="seat_1") -# ├─> Customer("bob", email="bob@acme.com") ← can authenticate! -# └─> CustomerSeat(id="seat_1", customer_id="bob") ← unchanged -``` +#### Reason 2: Migration Complexity -**Key Points**: -1. **CustomerSeat is NOT replaced** - Member is an additive layer that extends CustomerSeat with role management -2. **Every Member must link to individual Customer** - Required for authentication and benefits -3. **Team customer and individual customers coexist** - Within same organization -4. **CustomerSeat.customer_id stays unchanged** - Already points to individual Customer (current pattern) - -#### Tenets - -1. ✅ **Billing accuracy**: Events attributed via member_id for seat-based metered subscriptions -2. 🟡 **Backward compatibility**: - - ✅ Non-seat-based: Zero changes (90% of customers) - - ❌ Seat-based: Breaking changes to subscription queries, benefit grants, events - - `GET /v1/subscriptions?customer_id=alice` → empty (customer_id now points to team) - - `GET /v1/subscriptions?customer_id=acme` → returns subscriptions - - `GET /v1/benefits/grants?customer_id=bob` → works as before -3. ✅ **Customer experience**: Clear separation - Customer = payer, Member = user -4. 🟡 **Merchant Developer experience**: - - ✅ Non-seat-based: No changes - - ❌ Seat-based: Need to track customer_id as team or individuals -5. ✅ **Operational Flexibility**: - - ✅ Multiple billing managers (role="billing_manager", billable=False) - - ✅ Role-based access control via Member.role - - ✅ WorkOS/Auth0 integration (map SSO users → Members) -6. ✅ **Performance**: - - ✅ Non-seat-based: Zero overhead (no joins) - - 🟡 Seat-based: a join needed to get the members or team -7. ✅ **Polar developer experience**: - - Clear boundaries: Customer = billing, Member = usage - - Isolated to seat-based feature - -#### Comparison with Option 6 - -Both Option 6 and Option 7 solve the same problems but with inverted architectures: - -| Aspect | Option 6 (Business Above) | Option 7 (Member Below) | -|--------|---------------------------|-------------------------| -| **Non-seat-based** | Business(type="individual") wrapper | No changes (current architecture) | -| **Database changes** | Add business_id to 5+ tables | Add member table only | -| **API translation** | Required for all endpoints | Not required | -| **Performance** | +2 joins on every query | 0 joins for non-seat-based | -| **Migration** | Migrate ALL subscriptions | Migrate seat-based only | -| **Semantics** | Customer = user, Business = billing | Customer = billing, Member = user | -| **Backward compat** | 🟡 With translation layer | 🟡 Without translation layer | - - -### Option 8: Member Without Customer Link - -**Core Difference from Option 7**: Member does NOT link to individual Customer records. Instead, Member stores email/name directly. - -**Rationale**: Simplify data model by avoiding the need to create individual Customer records for each team member. +**Problem**: Splitting Customer into Customer + BillingAccount is risky. +**Analysis**: +Option 1 migration: +```sql +-- 1. Create BillingAccount table +-- 2. For each Customer, create BillingAccount +-- 3. Move stripe_customer_id from Customer → BillingAccount +-- 4. Add billing_account_id to Subscription, Order, PaymentMethod +-- 5. Backfill billing_account_id +-- 6. Handle dual-mode (check billing_account_id OR customer_id) ``` -Customer("acme") = billing entity (the team) - ├── Member("alice", email="alice@acme.com", role="billing_manager") - └── Member("bob", email="bob@acme.com", role="member") -# No individual Customer records for Alice/Bob +Option 2 migration: +```sql +-- 1. Add Customer.type field +-- 2. Set existing customers to type='individual' for non-seats +-- 3. Set customer to type='business' and create a new individual customer for billing_manager. We may still warm current merchants. +-- 4. Create Member table +-- Done! ``` -#### New Entity: Member (Email-based) +**Why This Matters**: +- Option 1 has 6 steps with data movement +- Option 2 has 4 steps, all additive +- Option 1 requires dual-mode operation (complex) +- Option 2 is backward compatible immediately -```python -class Member(RecordModel): - """ - Represents team membership WITHOUT linking to individual Customer. - Identity stored directly on Member record. - """ +**Our Take**: Lower migration risk is worth the query complexity trade-off. - id: UUID - team_customer_id: UUID # FK to Customer (team) +#### Reason 3: Conceptual Overhead - # Identity stored directly (NO user_customer_id) - email: str # Primary identifier - name: str | None +**Problem**: Developers must learn "Customer" (usage) vs "BillingAccount" (billing) distinction. - # Access control - role: MemberRole - billable: bool - customer_seat_id: UUID | None +**Analysis**: - # Lifecycle - status: MemberStatus - invitation_token: str | None - claimed_at: datetime | None -``` +Developer questions with Option 1: +- "Do I use customer_id or billing_account_id here?" +- "Why does Subscription have both customer_id AND billing_account_id?" +- "Which entity owns the payment method?" +- "Where do I add business-level metadata?" +Developer questions with Option 2: +- "Is this customer individual or business?" (Just check type field). +- "Why I can't assign benefits to business?" -#### Required Infrastructure Changes +**Why This Matters**: +- Onboarding new developers takes longer +- API consumers must understand both concepts +- Documentation must explain the distinction -**1. Dual Authentication System:** -- Keep `CustomerSession` for individual customers -- Add `MemberSession` for team members -- Portal endpoints need to support `AuthSubject[Customer | Member]` +**Our Take**: "Customer is a customer" is simpler to explain than "Customer is usage, BillingAccount is billing". -**2. Dual Benefit System:** -- `BenefitGrant.customer_id` for individual customers -- `BenefitGrant.member_id` for team members -- All benefit queries need to check both fields +#### Reason 4: Over-Engineering -**3. Dual OAuth Storage:** -- Keep `Customer.oauth_accounts` for individual customers -- Add `Member.oauth_accounts` JSONB for team members +**Problem**: Separation of concerns is theoretically clean but practically overkill. -**4. Email Validation:** -- Add unique constraint: `(team_customer_id, email)` on Members table -- Custom validation to prevent conflicts +**Analysis**: -#### Tenets +Option 1 assumes: +- We'll frequently need to query "all usage by this person regardless of who pays" +- We'll add many business-level features that don't apply to individuals +- The billing vs usage distinction is fundamental -1. ✅ **Billing accuracy**: Events attributed via member_id -2. 🟡 **Backward compatibility**: Same as Option 7 (seat-based requires changes) -3. 🟡 **Customer experience**: Works but limited multi-org scenario. -4. 🟡 **Merchant Developer experience**: multiple but similar endpointsfor team members and individual customers -5. 🟡 **Operational Flexibility**: Limited - cannot support multi-org scenario -6. ❌ **Performance**: Dual code paths increase complexity -7. ❌ **Polar developer experience**: - - Must maintain parallel authentication systems (CustomerSession vs MemberSession) - - Must maintain parallel benefit grants (customer_id vs member_id) - - Must maintain parallel OAuth systems - - All customer portal endpoints need conditional logic +Reality: +- Most queries are "show me this customer's data" (type-agnostic) +- Business-level features (analytics, invoicing) can be added to Customer with type checks +- The distinction matters for events, but events already have customer_id + billing_customer_id -#### Critical Limitations +**Why This Matters**: +- We're adding complexity for hypothetical features +- YAGNI principle: Don't build abstractions until you need them -**1. Multi-Organization Scenario (BLOCKED):** -```python -# Alice is member of ACME AND has individual subscription -Member(team="acme", email="alice@acme.com") # Team member +**Our Take**: Start simple (1 entity), add separation later if truly needed. We can always make a complex migration later. -# Alice buys individual subscription -# ❌ Cannot authenticate as Customer("alice") - no Customer record! -# ❌ Same email exists in two contexts (Member and would-be Customer) -# ❌ Login ambiguity: Is alice@acme.com logging in as Member or Customer? -``` +**Decision**: We're choosing **architectural simplicity** (fewer entities, simpler model) over **query simplicity** (no filtering). The trade-off is conscious and acceptable given proper tooling and discipline. -**2. Customer Portal Access Pattern:** -```python -# Current: All endpoints use AuthSubject[Customer] -async def list_subscriptions( - auth_subject: auth.CustomerPortalRead, # Customer only - ... -): - customer = auth_subject.subject # Customer object - subscriptions = query(Subscription.customer_id == customer.id) - -# Option 8: Need conditional logic everywhere -async def list_subscriptions( - auth_subject: auth.CustomerPortalRead | auth.MemberPortalRead, # NEW union type - ... -): - if isinstance(auth_subject.subject, Customer): - subscriptions = query(Subscription.customer_id == auth_subject.subject.id) - elif isinstance(auth_subject.subject, Member): - subscriptions = query(Subscription.customer_id == auth_subject.subject.team_customer_id) - # ^ This pattern repeated in XX endpoints! -``` +--- -**3. Benefit Grant:** -```python -# Current: Simple query -grants = BenefitGrant.query(customer_id=customer.id) - -# Option 8: Dual query everywhere -if isinstance(subject, Customer): - grants = BenefitGrant.query(customer_id=customer.id) -elif isinstance(subject, Member): - grants = BenefitGrant.query(member_id=member.id) -# ^ This pattern repeated in 9+ locations -``` +## Open Questions -#### Implementation Complexity - -**Files to Modify:** -- **New models**: `MemberSession`, modified `BenefitGrant` -- **Auth system**: Add `MemberSession` authenticator, `MemberPortalRead` dependency -- **All portal endpoints**: Support `Customer | Member` union type -- **All portal services**: Conditional logic for customer_id vs member_id -- **All portal repositories**: Dual query patterns -- **Benefit grant service**: Grant to customer_id OR member_id -- **OAuth system**: Add `Member.oauth_accounts` storage and linking - - -#### Comparison: Option 7 vs Option 8 - -| Aspect | Option 7 (Member→Customer) | Option 8 (Member Email Only) | -|--------|----------------------------|------------------------------| -| **Auth System** | ✅ CustomerSession works unchanged | ❌ Need MemberSession parallel system | -| **Benefit Grants** | ✅ BenefitGrant.customer_id unchanged | ❌ Need member_id field + dual queries | -| **Portal Endpoints** | ✅ All 44 endpoints work unchanged | ❌ All 44 need Customer\|Member conditional logic | -| **OAuth Accounts** | ✅ Customer.oauth_accounts unchanged | ❌ Need Member.oauth_accounts duplicate storage | -| **Multi-org Support** | ✅ Alice can be member AND individual customer | ❌ Blocked - cannot distinguish login context | -| **Migration Complexity** | 🟡 Medium (create Customers) | ✅ Simple (copy emails) | -| **Ongoing Maintenance** | ✅ Single code path | ❌ Dual code paths everywhere | -| **Files Modified** | ✅ 5-10 files | ❌ 70-100 files | +1. **WorkOS Integration**: Will this work for WorkOS? From c4b2945c2ca455d3853f1ece116b8618bb613eb8 Mon Sep 17 00:00:00 2001 From: Petru Rares Sincraian Date: Thu, 13 Nov 2025 10:26:52 +0100 Subject: [PATCH 09/15] feat: update to polimorphic --- .../design-documents/business-entity.mdx | 312 ++++++++++-------- 1 file changed, 167 insertions(+), 145 deletions(-) diff --git a/engineering/design-documents/business-entity.mdx b/engineering/design-documents/business-entity.mdx index 0cc3d58..c86a79d 100644 --- a/engineering/design-documents/business-entity.mdx +++ b/engineering/design-documents/business-entity.mdx @@ -58,9 +58,9 @@ Scale: 🔴 1 (poor) | 🟡 2 (acceptable) | 🟢 3 (excellent) | Backward compatibility | 6 | 🟢 3 (18) | 🟡 2 (12) | | Simplicity | 5 | 🟡 2 (10) | 🟡 2 (10) | | Minimal changes for B2B | 4 | 🟡 2 (8) | 🟡 2 (8) | -| Polar dev experience | 3 | 🔴 1 (3) | 🟢 3 (9) | +| Polar dev experience | 3 | 🟡 2 (6) | 🟢 3 (9) | | WorkOS | 2 | 🟢 3 (6) | 🟢 3 (6) | -| **Total Score** | - | **59** | **66** | +| **Total Score** | - | **62** | **66** | We will extend the existing `Customer` entity with a `type` field (individual or business) and introduce a `Member` junction table for team relationships. This provides: @@ -68,7 +68,7 @@ We will extend the existing `Customer` entity with a `type` field (individual or - **Simpler mental model**: Everything is a customer - individuals and businesses - **Easier migration**: Just add `type` field and Member table, no entity splitting required -**Key Decision Rationale**: Option 2 scores lower (59/81 vs 66/81), we're prioritizing architectural simplicity over developer experience. Having a single Customer entity is conceptually simpler and avoids the complexity of splitting billing from usage across two entities. The developer experience trade-offs (type filtering) are manageable with proper tooling. We can migrate to BillingAccount if this became a need. +**Key Decision Rationale**: Option 2 scores lower (62/81 vs 66/81), we're prioritizing architectural simplicity over developer experience. Having a single Customer entity is conceptually simpler and avoids the complexity of splitting billing from usage across two entities. The developer experience trade-offs (type filtering) are manageable with proper tooling. We can migrate to BillingAccount if this became a need. ### Option 2: Member Layer ✅ RECOMMENDED @@ -82,10 +82,10 @@ We will extend the existing `Customer` entity with a `type` field (individual or **Trade-offs & Mitigations**: -1. **Polar Developer Experience (🔴 1)**: Requires type filtering in queries - - **Problem**: Every billing query needs `WHERE customer.type = 'business'` filtering - - **Mitigations**: [Repository scopes + DB Views + Linting rules](#appendix-b-mitigation-strategies-option-2) - - **Our Take**: Trade-off is acceptable. With proper repository patterns, tooling, and documentation, merchants and ourselfs can write clean code. +1. **Polar Developer Experience (🟡 2)**: Type discrimination handled by SQLAlchemy polymorphism + - **Problem**: Polymorphism can lead to complex queries and type checking + - **Mitigations**: Use SQLAlchemy's [single-table inheritance with polymorphic models](#b1-sqlalchemy-polymorphic-models-implementation) + - **Our Take**: Polymorphism eliminates most manual filtering concerns. 2. **Billing Accuracy (🟡 2)**: Customer entity does double duty - **Problem**: Customer means both "user" and "business" (i know that upwards I said the opposite XD) @@ -135,7 +135,7 @@ Option 1's clean separation it's appealing, but we believe the simpler data mode --- -## Implementation Plan: Option 2 (Member Layer) +## Implementation Plan: Option 2 (Member Layer with Polymorphic Models) Given that each feature will take 2-4 days to implement. I think we can deliver within 14 business days (optimistic) and 28 business days for a single dev. @@ -244,90 +244,192 @@ erDiagram ### Appendix B: Mitigation Strategies (Option 2) -This appendix details some solutions for Option 2's trade-offs. +This appendix details solutions for Option 2's trade-offs. -#### B.1: Developer Experience - Repository Scopes +#### B.1: SQLAlchemy Polymorphic Models Implementation -**Problem**: Every query needs type filtering, causing boilerplate and bug risk. -**Solution**: Repository methods encapsulate type filtering. +**Problem**: Manual type filtering in every query causes boilerplate and reduces developer experience. +**Solution**: Use SQLAlchemy's [single-table inheritance](https://docs.sqlalchemy.org/en/20/orm/inheritance.html#single-table-inheritance) to eliminate manual type filtering and improve developer experience. + +**Implementation**: + +We'll leverage SQLAlchemy's polymorphic models to automatically handle type discrimination: + +```python +class Customer(RecordModel): + """Base customer model with polymorphic discrimination.""" + __tablename__ = "customers" + + id: Mapped[UUID] = mapped_column(primary_key=True) + organization_id: Mapped[UUID] = mapped_column(ForeignKey("organizations.id")) + type: Mapped[CustomerType] = mapped_column() # Discriminator column + email: Mapped[str | None] = mapped_column() # NULL for business customers + name: Mapped[str] = mapped_column() + stripe_customer_id: Mapped[str | None] = mapped_column() + # ... other common fields + + __mapper_args__ = { + "polymorphic_on": "type", + "polymorphic_identity": None, # Base class is abstract + } + +class BusinessCustomer(Customer): + """Business customer with team members and subscriptions.""" + + # Type hint for discriminator ensures proper typing + type: Mapped[Literal[CustomerType.business]] = mapped_column() + + # Business-specific relationships + members: Mapped[list["Member"]] = relationship( + "Member", + foreign_keys="Member.business_customer_id", + back_populates="business", + ) + + __mapper_args__ = { + "polymorphic_identity": CustomerType.business, + } + +class IndividualCustomer(Customer): + """Individual customer who can receive benefits and authenticate.""" + + type: Mapped[Literal[CustomerType.individual]] = mapped_column() + + # Individual-specific relationships + benefit_grants: Mapped[list["BenefitGrant"]] = relationship( + "BenefitGrant", + back_populates="customer", + ) + customer_sessions: Mapped[list["CustomerSession"]] = relationship( + "CustomerSession", + back_populates="customer", + ) + memberships: Mapped[list["Member"]] = relationship( + "Member", + foreign_keys="Member.individual_customer_id", + back_populates="individual", + ) + + __mapper_args__ = { + "polymorphic_identity": CustomerType.individual, + } +``` + +**Key Benefits:** + +1. **Automatic Type Filtering**: SQLAlchemy automatically adds WHERE clauses + ```python + # Automatically adds: WHERE customers.type = 'business' + businesses = await session.execute(select(BusinessCustomer)) + ``` + +2. **Smart Type Casting**: Queries return properly typed instances + ```python + # Returns BusinessCustomer or IndividualCustomer instances (not base Customer) + customers = await session.execute(select(Customer)) + for customer in customers.scalars(): + if isinstance(customer, BusinessCustomer): + # IDE knows customer.members exists + print(f"Business with {len(customer.members)} members") + elif isinstance(customer, IndividualCustomer): + # IDE knows customer.benefit_grants exists + print(f"Individual with {len(customer.benefit_grants)} benefits") + ``` + +3. **Type Safety**: Python's type system works naturally + ```python + def grant_benefit(customer: IndividualCustomer, benefit: Benefit): + customer.benefit_grants.append(BenefitGrant(...)) + + def add_member(business: BusinessCustomer, individual: IndividualCustomer): + business.members.append(Member(...)) + ``` + +This approach significantly improves developer experience (🔴 1 → 🟡 2) while maintaining Option 2's architectural simplicity. + +#### B.2: Developer Experience - Repository with Polymorphic Models + +**Problem**: Query boilerplate and ensuring type safety. +**Solution**: Use SQLAlchemy polymorphic models with simple repository methods. **Implementation**: ```python # server/polar/customer/repository.py class CustomerRepository: - """Centralizes Customer queries with type filtering.""" + """Centralizes Customer queries using polymorphic models.""" async def list_individuals( self, session: AsyncSession, organization_id: UUID, **filters, - ) -> list[Customer]: - """Get all individual customers. Type filter is built-in.""" - stmt = ( - select(Customer) - .where( - Customer.organization_id == organization_id, - Customer.type == CustomerType.individual, - ) + ) -> list[IndividualCustomer]: + """Get all individual customers. SQLAlchemy handles type filtering.""" + stmt = select(IndividualCustomer).where( + IndividualCustomer.organization_id == organization_id ) # Apply additional filters for key, value in filters.items(): - stmt = stmt.where(getattr(Customer, key) == value) - return await session.execute(stmt).scalars().all() + stmt = stmt.where(getattr(IndividualCustomer, key) == value) + result = await session.execute(stmt) + return result.scalars().all() async def list_businesses( self, session: AsyncSession, organization_id: UUID, **filters, - ) -> list[Customer]: - """Get all business customers. Type filter is built-in.""" - stmt = ( - select(Customer) - .where( - Customer.organization_id == organization_id, - Customer.type == CustomerType.business, - ) + ) -> list[BusinessCustomer]: + """Get all business customers. SQLAlchemy handles type filtering.""" + stmt = select(BusinessCustomer).where( + BusinessCustomer.organization_id == organization_id ) for key, value in filters.items(): - stmt = stmt.where(getattr(Customer, key) == value) - return await session.execute(stmt).scalars().all() + stmt = stmt.where(getattr(BusinessCustomer, key) == value) + result = await session.execute(stmt) + return result.scalars().all() async def get_individual( self, session: AsyncSession, id: UUID - ) -> Customer: - """Get customer and validate type=individual.""" - customer = await session.get(Customer, id) + ) -> IndividualCustomer: + """Get individual customer. Type-safe by design.""" + result = await session.execute( + select(IndividualCustomer).where(IndividualCustomer.id == id) + ) + customer = result.scalar_one_or_none() if not customer: - raise NotFound(f"Customer {id} not found") - if customer.type != CustomerType.individual: - raise ValueError(f"Customer {id} is not an individual") + raise NotFound(f"Individual customer {id} not found") return customer async def get_business( self, session: AsyncSession, id: UUID - ) -> Customer: - """Get customer and validate type=business.""" - customer = await session.get(Customer, id) - if not customer: - raise NotFound(f"Customer {id} not found") - if customer.type != CustomerType.business: - raise ValueError(f"Customer {id} is not a business") - return customer + ) -> BusinessCustomer: + """Get business customer. Type-safe by design.""" + result = await session.execute( + select(BusinessCustomer).where(BusinessCustomer.id == id) + ) + business = result.scalar_one_or_none() + if not business: + raise NotFound(f"Business customer {id} not found") + return business ``` -**Benefit**: Service layer code never writes type filters: +**Service layer usage**: ```python -# Service layer - clean and safe +# Service layer - clean, type-safe, and concise async def get_business_subscriptions(business_id: UUID): + # Returns BusinessCustomer instance, IDE knows .subscriptions exists business = await customer_repo.get_business(session, business_id) - # Type validation already done by repository return business.subscriptions + +async def grant_benefit_to_individual(individual_id: UUID, benefit: Benefit): + # Returns IndividualCustomer instance, IDE knows .benefit_grants exists + individual = await customer_repo.get_individual(session, individual_id) + individual.benefit_grants.append(BenefitGrant(...)) ``` -#### B.2: Billing Accuracy - Explicit Naming +#### B.3: Billing Accuracy - Explicit Naming **Problem**: `Event.customer_id` could mean "who did it" or "who pays" - ambiguous. **Solution**: Use explicit field names that clarify intent. @@ -362,104 +464,26 @@ class Event(RecordModel): backref="billing_events", ) ``` -#### B.4: Developer Experience - Linting Rules +#### B.4: Developer Experience - Type Checking with mypy -**Problem**: Easy to forget type filtering, causing bugs. -**Solution**: Pre-commit hooks that warn about missing type filters. Maybe we can add it to ruff. +**Problem**: Ensuring developers use the correct customer subtype. +**Solution**: Leverage mypy and type hints with polymorphic models. **Implementation**: -```python -# scripts/lint_customer_queries.py -""" -Pre-commit hook to check Customer queries have type filters. We can implement something more sophisiticated if we get false positivies. -""" -import ast -import sys - -class CustomerQueryChecker(ast.NodeVisitor): - def __init__(self): - self.warnings = [] - - def visit_Call(self, node): - if self._is_customer_query(node): - if not self._has_type_filter(node): - self.warnings.append( - f"Line {node.lineno}: Customer query missing type filter" - ) - self.generic_visit(node) - - def _is_customer_query(self, node): - return "Customer" in ast.unparse(node) - - def _has_type_filter(self, node): - source = ast.unparse(node) - return "Customer.type" in source - -def check_file(filepath): - with open(filepath) as f: - tree = ast.parse(f.read()) - checker = CustomerQueryChecker() - checker.visit(tree) - return checker.warnings - -if __name__ == "__main__": - warnings = [] - for filepath in sys.argv[1:]: - warnings.extend(check_file(filepath)) - - if warnings: - print("⚠️ Customer query warnings:") - for w in warnings: - print(f" {w}") - print("\nUse repository scopes instead: customer_repo.list_individuals()") - sys.exit(1) -``` -#### B.5: Billing Accuracy - Type Validation +With polymorphic models, mypy catch errors during dev time: -**Problem**: Runtime errors if wrong customer type is used. -**Solution**: Validation decorators and service-layer checks. - -**Implementation**: ```python -# server/polar/utils/validation.py -from functools import wraps - -def requires_business_customer(func): - """Decorator that validates customer is type='business'.""" - @wraps(func) - async def wrapper(self, customer: Customer, *args, **kwargs): - if customer.type != CustomerType.business: - raise ValueError( - f"Operation requires business customer, got {customer.type}" - ) - return await func(self, customer, *args, **kwargs) - return wrapper - -def requires_individual_customer(func): - """Decorator that validates customer is type='individual'.""" - @wraps(func) - async def wrapper(self, customer: Customer, *args, **kwargs): - if customer.type != CustomerType.individual: - raise ValueError( - f"Operation requires individual customer, got {customer.type}" - ) - return await func(self, customer, *args, **kwargs) - return wrapper -``` - -**Usage**: - -```python -# server/polar/benefit/service.py -class BenefitService: - @requires_individual_customer - async def grant_benefit( - self, - individual: Customer, - benefit: Benefit, - ) -> BenefitGrant: - pass +# This will pass mypy type checking +def add_team_member(business: BusinessCustomer, individual: IndividualCustomer): + business.members.append(Member( + business_customer_id=business.id, + individual_customer_id=individual.id, + )) + +# This will FAIL mypy type checking - IndividualCustomer doesn't have .members +def add_team_member_wrong(individual: IndividualCustomer, member: IndividualCustomer): + individual.members.append(Member(...)) # ❌ mypy error: "IndividualCustomer" has no attribute "members" ``` ### Appendix C: Why We Rejected BillingAccount @@ -562,8 +586,6 @@ Reality: **Decision**: We're choosing **architectural simplicity** (fewer entities, simpler model) over **query simplicity** (no filtering). The trade-off is conscious and acceptable given proper tooling and discipline. ---- - ## Open Questions 1. **WorkOS Integration**: Will this work for WorkOS? From 0c76a0f77d703ec88767d2fb8c0bb4572ac7684e Mon Sep 17 00:00:00 2001 From: Petru Rares Sincraian Date: Thu, 13 Nov 2025 14:17:06 +0100 Subject: [PATCH 10/15] feat: add examples of usage --- .../design-documents/business-entity.mdx | 351 +++++++++++++++++- 1 file changed, 333 insertions(+), 18 deletions(-) diff --git a/engineering/design-documents/business-entity.mdx b/engineering/design-documents/business-entity.mdx index c86a79d..85f1f5b 100644 --- a/engineering/design-documents/business-entity.mdx +++ b/engineering/design-documents/business-entity.mdx @@ -109,7 +109,57 @@ Member (business_customer_id, individual_customer_id, role) └─ Defines roles (admin, billing_manager, member) ``` -**See [Appendix A](#appendix-a-detailed-er-diagram-option-2) for detailed ER diagram and flows.** +#### Event Attribution: subscription_id vs billing_customer_id + +**Problem**: How do we attribute usage events to the correct payer in B2B scenarios? + +This problem is happening now in B2C where the same customer has multiple subscriptions with the same meter attached. + +**Two Approaches:** + +1. **Approach A: subscription_id** + ```python + class Event(RecordModel): + customer_id: UUID # Who performed the action (always IndividualCustomer) + subscription_id: UUID | None # Which subscription to bill. It's optional but we will try to infer it for most cases. When not possible, an error is raised. + ``` + - **Benefits**: + - Clearer semantic: "bill this subscription" + - Subscription already knows its customer (individual or business) + - Works for both metered billing and seat tracking + - Simpler for developers: "events are charged in the context of a subscription" + - **Trade-offs**: + - Merchant must track which subscription the user is acting under + +2. **Approach B: business_customer_id** + ```python + class Event(RecordModel): + customer_id: UUID # Who performed the action (IndividualCustomer) + business_customer_id: UUID | None # Which business to bill (NULL for B2C) + ``` + - **Benefits**: + - Matches `CustomerType.business` naming + - Clearer than generic "billing_customer_id" + - **Trade-offs**: + - Still requires resolving business→subscription + - Less clear semantic ("who" vs "what subscription") + +#### Dashboard Filtering Strategy + +**Problem**: Dashboard could show all customers including member-only individuals, creating confusion. + +**Requirements** (from merchant perspective): +1. Show all business customers +2. Show individual customers created via merchant API +3. Show individual customers who purchased something +4. Hide individual customers who ONLY exist as members (auto-created when added to business) +5. Members are viewable nested under their business customer + +**Solution: Use relationships to determine visibility** . Add another customer type that is "member", so we will have three types: business, individual, and member. Members are not viewable directly but are nested under their business customer. +If a individual customer is a member, they have the status of "individual" therefore are visible on the dashboard and are nested under their business customer. + +**See [Appendix A](#appendix-a-detailed-er-diagram-option-2) for detailed ER diagram.** +**See [Appendix D: Integration Examples](#appendix-d-integration-examples) for full integration flow and API examples.** ### Option 1: BillingAccount Entity ❌ REJECTED @@ -165,22 +215,29 @@ Given that each feature will take 2-4 days to implement. I think we can deliver ### Appendix A: Detailed ER Diagram (Option 2) + ```mermaid erDiagram Organization ||--o{ Customer : "has" Organization ||--o{ Product : "sells" - Customer ||--o{ Member : "business customers have" - Customer ||--o{ Member : "individuals belong to" Customer ||--o{ Subscription : "owns" + %% Polymorphic relationships - BusinessCustomer subtype + Customer ||--o{ Member : "business: has members" + + %% Polymorphic relationships - IndividualCustomer subtype + Customer ||--o{ Member : "individual: member of" + Customer ||--o{ CustomerSeat : "individual: claims seat" + Customer ||--o{ BenefitGrant : "individual: receives benefits" + Customer ||--o{ CustomerSession : "individual: authenticates" + + %% Common relationships (both subtypes) Customer ||--o{ Order : "owns" Customer ||--o{ PaymentMethod : "owns" - Customer ||--o{ Event : "generates or pays for" - Customer ||--o{ CustomerSeat : "can claim (individuals only)" - Customer ||--o{ BenefitGrant : "receives (individuals only)" - Customer ||--o{ CustomerSession : "can login (individuals only)" + Customer ||--o{ Event : "individual: performs action" Subscription ||--o{ CustomerSeat : "allocates" + Subscription ||--o{ Event : "bills usage" Subscription }o--|| Product : "is for" Subscription }o--o| PaymentMethod : "pays with" @@ -189,8 +246,8 @@ erDiagram Customer { uuid id PK uuid organization_id FK - string type "individual or business (NEW)" - string email "REQUIRED for individual, NULL for business" + string type "DISCRIMINATOR: individual | business" + string email "NULL for business, REQUIRED for individual" string name "Person name OR Business name" string external_id string stripe_customer_id @@ -199,8 +256,8 @@ erDiagram Member { uuid id PK - uuid business_customer_id FK "Customer where type=business" - uuid individual_customer_id FK "Customer where type=individual" + uuid business_customer_id FK "→ Customer (BusinessCustomer)" + uuid individual_customer_id FK "→ Customer (IndividualCustomer)" string role "admin, billing_manager, member" datetime invited_at datetime joined_at @@ -209,7 +266,7 @@ erDiagram Subscription { uuid id PK - uuid customer_id FK "Individual (B2C) OR Business (B2B)" + uuid customer_id FK "→ Customer (BusinessCustomer for B2B)" uuid payment_method_id FK int seats "NULL for non-seat, number for seat-based" string status @@ -218,30 +275,42 @@ erDiagram CustomerSeat { uuid id PK uuid subscription_id FK - uuid customer_id FK "ONLY type=individual receives benefits" + uuid customer_id FK "→ Customer (IndividualCustomer only)" string status } Event { uuid id PK - uuid customer_id FK "Who did it: Individual" - uuid billing_customer_id FK "Who pays: Individual (B2C) OR Business (B2B)" + uuid customer_id FK "→ Customer (IndividualCustomer who did it)" + uuid subscription_id FK "→ Subscription (which subscription to bill)" string name + jsonb properties } CustomerSession { uuid id PK - uuid customer_id FK "ONLY type=individual can login" + uuid customer_id FK "→ Customer (IndividualCustomer only)" string token } BenefitGrant { uuid id PK - uuid customer_id FK "ONLY type=individual receives benefits" + uuid customer_id FK "→ Customer (IndividualCustomer only)" uuid subscription_id FK } ``` +**Key Points:** +- **Single Table**: All customer data lives in the `customers` table +- **Polymorphic Discrimination**: The `type` column distinguishes between `individual`, `business`, and `members`. +- **Dashboard Filtering**: Uses type to determine visibility on the dashboard. +- **SQLAlchemy Subtypes**: + - `BusinessCustomer` (type='business') has `members` relationships + - `IndividualCustomer` (type='individual') has `benefit_grants`, `customer_sessions`, and can claim `customer_seats` +- **Event Attribution**: Still under discussion. +- **Type Safety**: `select(BusinessCustomer)` automatically filters `WHERE type = 'business'` +- **Smart Casting**: Queries return properly typed instances (e.g., `isinstance(customer, BusinessCustomer)` works) + ### Appendix B: Mitigation Strategies (Option 2) This appendix details solutions for Option 2's trade-offs. @@ -586,6 +655,252 @@ Reality: **Decision**: We're choosing **architectural simplicity** (fewer entities, simpler model) over **query simplicity** (no filtering). The trade-off is conscious and acceptable given proper tooling and discipline. +--- + +### Appendix D: Integration Examples + +This appendix shows complete integration flows from an external developer's perspective. + +#### D.1: Business Checkout Flow (Step-by-Step) + +**Scenario**: Acme Corp wants to purchase a team subscription for 10 seats. + +**Step 1: Merchant creates business customer and individual customer** + +First, create the individual customer: + +```python +res = polar.customers.create(request={ + "external_id": "cust_alice", + "email": "alice@acme.com", + "name": "Alice", + "organization_id": "1dbfc517-0bbf-4301-9ba8-555ca42b9737", + +}) +``` + +Then create the customer: +```python +res = polar.customers.create(request={ + "external_id": "cust_acme", + "name": "ACME_INC", + "type": "business", + "external_owner_id": "cust_alice", # This should create the relationship automatically + "billing_address": { + "country": polar_sdk.CountryAlpha2Input.US, + }, + "tax_id": [ + "911144442", + "us_ein", + ], + "organization_id": "1dbfc517-0bbf-4301-9ba8-555ca42b9737", + +}) +``` + +**Step 2: Merchant creates checkout session** + +```python + res = polar.checkouts.create(request={ + "external_business_customer_id": "cust_acme", # new attirbute to attach payment method and subscription to the business customer. + "external_customer_id": "cust_alice", + "products": ["product_1"], + }) +``` + +If the business_customer_id is not provided, and the product is a seat based product. Polar will automatically: + +1. First create a business customer +2. Create a membership relationship between the customer_id and the business created. + + +**Step 2: Customer completes checkout** + +Customer fills out Polar checkout form... + +**Step 3: Webhook fired to merchant** + +```json +{ + "type": "order.paid", + "data": { + "id": "ord_abc123", + "customer": { + "id": "cust_acme", // ← Business customer ID + "type": "business", + "name": "Acme Corp", + "email": null, // Business has no email + }, + "subscription": { + "id": "sub_456", + "customer_id": "cust_acme", // ← Belongs to business + "product_id": "prod_theme_pro", + "status": "active", + "seats": 10 + } + } +} +``` + +--- + +#### D.2: Querying Business Customer Data + +**Get business customer with members**: + +```http +GET /v1/customers/cust_acme?include=members +``` + +```json +{ + "id": "cus_business_xyz789", + "type": "business", // new attribute + "name": "Acme Corp", + "active_subscriptions": [ + { + "id": "sub_456", + "product_id": "prod_theme_pro", + "status": "active", + "seats": 10 + } + ], + "members": [ + { + "id": "member_1", + "customer": { + "id": "cust_alice", + "email": "alice@acme.com", + "name": "Alice" + }, + "role": "admin", + "is_active": true + }, + { + "id": "member_2", + "customer": { + "id": "cus_individual_ghi789", + "email": "dev@acme.com", + "name": "Bob Smith" + }, + "role": "member", + "is_active": true + } + ] +} +``` + +**List all orders for business**: + +```http +GET /v1/orders?customer_id=cust_acme +``` + +```json +{ + "data": [ + { + "id": "order_789", + "customer_id": "cust_acme", + "amount": 49900, + "created_at": "2025-01-15T10:00:00Z" + } + ] +} +``` + +--- + +#### D.3: Event Ingestion for Metered Billing + +**Scenario**: Bob (member of Acme) generates an API call that should bill Acme's subscription. + +**Approach A: Using subscription_id (RECOMMENDED)** + +```http +POST /v1/events +{ + "name": "api.request", + "customer_id": "cust_bob", + // nothing else is needed as bob is only a member of acme + "properties": { + "endpoint": "/v1/themes", + "method": "POST" + } +} +``` + +If bob is a member of two businesses, then an error will be thrown and XXX needs to be specified. + +--- + +#### D.4: Member Management + +**Add a new member to Acme Corp**: + +```http +POST /v1/customers/cust_acme/members +{ + "email": "petru@acme.com", + "role": "member" +} +``` + +**Response**: +```json +{ + "id": "member_2", + "business_customer_id": "cust_acme", + "individual_customer": { + "id": "cus_individual_ghi789", // Auto-created + "email": "dev@acme.com", + "type": "individual", + }, + "role": "member", + "is_active": true, + "invited_at": "2025-01-20T10:00:00Z" +} +``` + +--- + +#### D.5: Individual Who Is Both Member AND Customer + +**Scenario**: Jane (jane@acme.com) is: +1. Billing manager for Acme Corp (member) +2. Individual customer with her own personal subscription + +**Her customer profile**: +```json +{ + "id": "cust_jane", + "type": "individual", + "email": "jane@acme.com", + "name": "Jane Doe", + "active_subscriptions": [ + { + "id": "sub_personal_123", // Her personal subscription + "product_id": "prod_theme_basic" + } + ], + "memberships": [ + { + "business_customer_id": "cust_acme", + "role": "admin" + } + ] +} +``` + +**Dashboard behavior**: +- Jane appears in **main customer list** +- Jane appears in **Acme Corp's member list** (because she's a member) +- Two distinct contexts: individual purchaser AND business member + +--- + + ## Open Questions -1. **WorkOS Integration**: Will this work for WorkOS? +1. **WorkOS Integration**: Will this work for WorkOS authentication and directory sync? +2. **Event Attribution Edge Cases**: How do we handle events when a user is a member of multiple businesses with different subscriptions? From 64c36b78eeeb63dbd0fb177782242381e1db95a5 Mon Sep 17 00:00:00 2001 From: Petru Rares Sincraian Date: Fri, 14 Nov 2025 22:05:20 +0100 Subject: [PATCH 11/15] feat: add flow summary --- .../design-documents/business-entity.mdx | 1816 +++++++++++------ 1 file changed, 1188 insertions(+), 628 deletions(-) diff --git a/engineering/design-documents/business-entity.mdx b/engineering/design-documents/business-entity.mdx index 85f1f5b..2ebd645 100644 --- a/engineering/design-documents/business-entity.mdx +++ b/engineering/design-documents/business-entity.mdx @@ -1,7 +1,7 @@ **Status**: Draft **Created**: November 5, 2025 -**Last Updated**: November 5, 2025 +**Last Updated**: November 14, 2025 ## Problem Statement @@ -10,210 +10,205 @@ 1. **Ambiguous Event Attribution**: When a customer belongs to multiple seat-based subscriptions from different organizations (e.g., Alice works for both Acme and Slack), events lack business context to determine which customer should be billed. 2. **Inflexible Billing Management**: Changing the billing manager becomes tricky because billing information (payment methods, orders) is tied to a single Customer entity. 3. **No Business-Level Aggregation**: When a business has multiple subscriptions from the same merchant, it's difficult to aggregate usage or provide consolidated billing. + ### Requirements -1. **Subscription Visibility:** Know what subscriptions a business customer has. +1. **Subscription Visibility**: Know what subscriptions a business customer has 2. **Billing Transfer**: Allow transfer of subscriptions to another billing manager 3. **Clear Attribution**: Easily know who is the billing customer of an event +4. **Backward Compatibility**: Existing B2C integrations must continue working without changes + +### Current Merchant Workflows (Must Preserve) -### Ideal Workflows +#### Workflow A: Public Checkout (B2C - Current) +1. Customer visits merchant site, clicks checkout link +2. Polar Checkout: Buyer enters email + card +3. Polar auto-creates individual customer +4. Post-purchase: Customer manages subscription via Customer Portal -#### Workflow A: Public Checkout (Selling themes) +#### Workflow B: Public Checkout (B2B - Current seats flow) 1. Customer visits merchant site, clicks checkout link -2. Polar Checkout: Buyer enters email + card + team checkbox -3. Polar auto-creates business or individual billing accordingly +2. Polar Checkout: Buyer enters email + card +3. Polar auto-creates billing manager 4. Post-purchase: Buyer invites team members, manages via Customer Portal -#### Workflow B: Checkout Sessions (Startup case) -1. Customer creates account in merchant app, creates business team -2. Merchant app creates Polar customer + business via API -3. Billing manager from team clicks checkout in merchant app -4. Polar Checkout: Pre-filled business name + manager email, enters card +#### Workflow C: Checkout Sessions (API-driven) +1. Merchant app creates customer and business in their system +2. Merchant creates Polar customer + business inside Polar via API +3. Billing manager clicks checkout in merchant app +4. Polar Checkout: Pre-filled information, enters card 5. Returns to merchant app (optional: Customer Portal access) +--- + ## Solution -### Tenets +We evaluated two architectural approaches. Both solve the core problem but differ in complexity, migration risk, and developer experience. -1. **Billing Accuracy** - Charge the right customer, events hit correct paying customer - 1. We are charing the right amount for customers that are in 2 business subscriptions. - 2. Events always hit the correct entities for both customers. -2. **Backward Compatibility** - Existing B2C customers continue working unchanged -3. **Simplicity** - Seamless checkout, straightforward queries, simple event ingestion - 1. Seamless checkout one that makes sense and creates a single customer with everything tied up correctly (subscription and payment methods) and there is only 1 entity created during the checkout. - 2. Easy query the data that we want for customers and business customers. - 3. Keep event ingestion API straightforward. The main id that we pass in the event will be the user who triggered the action. -4. **Minimal Changes for B2B** - Merchants make minimal changes when adding B2B -5. **Polar Developer Experience** - Avoid excessive filtering or complex queries -6. **WorkOS/Auth0 Integration** - Support enterprise identity providers +### Tenets +These tenets guide our decision-making, weighted by importance: -### Scoring Matrix +1. **Billing Accuracy** (weight: 7) - Charge the right customer, events hit correct paying customer +2. **Backward Compatibility** (weight: 6) - Existing B2C and B2B customers continue working unchanged. +3. **Simplicity** (weight: 5) - Seamless checkout, straightforward queries, simple event ingestion +4. **Minimal Changes for B2B** (weight: 4) - Merchants make minimal changes when adding B2B +5. **Polar Developer Experience** (weight: 3) - Avoid excessive filtering or complex queries +6. **WorkOS/Auth0 Integration** (weight: 2) - Support enterprise identity providers Scale: 🔴 1 (poor) | 🟡 2 (acceptable) | 🟢 3 (excellent) -| Tenet | Weight | Option 2: Member Layer (✅ CHOSEN) | Option 1: BillingAccount | -| ----------------------- | ------ | --------------------------------- | ------------------------ | -| Billing Accuracy | 7 | 🟡 2 (14) | 🟢 3 (21) | -| Backward compatibility | 6 | 🟢 3 (18) | 🟡 2 (12) | -| Simplicity | 5 | 🟡 2 (10) | 🟡 2 (10) | -| Minimal changes for B2B | 4 | 🟡 2 (8) | 🟡 2 (8) | -| Polar dev experience | 3 | 🟡 2 (6) | 🟢 3 (9) | -| WorkOS | 2 | 🟢 3 (6) | 🟢 3 (6) | -| **Total Score** | - | **62** | **66** | +--- + +### Option 1: Beneficiary Model + +**Concept**: Introduce a `Beneficiary` entity that represents "who uses the product". `Customer` becomes purely a billing entity. Every Customer has one or more Beneficiaries - even individual customers have a single beneficiary (themselves). This creates uniform architecture with no special cases. + +**High-Level Architecture**: +``` +Customer (id, name, stripe_customer_id) # Pure billing entity + ├─ Subscriptions + ├─ Orders + ├─ PaymentMethods + └─ Beneficiaries → Who uses/accesses the product + +Beneficiary (id, customer_id, email, name, role) # Pure usage entity + ├─ BenefitGrants (what they can access) + ├─ Events (what they did) + ├─ CustomerSeats (seats they claimed) + └─ CustomerSessions (how they authenticate) +``` + +**Key Insight**: Uniform 1:N relationship everywhere +``` +Customer (Alice Personal) + └── Beneficiary (Alice) [1:1 for individuals] +Customer (ACME Corp) + ├── Beneficiary (Alice at ACME - it's different beneficiary ID than other Alice) + ├── Beneficiary (Bob at ACME) + └── Beneficiary (Carol at ACME) -We will extend the existing `Customer` entity with a `type` field (individual or business) and introduce a `Member` junction table for team relationships. This provides: -- **Minimal schema changes**: Reuse existing Customer entity for both individuals and businesses -- **Simpler mental model**: Everything is a customer - individuals and businesses -- **Easier migration**: Just add `type` field and Member table, no entity splitting required +Customer (Lolo Inc) + ├── Beneficiary (Alice at Lolo - it's different beneficiary ID than other Alice) + └── Beneficiary (Dan at Lolo) +``` -**Key Decision Rationale**: Option 2 scores lower (62/81 vs 66/81), we're prioritizing architectural simplicity over developer experience. Having a single Customer entity is conceptually simpler and avoids the complexity of splitting billing from usage across two entities. The developer experience trade-offs (type filtering) are manageable with proper tooling. We can migrate to BillingAccount if this became a need. -### Option 2: Member Layer ✅ RECOMMENDED -**Summary**: Add `type` field to Customer entity: `individual` or `business`. Business customers have Members linking to individual customers. Use Customer for both billing and usage. +**Scoring**: +| Tenet | Score | Rationale | +| ----------------------- | ----- | --------- | +| Billing Accuracy | 🟢 3 | Perfect separation - Customer = billing, Beneficiary = usage | +| Backward Compatibility | 🔴 1 | Current integration with B2B requires migration | +| Simplicity | 🟢 3 | Uniform 1:N model, no type discrimination, no special cases | +| Minimal B2B Changes | 🟢 3 | Just add beneficiaries, existing customer_id queries work | +| Polar Dev Experience | 🟢 3 | Clean queries, no type filtering, clear separation | +| WorkOS Integration | 🟢 3 | Beneficiaries map directly to WorkOS users/members | +| **Weighted Total** | **69** | | -**Why We Choose This**: -- **Simplicity (🟡 2)**: Single Customer entity for both individuals and businesses - simpler data model, easier to reason about -- **Backward Compatibility (🟢 3)**: Minimal migration - just add `type` field to existing customers, no entity splitting -- **Lower Risk**: Smaller change reduces risk -- **Conceptual Clarity**: "A customer is a customer" - whether individual or business, they're all customers +**Pros**: +- **No polymorphism**: Customer is always billing entity, no `type` field needed +- **Uniform API**: All authentication goes through Beneficiary, single code path +- **Easy multi-membership**: Alice has multiple beneficiary records (personal, ACME, Lolo). Each one has a different ID. +- **Clean separation**: Customer = billing, Beneficiary = usage/access +- **Perfect WorkOS fit**: Beneficiaries map 1:1 to WorkOS users/members -**Trade-offs & Mitigations**: +**Cons**: +- Extra entity for every user. +- More joins for some queries (beneficiary -> customer) +- Conceptual shift: "authenticate as beneficiary, not customer" -1. **Polar Developer Experience (🟡 2)**: Type discrimination handled by SQLAlchemy polymorphism - - **Problem**: Polymorphism can lead to complex queries and type checking - - **Mitigations**: Use SQLAlchemy's [single-table inheritance with polymorphic models](#b1-sqlalchemy-polymorphic-models-implementation) - - **Our Take**: Polymorphism eliminates most manual filtering concerns. +--- -2. **Billing Accuracy (🟡 2)**: Customer entity does double duty - - **Problem**: Customer means both "user" and "business" (i know that upwards I said the opposite XD) - - **Mitigations**: [Explicit naming conventions + Type validation + Clear docs](#appendix-b-mitigation-strategies-option-2) - - **Our Take**: Clear naming (individual_customer_id vs business_customer_id) and good documentation mitigate confusion. +### Option 2: Customer Type + Member +**Concept**: Extend existing `Customer` entity with a `type` discriminator field (`individual` | `business`). Business customers have Members (a separate junction table) linking to individual customers. Single Customer entity for both billing and usage. -**Key Architecture**: +**High-Level Architecture**: ``` Customer (id, email, name, type: individual|business) - ├─ Subscriptions (for business customers) - ├─ Orders (for any customer) - ├─ PaymentMethods (for any customer) - ├─ Events (generates if individual, pays if business) - ├─ CustomerSeats (can claim if individual) - ├─ BenefitGrants (receives if individual) - └─ CustomerSessions (authenticates if individual) + ├─ Subscriptions (any customer can have subscriptions) + ├─ Orders (any customer can have orders) + ├─ PaymentMethods (any customer can have payment methods) + ├─ Events (individuals generate, businesses pay) + ├─ CustomerSeats (individuals claim seats) + ├─ BenefitGrants (individuals receive benefits) + └─ CustomerSessions (individuals authenticate) Member (business_customer_id, individual_customer_id, role) - ├─ Links business customers to individual members - └─ Defines roles (admin, billing_manager, member) -``` - -#### Event Attribution: subscription_id vs billing_customer_id - -**Problem**: How do we attribute usage events to the correct payer in B2B scenarios? - -This problem is happening now in B2C where the same customer has multiple subscriptions with the same meter attached. - -**Two Approaches:** - -1. **Approach A: subscription_id** - ```python - class Event(RecordModel): - customer_id: UUID # Who performed the action (always IndividualCustomer) - subscription_id: UUID | None # Which subscription to bill. It's optional but we will try to infer it for most cases. When not possible, an error is raised. - ``` - - **Benefits**: - - Clearer semantic: "bill this subscription" - - Subscription already knows its customer (individual or business) - - Works for both metered billing and seat tracking - - Simpler for developers: "events are charged in the context of a subscription" - - **Trade-offs**: - - Merchant must track which subscription the user is acting under - -2. **Approach B: business_customer_id** - ```python - class Event(RecordModel): - customer_id: UUID # Who performed the action (IndividualCustomer) - business_customer_id: UUID | None # Which business to bill (NULL for B2C) - ``` - - **Benefits**: - - Matches `CustomerType.business` naming - - Clearer than generic "billing_customer_id" - - **Trade-offs**: - - Still requires resolving business→subscription - - Less clear semantic ("who" vs "what subscription") - -#### Dashboard Filtering Strategy - -**Problem**: Dashboard could show all customers including member-only individuals, creating confusion. - -**Requirements** (from merchant perspective): -1. Show all business customers -2. Show individual customers created via merchant API -3. Show individual customers who purchased something -4. Hide individual customers who ONLY exist as members (auto-created when added to business) -5. Members are viewable nested under their business customer - -**Solution: Use relationships to determine visibility** . Add another customer type that is "member", so we will have three types: business, individual, and member. Members are not viewable directly but are nested under their business customer. -If a individual customer is a member, they have the status of "individual" therefore are visible on the dashboard and are nested under their business customer. - -**See [Appendix A](#appendix-a-detailed-er-diagram-option-2) for detailed ER diagram.** -**See [Appendix D: Integration Examples](#appendix-d-integration-examples) for full integration flow and API examples.** - -### Option 1: BillingAccount Entity ❌ REJECTED - -**Summary**: Introduce dedicated `BillingAccount` entity that owns all billing-related entities. Customers remain for usage tracking. Clean separation of concerns. - -**Why We Rejected This**: -- **Too Many Entities**: Creates extra BillingAccount entity for every customer (even B2C) -- **Migration Complexity**: Requires splitting data between Customer and BillingAccount -- **Conceptual Overhead**: Developers must understand "Customer" (usage) vs "BillingAccount" (billing) distinction -- **Over-Engineering**: The separation of concerns is theoretically cleaner but adds complexity. - -**Why It Scores Well**: -- **Billing Accuracy (🟢 3)**: Crystal clear separation - no ambiguity about who pays vs who uses -- **Developer Experience (🟢 3)**: Clean queries without type filtering -- **Long-term Scalability**: Easier to extend with business-level features - -**Rejection Rationale**: Option 1 scores 7 points higher (66 vs 59) due to superior developer experience (🟢 3 vs 🔴 1) and separation of concerns. But, in our assessment: -- Having ONE entity (Customer) is conceptually simpler than TWO entities (Customer + BillingAccount) -- Migration risk is lower (adding a field vs splitting entities) -- The filtering overhead is acceptable with proper repository patterns - -Option 1's clean separation it's appealing, but we believe the simpler data model outweighs the query complexity trade-offs. See [Appendix C](#appendix-c-why-we-rejected-billingaccount) for detailed analysis. - ---- - -## Implementation Plan: Option 2 (Member Layer with Polymorphic Models) - -Given that each feature will take 2-4 days to implement. I think we can deliver within 14 business days (optimistic) and 28 business days for a single dev. - -**Feature 1**: Extend Customer entity with type discrimination. Add Member table. -1. **Schema Changes** (add type to Customer entity) -**Feature 2**: Update logic to handle both individual and business customers. -2. **Update Event Model**. We should validate that the event model has a business_customer_id if the customer is on multiple businesses. -3. **Update billing entities logic**: we should make sure that the person who is updating the subscription/order/etc. has permissions to do it. -4. **Checkout Flow Updates**: if we are purchasing for a business, we should create 2 customers, one for the business, and another one for the individual customer -**Feature 3:** Member Management -5. **MemberEndpoints** implement the needed endpoints and frontend to manage the Members inside a business. -**Feature 4**: Metered Pricing & Events -6. **Update event ingestion**: we should make sure that we are charging a billable customer on B2B scenarios. -7. Update metering service: we should provide a global usage of meters per subscription -**Feature 5**: Customer Portal & API -8. Expose customer-portal to list members, add members, remove members, edit member permissions -9. Show customer type clearly in API and expose members for business customers. -**Feature 6**: Display Business Customers in Dashboard -10. Display the business customers with their members in the dashboard -11. Update the dashboard to allow to view all subscriptions of business customers and global metrics -**Feature 7**: Rollout & Monitoring -12. Create alerts & monitors -13. Beta test with new customers -14. Rollout to new customers + ├─ Links business to individual members + └─ Roles: admin, billing_manager, member +``` + + +*Backward compatibility:* +- **ZERO BREAKING CHANGES**: All existing API calls work identically +- `type` defaults to `"individual"` if not specified +- Existing customers auto-migrated to `type="individual"` +- B2B features are opt-in via `type="business"` parameter + +**Scoring**: +| Tenet | Score | Rationale | +| ----------------------- | ----- | --------- | +| Billing Accuracy | 🟡 2 | Customer serves dual purpose, but naming conventions mitigate | +| Backward Compatibility | 🟢 3 | Zero breaking changes, additive-only schema | +| Simplicity | 🟡 2 | Single entity concept, but polymorphism adds query complexity | +| Minimal B2B Changes | 🟡 2 | Just add `type` parameter, similar code patterns | +| Polar Dev Experience | 🟡 2 | Requires type filtering, mitigated by SQLAlchemy polymorphism | +| WorkOS Integration | 🟢 3 | Individual customers map cleanly to WorkOS users | +| **Weighted Total** | **62** | | + +**Pros**: +- Minimal schema changes (add one field, one table) +- Zero breaking changes for existing merchants +- Lower migration risk (additive-only) +- "A customer is a customer" - simpler mental model + +**Cons**: +- Type discrimination in queries (mitigated by SQLAlchemy polymorphism) +- Customer entity serves dual purpose (usage + billing) +- Need clear naming conventions to avoid confusion + +--- + +### Decision Matrix + +Scale: 🔴 1 (poor) | 🟡 2 (acceptable) | 🟢 3 (excellent) + +| Tenet | Weight | Option 1: Beneficiary Model | Option 2: Customer Type + Member | +| ----------------------- | ------ | --------------------------- | -------------------------------- | +| Billing Accuracy | 7 | 🟢 3 (21) | 🟡 2 (14) | +| Backward compatibility | 6 | 🔴 3 (6) | 🟢 3 (18) | +| Simplicity | 5 | 🟢 3 (15) | 🟡 2 (10) | +| Minimal changes for B2B | 4 | 🟢 3 (12) | 🟡 2 (8) | +| Polar dev experience | 3 | 🟢 3 (9) | 🟡 2 (6) | +| WorkOS Integration | 2 | 🟢 3 (6) | 🟢 3 (6) | +| **Total Score** | - | **69** | **62** | + + +--- + + +## Migration Plan + +TODO + +### Rollback Plan + +TODO + +--- + +## Implementation Plan + +TODO + +--- ## Appendices -### Appendix A: Detailed ER Diagram (Option 2) +### Appendix E: ER Diagram (Option 1: Beneficiary Model) ```mermaid @@ -221,22 +216,18 @@ erDiagram Organization ||--o{ Customer : "has" Organization ||--o{ Product : "sells" - Customer ||--o{ Subscription : "owns" - %% Polymorphic relationships - BusinessCustomer subtype - Customer ||--o{ Member : "business: has members" + Customer ||--o{ Subscription : "owns (billing)" + Customer ||--o{ Beneficiary : "has (usage/access)" + Customer ||--o{ Order : "owns (billing)" + Customer ||--o{ PaymentMethod : "owns (billing)" - %% Polymorphic relationships - IndividualCustomer subtype - Customer ||--o{ Member : "individual: member of" - Customer ||--o{ CustomerSeat : "individual: claims seat" - Customer ||--o{ BenefitGrant : "individual: receives benefits" - Customer ||--o{ CustomerSession : "individual: authenticates" - - %% Common relationships (both subtypes) - Customer ||--o{ Order : "owns" - Customer ||--o{ PaymentMethod : "owns" - Customer ||--o{ Event : "individual: performs action" + Beneficiary ||--o{ BenefitGrant : "receives benefits" + Beneficiary ||--o{ CustomerSeat : "claims seats" + Beneficiary ||--o{ CustomerSession : "authenticates" + Beneficiary ||--o{ Event : "performs actions" Subscription ||--o{ CustomerSeat : "allocates" + Subscription ||--o{ BenefitGrant : "provides" Subscription ||--o{ Event : "bills usage" Subscription }o--|| Product : "is for" Subscription }o--o| PaymentMethod : "pays with" @@ -246,27 +237,25 @@ erDiagram Customer { uuid id PK uuid organization_id FK - string type "DISCRIMINATOR: individual | business" - string email "NULL for business, REQUIRED for individual" - string name "Person name OR Business name" + string email "Email for B2C, name for B2B" + string name "Customer name or business name" string external_id - string stripe_customer_id + string stripe_customer_id "Stripe billing ID" jsonb user_metadata } - Member { + Beneficiary { uuid id PK - uuid business_customer_id FK "→ Customer (BusinessCustomer)" - uuid individual_customer_id FK "→ Customer (IndividualCustomer)" - string role "admin, billing_manager, member" - datetime invited_at - datetime joined_at - bool is_active + uuid customer_id FK "→ Customer (who pays)" + string email "Beneficiary email (can repeat across customers)" + string name "Beneficiary name" + string role "owner | admin | billing_manager | member" + UNIQUE customer_id_email "Same email allowed across customers" } Subscription { uuid id PK - uuid customer_id FK "→ Customer (BusinessCustomer for B2B)" + uuid customer_id FK "→ Customer (who pays)" uuid payment_method_id FK int seats "NULL for non-seat, number for seat-based" string status @@ -275,13 +264,15 @@ erDiagram CustomerSeat { uuid id PK uuid subscription_id FK - uuid customer_id FK "→ Customer (IndividualCustomer only)" + uuid beneficiary_id FK "→ Beneficiary (who uses the seat)" + uuid customer_id FK "Denormalized for performance" string status } Event { uuid id PK - uuid customer_id FK "→ Customer (IndividualCustomer who did it)" + uuid beneficiary_id FK "→ Beneficiary (who did the action)" + uuid customer_id FK "Denormalized for billing queries" uuid subscription_id FK "→ Subscription (which subscription to bill)" string name jsonb properties @@ -289,540 +280,1030 @@ erDiagram CustomerSession { uuid id PK - uuid customer_id FK "→ Customer (IndividualCustomer only)" + uuid beneficiary_id FK "→ Beneficiary (who authenticates)" string token + datetime expires_at } BenefitGrant { uuid id PK - uuid customer_id FK "→ Customer (IndividualCustomer only)" + uuid beneficiary_id FK "→ Beneficiary (who receives benefit)" + uuid customer_id FK "Denormalized for performance" uuid subscription_id FK + uuid benefit_id FK } ``` **Key Points:** -- **Single Table**: All customer data lives in the `customers` table -- **Polymorphic Discrimination**: The `type` column distinguishes between `individual`, `business`, and `members`. -- **Dashboard Filtering**: Uses type to determine visibility on the dashboard. -- **SQLAlchemy Subtypes**: - - `BusinessCustomer` (type='business') has `members` relationships - - `IndividualCustomer` (type='individual') has `benefit_grants`, `customer_sessions`, and can claim `customer_seats` -- **Event Attribution**: Still under discussion. -- **Type Safety**: `select(BusinessCustomer)` automatically filters `WHERE type = 'business'` -- **Smart Casting**: Queries return properly typed instances (e.g., `isinstance(customer, BusinessCustomer)` works) +- **Separation of Concerns**: Customer = billing entity, Beneficiary = usage/access entity +- **No Polymorphism**: Customer table has no `type` discriminator, always represents billing +- **1:1 for B2C**: Individual customers have 1 default beneficiary +- **1:N for B2B**: Business customers have N beneficiaries (team members) +- **Unique Constraint**: `(customer_id, email)` on Beneficiary allows same email across different beneficiares. +- **Event Attribution**: Events always reference `beneficiary_id` (never ambiguous across business. It can be ambiguous across the same business with multiple subscriptions) +- **Denormalization**: `customer_id` kept in usage tables for query performance (optional) +- **Authentication**: Customer portal sessions authenticate as Beneficiary, not Customer +- **Backward Compatibility**: Service layer accepts both `customer_id` (resolves to default beneficiary) and `beneficiary_id` -### Appendix B: Mitigation Strategies (Option 2) +--- -This appendix details solutions for Option 2's trade-offs. +### Appendix F: Event Attribution Strategy -#### B.1: SQLAlchemy Polymorphic Models Implementation +This appendix details how we handle event attribution in B2B scenarios. -**Problem**: Manual type filtering in every query causes boilerplate and reduces developer experience. -**Solution**: Use SQLAlchemy's [single-table inheritance](https://docs.sqlalchemy.org/en/20/orm/inheritance.html#single-table-inheritance) to eliminate manual type filtering and improve developer experience. +**Problem**: When an individual customer is a member of multiple businesses, which subscription should be billed for their usage events? -**Implementation**: +**Solution**: Use `subscription_id` (optional) with automatic inference. -We'll leverage SQLAlchemy's polymorphic models to automatically handle type discrimination: +#### Inference Logic ```python -class Customer(RecordModel): - """Base customer model with polymorphic discrimination.""" - __tablename__ = "customers" - - id: Mapped[UUID] = mapped_column(primary_key=True) - organization_id: Mapped[UUID] = mapped_column(ForeignKey("organizations.id")) - type: Mapped[CustomerType] = mapped_column() # Discriminator column - email: Mapped[str | None] = mapped_column() # NULL for business customers - name: Mapped[str] = mapped_column() - stripe_customer_id: Mapped[str | None] = mapped_column() - # ... other common fields - - __mapper_args__ = { - "polymorphic_on": "type", - "polymorphic_identity": None, # Base class is abstract - } +async def infer_subscription(customer: IndividualCustomer) -> UUID: + """Infer which subscription to bill for this customer's event.""" + + # Get all business memberships + memberships = await get_memberships_with_subscriptions(customer) + + if len(memberships) == 0: + # Individual B2C customer - use their own subscription + if customer.subscriptions: + return customer.subscriptions[0].id + else: + raise BadRequest("Customer has no active subscriptions") + + elif len(memberships) == 1: + # Member of exactly ONE business - unambiguous + _, subscription = memberships[0] + return subscription.id + + else: + # Member of MULTIPLE businesses - ambiguous, require explicit subscription_id + raise BadRequest( + f"Customer is member of {len(memberships)} businesses. " + f"Specify 'subscription_id' parameter." + ) +``` -class BusinessCustomer(Customer): - """Business customer with team members and subscriptions.""" +#### Edge Cases - # Type hint for discriminator ensures proper typing - type: Mapped[Literal[CustomerType.business]] = mapped_column() +**Case 1**: Alice has her own B2C subscription AND is a member of Acme Corp +- **Inference**: Fails (2 possible subscriptions) +- **Merchant action**: Must specify `subscription_id` - # Business-specific relationships - members: Mapped[list["Member"]] = relationship( - "Member", - foreign_keys="Member.business_customer_id", - back_populates="business", - ) +**Case 2**: Bob is a member of Acme AND Slack +- **Inference**: Fails (2 business subscriptions) +- **Merchant action**: Must specify `subscription_id` - __mapper_args__ = { - "polymorphic_identity": CustomerType.business, - } +**Case 3**: Charlie is only a member of Acme +- **Inference**: Success (only 1 subscription) +- **Merchant action**: No action needed, auto-inferred + +#### API Error Response -class IndividualCustomer(Customer): - """Individual customer who can receive benefits and authenticate.""" - - type: Mapped[Literal[CustomerType.individual]] = mapped_column() - - # Individual-specific relationships - benefit_grants: Mapped[list["BenefitGrant"]] = relationship( - "BenefitGrant", - back_populates="customer", - ) - customer_sessions: Mapped[list["CustomerSession"]] = relationship( - "CustomerSession", - back_populates="customer", - ) - memberships: Mapped[list["Member"]] = relationship( - "Member", - foreign_keys="Member.individual_customer_id", - back_populates="individual", - ) - - __mapper_args__ = { - "polymorphic_identity": CustomerType.individual, +```json +{ + "error": { + "type": "ambiguous_subscription", + "message": "Customer cust_alice_123 belongs to 2 subscriptions. Specify 'subscription_id'.", + "details": { + "customer_id": "cust_alice_123", + "available_subscriptions": [ + { + "subscription_id": "sub_personal_456", + "type": "individual", + "product": "prod_theme_basic" + }, + { + "subscription_id": "sub_acme_789", + "type": "business", + "business_customer_id": "cust_acme_123", + "product": "prod_theme_pro" + } + ] } + } +} ``` -**Key Benefits:** +Merchants can then prompt the user or maintain context in their app to track which subscription is active. -1. **Automatic Type Filtering**: SQLAlchemy automatically adds WHERE clauses - ```python - # Automatically adds: WHERE customers.type = 'business' - businesses = await session.execute(select(BusinessCustomer)) - ``` +--- -2. **Smart Type Casting**: Queries return properly typed instances - ```python - # Returns BusinessCustomer or IndividualCustomer instances (not base Customer) - customers = await session.execute(select(Customer)) - for customer in customers.scalars(): - if isinstance(customer, BusinessCustomer): - # IDE knows customer.members exists - print(f"Business with {len(customer.members)} members") - elif isinstance(customer, IndividualCustomer): - # IDE knows customer.benefit_grants exists - print(f"Individual with {len(customer.benefit_grants)} benefits") - ``` +### Appendix H: Complete Integration Flows (Side-by-Side Comparison) -3. **Type Safety**: Python's type system works naturally - ```python - def grant_benefit(customer: IndividualCustomer, benefit: Benefit): - customer.benefit_grants.append(BenefitGrant(...)) +This appendix shows how merchants integrate with Polar for common workflows, comparing **Option 1 (Beneficiary Model)** vs **Option 2 (Customer Type + Member)** side-by-side. - def add_member(business: BusinessCustomer, individual: IndividualCustomer): - business.members.append(Member(...)) - ``` +--- -This approach significantly improves developer experience (🔴 1 → 🟡 2) while maintaining Option 2's architectural simplicity. +#### Flow 1: Create B2C Customer -#### B.2: Developer Experience - Repository with Polymorphic Models +##### Option 1: Beneficiary Model -**Problem**: Query boilerplate and ensuring type safety. -**Solution**: Use SQLAlchemy polymorphic models with simple repository methods. -**Implementation**: +```http +POST https://api.polar.sh/v1/customers +Authorization: Bearer polar_secret_... +Content-Type: application/json -```python -# server/polar/customer/repository.py -class CustomerRepository: - """Centralizes Customer queries using polymorphic models.""" - - async def list_individuals( - self, - session: AsyncSession, - organization_id: UUID, - **filters, - ) -> list[IndividualCustomer]: - """Get all individual customers. SQLAlchemy handles type filtering.""" - stmt = select(IndividualCustomer).where( - IndividualCustomer.organization_id == organization_id - ) - # Apply additional filters - for key, value in filters.items(): - stmt = stmt.where(getattr(IndividualCustomer, key) == value) - result = await session.execute(stmt) - return result.scalars().all() - - async def list_businesses( - self, - session: AsyncSession, - organization_id: UUID, - **filters, - ) -> list[BusinessCustomer]: - """Get all business customers. SQLAlchemy handles type filtering.""" - stmt = select(BusinessCustomer).where( - BusinessCustomer.organization_id == organization_id - ) - for key, value in filters.items(): - stmt = stmt.where(getattr(BusinessCustomer, key) == value) - result = await session.execute(stmt) - return result.scalars().all() - - async def get_individual( - self, session: AsyncSession, id: UUID - ) -> IndividualCustomer: - """Get individual customer. Type-safe by design.""" - result = await session.execute( - select(IndividualCustomer).where(IndividualCustomer.id == id) - ) - customer = result.scalar_one_or_none() - if not customer: - raise NotFound(f"Individual customer {id} not found") - return customer - - async def get_business( - self, session: AsyncSession, id: UUID - ) -> BusinessCustomer: - """Get business customer. Type-safe by design.""" - result = await session.execute( - select(BusinessCustomer).where(BusinessCustomer.id == id) - ) - business = result.scalar_one_or_none() - if not business: - raise NotFound(f"Business customer {id} not found") - return business +{ + "email": "alice@example.com", + "name": "Alice Smith", + "external_id": "alice_001", + "organization_id": "org_abc123" +} ``` -**Service layer usage**: +**Response 201:** +```json +{ + "id": "cust_alice_123", + "email": "alice@example.com", + "name": "Alice Smith", + "external_id": "alice_001", + "organization_id": "org_abc123", + "beneficiaries": [ + { + "id": "ben_alice_456", + "email": "alice@example.com", + "name": "Alice Smith", + "role": "admin" + } + ] +} +``` -```python -# Service layer - clean, type-safe, and concise -async def get_business_subscriptions(business_id: UUID): - # Returns BusinessCustomer instance, IDE knows .subscriptions exists - business = await customer_repo.get_business(session, business_id) - return business.subscriptions +**What Happens:** +- Creates 1 Customer (billing entity) +- Auto-creates 1 Beneficiary (usage entity) with same email +- Beneficiary linked to customer -async def grant_benefit_to_individual(individual_id: UUID, benefit: Benefit): - # Returns IndividualCustomer instance, IDE knows .benefit_grants exists - individual = await customer_repo.get_individual(session, individual_id) - individual.benefit_grants.append(BenefitGrant(...)) +--- + +##### Option 2: Customer Type + Member + +```http +POST https://api.polar.sh/v1/customers +Authorization: Bearer polar_secret_... +Content-Type: application/json + +{ + "email": "alice@example.com", + "name": "Alice Smith", + "external_id": "alice_001", + "organization_id": "org_abc123" + // type defaults to "individual" +} +``` + +**Response 201:** +```json +{ + "id": "cust_alice_123", + "type": "individual", + "email": "alice@example.com", + "name": "Alice Smith", + "external_id": "alice_001", + "organization_id": "org_abc123" +} ``` -#### B.3: Billing Accuracy - Explicit Naming +**What Happens:** +- Creates 1 Customer with `type="individual"` (default) +- No separate Member entity needed for individuals -**Problem**: `Event.customer_id` could mean "who did it" or "who pays" - ambiguous. -**Solution**: Use explicit field names that clarify intent. -**Implementation**: +--- -```python -# server/polar/models/event.py -class Event(RecordModel): - """ - Event tracking for usage-based billing. - - Fields: - - customer_id: The individual customer who performed the action (usage actor) - - billing_customer_id: The customer who pays for this usage (billing payer) - Can be: - - Same as customer_id (B2C individual) - - Business customer (B2B) - """ - id: UUID - customer_id: UUID # Usage actor (always individual) - billing_customer_id: UUID # Billing payer (individual OR business) - - # Relationships with explicit naming - usage_customer = relationship( - "Customer", - foreign_keys=[customer_id], - backref="usage_events", - ) - billing_customer = relationship( - "Customer", - foreign_keys=[billing_customer_id], - backref="billing_events", - ) -``` -#### B.4: Developer Experience - Type Checking with mypy - -**Problem**: Ensuring developers use the correct customer subtype. -**Solution**: Leverage mypy and type hints with polymorphic models. - -**Implementation**: - -With polymorphic models, mypy catch errors during dev time: +**Key Difference:** +- **Option 1**: Always creates Customer + Beneficiary (uniform 1:N model) +- **Option 2**: Creates only Customer, type discriminates individual vs business -```python -# This will pass mypy type checking -def add_team_member(business: BusinessCustomer, individual: IndividualCustomer): - business.members.append(Member( - business_customer_id=business.id, - individual_customer_id=individual.id, - )) +--- + +#### Flow 2: Create B2B Customer with Billing Manager -# This will FAIL mypy type checking - IndividualCustomer doesn't have .members -def add_team_member_wrong(individual: IndividualCustomer, member: IndividualCustomer): - individual.members.append(Member(...)) # ❌ mypy error: "IndividualCustomer" has no attribute "members" +##### Option 1: Beneficiary Model + +```http +# Step 1: Create business customer +POST https://api.polar.sh/v1/customers +Authorization: Bearer polar_secret_... + +{ + "name": "Acme Corp", + "external_id": "acme_001", + "organization_id": "org_abc123" + // No email required for business +} ``` -### Appendix C: Why We Rejected BillingAccount +**Response:** +```json +{ + "id": "cust_acme_123", + "name": "Acme Corp", + "email": null, + "beneficiaries": [] // Empty initially +} +``` -This appendix explains our rationale for rejecting Option 1. +```http +# Step 2: Add billing manager as beneficiary +POST https://api.polar.sh/v1/customers/cust_acme_123/beneficiaries +Authorization: Bearer polar_secret_... -#### Reason 1: Entity Segregation +{ + "email": "billing@acme.com", + "name": "Jane Doe", + "role": "billing_manager" +} +``` + +**Response:** +```json +{ + "id": "ben_jane_789", + "customer_id": "cust_acme_123", + "email": "billing@acme.com", + "name": "Jane Doe", + "role": "billing_manager" +} +``` -**Problem**: BillingAccount creates extra entity for every customer. +--- -**Analysis**: -- B2C customer (Alice) → Creates both Customer AND BillingAccount (1:1) -- B2B business (Acme) → Creates BillingAccount + Members + Customers -- Total entities: 2N for N customers (vs 1N with Option 2) +##### Option 2: Customer Type + Member -**Why This Matters**: -- More database tables to understand -- More joins in queries -- More entities to mock in tests -- Conceptual overhead: "What's the difference between Customer and BillingAccount?" +```http +# Step 1: Create business customer +POST https://api.polar.sh/v1/customers +Authorization: Bearer polar_secret_... -**Our Take**: The theoretical purity of separation isn't pragmatic. +{ + "type": "business", + "name": "Acme Corp", + "external_id": "acme_001", + "organization_id": "org_abc123" + // email optional for business +} +``` -#### Reason 2: Migration Complexity +**Response:** +```json +{ + "id": "cust_acme_123", + "type": "business", + "name": "Acme Corp", + "email": null, + "members": [] // Empty initially +} +``` -**Problem**: Splitting Customer into Customer + BillingAccount is risky. -**Analysis**: +```http +# Step 2: Add billing manager to business via Member +POST https://api.polar.sh/v1/customers/cust_acme_123/members +Authorization: Bearer polar_secret_... -Option 1 migration: -```sql --- 1. Create BillingAccount table --- 2. For each Customer, create BillingAccount --- 3. Move stripe_customer_id from Customer → BillingAccount --- 4. Add billing_account_id to Subscription, Order, PaymentMethod --- 5. Backfill billing_account_id --- 6. Handle dual-mode (check billing_account_id OR customer_id) +{ + "email": "billing@acme.com", + "name": "Jane Doe", + "role": "billing_manager" +} ``` -Option 2 migration: -```sql --- 1. Add Customer.type field --- 2. Set existing customers to type='individual' for non-seats --- 3. Set customer to type='business' and create a new individual customer for billing_manager. We may still warm current merchants. --- 4. Create Member table --- Done! +**Response:** +```json +{ + "id": "member_789", + "business_customer_id": "cust_acme_123", + "individual_customer_id": "cust_jane_456", + "role": "billing_manager" +} ``` -**Why This Matters**: -- Option 1 has 6 steps with data movement -- Option 2 has 4 steps, all additive -- Option 1 requires dual-mode operation (complex) -- Option 2 is backward compatible immediately +--- -**Our Take**: Lower migration risk is worth the query complexity trade-off. +**Key Difference:** +- **Option 1**: Beneficiaries don't exist outside the Customer. +- **Option 2**: Individual customers exist independently. -#### Reason 3: Conceptual Overhead +--- -**Problem**: Developers must learn "Customer" (usage) vs "BillingAccount" (billing) distinction. +#### Flow 3: One-Time Product Purchase (B2C) with Checkout Sessions -**Analysis**: +**Scenario**: Alice purchases a $49 theme (one-time payment) using checkout sessions. -Developer questions with Option 1: -- "Do I use customer_id or billing_account_id here?" -- "Why does Subscription have both customer_id AND billing_account_id?" -- "Which entity owns the payment method?" -- "Where do I add business-level metadata?" +##### Option 1: Beneficiary Model -Developer questions with Option 2: -- "Is this customer individual or business?" (Just check type field). -- "Why I can't assign benefits to business?" +```http +# Step 1: Create checkout session +POST https://api.polar.sh/v1/checkouts +Authorization: Bearer polar_secret_... -**Why This Matters**: -- Onboarding new developers takes longer -- API consumers must understand both concepts -- Documentation must explain the distinction +{ + "product_id": "prod_theme_123", + "customer_email": "alice@example.com", + "success_url": "https://merchant.com/success", + "customer_metadata": { + "user_id": "alice_001" + } +} +``` -**Our Take**: "Customer is a customer" is simpler to explain than "Customer is usage, BillingAccount is billing". +**Response:** +```json +{ + "id": "checkout_abc", + "url": "https://polar.sh/checkout/abc", + "customer_id": null // Created after payment +} +``` -#### Reason 4: Over-Engineering +```http +# Step 2: Customer completes checkout (Polar hosted) +# → Polar auto-creates Customer + Beneficiary -**Problem**: Separation of concerns is theoretically clean but practically overkill. +# Step 3: Webhook received +``` -**Analysis**: +**Webhook Payload:** +```json +{ + "type": "order.created", + "data": { + "id": "order_123", + "customer": { + "id": "cust_alice_456", + "email": "alice@example.com", + "beneficiaries": [ + { + "id": "ben_alice_789", + "email": "alice@example.com", + "role": "admin" + } + ] + }, + "product_id": "prod_theme_123", + "amount": 4900 + } +} +``` -Option 1 assumes: -- We'll frequently need to query "all usage by this person regardless of who pays" -- We'll add many business-level features that don't apply to individuals -- The billing vs usage distinction is fundamental +```http +# Step 4: Grant benefit to beneficiary +# Polar automatically creates BenefitGrant linked to ben_alice_789 +``` -Reality: -- Most queries are "show me this customer's data" (type-agnostic) -- Business-level features (analytics, invoicing) can be added to Customer with type checks -- The distinction matters for events, but events already have customer_id + billing_customer_id +--- -**Why This Matters**: -- We're adding complexity for hypothetical features -- YAGNI principle: Don't build abstractions until you need them +##### Option 2: Customer Type + Member -**Our Take**: Start simple (1 entity), add separation later if truly needed. We can always make a complex migration later. +```http +# Step 1: Create checkout session +POST https://api.polar.sh/v1/checkouts +Authorization: Bearer polar_secret_... -**Decision**: We're choosing **architectural simplicity** (fewer entities, simpler model) over **query simplicity** (no filtering). The trade-off is conscious and acceptable given proper tooling and discipline. +{ + "product_id": "prod_theme_123", + "customer_email": "alice@example.com", + "success_url": "https://merchant.com/success", + "customer_metadata": { + "user_id": "alice_001" + } +} +``` + +**Response:** +```json +{ + "id": "checkout_abc", + "url": "https://polar.sh/checkout/abc", + "customer_id": null // Created after payment +} +``` + +```http +# Step 2: Customer completes checkout (Polar hosted) +# → Polar auto-creates Customer with type="individual" + +# Step 3: Webhook received +``` + +**Webhook Payload:** +```json +{ + "type": "order.created", + "data": { + "id": "order_123", + "customer": { + "id": "cust_alice_456", + "type": "individual", + "email": "alice@example.com" + }, + "product_id": "prod_theme_123", + "amount": 4900 + } +} +``` + +```http +# Step 4: Grant benefit to customer +# Polar automatically creates BenefitGrant linked to cust_alice_456 +``` --- -### Appendix D: Integration Examples +**Key Difference:** +- **Option 1**: Benefit grant links to `beneficiary_id` +- **Option 2**: Benefit grant links to `customer_id` (individual customer) -This appendix shows complete integration flows from an external developer's perspective. +--- -#### D.1: Business Checkout Flow (Step-by-Step) +#### Flow 4: Seat-Based Product Purchase (B2B, 4 Seats) with Checkout Sessions -**Scenario**: Acme Corp wants to purchase a team subscription for 10 seats. +**Scenario**: Acme Corp purchases a 4-seat subscription ($199/month) for their team. -**Step 1: Merchant creates business customer and individual customer** +##### Option 1: Beneficiary Model -First, create the individual customer: +```http +# Step 1: Create business customer +POST https://api.polar.sh/v1/customers +Authorization: Bearer polar_secret_... -```python -res = polar.customers.create(request={ - "external_id": "cust_alice", - "email": "alice@acme.com", - "name": "Alice", - "organization_id": "1dbfc517-0bbf-4301-9ba8-555ca42b9737", - -}) +{ + "name": "Acme Corp", + "customer_email": "billing@acme.com", + "external_id": "acme_001", + "organization_id": "org_abc123" +} ``` -Then create the customer: -```python -res = polar.customers.create(request={ - "external_id": "cust_acme", - "name": "ACME_INC", - "type": "business", - "external_owner_id": "cust_alice", # This should create the relationship automatically - "billing_address": { - "country": polar_sdk.CountryAlpha2Input.US, +**Response:** `{ "id": "cust_acme_123", ... }` + +```http +# Step 2: Create checkout session for business +POST https://api.polar.sh/v1/checkouts +Authorization: Bearer polar_secret_... + +{ + "product_id": "prod_team_plan_4seats", + "external_customer_id": "acme_001", + "success_url": "https://merchant.com/success" +} +``` + +**Response:** +```json +{ + "id": "checkout_def", + "url": "https://polar.sh/checkout/def", + "customer_id": "cust_acme_123" +} +``` + +```http +# Step 3: Billing manager completes checkout +# → Polar auto-creates beneficiary for billing@acme.com +``` + +Step 4: Webhook received +**Webhook Payload:** +```json +{ + "type": "subscription.created", + "data": { + "id": "sub_acme_456", + "customer": { + "id": "cust_acme_123", + "name": "Acme Corp", + "beneficiaries": [ + { + "id": "ben_billing_789", // beneficiary created before or during the checkout + "email": "billing@acme.com", + "role": "admin" + } + ] }, - "tax_id": [ - "911144442", - "us_ein", - ], - "organization_id": "1dbfc517-0bbf-4301-9ba8-555ca42b9737", - -}) + "product_id": "prod_team_plan_4seats", + "seats": 4, + "status": "active" + } +} ``` -**Step 2: Merchant creates checkout session** -```python - res = polar.checkouts.create(request={ - "external_business_customer_id": "cust_acme", # new attirbute to attach payment method and subscription to the business customer. - "external_customer_id": "cust_alice", - "products": ["product_1"], - }) +```http +# Step 5: Beneficiaries claim seats +POST https://api.polar.sh/v1/subscriptions/sub_acme_456/seats +Authorization: Bearer polar_customer_session_... // ben_billing_789 session + +{ "beneficiary_email": "alive@acme.com" } ``` -If the business_customer_id is not provided, and the product is a seat based product. Polar will automatically: +**Response:** +```json +{ + "id": "seat_001", + "subscription_id": "sub_acme_456", + "beneficiary_id": "ben_alice_101", + "status": "active" +} +``` -1. First create a business customer -2. Create a membership relationship between the customer_id and the business created. +--- +##### Option 2: Customer Type + Member -**Step 2: Customer completes checkout** +```http +# Step 1: Create business customer +POST https://api.polar.sh/v1/customers +Authorization: Bearer polar_secret_... -Customer fills out Polar checkout form... +{ + "type": "business", + "name": "Acme Corp", + "customer_email": "billing@acme.com", + "external_id": "acme_001", + "organization_id": "org_abc123" +} +``` -**Step 3: Webhook fired to merchant** +**Response:** `{ "id": "cust_acme_123", "type": "business", ... }` +```http +# Step 2: Create checkout session +POST https://api.polar.sh/v1/checkouts +Authorization: Bearer polar_secret_... + +{ + "product_id": "prod_team_plan_4seats", + "external_customer_id": "acme_001", + "success_url": "https://merchant.com/success" +} +``` + +```http +# Step 3: Billing manager completes checkout +# Webhook received +``` + +Step 4: Webhook received +**Webhook Payload:** ```json { - "type": "order.paid", + "type": "subscription.created", "data": { - "id": "ord_abc123", + "id": "sub_acme_789", "customer": { - "id": "cust_acme", // ← Business customer ID + "id": "cust_acme_123", "type": "business", "name": "Acme Corp", - "email": null, // Business has no email + "members": [ + { + "id": "member_789", + "customer": { + "id": "cust_acme_456", // created on the fly or returning existing members + "email": "billing@acme.com" + }, + "role": "admin" + } + ] }, - "subscription": { - "id": "sub_456", - "customer_id": "cust_acme", // ← Belongs to business - "product_id": "prod_theme_pro", - "status": "active", - "seats": 10 - } + "product_id": "prod_team_plan_4seats", + "seats": 4, + "status": "active" } } ``` +```http +# Step 5: Beneficiaries claim seats +POST https://api.polar.sh/v1/subscriptions/sub_acme_789/seats +Authorization: Bearer polar_customer_session_... // cust_acme_456 session + +{ "customer_email": "alice@acme.com" } +``` + + +**Response:** +```json +{ + "id": "seat_001", + "subscription_id": "sub_acme_789", + "customer_id": "cust_alice_101", + "status": "active" +} +``` + --- -#### D.2: Querying Business Customer Data +**Key Difference:** +- **Option 1**: Seats link to `beneficiary_id` +- **Option 2**: Seats link to `customer_id` (individual customer) + +--- -**Get business customer with members**: +#### Flow 5: Seat-Based Product with Checkout Links (Public) + +**Scenario**: Public checkout link for team subscription - auto-creates business on checkout. + +##### Option 1: Beneficiary Model ```http -GET /v1/customers/cust_acme?include=members +# Merchant shares public checkout link +https://buy.polar.sh/polar_cl_gon2ISU4r7oYyjVaxXlRVUv5MKh1H3c1gCxKn1zZGHA + +# Customer clicks, enters email, completes purchase +# → Polar auto-creates Customer + Beneficiary + +# Webhook received ``` +**Webhook Payload:** ```json { - "id": "cus_business_xyz789", - "type": "business", // new attribute - "name": "Acme Corp", - "active_subscriptions": [ + "type": "subscription.created", + "data": { + "id": "sub_789", + "customer": { + "id": "cust_auto_123", + "email": "buyer@company.com", + "beneficiaries": [ + { + "id": "ben_buyer_456", + "email": "buyer@company.com", + "role": "admin" + } + ] + }, + "product_id": "prod_team_plan", + "seats": 5 + } +} +``` + +--- + +##### Option 2: Customer Type + Member + +```http +# Merchant shares public checkout link +https://polar.sh/org_abc/products/prod_team_plan + +# Customer clicks, enters email, completes purchase +# → Polar detects seat-based product, auto-creates: +# 1. Business customer (generated name from email domain) +# 2. Individual customer for buyer +# 3. Member linking them + +# Webhook received +``` + +**Webhook Payload:** +```json +{ + "type": "subscription.created", + "data": { + "id": "sub_789", + "customer": { + "id": "cust_business_123", + "type": "business", + "name": "company.com", // Auto-generated + "members": [ + { + "id": "member_456", + "customer": { + "id": "cust_buyer_789", + "type": "individual", + "email": "buyer@company.com" + }, + "role": "admin" + } + ] + }, + "product_id": "prod_team_plan", + "seats": 5 + } +} +``` + +--- + +**Key Difference:** +- **Option 1**: Single customer created +- **Option 2**: Two customers created, one business and one individual + +--- + +#### Flow 6: Download Benefits + +##### Flow 6a: B2C Customer Download Benefit + +**Scenario**: Bob (individual customer) access to a downloadable benefit. + +###### Option 1: Beneficiary Model + +```http +# Bob authenticates via Customer Portal +# Session identifies him by customer_id (B2C backward compatibility as 1 beneficiary == 1 customer in B2C) + +# B2C: Using customer_id (backward compatible) +GET https://api.polar.sh/v1/customer-portal/downloadables +Authorization: Bearer polar_customer_session_... + +``` + +**Response (same for both):** +```json +{ + "items": [ { - "id": "sub_456", - "product_id": "prod_theme_pro", - "status": "active", - "seats": 10 + "id": "grant_001", + "beneficiary_id": "ben_bob_default_456", // Auto-created default beneficiary + "customer_id": "cust_bob_123", // Same as billing customer + "file": { + "id": "benefit_theme_download", + "name": "Premium Theme Package" + } } - ], - "members": [ - { - "id": "member_1", - "customer": { - "id": "cust_alice", - "email": "alice@acme.com", - "name": "Alice" - }, - "role": "admin", - "is_active": true - }, + ] +} +``` + +**How B2C Compatibility Works:** +- Customer session endpoints accepts both `customer_id` (legacy) and `beneficiary_id` (new) +- B2C customers have auto-created "default" beneficiary (1:1 mapping) +- `customer_id=cust_bob_123` internally resolves to `beneficiary_id=ben_bob_default_456` + +--- + +##### Flow 6b: B2B Team Member Claims Benefit + +> ⚠️ This contains a breaking change with seats functionality + +**Scenario**: Alice (team member at ACME) claims access to a downloadable benefit. + +###### Option 1: Beneficiary Model + +```http +# Alice authenticates via Customer Portal +# Session identifies her as ben_alice_acme_123 (B2B: explicit beneficiary) + +GET https://api.polar.sh/v1/benefit-grants?beneficiary_id=ben_alice_acme_123 +Authorization: Bearer polar_beneficiary_session_... +``` + +**Response:** +```json +{ + "data": [ { - "id": "member_2", - "customer": { - "id": "cus_individual_ghi789", - "email": "dev@acme.com", - "name": "Bob Smith" + "id": "grant_002", + "beneficiary_id": "ben_alice_acme_123", + "customer_id": "cust_acme_456", // ACME Inc (business) paid for it + "benefit": { + "id": "benefit_theme_download", + "type": "downloadable", + "description": "Premium Theme Package" }, - "role": "member", - "is_active": true + "subscription_id": "sub_acme_789" } ] } ``` -**List all orders for business**: +**Key B2B Difference:** +- ❌ Cannot use `customer_id` query param for B2B (ambiguous - which beneficiary?) +- ✅ Must use `beneficiary_id` to identify which team member +- ✅ Authentication token contains beneficiary context + +--- + +###### Option 2: Customer Type + Member ```http -GET /v1/orders?customer_id=cust_acme +# Alice authenticates via Customer Portal +# Session identifies her as cust_alice_123 (individual customer) + +GET https://api.polar.sh/v1/customer-portal/downloadables +Authorization: Bearer polar_customer_session_... ``` +**Response:** ```json { - "data": [ + "items": [ { - "id": "order_789", - "customer_id": "cust_acme", - "amount": 49900, - "created_at": "2025-01-15T10:00:00Z" + "id": "grant_001", + "customer_id": "cust_alice_123", + "file": { + "id": "benefit_theme_download", + "name": "Premium Theme Package" + } } ] } ``` +```http +# Download benefit +GET https://api.polar.sh/v1/benefits/benefit_theme_download/download +Authorization: Bearer polar_customer_session_... +``` + --- -#### D.3: Event Ingestion for Metered Billing +**Key Differences:** +- **Option 1 (B2C)**: `customer_id` backward compatible, internally maps to default beneficiary +- **Option 1 (B2B)**: ⚠️ Uses `beneficiary_id` for explicit team member identification +- **Option 2**: Always uses `customer_id` (individual customer for both B2C and B2B members) + +--- + +#### Flow 7: Customer Portal Link Generation + +##### Flow 7a: B2C Customer Portal + +**Scenario**: Bob (individual customer) needs to access Customer Portal to manage his subscription. -**Scenario**: Bob (member of Acme) generates an API call that should bill Acme's subscription. +###### Option 1: Beneficiary Model -**Approach A: Using subscription_id (RECOMMENDED)** +```http +# Option A: Using customer_id (B2C backward compatible) +POST https://api.polar.sh/v1/customer-sessions +Authorization: Bearer polar_secret_... + +{ + "customer_id": "cust_bob_123" // ✅ B2C backward compatible +} + +# Option B: Using beneficiary_id (new explicit way) +POST https://api.polar.sh/v1/customer-sessions +Authorization: Bearer polar_secret_... + +{ + "beneficiary_id": "ben_bob_default_456" // ✅ New way +} +``` + +**Response (same for both):** +```json +{ + "token": "polar_cst_abc123xyz", + "customer_id": "cust_bob_123", + "beneficiary_id": "ben_bob_default_456", // Auto-resolved from customer + "return_url": "https://polar.sh/org_merchant/portal?token=polar_cst_abc123xyz", + "expires_at": "2025-12-01T10:00:00Z" +} +``` + +**How B2C Compatibility Works:** +- ✅ Endpoint accepts both `customer_id` (legacy) and `beneficiary_id` (new) +- ✅ If `customer_id` provided → resolve to default beneficiary → create session +- ✅ If `beneficiary_id` provided → create session directly + +**Portal shows:** +- Bob's subscriptions (from customer_id: cust_bob_123) +- Bob's benefit grants (from beneficiary_id: ben_bob_default_456) +- Bob's orders and invoices + +--- + +###### Option 2: Customer Type + Member ```http -POST /v1/events +POST https://api.polar.sh/v1/customer-sessions +Authorization: Bearer polar_secret_... + +{ + "customer_id": "cust_bob_456" +} +``` + +**Response:** +```json { + "token": "polar_cst_abc123xyz", + "customer_id": "cust_bob_456", + "return_url": "https://polar.sh/org_merchant/portal?token=polar_cst_abc123xyz", + "expires_at": "2025-12-01T10:00:00Z" +} +``` + +**Portal shows:** +- Bob's subscriptions +- Bob's benefit grants +- Bob's orders and invoices + +--- + +##### Flow 7b: B2B Customer Portal + + +**Scenario**: Jane (billing manager at Acme) needs to see company billing info. + +###### Option 1: Beneficiary Model + +> ⚠️ This contains a breaking change with seats functionality + + +We can only use the beneficiary id. The customerId has multiple benefficieres and we don't know which one to pick. + +```http +POST https://api.polar.sh/v1/customer-sessions +Authorization: Bearer polar_secret_... + +{ + "beneficiary_id": "ben_jane_789" // Jane's beneficiary at Acme +} +``` + +**Response:** +```json +{ + "token": "polar_cst_def456", + "beneficiary_id": "ben_jane_789", + "customer_id": "cust_acme_123", + "url": "https://polar.sh/portal?token=polar_cst_def456", + "expires_at": "2025-12-01T10:00:00Z" +} +``` + +**Portal shows (role-based on beneficiary.role = "billing_manager"):** +- Acme's subscriptions (from customer_id) +- All beneficiaries (list all team members) +- Company orders and invoices +- Aggregated usage across all beneficiaries + +--- + +###### Option 2: Customer Type + Member + +```http +POST https://api.polar.sh/v1/customer-sessions +Authorization: Bearer polar_secret_... + +{ + "customer_id": "cust_jane_456" // Jane's individual customer +} +``` + +**Response:** +```json +{ + "token": "polar_cst_def456", + "customer_id": "cust_jane_456", + "url": "https://polar.sh/portal?token=polar_cst_def456", + "expires_at": "2025-12-01T10:00:00Z" +} +``` + +**Portal logic:** +1. Check if cust_jane_456 is a member of any business customers +2. Find member record: business_customer_id = cust_acme_123, role = "billing_manager" +3. Show billing manager view + +**Portal shows:** +- Acme's subscriptions (from business customer) +- All members (query members of cust_acme_123) +- Company orders and invoices +- Aggregated usage across all members + +--- + +**Key Difference:** +- **Option 1**: ⚠️ Single `beneficiary_id` gives access to both personal and business context. Not compatible with B2B. +- **Option 2**: `customer_id` requires lookup via Member table to find business context. + +--- + +#### Flow 9: Event Ingestion + +##### Flow 9a: B2C Event Ingestion + +**Scenario**: Bob (individual customer) makes an API call - track usage. + +###### Option 1: Beneficiary Model + +```http +# Option A: Using customer_id (B2C backward compatible) +POST https://api.polar.sh/v1/events +Authorization: Bearer polar_secret_... + +{ + "customer_id": "cust_bob_123", // ✅ B2C backward compatible + "name": "api.request", + "properties": { + "endpoint": "/v1/themes", + "method": "POST" + } +} + +# Option B: Using beneficiary_id (new explicit way) +POST https://api.polar.sh/v1/events +Authorization: Bearer polar_secret_... + +{ + "beneficiary_id": "ben_bob_default_456", // ✅ New way "name": "api.request", - "customer_id": "cust_bob", - // nothing else is needed as bob is only a member of acme "properties": { "endpoint": "/v1/themes", "method": "POST" @@ -830,77 +1311,156 @@ POST /v1/events } ``` -If bob is a member of two businesses, then an error will be thrown and XXX needs to be specified. +**Response (same for both):** +201 ---- +**How B2C Compatibility Works:** +- Endpoint accepts both `customer_id` (legacy) and `beneficiary_id` (new) +- If `customer_id` provided → resolve to default beneficiary → record event +- If `beneficiary_id` provided → record event directly -#### D.4: Member Management +--- -**Add a new member to Acme Corp**: +###### Option 2: Customer Type + Member ```http -POST /v1/customers/cust_acme/members +POST https://api.polar.sh/v1/events +Authorization: Bearer polar_secret_... + { - "email": "petru@acme.com", - "role": "member" + "customer_id": "cust_bob_456", + "name": "api.request", + "properties": { + "endpoint": "/v1/themes", + "method": "POST" + } } ``` -**Response**: -```json +**Response:** +201 + +--- + +##### Flow 9b: B2B Event Ingestion + +> ⚠️ This contains a breaking change with seats functionality + +**Scenario**: Alice (team member at Acme) makes an API call - bill Acme's subscription. + +###### Option 1: Beneficiary Model + +```http +POST https://api.polar.sh/v1/events +Authorization: Bearer polar_secret_... + { - "id": "member_2", - "business_customer_id": "cust_acme", - "individual_customer": { - "id": "cus_individual_ghi789", // Auto-created - "email": "dev@acme.com", - "type": "individual", - }, - "role": "member", - "is_active": true, - "invited_at": "2025-01-20T10:00:00Z" + "customer_id": "cust_acme_123", // the business customer of the subscription, who to bill. ⚠️ This breaks current seats usage as merchants will need to pass a new customer ID or use the new benefficiaryId + "beneficiary_id": "ben_alice_acme_123", // Optional: Alice's beneficiary at Acme for granular usage + "name": "api.request", + "properties": { + "endpoint": "/v1/themes", + "method": "POST" + } } ``` +**Response:** +201 + +**What happens:** +- Bills Acme's subscription + --- -#### D.5: Individual Who Is Both Member AND Customer +###### Option 2: Customer Type + Member + +```http +POST https://api.polar.sh/v1/events +Authorization: Bearer polar_secret_... + +{ + "customer_id": "cust_alice_123", // Alice's individual customer + "name": "api.request", + "properties": { + "endpoint": "/v1/themes", + "method": "POST" + } +} +``` + +**What happens:** +- Polar checks if cust_alice_123 is a member of any businesses +- Finds member record: business_customer_id = cust_acme_456 +- If Alice is member of ONLY ONE business and doesn't have a subscription, we will bill that business -**Scenario**: Jane (jane@acme.com) is: -1. Billing manager for Acme Corp (member) -2. Individual customer with her own personal subscription +**Response (if Alice is member of only Acme):** +201 -**Her customer profile**: +**Response (if Alice is member of Acme AND Lolo):** ```json { - "id": "cust_jane", - "type": "individual", - "email": "jane@acme.com", - "name": "Jane Doe", - "active_subscriptions": [ - { - "id": "sub_personal_123", // Her personal subscription - "product_id": "prod_theme_basic" + "error": { + "type": "ambiguous_subscription", + "message": "Customer cust_alice_123 is a member of 2 businesses. Specify 'subscription_id'.", + "details": { + "available_subscriptions": [ + {"subscription_id": "sub_acme_789", "business": "Acme Corp"}, + {"subscription_id": "sub_lolo_456", "business": "Lolo Inc"} + ] } - ], - "memberships": [ - { - "business_customer_id": "cust_acme", - "role": "admin" - } - ] + } } ``` -**Dashboard behavior**: -- Jane appears in **main customer list** -- Jane appears in **Acme Corp's member list** (because she's a member) -- Two distinct contexts: individual purchaser AND business member +**Retry with explicit subscription:** +```http +POST https://api.polar.sh/v1/events +{ + "customer_id": "cust_alice_123", + "subscription_id": "sub_acme_789", // Explicit + "name": "api.request" +} +``` --- +#### Flow 10: Customer Portal Login + +The user access: https://polar.sh/petru-test/portal/request + +##### Flow 10.a: B2C login -## Open Questions +In both cases work as expected. + +##### Flow 10.b: B2B login + +###### Option 1: Beneficiary Model + +Beneficiary emails are not unique across per organization, they are unique per Customer. This means that a single email address can be associated with multiple beneficiaries within the same organization. + +These are different entities that can be associated with the same email address. + +###### Option 2: Customer Type + Member + +Every customer has a unique email address. This means that a single email address, but they can have subscriptions attached to them and being a member of multiple business customers. + +--- -1. **WorkOS Integration**: Will this work for WorkOS authentication and directory sync? -2. **Event Attribution Edge Cases**: How do we handle events when a user is a member of multiple businesses with different subscriptions? +#### Summary: API Call Comparison + +| Flow | Option 1: Beneficiary | Option 2: Customer Type + Member | +| ---- | -------------------- | -------------------------------- | +| 1. Create B2C | Easy | Easy | +| 2. Create B2B + Manager | Easy | Easy | +| 3. Checkout Session (B2C) | Easy | Easy | +| 4. Checkout Sessions (B2B) | Easy | Easy | +| 5. Checkout Links (B2B) | Easy | Easy | +| 6a. Download Files B2C | Easy | Easy | +| 6b. Download Files B2B | ⚠️ Breaking change | Easy | +| 7a. B2C Customer Portal Session | Easy | Easy | +| 7b. B2B Customer Portal Session | ⚠️ Breaking change | Easy | +| 9a. B2C Event Ingestion | Easy | Easy | +| 9b. B2B Event Ingestion | ⚠️ Breaking change | 🟡 Medium | +| 10a. Customer Portal Login (B2C) | Easy | Easy | +| 10b. Customer Portal Login (B2B) | 🟡 Medium | 🟡 Medium | From fd4604c74040cd6f263bd06af690e62b83d4fc8a Mon Sep 17 00:00:00 2001 From: Petru Rares Sincraian Date: Mon, 17 Nov 2025 09:39:02 +0100 Subject: [PATCH 12/15] fix: add owner attribute --- .../design-documents/business-entity.mdx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/engineering/design-documents/business-entity.mdx b/engineering/design-documents/business-entity.mdx index 2ebd645..ee4fab3 100644 --- a/engineering/design-documents/business-entity.mdx +++ b/engineering/design-documents/business-entity.mdx @@ -490,8 +490,12 @@ Authorization: Bearer polar_secret_... { "name": "Acme Corp", "external_id": "acme_001", - "organization_id": "org_abc123" - // No email required for business + "organization_id": "org_abc123", + owner: { + email: 'alice@example.com', + name: 'Alice Smith', + externalId: 'external_alice' + } } ``` @@ -501,7 +505,14 @@ Authorization: Bearer polar_secret_... "id": "cust_acme_123", "name": "Acme Corp", "email": null, - "beneficiaries": [] // Empty initially + "beneficiaries": [ + { + "id": "ben_alice_456", + "email": "alice@example.com", + "name": "Alice Smith", + "role": "admin" + } + ] } ``` From 64ee85f333f3327015e34cc49573a12107f0cf46 Mon Sep 17 00:00:00 2001 From: Petru Rares Sincraian Date: Mon, 17 Nov 2025 17:15:36 +0100 Subject: [PATCH 13/15] feat: members is the chosen approach and add migration guide --- .../design-documents/business-entity.mdx | 710 +++++++++++++++--- 1 file changed, 589 insertions(+), 121 deletions(-) diff --git a/engineering/design-documents/business-entity.mdx b/engineering/design-documents/business-entity.mdx index ee4fab3..07a1c26 100644 --- a/engineering/design-documents/business-entity.mdx +++ b/engineering/design-documents/business-entity.mdx @@ -43,6 +43,10 @@ ## Solution +**Decision: Option 1 (Member Model) is the preferred solution.** + +After evaluating both approaches using our weighted decision matrix, Option 1 scores 69 vs Option 2's 62. The Member Model provides superior billing accuracy, cleaner architecture, and better long-term maintainability. While it introduces breaking changes for existing B2B/seat-based customers, these are limited. The clear separation of concerns (Customer = billing, Member = usage) eliminates ambiguity and aligns perfectly with our Auth providers integration goals. + We evaluated two architectural approaches. Both solve the core problem but differ in complexity, migration risk, and developer experience. ### Tenets @@ -60,9 +64,9 @@ Scale: 🔴 1 (poor) | 🟡 2 (acceptable) | 🟢 3 (excellent) --- -### Option 1: Beneficiary Model +### (Preferred) Option 1: Member Model -**Concept**: Introduce a `Beneficiary` entity that represents "who uses the product". `Customer` becomes purely a billing entity. Every Customer has one or more Beneficiaries - even individual customers have a single beneficiary (themselves). This creates uniform architecture with no special cases. +**Concept**: Introduce a `Member` entity that represents "who uses the product". `Customer` becomes purely a billing entity. Every Customer has one or more Members - even individual customers have a single member (themselves). This creates uniform architecture with no special cases. **High-Level Architecture**: ``` @@ -70,28 +74,31 @@ Customer (id, name, stripe_customer_id) # Pure billing entity ├─ Subscriptions ├─ Orders ├─ PaymentMethods - └─ Beneficiaries → Who uses/accesses the product + └─ Members → Who uses/accesses the product -Beneficiary (id, customer_id, email, name, role) # Pure usage entity +Member (id, customer_id, email, name, external_id, role) # Pure usage entity ├─ BenefitGrants (what they can access) ├─ Events (what they did) ├─ CustomerSeats (seats they claimed) └─ CustomerSessions (how they authenticate) + + UNIQUE constraint: (customer_id, email) - same email allowed across different customers + UNIQUE constraint: (customer_id, external_id) - same external_id allowed across different customers ``` **Key Insight**: Uniform 1:N relationship everywhere ``` Customer (Alice Personal) - └── Beneficiary (Alice) [1:1 for individuals] + └── Member (Alice) [1:1 for individuals] Customer (ACME Corp) - ├── Beneficiary (Alice at ACME - it's different beneficiary ID than other Alice) - ├── Beneficiary (Bob at ACME) - └── Beneficiary (Carol at ACME) + ├── Member (Alice at ACME - different member ID than other Alice) + ├── Member (Bob at ACME) + └── Member (Carol at ACME) Customer (Lolo Inc) - ├── Beneficiary (Alice at Lolo - it's different beneficiary ID than other Alice) - └── Beneficiary (Dan at Lolo) + ├── Member (Alice at Lolo - different member ID than other Alice) + └── Member (Dan at Lolo) ``` @@ -99,25 +106,26 @@ Customer (Lolo Inc) **Scoring**: | Tenet | Score | Rationale | | ----------------------- | ----- | --------- | -| Billing Accuracy | 🟢 3 | Perfect separation - Customer = billing, Beneficiary = usage | +| Billing Accuracy | 🟢 3 | Perfect separation - Customer = billing, Member = usage | | Backward Compatibility | 🔴 1 | Current integration with B2B requires migration | | Simplicity | 🟢 3 | Uniform 1:N model, no type discrimination, no special cases | -| Minimal B2B Changes | 🟢 3 | Just add beneficiaries, existing customer_id queries work | +| Minimal B2B Changes | 🟢 3 | Just add members, existing customer_id queries work | | Polar Dev Experience | 🟢 3 | Clean queries, no type filtering, clear separation | -| WorkOS Integration | 🟢 3 | Beneficiaries map directly to WorkOS users/members | +| WorkOS Integration | 🟢 3 | Members map directly to WorkOS users/members | | **Weighted Total** | **69** | | **Pros**: - **No polymorphism**: Customer is always billing entity, no `type` field needed -- **Uniform API**: All authentication goes through Beneficiary, single code path -- **Easy multi-membership**: Alice has multiple beneficiary records (personal, ACME, Lolo). Each one has a different ID. -- **Clean separation**: Customer = billing, Beneficiary = usage/access -- **Perfect WorkOS fit**: Beneficiaries map 1:1 to WorkOS users/members +- **Uniform API**: All authentication goes through Member, single code path +- **Easy multi-membership**: Alice has multiple member records (personal, ACME, Lolo). Each one has a different ID. +- **Clean separation**: Customer = billing, Member = usage/access +- **Perfect WorkOS fit**: Members map 1:1 to WorkOS/BetterAuth members +- **Unique per customer**: Email and external_id are unique per customer, allowing same values across different customers. **Cons**: - Extra entity for every user. -- More joins for some queries (beneficiary -> customer) -- Conceptual shift: "authenticate as beneficiary, not customer" +- More joins for some queries (member -> customer) +- Conceptual shift: "authenticate as member, not customer" --- @@ -176,15 +184,15 @@ Member (business_customer_id, individual_customer_id, role) Scale: 🔴 1 (poor) | 🟡 2 (acceptable) | 🟢 3 (excellent) -| Tenet | Weight | Option 1: Beneficiary Model | Option 2: Customer Type + Member | -| ----------------------- | ------ | --------------------------- | -------------------------------- | -| Billing Accuracy | 7 | 🟢 3 (21) | 🟡 2 (14) | -| Backward compatibility | 6 | 🔴 3 (6) | 🟢 3 (18) | -| Simplicity | 5 | 🟢 3 (15) | 🟡 2 (10) | -| Minimal changes for B2B | 4 | 🟢 3 (12) | 🟡 2 (8) | -| Polar dev experience | 3 | 🟢 3 (9) | 🟡 2 (6) | -| WorkOS Integration | 2 | 🟢 3 (6) | 🟢 3 (6) | -| **Total Score** | - | **69** | **62** | +| Tenet | Weight | Option 1: Member Model | Option 2: Customer Type + Member | +| ----------------------- | ------ | ---------------------- | -------------------------------- | +| Billing Accuracy | 7 | 🟢 3 (21) | 🟡 2 (14) | +| Backward compatibility | 6 | 🔴 3 (6) | 🟢 3 (18) | +| Simplicity | 5 | 🟢 3 (15) | 🟡 2 (10) | +| Minimal changes for B2B | 4 | 🟢 3 (12) | 🟡 2 (8) | +| Polar dev experience | 3 | 🟢 3 (9) | 🟡 2 (6) | +| WorkOS Integration | 2 | 🟢 3 (6) | 🟢 3 (6) | +| **Total Score** | - | **69** | **62** | --- @@ -192,23 +200,479 @@ Scale: 🔴 1 (poor) | 🟡 2 (acceptable) | 🟢 3 (excellent) ## Migration Plan -TODO +### Overview + +The migration introduces the `Member` model while maintaining backward compatibility for existing B2C customers. B2B/seat-based customers will experience breaking changes and require coordinated migration. The rollout uses feature flags to minimize risk and allow gradual adoption. + +**Timeline**: ~1 month for a single SDE for implementation + 2-4 weeks for coordinated merchant migration + +### Phase 1: B2C Customer Migration (Automatic, Zero-Downtime) + +All existing individual customers receive automatic migration with full backward compatibility: + +**What happens:** +1. **Schema changes deployed**: Add `members` table with foreign key to `customers` + - UNIQUE constraint: `(customer_id, email)` - ensures email uniqueness per customer + - UNIQUE constraint: `(customer_id, external_id)` - ensures external_id uniqueness per customer +2. **Auto-create default members**: Migration script creates one member per customer + - `member.customer_id` → existing customer ID + - `member.email` → customer email + - `member.name` → customer name + - `member.external_id` → customer external_id (if exists) + - `member.role` → `"owner"` (default) +3. **Service layer dual support**: Endpoints accept both `customer_id` (legacy) and `member_id` (new) + - When `customer_id` provided → resolve to default member automatically + - When `member_id` provided → use directly +4. **Migrate usage data**: + - `benefit_grants.member_id` → default member ID (keep `customer_id` for backward compat queries) + - `customer_sessions.member_id` → default member ID + - `events.member_id` → default member ID (keep `customer_id` denormalized) + - `customer_seats.member_id` → NULL initially (migrated in Phase 2) + +**Merchant impact:** None. All existing API calls work identically + +--- + +### Phase 2: B2B/Seat-Based Customer Migration (Coordinated) + +Existing seat-based subscriptions require structural changes and merchant coordination: + +**Current state:** +- Billing manager = Customer with payment method +- Seat holders = Separate Customer entities linked via `customer_seats` table +- Usage events reference individual customer IDs + +**New state:** +- Billing manager = Customer (unchanged) with admin Member +- Seat holders = Members of the billing customer +- Usage events reference member IDs + +**Migration steps:** + +1. **Identify seat-based customers**: Query subscriptions/orders with `seats > 0` + +2. **Transform billing managers**: + ```sql + -- For each customer with seat-based subscription + INSERT INTO members (customer_id, email, name, external_id, role) + SELECT id, email, name, external_id, 'owner' + FROM customers + WHERE id IN (SELECT customer_id FROM subscriptions WHERE seats IS NOT NULL); + ``` + +3. **Transform seat holders**: + ```sql + -- For each claimed seat, create member under billing customer + INSERT INTO members (customer_id, email, name, external_id, role) + SELECT + cs.subscription.customer_id, -- Billing customer + seat_holder.email, + seat_holder.name, + seat_holder.external_id, + 'member' + FROM customer_seats cs + JOIN customers seat_holder ON cs.customer_id = seat_holder.id + WHERE cs.status = 'claimed' + -- avoid adding billing manager twice; + and cs.subscription.customer_id <> seat_holder.id + + -- Update customer_seats to reference members + UPDATE customer_seats + SET member_id = + WHERE customer_id = ; + ``` + +4. **Migrate usage data**: + - **Benefit grants**: Reassign from seat holder customer → member + - **Events**: Reassign from seat holder customer → member (update both `member_id` and keep `customer_id` pointing to billing customer) + - **Customer sessions**: Migrate to member sessions + +5. **Update merchant integrations** (⚠️ **Breaking changes**): + + | Flow | Current (uses customer_id) | New (uses member_id) | + |------|---------------------------|--------------------------| + | **Event ingestion** | `POST /events { customer_id: "seat_holder_cust_123" }` | `POST /events { customer_id: "billing_cust_456", member_id: "mem_123" }` | + | **Customer portal sessions** | `POST /customer-sessions { customer_id: "seat_holder_cust_123" }` | `POST /customer-sessions { member_id: "mem_123" }` | + | **Benefit downloads (B2B)** | `GET /benefit-grants?customer_id=seat_holder_cust_123` | `GET /benefit-grants?member_id=mem_123` | + + **📖 See [Appendix A: Merchant Migration Guide](#appendix-a-merchant-migration-guide-b2bseat-based-products) for detailed instructions, code examples, and FAQ.** + +**Communication plan:** +1. **2 weeks before migration**: Email merchants with seat-based products + - Explain breaking changes + - Provide [Appendix A: Merchant Migration Guide](#appendix-a-merchant-migration-guide-b2bseat-based-products) with code examples + - Offer dedicated support channel +2. **1 week before migration**: Second reminder with migration deadline +3. **Migration day**: Enable feature flag, monitor metrics +4. **Post-migration**: Support merchants during transition period + +--- + ### Rollback Plan -TODO +**Feature flag:** `member_model_enabled` (organization-level or global toggle) + +During the migration period, if a bug is found or a merchant complaint, disable the feature flag to revert to the previous functionality. All code should work with feature flag enabled or disabled. --- ## Implementation Plan -TODO + +### Phase 1: Schema +**Goal**: Database ready, feature flag in place, no production impact yet + +1. **Create member table with indexes** + - Add table: `members(id, customer_id FK, email, name, external_id, role, created_at, updated_at)` + - Indexes: `customer_id`, `(customer_id, email)` UNIQUE, `(customer_id, external_id)` UNIQUE + +2. **Add member_id columns to usage tables** + - `benefit_grants.member_id`, `events.member_id`, `customer_sessions.member_id`, `customer_seats.member_id` (all nullable initially) + +3. **Add feature flag infrastructure** + - Organization-level feature flag: `member_model_enabled` (default: false) + + +### Phase 2: Service Layer + +**Goal**: Backend supports both models simultaneously via feature flag + +1. **Implement Member service & repository** + - CRUD operations: create, get, list, update members for a customer + +6. **Update Customer service for auto-member creation** + - When creating B2C customer, auto-create default member (if flag enabled) + +7. **Add member resolution helper** + - `resolve_member(customer_id) → member_id` for backward compatibility + +8. **Update BenefitGrant service** + - Support both `customer_id` (resolve to member) and `member_id` direct assignment + +9. **Update Event service** + - Accept both `customer_id` and `member_id`, prefer member when provided + +10. **Update CustomerSession service** + - Support authentication via member_id, maintain customer_id backward compat + +11. **Update CustomerSeat service** + - Link seats to members instead of customers (when flag enabled) + +### Phase 3: API & Webhooks + +**Goal**: API accepts new parameters, webhooks include member data + +1. **Update API schemas (Pydantic)** + - Add `member_id` optional field to event creation, customer session creation, benefit grant queries + +13. **Update API endpoints** + - `/v1/events` accepts `member_id`, `/v1/customer-sessions` accepts `member_id`, `/v1/customers/{id}/members` CRUD endpoints + +14. **Update webhook payloads** + - Include `members` array in customer object, include `member_id` in event/grant webhooks + + +### Phase 4: Migration Scripts + +**Goal**: Automated migration for B2C and B2B customers + +1. **Write B2C migration script** + - For each customer without members, create default member (idempotent) + +17. **Write B2B/seat migration script** + - Transform seat holders to members, migrate usage data (see Migration Plan) + +### Phase 5: Customer Portal & Testing + +**Goal**: Customer portal supports member model, full test coverage + +1. **Update customer portal authentication** + - Support member-based sessions, show member context in UI (when flag enabled) + +21. **Update customer portal UI for B2B** + - Show list of members for billing managers, allow seat assignment to members + +### Phase 6: Rollout & Monitoring + +**Goal**: Gradual production rollout with monitoring + +25. **Run B2C migration script in production** + - Schema changes live, feature flag off, zero customer impact + +26. **Run B2C migration script in production** + - Create default members for all customers (batched, monitor for errors) + +27. **Enable feature flag for internal testing** + - Test on Polar's own organization first, validate no regressions + +28. **Enable feature flag globally for B2C customers** + - Monitor error rates, customer support tickets, billing accuracy + +29. **Coordinate with B2B/seat merchants** + - Send migration guide 2 weeks in advance, schedule migration windows with each merchant + +30. **Run B2B migration scripts per merchant** + - Migrate one merchant at a time, enable flag for their org, validate with merchant + +31. **Monitor & iterate** + - Track metrics: API error rates, webhook delivery, customer portal logins, support tickets + --- ## Appendices -### Appendix E: ER Diagram (Option 1: Beneficiary Model) +### Appendix A: Merchant Migration Guide (B2B/Seat-Based Products) + +**Audience**: Merchants using seat-based pricing or B2B subscriptions + +**TL;DR**: If you use seat-based pricing, you'll need to update your integration to use `member_id` instead of `customer_id` for B2B customers. B2C customers are unaffected. + +--- + +#### What is Changing? + +We're introducing a new **Member** entity to improve how Polar handles team subscriptions and usage-based billing. This change provides: + +1. **Better billing accuracy**: Clear separation between who pays (Customer) and who uses the product (Member) +2. **Multi-company support**: Users can be members of multiple companies without confusion +3. **Clearer usage attribution**: Events and benefits are always tied to the specific person who used them + +**Key architectural change**: +``` +Before: +Customer (billing manager) ─── purchases subscription with seats +Customer (seat holder 1) ─── separate customer entity +Customer (seat holder 2) ─── separate customer entity + +After: +Customer (billing manager) ─┬─ Member (billing manager) + ├─ Member (seat holder 1) + └─ Member (seat holder 2) +``` + +**For B2C customers**: Nothing changes. We automatically create a 1:1 member for each customer, and all existing API calls continue working. + +**For B2B/seat-based customers**: Seat holders become **members** of the billing customer instead of separate customer entities. + +--- + +#### Why This Change? + +**Problem we're solving**: When a user (e.g., Alice) works for multiple companies (Acme Corp and Slack Inc), and both companies purchase your product, Polar doesn't know which company to bill when Alice generates usage events. This causes billing ambiguity. This will allow other futures in the comming weeks, like better analytics and insights. + +**Solution**: The Member model ensures every action is attributed to a specific member of a specific customer, eliminating ambiguity. + +--- + +#### Breaking Changes (B2B/Seat-Based Only) + +Imagine that before we had a seat based subscription with: + +- Alice being the billing manager and a seat holder +- Ben being a seat holder + +##### 1. Event Ingestion API + +**Before** (current): +```typescript +// Seat holder's customer_id +await polar.events.ingest([{ + externalCustomerId: "cust_ben_123", + name: "api.request", + properties: { endpoint: "/themes" } +}]); +``` + +**After** (new - required): +```typescript +// Billing customer + specific member +await polar.events.create([{ + externalCustomerId: "cust_alice_123", // Who pays will be mandatory + externalMemberId: "cust_ben_123", // Who did the action + name: "api.request", + properties: { endpoint: "/themes" } +}]); +``` + +**Why**: Previously, `externalCustomerId` pointed to the seat holder (a separate customer). Now it must point to the **billing customer**, with `member_id` identifying the specific team member. + +--- + +##### 2. Customer Portal Sessions + +**Before** (current): +```typescript +// Generate portal link for seat holder +const session = await polar.customerSessions.create({ + externalCustomerId: "cust_alice_123" +}); +``` + +**After** (new - required): +```typescript +// Generate portal link for member +const session = await polar.customerSessions.create({ + externalCustomerId: "cust_alice_123", + externalMemberId: "cust_ben_123" +}); +``` + +**Why**: Portal sessions must authenticate as a specific member to show the correct subscriptions and benefits. + +--- + +#### Migration Checklist + +Use this checklist to ensure your integration is updated: + +- [ ] **Update SDK to latest version** + - TypeScript: `npm install @polar-sh/sdk@latest` + - Python: `pip install --upgrade polar-sdk` + +- [ ] **Update event ingestion code** + - [ ] Change `customer_id` from seat holder customer to billing customer + - [ ] Add `member_id` parameter for all B2B events + - [ ] Test event attribution in staging environment + +- [ ] **Update customer portal integration** + - [ ] Pass `customer_id` and `member_id` when generating portal links for B2B customers + - [ ] Ensure billing managers can still access company billing information + - [ ] Test portal access for both billing managers and team members + +- [ ] **Monitor after migration** + - [ ] Check error rates in your application logs + - [ ] Confirm billing accuracy for B2B customers + +--- + +#### How to Identify If You're Affected + +**You ARE affected if**: +- ✅ You sell products with seat-based pricing +- ✅ You use the Customer Seats API (`/v1/customer-seats`) +- ✅ You have subscriptions or orders with `seats > 0` +- ✅ You track usage events for team members + +**You ARE NOT affected if**: +- ❌ You only sell B2C products (individual subscriptions) +- ❌ You don't use seat-based pricing +- ❌ You don't use our API directly. + +--- + +#### Migration Timeline + +| Phase | What Happens | Action Required | +|-------|-------------|-----------------| +| **Week -2** | Migration announcement email sent | Review this guide, plan updates | +| **Week -1** | Staging environment updated | Test your integration in staging | +| **Week 0** | Production migration begins | Deploy updated code, monitor | +| **Week 1-2** | One-on-one migration support | Work with Polar team if issues arise | + +--- + +#### Getting Help + +**Before migration**: +- Review the [updated API documentation](#) (link to come) +- Test in staging environment (feature flag enabled for your org) +- Email support@polar.sh with questions + +**During migration**: +- Schedule 1-on-1 call if needed. + +**After migration**: +- Report issues immediately: support@polar.sh +- Check status page: status.polar.sh + + +#### Webhook Changes + +The following webhook payloads will include new `members` array and `member_id` fields: + +**`subscription.created` / `subscription.updated`**: +```json +{ + "type": "subscription.created", + "data": { + "id": "sub_123", + "customer": { + "id": "cust_acme_456", + "name": "Acme Corp", + "members": [ // NEW + { + "id": "mem_alice_789", + "email": "alice@acme.com", + "role": "owner" + } + ] + } + } +} +``` + +**`benefit_grant.created`**: +```json +{ + "type": "benefit_grant.created", + "data": { + "id": "grant_123", + "customer_id": "cust_acme_456", + "member_id": "mem_alice_789", // NEW + "benefit_id": "benefit_456" + } +} +``` + +**`customer_seat.claimed`**: +```json +{ + "type": "customer_seat.claimed", + "data": { + "id": "seat_123", + "subscription_id": "sub_456", + "customer_id": "cust_acme_789", // will point to the business customer + "member_id": "mem_alice_101", // NEW (replaces old customer_id reference) + "status": "claimed" + } +} +``` + +**Action required**: Update your webhook handlers to: +1. Extract and store `member_id` from webhooks +2. Use `member_id` for subsequent API calls +3. Handle the new `members` array in customer objects + +--- + +#### FAQ + +**Q: Will my existing customers break?** +A: No. B2C customers continue working with zero changes. B2B customers will need your updated integration deployed before we migrate their data. + +**Q: What happens if I don't migrate?** +A: Your B2B customers will experience errors when trying to access the portal, generate events, or claim benefits. B2C customers are unaffected. + +**Q: Can I test before production migration?** +A: Yes! We'll enable the feature flag in staging 2 weeks before production. Test thoroughly with seat-based subscriptions. + +**Q: How long do I have to migrate?** +A: We'll coordinate with you to schedule a migration window. Most merchants complete the update in 1-2 days. We provide 2 weeks notice minimum. + +**Q: Will historical data change?** +A: No. Past events, benefit grants, and subscriptions remain unchanged. Only new data uses the member model. + +**Q: What if Alice works for multiple companies?** +A: Perfect! That's why we built this. Alice will have separate `member_id` values for each company (e.g., `mem_alice_at_acme_123` and `mem_alice_at_slack_456`). + +**Q: Do I need to migrate B2C customers?** +A: No. B2C customers are automatically migrated with full backward compatibility. Your existing code works unchanged. + +--- + +### Appendix E: ER Diagram (Option 1: Member Model) ```mermaid @@ -217,14 +681,14 @@ erDiagram Organization ||--o{ Product : "sells" Customer ||--o{ Subscription : "owns (billing)" - Customer ||--o{ Beneficiary : "has (usage/access)" + Customer ||--o{ Member : "has (usage/access)" Customer ||--o{ Order : "owns (billing)" Customer ||--o{ PaymentMethod : "owns (billing)" - Beneficiary ||--o{ BenefitGrant : "receives benefits" - Beneficiary ||--o{ CustomerSeat : "claims seats" - Beneficiary ||--o{ CustomerSession : "authenticates" - Beneficiary ||--o{ Event : "performs actions" + Member ||--o{ BenefitGrant : "receives benefits" + Member ||--o{ CustomerSeat : "claims seats" + Member ||--o{ CustomerSession : "authenticates" + Member ||--o{ Event : "performs actions" Subscription ||--o{ CustomerSeat : "allocates" Subscription ||--o{ BenefitGrant : "provides" @@ -244,13 +708,15 @@ erDiagram jsonb user_metadata } - Beneficiary { + Member { uuid id PK uuid customer_id FK "→ Customer (who pays)" - string email "Beneficiary email (can repeat across customers)" - string name "Beneficiary name" + string email "Member email" + string name "Member name" + string external_id "External identifier" string role "owner | admin | billing_manager | member" - UNIQUE customer_id_email "Same email allowed across customers" + UNIQUE customer_id_email "(customer_id, email)" + UNIQUE customer_id_external_id "(customer_id, external_id)" } Subscription { @@ -264,14 +730,14 @@ erDiagram CustomerSeat { uuid id PK uuid subscription_id FK - uuid beneficiary_id FK "→ Beneficiary (who uses the seat)" + uuid member_id FK "→ Member (who uses the seat)" uuid customer_id FK "Denormalized for performance" string status } Event { uuid id PK - uuid beneficiary_id FK "→ Beneficiary (who did the action)" + uuid member_id FK "→ Member (who did the action)" uuid customer_id FK "Denormalized for billing queries" uuid subscription_id FK "→ Subscription (which subscription to bill)" string name @@ -280,14 +746,14 @@ erDiagram CustomerSession { uuid id PK - uuid beneficiary_id FK "→ Beneficiary (who authenticates)" + uuid member_id FK "→ Member (who authenticates)" string token datetime expires_at } BenefitGrant { uuid id PK - uuid beneficiary_id FK "→ Beneficiary (who receives benefit)" + uuid member_id FK "→ Member (who receives benefit)" uuid customer_id FK "Denormalized for performance" uuid subscription_id FK uuid benefit_id FK @@ -295,15 +761,17 @@ erDiagram ``` **Key Points:** -- **Separation of Concerns**: Customer = billing entity, Beneficiary = usage/access entity +- **Separation of Concerns**: Customer = billing entity, Member = usage/access entity - **No Polymorphism**: Customer table has no `type` discriminator, always represents billing -- **1:1 for B2C**: Individual customers have 1 default beneficiary -- **1:N for B2B**: Business customers have N beneficiaries (team members) -- **Unique Constraint**: `(customer_id, email)` on Beneficiary allows same email across different beneficiares. -- **Event Attribution**: Events always reference `beneficiary_id` (never ambiguous across business. It can be ambiguous across the same business with multiple subscriptions) +- **1:1 for B2C**: Individual customers have 1 default member +- **1:N for B2B**: Business customers have N members (team members) +- **Unique Constraints**: + - `(customer_id, email)` - Same email allowed across different customers, unique per customer + - `(customer_id, external_id)` - Same external_id allowed across different customers, unique per customer +- **Event Attribution**: Events always reference `member_id` (never ambiguous across customers. Can be ambiguous across same customer with multiple subscriptions) - **Denormalization**: `customer_id` kept in usage tables for query performance (optional) -- **Authentication**: Customer portal sessions authenticate as Beneficiary, not Customer -- **Backward Compatibility**: Service layer accepts both `customer_id` (resolves to default beneficiary) and `beneficiary_id` +- **Authentication**: Customer portal sessions authenticate as Member, not Customer +- **Backward Compatibility**: Service layer accepts both `customer_id` (resolves to default member) and `member_id` --- @@ -391,13 +859,13 @@ Merchants can then prompt the user or maintain context in their app to track whi ### Appendix H: Complete Integration Flows (Side-by-Side Comparison) -This appendix shows how merchants integrate with Polar for common workflows, comparing **Option 1 (Beneficiary Model)** vs **Option 2 (Customer Type + Member)** side-by-side. +This appendix shows how merchants integrate with Polar for common workflows, comparing **Option 1 (Member Model)** vs **Option 2 (Customer Type + Member)** side-by-side. --- #### Flow 1: Create B2C Customer -##### Option 1: Beneficiary Model +##### Option 1: Member Model ```http POST https://api.polar.sh/v1/customers @@ -420,7 +888,7 @@ Content-Type: application/json "name": "Alice Smith", "external_id": "alice_001", "organization_id": "org_abc123", - "beneficiaries": [ + "members": [ { "id": "ben_alice_456", "email": "alice@example.com", @@ -433,8 +901,8 @@ Content-Type: application/json **What Happens:** - Creates 1 Customer (billing entity) -- Auto-creates 1 Beneficiary (usage entity) with same email -- Beneficiary linked to customer +- Auto-creates 1 Member (usage entity) with same email +- Member linked to customer --- @@ -473,14 +941,14 @@ Content-Type: application/json --- **Key Difference:** -- **Option 1**: Always creates Customer + Beneficiary (uniform 1:N model) +- **Option 1**: Always creates Customer + Member (uniform 1:N model) - **Option 2**: Creates only Customer, type discriminates individual vs business --- #### Flow 2: Create B2B Customer with Billing Manager -##### Option 1: Beneficiary Model +##### Option 1: Member Model ```http # Step 1: Create business customer @@ -491,7 +959,7 @@ Authorization: Bearer polar_secret_... "name": "Acme Corp", "external_id": "acme_001", "organization_id": "org_abc123", - owner: { + owner: { // optional email: 'alice@example.com', name: 'Alice Smith', externalId: 'external_alice' @@ -505,7 +973,7 @@ Authorization: Bearer polar_secret_... "id": "cust_acme_123", "name": "Acme Corp", "email": null, - "beneficiaries": [ + "members": [ { "id": "ben_alice_456", "email": "alice@example.com", @@ -517,8 +985,8 @@ Authorization: Bearer polar_secret_... ``` ```http -# Step 2: Add billing manager as beneficiary -POST https://api.polar.sh/v1/customers/cust_acme_123/beneficiaries +# Step 2: Add billing manager as member +POST https://api.polar.sh/v1/customers/cust_acme_123/members Authorization: Bearer polar_secret_... { @@ -602,7 +1070,7 @@ Authorization: Bearer polar_secret_... **Scenario**: Alice purchases a $49 theme (one-time payment) using checkout sessions. -##### Option 1: Beneficiary Model +##### Option 1: Member Model ```http # Step 1: Create checkout session @@ -630,7 +1098,7 @@ Authorization: Bearer polar_secret_... ```http # Step 2: Customer completes checkout (Polar hosted) -# → Polar auto-creates Customer + Beneficiary +# → Polar auto-creates Customer + Member # Step 3: Webhook received ``` @@ -644,7 +1112,7 @@ Authorization: Bearer polar_secret_... "customer": { "id": "cust_alice_456", "email": "alice@example.com", - "beneficiaries": [ + "members": [ { "id": "ben_alice_789", "email": "alice@example.com", @@ -659,7 +1127,7 @@ Authorization: Bearer polar_secret_... ``` ```http -# Step 4: Grant benefit to beneficiary +# Step 4: Grant benefit to member # Polar automatically creates BenefitGrant linked to ben_alice_789 ``` @@ -723,7 +1191,7 @@ Authorization: Bearer polar_secret_... --- **Key Difference:** -- **Option 1**: Benefit grant links to `beneficiary_id` +- **Option 1**: Benefit grant links to `member_id` - **Option 2**: Benefit grant links to `customer_id` (individual customer) --- @@ -732,7 +1200,7 @@ Authorization: Bearer polar_secret_... **Scenario**: Acme Corp purchases a 4-seat subscription ($199/month) for their team. -##### Option 1: Beneficiary Model +##### Option 1: Member Model ```http # Step 1: Create business customer @@ -772,7 +1240,7 @@ Authorization: Bearer polar_secret_... ```http # Step 3: Billing manager completes checkout -# → Polar auto-creates beneficiary for billing@acme.com +# → Polar auto-creates member for billing@acme.com ``` Step 4: Webhook received @@ -785,9 +1253,9 @@ Step 4: Webhook received "customer": { "id": "cust_acme_123", "name": "Acme Corp", - "beneficiaries": [ + "members": [ { - "id": "ben_billing_789", // beneficiary created before or during the checkout + "id": "ben_billing_789", // member created before or during the checkout "email": "billing@acme.com", "role": "admin" } @@ -806,7 +1274,7 @@ Step 4: Webhook received POST https://api.polar.sh/v1/subscriptions/sub_acme_456/seats Authorization: Bearer polar_customer_session_... // ben_billing_789 session -{ "beneficiary_email": "alive@acme.com" } +{ "member_email": "alive@acme.com" } ``` **Response:** @@ -814,7 +1282,7 @@ Authorization: Bearer polar_customer_session_... // ben_billing_789 session { "id": "seat_001", "subscription_id": "sub_acme_456", - "beneficiary_id": "ben_alice_101", + "member_id": "ben_alice_101", "status": "active" } ``` @@ -907,7 +1375,7 @@ Authorization: Bearer polar_customer_session_... // cust_acme_456 session --- **Key Difference:** -- **Option 1**: Seats link to `beneficiary_id` +- **Option 1**: Seats link to `member_id` - **Option 2**: Seats link to `customer_id` (individual customer) --- @@ -916,14 +1384,14 @@ Authorization: Bearer polar_customer_session_... // cust_acme_456 session **Scenario**: Public checkout link for team subscription - auto-creates business on checkout. -##### Option 1: Beneficiary Model +##### Option 1: Member Model ```http # Merchant shares public checkout link https://buy.polar.sh/polar_cl_gon2ISU4r7oYyjVaxXlRVUv5MKh1H3c1gCxKn1zZGHA # Customer clicks, enters email, completes purchase -# → Polar auto-creates Customer + Beneficiary +# → Polar auto-creates Customer + Member # Webhook received ``` @@ -937,7 +1405,7 @@ https://buy.polar.sh/polar_cl_gon2ISU4r7oYyjVaxXlRVUv5MKh1H3c1gCxKn1zZGHA "customer": { "id": "cust_auto_123", "email": "buyer@company.com", - "beneficiaries": [ + "members": [ { "id": "ben_buyer_456", "email": "buyer@company.com", @@ -1010,11 +1478,11 @@ https://polar.sh/org_abc/products/prod_team_plan **Scenario**: Bob (individual customer) access to a downloadable benefit. -###### Option 1: Beneficiary Model +###### Option 1: Member Model ```http # Bob authenticates via Customer Portal -# Session identifies him by customer_id (B2C backward compatibility as 1 beneficiary == 1 customer in B2C) +# Session identifies him by customer_id (B2C backward compatibility as 1 member == 1 customer in B2C) # B2C: Using customer_id (backward compatible) GET https://api.polar.sh/v1/customer-portal/downloadables @@ -1028,7 +1496,7 @@ Authorization: Bearer polar_customer_session_... "items": [ { "id": "grant_001", - "beneficiary_id": "ben_bob_default_456", // Auto-created default beneficiary + "member_id": "ben_bob_default_456", // Auto-created default member "customer_id": "cust_bob_123", // Same as billing customer "file": { "id": "benefit_theme_download", @@ -1040,9 +1508,9 @@ Authorization: Bearer polar_customer_session_... ``` **How B2C Compatibility Works:** -- Customer session endpoints accepts both `customer_id` (legacy) and `beneficiary_id` (new) -- B2C customers have auto-created "default" beneficiary (1:1 mapping) -- `customer_id=cust_bob_123` internally resolves to `beneficiary_id=ben_bob_default_456` +- Customer session endpoints accepts both `customer_id` (legacy) and `member_id` (new) +- B2C customers have auto-created "default" member (1:1 mapping) +- `customer_id=cust_bob_123` internally resolves to `member_id=ben_bob_default_456` --- @@ -1052,14 +1520,14 @@ Authorization: Bearer polar_customer_session_... **Scenario**: Alice (team member at ACME) claims access to a downloadable benefit. -###### Option 1: Beneficiary Model +###### Option 1: Member Model ```http # Alice authenticates via Customer Portal -# Session identifies her as ben_alice_acme_123 (B2B: explicit beneficiary) +# Session identifies her as ben_alice_acme_123 (B2B: explicit member) -GET https://api.polar.sh/v1/benefit-grants?beneficiary_id=ben_alice_acme_123 -Authorization: Bearer polar_beneficiary_session_... +GET https://api.polar.sh/v1/benefit-grants?member_id=ben_alice_acme_123 +Authorization: Bearer polar_member_session_... ``` **Response:** @@ -1068,7 +1536,7 @@ Authorization: Bearer polar_beneficiary_session_... "data": [ { "id": "grant_002", - "beneficiary_id": "ben_alice_acme_123", + "member_id": "ben_alice_acme_123", "customer_id": "cust_acme_456", // ACME Inc (business) paid for it "benefit": { "id": "benefit_theme_download", @@ -1082,9 +1550,9 @@ Authorization: Bearer polar_beneficiary_session_... ``` **Key B2B Difference:** -- ❌ Cannot use `customer_id` query param for B2B (ambiguous - which beneficiary?) -- ✅ Must use `beneficiary_id` to identify which team member -- ✅ Authentication token contains beneficiary context +- ❌ Cannot use `customer_id` query param for B2B (ambiguous - which member?) +- ✅ Must use `member_id` to identify which team member +- ✅ Authentication token contains member context --- @@ -1123,8 +1591,8 @@ Authorization: Bearer polar_customer_session_... --- **Key Differences:** -- **Option 1 (B2C)**: `customer_id` backward compatible, internally maps to default beneficiary -- **Option 1 (B2B)**: ⚠️ Uses `beneficiary_id` for explicit team member identification +- **Option 1 (B2C)**: `customer_id` backward compatible, internally maps to default member +- **Option 1 (B2B)**: ⚠️ Uses `member_id` for explicit team member identification - **Option 2**: Always uses `customer_id` (individual customer for both B2C and B2B members) --- @@ -1135,7 +1603,7 @@ Authorization: Bearer polar_customer_session_... **Scenario**: Bob (individual customer) needs to access Customer Portal to manage his subscription. -###### Option 1: Beneficiary Model +###### Option 1: Member Model ```http # Option A: Using customer_id (B2C backward compatible) @@ -1146,12 +1614,12 @@ Authorization: Bearer polar_secret_... "customer_id": "cust_bob_123" // ✅ B2C backward compatible } -# Option B: Using beneficiary_id (new explicit way) +# Option B: Using member_id (new explicit way) POST https://api.polar.sh/v1/customer-sessions Authorization: Bearer polar_secret_... { - "beneficiary_id": "ben_bob_default_456" // ✅ New way + "member_id": "ben_bob_default_456" // ✅ New way } ``` @@ -1160,20 +1628,20 @@ Authorization: Bearer polar_secret_... { "token": "polar_cst_abc123xyz", "customer_id": "cust_bob_123", - "beneficiary_id": "ben_bob_default_456", // Auto-resolved from customer + "member_id": "ben_bob_default_456", // Auto-resolved from customer "return_url": "https://polar.sh/org_merchant/portal?token=polar_cst_abc123xyz", "expires_at": "2025-12-01T10:00:00Z" } ``` **How B2C Compatibility Works:** -- ✅ Endpoint accepts both `customer_id` (legacy) and `beneficiary_id` (new) -- ✅ If `customer_id` provided → resolve to default beneficiary → create session -- ✅ If `beneficiary_id` provided → create session directly +- ✅ Endpoint accepts both `customer_id` (legacy) and `member_id` (new) +- ✅ If `customer_id` provided → resolve to default member → create session +- ✅ If `member_id` provided → create session directly **Portal shows:** - Bob's subscriptions (from customer_id: cust_bob_123) -- Bob's benefit grants (from beneficiary_id: ben_bob_default_456) +- Bob's benefit grants (from member_id: ben_bob_default_456) - Bob's orders and invoices --- @@ -1211,19 +1679,19 @@ Authorization: Bearer polar_secret_... **Scenario**: Jane (billing manager at Acme) needs to see company billing info. -###### Option 1: Beneficiary Model +###### Option 1: Member Model > ⚠️ This contains a breaking change with seats functionality -We can only use the beneficiary id. The customerId has multiple benefficieres and we don't know which one to pick. +We can only use the member id. The customerId has multiple benefficieres and we don't know which one to pick. ```http POST https://api.polar.sh/v1/customer-sessions Authorization: Bearer polar_secret_... { - "beneficiary_id": "ben_jane_789" // Jane's beneficiary at Acme + "member_id": "ben_jane_789" // Jane's member at Acme } ``` @@ -1231,18 +1699,18 @@ Authorization: Bearer polar_secret_... ```json { "token": "polar_cst_def456", - "beneficiary_id": "ben_jane_789", + "member_id": "ben_jane_789", "customer_id": "cust_acme_123", "url": "https://polar.sh/portal?token=polar_cst_def456", "expires_at": "2025-12-01T10:00:00Z" } ``` -**Portal shows (role-based on beneficiary.role = "billing_manager"):** +**Portal shows (role-based on member.role = "billing_manager"):** - Acme's subscriptions (from customer_id) -- All beneficiaries (list all team members) +- All members (list all team members) - Company orders and invoices -- Aggregated usage across all beneficiaries +- Aggregated usage across all members --- @@ -1281,7 +1749,7 @@ Authorization: Bearer polar_secret_... --- **Key Difference:** -- **Option 1**: ⚠️ Single `beneficiary_id` gives access to both personal and business context. Not compatible with B2B. +- **Option 1**: ⚠️ Single `member_id` gives access to both personal and business context. Not compatible with B2B. - **Option 2**: `customer_id` requires lookup via Member table to find business context. --- @@ -1292,7 +1760,7 @@ Authorization: Bearer polar_secret_... **Scenario**: Bob (individual customer) makes an API call - track usage. -###### Option 1: Beneficiary Model +###### Option 1: Member Model ```http # Option A: Using customer_id (B2C backward compatible) @@ -1308,12 +1776,12 @@ Authorization: Bearer polar_secret_... } } -# Option B: Using beneficiary_id (new explicit way) +# Option B: Using member_id (new explicit way) POST https://api.polar.sh/v1/events Authorization: Bearer polar_secret_... { - "beneficiary_id": "ben_bob_default_456", // ✅ New way + "member_id": "ben_bob_default_456", // ✅ New way "name": "api.request", "properties": { "endpoint": "/v1/themes", @@ -1326,9 +1794,9 @@ Authorization: Bearer polar_secret_... 201 **How B2C Compatibility Works:** -- Endpoint accepts both `customer_id` (legacy) and `beneficiary_id` (new) -- If `customer_id` provided → resolve to default beneficiary → record event -- If `beneficiary_id` provided → record event directly +- Endpoint accepts both `customer_id` (legacy) and `member_id` (new) +- If `customer_id` provided → resolve to default member → record event +- If `member_id` provided → record event directly --- @@ -1359,7 +1827,7 @@ Authorization: Bearer polar_secret_... **Scenario**: Alice (team member at Acme) makes an API call - bill Acme's subscription. -###### Option 1: Beneficiary Model +###### Option 1: Member Model ```http POST https://api.polar.sh/v1/events @@ -1367,7 +1835,7 @@ Authorization: Bearer polar_secret_... { "customer_id": "cust_acme_123", // the business customer of the subscription, who to bill. ⚠️ This breaks current seats usage as merchants will need to pass a new customer ID or use the new benefficiaryId - "beneficiary_id": "ben_alice_acme_123", // Optional: Alice's beneficiary at Acme for granular usage + "member_id": "ben_alice_acme_123", // Optional: Alice's member at Acme for granular usage "name": "api.request", "properties": { "endpoint": "/v1/themes", @@ -1446,9 +1914,9 @@ In both cases work as expected. ##### Flow 10.b: B2B login -###### Option 1: Beneficiary Model +###### Option 1: Member Model -Beneficiary emails are not unique across per organization, they are unique per Customer. This means that a single email address can be associated with multiple beneficiaries within the same organization. +Member emails are not unique across per organization, they are unique per Customer. This means that a single email address can be associated with multiple members within the same organization. These are different entities that can be associated with the same email address. @@ -1460,7 +1928,7 @@ Every customer has a unique email address. This means that a single email addres #### Summary: API Call Comparison -| Flow | Option 1: Beneficiary | Option 2: Customer Type + Member | +| Flow | Option 1: Member | Option 2: Customer Type + Member | | ---- | -------------------- | -------------------------------- | | 1. Create B2C | Easy | Easy | | 2. Create B2B + Manager | Easy | Easy | From 5f63441b1e7b1ae7e7a0651f8b573d1bbb9e287e Mon Sep 17 00:00:00 2001 From: Petru Rares Sincraian Date: Mon, 17 Nov 2025 18:14:30 +0100 Subject: [PATCH 14/15] feat: add customer example --- .../design-documents/business-entity.mdx | 70 +++++++++++++++++-- 1 file changed, 63 insertions(+), 7 deletions(-) diff --git a/engineering/design-documents/business-entity.mdx b/engineering/design-documents/business-entity.mdx index 07a1c26..529207d 100644 --- a/engineering/design-documents/business-entity.mdx +++ b/engineering/design-documents/business-entity.mdx @@ -473,6 +473,35 @@ Imagine that before we had a seat based subscription with: - Alice being the billing manager and a seat holder - Ben being a seat holder + +##### 1. Customer API + Member API + +Given the example above, we will have the following schema on our database: + +1. Customer Alice: + 1. Member Alice (owner) + 2. Member Bob (member) +2. Customer Bob (new customer with no subscriptions and nothing attached to him): + 1. Member Bob (owner) + +**Before** (current): +```typescript +// Seat holder's customer_id +await polar.customers.get([{ + externalCustomerId: "cust_ben_123", +}]); +``` + +**After** (new - required): +```typescript +await polar.members.get([{ + externalCustomerId: "cust_alice_123", // The customer who pays + externalMemberId: "cust_ben_123", // The member of the business +}]); +``` + +**Why**: Previously, `externalCustomerId` pointed to Ben, a seat holder (a separate customer). Now you should use the members to get the data of Ben. + ##### 1. Event Ingestion API **Before** (current): @@ -523,6 +552,38 @@ const session = await polar.customerSessions.create({ --- +##### 3. Benefit grants + +**Before** (current): +```typescript +const session = await polar.benefits.grants({ + externalCustomerId: "cust_ben_123" +}); +``` + +**After** (new - required): +```typescript +const session = await polar.benefits.grants({ + externalCustomerId: "cust_alice_123", + externalMemberId: "cust_ben_123" +}); +``` + +##### 3. Other endpoints + +All the other endpoints where a customerId/externalCustomerId are you passing you will need to: + +1. Make sure that the customerId or externalCustomerId points to the customerId/externalCustomerId of the billing manager +2. Pass the `externalMemberId` alongside `externalCustomerId` + +Verify the following endpoints: + +1. GET: `/v1/customers`. +2. GET: `/v1/customers/{customerId}`. +2. PATCH: `/v1/customers/{customerId}`. +2. DELETE: `/v1/customers/{customerId}` +... TODO + #### Migration Checklist Use this checklist to ensure your integration is updated: @@ -531,17 +592,12 @@ Use this checklist to ensure your integration is updated: - TypeScript: `npm install @polar-sh/sdk@latest` - Python: `pip install --upgrade polar-sdk` -- [ ] **Update event ingestion code** +- [ ] **Update code** - [ ] Change `customer_id` from seat holder customer to billing customer - [ ] Add `member_id` parameter for all B2B events - - [ ] Test event attribution in staging environment - -- [ ] **Update customer portal integration** - - [ ] Pass `customer_id` and `member_id` when generating portal links for B2B customers - - [ ] Ensure billing managers can still access company billing information - - [ ] Test portal access for both billing managers and team members - [ ] **Monitor after migration** + - [ ] Test that you can log inside customer portal - [ ] Check error rates in your application logs - [ ] Confirm billing accuracy for B2B customers From 151e4bd3e15da9e404208456e01bddea77e8a6f9 Mon Sep 17 00:00:00 2001 From: Petru Rares Sincraian Date: Tue, 18 Nov 2025 12:11:20 +0100 Subject: [PATCH 15/15] feat: update implementation plan --- .../design-documents/business-entity.mdx | 123 +++++++----------- 1 file changed, 45 insertions(+), 78 deletions(-) diff --git a/engineering/design-documents/business-entity.mdx b/engineering/design-documents/business-entity.mdx index 529207d..549897e 100644 --- a/engineering/design-documents/business-entity.mdx +++ b/engineering/design-documents/business-entity.mdx @@ -327,97 +327,44 @@ During the migration period, if a bug is found or a merchant complaint, disable - Add table: `members(id, customer_id FK, email, name, external_id, role, created_at, updated_at)` - Indexes: `customer_id`, `(customer_id, email)` UNIQUE, `(customer_id, external_id)` UNIQUE -2. **Add member_id columns to usage tables** - - `benefit_grants.member_id`, `events.member_id`, `customer_sessions.member_id`, `customer_seats.member_id` (all nullable initially) - 3. **Add feature flag infrastructure** - Organization-level feature flag: `member_model_enabled` (default: false) +### Phase 1: Customer +**Goal**: Customer onboarding should create members. Expose members to customers API. -### Phase 2: Service Layer - -**Goal**: Backend supports both models simultaneously via feature flag - -1. **Implement Member service & repository** - - CRUD operations: create, get, list, update members for a customer - -6. **Update Customer service for auto-member creation** - - When creating B2C customer, auto-create default member (if flag enabled) - -7. **Add member resolution helper** - - `resolve_member(customer_id) → member_id` for backward compatibility - -8. **Update BenefitGrant service** - - Support both `customer_id` (resolve to member) and `member_id` direct assignment - -9. **Update Event service** - - Accept both `customer_id` and `member_id`, prefer member when provided - -10. **Update CustomerSession service** - - Support authentication via member_id, maintain customer_id backward compat - -11. **Update CustomerSeat service** - - Link seats to members instead of customers (when flag enabled) - -### Phase 3: API & Webhooks - -**Goal**: API accepts new parameters, webhooks include member data - -1. **Update API schemas (Pydantic)** - - Add `member_id` optional field to event creation, customer session creation, benefit grant queries - -13. **Update API endpoints** - - `/v1/events` accepts `member_id`, `/v1/customer-sessions` accepts `member_id`, `/v1/customers/{id}/members` CRUD endpoints - -14. **Update webhook payloads** - - Include `members` array in customer object, include `member_id` in event/grant webhooks - +### Phase 2: Benefits +**Goal**: benefits should be granted to members. -### Phase 4: Migration Scripts +### Phase 3: Member Management +**Goal**: Member management should support CRUD operations. -**Goal**: Automated migration for B2C and B2B customers +### Phase 4: Customer Portal +**Goal**: Member should be able to authenticate with customer portal and see their benefits. Billing managers and owners should be able to manage members. -1. **Write B2C migration script** - - For each customer without members, create default member (idempotent) +### Phase 5: Dashboard +**Goal**: The dashboard should display member-related information. Benefits should point to members. Members should be shown for B2B customers only. -17. **Write B2B/seat migration script** - - Transform seat holders to members, migrate usage data (see Migration Plan) +### Phase 6: Event ingestion +**Goal**: Event ingestion should support members. CustomerMeter should be the aggregation of all member events and a new MemberEventMeter should be created. -### Phase 5: Customer Portal & Testing +### Phase 7: Seats +**Goal**: Seats should point to members. -**Goal**: Customer portal supports member model, full test coverage +### Phase 8: Webhooks +**Goal**: Webhooks should include member information. Create webhooks for member events. -1. **Update customer portal authentication** - - Support member-based sessions, show member context in UI (when flag enabled) +### Phase 9: Adapters +**Goal**: Adapters should work out of the box with the member model. -21. **Update customer portal UI for B2B** - - Show list of members for billing managers, allow seat assignment to members +### Phase 10: Mobile app +**Goal**: Mobile app should work out of the box with the member model. -### Phase 6: Rollout & Monitoring +### Phase 11: Documentation +**Goal**: Document member model and migration process. -**Goal**: Gradual production rollout with monitoring - -25. **Run B2C migration script in production** - - Schema changes live, feature flag off, zero customer impact - -26. **Run B2C migration script in production** - - Create default members for all customers (batched, monitor for errors) - -27. **Enable feature flag for internal testing** - - Test on Polar's own organization first, validate no regressions - -28. **Enable feature flag globally for B2C customers** - - Monitor error rates, customer support tickets, billing accuracy - -29. **Coordinate with B2B/seat merchants** - - Send migration guide 2 weeks in advance, schedule migration windows with each merchant - -30. **Run B2B migration scripts per merchant** - - Migrate one merchant at a time, enable flag for their org, validate with merchant - -31. **Monitor & iterate** - - Track metrics: API error rates, webhook delivery, customer portal logins, support tickets - +### Phase 12: Rollout +**Goal**: Rollout member model to production for B2C and B2B customers. Migrate existing B2B customers to member model. --- @@ -578,11 +525,31 @@ All the other endpoints where a customerId/externalCustomerId are you passing yo Verify the following endpoints: +**Customer endpoints** +The will work the same for billing managers, but members will not be there. + 1. GET: `/v1/customers`. 2. GET: `/v1/customers/{customerId}`. 2. PATCH: `/v1/customers/{customerId}`. 2. DELETE: `/v1/customers/{customerId}` -... TODO +2. GET: `/v1/customers/external/{externalId}`. +2. PATCH: `/v1/customers/external/{externalId}`. +2. DELETE: `/v1/customers/external/{externalId}` + +**Customer Meters** + +The customer meter will point to the billing manager, so it will be the meter aggregated as a business. + +We will create a new endpoint to get the meters for members. + +**Seats** + +The response of seats will contain the `member` entity. `customer_id` will be removed. + +1. POST: `/v1/seats`: we will need to pass the `mail`, `externalMemberId`, `memberId`, along with `subscriptionId` +2. GET: `/v1/seats/{seatId}` +2. PATCH: `/v1/seats/{seatId}`. +2. DELETE: `/v1/seats/{seatId}` #### Migration Checklist