← All docs

Seat Management — Feature Specification

This document specifies per-seat billing for workspaces: how plan seat bounds are defined in Stripe, how purchased seats are chosen and changed, how usage is calculated from members and invitations, and how the UI and APIs enforce capacity end to end.

Billing remains workspace-scoped. There is no per-user subscription. A workspace either has no paid entitlement (free / ungated inviting) or an active/trialing subscription with an explicit purchased seat quantity.


1. Goals and non-goals

Goals

  1. Each paid plan defines a purchasable seat range (min_seatsmax_seats) in Stripe product metadata.
  2. Admins choose how many seats to buy at checkout and when changing plans.
  3. Admins can increase or decrease purchased seats mid-cycle on the current plan (with Stripe proration).
  4. Seat usage equals active members plus non-expired pending invitations; invites cannot exceed purchased capacity.
  5. Plans that cannot fit the workspace’s current usage (seats_used > plan.max_seats) are not selectable in the catalog.
  6. Free workspaces (no active entitlement) are not seat-gated for invites.

Non-goals

  • Soft seat overages / pay-as-you-go beyond max_seats.
  • Automatic seat increases when inviting (must purchase seats explicitly).
  • Seat assignment to named users (seats are a capacity counter, not labeled chairs).
  • Per-role seat pricing or different seat SKUs within one plan.
  • Enforcing seat limits on existing members after a mid-cycle decrease below usage (decrease below usage is rejected instead).

2. Core concepts

TermMeaning
Plan seat boundsmin_seats / max_seats on the catalog product (from Stripe metadata). Bounds of what may be purchased on that plan.
Purchased seatsSubscriptionRecord.quantity — the Stripe subscription item quantity currently billed.
Member countNumber of WorkspaceMembership rows for the workspace.
Pending invitesInvitations with accepted_at IS NULL and expires_at > now. Expired invites do not consume seats.
Seats usedmember_count + pending_invites. This is the reserved + occupied capacity.
Seat snapshotAPI object exposing usage, purchased seats, and current plan bounds for the UI.
Free workspaceNo active entitlement (and/or no active/trialing subscription). Invite seat checks no-op.

Mental model


3. Actors and permissions

CapabilityPermission(s)UI / API effect
View billing / seat usagebilling.view_billingSee current seats on billing; Members tab can load seat snapshot for invite copy
Subscribe / change planbilling.manage_billingPlan CTAs, checkout, plan-change preview/apply
Manage seats mid-cycleBoth billing.manage_billing and workspaces.manage_members“Manage seats”, /seats/, /seats-preview/
Invite membersworkspaces.manage_membersInvite form; server still enforces seat capacity
View invoicesbilling.view_and_download_invoicesInvoice list / upcoming invoice (orthogonal to seats)

Permission label for billing management should read as covering seats (e.g. “Can manage subscription and seats”). Member management should cover invites (e.g. “Can invite and remove members”).


4. Stripe catalog configuration

Stripe is the source of truth for plan definitions. Django syncs products/prices on server startup (and via admin/manual sync). See also stripe-catalog.md.

4.1 Required product metadata

KeyRequiredRulesPurpose
codeYesUnique uppercase identifier (e.g. PRO)Stable plan id
tierYesPositive integer as stringOrdering / ranking
min_seatsYesPositive integer ≥ 1Minimum purchasable quantity
max_seatsYesPositive integer ≥ min_seatsMaximum purchasable quantity
featuresYesJSON array of entitlement codes (e.g. ["manage_roles"])Machine-readable entitlements copied to the workspace on subscribe
plan_descriptionRecommendedJSON map of locale → bullet arraysLocalized “What’s included” copy on plan cards

Missing or invalid min_seats / max_seats fails catalog sync (ValueError). Sync must not silently default seat bounds. Do not put marketing bullets in features — use plan_description for that.

Plancodetiermin_seatsmax_seats
BasicBASIC1015
ProPRO20515
EnterpriseENTERPRISE3010100

4.3 Prices

  • Prices are per-seat unit prices (monthly and yearly recurring).
  • Prices do not carry seat metadata; quantity lives on the subscription item.
  • Internal price codes: {PRODUCT_CODE}_{INTERVAL}_{CURRENCY} (e.g. PRO_MONTH_EUR).

4.4 Sync mapping


5. Data model

5.1 Billing

ModelFieldDescription
CatalogProductmin_seatsPlan minimum purchasable seats
CatalogProductmax_seatsPlan maximum purchasable seats
SubscriptionRecordquantityPurchased seats (default 1)
SubscriptionRecordstripe_subscription_item_idItem id required for quantity updates
SubscriptionRecordlast_event_atWebhook ordering guard so stale events cannot overwrite newer quantity

