@seasonkoh/webaz 0.1.29 → 0.1.31

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.
Files changed (126) hide show
  1. package/dist/bond-refund-blockers.js +50 -0
  2. package/dist/bond-slash.js +100 -0
  3. package/dist/bond-terms.js +31 -0
  4. package/dist/direct-pay-bond-rail-clearance.js +10 -5
  5. package/dist/direct-pay-cancel-refund.js +160 -0
  6. package/dist/direct-pay-create.js +78 -9
  7. package/dist/direct-pay-disclosures.js +17 -11
  8. package/dist/direct-pay-fee-ar.js +2 -2
  9. package/dist/direct-pay-fee-prepay-request.js +80 -0
  10. package/dist/direct-pay-returns.js +104 -0
  11. package/dist/direct-pay-stock.js +9 -0
  12. package/dist/direct-receive-deferral.js +30 -1
  13. package/dist/direct-receive-deposits.js +122 -17
  14. package/dist/free-shipping.js +37 -0
  15. package/dist/fx-rates.js +7 -0
  16. package/dist/layer0-foundation/L0-1-database/schema.js +246 -0
  17. package/dist/layer0-foundation/L0-2-state-machine/engine.js +1 -0
  18. package/dist/layer0-foundation/L0-2-state-machine/transitions.js +73 -0
  19. package/dist/layer1-agent/L1-1-mcp-server/server.js +150 -33
  20. package/dist/layer2-business/L2-6-notifications/notification-engine.js +110 -41
  21. package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +133 -31
  22. package/dist/layer3-trust/L3-1-dispute-engine/evidence-storage.js +16 -4
  23. package/dist/layer3-trust/L3-1-dispute-engine/mutual-cancel.js +156 -0
  24. package/dist/platform-receive-accounts.js +94 -0
  25. package/dist/pwa/arbitration-read-admin.js +37 -0
  26. package/dist/pwa/arbitrator-lifecycle.js +100 -0
  27. package/dist/pwa/contract-fingerprint.js +15 -0
  28. package/dist/pwa/direct-pay-order-redaction.js +20 -2
  29. package/dist/pwa/human-presence.js +2 -6
  30. package/dist/pwa/public/app-account.js +9 -9
  31. package/dist/pwa/public/app-admin-disputes.js +55 -0
  32. package/dist/pwa/public/app-agent-appeal.js +90 -0
  33. package/dist/pwa/public/app-agent-approvals.js +93 -0
  34. package/dist/pwa/public/app-agent-pair.js +127 -0
  35. package/dist/pwa/public/app-arbitrator-admin.js +87 -0
  36. package/dist/pwa/public/app-arbitrator-entry.js +9 -0
  37. package/dist/pwa/public/app-bond-deferral-ui.js +9 -0
  38. package/dist/pwa/public/app-bond-refund-ui.js +66 -0
  39. package/dist/pwa/public/app-bond-slash-ui.js +74 -0
  40. package/dist/pwa/public/app-bond-terms-ui.js +23 -0
  41. package/dist/pwa/public/app-bond-ui.js +108 -0
  42. package/dist/pwa/public/app-chat-poll.js +6 -6
  43. package/dist/pwa/public/app-contribution-hub.js +23 -0
  44. package/dist/pwa/public/app-direct-pay-accounts.js +1 -1
  45. package/dist/pwa/public/app-direct-pay-buyer.js +1 -1
  46. package/dist/pwa/public/app-direct-pay-cancel-refund.js +72 -0
  47. package/dist/pwa/public/app-direct-pay-copy.js +12 -0
  48. package/dist/pwa/public/app-direct-pay-fee-history.js +39 -0
  49. package/dist/pwa/public/app-direct-pay-fee-ops.js +1 -1
  50. package/dist/pwa/public/app-direct-pay-fee-request.js +81 -0
  51. package/dist/pwa/public/app-direct-pay-fee-requests-admin.js +70 -0
  52. package/dist/pwa/public/app-direct-pay-memo.js +14 -0
  53. package/dist/pwa/public/app-direct-pay-negotiation.js +35 -0
  54. package/dist/pwa/public/app-direct-pay-pay.js +15 -0
  55. package/dist/pwa/public/app-direct-pay-paymodal.js +32 -0
  56. package/dist/pwa/public/app-direct-pay-reconcile.js +19 -0
  57. package/dist/pwa/public/app-direct-pay-returns.js +56 -0
  58. package/dist/pwa/public/app-direct-pay-reveal.js +82 -0
  59. package/dist/pwa/public/app-direct-pay-sales-report.js +70 -0
  60. package/dist/pwa/public/app-direct-pay.js +36 -37
  61. package/dist/pwa/public/app-dispute-close-ui.js +38 -0
  62. package/dist/pwa/public/app-free-shipping-ui.js +29 -0
  63. package/dist/pwa/public/app-gmv-rail-split.js +12 -0
  64. package/dist/pwa/public/app-listing-commerce-ui.js +72 -0
  65. package/dist/pwa/public/app-mutual-cancel.js +54 -0
  66. package/dist/pwa/public/app-notif-templates-orders.js +43 -0
  67. package/dist/pwa/public/app-notif-templates.js +22 -0
  68. package/dist/pwa/public/app-order-accept-ui.js +158 -0
  69. package/dist/pwa/public/app-order-errors.js +50 -0
  70. package/dist/pwa/public/app-order-labels.js +19 -0
  71. package/dist/pwa/public/app-order-rail-filter.js +26 -0
  72. package/dist/pwa/public/app-platform-receive-accounts.js +140 -0
  73. package/dist/pwa/public/app-poll-governor.js +22 -0
  74. package/dist/pwa/public/app-profile.js +4 -4
  75. package/dist/pwa/public/app-purchase-terms-ui.js +68 -0
  76. package/dist/pwa/public/app-sale-regions-ui.js +38 -0
  77. package/dist/pwa/public/app-seller.js +1 -1
  78. package/dist/pwa/public/app-trade-tax-ui.js +33 -0
  79. package/dist/pwa/public/app.js +133 -160
  80. package/dist/pwa/public/i18n.js +675 -5
  81. package/dist/pwa/public/index.html +41 -0
  82. package/dist/pwa/public/openapi.json +719 -11
  83. package/dist/pwa/public/style.css +3 -0
  84. package/dist/pwa/routes/admin-direct-receive-deposits.js +215 -3
  85. package/dist/pwa/routes/admin-protocol-params.js +16 -0
  86. package/dist/pwa/routes/admin-reports.js +31 -5
  87. package/dist/pwa/routes/agent-governance.js +1 -1
  88. package/dist/pwa/routes/agent-grants.js +281 -32
  89. package/dist/pwa/routes/analytics.js +2 -0
  90. package/dist/pwa/routes/arbitrator.js +67 -14
  91. package/dist/pwa/routes/bond-seller.js +162 -0
  92. package/dist/pwa/routes/direct-pay-cancel-refund.js +111 -0
  93. package/dist/pwa/routes/direct-pay-disclosure-acks.js +9 -4
  94. package/dist/pwa/routes/direct-pay-pending-accept.js +222 -0
  95. package/dist/pwa/routes/direct-pay-returns.js +74 -0
  96. package/dist/pwa/routes/direct-pay-timeouts.js +186 -9
  97. package/dist/pwa/routes/disputes-read.js +20 -6
  98. package/dist/pwa/routes/disputes-write.js +91 -33
  99. package/dist/pwa/routes/external-anchors.js +4 -2
  100. package/dist/pwa/routes/fee-prepay-requests.js +66 -0
  101. package/dist/pwa/routes/governance-onboarding.js +46 -8
  102. package/dist/pwa/routes/logistics.js +4 -4
  103. package/dist/pwa/routes/me-data.js +2 -2
  104. package/dist/pwa/routes/mutual-cancel.js +62 -0
  105. package/dist/pwa/routes/orders-action.js +182 -9
  106. package/dist/pwa/routes/orders-create.js +18 -7
  107. package/dist/pwa/routes/orders-read.js +76 -14
  108. package/dist/pwa/routes/platform-receive-accounts.js +111 -0
  109. package/dist/pwa/routes/products-create.js +45 -16
  110. package/dist/pwa/routes/products-update.js +15 -1
  111. package/dist/pwa/routes/profile-identity.js +4 -2
  112. package/dist/pwa/routes/returns.js +39 -8
  113. package/dist/pwa/routes/seller-directpay-report.js +110 -0
  114. package/dist/pwa/routes/seller-quota.js +5 -1
  115. package/dist/pwa/routes/shipping-templates.js +219 -0
  116. package/dist/pwa/routes/snf.js +4 -1
  117. package/dist/pwa/routes/webauthn.js +3 -3
  118. package/dist/pwa/server.js +63 -40
  119. package/dist/runtime/agent-grant-scopes.js +69 -1
  120. package/dist/runtime/webaz-schema-helpers.js +57 -3
  121. package/dist/sale-regions.js +116 -0
  122. package/dist/shipping-templates.js +119 -0
  123. package/dist/trade-tax.js +99 -0
  124. package/dist/trade-terms.js +76 -0
  125. package/dist/version.js +1 -1
  126. package/package.json +61 -2
