billing-kit — Architecture
Synced from billing-kit/docs/ARCHITECTURE.md — the repo is canonical.
Usage-based billing as a library, over a provider you choose.
your app ──record(usage)──▶ usage_events ──aggregate──▶ usage_aggregates
│ price
▼
provider ◀────settle(period)──── ledger_entries ◀──post── charges
│ ▲
└────webhook (payment, refund)───────┘
billing-kit owns everything left of settle. The provider owns everything right
of it. That line is the whole design, and it is drawn where it is because it is
the only place all three of Stripe, Paddle and Lago agree.
1. Scope
billing-kit does, always, itself:
| Why it cannot be delegated | |
|---|---|
| Metering — accepting and deduplicating usage events | The event rate is your traffic's rate, not a provider's API budget. |
| Aggregation — events to billable quantities | Two of the three providers cannot do it; the one that can is not always the one you ship with. |
| Pricing — quantities to amounts | You must be able to answer "why is this number" from your own tables, offline, at any time. |
| The ledger — an append-only double-entry record | It is the financial record. It has to survive changing providers. |
| Idempotency | Every provider's idempotency window is finite and shorter than your incident. |
The provider does:
customers, subscriptions, settlement of a closed period, payment capture, refunds, webhook signature verification. Nothing else.
billing-kit is not: a tax engine, a dunning system, a pricing UI, an accounting system, or a payment processor. It has interfaces where those attach and no opinions inside them.
2. The provider interface
2.1 The problem with the obvious interface
The interface everyone writes first is Stripe's object model with the word
Stripe deleted:
// Wrong. Do not ship this.
interface Provider {
createInvoice(customerId: string): Promise<Invoice>;
addLineItem(invoiceId: string, line: Line): Promise<void>;
finalizeInvoice(invoiceId: string): Promise<Invoice>;
}
Paddle cannot implement it. Paddle is the merchant of record: it is the legal
seller, it computes and remits the tax, and it issues the document. There is no
API that says "put this line on this invoice for this amount." You tell Paddle a
quantity against a price it already holds, and Paddle decides what the invoice
says. An adapter that fakes addLineItem by accumulating lines in memory and
guessing the total will disagree with the real invoice the first time tax,
rounding or a currency conversion enters, and the disagreement surfaces as a
ledger that does not reconcile.
Lago cannot implement it faithfully either, for a different reason: Lago issues
invoices but does not capture money. It hands off to a PSP. finalizeInvoice
returning something you treat as "paid" is a lie on Lago.
So the interface must be built from what the three actually have in common, which is less than it first appears.
2.2 What the three actually do
| Stage | Stripe | Paddle | Lago |
|---|---|---|---|
| Merchant of record | you | Paddle | you |
| Usage ingest | optional (Billing Meters) | none | native (events) |
| Aggregation | optional | none | native (billable metrics) |
| Arbitrary invoice lines | yes (InvoiceItem) |
no | yes (add-ons / one-off) |
| Quantity against a held price | yes | yes, only this | yes |
| Payment capture | yes | yes | no — delegates to a PSP |
| Tax | opt-in (Stripe Tax) | mandatory, theirs | pluggable |
| Refund | immediate API call | request, subject to approval | credit note |
| Request idempotency key | yes (Idempotency-Key) |
yes | no general key |
| Event dedupe key | — | — | yes (transaction_id) |
| Webhook signature | HMAC-SHA256, t=…,v1=… |
HMAC-SHA256, ts=…;h1=… |
JWT RS256, key fetched |
Five things fall out of that table, and all five are load-bearing:
- Settlement has two shapes, not one. Some providers take priced lines, some take only a quantity. Both must be first-class.
- Under quantity settlement the provider's number is authoritative and ours is an estimate. The ledger has to record which.
- Payment is not the same event as invoicing. On Lago they are different systems entirely.
- Refunds are not always synchronous. On Paddle a refund is a request that a human may decline.
- Webhook verification may need I/O. Lago fetches a public key, so the
verifier is
asynceven though Stripe's and Paddle's are pure. Making the signature the shape of the strictest member is not a Lago concession; it is what stops a future provider from forcing a breaking change.
2.3 The interface
/** What a provider can do. billing-kit branches on this, never on a name. */
export interface ProviderCapabilities {
/** How this provider will be asked to settle a period. */
settlement: readonly SettlementMode[];
/** True when the provider is the legal seller. Suppresses our tax hooks and
* makes our computed amounts advisory — the failure this prevents is a
* ledger that reconciles against a document we did not issue. */
merchantOfRecord: boolean;
/** False when settling does not collect money (Lago). Callers must not treat
* a settled invoice as a paid one. */
capturesPayment: boolean;
/** True when a refund is a request that may be declined (Paddle). The ledger
* posts on the webhook, never on the call's return. */
refundsAreAsynchronous: boolean;
/** Provider-side request idempotency, if any. Always an optimisation on top
* of our own record, never a substitute — every provider's retention window
* is finite and shorter than a bad weekend. */
idempotency: { header: string; retentionHours: number } | null;
}
export type SettlementMode =
/** Provider accepts priced lines we compose. Stripe, Lago. */
| 'lines'
/** Provider holds the price; we send only a quantity. Paddle, and Stripe or
* Lago when the operator chose to keep prices provider-side. */
| 'quantity';
export interface BillingProvider {
readonly name: string;
readonly capabilities: ProviderCapabilities;
// --- identity -----------------------------------------------------------
/**
* Idempotent by `ref.key`, which is ours, not theirs.
*
* Must treat "a customer with this email already exists" as success and
* return the existing id. Paddle dedupes customers on email server-side and
* answers a create with a conflict; an adapter that propagates that as an
* error makes every retry after a timeout permanently fatal.
*/
ensureCustomer(ref: CustomerRef): Promise<ProviderCustomer>;
/**
* Look up by our reference. Required, not optional.
*
* This is the recovery path for providers with no request idempotency key:
* after a timeout we cannot safely retry a create, so we must be able to ask
* whether the first attempt landed.
*/
findCustomer(key: string): Promise<ProviderCustomer | null>;
// --- subscription -------------------------------------------------------
ensureSubscription(input: SubscriptionInput): Promise<ProviderSubscription>;
cancelSubscription(id: string, at: CancelAt): Promise<ProviderSubscription>;
/**
* The item a quantity is pushed against, resolved from our metric key.
* Returns null on providers where prices live on our side.
*/
resolveItem(subscriptionId: string, metric: string): Promise<ProviderItem | null>;
// --- settlement ---------------------------------------------------------
/**
* Settle one sealed period. The single operation the whole library exists
* to reach.
*
* `request.mode` is always one the provider declared. The caller picked it;
* the adapter does not choose and does not fall back — a silent fallback
* from `lines` to `quantity` changes who owns the price without telling the
* ledger, and the discrepancy shows up a month later as unexplained drift.
*/
settle(request: SettlementRequest): Promise<SettlementResult>;
/** Recovery counterpart to `settle`, by our idempotency key. */
findSettlement(key: string): Promise<SettlementResult | null>;
// --- money out ----------------------------------------------------------
/**
* On providers where `refundsAreAsynchronous`, a resolved promise means the
* request was accepted, not that money moved. The ledger posts on the
* webhook in both cases, so the two paths stay identical.
*/
refund(input: RefundInput): Promise<RefundAcknowledgement>;
// --- inbound ------------------------------------------------------------
/**
* Verify and normalise. Takes bytes, because a signature is over bytes:
* JSON.parse then re-serialise changes key order and whitespace and the HMAC
* fails. This is the single most common integration bug and the interface is
* shaped to make it impossible.
*
* Async because Lago verifies an RS256 JWT against a key it must fetch.
*/
verifyWebhook(input: RawRequest): Promise<VerifiedEvent>;
}
export interface SettlementRequest {
mode: SettlementMode;
idempotencyKey: string;
customerId: string;
subscriptionId: string | null;
period: { start: Date; end: Date };
currency: string;
/** Present when mode === 'lines'. Amounts are ours and authoritative. */
lines?: readonly SettlementLine[];
/** Present when mode === 'quantity'. No amounts — the provider prices it. */
quantities?: readonly { itemId: string; quantity: string }[];
}
export interface SettlementResult {
providerRef: string;
status: 'draft' | 'open' | 'settled' | 'void' | 'failed';
/** What the provider says the period costs. Under `quantity` this is the
* first time we learn the real number. Null when the provider has not
* priced it yet (Paddle drafts). */
providerTotal: Money | null;
/** Tax the provider computed and owns. Never folded into providerTotal by
* us — a merchant-of-record's tax is not our revenue. */
providerTax: Money | null;
raw: unknown;
}
2.4 Normalised events
verifyWebhook returns a small union. It is deliberately smaller than any
provider's event catalogue:
export type VerifiedEvent = {
providerEventId: string;
/** The provider's own ordering signal. Webhooks arrive out of order; the
* state machine uses this and never wall-clock arrival. */
occurredAt: Date;
raw: unknown;
} & (
| { kind: 'payment.succeeded'; settlementRef: string; amount: Money }
| { kind: 'payment.failed'; settlementRef: string; reason: string }
| { kind: 'settlement.finalized'; settlementRef: string; total: Money; tax: Money | null }
| { kind: 'settlement.voided'; settlementRef: string }
| { kind: 'refund.settled'; refundRef: string; amount: Money }
| { kind: 'refund.declined'; refundRef: string; reason: string }
| { kind: 'subscription.changed'; subscriptionRef: string; status: SubscriptionStatus }
/** Signature valid, meaning not modelled. Stored and acknowledged. */
| { kind: 'unknown'; providerKind: string }
);
unknown is not a gap — it is the contract. A kit that invents a normalised
kind for every provider event ends up with a union that is Stripe's catalogue
with two others crammed in. Anything not in the list above is persisted raw,
acknowledged with 200 so the provider stops retrying, and made available to the
host application. Silently dropping it, or 500-ing on it, both end in a
disabled webhook endpoint.
2.5 Honesty check, per provider
Stripe — lines and quantity. merchantOfRecord: false,
capturesPayment: true, refundsAreAsynchronous: false,
idempotency: { header: 'Idempotency-Key', retentionHours: 24 }.
settle in lines mode creates invoice items then an invoice; in quantity
mode reports against a metered price. verifyWebhook is HMAC-SHA256 over
`${t}.${rawBody}` compared to v1, with a timestamp tolerance.
Paddle — quantity only. merchantOfRecord: true, capturesPayment: true,
refundsAreAsynchronous: true. settle pushes quantities to subscription
items; providerTotal is null until Paddle prices the transaction, and arrives
on settlement.finalized. resolveItem maps our metric to a Paddle price id.
verifyWebhook is HMAC-SHA256 over `${ts}:${rawBody}` compared to h1.
Lago — lines and quantity. merchantOfRecord: false,
capturesPayment: **false**, idempotency: null. Because capturesPayment is
false, settle returning status: 'settled' means invoiced, and no ledger
entry for cash is posted until a payment webhook arrives from the PSP Lago
delegates to. Because idempotency is null, findSettlement is the recovery
path and is therefore mandatory on the interface rather than optional.
verifyWebhook verifies an RS256 JWT against Lago's published key, cached with
a TTL — the reason the method is async.
No branch anywhere in billing-kit reads provider.name. Every branch reads
capabilities. That rule is what keeps a fourth provider from being a rewrite,
and it is enforceable in review by grepping for the string.
3. Money
3.1 The rule
Amounts are integer minor units in a signed 64-bit integer. Rates are exact decimals with a declared scale. Neither is ever a JavaScript
number.
Two types, because they are two different things and conflating them is how kits end up rounding in the wrong place.
3.2 Why not float
Not because of magnitude. IEEE-754 doubles hold integers exactly up to 2^53 − 1 = 9,007,199,254,740,991, which in cents is about ninety trillion dollars — no balance you hold will overflow it.
The reason is that decimal fractions are not representable and the errors compound:
0.1 + 0.2 === 0.30000000000000004. A per-minute rate of0.000019billed 43,200 times a month is 43,200 additions of an inexact value.- Postgres
SUM(double precision)is not associative. Floating-point addition depends on order, and the planner is free to change order — a parallel aggregate, a different index, a new row count, and the same rows produce a different total. A ledger whose total depends on the query plan cannot be re-derived, and re-derivability is the only thing that makes an audit possible. - The error is silent. There is no exception, no flag, just a number that is slightly wrong in a direction that correlates with volume.
NUMERIC in Postgres and BigInt in JS both give exact integer arithmetic and
exact addition in any order.
3.3 Storage
| Thing | Postgres | Prisma | Why |
|---|---|---|---|
| Amount | BIGINT |
BigInt |
8 bytes, indexable, and SUM(bigint) widens to numeric so aggregation cannot overflow. |
| Rate / unit price | NUMERIC(38,12) |
Decimal |
A unit price is not expressible in minor units — $0.0000012 per token is real. Twelve fractional digits covers per-token pricing with headroom. |
| Quantity | NUMERIC(38,12) |
Decimal |
Gigabyte-hours and fractional seconds are not integers. |
| Currency | CHAR(3) |
String |
ISO 4217 alpha code. Never nullable, never defaulted. |
The exponent is never assumed. * 100 is wrong for JPY (0), KWD (3), CLF (4),
and for MGA and MRU, whose subunit is one fifth rather than one hundredth.
billing-kit ships an ISO 4217 exponent table and Money refuses a currency not
in it.
3.4 Crossing the JavaScript boundary
The boundary is where precision is actually lost in practice, so it is closed by construction rather than by discipline.
/**
* An exact amount. Immutable, currency-tagged.
*
* There is deliberately no constructor taking `number`. Once a value has been
* through a JS float there is no way to tell an exact 19.99 from a 19.99 that
* has already drifted, so the type refuses to accept the ambiguity rather than
* documenting it.
*/
export class Money {
private constructor(
readonly minor: bigint,
readonly currency: string,
) {}
static fromMinor(minor: bigint | string, currency: string): Money;
/** Parses lexically. Never parseFloat, never Number(). */
static fromDecimalString(value: string, currency: string): Money;
plus(other: Money): Money; // throws on currency mismatch
minus(other: Money): Money;
negate(): Money;
/**
* JSON.stringify throws on a bare bigint. Without this, a Money that reaches
* a response body crashes the request instead of losing precision — which is
* better, but still an outage. With it, the wire form is a string and is
* exact.
*/
toJSON(): { amount: string; currency: string };
/** Display only. Not for arithmetic and not for comparison. */
toDecimalString(): string;
}
Wire format, everywhere — HTTP bodies, queue messages, logs:
{ "amount": "1999", "currency": "USD" }
A string, in minor units. 1999 and not 19.99, because a decimal string
re-introduces the question of how many places the currency has, and the answer
has to be looked up somewhere; minor units carry it in the currency tag alone.
Prisma returns Decimal (decimal.js) for NUMERIC and bigint for BIGINT.
Neither is JSON-safe and neither should escape the data layer: repository
functions return Money and Rate, never a raw driver type. The pg driver by
default returns NUMERIC as a string and BIGINT as a string — both
correct and both must stay strings until they reach Money.fromMinor.
3.5 Where rounding happens
Exactly one place, stated once:
quantity × rateis computed exactly and rounded to minor units once, at the charge, using banker's rounding (half-to-even), and the pre-rounding exact value is stored alongside.
Not at the event (too many roundings, error grows with row count). Not at the invoice (the ledger and the invoice then disagree by the residue). At the charge, once, with the residue kept so the rounding is auditable and so a later re-pricing can be checked against it.
4. The event model
record() seal() price() post() settle()
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
usage_events ─▶ usage_aggregates ─▶ charges ─▶ ledger_entries ─▶ settlements
│ │ │ │ │
unique on: unique on: unique on: unique on: unique on:
(tenant, (subject, (aggregate_id, (source_kind, idempotency
source, metric, price_ver) source_id, _key
external_id) window) leg)
Every hop has a natural key that is a function of its inputs. That is what makes replay safe: re-running any hop with the same inputs writes the same rows or no rows, never different rows.
4.1 Ingest
usage_events is append-only. Nothing updates it, ever.
The caller supplies external_id — their request id, their job id, whatever is
already unique on their side. Duplicate ingest returns 200 with
deduplicated: true, never 409. A retrying client treats 409 as fatal and
either loses the event or pages someone; the whole point of an idempotent
endpoint is that the retry is boring.
That holds for a retry, which is the same request. A reused key carrying a
different quantity or occurred_at is refused with idempotency_conflict,
and post() does the same when the legs differ. The dedupe key does not cover
the payload, so without the check the second call takes the duplicate branch
and is answered "already recorded" — success, for a request the system never
performed. The stored value stays and the correction is gone, with no failed
row and no error to follow.
Both silent answers are wrong and they cannot be told apart from inside the
call: keeping the stored value discards a correction, and overwriting it lets a
replayed stale message clobber one. So neither is chosen. metadata is excluded
from the comparison — it is annotation and legitimately differs across a retry.
occurred_at is supplied by the caller and is the partition key.
received_at is the database's now(). Both are kept: the gap between them is
how late data is measured, and lateness is what decides the seal delay in §4.2.
4.2 Aggregation and sealing
A window is (subject_id, metric, window_start, window_end) and that tuple is
the idempotency key. Windows are half-open, [start, end), so consecutive
windows can neither gap nor overlap.
A window has two states:
- open — recomputation is an upsert. Re-running aggregation is free and idempotent.
- sealed — immutable.
sealed_atis set and a trigger rejects updates.
Sealing happens after a configured lateness grace period (default 30 minutes
past window_end, tunable per metric from the observed received_at − occurred_at distribution).
An event that arrives for a sealed window is not dropped and does not
rewrite the window. It is aggregated into the current window as an
adjustment row carrying adjusts_window_start, so the money is correct and
the audit trail says which period it belongs to.
The failure this prevents: rewriting a sealed window silently changes an invoice that has already been sent to a customer. The customer's PDF and your database then disagree, and there is no record that they ever agreed.
4.3 Charges
A charge is (aggregate_id, price_version). Prices are versioned rows, never
mutated — a price change writes a new version with a validity range.
Charges are immutable. Repricing does not UPDATE; it writes a reversing charge
(the negation, referencing the original) and a new charge at the new version.
The sum is the truth and the history is intact.
4.4 The ledger
Double-entry, append-only, two rows per event, and the two rows share a
transaction_id with a deferred constraint that their amounts sum to zero.
Nothing is ever updated or deleted. There is no API to delete a ledger entry and
there will not be one.
Account kinds: customer_balance, revenue_accrued, revenue_settled,
cash, tax_payable, rounding, write_off.
The estimate/authority split from §2.2 lives here:
- Charging accrues:
revenue_accrued←customer_balance, always our number. - Settling posts:
revenue_settled←revenue_accrued, at the provider's number whencapabilities.merchantOfRecordor the mode wasquantity. - Any difference posts to a
settlement_varianceaccount. It is never absorbed silently. A non-zero variance balance is an alert, not a rounding curiosity.
Cash posts only on payment.succeeded. On Lago, where capturesPayment is
false, that webhook comes from the PSP and may be days after settlement — which
is why invoicing and payment are separate ledger events rather than one.
4.5 Provider calls
claim (row: in_flight, key, request hash)
│ crash here ─────────┐
▼ │
call provider (with provider's own key) │
│ │
▼ ▼
store response (row: settled) recovery: re-issue with the SAME key if the
provider has one; otherwise findSettlement()
idempotency_records is (operation, key) unique, storing the request hash and
the response. Claim first, then call. A row stuck in_flight past a timeout is
the recovery case, and it is handled by asking the provider rather than
guessing — which is exactly why findCustomer and findSettlement are on the
interface as requirements.
Storing the request hash catches the dangerous mistake: the same key with a different request. That is a bug in the caller, and it fails loudly rather than returning a stale response for a request that was never made.
4.6 Webhooks
Unique on (provider, provider_event_id). Store first, acknowledge, then
process — a 200 you sent before persisting is a lost event when the process dies
between.
Out-of-order delivery is normal, not exceptional. State transitions are guarded
by occurredAt from the provider, and a transition backwards is recorded and
ignored. A settlement.voided arriving before the settlement.finalized it
voids must not resurrect the settlement.
4.7 What replay actually means
Given usage_events and price_versions, the contents of usage_aggregates
and charges for any open window are a pure function. You may delete and
recompute them and get byte-identical rows.
For a sealed window you may not, and the schema will not let you. That asymmetry is the design. "Replay safe" without a seal means "an invoice can change after you sent it", which is not safety.
5. The metering engine
For subscriptions billed by elapsed time — a running container, a provisioned database — where the usage event is the passage of a minute and there is no client to emit it.
5.1 What is kept from the reference implementation
The engine in brett_ai/prisma/scripts/bill_resources_per_min.sql got the hard
parts right and they are carried forward as requirements:
- Set-based. One statement bills N resources. No row-by-row loop.
FOR UPDATE SKIP LOCKED. Concurrent workers cannot double-bill and do not queue behind each other.- Exact numerics. No floats in the arithmetic.
- Run metrics emitted, so a run is observable rather than inferred.
5.2 What changes, and why
a. A batch bound. The reference has no LIMIT. One run locks and updates
every due resource in a single transaction. At high row counts that is an
unbounded transaction: lock pile-up, WAL growth, replica lag, and a run whose
duration is a function of how long you were down. Replaced by a bounded batch
inside its own transaction, and a driver that loops until drained.
b. Elapsed-minute settlement. The reference advances
lastBilledAt = lastBilledAt + interval '1 minute' — exactly one minute per
run. Six hours of downtime needs 360 runs to catch up. No money is lost;
recovery is O(downtime) where it should be O(1). Replaced by computing whole
elapsed minutes and settling them in one charge.
c. A catch-up cap. Elapsed-minute settlement alone lets a resource untouched for a year produce one charge for 525,600 minutes, driving a prepaid balance far negative in a single statement with no opportunity to suspend at the right minute. Capped (default 1,440 minutes) so recovery is O(downtime / cap) and the balance guard still gets to run between chunks.
d. Deterministic lock ordering. The reference locks billable_resources via
SKIP LOCKED, then updates BillingAccount rows in whatever order the
aggregate emits. Two concurrent workers whose batches touch overlapping accounts
can deadlock. Accounts are locked explicitly, ordered by id, before the update.
e. The failure metrics row is actually written. The reference's EXCEPTION
handler inserts a row into billing_run_metrics and then RAISEs. The RAISE
aborts the transaction, which rolls back that insert. The error row is never
persisted — the one case you most need a record of is the one case that leaves
none. The failure record is written by the driver, outside the failed
transaction.
f. A balance guard. The reference bills a resource forever regardless of balance. billing-kit suspends in the same transaction as the charge. The trade is explicit: a resource may overdraft by at most one interval, because refusing to bill for time already consumed is worse than a bounded overdraft.
g. Several explicit statements in one transaction, not one CTE monolith. The
reference's updated_accounts and updated_resources CTEs are never referenced
by the final SELECT. Postgres does execute data-modifying CTEs exactly once
regardless, so it is correct — but a reviewer cannot tell that by reading it,
and it cannot be instrumented per step. Atomicity is unchanged; verifiability
and observability are not.
5.3 The batch
-- One batch. One transaction. Called in a loop by the driver until drained.
CREATE OR REPLACE FUNCTION billing.meter_batch(
p_batch integer DEFAULT 500,
p_max_minutes integer DEFAULT 1440
) RETURNS TABLE (items_billed integer, minutes_billed bigint, amount_minor bigint)
LANGUAGE plpgsql AS $$
BEGIN
CREATE TEMP TABLE due ON COMMIT DROP AS
SELECT
r.id,
r.subject_id,
r.rate_minor_per_minute,
r.currency,
r.last_billed_at,
-- Whole elapsed minutes, capped. The cap is why recovery is O(downtime/cap)
-- and not O(downtime), and why the balance guard still gets a turn.
least(
floor(extract(epoch from (now() - r.last_billed_at)) / 60)::bigint,
p_max_minutes::bigint
) AS minutes
FROM billing.billable_items r
WHERE r.status = 'active'
AND r.last_billed_at + interval '1 minute' <= now()
-- Oldest first. Without it, LIMIT makes progress arbitrary and a resource
-- can starve indefinitely behind a churning head of the table.
ORDER BY r.last_billed_at
LIMIT p_batch
-- Rows already locked by a sibling worker are skipped and do not consume the
-- limit, so a full batch is still returned under contention.
FOR UPDATE SKIP LOCKED;
-- Lock accounts in id order before touching them. Two workers with
-- overlapping account sets deadlock otherwise, and a deadlock here rolls
-- back a batch that had already done real work.
PERFORM 1 FROM billing.ledger_accounts a
WHERE a.subject_id IN (SELECT subject_id FROM due)
AND a.kind = 'customer_balance'
ORDER BY a.id
FOR UPDATE;
-- Charges. minutes * rate is exact: both operands are integers.
INSERT INTO billing.charges (
id, subject_id, item_id, metric,
window_start, window_end, quantity, rate, amount_minor, currency, created_at
)
SELECT
gen_random_uuid(), d.subject_id, d.id, 'elapsed_minutes',
d.last_billed_at,
d.last_billed_at + (d.minutes * interval '1 minute'),
d.minutes,
d.rate_minor_per_minute,
d.minutes * d.rate_minor_per_minute,
d.currency,
now()
FROM due d
WHERE d.minutes > 0
-- Makes the whole batch replayable: a driver that retries after an ambiguous
-- failure cannot double-charge a window it already wrote.
ON CONFLICT (item_id, window_start) DO NOTHING;
-- Ledger: two legs, summing to zero, referencing the charge.
INSERT INTO billing.ledger_entries (transaction_id, account_id, amount_minor, source_kind, source_id, leg)
SELECT c.id, acct.debit_id, -c.amount_minor, 'charge', c.id, 'debit' FROM new_charges c JOIN ...
UNION ALL
SELECT c.id, acct.credit_id, c.amount_minor, 'charge', c.id, 'credit' FROM new_charges c JOIN ...;
-- Advance on the grid, never to now(). Adding the settled minutes keeps
-- intervals contiguous and preserves the sub-minute remainder; setting
-- last_billed_at = now() would silently discard it every single run.
UPDATE billing.billable_items r
SET last_billed_at = r.last_billed_at + (d.minutes * interval '1 minute')
FROM due d
WHERE r.id = d.id AND d.minutes > 0;
-- Balance guard. A bounded one-interval overdraft is accepted; not billing
-- for consumed time is not.
UPDATE billing.billable_items r
SET status = 'suspended', suspended_at = now()
FROM billing.ledger_accounts a
WHERE r.subject_id = a.subject_id
AND a.kind = 'customer_balance'
AND a.balance_minor < 0
AND r.status = 'active'
AND r.id IN (SELECT id FROM due);
RETURN QUERY SELECT ...;
END;
$$;
5.4 The driver
/**
* Loop until drained. Each iteration is its own transaction — that is the
* entire point of the batch bound, and calling meter_batch once per cron tick
* would reintroduce the O(downtime) recovery the bound exists to remove.
*/
export const drain = async (opts: DrainOptions): Promise<DrainReport> => {
// Not for correctness — SKIP LOCKED already handles concurrency. This stops
// a slow run and a five-minute cron from stacking fifty overlapping drains
// during an incident, which is how a recovery turns into an outage.
const held = await db.query(`SELECT pg_try_advisory_lock(hashtext('billing-kit:meter'))`);
if (!held) return { status: 'skipped', reason: 'lock_held' };
const deadline = Date.now() + opts.deadlineMs; // bounded wall clock
let iterations = 0;
try {
while (Date.now() < deadline && iterations < opts.maxIterations) {
const r = await db.transaction((tx) => tx.query(`SELECT * FROM billing.meter_batch($1, $2)`, [...]));
iterations++;
// A short batch means the queue drained. Anything else and we go again.
if (r.itemsBilled < opts.batch) return { status: 'drained', iterations, ... };
}
// Hit a bound with work outstanding. Not an error — the next tick continues
// from exactly where this one stopped, because progress is durable per
// batch. But it must be reported, because a drain that never drains is the
// signal that batch size or cron frequency is wrong.
return { status: 'bounded', iterations, ... };
} catch (err) {
// Written from here, outside the aborted transaction. A metrics row
// inserted inside a PL/pgSQL EXCEPTION handler that then RAISEs is rolled
// back with everything else, so the one run you needed a record of leaves
// none.
await recordFailure(err, iterations);
throw err;
} finally {
await db.query(`SELECT pg_advisory_unlock(hashtext('billing-kit:meter'))`);
}
};
The database clock is authoritative. now() in the batch, never the app
server's Date.now(), for anything that becomes an interval boundary. Skewed or
drifting app servers otherwise produce overlapping or gapped intervals, and both
are unrecoverable after the fact. The injected clock in §7 is for the driver's
own timeouts and for tests, and never for a boundary.
6. Partitioning and retention
6.1 What is partitioned
| Table | Partition | Default granularity |
|---|---|---|
usage_events |
RANGE on occurred_at |
monthly; daily above ~50M rows/month |
charges |
RANGE on window_start |
monthly |
ledger_entries |
RANGE on posted_at |
monthly |
usage_aggregates |
not partitioned | bounded by subjects × metrics × windows |
provider_events |
RANGE on received_at |
monthly |
6.2 The Prisma trap, named
A partitioned parent's primary key must include the partition key, so it is
PRIMARY KEY (id, occurred_at), not id. Prisma can express neither
PARTITION BY nor that composite intent cleanly, and prisma db push will
happily produce a plain unpartitioned table with a single-column key. Nothing
fails. Nothing warns. You find out at a hundred million rows.
This is precisely the failure mode ai_member documents for its generated
tsvector columns and HNSW indexes, and it takes the same remedy, deliberately
copied because it is proven in-house:
- Prisma owns the columns and relations.
- A post-push script owns what Prisma cannot express:
PARTITION BY, the composite keys, BRIN indexes, the seal trigger,billing.meter_batch. - The script has a
--checkmode that reports what actually exists in the database, and the health endpoint fails when it disagrees.
It is not optional, for the same reason it is not optional there.
6.3 Creating partitions ahead
billing.ensure_partitions(months_ahead integer) runs from the same driver as
drain, every run. It is idempotent.
Partitions are created ahead, never lazily on an insert failure. A missing
partition makes every insert into that range fail, which for usage_events
means dropping billable usage on the floor. Verified by the health check, so a
missing partition is an alert three weeks early rather than an incident at
midnight on the first.
6.4 Indexes
Within a partition:
BRINon the time column. The table is append-only and therefore naturally clustered by time; BRIN is a few kilobytes where a btree is gigabytes.BTREE (subject_id, occurred_at)for the aggregation scan.UNIQUE (tenant_id, source, external_id)for ingest dedupe. This is the expensive one and it is the price of idempotency.
Aggregation windows align to partition boundaries where the granularity allows, so a window's scan prunes to one partition.
6.5 Retention
| Table | Default | Rationale |
|---|---|---|
usage_events |
90 days | Raw, high-volume, and fully summarised by aggregates. |
provider_events |
180 days | Long enough to reconstruct a disputed webhook sequence. |
usage_aggregates |
7 years | The evidence behind a line on an invoice. |
charges |
never | Financial record. |
ledger_entries |
never | Financial record. |
Expiry is DETACH PARTITION CONCURRENTLY, then export, then DROP. Never
DELETE. A DELETE of a hundred million rows writes a hundred million rows of
WAL, bloats the table until autovacuum catches up, and takes a lock for the
duration; dropping a detached partition is a catalog operation that returns
immediately.
Export target is pluggable and defaults to none — billing-kit will not silently ship your usage data anywhere. If no exporter is configured, expiry stops at detach and leaves the partition on disk with a warning, rather than dropping data on the assumption it was wanted elsewhere.
There is no retention policy for charges or ledger_entries because there is
no supported way to delete them. Erasure requests are served by redacting
subject PII in the subject table, which the ledger references by id.
7. Public API surface
7.1 Rules
- No middleware is exported. Middleware is a framework's shape, and there are two frameworks to serve. Functions in, plain data out.
- No global singleton, no
process.envread inside the library. A library that reads the environment cannot be instantiated twice in one process — which is exactly what a test suite and a multi-region worker both need. Configuration is an argument. - No Prisma requirement at runtime. Prisma defines and migrates the schema
because it is the house tool. The engine is raw SQL, so the runtime accepts a
narrow executor and a bare
pg.Poolsatisfies it. Forcing Prisma on every adopter is a tax the design does not need to charge. - Typed errors. A discriminated union, never a message string.
7.2 Construction
export interface SqlExecutor {
query<T>(text: string, params?: readonly unknown[]): Promise<T[]>;
transaction<T>(fn: (tx: SqlExecutor) => Promise<T>): Promise<T>;
}
const billing = createBilling({
db, // SqlExecutor — pg.Pool adapter or Prisma adapter
provider, // BillingProvider
currencies: ['USD', 'EUR'],
clock: () => new Date(), // driver timeouts and tests only, never a boundary
logger,
});
7.3 Namespaces
billing.usage.record(event) // one, idempotent on external_id
billing.usage.recordMany(events) // COPY-backed, same dedupe
billing.usage.query({ subject, metric, window })
billing.meter.batch({ batch, maxMinutes })
billing.meter.drain({ batch, deadlineMs, maxIterations })
billing.meter.ensurePartitions({ monthsAhead })
billing.periods.aggregate({ subject, window })
billing.periods.seal({ subject, window })
billing.periods.settle({ subject, window }) // picks the mode from capabilities
billing.periods.list({ subject })
billing.ledger.balance({ subject, account })
billing.ledger.entries({ subject, since, until, cursor })
billing.ledger.post(entry) // append only; no update, no delete
billing.customers.ensure(ref)
billing.subscriptions.ensure(input)
billing.subscriptions.cancel(id, at)
billing.webhooks.handle({ rawBody, headers }) // → { status, body, event }
billing.health() // schema, partitions, drift
Every method returns plain data. Nothing returns a framework Response, a
stream, or a class the caller must import to use.
7.4 Webhooks in both frameworks
handle takes bytes and returns { status: number; body: unknown }. The bytes
requirement is the whole integration risk, so both adapters exist and both are
about ten lines.
// Express. express.raw, NOT express.json.
//
// express.json parses and discards the original bytes. Re-serialising to verify
// changes key order and whitespace, the HMAC no longer matches, and every
// webhook fails signature verification with no clue why. This mounts before any
// global json parser.
app.post('/webhooks/billing', express.raw({ type: '*/*' }), async (req, res) => {
const r = await billing.webhooks.handle({
rawBody: req.body, // Buffer, untouched
headers: req.headers as Record<string, string>,
});
res.status(r.status).json(r.body);
});
// Next.js App Router. arrayBuffer(), not json().
export async function POST(req: Request) {
const r = await billing.webhooks.handle({
rawBody: new Uint8Array(await req.arrayBuffer()),
headers: Object.fromEntries(req.headers),
});
return Response.json(r.body, { status: r.status });
}
Pages Router additionally needs export const config = { api: { bodyParser: false } }.
Both adapters ship with a test that asserts verification fails when the body has
been through JSON.parse/JSON.stringify, so the mistake is caught in CI
rather than in production.
7.5 Anti-patterns the surface makes impossible
Three defects were found in the reference application's route glue — not in its engine — and the API is shaped so their equivalents do not compile or do not exist:
- Crediting a balance from a request body with no processor call. There is
no
credit(amount). Cash posts to the ledger only from a verifiedpayment.succeededwebhook, and the posting function is internal to the webhook path. - A cron endpoint whose shared-secret check is commented out. billing-kit
exports no HTTP endpoint at all, so there is nothing to comment out.
drainis a function; how it is triggered and authenticated belongs to the host, and the docs say so instead of shipping an endpoint that looks safe. - Passing a client-supplied provider customer id into a portal call (IDOR).
Every method takes our subject id.
providerCustomerIdis resolved internally from the subject and never appears in a public parameter. The type system is the enforcement.
7.6 Schema namespace
Everything lives in a billing Postgres schema, so billing-kit's tables cannot
collide with a host application's and a search_path change cannot make them
ambiguous. It also gives the open-core/hosted sync seam a clean boundary: the
billing schema is the library's, identical in both, and never a merge conflict.
8. Provenance
Nothing in this design is copied from brett_ai. Chain of title there is
unresolved, and this document was written from documented behaviour —
observed, described in prose, and reimplemented from the description.
Specifically inherited as ideas, and re-derived here:
| Idea | Where observed | What was taken |
|---|---|---|
| Set-based billing pipeline | bill_resources_per_min.sql |
The approach. Restructured into explicit statements in §5.3. |
FOR UPDATE SKIP LOCKED for concurrent workers |
same | Standard Postgres queue pattern; requirement, not code. |
| Emitting run metrics | same | The requirement, plus the fix that they survive a failure. |
| Monthly partitioning of charges | init_billing.sql index naming |
Generalised in §6. |
| Post-push script for what Prisma cannot express | ai_member/scripts/index-db.ts |
The pattern and its --check mode, deliberately reused. |
Two discrepancies observed in the reference, recorded because they inform §3 and §6 and because the owner should know:
prisma/schema.prismadeclares the money columns asFloat— Postgresdouble precision— whileprisma/scripts/init_billing.sqldeclares the same tables withNUMERIC(12,6).bill_resources_per_min.sqlcasts toNUMERICin its locals and return type, but the columns it reads are whichever DDL actually ran.BillingAccount.balanceappears only in the Prisma schema and is thereforedouble precisionin any deployment.- The two DDLs disagree on identifier case:
init_billing.sqlcreatesuser_id,price_per_minute,last_billed_at; the Prisma schema and the billing function both use quoted"userId","pricePerMinute","lastBilledAt". Only one of the two can be the deployed shape.
Neither is a criticism of the engine's design, which is sound. Both are reasons §3 fixes the storage type in one place and §6.2 refuses to let Prisma be the only source of DDL truth.
If any code is later found to have been carried over rather than re-derived, it is a defect in this repository and must be removed before release.
9. Open questions
- Tier semantics. Tiers are
regular,elite,pro,enterprise; prices are unset. Whether a tier is an entitlement bundle (billing-kit holds only the key) or a priced plan (billing-kit holds the price) determines whether entitlements live here or inai_member_cloud. Current lean: entitlements are the host's, billing-kit holds only the plan key and the prices attached to it. - Multi-currency subjects. A subject with balances in two currencies needs one ledger account per currency and a stated policy on cross-currency settlement. Deferred until a real requirement exists; the schema already keys accounts by currency so it is additive.
- Provider migration. Moving a live customer base between providers is the strongest argument for owning the ledger, and it deserves its own document.
- Proration. Mid-period plan changes have three defensible answers. Pick one before the first paying customer, not after.