Entitlement remains product-linked for features and for exposing plan min_seats / max_seats in the seat snapshot. Purchased count is not stored on entitlement; it comes from the subscription record.

5.2 Workspaces

No seat columns on workspace models. Usage is derived:

seats_used = count(memberships) + count(pending non-expired invitations)

6. Seat snapshot

Every billing data payload for the current workspace includes a seat snapshot (under me.seats).

Shape

{
  "member_count": 3,
  "pending_invites": 1,
  "seats_used": 4,
  "purchased_seats": 5,
  "min_seats": 5,
  "max_seats": 15
}
FieldSource
member_countMemberships
pending_invitesPending, non-expired invitations
seats_usedSum of the two above
purchased_seatsActive/trialing subscription quantity, or null
min_seats / max_seatsActive entitlement’s product bounds, or null if no active entitlement

When purchased / bounds are null

  • No active/trialing subscription → purchased_seats: null → invite gating skipped.
  • No active entitlement product → plan bounds null in snapshot; catalog products still expose their own bounds for plan cards.

7. Quantity validation rules

Central rule for subscribe, plan change, and manage seats:

minimum_quantity = max(product.min_seats, seats_used)
allowed:  minimum_quantity ≤ quantity ≤ product.max_seats

Error codes (billing APIs)

HTTP 400 with:

{
  "error": {
    "code": "seats_below_usage | seats_out_of_plan_range",
    "message": "...",
    "seats_used": 4,
    "member_count": 3,
    "pending_invites": 1,
    "min_seats": 5,
    "max_seats": 15,
    "minimum_quantity": 5,
    "requested_quantity": 3
  }
}
CodeWhenMessage (intent)
seats_below_usagequantity < max(min_seats, seats_used)Purchased seats cannot be below current seat usage / plan floor
seats_out_of_plan_rangequantity > max_seatsPurchased seats exceed the plan maximum

Note: requesting below plan min_seats even when usage is lower still uses seats_below_usage (same code; minimum_quantity clarifies the floor).

Invite-specific codes

Workspace invite create/accept use a flatter error shape:

{
  "code": "seat_limit_reached",
  "detail": "...",
  "seats_used": 5,
  "member_count": 4,
  "pending_invites": 1,
  "purchased_seats": 5,
  "max_seats": 15
}
GateConditionWhy
Create inviteseats_used >= purchased_seatsNeed a free slot; pending invite will occupy one
Accept inviteseats_used > purchased_seatsAccepting converts the reserved pending invite into a member; equality is OK

8. API surface

Base path: /api/v1/billing/ (workspace context from auth / workspace header as elsewhere).

8.1 Read — GET /data/

Returns catalog (each product includes min_seats / max_seats), current subscription (includes quantity), and me.seats snapshot.

8.2 Subscribe — POST /subscribe/

Permission: billing.manage_billing

{ "price_id": 12, "quantity": 5 }

Behavior:

  1. Resolve target product from price_id.
  2. validate_seat_quantity against that product.
  3. Create Stripe subscription with items=[{ price, quantity }], payment_behavior=default_incomplete.
  4. Incomplete subscription reuse: same price and same quantity → return existing client secret; different price/quantity → cancel incomplete and create new.

Success: { subscription_id, client_secret, status }.

8.3 Plan change — POST /plan-change/ and preview

Permission: billing.manage_billing

{ "price_id": 15, "quantity": 8 }
  • quantity optional; defaults to current subscription quantity.
  • Validates against the new product’s bounds.
  • Blocked if subscription is scheduled to cancel (cancel_at_period_end).
  • Stripe: update_subscription_price with proration_behavior=always_invoice.
  • Preview endpoint: POST /plan-change-preview/ (alias preview-upgrade/) returns proration summary.

Preview response fields:

{
  "amount_now": "12.34",
  "amount_next": "80.00",
  "currency": "eur",
  "next_billing_date": "...",
  "next_cycle_end": "..."
}

8.4 Manage seats — POST /seats/ and POST /seats-preview/

Permission: billing.manage_billing AND workspaces.manage_members

{ "quantity": 7 }

Guards (before quantity validation):

GuardError
No subscription with status=active“No active subscription found”
cancel_at_period_end“Cannot change seats when subscription is scheduled to be canceled”
Missing stripe_subscription_item_id“Active subscription is missing subscription item id”
Missing catalog product on price“Active subscription is missing a catalog product”

