@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.
- package/README.md +3 -2
- package/README.zh-CN.md +7 -6
- package/dist/currency.js +16 -0
- package/dist/deposit-rails.js +52 -0
- package/dist/direct-pay-aml-monitor.js +67 -0
- package/dist/direct-pay-aml-review.js +40 -0
- package/dist/direct-pay-base-bond-entry.js +5 -0
- package/dist/direct-pay-bond-rail-clearance.js +94 -0
- package/dist/direct-pay-compliance-ingress.js +145 -0
- package/dist/direct-pay-controls.js +136 -0
- package/dist/direct-pay-create.js +162 -0
- package/dist/direct-pay-deferral-quota.js +43 -0
- package/dist/direct-pay-disclosures.js +44 -0
- package/dist/direct-pay-eligibility.js +46 -0
- package/dist/direct-pay-fee-ar.js +241 -0
- package/dist/direct-pay-launch-readiness.js +108 -0
- package/dist/direct-pay-launch-summary.js +68 -0
- package/dist/direct-pay-ledger.js +94 -0
- package/dist/direct-receive-account-qr.js +77 -0
- package/dist/direct-receive-accounts.js +82 -0
- package/dist/direct-receive-deferral.js +142 -0
- package/dist/direct-receive-deposits.js +260 -0
- package/dist/direct-receive-payment-instruction.js +26 -0
- package/dist/fx-rates.js +71 -0
- package/dist/layer0-foundation/L0-1-database/schema.js +531 -1
- package/dist/layer0-foundation/L0-2-state-machine/genuine-sale.js +15 -5
- package/dist/layer0-foundation/L0-2-state-machine/transitions.js +41 -0
- package/dist/layer0-foundation/L0-5-manifest/manifest.js +1 -1
- package/dist/layer1-agent/L1-1-mcp-server/cli.js +64 -0
- package/dist/layer1-agent/L1-1-mcp-server/network-mode.js +11 -0
- package/dist/layer1-agent/L1-1-mcp-server/server.js +18 -16
- package/dist/layer4-economics/L4-4-skill-market/skill-engine.js +4 -4
- package/dist/ledger.js +12 -3
- package/dist/mcp.js +23 -4
- package/dist/merchant-bond-domain.js +64 -0
- package/dist/merchant-bond-exposure.js +78 -0
- package/dist/merchant-bond-watcher.js +26 -0
- package/dist/payment-rails.js +29 -0
- package/dist/product-verification.js +93 -0
- package/dist/pwa/acp-feed.js +3 -2
- package/dist/pwa/contract-fingerprint.js +3 -0
- package/dist/pwa/direct-pay-guards.js +20 -0
- package/dist/pwa/direct-pay-order-redaction.js +24 -0
- package/dist/pwa/endpoint-actions.js +3 -0
- package/dist/pwa/public/app-ai.js +10 -10
- package/dist/pwa/public/app-chat-poll.js +29 -0
- package/dist/pwa/public/app-create-kinds.js +17 -0
- package/dist/pwa/public/app-direct-pay-accounts.js +141 -0
- package/dist/pwa/public/app-direct-pay-buyer.js +72 -0
- package/dist/pwa/public/app-direct-pay-compliance.js +67 -0
- package/dist/pwa/public/app-direct-pay-deferral-admin.js +72 -0
- package/dist/pwa/public/app-direct-pay-deferral.js +61 -0
- package/dist/pwa/public/app-direct-pay-fee-center.js +33 -0
- package/dist/pwa/public/app-direct-pay-fee-ops.js +112 -0
- package/dist/pwa/public/app-direct-pay-product-verify.js +103 -0
- package/dist/pwa/public/app-direct-pay-readiness.js +38 -0
- package/dist/pwa/public/app-direct-pay-store-verify.js +100 -0
- package/dist/pwa/public/app-direct-pay.js +227 -0
- package/dist/pwa/public/app-discover.js +20 -20
- package/dist/pwa/public/app-external-links.js +32 -0
- package/dist/pwa/public/app-listings.js +4 -4
- package/dist/pwa/public/app-prelaunch-waz.js +39 -0
- package/dist/pwa/public/app-price.js +55 -0
- package/dist/pwa/public/app-product-media.js +15 -0
- package/dist/pwa/public/app-profile.js +6 -6
- package/dist/pwa/public/app-seller.js +5 -5
- package/dist/pwa/public/app-shop.js +19 -19
- package/dist/pwa/public/app.js +142 -146
- package/dist/pwa/public/i18n.js +398 -197
- package/dist/pwa/public/index.html +17 -0
- package/dist/pwa/public/openapi.json +495 -1
- package/dist/pwa/routes/admin-analytics.js +4 -2
- package/dist/pwa/routes/admin-direct-receive-deposits.js +321 -0
- package/dist/pwa/routes/buyer-feeds.js +2 -1
- package/dist/pwa/routes/chat.js +6 -1
- package/dist/pwa/routes/dashboards.js +2 -1
- package/dist/pwa/routes/direct-pay-availability.js +196 -0
- package/dist/pwa/routes/direct-pay-disclosure-acks.js +74 -0
- package/dist/pwa/routes/direct-pay-timeouts.js +71 -0
- package/dist/pwa/routes/direct-receive-accounts.js +155 -0
- package/dist/pwa/routes/direct-receive-payment-instructions.js +45 -0
- package/dist/pwa/routes/fx.js +12 -0
- package/dist/pwa/routes/leaderboard.js +47 -9
- package/dist/pwa/routes/listings.js +2 -2
- package/dist/pwa/routes/logistics.js +4 -0
- package/dist/pwa/routes/manifests.js +38 -0
- package/dist/pwa/routes/me-data.js +5 -2
- package/dist/pwa/routes/orders-action.js +117 -9
- package/dist/pwa/routes/orders-create.js +4 -0
- package/dist/pwa/routes/orders-read.js +36 -0
- package/dist/pwa/routes/p2p-products.js +2 -2
- package/dist/pwa/routes/products-create.js +5 -3
- package/dist/pwa/routes/products-links.js +34 -0
- package/dist/pwa/routes/products-list.js +2 -1
- package/dist/pwa/routes/products-update.js +22 -2
- package/dist/pwa/routes/promoter.js +3 -0
- package/dist/pwa/routes/referral.js +4 -0
- package/dist/pwa/routes/returns.js +26 -1
- package/dist/pwa/routes/rewards-apply.js +10 -5
- package/dist/pwa/routes/rewards-clearing-mature.js +96 -0
- package/dist/pwa/routes/rewards-escrow-expire.js +6 -2
- package/dist/pwa/routes/shops.js +2 -1
- package/dist/pwa/routes/url-claim.js +2 -2
- package/dist/pwa/routes/users-public.js +8 -0
- package/dist/pwa/routes/wallet-read.js +3 -0
- package/dist/pwa/routes/webauthn.js +1 -1
- package/dist/pwa/server.js +59 -102
- package/dist/runtime/webaz-schema-helpers.js +104 -0
- package/dist/store-verification.js +77 -0
- package/dist/version.js +1 -1
- package/package.json +71 -2
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
import { dbOne, dbAll } from '../../layer0-foundation/L0-1-database/db.js';
|
|
2
|
+
import { releaseFeeStake } from '../../direct-pay-ledger.js'; // Rail1 直付:取消/超时释放任何遗留模拟质押(AR 订单无 stake → no-op)
|
|
3
|
+
import { requireBothDisclosuresAcked } from '../../direct-pay-disclosures.js'; // PR-4e: D1/D2 披露契约门
|
|
4
|
+
import { requireDirectPayHumanPasskey } from '../direct-pay-guards.js'; // PR-4e: 现场真人 Passkey/gate-token 门
|
|
2
5
|
export function registerOrdersActionRoutes(app, deps) {
|
|
3
|
-
const { db, auth, isTrustedRole, generateId, transition, notifyTransition, settleOrder, settleFault, detectFraud, createDispute, checkTimeouts, recordViolationReputation, broadcastSystemEvent } = deps;
|
|
6
|
+
const { db, auth, isTrustedRole, generateId, transition, notifyTransition, settleOrder, settleFault, detectFraud, createDispute, checkTimeouts, recordViolationReputation, broadcastSystemEvent, consumeGateToken } = deps;
|
|
7
|
+
// PR-4e: direct_p2p 风险动作门 —— ① D1/D2 两次披露都 ack(缺则 DISCLOSURE_NOT_ACKED);② 现场真人 Passkey + 一次性
|
|
8
|
+
// WebAuthn gate token(purpose 固定 direct_pay_order_action,order+action 走 purpose_data + validate)。
|
|
9
|
+
// 纯前置门:返回 ok 才允许后续写入。【先 disclosure(只读)再 Passkey(消费 token)】→ 缺 ack 不浪费 token。
|
|
10
|
+
// 调用方必须在【任何写入前 + 只读状态预检之后】调用,避免错误状态消耗 token。
|
|
11
|
+
function directPayActionGate(orderId, action, userId, webauthnToken) {
|
|
12
|
+
const disc = requireBothDisclosuresAcked(db, orderId);
|
|
13
|
+
if (!disc.ok)
|
|
14
|
+
return { ok: false, status: 409, error: disc.reason || '需先完成两次风险披露确认', error_code: disc.error_code || 'DISCLOSURE_NOT_ACKED' };
|
|
15
|
+
const gate = requireDirectPayHumanPasskey({ db, consumeGateToken }, {
|
|
16
|
+
userId, webauthnToken, purpose: 'direct_pay_order_action',
|
|
17
|
+
validate: (data) => { const d = data; return !!d && d.order_id === orderId && d.action === action; },
|
|
18
|
+
});
|
|
19
|
+
if (!gate.ok)
|
|
20
|
+
return { ok: false, status: 403, error: gate.reason || '需现场真人 Passkey 确认', error_code: gate.error_code || 'HUMAN_PRESENCE_REQUIRED' };
|
|
21
|
+
return { ok: true };
|
|
22
|
+
}
|
|
4
23
|
// RFC-007 stage 2:卖家主动拒单 reason_code 白名单。
|
|
5
24
|
// classification(客观无责 vs 主观有责)是 stage 3 auto-verify 的事;stage 2 仅捕获 + 一律走违约结算。
|
|
6
25
|
// objective-claimed(stage 3 将尝试确定性核验):stock_consumed_concurrent / stale_price_snapshot / force_majeure
|
|
@@ -93,23 +112,38 @@ export function registerOrdersActionRoutes(app, deps) {
|
|
|
93
112
|
return void res.status(400).json({ error: `订单状态 ${order.status} 不可确认面交` });
|
|
94
113
|
if (order.has_pending_claim)
|
|
95
114
|
return void res.status(400).json({ error: '存在进行中的验证任务,不可确认' });
|
|
115
|
+
// Rail1:平台费已切换为链下应收(accrue 在完成结算时,与 completed 同一原子边界,fail-closed)。
|
|
116
|
+
// 建单不再锁 fee-stake,故【不再】前置要求 locked stake(AR 订单本就无 stake)。
|
|
117
|
+
const isDirectP2p = order.payment_rail === 'direct_p2p';
|
|
118
|
+
// PR-4e:direct_p2p 面交完成 = RISK 动作 → 两次披露门 + 现场真人 Passkey 门。
|
|
119
|
+
if (isDirectP2p) {
|
|
120
|
+
const g = directPayActionGate(req.params.id, 'confirm_in_person', user.id, req.body?.webauthn_token);
|
|
121
|
+
if (!g.ok)
|
|
122
|
+
return void res.status(g.status).json({ error: g.error, error_code: g.error_code });
|
|
123
|
+
}
|
|
96
124
|
const tx = db.transaction(() => {
|
|
97
125
|
db.prepare(`UPDATE orders SET status='completed', updated_at=datetime('now') WHERE id = ?`).run(req.params.id);
|
|
98
126
|
db.prepare(`INSERT INTO order_state_history (id, order_id, from_status, to_status, actor_id, actor_role, notes)
|
|
99
127
|
VALUES (?,?,?,?,?,?, '面交完成 — 买家确认')`)
|
|
100
128
|
.run(generateId('hst'), req.params.id, order.status, 'completed', user.id, user.role || 'buyer');
|
|
129
|
+
// direct_p2p:取平台费进同一事务 → 取费失败(缺 locked stake)回滚 completed,订单不会 terminal-completed 而费用落空。
|
|
130
|
+
if (isDirectP2p)
|
|
131
|
+
settleOrder(req.params.id);
|
|
101
132
|
});
|
|
102
133
|
try {
|
|
103
134
|
tx();
|
|
104
135
|
}
|
|
105
136
|
catch (e) {
|
|
106
|
-
return void res.status(500).json({ error: '状态写入失败:' + e.message });
|
|
107
|
-
}
|
|
108
|
-
try {
|
|
109
|
-
settleOrder(req.params.id);
|
|
137
|
+
return void res.status(isDirectP2p ? 409 : 500).json({ error: (isDirectP2p ? '直付完成结算失败,订单未完成:' : '状态写入失败:') + e.message, ...(isDirectP2p ? { error_code: 'DIRECT_PAY_SETTLE_FAILED' } : {}) });
|
|
110
138
|
}
|
|
111
|
-
|
|
112
|
-
|
|
139
|
+
// escrow:沿用原有非原子结算(失败仅记日志,不回滚 completed —— 与既有行为一致,本次不改 escrow)。
|
|
140
|
+
if (!isDirectP2p) {
|
|
141
|
+
try {
|
|
142
|
+
settleOrder(req.params.id);
|
|
143
|
+
}
|
|
144
|
+
catch (e) {
|
|
145
|
+
console.error('[settleOrder in-person]', e);
|
|
146
|
+
}
|
|
113
147
|
}
|
|
114
148
|
res.json({ success: true });
|
|
115
149
|
});
|
|
@@ -137,6 +171,32 @@ export function registerOrdersActionRoutes(app, deps) {
|
|
|
137
171
|
if (action === 'confirm' && uid !== buyerId) {
|
|
138
172
|
return void res.status(403).json({ error: '你不是本订单的买家', error_code: 'NOT_ORDER_BUYER' });
|
|
139
173
|
}
|
|
174
|
+
// Rail1 直付:买家专属动作。mark_paid = 买家声明"我已付款"→ accepted(进卖家发货流程);
|
|
175
|
+
// cancel = 付款前买家取消 → cancelled(释放费用质押)。仅 buyer、仅 direct_p2p 且仍在付款窗口。
|
|
176
|
+
if (action === 'mark_paid' || action === 'cancel') {
|
|
177
|
+
if (uid !== buyerId) {
|
|
178
|
+
return void res.status(403).json({ error: '你不是本订单的买家', error_code: 'NOT_ORDER_BUYER' });
|
|
179
|
+
}
|
|
180
|
+
if (order.payment_rail !== 'direct_p2p' || order.status !== 'direct_pay_window') {
|
|
181
|
+
return void res.status(409).json({ error: '该操作仅适用于直付订单的付款窗口', error_code: 'NOT_DIRECT_PAY_WINDOW' });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// PR-4e:mark_paid = direct_p2p RISK 动作(已过 direct_pay_window 只读预检)→ 两次披露门 + 现场真人 Passkey 门。cancel 不门控。
|
|
185
|
+
if (action === 'mark_paid') {
|
|
186
|
+
const g = directPayActionGate(req.params.id, 'mark_paid', uid, req.body?.webauthn_token);
|
|
187
|
+
if (!g.ok)
|
|
188
|
+
return void res.status(g.status).json({ error: g.error, error_code: g.error_code });
|
|
189
|
+
}
|
|
190
|
+
// Rail1:平台费链下应收,accrue 在完成结算时(settleOrder direct_p2p 分支)与 completed 同一原子边界、fail-closed
|
|
191
|
+
// (accrueFeeReceivable 缺费即抛 → 回滚 completed)。故【不再】前置要求 locked fee-stake。
|
|
192
|
+
// PR-4e:direct_p2p confirm = RISK 动作。先【只读状态预检】(仅 delivered 可确认收货,杜绝错误状态消耗 token),再两次披露门 + 现场真人 Passkey 门。
|
|
193
|
+
if (action === 'confirm' && order.payment_rail === 'direct_p2p') {
|
|
194
|
+
if (order.status !== 'delivered')
|
|
195
|
+
return void res.status(409).json({ error: `订单状态 ${order.status} 不可确认收货(仅 delivered)`, error_code: 'ORDER_NOT_DELIVERED' });
|
|
196
|
+
const g = directPayActionGate(req.params.id, 'confirm', uid, req.body?.webauthn_token);
|
|
197
|
+
if (!g.ok)
|
|
198
|
+
return void res.status(g.status).json({ error: g.error, error_code: g.error_code });
|
|
199
|
+
}
|
|
140
200
|
if (action === 'pickup' || action === 'transit' || action === 'deliver') {
|
|
141
201
|
// pickup 时若订单尚无物流,允许领取(孤儿单兜底)
|
|
142
202
|
const isOrphanPickup = action === 'pickup' && !logisticsId;
|
|
@@ -254,9 +314,28 @@ export function registerOrdersActionRoutes(app, deps) {
|
|
|
254
314
|
note: '已就客观无责拒单发起人工仲裁举证。自动终结已暂停,等待仲裁员裁决:维持→免责全退+退质押,驳回→违约结算。',
|
|
255
315
|
});
|
|
256
316
|
}
|
|
317
|
+
// Rail1 直付:付款窗口内买家取消 → cancelled + 释放费用质押(单事务:转移成功必同步放质押,
|
|
318
|
+
// 杜绝"已取消但质押漏放"泄漏 —— cancelled 单超时 cron 不再扫,放质押无后备路径,故必须原子)。
|
|
319
|
+
if (action === 'cancel') {
|
|
320
|
+
const fromStatusCancel = order.status;
|
|
321
|
+
try {
|
|
322
|
+
db.transaction(() => {
|
|
323
|
+
const r = transition(db, req.params.id, 'cancelled', uid, [], notes);
|
|
324
|
+
if (!r.success)
|
|
325
|
+
throw new Error(r.error || '状态转移失败');
|
|
326
|
+
releaseFeeStake(db, { orderId: req.params.id });
|
|
327
|
+
})();
|
|
328
|
+
}
|
|
329
|
+
catch (e) {
|
|
330
|
+
return void res.status(409).json({ error: e.message, error_code: 'CANCEL_FAILED' });
|
|
331
|
+
}
|
|
332
|
+
notifyTransition(db, req.params.id, fromStatusCancel, 'cancelled');
|
|
333
|
+
return void res.json({ success: true, status: 'cancelled', fee_stake_released: true });
|
|
334
|
+
}
|
|
257
335
|
const actionMap = {
|
|
258
336
|
accept: 'accepted', ship: 'shipped', pickup: 'picked_up',
|
|
259
|
-
transit: 'in_transit', deliver: 'delivered', confirm: 'confirmed', dispute: 'disputed'
|
|
337
|
+
transit: 'in_transit', deliver: 'delivered', confirm: 'confirmed', dispute: 'disputed',
|
|
338
|
+
mark_paid: 'accepted', // Rail1 直付:买家声明"我已付款" → accepted(汇入既有卖家发货流程;协议不验真实付款)
|
|
260
339
|
};
|
|
261
340
|
const toStatus = actionMap[action];
|
|
262
341
|
if (!toStatus)
|
|
@@ -270,6 +349,32 @@ export function registerOrdersActionRoutes(app, deps) {
|
|
|
270
349
|
VALUES (?,?,?,'description',?,?,?)`).run(eid, req.params.id, user.id, evidence_description, `hash_${Date.now()}`, evReasons.length ? JSON.stringify(evReasons) : null);
|
|
271
350
|
evidenceIds.push(eid);
|
|
272
351
|
}
|
|
352
|
+
// Rail1 全原子(Codex P1):direct_p2p confirm 必须把 delivered→confirmed→completed→settle/accrue 包进【同一 db.transaction】。
|
|
353
|
+
// 否则 delivered→confirmed 先单独提交、随后 accrue 失败 → 订单卡在 confirmed(retry 被 ORDER_NOT_DELIVERED 拒)。
|
|
354
|
+
// 任一步失败 → 整体回滚到 delivered(可重试);成功后再发通知。confirm-in-person 是另一端点、已单事务原子。
|
|
355
|
+
if (action === 'confirm' && order.payment_rail === 'direct_p2p') {
|
|
356
|
+
try {
|
|
357
|
+
db.transaction(() => {
|
|
358
|
+
const r1 = transition(db, req.params.id, 'confirmed', user.id, evidenceIds, notes);
|
|
359
|
+
if (!r1.success)
|
|
360
|
+
throw new Error(r1.error || 'confirmed transition failed');
|
|
361
|
+
const r2 = transition(db, req.params.id, 'completed', 'sys_protocol', [], '系统自动结算');
|
|
362
|
+
if (!r2.success)
|
|
363
|
+
throw new Error(r2.error || 'completed transition failed');
|
|
364
|
+
settleOrder(req.params.id); // direct_p2p 分支:释放遗留模拟 stake + accrueFeeReceivable(fail-closed)
|
|
365
|
+
})();
|
|
366
|
+
}
|
|
367
|
+
catch (e) {
|
|
368
|
+
return void res.status(409).json({ error: `直付完成结算失败,订单未完成(仍停在 delivered,可重试):${e.message}`, error_code: 'DIRECT_PAY_SETTLE_FAILED' });
|
|
369
|
+
}
|
|
370
|
+
notifyTransition(db, req.params.id, 'delivered', 'confirmed');
|
|
371
|
+
notifyTransition(db, req.params.id, 'confirmed', 'completed');
|
|
372
|
+
try {
|
|
373
|
+
broadcastSystemEvent('order_completed', '✓', `订单完成 ${req.params.id}`, req.params.id);
|
|
374
|
+
}
|
|
375
|
+
catch { }
|
|
376
|
+
return void res.json({ success: true, status: 'completed', settlement: { rail: 'direct_p2p', fee_accrued: true } });
|
|
377
|
+
}
|
|
273
378
|
const fromStatus = order.status;
|
|
274
379
|
const result = transition(db, req.params.id, toStatus, user.id, evidenceIds, notes);
|
|
275
380
|
if (!result.success)
|
|
@@ -292,6 +397,7 @@ export function registerOrdersActionRoutes(app, deps) {
|
|
|
292
397
|
let settlementBreakdown = null;
|
|
293
398
|
if (toStatus === 'confirmed') {
|
|
294
399
|
const sysUser = db.prepare("SELECT id FROM users WHERE id = 'sys_protocol'").get();
|
|
400
|
+
// direct_p2p confirm 已在上方【全原子早返回块】处理(delivered→confirmed→completed→settle 同一 tx);此处仅 escrow。
|
|
295
401
|
transition(db, req.params.id, 'completed', sysUser.id, [], '系统自动结算');
|
|
296
402
|
notifyTransition(db, req.params.id, 'confirmed', 'completed');
|
|
297
403
|
settleOrder(req.params.id);
|
|
@@ -339,7 +445,9 @@ export function registerOrdersActionRoutes(app, deps) {
|
|
|
339
445
|
reserveByKind.escrow_expired = round2(Number(r.s));
|
|
340
446
|
}
|
|
341
447
|
const redirectedToCommissionReserve = round2(reserveByKind.region_cap + reserveByKind.chain_gap + reserveByKind.orphan_sponsor + reserveByKind.opt_out_deactivated + reserveByKind.escrow_expired);
|
|
342
|
-
|
|
448
|
+
// RFC-018: matures_at IS NULL = opt-out escrow only; clearing rows (matures_at NOT NULL) are
|
|
449
|
+
// surfaced separately (PR3), not mislabeled as opt-out escrow here.
|
|
450
|
+
const escrowRow = (await dbOne("SELECT COALESCE(SUM(amount),0) AS s FROM pending_commission_escrow WHERE order_id = ? AND status = 'pending' AND matures_at IS NULL", [req.params.id]));
|
|
343
451
|
const heldInOptOutEscrow = round2(Number(escrowRow.s));
|
|
344
452
|
// QA 轮 9.5 P2:payouts 表只 MCP legacy 写,PWA settleOrder 直更 wallet.balance 不写 payouts
|
|
345
453
|
// 改用公式推算 sellerAmount(跟 PWA settleOrder 内部计算一致),更可靠
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { buildCartMandate, buildPaymentMandate, signMandate } from './ap2-mandate.js';
|
|
2
2
|
// RFC-014 PR3 — 金额走整数 base-units;钱包写绝对值(防 REAL 浮点加法 dust)。
|
|
3
3
|
import { toUnits, toDecimal, mulQty, mulRate } from '../../money.js';
|
|
4
|
+
import { createDirectPayResponse } from '../../direct-pay-create.js'; // PR-4c: direct_p2p 建单分叉(生产门+收款指令门+原子建单;本金不入协议)
|
|
4
5
|
import { applyWalletDelta } from '../../ledger.js';
|
|
5
6
|
import { dbOne } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam(仅下单事务外的预检查;事务内 escrow/INSERT 保持同步)
|
|
6
7
|
// 店铺推荐 → 商品三级归因的【懒升级】(sync,跑在下单事务内、getProductShareChain 之前)。
|
|
@@ -236,6 +237,9 @@ export function registerOrdersCreateRoutes(app, deps) {
|
|
|
236
237
|
const insurancePremium = toDecimal(insurancePremiumU);
|
|
237
238
|
const totalAmount = toDecimal(totalAmountU);
|
|
238
239
|
const donationAmount = toDecimal(donationAmountU);
|
|
240
|
+
// PR-4c:direct_p2p 分叉 —— 本金不入协议,跳过下方 escrow 预检/事务,改走直付建单(生产门+收款指令门+原子建单,仅锁卖家 fee-stake)。
|
|
241
|
+
if (String(req.body?.payment_rail || '') === 'direct_p2p')
|
|
242
|
+
return void createDirectPayResponse(res, db, { generateId, transition, appendOrderEvent, getProtocolParam }, { product, buyerId: user.id, reqQty, basePrice, totalAmount, totalAmountU, shippingAddress: String(shipping_address), directReceiveAccountId: (typeof req.body?.direct_receive_account_id === 'string' && req.body.direct_receive_account_id) ? String(req.body.direct_receive_account_id) : undefined, opts: { variantId: variant_id, hasVariants: Number(product.has_variants) === 1, flashActive: !!flashSale, couponCode: coupon_code, buyInsurance: !!buy_insurance, donationPct: donationPctNum, isGift: !!is_gift, anonymous: anonymousFlag === 1, deliveryWindow: !!delivery_window } });
|
|
239
243
|
// 友好预检查(读):真正的守恒在下面的同步事务内(applyWalletDelta 绝对值落库)。
|
|
240
244
|
const wallet = await dbOne('SELECT balance FROM wallets WHERE user_id = ?', [user.id]);
|
|
241
245
|
if (!wallet)
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// RFC-011 §⑥ 事件游标流(纯 db 函数,party-gated)
|
|
2
2
|
import { listOrderEventsSince } from '../../layer0-foundation/L0-2-state-machine/order-chain.js';
|
|
3
3
|
import { dbOne, dbAll } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
|
|
4
|
+
import { requireBothDisclosuresAcked } from '../../direct-pay-disclosures.js'; // PR-4f-b: direct_p2p 收款说明响应契约门
|
|
5
|
+
import { redactUnackedDirectPayTarget } from '../direct-pay-order-redaction.js'; // 收款目标披露门(共享;所有 orders reader 必过)
|
|
6
|
+
import { getQrImageForOwner } from '../../direct-receive-account-qr.js'; // Rail1 D2:ack 门后按订单快照 qr_ref 取收款码字节((ref,seller_id) 域内)
|
|
4
7
|
export function registerOrdersReadRoutes(app, deps) {
|
|
5
8
|
const { db, auth, getOrderStatus, getOrderChain, verifyOrderChain, getOrderDispute } = deps;
|
|
6
9
|
app.get('/api/orders', async (req, res) => {
|
|
@@ -25,6 +28,7 @@ export function registerOrdersReadRoutes(app, deps) {
|
|
|
25
28
|
delete o.recipient_code;
|
|
26
29
|
o.buyer_name = '🔒 ' + (typeof code === 'string' ? code : 'PR-?????');
|
|
27
30
|
}
|
|
31
|
+
redactUnackedDirectPayTarget(db, o, user.id); // #179 审计 P1:列表也过披露门,不得旁路
|
|
28
32
|
}
|
|
29
33
|
res.json(orders);
|
|
30
34
|
});
|
|
@@ -194,6 +198,38 @@ export function registerOrdersReadRoutes(app, deps) {
|
|
|
194
198
|
delete order.recipient_code;
|
|
195
199
|
}
|
|
196
200
|
}
|
|
201
|
+
// Direct Pay 响应契约门:direct_p2p 收款目标(instruction 快照 + 账号快照 qr_ref),买家在 D1/D2 both-acked 前
|
|
202
|
+
// 【不得】从 API 拿到(非仅 UI 软门)。与 /api/orders 列表共用同一 redact,防旁路(#179 审计 P1)。
|
|
203
|
+
redactUnackedDirectPayTarget(db, order, user.id);
|
|
197
204
|
res.json({ ...statusInfo, history, product, dispute, trackingInfo });
|
|
198
205
|
});
|
|
206
|
+
// Rail1 D2:直付订单收款二维码(硬化转发)。仅【订单买家】+ 两次披露 both-acked 后可取;按建单时快照的 (qr_ref, seller_id)
|
|
207
|
+
// 取【当时那一版】图字节。未 ack / 非买家 / 无 QR / 非 direct_p2p → 统一 404(不枚举,不泄露)。图字节不入 order JSON。
|
|
208
|
+
app.get('/api/orders/:id/direct-pay-qr', async (req, res) => {
|
|
209
|
+
const user = auth(req, res);
|
|
210
|
+
if (!user)
|
|
211
|
+
return;
|
|
212
|
+
const orderId = String(req.params.id);
|
|
213
|
+
const order = await dbOne('SELECT buyer_id, seller_id, payment_rail, direct_pay_account_snapshot FROM orders WHERE id = ?', [orderId]);
|
|
214
|
+
if (!order || order.payment_rail !== 'direct_p2p' || order.buyer_id !== user.id)
|
|
215
|
+
return void res.status(404).end();
|
|
216
|
+
if (!requireBothDisclosuresAcked(db, orderId).ok)
|
|
217
|
+
return void res.status(404).end(); // 未 ack:与"无 QR"同样 404,不泄露存在性
|
|
218
|
+
let snap = {};
|
|
219
|
+
try {
|
|
220
|
+
snap = order.direct_pay_account_snapshot ? JSON.parse(order.direct_pay_account_snapshot) : {};
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
snap = {};
|
|
224
|
+
}
|
|
225
|
+
if (!snap.qr_ref)
|
|
226
|
+
return void res.status(404).end();
|
|
227
|
+
const img = getQrImageForOwner(db, snap.qr_ref, order.seller_id); // (ref, seller_id) 域内取字节;seller_id 取自订单,非用户输入
|
|
228
|
+
if (!img)
|
|
229
|
+
return void res.status(404).end();
|
|
230
|
+
res.setHeader('Content-Type', img.mime);
|
|
231
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
232
|
+
res.setHeader('Cache-Control', 'private, no-store');
|
|
233
|
+
res.send(img.buf);
|
|
234
|
+
});
|
|
199
235
|
}
|
|
@@ -51,8 +51,8 @@ export function registerP2pProductsRoutes(app, deps) {
|
|
|
51
51
|
await dbRun(`
|
|
52
52
|
INSERT INTO products (id, seller_id, title, description, price, stock, status, images, ship_regions,
|
|
53
53
|
handling_hours, commission_rate, category_id, stake_amount, p2p_mode, content_hash, peer_endpoint,
|
|
54
|
-
content_signature, content_signed_at)
|
|
55
|
-
VALUES (?,?,?,?,?,?,'active',?,?,24,0.10,'cat_default',0,1
|
|
54
|
+
content_signature, content_signed_at, currency)
|
|
55
|
+
VALUES (?,?,?,?,?,?,'active',?,?,24,0.10,'cat_default',0,1,?,?,?,?,'WAZ')
|
|
56
56
|
`, [
|
|
57
57
|
id, user.id, title,
|
|
58
58
|
`[P2P] ${title}(完整详情见卖家节点)`,
|
|
@@ -23,7 +23,7 @@ export function registerProductsCreateRoutes(app, deps) {
|
|
|
23
23
|
const quotaCheck = checkSellerCanList(user);
|
|
24
24
|
if (!quotaCheck.ok)
|
|
25
25
|
return void res.json({ error: quotaCheck.reason });
|
|
26
|
-
const { title, description, price, stock = 1, category = '', specs, brand, model, source_url, source_price, external_title, weight_kg, ship_regions = '全国', handling_hours = 24, estimated_days, fragile = 0, return_days = 7, return_condition = '', warranty_days = 0, commission_rate, product_type = 'retail', // 里程碑 6
|
|
26
|
+
const { title, description, price, stock = 1, category = '', specs, brand, model, source_url, source_price, external_title, weight_kg, ship_regions = '全国', handling_hours = 24, estimated_days, fragile = 0, return_days = 7, return_condition = '', warranty_days = 0, low_stock_threshold = 3, auto_delist_on_zero = 1, commission_rate, product_type = 'retail', // 里程碑 6
|
|
27
27
|
aliases = [], // 里程碑 7.2:上架时同步声明的 alias 集合
|
|
28
28
|
image_hashes = [], // 商品图片 — 只存 hash(64 hex),实际 blob 在卖家节点 IDB
|
|
29
29
|
} = req.body;
|
|
@@ -80,14 +80,16 @@ export function registerProductsCreateRoutes(app, deps) {
|
|
|
80
80
|
specs, brand, model, source_url, source_price, source_price_at,
|
|
81
81
|
weight_kg, ship_regions, handling_hours, estimated_days, fragile,
|
|
82
82
|
return_days, return_condition, warranty_days,
|
|
83
|
+
low_stock_threshold, auto_delist_on_zero,
|
|
83
84
|
commitment_hash, description_hash, price_hash, hashed_at,
|
|
84
|
-
commission_rate, product_type, images
|
|
85
|
-
) VALUES (
|
|
85
|
+
commission_rate, product_type, images, currency
|
|
86
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'WAZ')`, [
|
|
86
87
|
id, user.id, title, description, priceNum, Number(stock), category, stakeAmount,
|
|
87
88
|
specsJson, brand ?? null, model ?? null,
|
|
88
89
|
source_url ?? null, source_price ? Number(source_price) : null, source_price ? now : null,
|
|
89
90
|
weight_kg ? Number(weight_kg) : null, ship_regions, Number(handling_hours), estJson, fragile ? 1 : 0,
|
|
90
91
|
Number(return_days), return_condition, Number(warranty_days),
|
|
92
|
+
Math.max(0, Math.floor(Number(low_stock_threshold) || 0)), auto_delist_on_zero ? 1 : 0,
|
|
91
93
|
makeCommitmentHash(pFields), makeDescriptionHash({ title, description, specs: specsJson }),
|
|
92
94
|
makePriceHash(priceNum, now), now,
|
|
93
95
|
commissionRateNum, product_type, imagesJsonForInsert
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { dbOne, dbAll, dbRun } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
|
|
2
|
+
import { invalidateProductVerification } from '../../product-verification.js';
|
|
2
3
|
export function registerProductsLinksRoutes(app, deps) {
|
|
3
4
|
// 只读/单写站点走 RFC-016 异步 seam;db 保留:认领冲突分支是 fee-lock 资金路径,
|
|
4
5
|
// INSERT 链接 + INSERT 验证任务 + 钱包扣费必须原子(db.transaction + 守恒/dup guard),Phase 3 迁 pg 行锁。
|
|
@@ -15,6 +16,21 @@ export function registerProductsLinksRoutes(app, deps) {
|
|
|
15
16
|
const links = await dbAll(`SELECT id, url, source, verified, revoked, verify_note, added_at, platform, external_id, external_title FROM product_external_links WHERE product_id = ? ORDER BY added_at ASC`, [req.params.id]);
|
|
16
17
|
res.json(links);
|
|
17
18
|
});
|
|
19
|
+
// Buyer-facing read for the product detail page: VERIFIED + non-revoked links only, shape { links }.
|
|
20
|
+
// NOT owner-gated (any logged-in viewer) — separate from the owner-only /links above (which returns a bare
|
|
21
|
+
// array the seller workbench consumes; that stays untouched). Only public-safe columns are returned.
|
|
22
|
+
app.get('/api/products/:id/external-links', async (req, res) => {
|
|
23
|
+
const user = auth(req, res);
|
|
24
|
+
if (!user)
|
|
25
|
+
return;
|
|
26
|
+
// Mirror product-detail visibility (products-crud GET /:id): others see active only; the seller sees own
|
|
27
|
+
// non-active. Otherwise 404 — never leak source URLs of a warehouse/deleted product to non-owners.
|
|
28
|
+
const product = await dbOne("SELECT id FROM products WHERE id = ? AND (status = 'active' OR seller_id = ?)", [req.params.id, user.id]);
|
|
29
|
+
if (!product)
|
|
30
|
+
return void res.status(404).json({ error: 'not_found' });
|
|
31
|
+
const links = await dbAll(`SELECT url, platform, external_title FROM product_external_links WHERE product_id = ? AND verified = 1 AND revoked = 0 ORDER BY added_at ASC`, [req.params.id]);
|
|
32
|
+
res.json({ links });
|
|
33
|
+
});
|
|
18
34
|
// 新链接(无人认领)直接 verified=1;已被他人认领则发起众包验证任务
|
|
19
35
|
app.post('/api/products/:id/links', async (req, res) => {
|
|
20
36
|
const user = auth(req, res);
|
|
@@ -69,6 +85,12 @@ export function registerProductsLinksRoutes(app, deps) {
|
|
|
69
85
|
await dbRun(`INSERT INTO product_external_links
|
|
70
86
|
(id, product_id, url, source, verified, verified_at, platform, external_id, external_title)
|
|
71
87
|
VALUES (?, ?, ?, 'manual', 1, datetime('now'), ?, ?, ?)`, [linkId, req.params.id, url, meta?.platform ?? null, meta?.external_id ?? null, linkExternalTitle]);
|
|
88
|
+
try {
|
|
89
|
+
invalidateProductVerification(db, String(req.params.id));
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
console.error('[product-verify invalidate]', e);
|
|
93
|
+
} // PR-⑥ 反作弊:外链变更 → 作废直付逐品验证
|
|
72
94
|
return void res.json({ link_id: linkId, verified: 1, external_title: linkExternalTitle, message: '链接已关联' });
|
|
73
95
|
}
|
|
74
96
|
// 已被他人认领:发起众包验证任务
|
|
@@ -129,6 +151,12 @@ export function registerProductsLinksRoutes(app, deps) {
|
|
|
129
151
|
console.error('[products-links claim tx]', msg);
|
|
130
152
|
return void res.status(500).json({ error: '发起认领失败,请重试' });
|
|
131
153
|
}
|
|
154
|
+
try {
|
|
155
|
+
invalidateProductVerification(db, String(req.params.id));
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
console.error('[product-verify invalidate]', e);
|
|
159
|
+
} // PR-⑥ 反作弊:外链变更 → 作废直付逐品验证
|
|
132
160
|
res.json({
|
|
133
161
|
link_id: linkId,
|
|
134
162
|
task_id: taskId,
|
|
@@ -147,6 +175,12 @@ export function registerProductsLinksRoutes(app, deps) {
|
|
|
147
175
|
if (!product || product.seller_id !== user.id)
|
|
148
176
|
return void res.status(403).json({ error: '无权限' });
|
|
149
177
|
await dbRun('DELETE FROM product_external_links WHERE id = ? AND product_id = ?', [req.params.linkId, req.params.id]);
|
|
178
|
+
try {
|
|
179
|
+
invalidateProductVerification(db, String(req.params.id));
|
|
180
|
+
}
|
|
181
|
+
catch (e) {
|
|
182
|
+
console.error('[product-verify invalidate]', e);
|
|
183
|
+
} // PR-⑥ 反作弊:外链变更 → 作废直付逐品验证
|
|
150
184
|
res.json({ success: true });
|
|
151
185
|
});
|
|
152
186
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHmac } from 'crypto';
|
|
2
2
|
import { dbOne, dbAll } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
|
|
3
|
+
import { genuineSalePredicate } from '../../layer0-foundation/L0-2-state-machine/genuine-sale.js'; // RFC-018 PR4: 真实成交(排除全额退货)
|
|
3
4
|
export function registerProductsListRoutes(app, deps) {
|
|
4
5
|
// db 已走 RFC-016 异步 seam(dbOne/dbAll),不再直接用 deps.db
|
|
5
6
|
const { getUser, VALID_PRODUCT_TYPES, RAW_MODE_MIN_TRUST, getAgentTrustCached, VALID_SORTS, PRODUCT_LIMITS, TRENDING_SCORE_EXPR, findProductsByAlias, decodeProductCursor, encodeProductCursor, MASTER_SEED, formatProductForAgent } = deps;
|
|
@@ -52,7 +53,7 @@ export function registerProductsListRoutes(app, deps) {
|
|
|
52
53
|
COALESCE(rs.total_points, 0) as rep_points, COALESCE(rs.level, 'new') as rep_level,
|
|
53
54
|
COALESCE(rs.transactions_done, 0) as seller_tx_count,
|
|
54
55
|
pc.seasonal_months as seasonal_months,
|
|
55
|
-
(SELECT COUNT(1) FROM orders o WHERE o.product_id = p.id AND o
|
|
56
|
+
(SELECT COUNT(1) FROM orders o WHERE o.product_id = p.id AND ${genuineSalePredicate('o')}) as sales_count,
|
|
56
57
|
(SELECT COUNT(DISTINCT buyer_id) FROM order_ratings r WHERE r.product_id = p.id AND r.stars >= 4) as recommend_count,
|
|
57
58
|
(SELECT COUNT(*) FROM dispute_cases dc WHERE dc.seller_id = p.seller_id) as seller_dispute_count,
|
|
58
59
|
-- 卖家仲裁胜率:无案件视为 0.8 中性(未经检验);有案件按真实比率
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { dbOne, dbRun } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
|
|
2
|
+
import { invalidateProductVerification } from '../../product-verification.js';
|
|
2
3
|
export function registerProductsUpdateRoutes(app, deps) {
|
|
3
|
-
// db 已走 RFC-016 异步 seam(dbOne/dbRun)
|
|
4
|
-
const { auth, makeCommitmentHash, makeDescriptionHash, makePriceHash, notifyWaitlist, notifyWishlistPriceDrop, checkStockAndMaybeDelist } = deps;
|
|
4
|
+
// db 已走 RFC-016 异步 seam(dbOne/dbRun);deps.db 仅用于 direct-pay 逐品验证作废(sync helper,反作弊生命周期)
|
|
5
|
+
const { db, auth, makeCommitmentHash, makeDescriptionHash, makePriceHash, notifyWaitlist, notifyWishlistPriceDrop, checkStockAndMaybeDelist } = deps;
|
|
5
6
|
app.put('/api/products/:id', async (req, res) => {
|
|
6
7
|
const user = auth(req, res);
|
|
7
8
|
if (!user)
|
|
@@ -134,6 +135,25 @@ export function registerProductsUpdateRoutes(app, deps) {
|
|
|
134
135
|
console.error('[wishlist price-drop notify]', e);
|
|
135
136
|
}
|
|
136
137
|
}
|
|
138
|
+
// 反作弊(PR-⑥):任何【买家可见的商品身份字段】变更 → 作废该商品的直付逐品验证,强制重新验证,防"先验证商品 A,
|
|
139
|
+
// 再改成商品 B"绕过逐品硬门。external-link 变更在 products-links 路由另行作废。fail-soft,不阻断编辑。
|
|
140
|
+
// ⚠️ 必须含 i18n_titles/i18n_descs:formatProductForAgent 按 Accept-Language 把 title/description 换成对应语言,
|
|
141
|
+
// 只改 en/ja/ko 标题描述同样是"换货"(非中文买家看到的就变了);并含 brand/model(商品身份)。
|
|
142
|
+
const newBrand = brand ?? product.brand;
|
|
143
|
+
const newModel = model ?? product.model;
|
|
144
|
+
const eq = (a, b) => String(a ?? '') === String(b ?? '');
|
|
145
|
+
const materialChanged = !eq(newTitle, product.title) || !eq(newDesc, product.description)
|
|
146
|
+
|| Number(newPrice) !== Number(product.price) || !eq(specsJson, product.specs)
|
|
147
|
+
|| !eq(newI18nTitles, product.i18n_titles) || !eq(newI18nDescs, product.i18n_descs)
|
|
148
|
+
|| !eq(newBrand, product.brand) || !eq(newModel, product.model);
|
|
149
|
+
if (materialChanged) {
|
|
150
|
+
try {
|
|
151
|
+
invalidateProductVerification(db, String(req.params.id));
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
console.error('[product-verify invalidate]', e);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
137
157
|
res.json({ success: true });
|
|
138
158
|
});
|
|
139
159
|
}
|
|
@@ -28,6 +28,8 @@ export function registerPromoterRoutes(app, deps) {
|
|
|
28
28
|
for (const r of earned)
|
|
29
29
|
byLevel[r.level] = { orders: r.orders, total: r.total };
|
|
30
30
|
const grand = byLevel[1].total + byLevel[2].total + byLevel[3].total;
|
|
31
|
+
// RFC-018: commission accrued but still in the clearing window (matures into grand_total). Pure read.
|
|
32
|
+
const clearing = (await dbOne("SELECT COALESCE(SUM(amount),0) as s FROM pending_commission_escrow WHERE recipient_user_id = ? AND matures_at IS NOT NULL AND status = 'pending'", [userId])).s;
|
|
31
33
|
const recent = await dbAll(`
|
|
32
34
|
SELECT cr.id, cr.order_id, cr.level, cr.amount, cr.rate, cr.created_at,
|
|
33
35
|
u.name as source_buyer_name
|
|
@@ -142,6 +144,7 @@ export function registerPromoterRoutes(app, deps) {
|
|
|
142
144
|
team: { l1, l2, l3, total: l1 + l2 + l3 },
|
|
143
145
|
earnings: {
|
|
144
146
|
grand_total: grand,
|
|
147
|
+
clearing_total: clearing, // RFC-018: accrued, maturing after the return window (not yet paid)
|
|
145
148
|
l1: byLevel[1],
|
|
146
149
|
l2: byLevel[2],
|
|
147
150
|
l3: byLevel[3],
|
|
@@ -23,6 +23,9 @@ export function registerReferralRoutes(app, deps) {
|
|
|
23
23
|
`, [user.id]));
|
|
24
24
|
const todayEarnings = (await dbOne(`SELECT COALESCE(SUM(amount), 0) as t FROM commission_records WHERE beneficiary_id = ? AND created_at > datetime('now', '-1 day')`, [user.id])).t;
|
|
25
25
|
const monthEarnings = (await dbOne(`SELECT COALESCE(SUM(amount), 0) as t FROM commission_records WHERE beneficiary_id = ? AND created_at > datetime('now', '-30 days')`, [user.id])).t;
|
|
26
|
+
// RFC-018: commission accrued but still in the clearing window (pending → matures into total_waz).
|
|
27
|
+
// Pure read; surfaced so earnings don't appear to vanish during clearing (Option A keeps total_waz = paid).
|
|
28
|
+
const clearingWaz = (await dbOne(`SELECT COALESCE(SUM(amount), 0) as t FROM pending_commission_escrow WHERE recipient_user_id = ? AND matures_at IS NOT NULL AND status = 'pending'`, [user.id])).t;
|
|
26
29
|
res.json({
|
|
27
30
|
invite_code: code,
|
|
28
31
|
invite_link: code ? `${req.protocol}://${req.get('host')}/i/${code}` : null,
|
|
@@ -34,6 +37,7 @@ export function registerReferralRoutes(app, deps) {
|
|
|
34
37
|
total_waz: earnings.total,
|
|
35
38
|
today_waz: todayEarnings,
|
|
36
39
|
month_waz: monthEarnings,
|
|
40
|
+
clearing_waz: clearingWaz, // RFC-018: accrued, maturing after the return window (not yet paid)
|
|
37
41
|
},
|
|
38
42
|
});
|
|
39
43
|
});
|
|
@@ -30,6 +30,26 @@ export function registerReturnsRoutes(app, deps) {
|
|
|
30
30
|
if (debited.changes !== 1)
|
|
31
31
|
throw new Error('INSUFFICIENT_SELLER_BALANCE');
|
|
32
32
|
db.prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?').run(refundAmt, rr.buyer_id);
|
|
33
|
+
// RFC-018: reverse this order's in-window clearing commission, proportional to the refund.
|
|
34
|
+
// These pending rows were never paid (maturation is gated on matures_at), so this is a pure
|
|
35
|
+
// reversal — no clawback. matures_at IS NOT NULL targets clearing rows only; opt-out escrow
|
|
36
|
+
// rows (matures_at IS NULL) are untouched. Atomic with the refund.
|
|
37
|
+
const ordTotal = Number(db.prepare('SELECT total_amount FROM orders WHERE id = ?').get(rr.order_id)?.total_amount ?? 0);
|
|
38
|
+
const refundFrac = ordTotal > 0 ? Math.min(1, refundAmt / ordTotal) : 1;
|
|
39
|
+
if (refundFrac >= 1) {
|
|
40
|
+
db.prepare(`UPDATE pending_commission_escrow SET status='reversed' WHERE order_id = ? AND status='pending' AND matures_at IS NOT NULL`).run(rr.order_id);
|
|
41
|
+
// RFC-018 PR4: a single FULL return is no longer a genuine sale — decrement the stored product
|
|
42
|
+
// completion_count (incremented at settleOrder). Idempotent: the return CAS'd to 'refunded'
|
|
43
|
+
// exactly once above, so this runs once. Partial refund stays a genuine sale (no decrement).
|
|
44
|
+
// Edge: multiple partial refunds cumulatively reaching full aren't caught here (each refundFrac<1),
|
|
45
|
+
// so the stored counter may transiently over-count by 1 — but the AUTHORITATIVE genuine count
|
|
46
|
+
// (genuineSalePredicate, cumulative SUM) is correct everywhere on-read, and the completion_count
|
|
47
|
+
// backfill (genuine-aware) self-heals the stored counter. completion_count is best-effort social proof.
|
|
48
|
+
db.prepare(`UPDATE products SET completion_count = MAX(0, COALESCE(completion_count,0) - 1) WHERE id = ?`).run(rr.product_id);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
db.prepare(`UPDATE pending_commission_escrow SET amount = amount * ? WHERE order_id = ? AND status='pending' AND matures_at IS NOT NULL`).run(1 - refundFrac, rr.order_id);
|
|
52
|
+
}
|
|
33
53
|
// 3. 恢复库存(CAS + 扣款成功后)
|
|
34
54
|
const ord = db.prepare('SELECT quantity, source, variant_id FROM orders WHERE id = ?').get(rr.order_id);
|
|
35
55
|
if (ord && ord.source !== 'secondhand') {
|
|
@@ -69,7 +89,7 @@ export function registerReturnsRoutes(app, deps) {
|
|
|
69
89
|
return void errorRes(res, 403, 'TRUSTED_ROLE_NO_TRADE', '受信角色无购物功能');
|
|
70
90
|
const order = await dbOne(`
|
|
71
91
|
SELECT o.id, o.buyer_id, o.seller_id, o.product_id, o.status, o.total_amount, o.created_at, o.updated_at,
|
|
72
|
-
p.return_days, p.title as product_title
|
|
92
|
+
o.payment_rail, p.return_days, p.title as product_title
|
|
73
93
|
FROM orders o JOIN products p ON p.id = o.product_id
|
|
74
94
|
WHERE o.id = ?
|
|
75
95
|
`, [req.params.order_id]);
|
|
@@ -77,6 +97,11 @@ export function registerReturnsRoutes(app, deps) {
|
|
|
77
97
|
return void res.status(404).json({ error: '订单不存在' });
|
|
78
98
|
if (order.buyer_id !== user.id)
|
|
79
99
|
return void res.status(403).json({ error: '仅买家可申请退货' });
|
|
100
|
+
// 直付(Rail1)= 非托管、refund:none —— WebAZ 不持货款,退款经现有 escrow 钱包路径会错误移动无关 WAZ。
|
|
101
|
+
// 故 direct_p2p 不走退货/退款流程(买家不满走争议=信誉处理);平台费应收的冲销由治理/admin 调整处理(非此路径)。
|
|
102
|
+
if (order.payment_rail === 'direct_p2p') {
|
|
103
|
+
return void res.status(400).json({ error: '直付(非托管)订单不支持退款流程;如有问题请发起争议', error_code: 'DIRECT_PAY_NO_REFUND' });
|
|
104
|
+
}
|
|
80
105
|
// P0-1: 只允许 completed 退货 — escrow 已结算
|
|
81
106
|
if (order.status !== 'completed') {
|
|
82
107
|
return void res.status(400).json({ error: '仅订单完成后可申请退货(确认收货后)' });
|
|
@@ -37,8 +37,10 @@ export function registerRewardsApplyRoutes(app, deps) {
|
|
|
37
37
|
missing.push(`completed_orders ${completedOrders}/${minOrders}`);
|
|
38
38
|
if (requirePasskey === 1 && passkeyCount === 0)
|
|
39
39
|
missing.push('passkey_not_registered');
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
// RFC-018: matures_at IS NULL = opt-out escrow (claimable on activation). Clearing rows
|
|
41
|
+
// (matures_at NOT NULL) auto-mature via rewards-clearing-mature and must NOT be drained here.
|
|
42
|
+
const pending = (await dbOne("SELECT COUNT(*) AS n, COALESCE(SUM(amount),0) AS total FROM pending_commission_escrow WHERE recipient_user_id = ? AND status = 'pending' AND matures_at IS NULL", [userId]));
|
|
43
|
+
const expired = (await dbOne("SELECT COUNT(*) AS n, COALESCE(SUM(amount),0) AS total FROM pending_commission_escrow WHERE recipient_user_id = ? AND status = 'expired' AND matures_at IS NULL", [userId]));
|
|
42
44
|
res.json({
|
|
43
45
|
state,
|
|
44
46
|
opted_in: optIn === 1,
|
|
@@ -138,12 +140,15 @@ export function registerRewardsApplyRoutes(app, deps) {
|
|
|
138
140
|
VALUES (?, 'activate', ?, ?, ?, ?, ?, ?, ?)`)
|
|
139
141
|
.run(userId, consent_version, currentMajor.hash, webauthn_token || null, // store gate_token id as audit cross-ref to webauthn_gate_tokens
|
|
140
142
|
requirePasskey === 1 ? 'passkey' : 'password', sha256_hex(req.ip || '').slice(0, 16), sha256_hex(String(req.headers['user-agent'] || '')).slice(0, 16), now);
|
|
141
|
-
// Activate batch settle: drain
|
|
143
|
+
// Activate batch settle: drain OPT-OUT escrow to wallet. RFC-018: matures_at IS NULL excludes
|
|
144
|
+
// clearing rows — those are NOT claimable on opt-in; they auto-mature after the return window
|
|
145
|
+
// (rewards-clearing-mature). Draining them here would pay commission early and defeat the
|
|
146
|
+
// clearing window's reversal guarantee (a returned order could no longer be reversed → clawback).
|
|
142
147
|
const pending = db.prepare(`SELECT id, amount, attribution_path FROM pending_commission_escrow
|
|
143
|
-
WHERE recipient_user_id = ? AND status = 'pending' AND expires_at > ?`).all(userId, now);
|
|
148
|
+
WHERE recipient_user_id = ? AND status = 'pending' AND matures_at IS NULL AND expires_at > ?`).all(userId, now);
|
|
144
149
|
let total = 0;
|
|
145
150
|
for (const p of pending) {
|
|
146
|
-
const upd = db.prepare(`UPDATE pending_commission_escrow SET status='settled', settled_at=? WHERE id=? AND status='pending'`).run(now, p.id);
|
|
151
|
+
const upd = db.prepare(`UPDATE pending_commission_escrow SET status='settled', settled_at=? WHERE id=? AND status='pending' AND matures_at IS NULL`).run(now, p.id);
|
|
147
152
|
if (upd.changes === 0)
|
|
148
153
|
continue; // race: expire cron took it
|
|
149
154
|
db.prepare(`UPDATE wallets SET balance = balance + ?, earned = earned + ? WHERE user_id = ?`).run(p.amount, p.amount, userId);
|