Nine capabilities, four statuses — because two is how a hollow surface passes as a working one
Most status vocabularies have two words in them, and the second word is doing far too much work. Built here means reachable, persisting, and doing its whole job with no payment key set. Honest-off means complete and refusing on purpose until a key or a founder flag is set. No data layer means correctly gated, validating through a real engine, and storing nothing. Inert means written, tested, and called by nobody. The last two are not features and this page never counts them as any.
Past-due state and the way back
Dunning as a state read, not an email cadence — and the recover path is never locked behind the gate it clears
The dunning endpoint is a pure read over the billing state machine: it resolves the account’s current standing (past due, suspended, cancelled) and hands back the recover action the interface should offer. It has no payment-provider dependency, which is why it does its whole job with no key set at all — this is the surface that works on day one. One design decision inside it is worth reading the file for. The billing-management routes are deliberately NOT put behind the billing gate, and the file says why in its own words: a past-due school must be able to reach the portal to fix its payment, and gating the recover path behind the very gate it clears would trap the payer. Most billing suites do the opposite — they lock the account and then ask you to sign in to pay.
Source: apps/api/src/routes/billing-admin.ts → resolveDunningState
apps/api/src/routes/billing-admin.ts → requireBillingAdmin
Built · works with no payment key set
Commission statements and payouts
A payout that cannot settle is HELD, with a reason code — never a fabricated settlement
Statements move draft → finalized → paid through a one-winner compare-and-set, and the amount is PINNED at finalize rather than re-derived at pay time. A pre-existing payout whose pinned amount diverges from the statement total is a 409 payout_amount_divergent — never a silent re-derive under the same idempotency wall. The part that matters most is what happens when the destination is not ready. A representative with no payouts-enabled connected account does not get a fake success and does not get a transfer to nowhere: the request returns 409 rep_payout_account_pending, the statement stays finalized, the payout row stays finalized, and no transfer fires. The held payout drains later, through the same row and the same deterministic transfer key, so onboarding the rep resolves it without a double-pay. A studio-funded payout is held the same way until its recovery invoice is paid — the platform never fronts an unrecovered commission. And a rep never finalizes or pays their own statement; separation of duties is checked before anything else. The same rule binds the payment rail itself, and this is the one we had to go back and fix. With no usable payment key — or a live key present while the go-live switch is still off — the layer runs its offline stand-in, whose transfer ids exist only in process memory. Marking a statement paid off those ids would be a settled payout with no money behind it, so the route now refuses with a 503 before it mutates anything: the statement stays finalized, the amount stays pinned, no payout row is created, and no audit record claims a transfer. It is the identical hold to the one above, applied to the rail instead of the destination, and it drains the same way once a key is set.
Source: apps/api/src/routes/org-statements.ts → rep_payout_account_pending
apps/api/src/routes/org-statements.ts → payout_amount_divergent
apps/api/src/routes/org-statements.ts → assertPayoutRailCanSettle
apps/api/src/services/payout-rail-guard.ts → PAYOUT_HONEST_OFF_CODE
Built · with no key set, the payout is refused before it mutates — never marked paid
Installment plans
Plan and schedule that reconcile to the penny, behind an idempotency wall
A plan is materialized against the order’s server-authoritative total — never a number the client sent — and the schedule’s integer cents sum to that total exactly, with the rounding remainder front-loaded so nobody is asked to pay a fractional cent. The plan id is deterministic in the order id, so one order has exactly one plan: a re-initiate returns the same plan instead of minting a second. On top of that it carries the same Idempotency-Key claim/commit/fail discipline order creation uses. The gates run in the right order: a minor is denied commerce entirely, only a finance role or admin may initiate financing, and a suspended or past-due school gets a 402 BEFORE the idempotency key is claimed — so a gated caller never burns a key. What it deliberately does NOT do: charge. This route owns the plan and the schedule; the charge rides the order’s payment intent, and settlement stays processing until the webhook confirms. The storefront never reports paid off a freshly created plan.
Source: apps/api/src/routes/installments.ts → requireIdempotencyKey
apps/api/src/routes/installments.ts → requireBillingActive
Built · plan and schedule persist; the charge is a separate, honest-off path
Prepay credits
Comp credits that really mint and really audit — and a paid credit that is structurally unreachable
A platform administrator can mint a comp credit for a specific student, and it is a real write to a real table with a real migration behind it, plus exactly one audit row per grant attributing the comp to the administrator who made it. The role gate is strict: an account manager is denied outright, and even an eligible administrator must have deliberately elected admin scope first. The guardian side is claim-token gated. A blank token is a 401 claim_required, and the store re-verifies the claim INSIDE the query that lists the credits — so the route cannot bypass the wall even by accident. The result is a display-only quote of what the credits would cover: nothing is redeemed, nothing is charged, and the charged amount is structurally zero. A credit sourced from a real payment cannot be minted at all. The rail flag is a compile-time constant set to false, the store refuses the mint, and the route maps that refusal to a 409 prepay_rail_disabled. A paid credit is not merely disabled — it is unreachable.
Source: apps/api/src/routes/prepay.ts → mintPrepayCredit
packages/db/prisma/migrations/0957_photo_prepay_credit
Built · comp grants persist and audit; the paid rail is a hard-off constant
Bank debit, disclosed before it is charged
Store the bank-debit method and disclose a zero fee — then refuse to debit until a founder flips it
Selecting a bank-debit method stores it and returns the fee disclosure up front, at zero. What it does not do is debit: the stored method comes back with status queued_not_charged and railProvisioned:false, because there is no live bank rail behind it. No account or routing number is ever accepted — only a display last-four and an opaque mandate reference. The separate activation route is founder-gated and returns 503 ach_rail_not_enabled while the flag is off, which is the default. Flipping that flag lifts the 503 and still does not move money, because no rail is provisioned. Both halves are built; neither is switched on.
Source: apps/api/src/routes/billing-admin.ts → ach_rail_not_enabled
apps/api/src/routes/billing-admin.ts → queued_not_charged
Honest-off · stored and disclosed, never debited; activation is founder-gated
Hosted portal and invoice list
The provider-hosted billing portal and the invoice list — complete, and refusing until a key exists
Both routes are written, role-gated, and audited. With no provider key set, the portal-session route refuses with a 503 carrying the machine code seam_unavailable and the message “billing portal unavailable” — never a crash, and never a fabricated session URL pointing somewhere that does not exist. The invoice list degrades to an empty list rather than inventing a charge. That distinction is the whole discipline: an empty invoice list is a true statement about a school with no invoices, so it is allowed to be a 200; a portal URL is either real or it is a lie, so its absence has to be a refusal.
Source: apps/api/src/routes/billing-admin.ts → seam_unavailable
apps/api/src/routes/billing-admin.ts → createBillingPortalSession
Honest-off · 503 seam_unavailable with no key; never a fabricated session
License subscriptions
Subscription lifecycle, built end to end, refusing at the mint
The subscription routes are complete and carry their own minor-wall security test. Without a provider key the payments port has no subscription creation method at all, so the service throws a structured seam_unavailable and the route maps it to a 503 — the mint refuses rather than pretending. Cancellation still flips the local row, because that is a real local state change that does not require a provider. Set a test key and the same routes mint against test mode with no code change. Nothing about going live is a rewrite; it is two configuration values, and the page says which two.
Source: apps/api/src/routes/subscriptions.ts → SubscriptionError
apps/api/src/routes/subscriptions.ts → seam_unavailable
Honest-off · 503 at mint with no key; local cancel still applies
Tuition collections console
Reachable, correctly gated, validating through a real engine — and storing nothing at all
This is the one to read carefully, because it is the one that would be easiest to oversell. The console is real in every way except the way that counts. The tenant wall holds, the finance entitlement is a 402 module-access gate, the finance-officer role check is fail-closed and builds its actor entirely from server-resolved context, and the write path binds the shipped installment engine so a schedule that does not reconcile is a genuine 422 rather than a silent accept. And then nothing is written. The three read endpoints return hard-coded empty structures. The write endpoint validates, succeeds, and honestly answers recorded: false with an empty schedule reference, because there is no family-account ledger, no installment-slot table, and no aging table to write to. The route file states this in its own header: no persistence in this kit. We are not going to describe this as tuition billing. It is a correctly built front half waiting on a back half, and that is exactly what it will be called until the tables exist.
Source: apps/api/src/routes/tuition-collections.ts → recorded: false
apps/api/src/routes/tuition-collections.ts → NO PERSISTENCE IN THIS KIT
No data layer · gated and reachable, stores nothing — not a working capability
Tuition billing engine core
Four hundred and sixty-nine lines of correct pure math with nobody calling it
The tuition billing engine — per-period billing, discount application, fee handling before the split, plan subtotals — is written and unit-tested as a pure module. It has zero callers anywhere in the application tree. Its only consumer is a sibling pure module whose own exports also have no application callers; both are reachable only as re-exports from the shared barrel. The measurement was run with a positive control in the same invocation, so the zero is a provable absence rather than a grep that quietly failed. It is listed here because leaving it off the page would be the dishonest choice. When it is wired to a route, it moves up this list and this card says so. Until then it is inert, and inert is not a feature.
Source: packages/shared/src/tuition-billing-engine-core.ts → billOnce
packages/shared/src/tuition-billing-engine-core.ts → planSubtotalCents
Inert · zero production callers — not a working capability