@seasonkoh/webaz 0.1.21 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,10 @@
12
12
  import { generateId } from '../L0-1-database/schema.js';
13
13
  import { appendOrderEvent } from './order-chain.js';
14
14
  import { VALID_TRANSITIONS, CURRENT_RESPONSIBLE, CURRENT_RESPONSIBLE_SELF_FULFILL } from './transitions.js';
15
+ // RFC-014 PR2 — 资金算术统一走整数 base-units;分配用 allocate 保证精确守恒。
16
+ import { toUnits, mulRate, allocate } from '../../money.js';
17
+ // RFC-014 PR3 — 钱包落库 helper 抽到共享 ledger 模块(原私有于此),防多份漂移。
18
+ import { walletUnits, applyWalletDelta } from '../../ledger.js';
15
19
  // ─── 核心函数 ────────────────────────────────────────────────
16
20
  /**
17
21
  * 执行状态转移
@@ -236,104 +240,101 @@ export function settleFault(db, orderId, faultState) {
236
240
  const v = Number(row?.value);
237
241
  return Number.isFinite(v) && v >= 0 ? v : 0.30;
238
242
  };
239
- const penaltyAmount = isSecondhand ? 0 : Math.round(total * faultPenaltyRate() * 100) / 100;
240
- const orderStakeBacking = Math.max(0, Math.round(Number(order.stake_backing || 0) * 100) / 100);
243
+ // RFC-014:金额一律转整数 base-units 后再算/分配。
244
+ const totalU = toUnits(total);
245
+ const penaltyU = isSecondhand ? 0 : mulRate(totalU, faultPenaltyRate());
246
+ const orderStakeBackingU = Math.max(0, toUnits(Number(order.stake_backing || 0)));
241
247
  // RFC-007 stage 4:没收后的【守恒 + 不牟利】再分配(取代旧的 buyer 50% / protocol 50%)。
242
248
  // 旧分配漏掉推广人(违反 §谁责任谁承担:推广人承担了真实推广成本却零补偿)。
243
249
  // 新规则(全部用订单快照,可复算,绝不印钱):
244
250
  // 1. 协议只回收【原本该收的平台费】protocolTake = min(F, total × protocol_fee_rate)
245
251
  // —— 协议不从违约牟利;fund_base(1%) 排除(无成交=无 GMV,社区基金不应从罚没获利)。
246
- // 2. R = F − protocolTake;买家补偿 = R × 50%(受损对手方固定份额)。
252
+ // 2. R = F − protocolTake;买家补偿 = R × 50%(受损对手方基础份额)。
247
253
  // 3. 推广人 = R 的另一半,按 l1/l2/l3 原始佣金比例分,【封顶各自原始佣金】—— 永不超过其真实损失。
248
- // 4. 推广半残值(超封顶 / 无推广人)→ commission_reserve 三级公池(与正常单未归属佣金同去向,不给买家=不过补)。
254
+ // 4. 推广半残值(超封顶 / 无推广人)→ 【买家】(受损方吸收违约方罚金剩余,故买家可超 50%)。
255
+ // 决策 A(2026-06-07,对齐 RFC-007 Invariant #2):残值是罚金且无成交,归被坑方,不入 commission_reserve。
249
256
  // 守恒:protocolTake + buyerComp + promotersPaid + reserveResidual ≡ F(按构造,残值兜底)。
250
257
  const FORFEIT_LEVEL_RATES = { 1: 0.70, 2: 0.20, 3: 0.10 };
251
- const round2 = (n) => Math.round(n * 100) / 100;
252
- const reserveResidual = (amount, note) => {
253
- const a = round2(amount);
254
- if (a <= 0)
255
- return;
256
- // 复制 redirectToCommissionReserve 的两笔写入(L0 不依赖 server.ts;kind 用既有 orphan_sponsor 桶)
257
- db.prepare(`UPDATE commission_reserve SET balance = balance + ?, total_orphan_sponsor = total_orphan_sponsor + ?, updated_at = datetime('now') WHERE id = 'main'`).run(a, a);
258
- db.prepare(`INSERT INTO commission_reserve_txns (id, kind, from_user_id, amount, related_order_id, note) VALUES (?,?,?,?,?,?)`)
259
- .run(generateId('crt'), 'redirect_orphan_sponsor', sellerId, a, orderId, note);
260
- };
261
258
  const protocolFeeRate = () => {
262
259
  const key = isSecondhand ? 'protocol_fee_rate_secondhand' : 'protocol_fee_rate_shop';
263
260
  const row = db.prepare('SELECT value FROM protocol_params WHERE key = ?').get(key);
264
261
  const v = Number(row?.value);
265
262
  return Number.isFinite(v) && v >= 0 ? v : (isSecondhand ? 0.01 : 0.02);
266
263
  };
267
- // 罚没【收取 + RFC-007 守恒分配】,返回实扣额(= 0 表示起步免赔付,无没收无印钱)
264
+ // 罚没【收取 + RFC-007 守恒分配】(整数 base-units;allocate 保证精确守恒),返回实扣额 units(0=起步免赔付)
268
265
  const forfeitAndDistribute = (penalty) => {
269
266
  // 起步免赔付:无背书订单(stake_backing=0)绝不没收、绝不碰新商家自由余额
270
- if (orderStakeBacking <= 0)
267
+ if (orderStakeBackingU <= 0)
271
268
  return 0;
272
- // stage 2 收取:先扣 staked(封顶背书额),不足部分再扣卖家自由 balance(责任自负;封顶其真实余额→不转负)
273
- const fromStaked = Math.min(penalty, orderStakeBacking);
274
- const remainder = round2(penalty - fromStaked);
275
- const sellerBal = Math.max(0, Number(db.prepare('SELECT COALESCE(balance,0) AS b FROM wallets WHERE user_id = ?').get(sellerId)?.b ?? 0));
276
- const fromBalance = round2(Math.min(remainder, sellerBal));
277
- const F = round2(fromStaked + fromBalance);
269
+ // 收取:先扣 staked(封顶背书额),不足再扣卖家自由 balance(责任自负;封顶其真实余额→不转负)
270
+ const fromStaked = Math.min(penalty, orderStakeBackingU);
271
+ const remainder = penalty - fromStaked;
272
+ const sellerBalU = Math.max(0, walletUnits(db, sellerId).balance);
273
+ const fromBalance = Math.min(remainder, sellerBalU);
274
+ const F = fromStaked + fromBalance;
278
275
  if (F <= 0)
279
276
  return 0;
280
- if (fromStaked > 0)
281
- db.prepare('UPDATE wallets SET staked = staked - ? WHERE user_id = ?').run(fromStaked, sellerId);
282
- if (fromBalance > 0)
283
- db.prepare('UPDATE wallets SET balance = balance - ? WHERE user_id = ?').run(fromBalance, sellerId);
277
+ applyWalletDelta(db, sellerId, { staked: -fromStaked, balance: -fromBalance });
284
278
  // 1. 协议回收原始平台费(封顶 F,不牟利)
285
- const protocolTake = round2(Math.min(F, total * protocolFeeRate()));
279
+ const protocolTake = Math.min(F, mulRate(totalU, protocolFeeRate()));
286
280
  if (protocolTake > 0)
287
- db.prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?').run(protocolTake, sysUserId);
288
- // 2. R 与买家补偿
289
- const R = round2(F - protocolTake);
290
- const buyerComp = round2(R * 0.5);
281
+ applyWalletDelta(db, sysUserId, { balance: protocolTake });
282
+ // 2. R 与买家补偿(精确平分:allocate 两桶求和 ≡ R)
283
+ const R = F - protocolTake;
284
+ const [buyerComp, promoterHalf] = allocate(R, [1, 1]);
291
285
  if (buyerComp > 0)
292
- db.prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?').run(buyerComp, buyerId);
293
- // 3. 推广半 → l1/l2/l3 按原始佣金比例,封顶各自原始佣金
294
- const promoterHalf = round2(R - buyerComp);
286
+ applyWalletDelta(db, buyerId, { balance: buyerComp });
287
+ // 3. 推广半 → l1/l2/l3 按原始佣金比例分,封顶原始佣金总额(allocate 求和 ≡ payable)
295
288
  const commissionRate = Number(order.snapshot_commission_rate ?? 0);
296
- const pool = round2(total * (Number.isFinite(commissionRate) && commissionRate > 0 ? commissionRate : 0));
289
+ const poolU = mulRate(totalU, Number.isFinite(commissionRate) && commissionRate > 0 ? commissionRate : 0);
297
290
  const levels = [
298
- { uid: order.l1_uid, orig: round2(pool * FORFEIT_LEVEL_RATES[1]) },
299
- { uid: order.l2_uid, orig: round2(pool * FORFEIT_LEVEL_RATES[2]) },
300
- { uid: order.l3_uid, orig: round2(pool * FORFEIT_LEVEL_RATES[3]) },
291
+ { uid: order.l1_uid, orig: mulRate(poolU, FORFEIT_LEVEL_RATES[1]) },
292
+ { uid: order.l2_uid, orig: mulRate(poolU, FORFEIT_LEVEL_RATES[2]) },
293
+ { uid: order.l3_uid, orig: mulRate(poolU, FORFEIT_LEVEL_RATES[3]) },
301
294
  ].filter(l => l.uid); // 仅【真实存在的推广人】参与
302
- const originalCommissionTotal = round2(levels.reduce((s, l) => s + l.orig, 0));
295
+ const originalCommissionTotal = levels.reduce((s, l) => s + l.orig, 0);
303
296
  let promotersPaid = 0;
304
297
  if (promoterHalf > 0 && originalCommissionTotal > 0) {
305
298
  const payable = Math.min(promoterHalf, originalCommissionTotal); // 封顶原始佣金总额
306
- for (const l of levels) {
307
- const share = round2(payable * (l.orig / originalCommissionTotal));
299
+ const shares = allocate(payable, levels.map(l => l.orig));
300
+ levels.forEach((l, i) => {
301
+ const share = shares[i];
308
302
  if (share <= 0)
309
- continue;
310
- db.prepare('UPDATE wallets SET balance = balance + ?, earned = earned + ? WHERE user_id = ?').run(share, share, l.uid);
311
- promotersPaid = round2(promotersPaid + share);
312
- }
303
+ return;
304
+ applyWalletDelta(db, l.uid, { balance: share, earned: share });
305
+ promotersPaid += share;
306
+ });
313
307
  }
314
- // 4. 推广半残值(超封顶 / 无推广人 / 取整余数)→ 三级公池;绝不给买家、绝不印钱
315
- reserveResidual(promoterHalf - promotersPaid, `RFC-007 fault forfeit residual order=${orderId}`);
308
+ // 4. 推广半残值(超封顶 / 无推广人 / 取整余数)→ 【买家】(受损方吸收违约方罚金剩余,可超 50%)。
309
+ // 决策 A(2026-06-07):对齐 RFC-007 Invariant #2「buyer absorbs the residual」+ 公开 economic.json。
310
+ // 理由:残值是【卖家罚金 + 无成交】,非正常单的未归属销售 margin,故归被坑的对手方,不入 commission_reserve。
311
+ // 守恒:protocolTake + (buyerComp+residual) + promotersPaid = protocolTake + R = F。绝不印钱。
312
+ const residual = promoterHalf - promotersPaid;
313
+ if (residual > 0)
314
+ applyWalletDelta(db, buyerId, { balance: residual });
316
315
  return F;
317
316
  };
318
317
  // P0.1:RFQ 路径的 bid_stake_held — fault 时由各分支按规则处理
319
- const bidStakeHeld = Number(order.bid_stake_held || 0);
318
+ const bidStakeHeldU = toUnits(Number(order.bid_stake_held || 0));
319
+ // bid_stake_held 没收 50/50 的公用逻辑(中标后弃单的额外惩罚)
320
+ const forfeitBidStake5050 = () => {
321
+ if (bidStakeHeldU <= 0)
322
+ return;
323
+ applyWalletDelta(db, sellerId, { staked: -bidStakeHeldU });
324
+ const [compBuyer, compSys] = allocate(bidStakeHeldU, [1, 1]);
325
+ if (compBuyer > 0)
326
+ applyWalletDelta(db, buyerId, { balance: compBuyer });
327
+ if (compSys > 0)
328
+ applyWalletDelta(db, sysUserId, { balance: compSys });
329
+ };
320
330
  if (faultState === 'fault_seller') {
321
331
  // 1. buyer escrow 全额退回
322
- db.prepare('UPDATE wallets SET escrowed = escrowed - ?, balance = balance + ? WHERE user_id = ?')
323
- .run(total, total, buyerId);
332
+ applyWalletDelta(db, buyerId, { escrowed: -totalU, balance: totalU });
324
333
  // P0.1:bid_stake_held 没收 50/50(中标后弃单的额外惩罚)
325
- if (bidStakeHeld > 0) {
326
- db.prepare('UPDATE wallets SET staked = staked - ? WHERE user_id = ?').run(bidStakeHeld, sellerId);
327
- const compToBuyer = Math.round(bidStakeHeld * 0.5 * 100) / 100;
328
- const compToSys = Math.round((bidStakeHeld - compToBuyer) * 100) / 100;
329
- if (compToBuyer > 0)
330
- db.prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?').run(compToBuyer, buyerId);
331
- if (compToSys > 0)
332
- db.prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?').run(compToSys, sysUserId);
333
- }
334
+ forfeitBidStake5050();
334
335
  // 2. 罚没(fault_penalty_rate×total,staked 不足扣自由 balance,绝不印钱)→ RFC-007 守恒分配
335
- if (penaltyAmount > 0)
336
- forfeitAndDistribute(penaltyAmount);
336
+ if (penaltyU > 0)
337
+ forfeitAndDistribute(penaltyU);
337
338
  // 3. 库存回退(非二手)
338
339
  if (!isSecondhand)
339
340
  db.prepare('UPDATE products SET stock = stock + 1 WHERE id = ?').run(order.product_id);
@@ -353,21 +354,12 @@ export function settleFault(db, orderId, faultState) {
353
354
  const isSelfFulfill = !order.logistics_id;
354
355
  if (isSelfFulfill) {
355
356
  // 1. buyer escrow 全额退回
356
- db.prepare('UPDATE wallets SET escrowed = escrowed - ?, balance = balance + ? WHERE user_id = ?')
357
- .run(total, total, buyerId);
357
+ applyWalletDelta(db, buyerId, { escrowed: -totalU, balance: totalU });
358
358
  // 2. bid_stake_held 没收 50/50(同 fault_seller 逻辑)
359
- if (bidStakeHeld > 0) {
360
- db.prepare('UPDATE wallets SET staked = staked - ? WHERE user_id = ?').run(bidStakeHeld, sellerId);
361
- const compToBuyer = Math.round(bidStakeHeld * 0.5 * 100) / 100;
362
- const compToSys = Math.round((bidStakeHeld - compToBuyer) * 100) / 100;
363
- if (compToBuyer > 0)
364
- db.prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?').run(compToBuyer, buyerId);
365
- if (compToSys > 0)
366
- db.prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?').run(compToSys, sysUserId);
367
- }
359
+ forfeitBidStake5050();
368
360
  // 3. 罚没(self-fulfill seller 违约;fault_penalty_rate×total,staked 不足扣自由 balance)→ RFC-007 守恒分配
369
- if (penaltyAmount > 0)
370
- forfeitAndDistribute(penaltyAmount);
361
+ if (penaltyU > 0)
362
+ forfeitAndDistribute(penaltyU);
371
363
  // 4. 库存回退
372
364
  if (!isSecondhand)
373
365
  db.prepare('UPDATE products SET stock = stock + 1 WHERE id = ?').run(order.product_id);
@@ -381,17 +373,13 @@ export function settleFault(db, orderId, faultState) {
381
373
  else {
382
374
  // Phase 2 logistics 市场:真正的 logistics 接单后违约
383
375
  // 1. buyer escrow 全额退回
384
- db.prepare('UPDATE wallets SET escrowed = escrowed - ?, balance = balance + ? WHERE user_id = ?')
385
- .run(total, total, buyerId);
376
+ applyWalletDelta(db, buyerId, { escrowed: -totalU, balance: totalU });
386
377
  // 2. seller 无责 → bid_stake_held / stake 全额返还
387
- if (bidStakeHeld > 0) {
388
- db.prepare('UPDATE wallets SET balance = balance + ?, staked = staked - ? WHERE user_id = ?').run(bidStakeHeld, bidStakeHeld, sellerId);
389
- }
378
+ if (bidStakeHeldU > 0)
379
+ applyWalletDelta(db, sellerId, { balance: bidStakeHeldU, staked: -bidStakeHeldU });
390
380
  // seller 无责 → 退还其【该单实际背书的 stake】(= stake_backing;起步阶段=0,无可退)
391
- if (orderStakeBacking > 0) {
392
- db.prepare('UPDATE wallets SET staked = staked - ?, balance = balance + ? WHERE user_id = ?')
393
- .run(orderStakeBacking, orderStakeBacking, sellerId);
394
- }
381
+ if (orderStakeBackingU > 0)
382
+ applyWalletDelta(db, sellerId, { staked: -orderStakeBackingU, balance: orderStakeBackingU });
395
383
  // 3. 库存回退
396
384
  if (!isSecondhand)
397
385
  db.prepare('UPDATE products SET stock = stock + 1 WHERE id = ?').run(order.product_id);
@@ -415,9 +403,8 @@ export function settleFault(db, orderId, faultState) {
415
403
  catch { }
416
404
  }
417
405
  // P0.1:买家违约 → bid_stake_held 全额返还卖家(卖家未失责)
418
- if (bidStakeHeld > 0) {
419
- db.prepare('UPDATE wallets SET balance = balance + ?, staked = staked - ? WHERE user_id = ?').run(bidStakeHeld, bidStakeHeld, sellerId);
420
- }
406
+ if (bidStakeHeldU > 0)
407
+ applyWalletDelta(db, sellerId, { balance: bidStakeHeldU, staked: -bidStakeHeldU });
421
408
  }
422
409
  // 标记已结算(防止重复处置)
423
410
  db.prepare("UPDATE orders SET settled_fault_at = datetime('now') WHERE id = ?").run(orderId);
@@ -434,19 +421,20 @@ export function settleDeclinedNoFault(db, orderId) {
434
421
  return;
435
422
  if (order.settled_fault_at)
436
423
  return; // 幂等(复用 settled_fault_at 标记)
437
- const total = Number(order.total_amount);
438
424
  const buyerId = order.buyer_id;
439
425
  const sellerId = order.seller_id;
440
426
  const isSecondhand = order.source === 'secondhand';
441
- const orderStakeBacking = Math.max(0, Math.round(Number(order.stake_backing || 0) * 100) / 100);
442
- const bidStakeHeld = Number(order.bid_stake_held || 0);
427
+ // RFC-014:整数 base-units
428
+ const totalU = toUnits(Number(order.total_amount));
429
+ const orderStakeBackingU = Math.max(0, toUnits(Number(order.stake_backing || 0)));
430
+ const bidStakeHeldU = toUnits(Number(order.bid_stake_held || 0));
443
431
  // 1. 买家 escrow 全额退回
444
- db.prepare('UPDATE wallets SET escrowed = escrowed - ?, balance = balance + ? WHERE user_id = ?').run(total, total, buyerId);
432
+ applyWalletDelta(db, buyerId, { escrowed: -totalU, balance: totalU });
445
433
  // 2. 卖家质押全退(封顶其实际 staked,绝不转负)—— 无责零成本
446
- const sellerStaked = Math.max(0, Number(db.prepare('SELECT COALESCE(staked,0) AS s FROM wallets WHERE user_id = ?').get(sellerId)?.s ?? 0));
447
- const returnStake = Math.min(orderStakeBacking + bidStakeHeld, sellerStaked);
434
+ const sellerStakedU = Math.max(0, walletUnits(db, sellerId).staked);
435
+ const returnStake = Math.min(orderStakeBackingU + bidStakeHeldU, sellerStakedU);
448
436
  if (returnStake > 0)
449
- db.prepare('UPDATE wallets SET staked = staked - ?, balance = balance + ? WHERE user_id = ?').run(returnStake, returnStake, sellerId);
437
+ applyWalletDelta(db, sellerId, { staked: -returnStake, balance: returnStake });
450
438
  // 3. 库存 / 二手状态恢复
451
439
  if (!isSecondhand)
452
440
  db.prepare('UPDATE products SET stock = stock + 1 WHERE id = ?').run(order.product_id);