@krizaka /学分
每个Krizaka产品都有一个信用分类账。 它取代了三种型号:Yomizaka的 audit_credits 和 free_audit_used_at 列在 accounts (reserveAudit / releaseAudit / refundFailedAudit),以及Java工具包 credit_wallet / credit_ledger_entry / credit_hold (krizaka-billing,后面跟着他们的名字和事件)。优惠券模块(#46)通过它授予上限积分。
分类帐只是附注,余额是派生出来的。 没有什么东西能储存可以漂移的平衡:罐子剩下的是它的线条之和。数据库触发器拒绝任何
UPDATE或者DELETE一行的。积分是整数,而不是浮点数。
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
该模型
- 两种锅。 一、 授予 锅是免费使用的(优惠券,津贴,演示):它总是 到期,可能会被封顶 每天 和 每个月,并说明用完后会发生什么:
stop或者fallback_paid. 一个 购买 Pot不会过期,也不会封顶。 consume(tx, { accountId, amount, reason, idempotencyKey })从授予的POTS中提取 第一个到期,然后从购买的积分中扣除。没有什么是片面的:它涵盖了全部或拒绝。- 越过锅盖:
CREDITS_CAP_DAY/CREDITS_CAP_MONTH(合理使用限制:购买的积分不绕过它;另一个被授予的锅仍然可以服务); - 一个
stop空锅:CREDITS_EXHAUSTED,即使存在购买积分; - 一个
fallback_paid锅是空的:它移动到购买的积分,然后回答PAYWALL; - 没有学分:
PAYWALL. 拒绝是CreditsErrorS和acode和HTTPstatus路线可以回答(402 / 429)。
- 越过锅盖:
- 成立。
hold为正在进行的工作保留积分(它计入上限和余额,并在之后到期)credits.holdTtlSeconds);settle(holdId, { amount? })花费工作的成本(最多是保留),并释放其余部分;release释放一切。一旦每个;重播回答第一个结果。 refund将一次消费(或固定持有)所花的钱返还给相同的锅。expire(grantId)注销剩下的钱(一张作废的优惠券:已经消费的积分永远不会收回);expireDue在一个工作中扫掉掉了的锅。adjust是管理员的签名更正,从不低于零。balance是{ total, granted, purchased, held, available, grants[] }派生的;history先写最新的行。- 幂等性。 每次写信都需要一把钥匙;操作的行是
<kind>.<key>:<n>(在数据库中唯一)。密钥在每个分类账中是唯一的,而不是每个帐户:使用id(付款ref,作业id)。已使用的密钥 另一个账户 被拒绝了(CREDITS_KEY_REUSED). 键不带冒号。 - 并发性。 每个作家都会
pg_advisory_xact_lock首先在帐户上,然后读取并决定:最后一个信用的并发消费永远不会为负,上限在负载下保持,并且多次发送的一个键花费一次(所有在PostgreSQL上证明,在循环中)。 - 日期和月份 在管理员的IANA区域中被PostgreSQL删除(
credits.timeZone、默认America/Toronto),所以夏令时是数据库自己的时区数据;“今天”来自注入时钟。 - 事件 (通过
@krizaka/outbox,在调用者的事务中,从事实派生的id,因此每个都宣布一次):evt.credit.granted.v1和evt.credit.exhausted.v1的名字krizaka-billing-api,每个都有一个JSON模式(CREDIT_EVENT_SCHEMAS)。 - 设置 (
@krizaka/credits/settings):credits.timeZone(在运行时的IANA区域中进行选择)和credits.holdTtlSeconds(900年,30 - 86400)。 - 表 (
creditsSchema、Postgres模式credits):grant,entry,hold. 它引用的一个产品表是所有者(owner(帐户表)。有信用的账户是 匿名,从未删除它是一种会计记录,就像发票一样。 creditsLedgerContract(@krizaka/credits/testkit)是每个商店的合同;memoryCreditStore()是用于单元测试的(它没有事务,所以lock不做任何事情:并发性是在PostgreSQL上证明的)。
采用它(Yomizaka)
audit_credits 变成购买的锅(purchase 每个账户的余额,键 import-<accountId>),免费审核是一项授予的工作 onExhausted: "fallback_paid", reserveAudit 一个 consume (一个 PAYWALL 已经是Yomizaka的402), releaseAudit 和 refundFailedAudit 一个 refund 消费键的。