ledger.js 6.75 KB
/**
 * `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,
    })
  }
}