Status nuance: seat snapshot / invite gating treat active and trialing as purchased. Mid-cycle seat change endpoints require status=active only (trialing workspaces cannot use Manage seats until active).

Behavior:

  1. Same quantity → noop (preview returns zero amounts; update returns current snapshot).
  2. Else validate quantity against current plan product.
  3. Preview: Stripe invoice preview with create_prorations (amount display only).
  4. Update: Stripe quantity modify with always_invoice (same as plan changes — upgrades invoice immediately; downgrade credits apply to the customer balance / future invoices), then persist SubscriptionRecord.quantity locally and return updated snapshot.

Success (POST /seats/):

{
  "quantity": 7,
  "seats": { "...snapshot..." }
}

8.5 Invitation APIs (workspace)

ActionSeat check
Create invitationensure_invite_seat_available inside transaction.atomic + workspace row lock
Accept / redeem invitationensure_invite_acceptance_allowed when adding a new member (already-member path skips)

Concurrent last-seat races are serialized with select_for_update on the workspace row.


9. Stripe integration details

9.1 Create subscription

stripe.Subscription.create(
  items=[{ price, quantity }],
  payment_behavior="default_incomplete",
  ...
)

9.2 Proration policy

OperationStripe proration_behavior
Seat quantity change (same plan)always_invoice on modify; preview uses create_prorations for display
Plan / price changealways_invoice on modify; preview uses create_prorations for display

9.3 Webhooks

No seat-specific event types. Quantity is synced from subscription item on:

  • customer.subscription.created
  • customer.subscription.updated
  • customer.subscription.deleted

Rules:

  1. Persist quantity from the first subscription item (default 1 if missing).
  2. If webhook event_created is older than SubscriptionRecord.last_event_at, ignore the event (protects against out-of-order quantity regressions).
  3. Entitlement active when subscription status is active or trialing.

10. Invitation system integration

Invitations reserve seats. A pending invite is capacity until it expires or is accepted.

10.1 Create invite

  1. Permission check (manage_members).
  2. In a transaction: lock workspace → ensure_invite_seat_available → upsert invitation.
  3. If seats_used >= purchased_seats400 seat_limit_reached.
  4. Free / no entitlement → skip seat check; unlimited invites.

10.2 Accept invite

  1. If invitee is already a member → accept path without seat check.
  2. Otherwise ensure_invite_acceptance_allowed: block only when seats_used > purchased_seats (stale invite after seats were reduced or usage increased elsewhere).
  3. Accepting when seats_used == purchased_seats succeeds because the invite was already counted in usage.

10.3 Seat release

EventEffect on seats_used
Member removedDecrements member count → frees a seat
Invite expiresPending no longer counted → frees a seat
Invite acceptedPending → member (net zero if counted correctly)
Purchased seats increasedRaises capacity without changing usage
Purchased seats decreasedAllowed only down to max(min_seats, seats_used)

10.4 Invite UI

  • Members settings loads me.seats when the user can view billing.
  • Invite button disabled when purchased_seats != null && seats_used >= purchased_seats.
  • Copy shows used of purchased and plan max.
  • CTA:
    • If purchased_seats < max_seatsAdd seats/workspace/billing?manageSeats=1
    • Else → Upgrade to a plan that supports more seats/workspace/billing
  • Server may still return seat_limit_reached (race); UI maps that to the same messaging.

11. Frontend specification

11.1 Shared helpers (seats.js)

HelperBehavior
getSeatBounds(product, seats)minQuantity = max(product.min_seats, seats_used || 1), maxQuantity = product.max_seats, defaultQuantity = minQuantity (clamped)
isPlanUnavailableForSeats(product, seats)true when seats.seats_used > product.max_seats
getSeatLimitCtaKey / getSeatLimitCtaToAdd seats vs upgrade deep-link

11.2 Plan catalog — when CTAs disable

A plan card is unavailable when the workspace’s current usage exceeds that plan’s maximum:

unavailable ⇔ seats_used > product.max_seats

UI:

  • Card visually muted.
  • Hint: team uses more seats than this plan supports.
  • CTA label: “Team too large” (or equivalent); button disabled.

Other disable reasons (orthogonal):

  • User lacks manage_billing.
  • Global billing action loading / polling.
  • Card is the current plan (except incomplete → “Finalize Payment”).
  • Cancel-at-period-end flows that require resume before switching.

Clarification: plans are not disabled because “min_seats is exceeded.” min_seats raises the floor of the quantity picker. Plans are disabled when usage exceeds max_seats.

11.3 Seat quantity picker

