@krizaka/credits
One credits ledger: grants that expire with daily and monthly caps, purchases, holds and refunds, exact under concurrency.
Install
npm install @krizaka/creditsAlso published as subpath exports: @krizaka/credits/settings, @krizaka/credits/testkit.
One credits ledger for every Krizaka product. It replaces three models: Yomizaka's audit_credits and free_audit_used_at columns on accounts (reserveAudit / releaseAudit / refundFailedAudit), and the Java kit's credit_wallet / credit_ledger_entry / credit_hold (krizaka-billing, whose names and events it follows). The coupons module (#46) grants capped credits through it.
The ledger is append-only and the balance is derived. Nothing stores a balance that could drift: what is left of a pot is the sum of its lines. A database trigger refuses any
UPDATEorDELETEof a line. Credits are integers, never floats.
import { emitMigration, transaction } from "@krizaka/db";
import { createCredits, creditsSchema } from "@krizaka/credits";
import { creditsSettings, readCreditsSettings } from "@krizaka/credits/settings";
emitMigration(creditsSchema, 1, { tables: { owner: "accounts" } }); // commit the SQL once, in your migrations
const credits = createCredits({ clock, settings: () => readCreditsSettings((key) => settings.get(key)), outbox });
await transaction(connect, async (tx) => {
// a coupon's free credits: 20 audits, at most 5 a day, then stop; they lapse in 30 days
await credits.grant(tx, { accountId, amount: 20, capPerDay: 5, expiresAt, onExhausted: "stop", source: `coupon:${redemptionId}`, idempotencyKey: `redeem-${redemptionId}` });
// credits bought (the payment webhook, in the event's transaction)
await credits.purchase(tx, { accountId, amount: 10, source: `purchase:${paymentRef}`, idempotencyKey: `pay-${paymentRef}` });
});
// spending: it refuses with a typed code, writes nothing when it refuses, and a replay of the key spends nothing more
const { draws } = await credits.consume(tx, { accountId, amount: 1, reason: `audit:${auditId}`, idempotencyKey: `audit-${auditId}` });
await credits.refund(tx, { accountId, consumed: `audit-${auditId}`, reason: "the run failed" }); // back to the same pots, onceThe model
- Two kinds of pot. A granted pot is free usage (a coupon, an allowance, a demo): it always expires, may be capped per day and per month, and says what happens when it runs out:
stoporfallback_paid. A purchased pot does not expire and is never capped. consume(tx, { accountId, amount, reason, idempotencyKey })draws from the granted pots that expire first, each within its caps, then from purchased credits. Nothing is partial: it covers the whole amount or refuses.- past a pot's cap:
CREDITS_CAP_DAY/CREDITS_CAP_MONTH(a fair-use limit: purchased credits do not bypass it; another granted pot still can serve); - a
stoppot that is empty:CREDITS_EXHAUSTED, even if purchased credits exist; - a
fallback_paidpot that is empty: it moves on to purchased credits, then answersPAYWALL; - no credits at all:
PAYWALL. Refusals areCreditsErrors with acodeand the HTTPstatusa route may answer (402 / 429).
- past a pot's cap:
- Holds.
holdkeeps credits back for work in progress (it counts against the caps and the balance, and expires aftercredits.holdTtlSeconds);settle(holdId, { amount? })spends what the work cost (at most the hold) and frees the rest;releasefrees it all. Once each; a replay answers the first result. refundgives back what a consume (or a settled hold) spent, to the same pots, once.expire(grantId)writes off what is left of a pot (a revoked coupon: credits already spent are never taken back);expireDuesweeps the lapsed pots in a job.adjustis an administrator's signed correction, never below zero.balanceis{ total, granted, purchased, held, available, grants[] }, derived;historypages the lines newest first.- Idempotency. Every writing call takes a key; the lines of an operation are
<kind>.<key>:<n>(unique in the database). Keys are unique per ledger, not per account: use an id (a payment ref, a job id). A key already used by another account is refused (CREDITS_KEY_REUSED). Keys carry no colon. - Concurrency. Every writer takes
pg_advisory_xact_lockon the account first, then reads and decides: concurrent consumption of the last credit never goes negative, a cap holds under load, and one key sent many times spends once (all proved on PostgreSQL, in loops). - Days and months are cut by PostgreSQL in the admin's IANA zone (
credits.timeZone, defaultAmerica/Toronto), so daylight-saving shifts are the database's own tz data; « today » comes from the injected clock. - Events (through
@krizaka/outbox, in the caller's transaction, ids derived from the fact so each is announced once):evt.credit.granted.v1andevt.credit.exhausted.v1, the names ofkrizaka-billing-api, with a JSON Schema each (CREDIT_EVENT_SCHEMAS). - Settings (
@krizaka/credits/settings):credits.timeZone(a choice among the runtime's IANA zones) andcredits.holdTtlSeconds(900, 30–86400). - Tables (
creditsSchema, Postgres schemacredits):grant,entry,hold. The one product table it references is the owner (owner, the account table). An account that has credits is anonymised, never deleted: it is an accounting record, like an invoice. creditsLedgerContract(@krizaka/credits/testkit) is the contract every store runs;memoryCreditStore()is for unit tests (it has no transaction, solockdoes nothing: concurrency is proved on PostgreSQL).
Adopting it (Yomizaka)
audit_credits becomes a purchased pot (purchase once per account for the balance it has, key import-<accountId>), the free audit a granted pot with onExhausted: "fallback_paid", reserveAudit a consume (a PAYWALL is already Yomizaka's 402), releaseAudit and refundFailedAudit a refund of the consume key.
Exports
The 43 names the package exports, read from its types.
| Name | Kind | What it is |
|---|---|---|
Balance | type | |
Bucket | type | |
BUCKET | constant | |
Calendar | type | The calendar the caps are counted in: the zone, and the local day and month of « now » in it (2026-10-26, 2026-10). |
Clock | type | |
Consumed | type | |
ConsumeInput | type | |
createCredits | function | The credits ledger of a product. |
CREDIT_EVENT | constant | Event types: the names of krizaka-billing-api, so a TypeScript product and a Java service speak alike. |
CREDIT_EVENT_SCHEMAS | constant | The JSON Schema of each event's payload (a consumer validates against it; the Java twin publishes the same names). |
Credits | type | The ledger as a product uses it. |
CREDITS_CODE | constant | Stable codes of the refusals of this package. |
CREDITS_DEFAULTS | constant | The defaults and bounds of the settings (the admin changes them without a redeploy). |
CREDITS_STATUS | constant | The HTTP status a route may answer for each refusal. |
CREDITS_TOPIC | constant | The topic the ledger's events are appended to on the outbox. |
CreditsCode | type | |
CreditsDeps | type | |
CreditsError | class | A refusal: a stable code, the HTTP status a route may answer, and a message. |
creditsSchema | constant | The tables of « credits », in their own Postgres schema (ADR-001 §3), following krizaka-billing's model and names: grant (a pot: granted with an expiry… |
CreditsSettings | type | The knobs of the ledger, read from the admin's settings (@krizaka/credits/settings). |
CreditStore | type | Where the ledger lives. |
dbCreditStore | function | The PostgreSQL store of the ledger, in schema credits (see creditsSchema), over the Query it is given: the caller's transaction for every writing call,… |
Draw | type | How much of one grant a consumption takes. |
Entry | type | One line of the ledger. |
ENTRY_TYPE | constant | The kinds of line of the ledger. |
EntryType | type | |
Grant | type | A pot of credits. |
GrantInput | type | |
GrantState | type | A grant as the planner sees it: what is left, what it gave today and this month, and whether it was closed by an expiry line. |
Hold | type | |
HOLD_STATUS | constant | |
HOLD_TTL_BOUNDS | constant | |
HoldStatus | type | |
memoryCreditStore | function | An in-memory ledger, for unit tests and demos: the same behaviour as the database's (the contract kit runs on both) except that lock does nothing, because… |
ON_EXHAUSTED | constant | What happens when a grant has run out: refuse (stop), or move on to purchased credits (fallback_paid). |
OnExhausted | type | |
OutboxPort | type | The part of @krizaka/outbox's Outbox this package needs: structural, so credits does not depend on outbox. |
Plan | type | |
planDraws | function | Plans where need credits come from, or says why they cannot. |
PurchaseInput | type | |
Query | type | |
skipFirst | function | Drops the first skip credits of a plan (the ones already held back by active holds), keeping the order. |
uuidOf | function | A stable UUID (version 5 shaped) from a seed: announcing the same fact twice is one outbox message. |
@krizaka/credits/settings
| Name | Kind | What it is |
|---|---|---|
creditsSettings | constant | The settings of « credits », a fragment the product adds to its registry (@krizaka/credits/settings): the admin changes the zone the caps are counted in an… |
readCreditsSettings | function | The ledger's settings as createCredits takes them, from the product's accessor ((key) => service.get(key, ctx)). |
@krizaka/credits/testkit
| Name | Kind | What it is |
|---|---|---|
creditsLedgerContract | function | What every credits store must do, behind the same ledger: derived balances, the order of draws, idempotency, the caps of the day and the month in the configu… |
LedgerHarness | type | What a store (through the ledger) is run against: the ledger on that store, a clock the test moves, and accounts that exist for it. |
T0 | constant | Midday on 26 October 2026 in Toronto (16:00 UTC): the day the scenarios start on. |
Package
| Version on npm | 0.1.3 (npm) |
| Licence | Apache-2.0 |
| Node.js | >=22 |
| Dependencies | @krizaka/db workspace:^, @krizaka/intl ^0.4.1, @krizaka/settings workspace:^, @krizaka/time workspace:^ |
| Peer dependencies | vitest >=4, zod >=3.23 |
| Source | credits |
Changelog
0.1.3
Patch changes
- The READMEs, comments and published declarations no longer name the products that left the organisation (krizaka/.github#413, rule
no-departed-published). The sources were cleaned in #262 with an empty changeset, so the published tarballs still carried the old text; this release replaces them. No behaviour change. - Updated dependencies [2a7898c]
- Updated dependencies [2272b57]
- @krizaka/[email protected]
- @krizaka/[email protected]
0.1.2
Patch changes
- Updated dependencies [0d19b64]
- @krizaka/[email protected]
- @krizaka/[email protected]
0.1.1
Patch changes
- Updated dependencies [8210a9a]
- @krizaka/[email protected]
0.1.0
Minor changes
- New package (#56):
@krizaka/credits, one credits ledger.createCredits(grantwith expiry, daily and monthly caps andstop/fallback_paid;purchase;consumefrom the pots that lapse first, refusing withCREDITS_CAP_DAY,CREDITS_CAP_MONTH,CREDITS_EXHAUSTEDorPAYWALL;hold,settle,release;refund;expireandexpireDue;adjust;balanceandhistory),creditsSchema(credits.grant,credits.entryappend-only by trigger,credits.hold),dbCreditStorewith a per-account advisory lock,memoryCreditStore,@krizaka/credits/settings(credits.timeZone,credits.holdTtlSeconds),evt.credit.granted.v1andevt.credit.exhausted.v1through the outbox, andcreditsLedgerContract.