Guide

Seat-based billing behind a billing adapter

Most billing tutorials start inside a vendor dashboard. Start one layer earlier: your product doesn’t need to know who charges the card — it needs to know how many seats a plan allows and whether joining should succeed. This guide shows the seam we ship in our multi-tenant SvelteKit starter: a two-method interface, enforcement at exactly one choke point, a deterministic mock for tests, and a precise list of what a real merchant-of-record integration must do when you wire it in.

Scope first: what ships today meters seats with a membership count under single-process SQLite, and checkout runs through a mock — by design, not by accident. Every limitation is stated below next to the code that causes it.

Why billing lives behind an interface

Seat-based SaaS has exactly two jobs: collect money and enforce entitlements. Payment providers want to own the first job completely — and if you use a merchant of record (MoR), they also become the legal seller: they handle sales tax and VAT collection, refunds, and some fraud loss, then pay out your share. That trade is often worth making; our guide on selling software without Stripe access compares MoR options with verified fee figures and their verification dates. Which provider you pick, though, must stay reversible — so the product depends on neither choice. All payment knowledge sits behind one interface, and everything else consumes the interface.

The second job — entitlement — never leaves your codebase no matter what the marketing promises. An MoR knows someone paid; it has no idea what that entitles them to inside your app. Seats, limits, and what a downgrade means are yours, expressed in your schema and enforced in your service layer.

The contract as shipped

Here is the entire contract, verbatim from the starter’s billing/adapter.ts:

export type SubscriptionStatus = 'none' | 'active' | 'trialing' | 'past_due' | 'canceled';

export type SubscriptionState = {
	status: SubscriptionStatus;
	planSeats: number;
	renewsAtMs?: number | null;
};

export interface BillingAdapter {
	readonly name: string;
	getSubscriptionState(orgId: string): Promise<SubscriptionState>;
	/** Returns a hosted-checkout URL, or null when checkout isn't configured. */
	createCheckoutUrl(input: { orgId: string; seats: number }): Promise<string | null>;
}

export class BillingError extends Error {
	constructor(
		public code: 'seat_limit' | 'subscription_required',
		message: string
	) {
		super(message);
	}
}

Two methods carry more product weight than their size suggests:

  • getSubscriptionState is the entitlement oracle. Your local answer to “can this org add a person?” is derived from whatever this returns — status plus plan seats — so in production it will be backed by rows your webhooks keep current.
  • createCheckoutUrl returns a hosted-checkout link or null. The null case matters: “checkout isn’t wired up” becomes a representable state instead of an exception path nobody tested.
  • BillingError has exactly two machine codes, seat_limit and subscription_required, so the HTTP layer and the UI can react specifically without parsing prose.

Metering happens at accept time

The counting rule is deliberately boring — a seat is a membership row:

export async function countActiveSeats(db: Db, orgId: string): Promise<number> {
	const rows = await db.select({ id: memberships.id }).from(memberships).where(eq(memberships.orgId, orgId));
	return rows.length;
}

And the gate is called from exactly one place: invite acceptance. Not invite creation — inviting beyond your seat count is allowed on purpose, because the alternative (blocking invites at the limit) punishes the wrong moment. The limit surfaces when someone actually tries to join, with copy that names the fix:

const [state, seatsUsed] = await Promise.all([billing.getSubscriptionState(orgId), countActiveSeats(db, orgId)]);
if (state.status === 'none') throw new BillingError('subscription_required', 'This organization has no active subscription.');
if (seatsUsed >= state.planSeats) {
	throw new BillingError('seat_limit', `All ${state.planSeats} seats are in use. Upgrade the plan or free a seat.`);
}

Three honest notes on this gate, because the sharp edges are the transferable part:

  • Status handling is narrower than it looks. As shipped, only 'none' throws subscription_required; other non-active statuses pass through with whatever planSeats says. Decide deliberately whether past_due or canceled orgs may still fill remaining seats, and encode that decision here — the type system already makes the statuses visible to you.
  • The check runs before the atomic claim. On the starter’s single-process SQLite setup, requests serialize and the ordering is safe. Across multiple instances, two accepts on different invites could both clear this check in a race — the architecture notes say outright that when Postgres lands, claim and seat-count belong in one transaction.
  • Existing members are never evicted. The gate guards joins only. What happens to an over-limit org after a downgrade is a product decision this initial rule hasn’t made yet — don’t let anyone tell you it came free.

