logicspike/docs

Billing

Credit System Standards

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

  1. Flat charge: creditGuard middleware for REST routes (exact CREDIT_COSTS value, auto-refund on failure), or chargedCall (packages/core-billing/src/kit.ts) for MCP handlers and anything that needs to wrap arbitrary async work — charge once, run work, refund on throw or on a domain-level refundWhen predicate. This is the standard: five services used to hand-roll near-identical charge/refund wrapper pairs (chargeChatAi/refundChatAi and four siblings) with six different insufficient-credits response shapes; chargedCall replaced all of them 2026-08-29. Reach for a raw ledger.charge/ledger.refund pair only when your control flow genuinely can't fit chargedCall's shape (see blog's publish-billing.ts, which keeps custom once-per- live-cycle dedup logic around a CreditLedger directly).
  2. Metered accumulator: sub-credit prices (email 0.2cr, forms 0.25cr, chat tokens) accumulate thousandths per tenant and settle whole credits through ledger.charge when they cross 1000. The settlement algorithm itself is generic — accumulateAndMaybeSettle / canUsageProceed / flushUnbilledUsage (packages/core-billing/src/accumulator.ts) — and chat-usage.ts / email-usage.ts / forms-service/src/lib/credits.ts are 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.
  3. 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

  1. Add the price to CREDIT_COSTS (packages/core-types/src/credit-costs.ts) if it isn't there yet — never a local const.
  2. Add the reason to CREDIT_REASONS (packages/core-billing/src/reasons.ts) — dot-namespaced, <service>.<action>[.<phase>].
  3. In the MCP handler, build the ledger via new CreditLedger(asLedgerDb(createCachedHttpDb(c.env.CORE_DATABASE_URL))) — never a bare createDb()/neon() call per request.
  4. Wrap the handler's work in chargedCall({ ledger, tenantId, cost: "YOUR_COST_KEY", reason: "your.reason", idempotencyKey, work: () => ... }). Always pass an idempotencyKey unless the action is genuinely unrepeatable — omitting it means a client retry double-charges.
  5. If the work can report a "this wasn't really billable" outcome (not found, no-op, suppressed), pass refundWhen instead of hand-rolling a refund call after the fact.
  6. On !outcome.ok, render mcpChargeFailure(outcome) (MCP) or restChargeFailure(outcome) (REST) — never a bespoke string body.
  7. Report creditsCharged: outcome.creditsCharged in the success response so the caller can see idempotent replays and system-tenant bypasses costing 0.
  8. Add billing: { kind: "flat", costKey: "YOUR_COST_KEY", reason: "your.reason" } to the tool's CatalogEntry (packages/service-catalog/src/*.catalog.ts) and add its namespace.tool name to the hand-list in packages/service-catalog/test/billing-parity.test.ts.

HOWTO: add a recurring charge for a dynamically-created resource

  1. Add the price to CREDIT_COSTS and the reason to CREDIT_REASONS (same as steps 1–2 above).
  2. 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 way chargedCall failures are surfaced (step 6 above) and do NOT create the resource.
  3. When the resource is deleted/released, call cancelRecurringCharge so the sweep stops billing for something that no longer exists.
  4. On your own serve path for the resource, call getRecurringCharge (or listRecurringCharges) and treat a paused status as "suspend access" — the primitive will not do this for you.
  5. If the resource supports a manual pause/resume UX, wire it to pauseRecurringCharge / resumeRecurringCharge directly — don't touch the recurring_charges table yourself (the no-direct-writes guard test enforces this the same way it enforces rule 2 above).
  6. No catalog/dashboard tooling change is required — apps/billing-crons already sweeps every active row hourly regardless of service_code.
Billing