@krizaka/credits

中文文档
0.1.4发布于 2026/11/01Apache-2.0TypeScriptES Module构建来源已验证

One credits ledger for every Krizaka product: grants that expire with daily and monthly caps, purchases, debits, refunds, holds. Append-only; the balance is derived. Concurrent consumption never goes negative and never spends twice. No database client, no

原始使用文档

@krizaka/credits

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.

版本与发布记录

@krizaka/credits 共发布 6 个版本,首次发布于 2026/10/27;过去 12 个月发布了 6 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.1.4latest2026/11/011 天前
0.1.32026/10/304 天前
0.1.22026/10/295 天前
0.1.12026/10/277 天前
0.1.02026/10/277 天前
0.0.0-stage2026/10/277 天前

关于 @krizaka/credits 的常见问题

@krizaka/credits 是什么?

每个Krizaka产品都有一个信用分类账:每天和每月限额到期的赠款,购买,借记,退款,持有。扩展;平衡是推导出来的。同时消费永远不会为负,也不会花费两次。没有数据库客户机,没有

如何安装 @krizaka/credits?

在项目目录运行 npm install @krizaka/credits,或使用 pnpm add / yarn add / bun add。

@krizaka/credits 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@krizaka/credits 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@krizaka/credits 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@krizaka/credits 可以商用吗?

@krizaka/credits 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@krizaka/credits 还在维护吗?

最新版本 0.1.4 发布于 2026/11/01(1 天前);至今共发布 6 个版本,其中过去 12 个月发布了 6 个;首次发布于 2026/10/27。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。