A mock provider keeps dev and tests hermetic

The default implementation answers deterministically: every org counts as active with a configurable seat count, and checkout URLs come backnull because there’s nothing external to link to:

export class MockBillingAdapter implements BillingAdapter {
	readonly name = 'mock';
	private readonly planSeats: number;

	constructor(opts: { planSeats?: number } = {}) {
		const fromEnv = Number.parseInt(process.env.MOCK_PLAN_SEATS ?? '', 10);
		this.planSeats = opts.planSeats ?? (Number.isFinite(fromEnv) ? fromEnv : 3);
	}

	async getSubscriptionState(_orgId: string): Promise<SubscriptionState> {
		return { status: 'active', planSeats: this.planSeats, renewsAtMs: null };
	}

	async createCheckoutUrl(_input: { orgId: string; seats: number }): Promise<string | null> {
		return null;
	}
}

Two payoffs fall straight out of the shape. Tests exercise the entire seat-limit path through the real interface — the suite’s invite tests cover joins blocked beyond the plan seat count until a seat frees up — with no network, no API keys, and no recorded fixtures to go stale. And development works before any payment account exists, while the wiring stays a one-liner:

export const billing: BillingAdapter = new MockBillingAdapter();

When a merchant-of-record account is live, that line becomesnew YourVendorAdapter(env) and no other file changes. The env override (MOCK_PLAN_SEATS, defaulting to 3) exists so demos and tests can rehearse the over-limit path without touching code.

What a merchant-of-record adapter must satisfy

Swapping in a real provider (Lemon Squeezy, Paddle, and Payhip are the candidates the starter’s billing documentation names) is mostly webhook plumbing. The requirements, as the shipped docs specify them:

  1. Map subscription events to local rows. Translate provider subscription webhooks into {status, planSeats} records keyed by orgId — typically a new subscriptions table — while keeping the interface untouched.
  2. Build checkout sessions server-side. createCheckoutUrl creates a session for your seats plan with the org ID carried in passthrough metadata, so the returning webhook can find the buyer.
  3. Receive webhooks outside the auth guard. A new route whose whole job is: verify the signature, update the local row. Never trust client-side success redirects for entitlements — they’re forgeable and lossy.
  4. Assume delivery will repeat. Providers retry webhooks; make updating an entitlement row idempotent so replayed events converge on the same state.
  5. Keep the mock as the development default so tests stay hermetic and the demo keeps running with zero credentials.

Fee percentages, surcharges, and payout mechanics differ per provider and change without notice — the comparison guide linked above tracks them with capture dates rather than embedding numbers that rot. This page’s claim is narrower and stable: whichever provider you choose, it fits behind these two methods or it doesn’t fit.

Production checklist

  • Define the interface before picking a vendor — two methods and a name field were enough here.
  • Entitlements live in your own database, keyed by organization ID; vendor webhooks merely update those rows.
  • Verify webhook signatures at a dedicated endpoint outside your auth guard, and never grant entitlements from a client-side success redirect.
  • Treat duplicate webhook deliveries as normal: updating a local entitlement row must be safe to replay.
  • Enforce seat limits at exactly one choke point per join, with error copy that names the fix.
  • Map every subscription status you model to join behavior on purpose — decide whether a past-due plan can add seats before a customer asks.
  • Keep checkout-URL creation nullable so “not configured” is representable without throwing.
  • Default development and tests to the mock provider so the suite never needs a network.
  • Plan-seat changes arrive through the same subscription state as status changes — one row, one source of truth.
  • Swap providers by editing one wiring file; if a second file imports a vendor SDK, the seam has already leaked.

The seam described here ships in the multi-tenant SvelteKit starter, alongside the invite flow it gates — covered in detail in the previous guide on single-use invite links.

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.