Skip to content

feat(billing): per-seat foundation — org billing tables + seat gate (dormant) - #5117

Merged
viktormarinho merged 3 commits into
mainfrom
viktormarinho/org-billing-foundation
Jul 23, 2026
Merged

feat(billing): per-seat foundation — org billing tables + seat gate (dormant)#5117
viktormarinho merged 3 commits into
mainfrom
viktormarinho/org-billing-foundation

Conversation

@viktormarinho

@viktormarinho viktormarinho commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Phase-2 opener of the per-seat monetization plan: data model + enforcement seam, fully dormant — behind STUDIO_BILLING_ENFORCED (default off, declared off in deco-apps-cd#175), with migration 139 backfilling every existing org legacy = true. Deploying this changes behavior for no one.

  • migration 139: organization_billing (billing identity — platform-written only, deliberately NOT in org metadata, which org admins can write via ORGANIZATION_UPDATE; billing there would let any admin flag their own org legacy) + organization_paid_seat (presence = paid seat; absence = free). New orgs get legacy = false from the org-creation hook; a missing row fails open (treated legacy) so a hook failure can't brick an org.
  • Seats ≠ roles: seats are monetization, orthogonal to Better Auth governance. An org owner can hold a free seat (the report-funnel onboarding user), so the seat gate fires in AccessControl.check() before the admin/owner bypass.
  • Free seat = sees everything, changes nothing: tools declaring annotations.readOnlyHint: true pass (fail-closed — an unannotated read tool gets annotated, not the gate widened); route-level resource keys have a read allowlist (ORG_FS_READ, not ORG_FS_WRITE).
  • PaidSeatRequiredError extends ForbiddenError (every existing handler keeps returning 403 untouched) and carries the [PAID_SEAT_REQUIRED] message prefix as the wire contract — same convention as [CREDITS]/is-credit-error.ts, so the future paywall UI distinguishes it from a plain permission 403 across every transport (REST, MCP tool error). CTA branching (subscribe vs "ask your admin") uses the role the frontend already has.
  • resolveOrgFromPath loads billing + seat state (two PK lookups, members only, flag on only) and arms the gate for the request.

Testing

  • 6 new unit tests in access-control.test.ts (32 pass): owner-with-free-seat blocked, readOnlyHint passes, basic-usage tools gated, ORG_FS_READ passes / ORG_FS_WRITE blocked, gate-off default fully open, ForbiddenError inheritance + prefix contract.
  • Full bun test green; bun run check (all workspaces) green; bun run lint no new warnings; knip clean; bun run fmt applied.
  • Not in this PR (next PRs, per plan): seat management tools + change-log (2.5), members UI (2.6), paywall UI (2.7), gateway benefits sync (2.8), Stripe (phase 3).

🤖 Generated with Claude Code


Summary by cubic

Lays the foundation for per-seat billing, fully dormant behind STUDIO_BILLING_ENFORCED (default off). Adds org billing tables and a seat gate in access control without changing behavior for existing users.

  • New Features

    • Seat gate in AccessControl.check() runs before admin/owner bypass; free seats can read but can’t write or spend.
    • Tools with annotations.readOnlyHint: true and the ORG_FS_READ resource pass; ORG_FS_WRITE is blocked when gated.
    • PaidSeatRequiredError extends ForbiddenError with [PAID_SEAT_REQUIRED] prefix for paywall detection.
    • resolveOrgFromPath folds billing/seat into the member query as scalar subqueries when billingEnforced is true; zero extra round-trips, and flag-off pays nothing.
    • Settings add billingEnforced (from STUDIO_BILLING_ENFORCED); 6 unit tests cover the gate and error contract.
  • Migration

    • Migration 139 adds organization_billing (billing identity) and organization_paid_seat (presence = paid seat).
    • Backfills all existing orgs with legacy = true; new orgs get legacy = false via the org-creation hook.
    • Missing billing row fails open (treated legacy) to avoid bricking orgs.

Written for commit 1ef1edd. Summary will update on new commits.

Review in cubic

viktormarinho and others added 2 commits July 23, 2026 16:09
…dormant)

Foundation for the per-seat subscription, fully dormant: everything sits
behind STUDIO_BILLING_ENFORCED (default off; self-hosted never turns it
on) and migration 137 backfills every existing org legacy = true, so
nothing changes for anyone on deploy.

- organization_billing (platform-written only — deliberately NOT in org
  metadata, which admins can write via ORGANIZATION_UPDATE) + backfill;
  new orgs get legacy = false from the org-creation hook, fail-OPEN when
  the row is missing.
- organization_paid_seat: presence = paid; absence = free. Seats are
  monetization, orthogonal to Better Auth roles — an org OWNER can hold
  a free seat (the report-funnel onboarding case), so the gate fires
  BEFORE the admin/owner bypass in AccessControl.check().
- Free seat = sees everything, changes nothing: tools declaring
  readOnlyHint pass (fail-closed for unannotated ones), plus the
  ORG_FS_READ route resource key.
- PaidSeatRequiredError extends ForbiddenError (existing handlers keep
  403) with the [PAID_SEAT_REQUIRED] message prefix as the wire contract
  for the future paywall UI — same convention as [CREDITS].
- resolveOrgFromPath loads billing + seat (two PK lookups, members only,
  flag on only) and sets the gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
resolveOrgFromPath is the hot path for every org-scoped request. The
billing legacy flag and paid-seat presence now ride the existing member
query as scalar subqueries ($if-gated on STUDIO_BILLING_ENFORCED), so
enforcement costs zero extra round-trips — and flag-off deployments pay
exactly nothing, same as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@viktormarinho

Copy link
Copy Markdown
Contributor Author

Pushed a hot-path refinement: the seat-gate reads (billing legacy + paid-seat presence) no longer add round-trips — they ride the existing membership query as scalar subqueries, $if-gated on STUDIO_BILLING_ENFORCED. Flag off (today, and self-hosted forever): query is byte-identical to before. Flag on: same single round-trip, two extra index point-reads inside it.

Main took 137/138 (agent-sandbox); org billing is now migration 139.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@viktormarinho
viktormarinho enabled auto-merge (squash) July 23, 2026 19:37
@viktormarinho
viktormarinho merged commit ebe558c into main Jul 23, 2026
15 checks passed
@viktormarinho
viktormarinho deleted the viktormarinho/org-billing-foundation branch July 23, 2026 19:42
decocms Bot pushed a commit that referenced this pull request Jul 23, 2026
PR: #5117 feat(billing): per-seat foundation — org billing tables + seat gate (dormant)
Bump type: minor

- decocms (apps/mesh/package.json): 4.109.7 -> 4.110.0

Deploy-Scope: server
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant