Guide
Seat billing behind one adapter
The mistake most starters make with billing is letting the payment provider leak through the whole codebase — a Stripe call in a route here, a customer ID sniffed over there, and the product is welded to a merchant it may want to change. Our SvelteKit + Supabase Starter keeps all payment knowledge behind one interface, enforces seats at exactly one gate, and swaps the merchant of record by changing one function. This guide shows how.
Scope first: the shipped adapter is a mock. The real one is yours to wire, and the guide is explicit about what it must do — because the enforcement contract is what matters, not the provider.
Why an adapter
Checkout runs through a merchant of record — Lemon Squeezy, Paddle, or Payhip are the usual candidates. The MoR becomes the legal seller of record and handles sales tax, so you do not have to register as a merchant in every jurisdiction your customers live in. Because which MoR you pick is swappable by design, all payment knowledge sits behind a single TypeScript interface:
// src/lib/server/billing/adapter.ts
export interface BillingAdapter {
assertSeatAvailable(orgId: string, currentMemberCount: number): Promise<void>;
getSeatCount(orgId: string): Promise<number>;
getPlan(orgId: string): Promise<{ planId: string; seatLimit: number }>;
}Everything else in the codebase consumes the interface, never a provider. Today it is satisfied by MockBillingAdapter —MOCK_PLAN_SEATS seats, default 3. The wiring point is createBillingAdapter() in src/lib/server/billing/mock.ts: swap one function, change nothing else in the services or routes.
Where enforcement happens — exactly one gate
Seat capacity is enforced at invite acceptance (acceptInvite), and nowhere else. The service counts current memberships for the org and calls assertSeatAvailable(orgId, count) before the single-use claim:
// inside acceptInvite — count seats, then gate, then claim
const seatsUsed = COUNT(memberships) WHERE org_id = input.orgId;
await billing.assertSeatAvailable(input.orgId, seatsUsed);
// ... single-use UPDATE claimInviting beyond the seat count is allowed on purpose — the limit surfaces at accept time as an actionable error (“upgrade or free a seat”). That matches how most incumbents behave and what beta testers expect to see. The initial counting rule is a literal count: seatsUsed = COUNT(memberships) for the org.
What the real adapter must do
When you wire a real merchant of record, the adapter owns five responsibilities:
assertSeatAvailable: compare thecurrentMemberCountpassed by the caller against the org’s plan seat limit; throwBillingError('seat_limit', …)when full.getSeatCount: return the org’s current seat usage for admin screens and pre-flight checks.getPlan: return the org’s plan id and seat limit from asubscriptionstable keyed by orgId and kept in sync by merchant webhooks.- A webhook endpoint (a new route, outside the auth guard): verify the signature, then update the local subscription row. Never trust client-side success redirects for entitlements.
- Keep
MockBillingAdapterthe default underNODE_ENV=developmentso tests and dev stay hermetic.
The error contract
BillingError carries machine codes — seat_limit | no_plan | adapter_error. The service layer translates seat_limit into a clear, actionable rejection at invite-accept time — never a silent drop. Note that the seat gate does not replace RBAC; it composes with it (see the RBAC guide). A full org still must have a seat before a role can be granted.
Deliberate limits
- The shipped adapter is a mock. It enforces
MOCK_PLAN_SEATSand keeps dev and tests hermetic; real entitlement requires the subscriptions-table path above. - Seats are counted, not computed. It counts every membership; seat definitions that distinguish paid from free seats belong to the real adapter.
- No checkout, refund, or prorating logic ships. Those live behind the merchant of record, which is exactly why the codebase fits behind the interface.
Production checklist
- Put every payment capability behind one BillingAdapter interface — the product must not depend on the merchant-of-record choice.
- Enforce seats at exactly one gate: invite acceptance (acceptInvite), before the single-use claim.
- Allow inviting beyond the seat count on purpose; surface the limit at accept time as an actionable error.
- Make the real adapter read seats from a subscriptions table kept in sync by merchant webhooks — never trust client-side success redirects.
- Keep the mock adapter as the default under development so tests and dev stay hermetic.
- Carry machine error codes (seat_limit | no_plan | adapter_error) and translate seat_limit into a clear rejection — never a silent drop.
The interface, the metadata in the architecture guide (where billing sits in the stack), and the one-gate placement all trace to the shipped starter kit.
Related reading
- Architecture — where the billing seam sits in the stack
- RBAC & RLS — the role matrix the seat gate composes with
- Testing — how the gate and mock are proved
- Product page — features, pricing, FAQ
- All documentation — guides, deep dives, and evaluations
Get in touch
Questions about the product, team licenses, or anything else? We'll respond within 48 hours.