/** * `ledger.js` —— 钱的原子性命门:`deduct` / `grant`。 * * 职责单一:把「余额检查 → 扣减/增加 → 写流水」编排成**同一事务**的原子操作, * 并用 `idempotency_key` 兜重放幂等。真正的 SQL/事务在 `repo/ledger-repo.js`;本文件只编排, * 不认识数据库具体形态(repo 注入)。 * * 幂等模型:扣分必须带 `idempotency_key`,由 ledger 唯一约束裁决并发竞争;唯一键冲突后 * 回读首次结果并逐项核对用户、金额和引用,完全一致才作为重放返回。禁止 SELECT 后 INSERT。 * * @typedef {import('./repo/index.js').BillingRepo} BillingRepo */ import { BillingError, BillingErrorCode } from './errors.js' /** 流水类型枚举(写入 ledger.type,用于审计与过滤)。 */ export const LedgerType = Object.freeze({ DEDUCT: 'deduct', GRANT: 'grant', }) /** * 装配 deduct / grant,闭包持有注入的 repo。 * @param {BillingRepo} billingRepo */ export function createLedger(billingRepo) { /** * 扣费:事务内余额检查 + 扣减 + 写 ledger。余额不足抛 INSUFFICIENT_BALANCE 不写账。 * 同 idempotency_key 重放返首次结果、不二次扣。 * * @param {object} p * @param {string} p.user_id * @param {number} p.amount 正整数积分 * @param {string} p.reason 人类可读原因('continue' / 'unlock_branch' ...) * @param {string} [p.ref_type] 关联对象类型('drama' / 'episode' / 'continue' ...) * @param {string} [p.ref_id] * @param {string} p.idempotency_key 扣费必须带 key(重放保护) * @returns {Promise<{ balance_after:number, ledger_id:string }>} */ async function deduct({ user_id, amount, source, reason, ref_type = null, ref_id = null, idempotency_key, note = null, failure_code = null }) { assertAmount(amount) assertIdempotencyKey(idempotency_key, 'deduct') const result = await billingRepo.applyDelta({ user_id, delta: -Math.abs(amount), require_sufficient: true, ledger_row: { type: LedgerType.DEDUCT, source: source ?? sourceForLegacyDeduct(ref_type), credit_kind: null, reason, ref_type, ref_id, note, failure_code, idempotency_key, }, }) if (result.kind === 'insufficient_balance') { throw new BillingError({ code: BillingErrorCode.INSUFFICIENT_BALANCE, message: `余额不足:当前 ${result.balance_before},需扣 ${amount}`, }) } return { balance_after: result.balance_after, ledger_id: result.ledger_id, replayed: result.kind === 'replayed' } } /** * 入账:充值成功 / 新用户赠送 / 退款返还。写 ledger,余额增加。 * 给了 idempotency_key 时同样幂等。 * * @param {object} p * @param {string} p.user_id * @param {number} p.amount * @param {string} p.reason * @param {string} [p.source] 来源('recharge' / 'signup_gift' / 'refund' ...) * @param {string} [p.idempotency_key] * @returns {Promise<{ balance_after:number, ledger_id:string }>} */ async function grant({ user_id, amount, source, credit_kind, reason, ref_type = null, ref_id = null, idempotency_key, note = null, failure_code = null }) { assertAmount(amount) if (source === undefined || source === '') throw new Error('grant requires source') assertIdempotencyKey(idempotency_key, 'grant') const result = await billingRepo.applyDelta({ user_id, delta: Math.abs(amount), require_sufficient: false, ledger_row: { type: LedgerType.GRANT, source, credit_kind: credit_kind ?? creditKindForLegacyGrant(source), reason, ref_type, ref_id, note, failure_code, idempotency_key, }, }) return { balance_after: result.balance_after, ledger_id: result.ledger_id, replayed: result.kind === 'replayed' } } /** * 支付退款或拒付冲正:只扣可用余额,绝不形成负余额。 * @param {{ user_id:string, total_points:number, reason:'payment_refund_reversal'|'payment_dispute_reversal', ref_id:string, idempotency_key:string }} input * @returns {Promise<{ ledger_id:string|null, balance_after:number, points_reversed:number, reversal_shortfall_points:number, replayed:boolean }>} */ async function reversePayment({ user_id, total_points, reason, ref_id, idempotency_key }) { assertAmount(total_points) assertIdempotencyKey(idempotency_key, 'reversePayment') assertPaymentReversalReason(reason) const result = await billingRepo.reverseDelta({ user_id, total_points, ledger_row: { type: LedgerType.DEDUCT, source: 'stripe', credit_kind: null, reason, ref_type: 'order', ref_id, note: null, failure_code: null, idempotency_key, }, }) return { ledger_id: result.ledger_id, balance_after: result.balance_after, points_reversed: result.points_reversed, reversal_shortfall_points: result.reversal_shortfall_points, replayed: result.kind === 'replayed', } } return { deduct, grant, reversePayment } } function assertPaymentReversalReason(reason) { switch (reason) { case 'payment_refund_reversal': case 'payment_dispute_reversal': return default: throw new Error(`Unhandled payment reversal reason: ${reason}`) } } function assertIdempotencyKey(idempotency_key, operation) { if (typeof idempotency_key !== 'string' || idempotency_key === '') { throw new Error(`${operation} requires a stable idempotency_key`) } } function sourceForLegacyDeduct(ref_type) { switch (ref_type) { case 'llm_chat': case 'llm_operation': return 'llm' case 'tts_operation': case 'visual_game_tts': return 'tts' case 'image_operation': return 'image_generation' case 'video_operation': return 'video_generation' case 'unlock': return 'unlock' default: throw new Error(`deduct requires source for ref_type=${ref_type}`) } } function creditKindForLegacyGrant(source) { switch (source) { case 'apple': case 'google': case 'stripe': return 'paid' case 'visual_game_signup': case 'admin': case 'platform': case 'legacy_admin': case 'verification': return 'admin' default: throw new Error(`grant requires credit_kind for source=${source}`) } } /** * @param {unknown} amount */ function assertAmount(amount) { if (typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0) { throw new BillingError({ code: BillingErrorCode.INSUFFICIENT_BALANCE, message: `amount 必须是正整数,收到: ${String(amount)}`, retryable: false, }) } }