@@ -0,0 +1,80 @@
1
+ import { getPlatformAccount } from './platform-receive-accounts.js';
2
+ import { recordFeePrepayTopup } from './direct-pay-fee-ar.js';
3
+ export const MAX_EVIDENCE_LEN = 200;
4
+ export const MAX_EVIDENCE_NOTE_LEN = 500;
5
+ const COLS = 'id, seller_id, amount_units, currency, platform_account_id, evidence_ref, evidence_note, status, created_at, reviewed_by, reviewed_at, review_note, resulting_payment_id';
6
+ /** 卖家发起预充值申请。校验:金额正整数、凭据必填(不能无据)、平台收款方式须为 active。建 pending。不动钱。 */
7
+ export function createFeePrepayRequest(db, sellerId, input, generateId) {
8
+ const amount = Number(input.amountUnits);
9
+ if (!Number.isSafeInteger(amount) || amount <= 0)
10
+ return { ok: false, reason: 'amount must be a positive integer (base units)' }; // 严格整数,不静默截断非整数
11
+ const evidenceRef = String(input.evidenceRef ?? '').trim();
12
+ if (!evidenceRef)
13
+ return { ok: false, reason: '付款凭证号必填(不能无据)' };
14
+ if (evidenceRef.length > MAX_EVIDENCE_LEN)
15
+ return { ok: false, reason: `evidence_ref must be ≤ ${MAX_EVIDENCE_LEN} chars` };
16
+ const note = input.evidenceNote == null ? null : String(input.evidenceNote).trim().slice(0, MAX_EVIDENCE_NOTE_LEN) || null;
17
+ const currency = input.currency == null || !String(input.currency).trim() ? null : String(input.currency).trim().toUpperCase().slice(0, 8);
18
+ // platform_account_id 必填 + 必须 active:充值必须对准一个 WebAZ 收款账户,admin 才能据此核对到账来源(不能无账户的半场外流程)。
19
+ const pid = input.platformAccountId == null ? '' : String(input.platformAccountId).trim();
20
+ if (!pid)
21
+ return { ok: false, reason: '必须选择平台收款方式' };
22
+ const acc = getPlatformAccount(db, pid);
23
+ if (!acc || acc.status !== 'active')
24
+ return { ok: false, reason: '所选平台收款方式无效或已停用' };
25
+ const id = generateId('fpr');
26
+ db.prepare(`INSERT INTO direct_pay_fee_prepay_requests (id, seller_id, amount_units, currency, platform_account_id, evidence_ref, evidence_note, status) VALUES (?,?,?,?,?,?,?, 'pending')`)
27
+ .run(id, sellerId, amount, currency, pid, evidenceRef, note);
28
+ return { ok: true, request: getRequest(db, id) };
29
+ }
30
+ /** 卖家自己的申请(全状态,新→旧)。 */
31
+ export function listSellerRequests(db, sellerId) {
32
+ return db.prepare(`SELECT ${COLS} FROM direct_pay_fee_prepay_requests WHERE seller_id = ? ORDER BY created_at DESC, id DESC`).all(sellerId);
33
+ }
34
+ export function getRequest(db, id) {
35
+ return db.prepare(`SELECT ${COLS} FROM direct_pay_fee_prepay_requests WHERE id = ?`).get(id) ?? null;
36
+ }
37
+ /** 卖家撤销自己的 pending 申请(owner-scoped;仅 pending 可撤)。返回是否改动。 */
38
+ export function cancelRequest(db, id, sellerId) {
39
+ return db.prepare("UPDATE direct_pay_fee_prepay_requests SET status='cancelled', reviewed_at=datetime('now') WHERE id=? AND seller_id=? AND status='pending'").run(id, sellerId).changes > 0;
40
+ }
41
+ /** admin 队列:按状态列出所有卖家的申请(pending 升序供审核;无状态时取最近)。 */
42
+ export function listAllRequests(db, status) {
43
+ if (status)
44
+ return db.prepare(`SELECT ${COLS} FROM direct_pay_fee_prepay_requests WHERE status = ? ORDER BY created_at ASC, id ASC`).all(status);
45
+ return db.prepare(`SELECT ${COLS} FROM direct_pay_fee_prepay_requests ORDER BY created_at DESC, id DESC LIMIT 200`).all();
46
+ }
47
+ /**
48
+ * admin 确认真实到账 → 入账(【唯一动钱处】)。原子:pending→approved + recordFeePrepayTopup(记 direct_pay_fee_payments,
49
+ * 带申请的 evidence_ref)+ 回填 resulting_payment_id,全在一个 tx。非 pending / 记账失败 → 整体回滚不动钱。method=usdc|fiat。
50
+ */
51
+ export function approveFeePrepayRequest(db, args) {
52
+ if (args.method !== 'usdc' && args.method !== 'fiat')
53
+ return { ok: false, error: 'BAD_METHOD' };
54
+ const req = getRequest(db, args.requestId);
55
+ if (!req)
56
+ return { ok: false, error: 'REQUEST_NOT_FOUND' };
57
+ if (req.status !== 'pending')
58
+ return { ok: false, error: 'NOT_PENDING' };
59
+ let paymentId = '';
60
+ try {
61
+ db.transaction(() => {
62
+ const r = recordFeePrepayTopup(db, { sellerId: req.seller_id, amountUnits: req.amount_units, method: args.method, recordedBy: args.adminId, evidenceRef: req.evidence_ref, note: args.reviewNote ?? undefined });
63
+ if (!r.ok)
64
+ throw new Error(r.error);
65
+ const upd = db.prepare("UPDATE direct_pay_fee_prepay_requests SET status='approved', reviewed_by=?, reviewed_at=datetime('now'), review_note=?, resulting_payment_id=? WHERE id=? AND status='pending'").run(args.adminId, args.reviewNote ?? null, r.id, args.requestId);
66
+ if (upd.changes !== 1)
67
+ throw new Error('NOT_PENDING'); // 并发保险:非 pending → 回滚(含刚记的 topup)
68
+ paymentId = r.id;
69
+ })();
70
+ }
71
+ catch (e) {
72
+ return { ok: false, error: e.message };
73
+ }
74
+ return { ok: true, paymentId };
75
+ }
76
+ /** admin 驳回 pending 申请(不动钱)。返回 ok / 非 pending。 */
77
+ export function rejectFeePrepayRequest(db, args) {
78
+ const upd = db.prepare("UPDATE direct_pay_fee_prepay_requests SET status='rejected', reviewed_by=?, reviewed_at=datetime('now'), review_note=? WHERE id=? AND status='pending'").run(args.adminId, args.reviewNote ?? null, args.requestId);
79
+ return upd.changes === 1 ? { ok: true } : { ok: false, error: 'NOT_PENDING' };
80
+ }
@@ -0,0 +1,104 @@
1
+ function loadReturn(db, returnId) {
2
+ return db.prepare(`SELECT r.id, r.order_id, r.buyer_id, r.seller_id, r.product_id, r.status, r.reason,
3
+ r.refund_amount, r.await_refund_since, o.payment_rail, o.total_amount AS order_total
4
+ FROM return_requests r JOIN orders o ON o.id = r.order_id WHERE r.id = ?`).get(returnId);
5
+ }
6
+ export function returnRefundRespondDays(db) {
7
+ try {
8
+ const p = db.prepare("SELECT value FROM protocol_params WHERE key = 'direct_pay.return_refund_respond_days'").get();
9
+ if (p)
10
+ return Math.max(1, Number(p.value) || 5);
11
+ }
12
+ catch { /* 表缺失 → 默认 */ }
13
+ return 5;
14
+ }
15
+ /**
16
+ * 卖家同意退货后进入 await_refund(替代 escrow 的 executeReturnRefund)。
17
+ * fromStatus = 'pending'(同意·无取件)| 'picked_up'(取件流·确认收到退货)。CAS 防并发。
18
+ * 同 tx 内写协商时间线消息(与 escrow accept 同模式)。调用方负责通知。
19
+ */
20
+ export function enterAwaitRefund(db, args) {
21
+ const rr = loadReturn(db, args.returnId);
22
+ if (!rr)
23
+ return { ok: false, error: '退货请求不存在', error_code: 'RETURN_NOT_FOUND' };
24
+ if (rr.payment_rail !== 'direct_p2p')
25
+ return { ok: false, error: '仅直付订单走场外退款握手', error_code: 'NOT_DIRECT_PAY' };
26
+ const resp = typeof args.sellerResponse === 'string' ? args.sellerResponse.slice(0, 500) : null;
27
+ const r = db.prepare(`UPDATE return_requests SET status = 'await_refund', await_refund_since = datetime('now'),
28
+ seller_response = COALESCE(?, seller_response) WHERE id = ? AND status = ?`)
29
+ .run(resp, args.returnId, args.fromStatus);
30
+ if (r.changes !== 1)
31
+ return { ok: false, error: '退货请求已被处理(请刷新后查看)', error_code: 'RETURN_ALREADY_SETTLED' };
32
+ try {
33
+ db.prepare(`INSERT INTO return_messages (id, return_id, sender_id, sender_role, body) VALUES (?,?,?,?,?)`)
34
+ .run(args.messageId, rr.id, rr.seller_id, 'seller', `[✓ 同意退货 · 待场外退款] ${resp || ''}`);
35
+ }
36
+ catch { /* 时间线消息 best-effort */ }
37
+ return { ok: true, status: 'await_refund' };
38
+ }
39
+ /** 卖家声明已场外退款(await_refund → refund_marked,可附退款参考)。此后买家 confirm 或走争议。 */
40
+ export function markReturnRefunded(db, args) {
41
+ const rr = loadReturn(db, args.returnId);
42
+ if (!rr)
43
+ return { ok: false, error: '退货请求不存在', error_code: 'RETURN_NOT_FOUND' };
44
+ if (rr.seller_id !== args.sellerId)
45
+ return { ok: false, error: '只有订单卖家可声明退款', error_code: 'NOT_ORDER_SELLER' };
46
+ if (rr.payment_rail !== 'direct_p2p')
47
+ return { ok: false, error: '仅直付订单走场外退款握手', error_code: 'NOT_DIRECT_PAY' };
48
+ const ref = typeof args.refundReference === 'string' ? args.refundReference.trim().slice(0, 200) : null;
49
+ const r = db.prepare(`UPDATE return_requests SET status = 'refund_marked', refund_reference = ? WHERE id = ? AND status = 'await_refund'`)
50
+ .run(ref, args.returnId);
51
+ if (r.changes !== 1)
52
+ return { ok: false, error: '当前状态不可声明退款(须先同意退货,或已被处理)', error_code: 'NOT_AWAIT_REFUND' };
53
+ try {
54
+ db.prepare(`INSERT INTO return_messages (id, return_id, sender_id, sender_role, body) VALUES (?,?,?,?,?)`)
55
+ .run(args.messageId, rr.id, rr.seller_id, 'seller', `[💸 已声明场外退款] ${ref ? `退款参考:${ref}` : ''}`);
56
+ }
57
+ catch { /* best-effort */ }
58
+ return { ok: true, status: 'refund_marked' };
59
+ }
60
+ /**
61
+ * 买家确认已收到场外退款(refund_marked → refunded,终态)。【必须由路由 db.transaction 包裹 + Passkey 门】。
62
+ * 全额退款 → completion_count -1(与 escrow 同口径的 best-effort 社交计数;无钱、无库存、无 clearing)。
63
+ */
64
+ export function confirmReturnRefundReceived(db, args) {
65
+ const rr = loadReturn(db, args.returnId);
66
+ if (!rr)
67
+ return { ok: false, error: '退货请求不存在', error_code: 'RETURN_NOT_FOUND' };
68
+ if (rr.buyer_id !== args.buyerId)
69
+ return { ok: false, error: '只有订单买家可确认收到退款', error_code: 'NOT_ORDER_BUYER' };
70
+ if (rr.payment_rail !== 'direct_p2p')
71
+ return { ok: false, error: '仅直付订单走场外退款握手', error_code: 'NOT_DIRECT_PAY' };
72
+ const cas = db.prepare(`UPDATE return_requests SET status = 'refunded', resolved_at = datetime('now') WHERE id = ? AND status = 'refund_marked'`)
73
+ .run(args.returnId);
74
+ if (cas.changes !== 1)
75
+ return { ok: false, error: '卖家尚未声明退款,不可确认', error_code: 'REFUND_NOT_MARKED' };
76
+ if (Number(rr.refund_amount) >= Number(rr.order_total)) {
77
+ db.prepare(`UPDATE products SET completion_count = MAX(0, COALESCE(completion_count,0) - 1) WHERE id = ?`).run(rr.product_id);
78
+ }
79
+ try {
80
+ db.prepare(`INSERT INTO return_messages (id, return_id, sender_id, sender_role, body) VALUES (?,?,?,?,?)`)
81
+ .run(args.messageId, rr.id, rr.buyer_id, 'buyer', '[✅ 已确认收到场外退款] 退货完成');
82
+ }
83
+ catch { /* best-effort */ }
84
+ const SELLER_FAULT_REASONS = new Set(['quality', 'wrong_item', 'damaged']);
85
+ return {
86
+ ok: true, status: 'refunded', order_id: rr.order_id, seller_id: rr.seller_id,
87
+ seller_fault_reason: SELLER_FAULT_REASONS.has(String(rr.reason)) ? String(rr.reason) : null,
88
+ };
89
+ }
90
+ /**
91
+ * 直付退货是否可升级争议(escalate 的 direct_p2p 分支;通用分支 rejected / pending≥7d 仍适用):
92
+ * - refund_marked:随时可(卖家声明≠买家收到,声明不实的收口权在买家)。
93
+ * - await_refund:超 respond 窗(默认 5 天)—— 卖家同意了却迟迟不退款。
94
+ */
95
+ export function directPayReturnEscalatable(db, rr) {
96
+ if (rr.status === 'refund_marked')
97
+ return true;
98
+ if (rr.status === 'await_refund' && rr.await_refund_since) {
99
+ const over = db.prepare(`SELECT datetime(?, '+' || ? || ' days') < datetime('now') AS o`)
100
+ .get(String(rr.await_refund_since), returnRefundRespondDays(db));
101
+ return !!over.o;
102
+ }
103
+ return false;
104
+ }
@@ -0,0 +1,9 @@
1
+ /** 允许自动回补的取消来源状态(= A 类:货从未出库)。pending_accept=手动接单待确认(v16),付款前必然未出库。 */
2
+ export const PRE_SHIP_RESTOCK_STATUSES = new Set(['pending_accept', 'direct_pay_window', 'direct_expired_unconfirmed', 'payment_query', 'accepted']);
3
+ /** 回补库存(仅 A 类 pre-ship 来源;B 类已出库来源拒绝 —— 走退货验收上架)。返回是否真的回补了。 */
4
+ export function restorePreShipDirectPayStock(db, args) {
5
+ if (!PRE_SHIP_RESTOCK_STATUSES.has(args.fromStatus))
6
+ return false; // 已出库/争议来源:绝不直接回补
7
+ db.prepare('UPDATE products SET stock = stock + ? WHERE id = ?').run(args.quantity, args.productId);
8
+ return true;
9
+ }
@@ -121,7 +121,36 @@ export function listDeferrals(db, opts = {}) {
121
121
  }
