Billing
A product can charge each organization a subscription through Stripe.
Plans differ by limits (how many of something) and features (named
capabilities). Billing is optional: a product config without billing has
no limits and needs no Stripe keys.
How it works
- Every organization is on one effective plan, decided in this order:
- a platform admin’s override (comped or invoiced by hand);
- a subscription whose Stripe status is
active,trialingorpast_due(Stripe keeps retrying a failed payment; access stays until it gives up); - the trial a new organization gets (
billing.trial, no card; a self-service signup gives it to a user’s first organization only); - the default plan (
billing.defaultPlan, e.g. Free).
- Stripe is the source of truth. The API keeps a copy per organization
(
organization_subscriptions) current through Stripe’s webhook, and checks limits against it locally - no Stripe call per request. - Limits refuse additions only. When an organization drops to a smaller
plan, nothing is removed or blocked: members can still log in and work;
only new members, units, todos… above the limit are refused (403
PLAN_LIMIT_REACHED). The Billing tab shows the usage over the limit. - A platform admin is never limited on core’s members, units and
invitations, also when acting on an organization with
X-Organization-Id. A product’s own count limits them only if it does not pass the caller: the Keelmint’s todos do not, so a platform admin’s todo create counts against the target organization’s limit like anyone’s.
Configure the plans (libs/<product>/shared/src/product.config.ts)
billing: { plans: [ { key: 'free', limits: { members: 3, units: 0, todos: 50 }, features: [], paid: false }, { key: 'pro', limits: { members: 25, units: 20, todos: null }, features: [], paid: true }, { key: 'business', limits: {}, features: ['audit'], paid: true }, ], defaultPlan: 'free', trial: { plan: 'pro', days: 14 },},limits: a missing key ornullis unlimited. Core countsmembers(live members plus pending invitations: an invitation holds its seat) andunits. A product adds its own (keelmint:todos), see below.features: core knowsaudit(the organization audit log). A product adds its own, see below.paid: trueplans are sold; the default plan must not be paid.defineProductConfigrefuses a malformed config at boot.
Plan and limit names in the UI: billing.plans.<key> and
billing.limits.<key> in the product’s i18n files (core has free, pro,
business, members, units); without one the key is shown.
Stripe setup
-
In the Stripe dashboard (test mode first), create a Product per paid plan and a recurring Price per interval you sell (monthly, yearly).
-
Set the API’s environment (
apps/<product>/api/.env, in productiondeploy/<product>/.env):Terminal window STRIPE_SECRET_KEY=sk_test_...STRIPE_WEBHOOK_SECRET=whsec_...# plan:month|year:priceId, comma-separated; every paid plan needs one.STRIPE_PRICES=pro:month:price_...,pro:year:price_...,business:month:price_...With
billingin the product config, a missing variable or a paid plan without a price fails the boot. In development only, leaving all three out is allowed: the API starts, plans and limits work, and checkout, the portal and the webhook answer 409BILLING_NOT_CONFIGUREDuntil you add your Stripe test keys. -
Webhook: add an endpoint
https://<host>/api/billing/webhookwith the eventscheckout.session.completed,customer.subscription.created,customer.subscription.updated,customer.subscription.deleted. Its signing secret isSTRIPE_WEBHOOK_SECRET. -
Customer Portal (Settings → Billing → Customer portal): allow plan switching between your Pro and Business prices, cancellation, payment method updates and invoices. “Manage billing” opens it.
Locally
stripe listen --forward-to localhost:3000/api/billing/webhookprints a whsec_... to use as STRIPE_WEBHOOK_SECRET. Pay with the test
card 4242 4242 4242 4242 (any future date, any CVC).
In the app
- Billing tab (organization screen,
Billing.Readorganization-wide;ORG_ADMINholdsBilling.Read/Create/Update): the plan and why, usage per limit, the paid plans with Upgrade (Stripe Checkout) and Manage billing (Customer Portal). Back from Checkout it waits for the webhook. A platform admin sees a plan override select instead of Upgrade and Manage billing: paying is the organization’s own, so checkout and the portal refuseX-Organization-Id(400ORGANIZATION_OVERRIDE_NOT_ALLOWED). - A request refused by a limit or a feature shows a toast; for a billing reader
(
Billing.Readorganization-wide, or a platform admin) it names the plan and has an Upgrade link. Anyone else gets the plain message without it. - Platform:
GET /api/organizationsrows carryplan;PATCH /api/organizations/:id/plan { planOverride }sets or clears the override (platform admin).
API: GET /api/organization/billing, POST /api/organization/billing/checkout { plan, interval }, POST /api/organization/billing/portal,
POST /api/billing/webhook (Stripe only).
In a product’s code
Counting your own things against a plan’s limits, and gating routes by plan features, is in Plans and limits.
Turning billing on for an existing product
Billing starts from the migration that creates organization_subscriptions
with no rows: every organization that already exists is on the default
plan at once (only organizations created afterwards get the trial). An
organization above the default plan’s limits keeps everything but can add
nothing. Before switching it on, give existing organizations a plan:
-- e.g. everyone gets the trial from todayINSERT INTO organization_subscriptions (id, "organizationId", "trialEndsAt", "updatedAt")SELECT gen_random_uuid(), id, now() + interval '14 days', now() FROM organizations WHERE "isDeleted" = falseON CONFLICT ("organizationId") DO NOTHING;or set a plan override per organization from the platform (platform admin).
Tests
The e2e suite starts a fake Stripe (startFakeStripe in
@core/api-core/e2e, reached through STRIPE_API_BASE, refused in
production) and signs webhooks with the SDK’s generateTestHeaderString.
Organizations made by the suites get a business override unless a test
asks for none (createOrganization(..., null)), so suites not about billing
run unlimited. Seeds and the demo script give their organizations the
business override too.