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
- Each paid plan defines a purchasable seat range (
min_seats…max_seats) in Stripe product metadata. - Admins choose how many seats to buy at checkout and when changing plans.
- Admins can increase or decrease purchased seats mid-cycle on the current plan (with Stripe proration).
- Seat usage equals active members plus non-expired pending invitations; invites cannot exceed purchased capacity.
- Plans that cannot fit the workspace’s current usage (
seats_used > plan.max_seats) are not selectable in the catalog. - 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
| Term | Meaning |
|---|---|
| Plan seat bounds | min_seats / max_seats on the catalog product (from Stripe metadata). Bounds of what may be purchased on that plan. |
| Purchased seats | SubscriptionRecord.quantity — the Stripe subscription item quantity currently billed. |
| Member count | Number of WorkspaceMembership rows for the workspace. |
| Pending invites | Invitations with accepted_at IS NULL and expires_at > now. Expired invites do not consume seats. |
| Seats used | member_count + pending_invites. This is the reserved + occupied capacity. |
| Seat snapshot | API object exposing usage, purchased seats, and current plan bounds for the UI. |
| Free workspace | No active entitlement (and/or no active/trialing subscription). Invite seat checks no-op. |
Mental model
3. Actors and permissions
| Capability | Permission(s) | UI / API effect |
|---|---|---|
| View billing / seat usage | billing.view_billing | See current seats on billing; Members tab can load seat snapshot for invite copy |
| Subscribe / change plan | billing.manage_billing | Plan CTAs, checkout, plan-change preview/apply |
| Manage seats mid-cycle | Both billing.manage_billing and workspaces.manage_members | “Manage seats”, /seats/, /seats-preview/ |
| Invite members | workspaces.manage_members | Invite form; server still enforces seat capacity |
| View invoices | billing.view_and_download_invoices | Invoice 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
| Key | Required | Rules | Purpose |
|---|---|---|---|
code | Yes | Unique uppercase identifier (e.g. PRO) | Stable plan id |
tier | Yes | Positive integer as string | Ordering / ranking |
min_seats | Yes | Positive integer ≥ 1 | Minimum purchasable quantity |
max_seats | Yes | Positive integer ≥ min_seats | Maximum purchasable quantity |
features | Yes | JSON array of entitlement codes (e.g. ["manage_roles"]) | Machine-readable entitlements copied to the workspace on subscribe |
plan_description | Recommended | JSON map of locale → bullet arrays | Localized “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.
4.2 Recommended plan set
| Plan | code | tier | min_seats | max_seats |
|---|---|---|---|---|
| Basic | BASIC | 10 | 1 | 5 |
| Pro | PRO | 20 | 5 | 15 |
| Enterprise | ENTERPRISE | 30 | 10 | 100 |
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
| Model | Field | Description |
|---|---|---|
CatalogProduct | min_seats | Plan minimum purchasable seats |
CatalogProduct | max_seats | Plan maximum purchasable seats |
SubscriptionRecord | quantity | Purchased seats (default 1) |
SubscriptionRecord | stripe_subscription_item_id | Item id required for quantity updates |
SubscriptionRecord | last_event_at | Webhook 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
}
| Field | Source |
|---|---|
member_count | Memberships |
pending_invites | Pending, non-expired invitations |
seats_used | Sum of the two above |
purchased_seats | Active/trialing subscription quantity, or null |
min_seats / max_seats | Active 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
}
}
| Code | When | Message (intent) |
|---|---|---|
seats_below_usage | quantity < max(min_seats, seats_used) | Purchased seats cannot be below current seat usage / plan floor |
seats_out_of_plan_range | quantity > max_seats | Purchased seats exceed the plan maximum |
Note: requesting below plan
min_seatseven when usage is lower still usesseats_below_usage(same code;minimum_quantityclarifies 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
}
| Gate | Condition | Why |
|---|---|---|
| Create invite | seats_used >= purchased_seats | Need a free slot; pending invite will occupy one |
| Accept invite | seats_used > purchased_seats | Accepting 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:
- Resolve target product from
price_id. validate_seat_quantityagainst that product.- Create Stripe subscription with
items=[{ price, quantity }],payment_behavior=default_incomplete. - 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 }
quantityoptional; 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_pricewithproration_behavior=always_invoice. - Preview endpoint:
POST /plan-change-preview/(aliaspreview-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):
| Guard | Error |
|---|---|
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
activeandtrialingas purchased. Mid-cycle seat change endpoints requirestatus=activeonly (trialing workspaces cannot use Manage seats until active).
Behavior:
- Same quantity → noop (preview returns zero amounts; update returns current snapshot).
- Else validate quantity against current plan product.
- Preview: Stripe invoice preview with
create_prorations(amount display only). - Update: Stripe quantity modify with
always_invoice(same as plan changes — upgrades invoice immediately; downgrade credits apply to the customer balance / future invoices), then persistSubscriptionRecord.quantitylocally and return updated snapshot.
Success (POST /seats/):
{
"quantity": 7,
"seats": { "...snapshot..." }
}
8.5 Invitation APIs (workspace)
| Action | Seat check |
|---|---|
| Create invitation | ensure_invite_seat_available inside transaction.atomic + workspace row lock |
| Accept / redeem invitation | ensure_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
| Operation | Stripe proration_behavior |
|---|---|
| Seat quantity change (same plan) | always_invoice on modify; preview uses create_prorations for display |
| Plan / price change | always_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.createdcustomer.subscription.updatedcustomer.subscription.deleted
Rules:
- Persist
quantityfrom the first subscription item (default1if missing). - If webhook
event_createdis older thanSubscriptionRecord.last_event_at, ignore the event (protects against out-of-order quantity regressions). - Entitlement active when subscription status is
activeortrialing.
10. Invitation system integration
Invitations reserve seats. A pending invite is capacity until it expires or is accepted.
10.1 Create invite
- Permission check (
manage_members). - In a transaction: lock workspace →
ensure_invite_seat_available→ upsert invitation. - If
seats_used >= purchased_seats→400 seat_limit_reached. - Free / no entitlement → skip seat check; unlimited invites.
10.2 Accept invite
- If invitee is already a member → accept path without seat check.
- Otherwise
ensure_invite_acceptance_allowed: block only whenseats_used > purchased_seats(stale invite after seats were reduced or usage increased elsewhere). - Accepting when
seats_used == purchased_seatssucceeds because the invite was already counted in usage.
10.3 Seat release
| Event | Effect on seats_used |
|---|---|
| Member removed | Decrements member count → frees a seat |
| Invite expires | Pending no longer counted → frees a seat |
| Invite accepted | Pending → member (net zero if counted correctly) |
| Purchased seats increased | Raises capacity without changing usage |
| Purchased seats decreased | Allowed only down to max(min_seats, seats_used) |
10.4 Invite UI
- Members settings loads
me.seatswhen the user can view billing. - Invite button disabled when
purchased_seats != null && seats_used >= purchased_seats. - Copy shows
used of purchasedand planmax. - CTA:
- If
purchased_seats < max_seats→ Add seats →/workspace/billing?manageSeats=1 - Else → Upgrade to a plan that supports more seats →
/workspace/billing
- If
- Server may still return
seat_limit_reached(race); UI maps that to the same messaging.
11. Frontend specification
11.1 Shared helpers (seats.js)
| Helper | Behavior |
|---|---|
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 / getSeatLimitCtaTo | Add 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_seatsraises the floor of the quantity picker. Plans are disabled when usage exceedsmax_seats.
11.3 Seat quantity picker
Reusable control:
- Stepper (− / +) and numeric input, clamped to
[minQuantity, maxQuantity]. - Optional line estimate:
unitPrice × quantitywith 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
- Owner-only free workspace (
seats_used = 1, no purchased seats). - Chooses Pro (
min_seats=5,max_seats=15). - Seat picker defaults to
5. - Pays for 5 seats → webhook sets
quantity=5, entitlement active. - Can invite until
seats_usedreaches 5.
12.2 Invite blocked at capacity
- Purchased 5; 4 members + 1 pending invite →
seats_used=5. - Invite CTA disabled; API would return
seat_limit_reached. - If
purchased < max→ CTA “Add seats”; else “Upgrade…”.
12.3 Decrease seats blocked by pending invites
- Purchased 5; 3 members + 2 pending →
seats_used=5. - Manage seats min quantity = 5; cannot buy 4 until invites expire/cancel or members leave.
12.4 Plan too small for team
- Workspace has
seats_used=8on Pro (max 15). - Basic has
max_seats=5. - Basic CTA disabled:
8 > 5. - Enterprise remains selectable; picker floor is
max(enterprise.min_seats, 8).
12.5 Plan change requiring higher minimum
- On Basic with
quantity=3,seats_used=2. - Switching to Pro (
min_seats=5) with quantity left at 3 → APIseats_below_usage,minimum_quantity=5. - UI should pick at least 5 in CheckoutSeatsModal before preview.
12.6 Plan change exceeding new max
- On Enterprise with
quantity=50. - Switch to Pro (
max_seats=15) keeping 50 →seats_out_of_plan_range. - 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
- Bypass path left a pending invite while purchased seats were later reduced (or usage drifted).
- Accept when
seats_used > purchased→400 seat_limit_reachedwith accept-specific message. - No membership created.
12.8 Free workspace
- No entitlement → invite create succeeds without seat checks.
- After subscribe, gating begins immediately based on purchased quantity.
12.9 Cancel at period end
- Manage seats and plan switch blocked until resume.
- Deep-link
?manageSeats=1opens resume dialog instead of seat modal.
12.10 Concurrent invites for last seat
- Two admins submit invite when one seat remains.
- Workspace row lock ensures only one succeeds; the other gets
seat_limit_reached.
12.11 Out-of-order webhook
- Local quantity updated to 7; delayed webhook with older timestamp and quantity 5 arrives.
- Handler no-ops; quantity stays 7.
13. Edge cases checklist
| # | Case | Expected behavior |
|---|---|---|
| 1 | Catalog product missing min_seats / max_seats | Sync fails |
| 2 | min_seats > max_seats in metadata | Sync fails |
| 3 | Quantity below usage | seats_below_usage |
| 4 | Quantity below plan min (usage lower) | seats_below_usage, minimum_quantity = min_seats |
| 5 | Quantity above plan max | seats_out_of_plan_range |
| 6 | Quantity equal to max | Allowed |
| 7 | Pending invites count toward usage | Yes |
| 8 | Expired invites count toward usage | No |
| 9 | Accept when usage == purchased | Allowed (reservation → membership) |
| 10 | Create when usage == purchased | Blocked |
| 11 | Accept when already a member | Seat check skipped |
| 12 | Remove member frees seat | Invite succeeds afterward |
| 13 | Trialing: invites gated by quantity | Yes (get_active_subscription includes trialing) |
| 14 | Trialing: Manage seats API | Blocked (requires active) |
| 15 | Cancel scheduled: Manage seats / plan change | Blocked |
| 16 | Unchanged seat quantity | No Stripe call; noop response |
| 17 | Regular member hits /seats/ | 403 |
| 18 | User with only billing manage, not members | Cannot Manage seats |
| 19 | User with only members, not billing | Cannot Manage seats; can invite until capacity |
| 20 | Incomplete sub finalize with new quantity | Cancel incomplete if price/qty differs; create new |
| 21 | Plan unavailable: seats_used > max_seats | CTA disabled |
| 22 | Missing seats object on client | Plan availability helper returns false (plans stay clickable); picker defaults used=1 |
| 23 | Members tab without view_billing | No seat snapshot → generic invite copy, no client-side gate (server still enforces if subscribed) |
| 24 | Payment form open too long | Expire incomplete after timeout |
| 25 | Seat preview modal open too long | Auto-close after configured delay |
| 26 | Decrease with usage > plan min | Min picker raised; decrease-blocked hint shown |
14. Error code reference
Billing
| Code | HTTP | Used by |
|---|---|---|
seats_below_usage | 400 | subscribe, plan-change, seats |
seats_out_of_plan_range | 400 | subscribe, plan-change, seats |
Workspaces
| Code | HTTP | Used by |
|---|---|---|
seat_limit_reached | 400 | invite 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
- Server is authoritative. Client disables are UX only; all mutations re-validate.
- Dual permission for seat purchase changes prevents billing-only or members-only roles from changing capacity alone.
- Row locks on invite create/accept prevent double-booking the last seat.
- Webhook ordering (
last_event_at) prevents stale Stripe events from clobbering quantity after a successful local update. - Local write-through on
/seats/updates quantity immediately so invite gating does not wait on webhook delivery. - No seat gate on free workspaces avoids blocking product exploration before payment.
17. Acceptance criteria
Catalog
- Products without valid
min_seats/max_seatsfail sync. - Catalog API exposes bounds on every product.
- Plan cards show per-seat price and
min–maxrange.
Checkout
- Subscribe requires quantity; Stripe subscription item quantity matches.
- Picker cannot go below
max(min_seats, seats_used)or abovemax_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_usedhave 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=1opens 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)
| Area | Cases |
|---|---|
| Catalog sync | Missing min/max; min > max; valid parse |
| Subscribe | Below usage; below min; at max; above max; quantity passed to Stripe |
| Seats update | Happy path; pending invites block decrease; cancel scheduled; noop; 403 for member |
| Seats preview | Proration amounts; noop zero amounts |
| Plan change | Keep quantity; force min on higher plan; reject over new max; cancel scheduled |
| Webhooks | Persist quantity; ignore older event |
| Invites | Full 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
- 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 belowseats_usedwhen set via app APIs. seats_used ≤ purchased_seatsis 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.- Plan selectability requires
seats_used ≤ product.max_seats(equivalently, CTA disabled whenseats_used > product.max_seats). - Free ⇒ ungated invites; paid ⇒ purchased capacity.
Related documents
- Stripe catalog setup — creating products, metadata, prices, and sync
- Project README — high-level per-seat billing overview and startup order