122
122
  return db.prepare(`SELECT ${cols} FROM direct_receive_deferrals ORDER BY created_at DESC, rowid DESC`).all();
123
123
  }
124
- /** 到期清理(cron):granted 且【超过 grace_until】→ expired。返回受影响的 user_id(调用方据此停权;本模块不改 privileges)。 */
124
+ // ───── B4:缓交收口 —— 到期前提醒 / 缴清转正式 / 到期停权 helper ─────────────────────────────
125
+ /** 即将到期且未提醒过的 granted 缓交(expires_at 在 now..now+withinDays 内)。cron 提醒用;提醒后 markDeferralReminded 去重。 */
126
+ export function listExpiringDeferrals(db, nowIso, withinDays) {
127
+ const now = Date.parse(nowIso);
128
+ if (!Number.isFinite(now))
129
+ return [];
130
+ const rows = db.prepare("SELECT id, user_id, expires_at FROM direct_receive_deferrals WHERE status = 'granted' AND reminder_sent_at IS NULL AND expires_at IS NOT NULL").all();
131
+ const horizon = now + Math.max(0, withinDays) * 86_400_000;
132
+ return rows.filter(r => { const e = Date.parse(r.expires_at); return Number.isFinite(e) && e > now && e <= horizon; });
133
+ }
134
+ export function markDeferralReminded(db, deferralId) {
135
+ db.prepare("UPDATE direct_receive_deferrals SET reminder_sent_at = datetime('now') WHERE id = ? AND reminder_sent_at IS NULL").run(deferralId);
136
+ }
137
+ /** 缴清转正式(B4):生产保证金确认后调用 —— 该 user 的 granted 缓交 → satisfied(解除缓交额度压低;
138
+ * 入场门此后经 bond 满足)。返回转化条数。 */
139
+ export function satisfyDeferralOnBond(db, userId) {
140
+ return db.prepare("UPDATE direct_receive_deferrals SET status = 'satisfied', satisfied_at = datetime('now') WHERE user_id = ? AND status = 'granted'").run(userId).changes;
141
+ }
142
+ /** 到期停权(B4):expireDeferrals 的调用方用 —— 该 user 无生产 bond 时把 privilege 置 suspended(理由 deferral_expired)。
143
+ * 有生产 bond(缓交期间已缴清但 satisfied 漏转的兜底)→ 不停权。 */
144
+ export function suspendPrivilegeOnDeferralExpiry(db, userId) {
145
+ const hasBond = !!db.prepare("SELECT 1 FROM direct_receive_deposits WHERE user_id = ? AND status = 'locked' AND production_receipt_confirmed_at IS NOT NULL LIMIT 1").get(userId);
146
+ if (hasBond)
147
+ return false;
148
+ db.prepare(`INSERT INTO direct_receive_privileges (user_id, status, tier, suspended_reason, updated_at)
149
+ VALUES (?, 'suspended', 'T0', 'deferral_expired', datetime('now'))
150
+ ON CONFLICT(user_id) DO UPDATE SET status='suspended', suspended_reason='deferral_expired', updated_at=datetime('now')`).run(userId);
151
+ return true;
152
+ }
153
+ /** 到期清理(cron):granted 且【超过 grace_until】→ expired。返回受影响的 user_id(调用方据此停权;本模块不改 privileges —— 停权用 suspendPrivilegeOnDeferralExpiry)。 */
125
154
  export function expireDeferrals(db, nowIso) {
126
155
  const now = Date.parse(nowIso);
127
156
  if (!Number.isFinite(now))
@@ -2,13 +2,14 @@ import { toUnits, toDecimal } from './money.js';
2
2
  import { getDepositRail, assertProductionDepositRail } from './deposit-rails.js';
3
3
  import { assertBondRailCleared } from './direct-pay-bond-rail-clearance.js';
4
4
  import { recordBaseBondSlash } from './direct-pay-ledger.js';
5
+ import { getPlatformAccount } from './platform-receive-accounts.js'; // P1:确认时账户快照必须可解析(审计链)
5
6
  /** LOCKED 保守默认(治理可调)。T0 = 固定 token 数(标签 ≈ S$500,非 FX)。 */
6
7
  export const DEFAULT_BASE_BOND_CONFIG = {
7
8
  tierRequiredUnits: { T0: toUnits(500) },
8
9
  pendingTtlDays: 7,
9
10
  };
10
11
  const isNonNegUnits = (x) => typeof x === 'number' && Number.isSafeInteger(x) && x >= 0;
11
- const getRow = (db, id) => db.prepare('SELECT id, user_id, tier, required_amount, amount, currency, deposit_rail, status, production_receipt_confirmed_at, created_at FROM direct_receive_deposits WHERE id = ?').get(id);
12
+ const getRow = (db, id) => db.prepare('SELECT id, user_id, tier, required_amount, amount, currency, deposit_rail, status, production_receipt_confirmed_at, terms_version, platform_account_id, created_at FROM direct_receive_deposits WHERE id = ?').get(id);
12
13
  /** 某档要求的【固定 token 数】(整数 base-units)。未配置的档 → 抛(T1/T2 在 PR-5 才支持)。 */
13
14
  export function requiredBondUnits(tier, config = DEFAULT_BASE_BOND_CONFIG) {
14
15
  const u = config.tierRequiredUnits[tier];
@@ -36,9 +37,9 @@ export function openDeposit(db, args) {
36
37
  catch (e) {
37
38
  return { ok: false, reason: e.message };
38
39
  }
39
- db.prepare(`INSERT INTO direct_receive_deposits (id, user_id, tier, required_amount, amount, currency, deposit_rail, status, created_at, updated_at)
40
- VALUES (?,?,?,?,0,?,?, 'pending', datetime('now'), datetime('now'))`)
41
- .run(depositId, userId, tier, toDecimal(required), currency, depositRail);
40
+ db.prepare(`INSERT INTO direct_receive_deposits (id, user_id, tier, required_amount, amount, currency, deposit_rail, status, external_ref, terms_version, platform_account_id, created_at, updated_at)
41
+ VALUES (?,?,?,?,0,?,?, 'pending', ?, ?, ?, datetime('now'), datetime('now'))`)
42
+ .run(depositId, userId, tier, toDecimal(required), currency, depositRail, args.externalRef ?? null, args.termsVersion ?? null, args.platformAccountId ?? null);
42
43
  return { ok: true, status: 'pending' };
43
44
  }
44
45
  /**
@@ -153,8 +154,9 @@ export function slashBond(db, args) {
153
154
  return { ok: false, reason: 'deposit not found' };
154
155
  if (row.status === 'slashed')
155
156
  return { ok: true, status: 'slashed', already: true }; // 幂等:不重复记 provenance
156
- if (row.status !== 'locked')
157
- return { ok: false, reason: `can only slash a locked bond (got '${row.status}')` };
157
+ // B3:refunding(退出冷静期中)也可罚 —— 退出申请不能成为躲避罚没的通道;罚没即终结退还流。
158
+ if (row.status !== 'locked' && row.status !== 'refunding')
159
+ return { ok: false, reason: `can only slash a locked/refunding bond (got '${row.status}')` };
158
160
  db.transaction(() => {
159
161
  recordBaseBondSlash(db, { userId: row.user_id, amountUnits: toUnits(row.amount), txnId: args.txnId, reason: args.reason }); // provenance only
160
162
  db.prepare("UPDATE direct_receive_deposits SET status = 'slashed', updated_at = datetime('now') WHERE id = ?").run(args.depositId);
@@ -166,18 +168,20 @@ export function slashBond(db, args) {
166
168
  return { ok: true, status: 'slashed' };
167
169
  }
168
170
  // ───── PR-4b-3: 生产 receipt 写入(唯一 writer)─────────────────────────────────────────────────
169
- /** base-bond/合规 policy 版本 —— 【服务端生成】(非客户端入参),legal clearance 落地时随 policy 更新。 */
170
- export const DIRECT_PAY_BASE_BOND_POLICY_VERSION = 'pre-legal-unset';
171
- /** 生产 receipt 允许的法域【严格白名单】。默认空 = 任何 jurisdiction 都拒(额外 fail-closed,待法务配置)。 */
172
- export const DIRECT_PAY_BOND_JURISDICTIONS = [];
171
+ /** base-bond/合规 policy 版本 —— 【服务端生成】(非客户端入参)。2026-07-05 = 条款版本(bond-terms.ts,
172
+ * 缴纳前强制同意;Holden 决策 B 放行 operator_attested,详见 bond-rail-clearance 文件头)。 */
173
+ export const DIRECT_PAY_BASE_BOND_POLICY_VERSION = 'bond-terms.v1.2026-07-05';
174
+ /** 生产 receipt 允许的法域【严格白名单】。语义(P2 澄清):这是【平台收款主体法域】(WebAZ 以哪个法域主体
175
+ * 接收担保物;决定 registry 放行口径),【不是】卖家法域 —— 卖家侧资格由 KYB/制裁/AML 门独立把守。
176
+ * 2026-07-05:SG(随 operator_attested 放行)。 */
177
+ export const DIRECT_PAY_BOND_JURISDICTIONS = ['SG'];
173
178
  /**
174
179
  * 生产保证金 receipt【唯一】writer(PR-4b-3 scaffold)。这是【整个协议中】唯一允许写 production_receipt_confirmed_at
175
180
  * 的函数(#100 guard 机器强制:写赋值必在本函数内 且 本函数必调 assertProductionDepositRail)。
176
181
  *
177
- * 当前【fail-closed】:Lock A 对 manual(非生产)/usdc/fiat(GATED,implemented=false)抛;operator_attested Lock A,
178
- * 但 Lock B(assertBondRailCleared,registry 治理默认关)对所有 rail 没有任何 rail 能写 production receipt
179
- * 调用即抛 永不写 production receipt → Direct Pay 仍 non-launchableassert 之后的写路径在 legal-cleared rail
180
- * 落地前【不可达】(本 PR 不接真实 USDC/fiat/PSP/on-chain)。不碰 buyer wallet/escrow/order/settlement/refund。
182
+ * 2026-07-05 起:operator_attested 轨已放行(SG;Holden 决策 B,详见 bond-rail-clearance 文件头)→ 本函数
183
+ * 对该轨【真实可写】(仍要求 ROOT+Passkey 路由门 + 双锁 assert + jurisdiction 白名单)。manual(非生产)与
184
+ * usdc/fiat 自动收款轨照旧被 Lock A/B 拒。不碰 buyer wallet/escrow/order/settlement/refund
181
185
  *
182
186
  * 硬约束(即便未来 rail 放行也必须守):
183
187
  * - 拒绝把【非生产 locked】(manual/test lock,无 production receipt)升级成生产 —— 旧/测试行不得冒充生产到位。
@@ -186,12 +190,12 @@ export const DIRECT_PAY_BOND_JURISDICTIONS = [];
186
190
  */
187
191
  export function confirmProductionReceipt(db, args) {
188
192
  const { depositId, railId, expectedAmountUnits, receiptRef, jurisdiction } = args;
189
- // ⚠️ 生产/法务硬闸 —— assert【真正第一】:在任何 row 读取 / 幂等 / 拒绝 / 写之前。当前所有 rail 都被拒 → 抛 → 恒 fail-closed。
193
+ // ⚠️ 生产/法务硬闸 —— assert【真正第一】:在任何 row 读取 / 幂等 / 拒绝 / 写之前。未放行 rail 一律抛。
190
194
  // 置于最前的语义意义:即便某 deposit 之前在【已 cleared 的 rail】下确认过,若该 rail 后被【撤回】(legalCleared→false),
191
195
  // 重新确认也不再被当"幂等已确认"放过 —— 必须重新通过闸。#100 guard 要求本 helper body 必含此调用。
192
196
  assertProductionDepositRail(getDepositRail(railId));
193
197
  // Lock B(Phase 4 scaffold):rail-clearance registry 放行闸 —— legal_cleared + production_ready + 非占位 policy_version +
194
- // jurisdiction ∈ allowlist。与 Lock A【独立】,缺一即拒;当前 registry 全 fail-closed → 恒抛。置于任何 row 读/写之前。
198
+ // jurisdiction ∈ allowlist。与 Lock A【独立】,缺一即拒;置于任何 row 读/写之前。
195
199
  assertBondRailCleared(railId, jurisdiction);
196
200
  // ───── 以下在 legal-cleared 生产 rail 落地前【全部不可达】(两把闸已抛)─────
197
201
  const row = getRow(db, depositId);
@@ -211,6 +215,15 @@ export function confirmProductionReceipt(db, args) {
211
215
  return { ok: false, reason: `jurisdiction '${jurisdiction}' not in allowlist` };
212
216
  if (!['pending', 'confirmed', 'insufficient'].includes(row.status))
213
217
  return { ok: false, reason: `cannot production-confirm from status '${row.status}'` };
218
+ // P1(#240 审计):生产确认强制【条款同意 + 平台收款账户】—— 没有当前版本条款同意 = 罚没/退还无合同依据;
219
+ // 没有账户快照 = 收款审计链断。旧申报/绕过 seller route 的 openDeposit 行在此被拒(须重新申报)。
220
+ if (row.terms_version !== DIRECT_PAY_BASE_BOND_POLICY_VERSION) {
221
+ return { ok: false, reason: `deposit lacks CURRENT terms agreement (has '${row.terms_version ?? 'none'}', require '${DIRECT_PAY_BASE_BOND_POLICY_VERSION}') — seller must re-declare with terms consent` };
222
+ }
223
+ if (!row.platform_account_id)
224
+ return { ok: false, reason: 'deposit lacks a platform receiving account snapshot — seller must re-declare selecting an account' };
225
+ if (!getPlatformAccount(db, row.platform_account_id))
226
+ return { ok: false, reason: `platform account '${row.platform_account_id}' not found — audit chain broken, reject` };
214
227
  if (!isNonNegUnits(expectedAmountUnits) || expectedAmountUnits <= 0)
215
228
  return { ok: false, reason: 'expectedAmount must be a positive integer base-units' };
216
229
  if (expectedAmountUnits < toUnits(row.required_amount))
@@ -239,12 +252,104 @@ export function isProductionBaseBondLocked(db, args) {
239
252
  /**
240
253
  * 卖家级生产门(PR-4c go-live 充分必要的担保侧条件):该卖家是否有任一【生产级】锁定 base bond
241
254
  * (status='locked' 且 production_receipt_confirmed_at 非 NULL)。**非仅看 direct_receive_privileges.status='active'**。
242
- * 4b-min 无生产 base-bond rail(WAZ 未启用 / USDC·fiat 生产收款 GATED) false → 直付建单 fail-closed / non-launchable
255
+ * 2026-07-05 operator_attested 放行经运营确认的保证金使本门为真(直付整体开关 direct_pay.enabled 另判)
243
256
  * 一个生产级 receipt 只会发给已 KYC + 制裁筛查的商户,故该门也承接了 KYC/sanctions(独立 4a 事实门待其系统就绪再接)。
244
257
  */
245
258
  export function sellerHasProductionBaseBondLocked(db, sellerId) {
246
259
  return !!db.prepare("SELECT 1 FROM direct_receive_deposits WHERE user_id = ? AND status = 'locked' AND production_receipt_confirmed_at IS NOT NULL LIMIT 1").get(sellerId);
247
260
  }
261
+ /** admin 驳回申报(B1):pending|insufficient|confirmed(未 lock)→ expired + 驳回说明。locked/生产确认后不可驳。幂等。 */
262
+ export function rejectDeposit(db, args) {
263
+ const row = getRow(db, args.depositId);
264
+ if (!row)
265
+ return { ok: false, reason: 'deposit not found' };
266
+ if (row.status === 'expired')
267
+ return { ok: true, status: 'expired', already: true };
268
+ if (!['pending', 'insufficient', 'confirmed'].includes(row.status))
269
+ return { ok: false, reason: `cannot reject from status '${row.status}'` };
270
+ db.prepare(`UPDATE direct_receive_deposits SET status = 'expired', reject_note = ?, updated_at = datetime('now') WHERE id = ?`)
271
+ .run(args.note ? String(args.note).slice(0, 300) : null, args.depositId);
272
+ return { ok: true, status: 'expired' };
273
+ }
274
+ // ───── B2:退还(refund-on-exit)状态流。零真实出款 —— 退还动作发生在场外,协议只编排/记录。 ─────
275
+ /** 卖家申请退出退还:locked → refunding(CAS)+ 冷静期锚点 + 暂停直付资格(退出中不得接新直付单)。
276
+ * 调用方【必须先过 unlock blockers 枚举】(bond-refund-blockers,fail-closed);本函数只做状态手术。 */
277
+ export function requestBondRefund(db, args) {
278
+ const row = getRow(db, args.depositId);
279
+ if (!row || row.user_id !== args.userId)
280
+ return { ok: false, reason: 'deposit not found' };
281
+ if (row.status !== 'locked')
282
+ return { ok: false, reason: `can only request refund on a locked bond (got '${row.status}')` };
283
+ db.transaction(() => {
284
+ const r = db.prepare("UPDATE direct_receive_deposits SET status = 'refunding', refund_requested_at = datetime('now'), updated_at = datetime('now') WHERE id = ? AND status = 'locked'").run(args.depositId);
285
+ if (r.changes !== 1)
286
+ throw new Error('refund request race: already processed');
287
+ db.prepare(`INSERT INTO direct_receive_privileges (user_id, status, tier, suspended_reason, updated_at)
288
+ VALUES (?, 'suspended', ?, 'bond_refund_requested', datetime('now'))
289
+ ON CONFLICT(user_id) DO UPDATE SET status='suspended', suspended_reason='bond_refund_requested', updated_at=datetime('now')`)
290
+ .run(row.user_id, row.tier);
291
+ })();
292
+ return { ok: true, status: 'refunding' };
293
+ }
294
+ /** 卖家撤销退出申请:refunding → locked + 恢复直付资格。仅 admin 尚未执行退还前。 */
295
+ export function cancelBondRefundRequest(db, args) {
296
+ const row = getRow(db, args.depositId);
297
+ if (!row || row.user_id !== args.userId)
298
+ return { ok: false, reason: 'deposit not found' };
299
+ if (row.status !== 'refunding')
300
+ return { ok: false, reason: `no refund request in progress (got '${row.status}')` };
301
+ db.transaction(() => {
302
+ const r = db.prepare("UPDATE direct_receive_deposits SET status = 'locked', refund_requested_at = NULL, updated_at = datetime('now') WHERE id = ? AND status = 'refunding'").run(args.depositId);
303
+ if (r.changes !== 1)
304
+ throw new Error('cancel race: already processed');
305
+ db.prepare(`INSERT INTO direct_receive_privileges (user_id, status, tier, suspended_reason, granted_at, updated_at)
306
+ VALUES (?, 'active', ?, NULL, datetime('now'), datetime('now'))
307
+ ON CONFLICT(user_id) DO UPDATE SET status='active', suspended_reason=NULL, updated_at=datetime('now')`)
308
+ .run(row.user_id, row.tier);
309
+ })();
310
+ return { ok: true, status: 'locked' };
311
+ }
312
+ /** admin 执行退还(场外退款完成后记录):refunding + 冷静期已满 → refunded + 凭据 + released_at。
313
+ * 资格保持 suspended(bond 已退,入场门 sellerHasProductionBaseBondLocked 自然为 false)。凭据必填。 */
314
+ export function executeBondRefund(db, args) {
315
+ const row = getRow(db, args.depositId);
316
+ if (!row)
317
+ return { ok: false, reason: 'deposit not found' };
318
+ if (row.status === 'refunded')
319
+ return { ok: true, status: 'refunded', already: true };
320
+ if (row.status !== 'refunding')
321
+ return { ok: false, reason: `can only execute on a refunding bond (got '${row.status}')` };
322
+ if (!args.evidenceRef || !args.evidenceRef.trim())
323
+ return { ok: false, reason: 'refund evidence ref required(场外退还凭据必填)' };
324
+ const reqAt = db.prepare('SELECT refund_requested_at r FROM direct_receive_deposits WHERE id = ?').get(args.depositId).r;
325
+ const reqMs = reqAt ? Date.parse(reqAt.includes('T') ? reqAt : reqAt.replace(' ', 'T') + 'Z') : NaN;
326
+ const nowMs = Date.parse(args.nowIso);
327
+ const cooling = Math.max(0, args.coolingDays) * 86_400_000;
328
+ if (!Number.isFinite(reqMs) || !Number.isFinite(nowMs))
329
+ return { ok: false, reason: 'unparseable refund timestamps' };
330
+ if (nowMs - reqMs < cooling)
331
+ return { ok: false, reason: `cooling window not over (need ${args.coolingDays}d from request)` };
332
+ db.prepare(`UPDATE direct_receive_deposits SET status = 'refunded', released_at = datetime('now'), refund_evidence_ref = ?, updated_at = datetime('now') WHERE id = ? AND status = 'refunding'`)
333
+ .run(args.evidenceRef.trim().slice(0, 200), args.depositId);
334
+ return { ok: true, status: 'refunded' };
335
+ }
336
+ /** 卖家自行撤回自己的 pending 申报(B1)。owner-scoped CAS;非 pending 不动。 */
337
+ export function cancelPendingDeposit(db, args) {
338
+ const row = getRow(db, args.depositId);
339
+ if (!row || row.user_id !== args.userId)
340
+ return { ok: false, reason: 'deposit not found' };
341
+ if (row.status !== 'pending')
342
+ return { ok: false, reason: `cannot cancel from status '${row.status}'` };
343
+ const r = db.prepare("UPDATE direct_receive_deposits SET status = 'expired', reject_note = '卖家自行撤回', updated_at = datetime('now') WHERE id = ? AND user_id = ? AND status = 'pending'")
344
+ .run(args.depositId, args.userId);
345
+ return r.changes === 1 ? { ok: true, status: 'expired' } : { ok: false, reason: 'cancel race: already processed' };
346
+ }
347
+ /** 卖家最新一笔保证金存款(任意状态;B1 状态卡/去重用)。 */
348
+ export function getSellerLatestDeposit(db, sellerId) {
349
+ return db.prepare(`SELECT id, user_id, tier, required_amount, amount, currency, deposit_rail, status, production_receipt_confirmed_at,
350
+ external_ref, reject_note, confirmed_at, locked_at, refund_requested_at, refund_evidence_ref, terms_version, platform_account_id, created_at
351
+ FROM direct_receive_deposits WHERE user_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1`).get(sellerId) ?? null; // rowid=插入序 tiebreak(id 字典序会被混合前缀坑)
352
+ }
248
353
  /**
249
354
  * refund-on-exit 阻止原因(纯判断占位 —— 真实退款状态流 + RFC-018 clearing lock 集成 = 4b-2)。
250
355
  * 返回阻止原因码;null = 在这些条件下【不被阻止】(但本 PR 不执行任何退款)。无副作用,不读库,不出款。
@@ -0,0 +1,37 @@
1
+ import { toUnits } from './money.js';
2
+ const MAX_THRESHOLD = 10_000_000;
3
+ /** 写入校验:null/空=清除;返回规范化数值或 {error}。 */
4
+ export function validateFreeShippingThreshold(raw) {
5
+ if (raw === null || raw === undefined || raw === '')
6
+ return { value: null };
7
+ if (typeof raw !== 'number' && typeof raw !== 'string')
8
+ return { error: '免邮阈值必须是数字' }; // 审计 P3:拒 boolean/数组(Number(true)=1 会误存)
9
+ const t = Number(raw);
10
+ if (!Number.isFinite(t) || t <= 0 || t > MAX_THRESHOLD)
11
+ return { error: `免邮阈值必须是 0~${MAX_THRESHOLD} 的正数` };
12
+ return { value: Math.round(t * 100) / 100 };
13
+ }
14
+ /** 生效阈值:商品 ?? 店铺 ?? null(与接单/运费/可售同层级约定)。 */
15
+ export function effectiveFreeShippingThreshold(db, product, sellerId) {
16
+ const own = product.free_shipping_threshold;
17
+ if (own !== null && own !== undefined && Number.isFinite(Number(own)) && Number(own) > 0)
18
+ return Number(own);
19
+ try {
20
+ const row = db.prepare('SELECT store_free_shipping_threshold FROM users WHERE id = ?').get(sellerId);
21
+ const st = row?.store_free_shipping_threshold;
22
+ return (st !== null && st !== undefined && Number.isFinite(Number(st)) && Number(st) > 0) ? Number(st) : null;
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ /** 判免:券后货款(整数 units)≥ 生效阈值。读失败 fail-open 到不免(促销缺席≠事故,与合规门相反方向)。 */
29
+ export function freeShippingWaives(db, product, sellerId, goodsSubtotalU) {
30
+ try {
31
+ const t = effectiveFreeShippingThreshold(db, product, sellerId);
32
+ return t !== null && goodsSubtotalU >= toUnits(t);
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
package/dist/fx-rates.js CHANGED
@@ -67,5 +67,12 @@ export function convertUsdcToLocal(usdcAmount, currency, rates) {
67
67
  return NaN;
68
68
  return usdcAmount * rate;
69
69
  }
70
+ /** SYNC: last-known snapshot(过 TTL 标 stale)或 fallback 表(stale)。给建单时刻的应付参考换算快照用 ——
71
+ * display-only,零网络零 await,永不阻塞/永不抛;参考价精度要求低,陈旧可接受且必带 stale 标记。 */
72
+ export function getUsdRatesSync(now = Date.now()) {
73
+ if (_cache)
74
+ return now - _cache.fetchedAt < TTL_MS ? _cache.snap : { ...(_cache.snap), stale: true };
75
+ return { base: 'USD', rates: { ...FALLBACK_USD_RATES }, as_of: new Date(now).toISOString(), stale: true };
76
+ }
70
77
  /** test-only: reset the module cache so tests are deterministic. */
71
78
  export function __resetFxCacheForTest() { _cache = null; }