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 MockBillingAdapterMOCK_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 claim

Inviting 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 the currentMemberCount passed by the caller against the org’s plan seat limit; throw BillingError('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 a subscriptions table 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 MockBillingAdapter the default under NODE_ENV=development so 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_SEATS and 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

Get in touch

Questions about the product, team licenses, or anything else? We'll respond within 48 hours.

Max 2000 characters

Stored in our own database — no third party. Deleted on request.