@seasonkoh/webaz 0.1.28 → 0.1.29

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 (111) hide show
  1. package/README.md +3 -2
  2. package/README.zh-CN.md +7 -6
  3. package/dist/currency.js +16 -0
  4. package/dist/deposit-rails.js +52 -0
  5. package/dist/direct-pay-aml-monitor.js +67 -0
  6. package/dist/direct-pay-aml-review.js +40 -0
  7. package/dist/direct-pay-base-bond-entry.js +5 -0
  8. package/dist/direct-pay-bond-rail-clearance.js +94 -0
  9. package/dist/direct-pay-compliance-ingress.js +145 -0
  10. package/dist/direct-pay-controls.js +136 -0
  11. package/dist/direct-pay-create.js +162 -0
  12. package/dist/direct-pay-deferral-quota.js +43 -0
  13. package/dist/direct-pay-disclosures.js +44 -0
  14. package/dist/direct-pay-eligibility.js +46 -0
  15. package/dist/direct-pay-fee-ar.js +241 -0
  16. package/dist/direct-pay-launch-readiness.js +108 -0
  17. package/dist/direct-pay-launch-summary.js +68 -0
  18. package/dist/direct-pay-ledger.js +94 -0
  19. package/dist/direct-receive-account-qr.js +77 -0
  20. package/dist/direct-receive-accounts.js +82 -0
  21. package/dist/direct-receive-deferral.js +142 -0
  22. package/dist/direct-receive-deposits.js +260 -0
  23. package/dist/direct-receive-payment-instruction.js +26 -0
  24. package/dist/fx-rates.js +71 -0
  25. package/dist/layer0-foundation/L0-1-database/schema.js +531 -1
  26. package/dist/layer0-foundation/L0-2-state-machine/genuine-sale.js +15 -5
  27. package/dist/layer0-foundation/L0-2-state-machine/transitions.js +41 -0
  28. package/dist/layer0-foundation/L0-5-manifest/manifest.js +1 -1
  29. package/dist/layer1-agent/L1-1-mcp-server/cli.js +64 -0
  30. package/dist/layer1-agent/L1-1-mcp-server/network-mode.js +11 -0
  31. package/dist/layer1-agent/L1-1-mcp-server/server.js +18 -16
  32. package/dist/layer4-economics/L4-4-skill-market/skill-engine.js +4 -4
  33. package/dist/ledger.js +12 -3
  34. package/dist/mcp.js +23 -4
  35. package/dist/merchant-bond-domain.js +64 -0
  36. package/dist/merchant-bond-exposure.js +78 -0
  37. package/dist/merchant-bond-watcher.js +26 -0
  38. package/dist/payment-rails.js +29 -0
  39. package/dist/product-verification.js +93 -0
  40. package/dist/pwa/acp-feed.js +3 -2
  41. package/dist/pwa/contract-fingerprint.js +3 -0
  42. package/dist/pwa/direct-pay-guards.js +20 -0
  43. package/dist/pwa/direct-pay-order-redaction.js +24 -0
  44. package/dist/pwa/endpoint-actions.js +3 -0
  45. package/dist/pwa/public/app-ai.js +10 -10
  46. package/dist/pwa/public/app-chat-poll.js +29 -0
  47. package/dist/pwa/public/app-create-kinds.js +17 -0
  48. package/dist/pwa/public/app-direct-pay-accounts.js +141 -0
  49. package/dist/pwa/public/app-direct-pay-buyer.js +72 -0
  50. package/dist/pwa/public/app-direct-pay-compliance.js +67 -0
  51. package/dist/pwa/public/app-direct-pay-deferral-admin.js +72 -0
  52. package/dist/pwa/public/app-direct-pay-deferral.js +61 -0
  53. package/dist/pwa/public/app-direct-pay-fee-center.js +33 -0
  54. package/dist/pwa/public/app-direct-pay-fee-ops.js +112 -0
  55. package/dist/pwa/public/app-direct-pay-product-verify.js +103 -0
  56. package/dist/pwa/public/app-direct-pay-readiness.js +38 -0
  57. package/dist/pwa/public/app-direct-pay-store-verify.js +100 -0
  58. package/dist/pwa/public/app-direct-pay.js +227 -0
  59. package/dist/pwa/public/app-discover.js +20 -20
  60. package/dist/pwa/public/app-external-links.js +32 -0
  61. package/dist/pwa/public/app-listings.js +4 -4
  62. package/dist/pwa/public/app-prelaunch-waz.js +39 -0
  63. package/dist/pwa/public/app-price.js +55 -0
  64. package/dist/pwa/public/app-product-media.js +15 -0
  65. package/dist/pwa/public/app-profile.js +6 -6
  66. package/dist/pwa/public/app-seller.js +5 -5
  67. package/dist/pwa/public/app-shop.js +19 -19
  68. package/dist/pwa/public/app.js +142 -146
  69. package/dist/pwa/public/i18n.js +398 -197
  70. package/dist/pwa/public/index.html +17 -0
  71. package/dist/pwa/public/openapi.json +495 -1
  72. package/dist/pwa/routes/admin-analytics.js +4 -2
  73. package/dist/pwa/routes/admin-direct-receive-deposits.js +321 -0
  74. package/dist/pwa/routes/buyer-feeds.js +2 -1
  75. package/dist/pwa/routes/chat.js +6 -1
  76. package/dist/pwa/routes/dashboards.js +2 -1
  77. package/dist/pwa/routes/direct-pay-availability.js +196 -0
  78. package/dist/pwa/routes/direct-pay-disclosure-acks.js +74 -0
  79. package/dist/pwa/routes/direct-pay-timeouts.js +71 -0
  80. package/dist/pwa/routes/direct-receive-accounts.js +155 -0
  81. package/dist/pwa/routes/direct-receive-payment-instructions.js +45 -0
  82. package/dist/pwa/routes/fx.js +12 -0
  83. package/dist/pwa/routes/leaderboard.js +47 -9
  84. package/dist/pwa/routes/listings.js +2 -2
  85. package/dist/pwa/routes/logistics.js +4 -0
  86. package/dist/pwa/routes/manifests.js +38 -0
  87. package/dist/pwa/routes/me-data.js +5 -2
  88. package/dist/pwa/routes/orders-action.js +117 -9
  89. package/dist/pwa/routes/orders-create.js +4 -0
  90. package/dist/pwa/routes/orders-read.js +36 -0
  91. package/dist/pwa/routes/p2p-products.js +2 -2
  92. package/dist/pwa/routes/products-create.js +5 -3
  93. package/dist/pwa/routes/products-links.js +34 -0
  94. package/dist/pwa/routes/products-list.js +2 -1
  95. package/dist/pwa/routes/products-update.js +22 -2
  96. package/dist/pwa/routes/promoter.js +3 -0
  97. package/dist/pwa/routes/referral.js +4 -0
  98. package/dist/pwa/routes/returns.js +26 -1
  99. package/dist/pwa/routes/rewards-apply.js +10 -5
  100. package/dist/pwa/routes/rewards-clearing-mature.js +96 -0
  101. package/dist/pwa/routes/rewards-escrow-expire.js +6 -2
  102. package/dist/pwa/routes/shops.js +2 -1
  103. package/dist/pwa/routes/url-claim.js +2 -2
  104. package/dist/pwa/routes/users-public.js +8 -0
  105. package/dist/pwa/routes/wallet-read.js +3 -0
  106. package/dist/pwa/routes/webauthn.js +1 -1
  107. package/dist/pwa/server.js +59 -102
  108. package/dist/runtime/webaz-schema-helpers.js +104 -0
  109. package/dist/store-verification.js +77 -0
  110. package/dist/version.js +1 -1
  111. package/package.json +71 -2
