Skip to main content
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.

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:
# 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:
# 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
OptionWeightOption 1: Business + BusinessCustomerOption 6: Synthetic BusinessOption 7: Member/Beneficiary
Billing Accuracy7
Backward compatibility6🟡🟡🟡
Customer experience5
Merchant dev experience4🟡🟡
Operational flexibility3🟡
Performance2
Polar dev experience1🟡🟡
Score-131115
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:
  • 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.
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).
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.
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.
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

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.
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.
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

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.
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.
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.
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:
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)
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.
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.
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):
# 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:
AspectOption 6 (Business Above)Option 7 (Member Below)
Non-seat-basedBusiness(type=“individual”) wrapperNo changes (current architecture)
Database changesAdd business_id to 5+ tablesAdd member table only
API translationRequired for all endpointsNot required
Performance+2 joins on every query0 joins for non-seat-based
MigrationMigrate ALL subscriptionsMigrate seat-based only
SemanticsCustomer = user, Business = billingCustomer = billing, Member = user
Backward compat🟡 With translation layer🟡 Without translation layer
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)

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):
# 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:
# 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:
# 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

AspectOption 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