Skip to content

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:
    1. a platform admin’s override (comped or invoiced by hand);
    2. a subscription whose Stripe status is active, trialing or past_due (Stripe keeps retrying a failed payment; access stays until it gives up);
    3. the trial a new organization gets (billing.trial, no card; a self-service signup gives it to a user’s first organization only);
    4. 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 or null is unlimited. Core counts members (live members plus pending invitations: an invitation holds its seat) and units. A product adds its own (keelmint: todos), see below.
  • features: core knows audit (the organization audit log). A product adds its own, see below.
  • paid: true plans are sold; the default plan must not be paid.
  • defineProductConfig refuses 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

  1. In the Stripe dashboard (test mode first), create a Product per paid plan and a recurring Price per interval you sell (monthly, yearly).

  2. Set the API’s environment (apps/<product>/api/.env, in production deploy/<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 billing in 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 409 BILLING_NOT_CONFIGURED until you add your Stripe test keys.

  3. Webhook: add an endpoint https://<host>/api/billing/webhook with the events checkout.session.completed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted. Its signing secret is STRIPE_WEBHOOK_SECRET.

  4. 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

Terminal window
stripe listen --forward-to localhost:3000/api/billing/webhook

prints 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.Read organization-wide; ORG_ADMIN holds Billing.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 refuse X-Organization-Id (400 ORGANIZATION_OVERRIDE_NOT_ALLOWED).
  • A request refused by a limit or a feature shows a toast; for a billing reader (Billing.Read organization-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/organizations rows carry plan; 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 today
INSERT INTO organization_subscriptions (id, "organizationId", "trialEndsAt", "updatedAt")
SELECT gen_random_uuid(), id, now() + interval '14 days', now()
FROM organizations WHERE "isDeleted" = false
ON 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.