ledger-repo.js 7.32 KB
import { randomUUID } from 'node:crypto'
const LEDGER = 'billing_ledger'

export function createLedgerRepo(db, runner = db) {
  const query = runner
  const transactionBound = query !== db

  async function inTransaction(work) {
    if (transactionBound) return work(query)
    return db.transaction(work)
  }

  async function getBalance(user_id, trx = query) {
    const row = await trx(LEDGER).where({ user_id }).sum({ balance: 'amount' }).first()
    return Number(row.balance)
  }

  async function applyDelta({ user_id, delta, require_sufficient, ledger_row }) {
    assertIdempotencyKey(ledger_row.idempotency_key)
    return inTransaction(async trx => {
      const user = await trx('users').where({ id: user_id }).select('id').forUpdate().first()
      if (user === undefined) throw new Error(`Cannot write billing_ledger: user_id=${user_id} does not exist`)
      const balance_before = await getBalance(user_id, trx)
      const balance_after = balance_before + delta
      if (require_sufficient && balance_after < 0) {
        return { kind: 'insufficient_balance', balance_before }
      }
      const id = randomUUID()
      const row = { id, user_id, amount: delta, balance_after, created_at: new Date(), ...ledger_row }
      try {
        await trx(LEDGER).insert(row)
        return { kind: 'applied', ledger_id: id, balance_after }
      } catch (error) {
        if (error.code !== 'ER_DUP_ENTRY') throw error
        const existing = await trx(LEDGER).where({ idempotency_key: ledger_row.idempotency_key }).first()
        assertSameLedger(existing, row, ledger_row.idempotency_key)
        return { kind: 'replayed', ledger_id: existing.id, balance_after: Number(existing.balance_after) }
      }
    })
  }

  async function reverseDelta({ user_id, total_points, ledger_row }) {
    assertIdempotencyKey(ledger_row.idempotency_key)
    return inTransaction(async trx => {
      const user = await trx('users').where({ id: user_id }).select('id').forUpdate().first()
      if (user === undefined) throw new Error(`Cannot write billing_ledger: user_id=${user_id} does not exist`)
      const balance_before = await getBalance(user_id, trx)
      const points_reversed = Math.min(balance_before, total_points)
      const reversal_shortfall_points = total_points - points_reversed
      if (points_reversed === 0) {
        return {
          kind: 'not_applied',
          ledger_id: null,
          balance_after: balance_before,
          points_reversed,
          reversal_shortfall_points,
        }
      }
      const id = randomUUID()
      const row = {
        id,
        user_id,
        amount: -points_reversed,
        balance_after: balance_before - points_reversed,
        created_at: new Date(),
        ...ledger_row,
      }
      try {
        await trx(LEDGER).insert(row)
        return { kind: 'applied', ledger_id: id, balance_after: row.balance_after, points_reversed, reversal_shortfall_points }
      } catch (error) {
        if (error.code !== 'ER_DUP_ENTRY') throw error
        const existing = await trx(LEDGER).where({ idempotency_key: ledger_row.idempotency_key }).first()
        assertSameLedger(existing, row, ledger_row.idempotency_key)
        return {
          kind: 'replayed',
          ledger_id: existing.id,
          balance_after: Number(existing.balance_after),
          points_reversed: -Number(existing.amount),
          reversal_shortfall_points: total_points + Number(existing.amount),
        }
      }
    })
  }

  async function findLedgerByIdempotencyKey(idempotency_key) {
    if (idempotency_key === null) return null
    const row = await query(LEDGER).where({ idempotency_key }).first()
    if (row === undefined) return null
    return ledgerFromRow(row)
  }

  async function listLedger(user_id, filter) {
    let rows = query(LEDGER).where({ user_id })
    if (filter.type !== undefined) rows = rows.where({ type: filter.type })
    if (filter.since !== undefined) rows = rows.where('created_at', '>=', filter.since)
    if (filter.until !== undefined) rows = rows.where('created_at', '<=', filter.until)
    const limit = filter.limit === undefined ? 20 : filter.limit
    const result = await rows.orderBy('created_at', 'desc').orderBy('id', 'desc').limit(limit + 1)
    const items = result.slice(0, limit).map(ledgerFromRow)
    const last = items.at(-1)
    return {
      items,
      next_cursor: result.length > limit ? `${last.created_at.toISOString()}|${last.id}` : null,
    }
  }

  return { getBalance, applyDelta, reverseDelta, findLedgerByIdempotencyKey, listLedger }
}

function assertSameLedger(existing, row, idempotency_key) {
  if (existing === undefined) {
    throw new Error(`billing_ledger idempotency conflict: key=${idempotency_key}, field=existing_row`)
  }
  const existingLedger = ledgerFromRow(existing)
  for (const field of ['user_id', 'amount', 'type', 'source', 'reason', 'ref_type', 'ref_id', 'credit_kind', 'note', 'failure_code']) {
    if (existingLedger[field] !== row[field]) {
      throw new Error(`billing_ledger idempotency conflict: key=${idempotency_key}, field=${field}`)
    }
  }
}

function assertIdempotencyKey(idempotency_key) {
  if (typeof idempotency_key !== 'string' || idempotency_key === '') {
    throw new Error('billing_ledger requires a stable idempotency_key')
  }
}

function ledgerFromRow(row) {
  const id = requiredString(row.id, 'billing_ledger.id')
  const type = requiredString(row.type, 'billing_ledger.type', id)
  const amount = requiredInteger(row.amount, 'billing_ledger.amount', id)
  const credit_kind = nullableString(row.credit_kind, 'billing_ledger.credit_kind', id)
  switch (type) {
    case 'grant':
      if (amount <= 0 || credit_kind === null) throw new Error(`Invalid billing_ledger grant, id=${id}`)
      break
    case 'deduct':
      if (amount >= 0 || credit_kind !== null) throw new Error(`Invalid billing_ledger deduct, id=${id}`)
      break
    default:
      throw new Error(`Unhandled billing_ledger.type: ${type}, id: ${id}`)
  }
  return {
    id,
    user_id: requiredString(row.user_id, 'billing_ledger.user_id', id),
    type,
    amount,
    source: requiredString(row.source, 'billing_ledger.source', id),
    credit_kind,
    reason: requiredString(row.reason, 'billing_ledger.reason', id),
    ref_type: nullableString(row.ref_type, 'billing_ledger.ref_type', id),
    ref_id: nullableString(row.ref_id, 'billing_ledger.ref_id', id),
    note: nullableString(row.note, 'billing_ledger.note', id),
    failure_code: nullableString(row.failure_code, 'billing_ledger.failure_code', id),
    balance_after: requiredInteger(row.balance_after, 'billing_ledger.balance_after', id),
    idempotency_key: nullableString(row.idempotency_key, 'billing_ledger.idempotency_key', id),
    created_at: requiredDate(row.created_at, 'billing_ledger.created_at', id),
  }
}

function requiredString(value, field, id = 'unknown') {
  if (typeof value !== 'string' || value === '') throw new Error(`Invalid ${field}, id=${id}`)
  return value
}

function nullableString(value, field, id) {
  if (value === null) return null
  return requiredString(value, field, id)
}

function requiredInteger(value, field, id) {
  const number = Number(value)
  if (!Number.isSafeInteger(number)) throw new Error(`Invalid ${field}, id=${id}`)
  return number
}

function requiredDate(value, field, id) {
  const date = new Date(value)
  if (Number.isNaN(date.getTime())) throw new Error(`Invalid ${field}, id=${id}`)
  return date
}