Reusable control:

  • Stepper (− / +) and numeric input, clamped to [minQuantity, maxQuantity].
  • Optional line estimate: unitPrice × quantity with interval label.
  • Hint: “Choose between {{min}} and {{max}} seats for this plan.”

11.4 Flow A — New subscribe / finalize incomplete

Bounds from getSeatBounds(selectedProduct, seats).

11.5 Flow B — Plan switch / upgrade

If cancel-at-period-end: show resume dialog instead of seat picker.

11.6 Flow C — Manage seats (same plan)

Manage seats picker bounds:

minQuantity = max(seats.min_seats, seats_used)
maxQuantity = seats.max_seats
initial     = clamp(purchased_seats)

Continue disabled when quantity equals current purchased seats. If usage forces min above plan min, show hint to remove teammates or cancel pending invites before decreasing further.

Deep-link: /workspace/billing?manageSeats=1 auto-opens Manage seats once (query stripped), when the user has seat-management permissions and an active entitlement.

11.7 Current subscription card

  • Show {{used}} of {{purchased}} used.
  • “Manage seats” only if canManageSeats.

11.8 Payment form

  • Display seats × unit price.
  • Charge amount = price.amount * quantity.

12. End-to-end scenarios

12.1 Happy path — first paid subscription

  1. Owner-only free workspace (seats_used = 1, no purchased seats).
  2. Chooses Pro (min_seats=5, max_seats=15).
  3. Seat picker defaults to 5.
  4. Pays for 5 seats → webhook sets quantity=5, entitlement active.
  5. Can invite until seats_used reaches 5.

12.2 Invite blocked at capacity

  1. Purchased 5; 4 members + 1 pending invite → seats_used=5.
  2. Invite CTA disabled; API would return seat_limit_reached.
  3. If purchased < max → CTA “Add seats”; else “Upgrade…”.

12.3 Decrease seats blocked by pending invites

  1. Purchased 5; 3 members + 2 pending → seats_used=5.
  2. Manage seats min quantity = 5; cannot buy 4 until invites expire/cancel or members leave.

12.4 Plan too small for team

  1. Workspace has seats_used=8 on Pro (max 15).
  2. Basic has max_seats=5.
  3. Basic CTA disabled: 8 > 5.
  4. Enterprise remains selectable; picker floor is max(enterprise.min_seats, 8).

12.5 Plan change requiring higher minimum

  1. On Basic with quantity=3, seats_used=2.
  2. Switching to Pro (min_seats=5) with quantity left at 3 → API seats_below_usage, minimum_quantity=5.
  3. UI should pick at least 5 in CheckoutSeatsModal before preview.

12.6 Plan change exceeding new max

  1. On Enterprise with quantity=50.
  2. Switch to Pro (max_seats=15) keeping 50 → seats_out_of_plan_range.
  3. UI should have disabled Pro if seats_used > 15; if usage ≤ 15 but purchased 50, picker forces quantity into Pro’s range before apply.

12.7 Stale invite after seat reduction

  1. Bypass path left a pending invite while purchased seats were later reduced (or usage drifted).
  2. Accept when seats_used > purchased400 seat_limit_reached with accept-specific message.
  3. No membership created.

12.8 Free workspace

  1. No entitlement → invite create succeeds without seat checks.
  2. After subscribe, gating begins immediately based on purchased quantity.

12.9 Cancel at period end

  1. Manage seats and plan switch blocked until resume.
  2. Deep-link ?manageSeats=1 opens resume dialog instead of seat modal.

12.10 Concurrent invites for last seat

  1. Two admins submit invite when one seat remains.
  2. Workspace row lock ensures only one succeeds; the other gets seat_limit_reached.

12.11 Out-of-order webhook

  1. Local quantity updated to 7; delayed webhook with older timestamp and quantity 5 arrives.
  2. Handler no-ops; quantity stays 7.

13. Edge cases checklist

