Skip to the content
Krizaka TypeScript
Krizaka TypeScript

@krizaka/credits

One credits ledger: grants that expire with daily and monthly caps, purchases, holds and refunds, exact under concurrency.

Install

bashTerminal
npm install @krizaka/credits

Also 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 UPDATE or DELETE of 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, once

The 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: stop or fallback_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 stop pot that is empty: CREDITS_EXHAUSTED, even if purchased credits exist;
    • a fallback_paid pot that is empty: it moves on to purchased credits, then answers PAYWALL;
    • no credits at all: PAYWALL. Refusals are CreditsErrors with a code and the HTTP status a route may answer (402 / 429).
  • Holds. hold keeps credits back for work in progress (it counts against the caps and the balance, and expires after credits.holdTtlSeconds); settle(holdId, { amount? }) spends what the work cost (at most the hold) and frees the rest; release frees it all. Once each; a replay answers the first result.
  • refund gives 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); expireDue sweeps the lapsed pots in a job. adjust is an administrator's signed correction, never below zero.
  • balance is { total, granted, purchased, held, available, grants[] }, derived; history pages 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_lock on 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, default America/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.v1 and evt.credit.exhausted.v1, the names of krizaka-billing-api, with a JSON Schema each (CREDIT_EVENT_SCHEMAS).
  • Settings (@krizaka/credits/settings): credits.timeZone (a choice among the runtime's IANA zones) and credits.holdTtlSeconds (900, 30–86400).
  • Tables (creditsSchema, Postgres schema credits): 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, so lock does 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.

NameKindWhat it is
Balancetype
Buckettype
BUCKETconstant
CalendartypeThe calendar the caps are counted in: the zone, and the local day and month of « now » in it (2026-10-26, 2026-10).
Clocktype
Consumedtype
ConsumeInputtype
createCreditsfunctionThe credits ledger of a product.
CREDIT_EVENTconstantEvent types: the names of krizaka-billing-api, so a TypeScript product and a Java service speak alike.
CREDIT_EVENT_SCHEMASconstantThe JSON Schema of each event's payload (a consumer validates against it; the Java twin publishes the same names).
CreditstypeThe ledger as a product uses it.
CREDITS_CODEconstantStable codes of the refusals of this package.
CREDITS_DEFAULTSconstantThe defaults and bounds of the settings (the admin changes them without a redeploy).
CREDITS_STATUSconstantThe HTTP status a route may answer for each refusal.
CREDITS_TOPICconstantThe topic the ledger's events are appended to on the outbox.
CreditsCodetype
CreditsDepstype
CreditsErrorclassA refusal: a stable code, the HTTP status a route may answer, and a message.
creditsSchemaconstantThe 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…
CreditsSettingstypeThe knobs of the ledger, read from the admin's settings (@krizaka/credits/settings).
CreditStoretypeWhere the ledger lives.
dbCreditStorefunctionThe PostgreSQL store of the ledger, in schema credits (see creditsSchema), over the Query it is given: the caller's transaction for every writing call,…
DrawtypeHow much of one grant a consumption takes.
EntrytypeOne line of the ledger.
ENTRY_TYPEconstantThe kinds of line of the ledger.
EntryTypetype
GranttypeA pot of credits.
GrantInputtype
GrantStatetypeA 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.
Holdtype
HOLD_STATUSconstant
HOLD_TTL_BOUNDSconstant
HoldStatustype
memoryCreditStorefunctionAn 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_EXHAUSTEDconstantWhat happens when a grant has run out: refuse (stop), or move on to purchased credits (fallback_paid).
OnExhaustedtype
OutboxPorttypeThe part of @krizaka/outbox's Outbox this package needs: structural, so credits does not depend on outbox.
Plantype
planDrawsfunctionPlans where need credits come from, or says why they cannot.
PurchaseInputtype
Querytype
skipFirstfunctionDrops the first skip credits of a plan (the ones already held back by active holds), keeping the order.
uuidOffunctionA stable UUID (version 5 shaped) from a seed: announcing the same fact twice is one outbox message.

@krizaka/credits/settings

NameKindWhat it is
creditsSettingsconstantThe 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…
readCreditsSettingsfunctionThe ledger's settings as createCredits takes them, from the product's accessor ((key) => service.get(key, ctx)).

@krizaka/credits/testkit

NameKindWhat it is
creditsLedgerContractfunctionWhat 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…
LedgerHarnesstypeWhat a store (through the ledger) is run against: the ledger on that store, a clock the test moves, and accounts that exist for it.
T0constantMidday on 26 October 2026 in Toronto (16:00 UTC): the day the scenarios start on.

Package

Version on npm0.1.3 (npm)
LicenceApache-2.0
Node.js>=22
Dependencies@krizaka/db workspace:^, @krizaka/intl ^0.4.1, @krizaka/settings workspace:^, @krizaka/time workspace:^
Peer dependenciesvitest >=4, zod >=3.23
Sourcecredits

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]

0.1.2

Patch changes

0.1.1

Patch changes

0.1.0

Minor changes

  • New package (#56): @krizaka/credits, one credits ledger. createCredits (grant with expiry, daily and monthly caps and stop / fallback_paid; purchase; consume from the pots that lapse first, refusing with CREDITS_CAP_DAY, CREDITS_CAP_MONTH, CREDITS_EXHAUSTED or PAYWALL; hold, settle, release; refund; expire and expireDue; adjust; balance and history), creditsSchema (credits.grant, credits.entry append-only by trigger, credits.hold), dbCreditStore with a per-account advisory lock, memoryCreditStore, @krizaka/credits/settings (credits.timeZone, credits.holdTtlSeconds), evt.credit.granted.v1 and evt.credit.exhausted.v1 through the outbox, and creditsLedgerContract.

On this page