Status: enforced. These rules are what "one credit mechanism" means in this
codebase. They're not aspirational — packages/core-billing/test/no-direct-writes.test.ts
fails the build on violations of rule 2, and TypeScript enforces rules 1 and 4.
1. Every price lives in one of two places
CREDIT_COSTS(packages/core-types/src/credit-costs.ts) — every fixed per-action price and rate coefficient. Frontend imports it from@repo/core-types, backend from@repo/core-database(re-export).- The plan/pack/addon DB tables (
plans.baseCredits,credit_packs,addon_catalog.creditCostPerUnit) — prices that admins manage as data.
A service-local const SOMETHING_COST = 20 is a bug, even when it works: it
makes the price invisible to the registry, to pricing audits, and to anyone
reasoning about what the platform charges. (Four of these existed —
alias/mailbox claims in apps/communication, two newsletter send paths —
plus a ratio duplicated three times. All consolidated 2026-08-29.)
Derived values derive: EMAIL_RECIPIENTS_PER_CREDIT is computed from
EMAIL_SEND_THOUSANDTHS in the registry, and a unit test pins the product.
Never restate a ratio a second time.
2. Every balance mutation goes through CreditLedger
packages/core-billing/src/ledger.ts is the only code allowed to write
tenant_credits / credit_transactions (plus migrations, test fixtures,
and one allowlisted zero-balance wallet bootstrap in manager's
provisionFreePlan). The guard test enforces this with a repo-wide scan.
This rule has history: manager used to run a parallel transaction-based
debitCredits/addCredits implementation, and free-plan provisioning
wrote wallet rows with no transaction row at all — permanently violating
balance == SUM(credit_transactions.amount) for every free tenant. Both
are gone; manager's functions are thin delegates over the ledger now.
The ledger's API surface:
| Method | Use for |
|---|---|
charge |
Fixed debit (a publish, a send) — pattern 1 & 3 |
hold / capture / void |
Uncertain-cost work (AI with unknown token usage) — pattern 2 |
grant |
Bucket-targeted credit: plan dispensing (subscription + expiry), packs/welcome/admin grants (permanent) |
refund |
Compensating refund of a settled debit |
adjust |
Admin ± with mandatory description |
balance / balanceDetailed |
Reads (lazy expiry, never write) |
sweepExpiredHolds |
Cron housekeeping |
3. Three charge patterns — pick one, don't invent a fourth
- Flat charge:
creditGuardmiddleware for REST routes (exactCREDIT_COSTSvalue, auto-refund on failure), orchargedCall(packages/core-billing/src/kit.ts) for MCP handlers and anything that needs to wrap arbitrary async work — charge once, runwork, refund on throw or on a domain-levelrefundWhenpredicate. This is the standard: five services used to hand-roll near-identical charge/refund wrapper pairs (chargeChatAi/refundChatAiand four siblings) with six different insufficient-credits response shapes;chargedCallreplaced all of them 2026-08-29. Reach for a rawledger.charge/ledger.refundpair only when your control flow genuinely can't fitchargedCall's shape (see blog'spublish-billing.ts, which keeps custom once-per- live-cycle dedup logic around aCreditLedgerdirectly). - Metered accumulator: sub-credit prices (email 0.2cr, forms 0.25cr,
chat tokens) accumulate thousandths per tenant and settle whole credits
through
ledger.chargewhen they cross 1000. The settlement algorithm itself is generic —accumulateAndMaybeSettle/canUsageProceed/flushUnbilledUsage(packages/core-billing/src/accumulator.ts) — andchat-usage.ts/email-usage.ts/forms-service/src/lib/credits.tsare thin adapters over it, wiring only their own table. A new metered integration builds an adapter the same way; it does not reimplement the settlement math. - Hold/capture: reserve worst-case, settle actual, refund the delta.
See
brain-service's knowledge-embed flow.
Every pattern renders insufficient-credits/plan-inactive failures through
the same shape: creditErrorBody() turns an InsufficientCreditsError /
PlanInactiveError into { code, message, required?, balance?/status? },
and mcpChargeFailure() / restChargeFailure() wrap that into the
MCP ({ data: null, error }) or REST ({ error }) envelope plus the right
HTTP status. chargedCall and creditGuard both already emit this — a
manual charge/refund pair should too, instead of a bespoke error string.
mcp-dispatch parses the object (not just a string) out of both the
non-2xx and the 200-with-error response shapes, so an agent sees
code/required/balance instead of an opaque "Service returned 402";
flows-service treats both codes as permanent (never retried).
4. Reasons are a closed registry
reason is typed CreditReason (core-billing/src/reasons.ts). Freeform
context goes in description, never in reason — the reason column is what
analytics, spend-by-service, and reconciliation group by. Admin routes
validate against the admin.* subset via zod enum. Adding a new charge type
means adding the reason to the registry first.
5. Idempotency-key conventions
- Money-in (webhooks/payments):
<provider>_<eventOrPaymentId>— e.g.rzp_pay_<id>,rzp_sub_charge_<subId>_<periodStart>. - Metered/daily:
<service>:<entity>:<period>— e.g.analytics:<connectionId>:<YYYY-MM-DD>. - Provisioning:
free_welcome_<tenantId>. - Same key ⇒ same params. The ledger returns the winner's row on replay; that's only correct if every writer with that key meant the same charge.
Crash-window semantics (deliberate): the ledger is move-first — money moves, then the tx row lands; a loser of the unique-index race compensates. A crash between the two steps yields a detectable skew (integrity audit Check 2) and a replay that re-applies. The old manager path was claim-first, where the same crash silently swallowed paid credits with nothing to detect. Detectable-and-rare beats silent-and-rare; don't "fix" this by reordering.
6. Expiry is audited
Any ledger write that touches a wallet whose subscription bucket is past
subscription_expires_at zeroes it AND writes a subscription_expired
transaction row (race-free CAS; see expireElapsedWithAudit). The inline
CASE-zeroing in the debit/credit SET clauses remains as the backstop, so
expired credits are never spendable even in the crash window. Invariants:
- Check 1:
balance == effective_subscription + permanent - Check 2:
balance == SUM(credit_transactions.amount)
Both are asserted continuously by apps/manager/src/services/integrity-audit.ts
and by the cross-implementation parity tests
(apps/manager/test/credit-mechanism-parity.test.ts).
7. System tenants bypass everything
isSystemTenant() ("system", "logicspike-internal") short-circuits every
ledger method into a no-op with MAX_SAFE_INTEGER balance. This now applies
uniformly — including manager's admin/addon flows, which historically used
the parallel implementation without the bypass. A grant to a system tenant
is deliberately a no-op.
8. DB drivers
The ledger takes a LedgerDb (structural Pick of the Drizzle client) and
uses only single-statement CAS — no transactions — so it runs identically on
manager's neon-serverless Pool and every other service's neon-http client.
Pass an http client through asLedgerDb() (one documented boundary cast, not
as never). The db must ALWAYS be the CORE database (CORE_DATABASE_URL),
never a service's regional DB — see the warning box in ledger.ts.
Constructing that http client per call allocates a fresh connection on every
charge and every refund. createCachedHttpDb() (core-database/src/client.ts)
memoizes a neon-http client per connection string per isolate — safe to cache
because neon-http is a stateless fetch wrapper, unlike the neon-serverless
Pool (which must NEVER be cached across requests — see that file's warning
box). Every MCP handler that builds a ledger from CORE_DATABASE_URL should
go through createCachedHttpDb(), not a bare createDb()/neon() call.
9. Recurring charges: a generic primitive, and one grandfathered exception
Billing a tenant on an interval (monthly, or any intervalDays) for a
dynamically-created resource — a claimed alias, a mailbox seat, anything a
tenant can create/delete at will rather than a fixed catalog SKU — goes
through packages/core-billing/src/recurring.ts:
registerRecurringCharge / pauseRecurringCharge / resumeRecurringCharge /
cancelRecurringCharge / getRecurringCharge / listRecurringCharges /
processRecurringCharges, backed by one recurring_charges table
(unique on tenant_id, service_code, resource_ref). apps/billing-crons
sweeps it hourly via POST /billing/internal/recurring-charges.
The primitive is deliberately dumb: it moves money and tracks status
(active / paused / cancelled) — it does not suspend the resource
when a charge fails. The owning service discovers a pause by reading
status on its own serve path (getRecurringCharge) and decides what
"suspended" means for that resource. No event emission, no webhook — a
status column any consumer can poll has no delivery-failure mode.
Concurrency: the sweep's period advance is a CAS gated on the observed
period_index, and the ledger charge is idempotency-keyed on
recurring:<id>:<periodIndex> — two overlapping sweeps either both hit the
same idempotency key (one real charge, one free replay) or one loses the
CAS race after its charge already landed under the shared key. Either way,
exactly one debit per period. registerRecurringCharge itself is also
concurrent-registration-safe: it mints a fresh id per call (so the ledger's
own idempotency key can't dedupe two truly concurrent registrations for the
same resource), so the row insert is the real concurrency gate — an
onConflictDoNothing on (tenant_id, service_code, resource_ref), with the
loser refunding its own first-period charge and returning the winner's row.
First production adopter: comms.alias.claim and mailbox.provision
(apps/communication) — previously one-time-forever charges, now monthly
via this primitive. dispatchSend() reads getRecurringCharge live to
decide whether a claimed alias/mailbox can still send (fails open if no row
exists yet, or the lookup errors); nothing else about the resource is
touched. Read this pair first if you're adding a new recurring integration.
Grandfathered exception: tenant_addons / processAddonRenewals
(apps/manager) is NOT migrated onto this primitive, on purpose. Addons are
catalog-SKU-shaped (their price is admin-mutable addon_catalog data, not a
value frozen at registration time) and a renewal triggers
rebuildEntitlements — both are the wrong fit for a primitive that stays
intentionally unaware of what it's billing for. If you're adding a NEW
recurring charge, use recurring.ts; do not extend the addons engine to
cover it.
HOWTO: add a per-action charge to an MCP tool
- Add the price to
CREDIT_COSTS(packages/core-types/src/credit-costs.ts) if it isn't there yet — never a localconst. - Add the reason to
CREDIT_REASONS(packages/core-billing/src/reasons.ts) — dot-namespaced,<service>.<action>[.<phase>]. - In the MCP handler, build the ledger via
new CreditLedger(asLedgerDb(createCachedHttpDb(c.env.CORE_DATABASE_URL)))— never a barecreateDb()/neon()call per request. - Wrap the handler's work in
chargedCall({ ledger, tenantId, cost: "YOUR_COST_KEY", reason: "your.reason", idempotencyKey, work: () => ... }). Always pass anidempotencyKeyunless the action is genuinely unrepeatable — omitting it means a client retry double-charges. - If the work can report a "this wasn't really billable" outcome (not
found, no-op, suppressed), pass
refundWheninstead of hand-rolling a refund call after the fact. - On
!outcome.ok, rendermcpChargeFailure(outcome)(MCP) orrestChargeFailure(outcome)(REST) — never a bespoke string body. - Report
creditsCharged: outcome.creditsChargedin the success response so the caller can see idempotent replays and system-tenant bypasses costing 0. - Add
billing: { kind: "flat", costKey: "YOUR_COST_KEY", reason: "your.reason" }to the tool'sCatalogEntry(packages/service-catalog/src/*.catalog.ts) and add itsnamespace.toolname to the hand-list inpackages/service-catalog/test/billing-parity.test.ts.
HOWTO: add a recurring charge for a dynamically-created resource
- Add the price to
CREDIT_COSTSand the reason toCREDIT_REASONS(same as steps 1–2 above). - When the resource is created, call
registerRecurringCharge(asLedgerDb(coreDb), { tenantId, serviceCode: "your-service", resourceRef: <the resource's id/slug>, amount, reason, intervalDays, chargeFirstPeriodNow: true }). On{ ok: false }, surface it the same waychargedCallfailures are surfaced (step 6 above) and do NOT create the resource. - When the resource is deleted/released, call
cancelRecurringChargeso the sweep stops billing for something that no longer exists. - On your own serve path for the resource, call
getRecurringCharge(orlistRecurringCharges) and treat apausedstatus as "suspend access" — the primitive will not do this for you. - If the resource supports a manual pause/resume UX, wire it to
pauseRecurringCharge/resumeRecurringChargedirectly — don't touch therecurring_chargestable yourself (theno-direct-writesguard test enforces this the same way it enforces rule 2 above). - No catalog/dashboard tooling change is required —
apps/billing-cronsalready sweeps everyactiverow hourly regardless ofservice_code.