#CaseExpected behavior
1Catalog product missing min_seats / max_seatsSync fails
2min_seats > max_seats in metadataSync fails
3Quantity below usageseats_below_usage
4Quantity below plan min (usage lower)seats_below_usage, minimum_quantity = min_seats
5Quantity above plan maxseats_out_of_plan_range
6Quantity equal to maxAllowed
7Pending invites count toward usageYes
8Expired invites count toward usageNo
9Accept when usage == purchasedAllowed (reservation → membership)
10Create when usage == purchasedBlocked
11Accept when already a memberSeat check skipped
12Remove member frees seatInvite succeeds afterward
13Trialing: invites gated by quantityYes (get_active_subscription includes trialing)
14Trialing: Manage seats APIBlocked (requires active)
15Cancel scheduled: Manage seats / plan changeBlocked
16Unchanged seat quantityNo Stripe call; noop response
17Regular member hits /seats/403
18User with only billing manage, not membersCannot Manage seats
19User with only members, not billingCannot Manage seats; can invite until capacity
20Incomplete sub finalize with new quantityCancel incomplete if price/qty differs; create new
21Plan unavailable: seats_used > max_seatsCTA disabled
22Missing seats object on clientPlan availability helper returns false (plans stay clickable); picker defaults used=1
23Members tab without view_billingNo seat snapshot → generic invite copy, no client-side gate (server still enforces if subscribed)
24Payment form open too longExpire incomplete after timeout
25Seat preview modal open too longAuto-close after configured delay
26Decrease with usage > plan minMin picker raised; decrease-blocked hint shown

14. Error code reference

Billing

CodeHTTPUsed by
seats_below_usage400subscribe, plan-change, seats
seats_out_of_plan_range400subscribe, plan-change, seats

Workspaces

CodeHTTPUsed by
seat_limit_reached400invite create, invite accept/redeem

15. UI copy requirements (i18n)

Seat-related strings must exist for all supported locales (e.g. EN / HR), including at least:

Billing: per-seat pricing labels, plan seat range, choose seats, manage seats, seats used of purchased, seat range hint, decrease blocked hint, seat change preview labels, plan-too-small CTA/hint, updating seats banner.

Workspace: invite seat limit messages, add-seats CTA, upgrade-for-seats CTA, invite section description with usage.


16. Security and consistency

  1. Server is authoritative. Client disables are UX only; all mutations re-validate.
  2. Dual permission for seat purchase changes prevents billing-only or members-only roles from changing capacity alone.
  3. Row locks on invite create/accept prevent double-booking the last seat.
  4. Webhook ordering (last_event_at) prevents stale Stripe events from clobbering quantity after a successful local update.
  5. Local write-through on /seats/ updates quantity immediately so invite gating does not wait on webhook delivery.
  6. No seat gate on free workspaces avoids blocking product exploration before payment.

17. Acceptance criteria

Catalog

  • Products without valid min_seats / max_seats fail sync.
  • Catalog API exposes bounds on every product.
  • Plan cards show per-seat price and min–max range.

Checkout

  • Subscribe requires quantity; Stripe subscription item quantity matches.
  • Picker cannot go below max(min_seats, seats_used) or above max_seats.
  • Payment total reflects unit × quantity.

Plan change

  • Preview and apply accept optional quantity (default current).
  • Target plan validated for new quantity.
  • Plans with max_seats < seats_used have disabled CTAs.

Manage seats

  • Requires both billing + members permissions.
  • Preview shows proration; confirm updates Stripe and local quantity.
  • Blocked when canceling or not active.
  • Deep-link ?manageSeats=1 opens the flow when allowed.

Invitations

  • Cannot create invite at full capacity.
  • Pending invites reserve seats; expiry releases them.
  • Accept works at equality; blocked when over capacity.
  • Free workspaces invite without seat limits.
  • Invite UI CTA routes to add seats or upgrade appropriately.

Resilience

  • Concurrent invites for last seat: only one succeeds.
  • Stale subscription webhooks do not reduce quantity incorrectly.

18. Testing matrix (minimum)

AreaCases
Catalog syncMissing min/max; min > max; valid parse
SubscribeBelow usage; below min; at max; above max; quantity passed to Stripe
Seats updateHappy path; pending invites block decrease; cancel scheduled; noop; 403 for member
Seats previewProration amounts; noop zero amounts
Plan changeKeep quantity; force min on higher plan; reject over new max; cancel scheduled
WebhooksPersist quantity; ignore older event
InvitesFull capacity block; free unlimited; remove member frees seat; expire frees seat; stale accept blocked; accept at equality OK

19. System context diagram


20. Glossary of invariants

  1. Purchased seats always ∈ [1, ∞) on a subscription record, but must also satisfy the current (or target) plan’s [min_seats, max_seats] and not be below seats_used when set via app APIs.
  2. seats_used ≤ purchased_seats is the steady-state invariant for invite creation on paid workspaces. Temporary violation is possible only via non-API paths or after capacity shrink races; acceptance then fails closed.
  3. Plan selectability requires seats_used ≤ product.max_seats (equivalently, CTA disabled when seats_used > product.max_seats).
  4. Free ⇒ ungated invites; paid ⇒ purchased capacity.

  • Stripe catalog setup — creating products, metadata, prices, and sync
  • Project README — high-level per-seat billing overview and startup order