@@ -0,0 +1,260 @@
1
+ import { toUnits, toDecimal } from './money.js';
2
+ import { getDepositRail, assertProductionDepositRail } from './deposit-rails.js';
3
+ import { assertBondRailCleared } from './direct-pay-bond-rail-clearance.js';
4
+ import { recordBaseBondSlash } from './direct-pay-ledger.js';
5
+ /** LOCKED 保守默认(治理可调)。T0 = 固定 token 数(标签 ≈ S$500,非 FX)。 */
6
+ export const DEFAULT_BASE_BOND_CONFIG = {
7
+ tierRequiredUnits: { T0: toUnits(500) },
8
+ pendingTtlDays: 7,
9
+ };
10
+ 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
+ /** 某档要求的【固定 token 数】(整数 base-units)。未配置的档 → 抛(T1/T2 在 PR-5 才支持)。 */
13
+ export function requiredBondUnits(tier, config = DEFAULT_BASE_BOND_CONFIG) {
14
+ const u = config.tierRequiredUnits[tier];
15
+ if (!isNonNegUnits(u) || u <= 0)
16
+ throw new Error(`requiredBondUnits: tier '${tier}' 未配置或非正整数 units(4b-min 仅支持已配置档,通常 T0)`);
17
+ return u;
18
+ }
19
+ /** 开新存款:创建 pending 行。required = 该档固定 token 数。amount=0(待 confirm)。 */
20
+ export function openDeposit(db, args) {
21
+ const { depositId, userId, tier, currency, depositRail, config } = args;
22
+ if (!depositId || !userId)
23
+ return { ok: false, reason: 'missing depositId/userId' };
24
+ // WAZ NOT enabled:base bond 只接受【外部资产币种】usdc | fiat(生产收款仍 GATED);'waz' 一律 fail-closed,
25
+ // 杜绝把 schema 默认 currency='waz' 固化成合法路径(防 4c/4f 误接成"WAZ 担保物可用")。
26
+ if (!['usdc', 'fiat'].includes(currency))
27
+ return { ok: false, reason: `currency '${currency}' not allowed for base bond (only usdc|fiat; WAZ not enabled)` };
28
+ if (!['manual', 'operator_attested', 'usdc_onchain', 'fiat_psp'].includes(depositRail))
29
+ return { ok: false, reason: `invalid deposit_rail '${depositRail}'` };
30
+ if (getRow(db, depositId))
31
+ return { ok: false, reason: 'deposit already exists' };
32
+ let required;
33
+ try {
34
+ required = requiredBondUnits(tier, config);
35
+ }
36
+ catch (e) {
37
+ return { ok: false, reason: e.message };
38
+ }
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);
42
+ return { ok: true, status: 'pending' };
43
+ }
44
+ /**
45
+ * 确认到账(经 deposit-rail 网关)。
46
+ * - manual rail:仅 test/non-production —— 置 amount + status=confirmed,**绝不**设 production_receipt_confirmed_at。
47
+ * - usdc_onchain / fiat_psp:getDepositRail().confirmReceipt() 直接【抛】(GATED,fail-closed),本函数不吞。
48
+ * production_receipt_confirmed_at 只由【未来的 legal-cleared 生产 rail】设置(当前不存在)→ 本 PR 后恒 NULL。
49
+ */
50
+ export function confirmDepositReceipt(db, args) {
51
+ const { depositId, expectedAmountUnits, externalRef } = args;
52
+ const row = getRow(db, depositId);
53
+ if (!row)
54
+ return { ok: false, reason: 'deposit not found' };
55
+ // 生产 rail(operator_attested / usdc_onchain / fiat_psp)只能走 confirmProductionReceipt(ROOT + Passkey + Lock B)。
56
+ // confirmDepositReceipt 是【非生产 / manual 专用】路径 —— 任何生产 rail 在此即抛(fail-closed),杜绝被
57
+ // confirmDepositReceipt + lockBond 锁成 active privilege、绕过生产确认门(operator_attested 的 record-only
58
+ // confirmReceipt 不会自己抛,故必须在此显式拦)。
59
+ if (getDepositRail(row.deposit_rail).isProduction) {
60
+ throw new Error(`deposit-rail '${row.deposit_rail}' is a production rail — must use confirmProductionReceipt (ROOT + Passkey); confirmDepositReceipt is manual/non-production only`);
61
+ }
62
+ if (row.status === 'confirmed' || row.status === 'locked')
63
+ return { ok: true, status: row.status, already: true }; // 幂等
64
+ if (row.status !== 'pending' && row.status !== 'insufficient')
65
+ return { ok: false, reason: `cannot confirm from status '${row.status}'` };
66
+ if (!isNonNegUnits(expectedAmountUnits) || expectedAmountUnits <= 0)
67
+ return { ok: false, reason: 'expectedAmount must be a positive integer base-units' };
68
+ // 网关:manual → confirmed(test);usdc/fiat → 抛(GATED)。本函数不 try/catch 网关,让 GATED 抛穿透(fail-closed)。
69
+ const conf = getDepositRail(row.deposit_rail)
70
+ .confirmReceipt({ depositId, expectedAmount: expectedAmountUnits, currency: row.currency, externalRef });
71
+ if (!conf.confirmed)
72
+ return { ok: false, reason: conf.reason || 'receipt not confirmed' };
73
+ // manual = 非生产 → 绝不写 production_receipt_confirmed_at(保持 NULL)。
74
+ db.prepare(`UPDATE direct_receive_deposits SET amount = ?, status = 'confirmed', confirmed_at = datetime('now'),
75
+ external_ref = COALESCE(?, external_ref), updated_at = datetime('now') WHERE id = ?`)
76
+ .run(toDecimal(expectedAmountUnits), conf.externalRef ?? externalRef ?? null, depositId);
77
+ return { ok: true, status: 'confirmed' };
78
+ }
79
+ /** 锁定:confirmed 且 amount ≥ required → locked + 激活 privilege(单事务)。WAZ NOT enabled → 不动任何余额。 */
80
+ export function lockBond(db, args) {
81
+ const row = getRow(db, args.depositId);
82
+ if (!row)
83
+ return { ok: false, reason: 'deposit not found' };
84
+ if (row.status === 'locked')
85
+ return { ok: true, status: 'locked', already: true }; // 幂等
86
+ if (row.status !== 'confirmed')
87
+ return { ok: false, reason: `cannot lock from status '${row.status}' (must be confirmed)` };
88
+ if (toUnits(row.amount) < toUnits(row.required_amount))
89
+ return { ok: false, reason: 'insufficient: amount < required' };
90
+ db.transaction(() => {
91
+ db.prepare("UPDATE direct_receive_deposits SET status = 'locked', locked_at = datetime('now'), updated_at = datetime('now') WHERE id = ?").run(args.depositId);
92
+ // ⚠️ privilege=active 在 4b-min 是【非生产】(manual/test;本 PR 的 lock 不带 production_receipt_confirmed_at)。
93
+ // active 本身【不是】production go-live 的充分条件 —— 生产门是 isProductionBaseBondLocked()(production receipt 非 NULL),
94
+ // 由 4c 强制;active 仅表示"已走完非生产 lock 流程"。
95
+ db.prepare(`INSERT INTO direct_receive_privileges (user_id, status, tier, granted_at, updated_at)
96
+ VALUES (?, 'active', ?, datetime('now'), datetime('now'))
97
+ ON CONFLICT(user_id) DO UPDATE SET status='active', tier=excluded.tier, suspended_reason=NULL, granted_at=COALESCE(direct_receive_privileges.granted_at, excluded.granted_at), updated_at=datetime('now')`)
98
+ .run(row.user_id, row.tier);
99
+ })();
100
+ return { ok: true, status: 'locked' };
101
+ }
102
+ /** 标记不足:confirmed 但 amount < required → insufficient(待 top-up)。 */
103
+ export function markInsufficient(db, args) {
104
+ const row = getRow(db, args.depositId);
105
+ if (!row)
106
+ return { ok: false, reason: 'deposit not found' };
107
+ if (row.status === 'insufficient')
108
+ return { ok: true, status: 'insufficient', already: true };
109
+ if (row.status !== 'confirmed')
110
+ return { ok: false, reason: `cannot mark insufficient from status '${row.status}'` };
111
+ if (toUnits(row.amount) >= toUnits(row.required_amount))
112
+ return { ok: false, reason: 'amount already meets required' };
113
+ db.prepare("UPDATE direct_receive_deposits SET status = 'insufficient', updated_at = datetime('now') WHERE id = ?").run(args.depositId);
114
+ return { ok: true, status: 'insufficient' };
115
+ }
116
+ /** 补足:amount += add(整数 units)。若达/超 required 且当前 insufficient/confirmed → 置 confirmed(可再 lock)。 */
117
+ export function topUp(db, args) {
118
+ const row = getRow(db, args.depositId);
119
+ if (!row)
120
+ return { ok: false, reason: 'deposit not found' };
121
+ if (!isNonNegUnits(args.addUnits) || args.addUnits <= 0)
122
+ return { ok: false, reason: 'addUnits must be a positive integer base-units' };
123
+ if (!['confirmed', 'insufficient'].includes(row.status))
124
+ return { ok: false, reason: `cannot top-up from status '${row.status}'` };
125
+ const newAmount = toUnits(row.amount) + args.addUnits;
126
+ db.prepare("UPDATE direct_receive_deposits SET amount = ?, status = 'confirmed', updated_at = datetime('now') WHERE id = ?")
127
+ .run(toDecimal(newAmount), args.depositId);
128
+ return { ok: true, status: 'confirmed' };
129
+ }
130
+ /** 过期:pending 且超过 TTL → expired。 */
131
+ export function expireDeposit(db, args) {
132
+ const row = getRow(db, args.depositId);
133
+ if (!row)
134
+ return { ok: false, reason: 'deposit not found' };
135
+ if (row.status !== 'pending')
136
+ return { ok: false, reason: `only pending can expire (got '${row.status}')` };
137
+ const ttlDays = args.config?.pendingTtlDays ?? DEFAULT_BASE_BOND_CONFIG.pendingTtlDays;
138
+ const created = Date.parse(row.created_at), now = Date.parse(args.nowIso);
139
+ if (!Number.isFinite(created) || !Number.isFinite(now))
140
+ return { ok: false, reason: 'unparseable timestamps' };
141
+ if (now - created < ttlDays * 86_400_000)
142
+ return { ok: false, reason: 'not yet past TTL' };
143
+ db.prepare("UPDATE direct_receive_deposits SET status = 'expired', updated_at = datetime('now') WHERE id = ?").run(args.depositId);
144
+ return { ok: true, status: 'expired' };
145
+ }
146
+ /**
147
+ * 罚没(卖家违约):locked → slashed,仅记 provenance(recordBaseBondSlash:total_base_bond_slash + txn),
148
+ * **不动任何 balance / 无 outflow**(WAZ NOT enabled),并吊销 privilege(suspended)。单事务,幂等。
149
+ */
150
+ export function slashBond(db, args) {
151
+ const row = getRow(db, args.depositId);
152
+ if (!row)
153
+ return { ok: false, reason: 'deposit not found' };
154
+ if (row.status === 'slashed')
155
+ 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}')` };
158
+ db.transaction(() => {
159
+ recordBaseBondSlash(db, { userId: row.user_id, amountUnits: toUnits(row.amount), txnId: args.txnId, reason: args.reason }); // provenance only
160
+ db.prepare("UPDATE direct_receive_deposits SET status = 'slashed', updated_at = datetime('now') WHERE id = ?").run(args.depositId);
161
+ db.prepare(`INSERT INTO direct_receive_privileges (user_id, status, tier, suspended_reason, updated_at)
162
+ VALUES (?, 'suspended', ?, ?, datetime('now'))
163
+ ON CONFLICT(user_id) DO UPDATE SET status='suspended', suspended_reason=excluded.suspended_reason, updated_at=datetime('now')`)
164
+ .run(row.user_id, row.tier, args.reason ?? 'base_bond_slashed');
165
+ })();
166
+ return { ok: true, status: 'slashed' };
167
+ }
168
+ // ───── 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 = [];
173
+ /**
174
+ * 生产保证金 receipt【唯一】writer(PR-4b-3 scaffold)。这是【整个协议中】唯一允许写 production_receipt_confirmed_at
175
+ * 的函数(#100 guard 机器强制:写赋值必在本函数内 且 本函数必调 assertProductionDepositRail)。
176
+ *
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-launchable。assert 之后的写路径在 legal-cleared rail
180
+ * 落地前【不可达】(本 PR 不接真实 USDC/fiat/PSP/on-chain)。不碰 buyer wallet/escrow/order/settlement/refund。
181
+ *
182
+ * 硬约束(即便未来 rail 放行也必须守):
183
+ * - 拒绝把【非生产 locked】(manual/test lock,无 production receipt)升级成生产 —— 旧/测试行不得冒充生产到位。
184
+ * - jurisdiction 必须 ∈ DIRECT_PAY_BOND_JURISDICTIONS(严格白名单);policy_version 由服务端常量盖章,非入参。
185
+ * - assert 在任何写之前;原子 confirm+lock(amount + status=locked + production receipt + provenance 快照 + privilege)。
186
+ */
187
+ export function confirmProductionReceipt(db, args) {
188
+ const { depositId, railId, expectedAmountUnits, receiptRef, jurisdiction } = args;
189
+ // ⚠️ 生产/法务硬闸 —— assert【真正第一】:在任何 row 读取 / 幂等 / 拒绝 / 写之前。当前所有 rail 都被拒 → 抛 → 恒 fail-closed。
190
+ // 置于最前的语义意义:即便某 deposit 之前在【已 cleared 的 rail】下确认过,若该 rail 后被【撤回】(legalCleared→false),
191
+ // 重新确认也不再被当"幂等已确认"放过 —— 必须重新通过闸。#100 guard 要求本 helper body 必含此调用。
192
+ assertProductionDepositRail(getDepositRail(railId));
193
+ // Lock B(Phase 4 scaffold):rail-clearance registry 放行闸 —— legal_cleared + production_ready + 非占位 policy_version +
194
+ // jurisdiction ∈ allowlist。与 Lock A【独立】,缺一即拒;当前 registry 全 fail-closed → 恒抛。置于任何 row 读/写之前。
195
+ assertBondRailCleared(railId, jurisdiction);
196
+ // ───── 以下在 legal-cleared 生产 rail 落地前【全部不可达】(两把闸已抛)─────
197
+ const row = getRow(db, depositId);
198
+ if (!row)
199
+ return { ok: false, reason: 'deposit not found' };
200
+ if (railId !== row.deposit_rail)
201
+ return { ok: false, reason: 'rail_id does not match the deposit rail' };
202
+ // 幂等:已生产确认 → already(此时 rail 仍 cleared,assert 已过)
203
+ if (row.status === 'locked' && row.production_receipt_confirmed_at != null)
204
+ return { ok: true, status: 'locked', already: true };
205
+ // 拒绝把【非生产 locked】(manual/test lock,无 production receipt)升级成生产
206
+ if (row.status === 'locked')
207
+ return { ok: false, reason: 'deposit is locked WITHOUT a production receipt (manual/test) — cannot be upgraded to production' };
208
+ if (!receiptRef)
209
+ return { ok: false, reason: 'missing production receipt ref' };
210
+ if (!DIRECT_PAY_BOND_JURISDICTIONS.includes(jurisdiction))
211
+ return { ok: false, reason: `jurisdiction '${jurisdiction}' not in allowlist` };
212
+ if (!['pending', 'confirmed', 'insufficient'].includes(row.status))
213
+ return { ok: false, reason: `cannot production-confirm from status '${row.status}'` };
214
+ if (!isNonNegUnits(expectedAmountUnits) || expectedAmountUnits <= 0)
215
+ return { ok: false, reason: 'expectedAmount must be a positive integer base-units' };
216
+ if (expectedAmountUnits < toUnits(row.required_amount))
217
+ return { ok: false, reason: 'insufficient: amount < required' };
218
+ db.transaction(() => {
219
+ db.prepare(`UPDATE direct_receive_deposits SET amount = ?, status = 'locked', confirmed_at = COALESCE(confirmed_at, datetime('now')),
220
+ locked_at = datetime('now'), production_receipt_confirmed_at = datetime('now'),
221
+ production_receipt_ref = ?, production_rail_id = ?, production_jurisdiction = ?, production_policy_version = ?,
222
+ external_ref = COALESCE(?, external_ref), updated_at = datetime('now') WHERE id = ?`)
223
+ .run(toDecimal(expectedAmountUnits), receiptRef, railId, jurisdiction, DIRECT_PAY_BASE_BOND_POLICY_VERSION, receiptRef, depositId);
224
+ db.prepare(`INSERT INTO direct_receive_privileges (user_id, status, tier, granted_at, updated_at)
225
+ VALUES (?, 'active', ?, datetime('now'), datetime('now'))
226
+ ON CONFLICT(user_id) DO UPDATE SET status='active', tier=excluded.tier, suspended_reason=NULL, granted_at=COALESCE(direct_receive_privileges.granted_at, excluded.granted_at), updated_at=datetime('now')`)
227
+ .run(row.user_id, row.tier);
228
+ })();
229
+ return { ok: true, status: 'locked' };
230
+ }
231
+ /**
232
+ * 生产门读取:某存款是否【生产级】锁定 = status='locked' 且 production_receipt_confirmed_at 非 NULL。
233
+ * 4b-min 永远 false(manual rail 不设该列;无生产 rail)。供后续 4c 生产 go-live 事实装配【单一真相】,杜绝 manual 冒充。
234
+ */
235
+ export function isProductionBaseBondLocked(db, args) {
236
+ const row = getRow(db, args.depositId);
237
+ return !!row && row.status === 'locked' && row.production_receipt_confirmed_at != null;
238
+ }
239
+ /**
240
+ * 卖家级生产门(PR-4c go-live 充分必要的担保侧条件):该卖家是否有任一【生产级】锁定 base bond
241
+ * (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。
243
+ * 一个生产级 receipt 只会发给已 KYC + 制裁筛查的商户,故该门也承接了 KYC/sanctions(独立 4a 事实门待其系统就绪再接)。
244
+ */
245
+ export function sellerHasProductionBaseBondLocked(db, sellerId) {
246
+ 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
+ }
248
+ /**
249
+ * refund-on-exit 阻止原因(纯判断占位 —— 真实退款状态流 + RFC-018 clearing lock 集成 = 4b-2)。
250
+ * 返回阻止原因码;null = 在这些条件下【不被阻止】(但本 PR 不执行任何退款)。无副作用,不读库,不出款。
251
+ */
252
+ export function refundOnExitBlockedReason(facts) {
253
+ if (facts.status !== 'locked')
254
+ return 'NOT_LOCKED';
255
+ if (facts.hasOpenDispute === true)
256
+ return 'OPEN_DISPUTE';
257
+ if (facts.withinCoolingWindow === true)
258
+ return 'COOLING_WINDOW';
259
+ return null;
260
+ }
@@ -0,0 +1,26 @@
1
+ /** instruction / label 文本上限(纯展示文本,防滥用;非业务语义)。 */
2
+ export const MAX_INSTRUCTION_LEN = 500;
3
+ export const MAX_LABEL_LEN = 40;
4
+ /** 卖家当前 active 收款说明(最新一条)。无则 null → 调用方必须 fail-closed,不创建直付订单。 */
5
+ export function getActivePaymentInstruction(db, sellerId) {
6
+ const r = db.prepare("SELECT id, instruction, label FROM direct_receive_payment_instructions WHERE seller_id = ? AND status = 'active' ORDER BY updated_at DESC, created_at DESC LIMIT 1").get(sellerId);
7
+ return r ?? null;
8
+ }
9
+ /**
10
+ * 设置/替换卖家当前 active 收款说明。原子:同一同步事务内先把该 seller 所有 active 置 inactive,再插入一条新 active。
11
+ * 保证至多一条 active。instruction/label 调用方需先 trim + 校验非空/长度;此处只信任已校验入参并落库。
12
+ * 只写 direct_receive_payment_instructions,绝不碰 wallet/escrow/settlement/order。
13
+ */
14
+ export function setActivePaymentInstruction(db, sellerId, args, generateId) {
15
+ const id = generateId('dri');
16
+ db.transaction(() => {
17
+ db.prepare("UPDATE direct_receive_payment_instructions SET status = 'inactive', updated_at = datetime('now') WHERE seller_id = ? AND status = 'active'").run(sellerId);
18
+ db.prepare("INSERT INTO direct_receive_payment_instructions (id, seller_id, instruction, label, status) VALUES (?, ?, ?, ?, 'active')").run(id, sellerId, args.instruction, args.label);
19
+ })();
20
+ return { id, instruction: args.instruction, label: args.label };
21
+ }
22
+ /** 停用卖家当前所有 active 收款说明。返回是否有行被停用(false = 本来就没有 active)。 */
23
+ export function deactivatePaymentInstruction(db, sellerId) {
24
+ const info = db.prepare("UPDATE direct_receive_payment_instructions SET status = 'inactive', updated_at = datetime('now') WHERE seller_id = ? AND status = 'active'").run(sellerId);
25
+ return info.changes > 0;
26
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * FX rates (DISPLAY-ONLY) — USDC → buyer-local-currency conversion for price display.
3
+ *
4
+ * Scope (Holden decision 2026-07-01): USDC pricing display + real-time local conversion. This is NOT a
5
+ * settlement path — it NEVER changes the USDC amount owed; it only produces an informational "≈ ¥X" hint.
6
+ * USDC is treated 1:1 with USD (peg deviation ±0.1% is negligible for a display hint). No DB, no money move.
7
+ *
8
+ * Real-time: live rates are fetched from a keyless public FX API and cached briefly; on any failure we serve
9
+ * the last-known snapshot (marked stale) or a hardcoded fallback table, so the price display NEVER breaks.
10
+ */
11
+ // users.region (VALID_REGIONS: china/us/eu/india/singapore/global_north/global) → buyer local display currency.
12
+ // Anything unknown / broad-region / null → USD.
13
+ export const REGION_CURRENCY = {
14
+ china: 'CNY', us: 'USD', eu: 'EUR', india: 'INR', singapore: 'SGD',
15
+ global_north: 'USD', global: 'USD',
16
+ };
17
+ export const SUPPORTED_CURRENCIES = ['USD', 'CNY', 'EUR', 'INR', 'SGD', 'IDR', 'MYR', 'PHP', 'VND', 'THB'];
18
+ const isSupported = (c) => SUPPORTED_CURRENCIES.includes(c);
19
+ export function regionToCurrency(region) {
20
+ const c = REGION_CURRENCY[String(region || '').toLowerCase()];
21
+ return c && isSupported(c) ? c : 'USD';
22
+ }
23
+ // Fallback (per 1 USD) — used ONLY when the live fetch fails AND no prior snapshot exists. Always served
24
+ // with stale:true so the UI can show it as approximate. Kept coarse on purpose (a safety net, not a source).
25
+ export const FALLBACK_USD_RATES = { USD: 1, CNY: 7.2, EUR: 0.92, INR: 83, SGD: 1.35, IDR: 16000, MYR: 4.5, PHP: 58, VND: 25000, THB: 34 };
26
+ const TTL_MS = 15 * 60 * 1000; // 15-min cache: fiat FX barely moves intraday → "real-time" enough for a display hint, no API hammering
27
+ const FX_URL = process.env.FX_RATES_URL || 'https://open.er-api.com/v6/latest/USD';
28
+ let _cache = null;
29
+ /** PURE: external payload → our supported-currency subset. Throws if any supported rate is missing/invalid. */
30
+ export function parseUsdRates(payload) {
31
+ const p = payload;
32
+ const r = p?.rates || p?.conversion_rates;
33
+ if (!r || typeof r !== 'object')
34
+ throw new Error('fx: no rates in payload');
35
+ const out = {};
36
+ for (const c of SUPPORTED_CURRENCIES) {
37
+ const v = Number(r[c]);
38
+ if (!Number.isFinite(v) || v <= 0)
39
+ throw new Error(`fx: bad/absent rate for ${c}`);
40
+ out[c] = v;
41
+ }
42
+ return out;
43
+ }
44
+ /** Cached live USD rates; on failure serve last-known (stale) or the fallback table. fetchImpl/now injectable for tests. */
45
+ export async function getUsdRates(fetchImpl = fetch, now = Date.now()) {
46
+ if (_cache && now - _cache.fetchedAt < TTL_MS)
47
+ return _cache.snap;
48
+ try {
49
+ const res = await fetchImpl(FX_URL, { signal: AbortSignal.timeout(5000) });
50
+ if (!res.ok)
51
+ throw new Error('fx: http ' + res.status);
52
+ const rates = parseUsdRates(await res.json());
53
+ const snap = { base: 'USD', rates, as_of: new Date(now).toISOString(), stale: false };
54
+ _cache = { snap, fetchedAt: now };
55
+ return snap;
56
+ }
57
+ catch {
58
+ if (_cache)
59
+ return { ...(_cache.snap), stale: true }; // last-known, flagged stale
60
+ return { base: 'USD', rates: { ...FALLBACK_USD_RATES }, as_of: new Date(now).toISOString(), stale: true };
61
+ }
62
+ }
63
+ /** PURE: USDC decimal amount → local-currency decimal (USDC≈USD). Display-only; returns NaN on bad input. */
64
+ export function convertUsdcToLocal(usdcAmount, currency, rates) {
65
+ const rate = rates?.[currency];
66
+ if (!Number.isFinite(usdcAmount) || !Number.isFinite(rate) || rate <= 0)
67
+ return NaN;
68
+ return usdcAmount * rate;
69
+ }
70
+ /** test-only: reset the module cache so tests are deterministic. */
71
+ export function __resetFxCacheForTest() { _cache = null; }