escrow.js
5.45 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
import { randomUUID } from 'node:crypto'
import { createBillingRepo, createLedger } from '@drama/billing'
const ESCROWS = 'vk_escrows'
const PAYOUTS = 'vk_escrow_payouts'
const SOURCE = 'voko'
const REF_TYPE = 'vk_task'
/**
* 托管:发布时从发布方钱包扣款并记 held,退回时原路发还。
* 所有方法都在调用方的事务(`trx`)里动账,业务表与账本一起提交或回滚;幂等键由任务唯一决定。
* 余额不足时账本抛 `BillingError(INSUFFICIENT_BALANCE)`,整个事务回滚。
*
* @param {{ db: import('knex').Knex }} deps
*/
export function createEscrow({ db }) {
if (typeof db !== 'function') throw new Error('createEscrow: db is required')
/** @param {import('knex').Knex} trx */
const ledgerIn = (trx) => createLedger(createBillingRepo(db, trx))
/**
* @param {object} _ctx
* @param {import('knex').Knex} trx
* @param {{ task_id: string, payer_wallet: string, amount: number }} input
*/
async function hold(_ctx, trx, { task_id, payer_wallet, amount }) {
const deducted = await ledgerIn(trx).deduct({ user_id: payer_wallet, amount, source: SOURCE, reason: 'vk_escrow_hold', ref_type: REF_TYPE, ref_id: task_id, idempotency_key: `vk:escrow:${task_id}` })
await trx(ESCROWS).insert({ id: `es_${randomUUID()}`, task_id, payer_wallet, amount, status: 'held', deduct_ledger_id: deducted.ledger_id, created_at: new Date() })
}
/**
* 加赏金的补扣是否已经做过(同一 Idempotency-Key 的重放)。
* @param {object} _ctx
* @param {import('knex').Knex} trx
* @param {{ task_id: string, idempotency_key: string }} input
*/
async function topupExists(_ctx, trx, { task_id, idempotency_key }) {
return (await createBillingRepo(db, trx).findLedgerByIdempotencyKey(topupKey(task_id, idempotency_key))) !== null
}
/**
* @param {object} _ctx
* @param {import('knex').Knex} trx
* @param {{ task_id: string, amount: number, idempotency_key: string }} input
*/
async function topup(_ctx, trx, { task_id, amount, idempotency_key }) {
const escrow = await heldEscrow(trx, task_id)
await ledgerIn(trx).deduct({ user_id: escrow.payer_wallet, amount, source: SOURCE, reason: 'vk_escrow_hold', ref_type: REF_TYPE, ref_id: task_id, idempotency_key: topupKey(task_id, idempotency_key) })
await trx(ESCROWS).where({ id: escrow.id }).increment('amount', amount)
}
/**
* 全额退回发布方:escrow → refunded,并记一条 refund 发放明细。
* @param {object} _ctx
* @param {import('knex').Knex} trx
* @param {{ task_id: string }} input
*/
async function refund(_ctx, trx, { task_id }) {
const escrow = await heldEscrow(trx, task_id)
const now = new Date()
const granted = await ledgerIn(trx).grant({
user_id: escrow.payer_wallet,
amount: escrow.amount,
source: SOURCE,
credit_kind: 'earned',
reason: 'vk_escrow_refund',
ref_type: REF_TYPE,
ref_id: task_id,
idempotency_key: `vk:payout:${task_id}:${escrow.payer_wallet}:refund`,
})
await trx(PAYOUTS).insert({ id: `ep_${randomUUID()}`, escrow_id: escrow.id, task_id, recipient_wallet: escrow.payer_wallet, amount: escrow.amount, kind: 'refund', ledger_id: granted.ledger_id, created_at: now })
await trx(ESCROWS).where({ id: escrow.id }).update({ status: 'refunded', closed_at: now })
}
/**
* 钱包视图:可用 = 账本余额;冻结 = 该钱包名下 held 托管之和。
* @param {object} _ctx
* @param {{ wallet_user_id: string }} input
* @returns {Promise<{ available: number, held: number }>}
*/
async function walletOf(_ctx, { wallet_user_id }) {
const [available, heldRow] = await Promise.all([
createBillingRepo(db).getBalance(wallet_user_id),
db(ESCROWS).where({ payer_wallet: wallet_user_id, status: 'held' }).sum({ held: 'amount' }).first(),
])
return { available, held: Number(heldRow.held) }
}
/**
* 该钱包今天(UTC 日)已冻结进托管的积分,用于单日支出上限。
* @param {object} _ctx
* @param {import('knex').Knex} trx
* @param {{ wallet_user_id: string, now: Date }} input
* @returns {Promise<number>}
*/
async function spentToday(_ctx, trx, { wallet_user_id, now }) {
const dayStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
const row = await trx('billing_ledger').where({ user_id: wallet_user_id, source: SOURCE, reason: 'vk_escrow_hold' }).where('created_at', '>=', dayStart).sum({ spent: 'amount' }).first()
return -Number(row.spent)
}
return { hold, topupExists, topup, refund, walletOf, spentToday }
}
/**
* 取任务的 held 托管并加行锁;不存在或已关闭都是不变量被破坏。
* @param {import('knex').Knex} trx
* @param {string} task_id
* @returns {Promise<{ id: string, payer_wallet: string, amount: number }>}
*/
async function heldEscrow(trx, task_id) {
const row = await trx(ESCROWS).where({ task_id }).forUpdate().first()
if (row === undefined || row.status !== 'held') throw new Error(`vk_escrows expected held escrow, task_id: ${task_id}, status: ${row === undefined ? 'missing' : row.status}`)
if (!Number.isSafeInteger(row.amount) || row.amount <= 0) throw new Error(`vk_escrows.amount invalid, id: ${row.id}`)
return { id: row.id, payer_wallet: row.payer_wallet, amount: row.amount }
}
/**
* @param {string} task_id
* @param {string} idempotency_key
*/
function topupKey(task_id, idempotency_key) {
return `vk:escrow_topup:${task_id}:${idempotency_key}`
}