This page shows how to change a plan, add one, or add a new limit that a plan sells.
You need: billing set up (Set up billing).
The catalog is PLANS in libs/domain/billing/+lib.ts:
| Plan id | Name | Price | Yearly price | Trial | Stripe price |
|---|---|---|---|---|---|
free |
Free | 0 | none | none | none |
pro |
Household | $4.00 per household a month | $40.00 | none | STRIPE_PRICE_PRO, STRIPE_PRICE_PRO_YEARLY |
Financy is paid from sign-up: the free plan allows nothing, so a new owner is sent to checkout, and
a household whose subscription ended can read, edit and export what it has but add nothing.
Household refunds the first payment within 30 days (refundDays).
A plan with a yearlyAmount is sold a year at a time too
(yearly price).
A group is on a paid plan while its subscription is trialing or active. It also keeps the plan for a grace period after a failed payment (failed payments). A canceled, incomplete or paused subscription puts the group back on Free. An event for a price not in the catalog changes nothing: the group keeps the plan it had, even when the event cancels the subscription (unknown prices). So before you replace a plan's Stripe price, keep the old price in the settings until no subscription bills it.
- Add the plan to
PLANS: itsamountin cents, itsyearlyAmount(ornullfor monthly only),trialDays,perSeatandentitlements. - Map it to its Stripe price in
planRefsandreadBillingSetup(libs/server/billing/setup.ts), with a new price variable. - Add the variable to
infra/envs/.env.example. - Pass it to the
apiandworkerservices ininfra/compose/compose.shared.yml. - Create the price in Stripe and set the variable.
A product sells its own plans: edit PLANS and its price variables, as above, and Seed's tests keep
passing. They do not read PLANS:
- Unit and integration tests run on a fixed catalog of their own,
TEST_PLANSinlibs/domain/billing/test-plans.ts, a copy of Seed's Free and Pro. A function that reads the catalog takes it as its last parameter,PLANSby default, and the tests passTEST_PLANS. Production code never imports that file;tests/test-catalog-boundary.test.tsfails if it does. The worker's billing jobs take the catalog asJobDeps.plans. - A test spells out only the caps it is about:
testLimitsin the same file fills in every other limit key, andtestUsagegives the entitlement gate a count for every key that fails the test if read. A limit key the product adds needs no edit in Seed's tests, and the test plans give it no cap. - The website's tests and the demo seed's test read
PLANSitself: they check what the site and the demo show. - The browser tests read what the running stack answers: the trial from
GET /api/billing/trial, the note cap from the API's refusal and the seat price from the group's billing. When the free plan allows nothing (the development routeGET /api/test/free-plansays so), the fixtures put every new group on the paid plan through the development provider's webhook, so the specs run on any catalog: the sign-up fixture (e2e/fixtures/sign-up.ts) pays for the group sign-up made,createGroup(e2e/fixtures/groups.ts) for a group made on its page, and a spec that creates a group through the API callspayForGroupafter it. A spec about the free plan itself opts out with{ keepFree: true }, ase2e/plan-limits.e2e.tsande2e/billing.e2e.tsdo. In that mode:- sign-up waits for the worker to finish the starter data first (
POST /api/test/starter-data), so a new account has no welcome note, every run the same; - a paid group bills per seat, so a spec that invites through the API sends
acceptSeatPrice: true; - an imported group starts on the free plan, so the import is refused.
- sign-up waits for the worker to finish the starter data first (
Keep the ids free and pro (FREE_PLAN_ID and PRO_PLAN_ID): the code names the free plan and
the development provider maps its prices to pro. To rename the paid plan, change its name.
Financy's Household plan is not billed per member (perSeat: false): one price covers up to six
members, so checkout asks for one seat and nothing below runs. The rest of this section is how
Seed bills a plan that sets perSeat.
A per-member plan sets perSeat. The subscription's quantity is the group's member count, the
owner included. That is the same count maxMembers caps. A pending invitation reserves
no seat; accepting it does.
- Checkout asks Stripe for one seat per member at that moment.
- A member joins, leaves or is removed. The change is saved first. The worker then queues a seat sync in the outbox, which asks Stripe for the new quantity when it differs from the stored one.
- Stripe prorates. By its default, the rest of the period is added to, or credited on, the next invoice. Nothing is charged at once, and the app computes no money.
- Stripe is down. The member is added all the same. The outbox retries the sync with backoff and the same idempotency key, so Stripe applies it once.
- The webhook stores the quantity Stripe reports. When it differs from the member count, it queues a seat sync in the same transaction.
- The nightly cleanup queues a seat sync for every group whose stored quantity differs from its count. That catches a sync the outbox gave up on, and a count that moved without a group change, such as a deleted account.
The owner confirms the price. In a group billed per member, the invitation form shows what one
more member adds to the bill, with a box to accept it. A create without acceptSeatPrice: true is
refused with SEAT_PRICE_NOT_ACCEPTED, so the rule holds without JavaScript too. Only the owner and
admins create invitations, so nobody else learns about the bill.
An invitation created before its group moved to per-member billing carries no price confirmation. Accepting it still adds a seat.
A plan's entitlements hold feature flags and numeric limits. No handler names a plan: a command
says what it needs, and the plan answers.
| Key | Free | Household | Checked when |
|---|---|---|---|
memberRoles |
no | yes | A member is promoted (GroupMemberRoleCommand) |
budgets |
no | yes | A budget limit is set, envelopes are switched on or money moves between them; removing a limit or switching back to limits passes on every plan |
maxNotes |
0 | no cap | A note is created, or notes move into the group |
maxMembers |
1 | 6 | An invitation is created or accepted |
maxAttachments |
0 | no cap | A note attachment is signed or confirmed: files of all notes |
storageBytes |
0 | 10 GiB | The same: the bytes of those files |
maxAccounts |
0 | no cap | An account is created or restored, or imported with a file |
maxCategories |
0 | no cap | A category is created or unarchived, or imported with a file |
maxTransactions |
0 | no cap | A transaction is created or restored |
maxRecurringRules |
0 | no cap | A repeating entry's rule is created or restored |
A limit of null means no cap. Each plan also has attachments rules for one file and one note
(maxFileBytes 2 MiB, maxPerNote 10 on both plans). They are not sold by a plan, so they hold
with billing off too (Limits).
Only a promotion is a paid feature. Any plan may give a member a lower role or remove them. A group back on Free can still demote an admin it made on Pro.
Downgrade deletes nothing. Everything over a cap stays readable, editable and deletable. Only a write that adds to the count is refused, until the group is under the cap or upgrades.
Billing off. With BILLING_PROVIDER=off nobody can pay to lift a cap, so every group gets every
feature with no cap (UNLIMITED). The settings page still shows the group as Free.
A plan refusal is 402 Payment Required, so a client can tell it from a role refusal (403):
{
"error": {
"code": "forbidden",
"message": "The group has reached its plan's limit",
"reason": "PLAN_LIMIT_REACHED",
"details": {
"code": "PLAN_LIMIT_REACHED",
"entitlement": "maxNotes",
"limit": 10,
"canUpgrade": true
}
}
}
reason is PLAN_FEATURE_MISSING or PLAN_LIMIT_REACHED. canUpgrade is true only for the owner,
who alone can change the plan. The socket answers the same (api-errors.md). In the
SPA, PlanRefusalNotice gives the owner a link to the Billing tab and tells anyone else to ask the
owner.
- Add the key to
FeatureKeyorLimitKeyinlibs/domain/billing/+lib.ts. TypeScript then asks for it in every plan and inUNLIMITED. - Give it a refusal message: its wording in
REFUSAL_KEYS(libs/ui/plan-refusal.tsx), and those keys in the app's catalogs ini18n/app/. TypeScript asks for the entry; without it the app cannot say why the plan refused. - List each command that needs it in
ENTITLEMENT_NEEDS(apps/api/cqrs/entitlement-needs.ts), withneedsFeatureorneedsRoom. - For a limit, add a usage count to
usageinapps/api/cqrs/entitlements.ts. The gate refuses to start without one. - For a limit, count again inside the write's transaction, as the note repository does.
- For a limit on an aggregate that "move all of a group's data" moves, its
MovableAggregatecounts what it moves againstcontext.allowances.<key>, every cap of the target's plan, which the gate fills in (docs/aggregate-lifecycle.md).
Step 5 closes a race: the gate counts before the handler runs, so two requests can both pass it for
the last free slot. The gate passes the cap to the handler (allowance on the command). The
repository counts again after its insert, under the group's row lock, and refuses the second write.
tests/integration/plan-limits.integration.test.ts races two creates to prove it. A handler that
gets no allowance throws, so a bus without the gate fails closed.
The gate runs on the command bus, after the idempotency middleware, so a retry still gets its stored answer. It judges a command only when the handler would allow it on some plan. A stranger still gets 404, and a viewer who tries to write gets 403, on every plan.