Billing & entitlements¶
Billing answers one question for every request that spends something: what may this caller do right now? The answer is computed on the server from a verified identity, and client-side gating is a courtesy that improves the interface, never a control. That rule is ADR-0005 and it is the reason the billing code is shaped the way it is.
Plans¶
lib/plans.ts is a pure module with no Stripe, Supabase or browser dependencies, so the same definitions run on the client (to decide what to show) and on the server (to decide what to allow).
| Plan | Price | Simulator | Saved circuits | Hardware jobs / month | Live tutor | Seats |
|---|---|---|---|---|---|---|
| Free | — | 16 qubits | 10 | 0 | offline tier | 1 |
| Pro | $19 / month, $190 / year | 16 qubits | unlimited | 50 | yes | 1 |
| Team | $99 / month, $990 / year | 16 qubits | unlimited | 500 per seat | yes | 10 |
| University | arranged | 16 qubits | unlimited | 5,000 | yes | unlimited |
Twenty feature identifiers (hardware.real, tutor.live, course.advanced, certification, team.admin, …) are granted per plan. The full 16-qubit simulator, every visualization, the Foundations course, QASM export and the offline tutor are on every plan including Free; the simulator runs in the visitor's browser and costs nothing to serve.
A test asserts that every plan's marketing highlights agree with its enforced limits, and another that University's hand-arranged items (SSO configured with the customer's IT department, a syllabus-specific lesson track) appear only on the contact-sales plan. Plan copy cannot drift from plan code.
Resolving an entitlement¶
lib/billing/entitlements.ts (server-only) produces an Entitlement for a verified user id:
- Read the personal subscription row (
plan_id,status). - Read any organisation seat (
org_members.plan_id,status). - Take the stronger of the two.
- Apply the launch trial if configured:
NEXT_PUBLIC_LAUNCH_TRIAL_DAYS(rolling, per account) orNEXT_PUBLIC_LAUNCH_TRIAL_UNTIL(absolute date) treats signed-in accounts as Pro with statustrialing. Nothing is written to the database; a real paid plan always wins. - Any lookup failure degrades to Free.
A subscription confers its plan only in good standing: active, trialing or past_due (dunning should not lock someone out mid-lesson); canceled and incomplete fall back to Free. Routes call requireFeature(userId, feature) and get a 402 featureLockedResponse on refusal. The /api/billing/entitlement endpoint exists so the interface can render the right state, and its documentation says in capitals that it is a courtesy, not a gate.
Stripe¶
Stripe is optional. isStripeConfigured is true only when STRIPE_SECRET_KEY is set; without it, checkout returns { configured: false } with a plain-language message and the pricing page says so.
- Checkout creates a Checkout Session for a verified user and carries the user id in session metadata. Inside the store apps it refuses with 403 (see Mobile apps).
- Webhook is the only writer of
subscriptions. It verifies the signature against the raw body (400 on failure), logs every event id instripe_eventsfor idempotency, returns 500 on a database failure so Stripe retries, and 200 for a verified event it cannot use. Writes carryevent.created, and the SQL functionapply_stripe_subscriptiondiscards any write older than the stored one, so out-of-order delivery cannot downgrade a customer. - Reconcile pulls truth from Stripe on demand: automatically when a subscription's period end is more than a day in the past, by a user for themselves, or by an operator with a secret for the nightly sweep.
- Customer portal lets a verified user manage their own subscription.
scripts/stripe-smoke.mjs asks Stripe directly whether the configured price ids, webhook secret and key are valid, one pass/fail line per claim, so a misconfigured deployment is caught before a customer finds it.
Metering hardware jobs¶
Quota is the one place where a race would cost real money, so it fails closed. lib/billing/usage.ts reserves a job through the SQL function reserve_hardware_job (later wrapped by reserve_hardware_job_within_budget), which counts this period's rows and inserts the new one under a per-user advisory lock. The browser cannot insert into hardware_usage at all. If the store is unreachable, QuotaUnavailableError becomes a 503 rather than a free job. The usage window is the subscription's monthly anchor, with a UTC calendar month as the fallback.
Spend budgets (spend_budgets) add a second ceiling expressed in jobs and shots rather than dollars, because no invoice feed exists to meter against. A warning fires once per period at a configurable percentage and a hard stop is optional. A one-time engagement bonus of 25 hardware jobs is granted through hardware_bonus.
Organisations¶
Team and University plans own seats. Only those plans can create an organisation, and creation requires the caller's own active subscription at the same plan, so a Free account cannot self-grant seats. Roles are owner, admin and member; the last owner cannot be removed or demoted; seat caps are re-checked server-side on every invite; suspended seats free capacity. Invites hold a seat as invited, use random hashed tokens, and are accepted only by the matching verified email. Instructors see a learner's progress only when the learner has written a consent row for that organisation; an instructor cannot consent on a learner's behalf.