@seasonkoh/webaz 0.1.29 → 0.1.30
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/dist/bond-refund-blockers.js +50 -0
- package/dist/bond-slash.js +100 -0
- package/dist/bond-terms.js +31 -0
- package/dist/direct-pay-bond-rail-clearance.js +10 -5
- package/dist/direct-pay-cancel-refund.js +160 -0
- package/dist/direct-pay-create.js +78 -9
- package/dist/direct-pay-disclosures.js +17 -11
- package/dist/direct-pay-fee-ar.js +2 -2
- package/dist/direct-pay-fee-prepay-request.js +80 -0
- package/dist/direct-pay-returns.js +104 -0
- package/dist/direct-pay-stock.js +9 -0
- package/dist/direct-receive-deferral.js +30 -1
- package/dist/direct-receive-deposits.js +122 -17
- package/dist/free-shipping.js +37 -0
- package/dist/fx-rates.js +7 -0
- package/dist/layer0-foundation/L0-1-database/schema.js +246 -0
- package/dist/layer0-foundation/L0-2-state-machine/engine.js +1 -0
- package/dist/layer0-foundation/L0-2-state-machine/transitions.js +73 -0
- package/dist/layer1-agent/L1-1-mcp-server/server.js +81 -16
- package/dist/layer2-business/L2-6-notifications/notification-engine.js +110 -41
- package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +133 -31
- package/dist/layer3-trust/L3-1-dispute-engine/evidence-storage.js +16 -4
- package/dist/layer3-trust/L3-1-dispute-engine/mutual-cancel.js +156 -0
- package/dist/platform-receive-accounts.js +94 -0
- package/dist/pwa/arbitration-read-admin.js +37 -0
- package/dist/pwa/arbitrator-lifecycle.js +100 -0
- package/dist/pwa/contract-fingerprint.js +15 -0
- package/dist/pwa/direct-pay-order-redaction.js +20 -2
- package/dist/pwa/human-presence.js +2 -6
- package/dist/pwa/public/app-account.js +9 -9
- package/dist/pwa/public/app-admin-disputes.js +55 -0
- package/dist/pwa/public/app-agent-appeal.js +90 -0
- package/dist/pwa/public/app-agent-approvals.js +93 -0
- package/dist/pwa/public/app-agent-pair.js +127 -0
- package/dist/pwa/public/app-arbitrator-admin.js +87 -0
- package/dist/pwa/public/app-arbitrator-entry.js +9 -0
- package/dist/pwa/public/app-bond-deferral-ui.js +9 -0
- package/dist/pwa/public/app-bond-refund-ui.js +66 -0
- package/dist/pwa/public/app-bond-slash-ui.js +74 -0
- package/dist/pwa/public/app-bond-terms-ui.js +23 -0
- package/dist/pwa/public/app-bond-ui.js +108 -0
- package/dist/pwa/public/app-chat-poll.js +6 -6
- package/dist/pwa/public/app-contribution-hub.js +23 -0
- package/dist/pwa/public/app-direct-pay-accounts.js +1 -1
- package/dist/pwa/public/app-direct-pay-buyer.js +1 -1
- package/dist/pwa/public/app-direct-pay-cancel-refund.js +72 -0
- package/dist/pwa/public/app-direct-pay-copy.js +12 -0
- package/dist/pwa/public/app-direct-pay-fee-history.js +39 -0
- package/dist/pwa/public/app-direct-pay-fee-ops.js +1 -1
- package/dist/pwa/public/app-direct-pay-fee-request.js +81 -0
- package/dist/pwa/public/app-direct-pay-fee-requests-admin.js +70 -0
- package/dist/pwa/public/app-direct-pay-memo.js +14 -0
- package/dist/pwa/public/app-direct-pay-negotiation.js +35 -0
- package/dist/pwa/public/app-direct-pay-pay.js +15 -0
- package/dist/pwa/public/app-direct-pay-paymodal.js +32 -0
- package/dist/pwa/public/app-direct-pay-reconcile.js +19 -0
- package/dist/pwa/public/app-direct-pay-returns.js +56 -0
- package/dist/pwa/public/app-direct-pay-reveal.js +82 -0
- package/dist/pwa/public/app-direct-pay-sales-report.js +70 -0
- package/dist/pwa/public/app-direct-pay.js +36 -37
- package/dist/pwa/public/app-dispute-close-ui.js +38 -0
- package/dist/pwa/public/app-free-shipping-ui.js +29 -0
- package/dist/pwa/public/app-gmv-rail-split.js +12 -0
- package/dist/pwa/public/app-listing-commerce-ui.js +72 -0
- package/dist/pwa/public/app-mutual-cancel.js +54 -0
- package/dist/pwa/public/app-notif-templates-orders.js +43 -0
- package/dist/pwa/public/app-notif-templates.js +22 -0
- package/dist/pwa/public/app-order-accept-ui.js +158 -0
- package/dist/pwa/public/app-order-errors.js +50 -0
- package/dist/pwa/public/app-order-labels.js +19 -0
- package/dist/pwa/public/app-order-rail-filter.js +26 -0
- package/dist/pwa/public/app-platform-receive-accounts.js +140 -0
- package/dist/pwa/public/app-poll-governor.js +22 -0
- package/dist/pwa/public/app-profile.js +4 -4
- package/dist/pwa/public/app-purchase-terms-ui.js +68 -0
- package/dist/pwa/public/app-sale-regions-ui.js +38 -0
- package/dist/pwa/public/app-seller.js +1 -1
- package/dist/pwa/public/app-trade-tax-ui.js +33 -0
- package/dist/pwa/public/app.js +133 -160
- package/dist/pwa/public/i18n.js +675 -5
- package/dist/pwa/public/index.html +41 -0
- package/dist/pwa/public/openapi.json +709 -11
- package/dist/pwa/public/style.css +3 -0
- package/dist/pwa/routes/admin-direct-receive-deposits.js +215 -3
- package/dist/pwa/routes/admin-protocol-params.js +16 -0
- package/dist/pwa/routes/admin-reports.js +31 -5
- package/dist/pwa/routes/agent-governance.js +1 -1
- package/dist/pwa/routes/agent-grants.js +253 -32
- package/dist/pwa/routes/analytics.js +2 -0
- package/dist/pwa/routes/arbitrator.js +67 -14
- package/dist/pwa/routes/bond-seller.js +162 -0
- package/dist/pwa/routes/direct-pay-cancel-refund.js +111 -0
- package/dist/pwa/routes/direct-pay-disclosure-acks.js +9 -4
- package/dist/pwa/routes/direct-pay-pending-accept.js +222 -0
- package/dist/pwa/routes/direct-pay-returns.js +74 -0
- package/dist/pwa/routes/direct-pay-timeouts.js +186 -9
- package/dist/pwa/routes/disputes-read.js +20 -6
- package/dist/pwa/routes/disputes-write.js +91 -33
- package/dist/pwa/routes/external-anchors.js +4 -2
- package/dist/pwa/routes/fee-prepay-requests.js +66 -0
- package/dist/pwa/routes/governance-onboarding.js +46 -8
- package/dist/pwa/routes/logistics.js +4 -4
- package/dist/pwa/routes/me-data.js +2 -2
- package/dist/pwa/routes/mutual-cancel.js +62 -0
- package/dist/pwa/routes/orders-action.js +182 -9
- package/dist/pwa/routes/orders-create.js +18 -7
- package/dist/pwa/routes/orders-read.js +76 -14
- package/dist/pwa/routes/platform-receive-accounts.js +111 -0
- package/dist/pwa/routes/products-create.js +13 -4
- package/dist/pwa/routes/products-update.js +15 -1
- package/dist/pwa/routes/profile-identity.js +4 -2
- package/dist/pwa/routes/returns.js +39 -8
- package/dist/pwa/routes/seller-directpay-report.js +110 -0
- package/dist/pwa/routes/seller-quota.js +5 -1
- package/dist/pwa/routes/shipping-templates.js +219 -0
- package/dist/pwa/routes/snf.js +4 -1
- package/dist/pwa/routes/webauthn.js +3 -3
- package/dist/pwa/server.js +59 -36
- package/dist/runtime/agent-grant-scopes.js +69 -1
- package/dist/runtime/webaz-schema-helpers.js +57 -3
- package/dist/sale-regions.js +116 -0
- package/dist/shipping-templates.js +119 -0
- package/dist/trade-tax.js +99 -0
- package/dist/trade-terms.js +76 -0
- package/dist/version.js +1 -1
- package/package.json +59 -2
|
@@ -4,6 +4,8 @@ import { getCasesForRole, getCasesForMaintainer, validateCaseReviews } from '../
|
|
|
4
4
|
// RFC-016 Phase 1 — 申请/题目/案例/申诉的校验读 + 列表读 + 单语句写 → async seam;
|
|
5
5
|
// activate/resign/resolve-appeal 的 3 个角色态 CAS db.transaction 保持同步(Phase 3 迁 pg)。
|
|
6
6
|
import { dbOne, dbAll, dbRun } from '../../layer0-foundation/L0-1-database/db.js';
|
|
7
|
+
import { grantArbitratorTx, suspendArbitrator } from '../arbitrator-lifecycle.js'; // PR-B/PR-C.2:激活桥接 active whitelist 同事务;#249-P2:仲裁员卸任真源=whitelist(suspend 可复用,非终态 revoke)
|
|
8
|
+
import { isActiveWhitelistArbitrator } from '../../layer3-trust/L3-1-dispute-engine/dispute-engine.js';
|
|
7
9
|
// PR #22 review fix P1-2:披露文本版本(server 与 client 同步,变更披露需 bump)
|
|
8
10
|
// client 在 src/pwa/public/app.js 必须用相同 version
|
|
9
11
|
export const GOVERNANCE_APPLY_DISCLOSURE_VERSION = 'v1.0-2026-06-02';
|
|
@@ -390,10 +392,14 @@ export function registerGovernanceOnboardingRoutes(app, deps) {
|
|
|
390
392
|
return void errorRes(res, 401, 'PASSKEY_INVALID', `maintainer Passkey 验证失败: ${result.reason || '未知'}`);
|
|
391
393
|
}
|
|
392
394
|
}
|
|
393
|
-
// 5. 事务:conditional UPDATE 防竞态 + INSERT activate row + 加 users.roles
|
|
395
|
+
// 5. 事务:conditional UPDATE 防竞态 + PR-C.2 仲裁授权 + INSERT activate row + 加 users.roles(仅治理记录,非 eligibility 源)
|
|
394
396
|
// PR #25 self-review fix P1-1:status check 进 transaction(防 2 maintainer 同时激活 → 2 activate row)
|
|
395
397
|
// PR #25 self-review fix P1-2:不存 note 到 appeal_resolution(语义错);maintainer note 由 logAdminAction(detail) 记
|
|
398
|
+
// PR-C.2:role='arbitrator' 的 grantArbitratorTx 与状态翻转【同事务】—— 保证"governance active ⟺ whitelist active",
|
|
399
|
+
// grant 失败(revoked/非人类/无 Passkey)→ 抛 sentinel,整个激活回滚。verifier 角色走其自身白名单,不在此桥。
|
|
396
400
|
const RACE_LOST = 'RACE_LOST_PENDING_TO_ACTIVE';
|
|
401
|
+
const GRANT_ABORT = 'GRANT_ABORT';
|
|
402
|
+
let grantErr = null;
|
|
397
403
|
try {
|
|
398
404
|
db.transaction(() => {
|
|
399
405
|
// 5.1 conditional UPDATE 原 apply row(只在 status='pending_onboarding' 时改 → 防竞态)
|
|
@@ -402,6 +408,14 @@ export function registerGovernanceOnboardingRoutes(app, deps) {
|
|
|
402
408
|
// 另一 maintainer 已激活 → 抛出 sentinel,整个事务回滚
|
|
403
409
|
throw new Error(RACE_LOST);
|
|
404
410
|
}
|
|
411
|
+
// 5.1b 仲裁授权与状态翻转同事务(唯一 runtime 授权源 = active arbitrator_whitelist)
|
|
412
|
+
if (role === 'arbitrator') {
|
|
413
|
+
const g = grantArbitratorTx(db, { userId: app_.user_id, grantedBy: adminId, note: 'governance onboarding 激活' });
|
|
414
|
+
if (!g.ok) {
|
|
415
|
+
grantErr = g.error_code || 'GRANT_FAILED';
|
|
416
|
+
throw new Error(GRANT_ABORT);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
405
419
|
// 5.2 INSERT 新 row(append-only audit trail,action='activate')
|
|
406
420
|
const activateId = generateId('gapp');
|
|
407
421
|
db.prepare(`
|
|
@@ -409,7 +423,10 @@ export function registerGovernanceOnboardingRoutes(app, deps) {
|
|
|
409
423
|
(id, user_id, role, action, status, passkey_sig, iron_rule_method)
|
|
410
424
|
VALUES (?, ?, ?, 'activate', 'active', ?, 'passkey')
|
|
411
425
|
`).run(activateId, app_.user_id, role, webauthn_token || null);
|
|
412
|
-
// 5.3 加 role 到 users.roles JSON 数组(去重)
|
|
426
|
+
// 5.3 加 role 到 users.roles JSON 数组(去重)。
|
|
427
|
+
// 仲裁员例外:仲裁资格 = active arbitrator_whitelist(5.1b 已同事务 grant,#209/#220),【不是角色】——
|
|
428
|
+
// 写进 roles 会在角色切换 UI 造出可切换的"仲裁员"假身份(与买卖身份互斥感误导用户,梦想者1号被卡案根源之一)。
|
|
429
|
+
// 普通人保留 buyer/seller 身份 + 白名单资格,仲裁台入口随 can_arbitrate 出现,买卖能力不受影响。
|
|
413
430
|
const user = db.prepare("SELECT roles FROM users WHERE id = ?").get(app_.user_id);
|
|
414
431
|
let roles = [];
|
|
415
432
|
try {
|
|
@@ -418,7 +435,7 @@ export function registerGovernanceOnboardingRoutes(app, deps) {
|
|
|
418
435
|
catch {
|
|
419
436
|
roles = [];
|
|
420
437
|
}
|
|
421
|
-
if (!roles.includes(role)) {
|
|
438
|
+
if (role !== 'arbitrator' && !roles.includes(role)) {
|
|
422
439
|
roles.push(role);
|
|
423
440
|
db.prepare("UPDATE users SET roles = ? WHERE id = ?").run(JSON.stringify(roles), app_.user_id);
|
|
424
441
|
}
|
|
@@ -428,6 +445,10 @@ export function registerGovernanceOnboardingRoutes(app, deps) {
|
|
|
428
445
|
if (e.message === RACE_LOST) {
|
|
429
446
|
return void errorRes(res, 409, 'ALREADY_ACTIVATED', '该 application 已被其他 maintainer 激活(竞态)');
|
|
430
447
|
}
|
|
448
|
+
if (e.message === GRANT_ABORT) { // PR-C.2:仲裁授权失败 → 整个激活已回滚
|
|
449
|
+
logAdminAction(adminId, 'governance_activate', 'user', app_.user_id, { ok: false, role, error_code: grantErr });
|
|
450
|
+
return void errorRes(res, 409, grantErr || 'GRANT_FAILED', '激活失败:无法授予仲裁员资格(见 error_code),激活已回滚');
|
|
451
|
+
}
|
|
431
452
|
throw e; // 真实 SQL error → re-throw 给 express error handler
|
|
432
453
|
}
|
|
433
454
|
// 6. admin 审计 log(maintainer note 存 detail JSON,非 governance_applications 字段)
|
|
@@ -478,7 +499,10 @@ export function registerGovernanceOnboardingRoutes(app, deps) {
|
|
|
478
499
|
catch {
|
|
479
500
|
currentRoles = [];
|
|
480
501
|
}
|
|
481
|
-
|
|
502
|
+
// #249-P2:activate 已不再把 arbitrator 写进 users.roles(资格=白名单)→ 仲裁员的"当前在任"真源
|
|
503
|
+
// = active whitelist(或 legacy roles 残留,两者任一即可卸);verifier 真源仍 = users.roles。
|
|
504
|
+
const wlActiveArb = role === 'arbitrator' && isActiveWhitelistArbitrator(db, userId);
|
|
505
|
+
if (!currentRoles.includes(role) && !wlActiveArb) {
|
|
482
506
|
return void errorRes(res, 404, 'NOT_ACTIVE', `你当前不是 ${role},无需卸任`);
|
|
483
507
|
}
|
|
484
508
|
// 用于日志显示 + 后续 INSERT 关联(取最新的 active 行;若没有也容许,以 user.roles 为准)
|
|
@@ -534,7 +558,8 @@ export function registerGovernanceOnboardingRoutes(app, deps) {
|
|
|
534
558
|
try {
|
|
535
559
|
resignId = generateId('gapp');
|
|
536
560
|
db.transaction(() => {
|
|
537
|
-
// 1.
|
|
561
|
+
// 1. 重读真源(进事务后)。仲裁员=active whitelist(#249 起 activate 不写 roles;suspend=自愿卸任可经
|
|
562
|
+
// admin reinstate 复用,刻意不用终态 revoke);legacy roles 里的 'arbitrator' 存量一并摘除。verifier=users.roles。
|
|
538
563
|
const u = db.prepare("SELECT roles FROM users WHERE id = ?").get(userId);
|
|
539
564
|
let roles = [];
|
|
540
565
|
try {
|
|
@@ -543,10 +568,23 @@ export function registerGovernanceOnboardingRoutes(app, deps) {
|
|
|
543
568
|
catch {
|
|
544
569
|
roles = [];
|
|
545
570
|
}
|
|
546
|
-
|
|
571
|
+
const legacyInRoles = roles.includes(role);
|
|
572
|
+
if (role === 'arbitrator') {
|
|
573
|
+
const wlNow = isActiveWhitelistArbitrator(db, userId);
|
|
574
|
+
if (!wlNow && !legacyInRoles)
|
|
575
|
+
throw new Error(RACE_LOST);
|
|
576
|
+
if (wlNow) {
|
|
577
|
+
const m = suspendArbitrator(db, { userId, note: '主动卸任(governance resign)' });
|
|
578
|
+
if (!m.ok)
|
|
579
|
+
throw new Error(RACE_LOST);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
else if (!legacyInRoles)
|
|
547
583
|
throw new Error(RACE_LOST);
|
|
548
|
-
|
|
549
|
-
|
|
584
|
+
if (legacyInRoles) {
|
|
585
|
+
roles = roles.filter(r => r !== role);
|
|
586
|
+
db.prepare("UPDATE users SET roles = ? WHERE id = ?").run(JSON.stringify(roles), userId);
|
|
587
|
+
}
|
|
550
588
|
// 2. 把所有该 user+role 的 active 行 → inactive(可能有 apply + activate 两行)
|
|
551
589
|
db.prepare("UPDATE governance_applications SET status = 'inactive' WHERE user_id = ? AND role = ? AND status = 'active'").run(userId, role);
|
|
552
590
|
// 3. INSERT resign 行(append-only audit)
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { dbAll } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
|
|
2
|
-
import {
|
|
2
|
+
import { projectDirectPayTargetForViewer } from '../direct-pay-order-redaction.js'; // 披露门:按查看者投影(物流=第三方 → 剥离收款目标)
|
|
3
3
|
export function registerLogisticsRoutes(app, deps) {
|
|
4
|
-
//
|
|
5
|
-
const { auth } = deps;
|
|
4
|
+
// 查询走 RFC-016 异步 seam(dbAll);db 仅传给共享投影器(其买家分支查 ack,本路由查看者恒第三方)
|
|
5
|
+
const { auth, db } = deps;
|
|
6
6
|
app.get('/api/logistics/companies', async (_req, res) => {
|
|
7
7
|
const companies = await dbAll(`SELECT id, name FROM users WHERE role = 'logistics' ORDER BY name ASC`);
|
|
8
8
|
res.json(companies);
|
|
@@ -35,7 +35,7 @@ export function registerLogisticsRoutes(app, deps) {
|
|
|
35
35
|
`, [user.id]);
|
|
36
36
|
// 披露门:物流是非买家第三方,无条件删除 direct_p2p 收款目标(instruction 快照 + 账号快照);物流只需商品/状态/地址。
|
|
37
37
|
for (const o of [...available, ...mine])
|
|
38
|
-
|
|
38
|
+
projectDirectPayTargetForViewer(db, o, user.id);
|
|
39
39
|
res.json({ available, mine });
|
|
40
40
|
});
|
|
41
41
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { dbOne, dbAll } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
|
|
2
|
-
import {
|
|
2
|
+
import { projectDirectPayTargetForViewer } from '../direct-pay-order-redaction.js'; // 披露门:自导出的 orders 也按查看者投影,不得旁路
|
|
3
3
|
export function registerMeDataRoutes(app, deps) {
|
|
4
4
|
// db 走 RFC-016 异步 seam(dbOne/dbAll);deps.db 仅供披露门(requireBothDisclosuresAcked 是同步 gate)
|
|
5
5
|
const { auth, db } = deps;
|
|
@@ -58,7 +58,7 @@ export function registerMeDataRoutes(app, deps) {
|
|
|
58
58
|
data.wallet = await dbOne(`SELECT balance, staked, escrowed, earned FROM wallets WHERE user_id = ?`, [uid]);
|
|
59
59
|
data.orders = await dbAll(`SELECT * FROM orders WHERE buyer_id = ? OR seller_id = ? ORDER BY created_at DESC LIMIT 1000`, [uid, uid]);
|
|
60
60
|
for (const o of data.orders)
|
|
61
|
-
|
|
61
|
+
projectDirectPayTargetForViewer(db, o, uid); // 披露门:自导出按查看者投影(买家行=ack 门/卖家行=收款方保留),JSON + CSV 同源
|
|
62
62
|
data.shareables = await dbAll(`SELECT * FROM shareables WHERE owner_id = ? AND status != 'removed'`, [uid]);
|
|
63
63
|
data.bookmarks = await dbAll(`SELECT b.*, s.title FROM shareable_bookmarks b LEFT JOIN shareables s ON s.id = b.shareable_id WHERE b.user_id = ?`, [uid]);
|
|
64
64
|
data.likes = await dbAll(`SELECT l.*, s.title FROM shareable_likes l LEFT JOIN shareables s ON s.id = l.shareable_id WHERE l.user_id = ?`, [uid]);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { proposeMutualCancel, acceptMutualCancel, declineMutualCancel, withdrawMutualCancel, getMutualCancelState } from '../../layer3-trust/L3-1-dispute-engine/mutual-cancel.js';
|
|
2
|
+
export function registerMutualCancelRoutes(app, deps) {
|
|
3
|
+
const { db, auth, generateId, errorRes } = deps;
|
|
4
|
+
// 域返回 error_code → HTTP 状态。未知/校验类 → 409(与当前状态冲突);系统缺失 → 500。
|
|
5
|
+
const httpFor = (code) => code === 'ORDER_NOT_FOUND' ? 404
|
|
6
|
+
: code === 'NOT_A_PARTY' ? 403
|
|
7
|
+
: code === 'SYS_MISSING' || code === 'TRANSITION_FAILED' ? 500
|
|
8
|
+
: 409;
|
|
9
|
+
app.get('/api/orders/:id/mutual-cancel', (req, res) => {
|
|
10
|
+
const user = auth(req, res);
|
|
11
|
+
if (!user)
|
|
12
|
+
return;
|
|
13
|
+
const r = getMutualCancelState(db, req.params.id, user.id);
|
|
14
|
+
if (!r.ok)
|
|
15
|
+
return void errorRes(res, httpFor(r.error_code), r.error_code || 'MUTUAL_CANCEL_STATE_ERROR', r.error || '读取失败');
|
|
16
|
+
res.json({ success: true, proposal: r.proposal ?? null, can_propose: !!r.can_propose, can_accept: !!r.can_accept, can_decline: !!r.can_decline, can_withdraw: !!r.can_withdraw });
|
|
17
|
+
});
|
|
18
|
+
app.post('/api/orders/:id/mutual-cancel/propose', (req, res) => {
|
|
19
|
+
const user = auth(req, res);
|
|
20
|
+
if (!user)
|
|
21
|
+
return;
|
|
22
|
+
const reason = typeof req.body?.reason === 'string' ? req.body.reason : null;
|
|
23
|
+
const r = proposeMutualCancel(db, req.params.id, user.id, reason, generateId('mcp'));
|
|
24
|
+
if (!r.ok)
|
|
25
|
+
return void errorRes(res, httpFor(r.error_code), r.error_code || 'MUTUAL_CANCEL_PROPOSE_ERROR', r.error || '提议失败');
|
|
26
|
+
res.json({ success: true, proposal_id: r.proposal_id, status: r.status });
|
|
27
|
+
});
|
|
28
|
+
app.post('/api/orders/:id/mutual-cancel/accept', (req, res) => {
|
|
29
|
+
const user = auth(req, res);
|
|
30
|
+
if (!user)
|
|
31
|
+
return;
|
|
32
|
+
let r;
|
|
33
|
+
try {
|
|
34
|
+
// 资金 + 状态翻转 + 争议 resolved 必须同一原子边界(RFC-016 钱路铁律);域函数内已做竞态重校验。
|
|
35
|
+
r = db.transaction(() => acceptMutualCancel(db, req.params.id, user.id))();
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
return void errorRes(res, 500, 'MUTUAL_CANCEL_ACCEPT_FAILED', e.message);
|
|
39
|
+
}
|
|
40
|
+
if (!r.ok)
|
|
41
|
+
return void errorRes(res, httpFor(r.error_code), r.error_code || 'MUTUAL_CANCEL_ACCEPT_ERROR', r.error || '确认失败');
|
|
42
|
+
res.json({ success: true, status: r.status, settlement: r.settlement });
|
|
43
|
+
});
|
|
44
|
+
app.post('/api/orders/:id/mutual-cancel/decline', (req, res) => {
|
|
45
|
+
const user = auth(req, res);
|
|
46
|
+
if (!user)
|
|
47
|
+
return;
|
|
48
|
+
const r = declineMutualCancel(db, req.params.id, user.id);
|
|
49
|
+
if (!r.ok)
|
|
50
|
+
return void errorRes(res, httpFor(r.error_code), r.error_code || 'MUTUAL_CANCEL_DECLINE_ERROR', r.error || '拒绝失败');
|
|
51
|
+
res.json({ success: true, status: r.status });
|
|
52
|
+
});
|
|
53
|
+
app.post('/api/orders/:id/mutual-cancel/withdraw', (req, res) => {
|
|
54
|
+
const user = auth(req, res);
|
|
55
|
+
if (!user)
|
|
56
|
+
return;
|
|
57
|
+
const r = withdrawMutualCancel(db, req.params.id, user.id);
|
|
58
|
+
if (!r.ok)
|
|
59
|
+
return void errorRes(res, httpFor(r.error_code), r.error_code || 'MUTUAL_CANCEL_WITHDRAW_ERROR', r.error || '撤回失败');
|
|
60
|
+
res.json({ success: true, status: r.status });
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { dbOne, dbAll } from '../../layer0-foundation/L0-1-database/db.js';
|
|
1
|
+
import { dbOne, dbAll, dbRun } from '../../layer0-foundation/L0-1-database/db.js';
|
|
2
|
+
import { createNotification } from '../../layer2-business/L2-6-notifications/notification-engine.js';
|
|
2
3
|
import { releaseFeeStake } from '../../direct-pay-ledger.js'; // Rail1 直付:取消/超时释放任何遗留模拟质押(AR 订单无 stake → no-op)
|
|
4
|
+
import { restorePreShipDirectPayStock } from '../../direct-pay-stock.js'; // D3 库存回补唯一入口(pre-ship 放行;已出库拒绝)
|
|
3
5
|
import { requireBothDisclosuresAcked } from '../../direct-pay-disclosures.js'; // PR-4e: D1/D2 披露契约门
|
|
4
6
|
import { requireDirectPayHumanPasskey } from '../direct-pay-guards.js'; // PR-4e: 现场真人 Passkey/gate-token 门
|
|
5
7
|
export function registerOrdersActionRoutes(app, deps) {
|
|
@@ -103,15 +105,15 @@ export function registerOrdersActionRoutes(app, deps) {
|
|
|
103
105
|
// RFC-016: 校验读 → 异步 seam;下方 completed+history 写仍是同步 db.transaction(Phase 3 迁 pg 事务)
|
|
104
106
|
const order = await dbOne('SELECT * FROM orders WHERE id = ?', [req.params.id]);
|
|
105
107
|
if (!order)
|
|
106
|
-
return void res.status(404).json({ error: '订单不存在' });
|
|
108
|
+
return void res.status(404).json({ error: '订单不存在', error_code: 'ORDER_NOT_FOUND' });
|
|
107
109
|
if (order.fulfillment_mode !== 'in_person')
|
|
108
|
-
return void res.status(400).json({ error: '该订单非面交' });
|
|
110
|
+
return void res.status(400).json({ error: '该订单非面交', error_code: 'NOT_IN_PERSON' });
|
|
109
111
|
if (order.buyer_id !== user.id)
|
|
110
|
-
return void res.status(403).json({ error: '仅买家可确认面交完成' });
|
|
112
|
+
return void res.status(403).json({ error: '仅买家可确认面交完成', error_code: 'NOT_ORDER_BUYER' });
|
|
111
113
|
if (!['paid', 'accepted'].includes(order.status))
|
|
112
|
-
return void res.status(400).json({ error: `订单状态 ${order.status}
|
|
114
|
+
return void res.status(400).json({ error: `订单状态 ${order.status} 不可确认面交`, error_code: 'NOT_CONFIRMABLE_IN_PERSON' });
|
|
113
115
|
if (order.has_pending_claim)
|
|
114
|
-
return void res.status(400).json({ error: '存在进行中的验证任务,不可确认' });
|
|
116
|
+
return void res.status(400).json({ error: '存在进行中的验证任务,不可确认', error_code: 'HAS_PENDING_CLAIM' });
|
|
115
117
|
// Rail1:平台费已切换为链下应收(accrue 在完成结算时,与 completed 同一原子边界,fail-closed)。
|
|
116
118
|
// 建单不再锁 fee-stake,故【不再】前置要求 locked stake(AR 订单本就无 stake)。
|
|
117
119
|
const isDirectP2p = order.payment_rail === 'direct_p2p';
|
|
@@ -155,7 +157,8 @@ export function registerOrdersActionRoutes(app, deps) {
|
|
|
155
157
|
// P0 fix: 受信角色禁交易
|
|
156
158
|
if (isTrustedRole(user))
|
|
157
159
|
return void res.status(403).json({ error: '受信角色不可参与订单流转', error_code: 'TRUSTED_ROLE_NO_TRADE' });
|
|
158
|
-
const { action,
|
|
160
|
+
const { action, evidence_description = '', logistics_company_id = '' } = req.body;
|
|
161
|
+
let notes = String(req.body?.notes ?? '').slice(0, 500); // 服务端硬界:附言/备注最长 500 字符;mark_paid 时被服务端权威付款参考覆盖(见下)
|
|
159
162
|
// RFC-016: 顶层校验读 → 异步 seam;state-machine / settle / decline 写序列仍同步(Phase 3 迁 pg 行锁+事务)
|
|
160
163
|
const order = await dbOne('SELECT * FROM orders WHERE id = ?', [req.params.id]);
|
|
161
164
|
if (!order)
|
|
@@ -177,15 +180,81 @@ export function registerOrdersActionRoutes(app, deps) {
|
|
|
177
180
|
if (uid !== buyerId) {
|
|
178
181
|
return void res.status(403).json({ error: '你不是本订单的买家', error_code: 'NOT_ORDER_BUYER' });
|
|
179
182
|
}
|
|
180
|
-
|
|
181
|
-
|
|
183
|
+
// 买家取消:付款窗口内(未付/后悔)/ 货款协商阶段(承认未付)/ 付款窗过期宽限期(确认未付,免等 48h 自动关单 ——
|
|
184
|
+
// 审计项 H:状态机本就允许 buyer direct_expired_unconfirmed→cancelled,此 guard 曾与状态机矛盾=死能力)。mark_paid 仍仅付款窗口。
|
|
185
|
+
const cancelInQuery = action === 'cancel' && ['payment_query', 'direct_expired_unconfirmed'].includes(order.status);
|
|
186
|
+
if (order.payment_rail !== 'direct_p2p' || (order.status !== 'direct_pay_window' && !cancelInQuery)) {
|
|
187
|
+
return void res.status(409).json({ error: '该操作仅适用于直付订单的付款窗口/过期宽限/协商阶段', error_code: 'NOT_DIRECT_PAY_WINDOW' });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// ── Rail1 直付货款协商(争议≠仲裁)动作。均仅 direct_p2p + 角色/状态门。PR-B1 人驱动协商 + PR-B2 卖家宽限后请求取消。 ──
|
|
191
|
+
const PQ_ACTIONS = ['report_nonpayment', 'confirm_received', 'pq_escalate', 'pq_withdraw', 'request_cancel'];
|
|
192
|
+
if (PQ_ACTIONS.includes(action)) {
|
|
193
|
+
if (order.payment_rail !== 'direct_p2p')
|
|
194
|
+
return void res.status(409).json({ error: '该操作仅适用于直付订单', error_code: 'NOT_DIRECT_PAY' });
|
|
195
|
+
if ((action === 'report_nonpayment' || action === 'confirm_received' || action === 'request_cancel') && uid !== sellerId)
|
|
196
|
+
return void res.status(403).json({ error: '你不是本订单的卖家', error_code: 'NOT_ORDER_SELLER' });
|
|
197
|
+
if ((action === 'pq_escalate' || action === 'pq_withdraw') && uid !== buyerId && uid !== sellerId)
|
|
198
|
+
return void res.status(403).json({ error: '只有买卖双方可操作', error_code: 'NOT_ORDER_PARTY' });
|
|
199
|
+
if (action === 'report_nonpayment' && order.status !== 'accepted')
|
|
200
|
+
return void res.status(409).json({ error: `仅可在【待发货(accepted)】阶段报告未收款,当前 ${order.status}`, error_code: 'NOT_ACCEPTED' });
|
|
201
|
+
if ((action === 'confirm_received' || action === 'pq_escalate' || action === 'request_cancel') && order.status !== 'payment_query')
|
|
202
|
+
return void res.status(409).json({ error: `仅可在【货款协商(payment_query)】阶段操作,当前 ${order.status}`, error_code: 'NOT_PAYMENT_QUERY' });
|
|
203
|
+
if (action === 'pq_withdraw') {
|
|
204
|
+
if (order.status !== 'disputed')
|
|
205
|
+
return void res.status(409).json({ error: `仅可在【争议(disputed)】阶段撤回,当前 ${order.status}`, error_code: 'NOT_DISPUTED' });
|
|
206
|
+
// 已裁定的争议不可撤回(disputed 已终结成 resolved/dismissed 前,dispute.status 仍 open/in_review)
|
|
207
|
+
const d = await dbOne("SELECT status FROM disputes WHERE order_id = ? ORDER BY created_at DESC LIMIT 1", [req.params.id]);
|
|
208
|
+
if (d && (d.status === 'resolved' || d.status === 'dismissed'))
|
|
209
|
+
return void res.status(409).json({ error: '争议已裁定,不可撤回', error_code: 'DISPUTE_ALREADY_RULED' });
|
|
210
|
+
// ★ 撤回【仅限货款协商升级】的仲裁:最近一次进入 disputed 必须 from payment_query。
|
|
211
|
+
// 否则履约类争议(delivered→disputed 货损/货不对版)会被错误撤回、dismiss、状态倒退回 payment_query。fail-closed。
|
|
212
|
+
const lastDisputed = await dbOne("SELECT from_status FROM order_state_history WHERE order_id = ? AND to_status = 'disputed' ORDER BY created_at DESC, rowid DESC LIMIT 1", [req.params.id]);
|
|
213
|
+
if (!lastDisputed || lastDisputed.from_status !== 'payment_query')
|
|
214
|
+
return void res.status(409).json({ error: '仅可撤回由货款协商升级的仲裁;履约类争议(货损/货不对版)须经仲裁裁定', error_code: 'NOT_PAYMENT_QUERY_DISPUTE' });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// PR-B2:卖家在【买家响应宽限已过】后请求取消 —— 不立即关单,而是开【N 天买家申诉窗】(payment_query_cancel_deadline)。
|
|
218
|
+
// 窗内买家仍可 confirm-side/主张已付/升级举证(pq_escalate);窗满由 cron 关单。不动状态,不涉资金。幂等(deadline 已设则不重置)。
|
|
219
|
+
if (action === 'request_cancel') {
|
|
220
|
+
const g = await dbOne("SELECT (payment_query_deadline IS NOT NULL AND datetime(payment_query_deadline) < datetime('now')) AS over FROM orders WHERE id = ?", [req.params.id]);
|
|
221
|
+
if (!g?.over)
|
|
222
|
+
return void res.status(409).json({ error: '买家响应宽限未到,暂不可请求取消', error_code: 'GRACE_NOT_ELAPSED' });
|
|
223
|
+
let rd = 7;
|
|
224
|
+
try {
|
|
225
|
+
const p = await dbOne("SELECT value FROM protocol_params WHERE key = 'direct_pay.payment_query_recourse_days'");
|
|
226
|
+
if (p)
|
|
227
|
+
rd = Math.max(1, Number(p.value) || 7);
|
|
228
|
+
}
|
|
229
|
+
catch { }
|
|
230
|
+
await dbRun(`UPDATE orders SET payment_query_cancel_deadline = datetime('now', ? ) WHERE id = ? AND payment_query_cancel_deadline IS NULL`, [`+${rd} days`, req.params.id]);
|
|
231
|
+
const dl = (await dbOne("SELECT payment_query_cancel_deadline d FROM orders WHERE id = ?", [req.params.id]))?.d;
|
|
232
|
+
try {
|
|
233
|
+
createNotification(db, order.buyer_id, req.params.id, 'payment_query_cancel_requested', '⏳ 卖家申请取消订单', `卖家称未收到货款且你未回应,已申请取消。你约有 ${rd} 天:若确已付款请提供付款参考或发起举证(pq_escalate),否则订单将于 ${dl} 自动取消。直付非托管,无平台退款。`);
|
|
182
234
|
}
|
|
235
|
+
catch { }
|
|
236
|
+
return void res.json({ success: true, status: 'payment_query', cancel_recourse_deadline: dl });
|
|
183
237
|
}
|
|
184
238
|
// PR-4e:mark_paid = direct_p2p RISK 动作(已过 direct_pay_window 只读预检)→ 两次披露门 + 现场真人 Passkey 门。cancel 不门控。
|
|
185
239
|
if (action === 'mark_paid') {
|
|
186
240
|
const g = directPayActionGate(req.params.id, 'mark_paid', uid, req.body?.webauthn_token);
|
|
187
241
|
if (!g.ok)
|
|
188
242
|
return void res.status(g.status).json({ error: g.error, error_code: g.error_code });
|
|
243
|
+
// 防作弊 D1:付款参考【服务端权威派生】(与前端 dpPayRef 同算法),忽略客户端可伪造的参考文本 —— UI 已锁只读(#222),
|
|
244
|
+
// 这里把 API 级也收口:直接 POST 伪造 notes 冒充别单参考号 → 无效。客户端附言仅作补充备注,剥掉任何"付款参考"字样。
|
|
245
|
+
const canonicalRef = 'WAZ-' + req.params.id.replace(/[^a-zA-Z0-9]/g, '').slice(-8).toUpperCase();
|
|
246
|
+
const extra = notes.replace(/付款参考[::]?\s*[^\s·;,。]*/g, '').replace(/WAZ-[A-Z0-9]{8}/g, '').trim().slice(0, 120);
|
|
247
|
+
// 防作弊 D2:同买家·同卖家·同金额的其它在途直付单 → 时间线预警(一笔真实转账冒充两单的重复确认,卖家第一道防线)
|
|
248
|
+
const dup = await dbOne(`SELECT COUNT(*) n FROM orders WHERE buyer_id = ? AND seller_id = ? AND payment_rail = 'direct_p2p' AND total_amount = ? AND id != ? AND status IN ('accepted','shipped','picked_up','in_transit','delivered')`, [buyerId, sellerId, order.total_amount, req.params.id]);
|
|
249
|
+
// 审计项 E:时间线带上应付金额(下单冻结的参考换算)—— 卖家对账三要素:参考号 + 金额 + 币种,与买家所见同一数字
|
|
250
|
+
let payable = `应付 ${order.total_amount} USDC`;
|
|
251
|
+
try {
|
|
252
|
+
const ps = JSON.parse(order.direct_pay_account_snapshot || '{}');
|
|
253
|
+
if (Number.isFinite(Number(ps?.payable_approx)) && ps?.payable_currency)
|
|
254
|
+
payable += `(≈${ps.payable_currency} ${Number(ps.payable_approx).toFixed(2)},下单时参考价)`;
|
|
255
|
+
}
|
|
256
|
+
catch { /* 快照缺失/坏 → 仅 USDC */ }
|
|
257
|
+
notes = `付款参考: ${canonicalRef} · ${payable}${extra ? ' · ' + extra : ''}${(dup?.n || 0) > 0 ? ` ⚠️ 同买家另有 ${dup.n} 笔同金额直付订单在途,请逐单核对付款参考再发货` : ''}`;
|
|
189
258
|
}
|
|
190
259
|
// Rail1:平台费链下应收,accrue 在完成结算时(settleOrder direct_p2p 分支)与 completed 同一原子边界、fail-closed
|
|
191
260
|
// (accrueFeeReceivable 缺费即抛 → 回滚 completed)。故【不再】前置要求 locked fee-stake。
|
|
@@ -324,6 +393,8 @@ export function registerOrdersActionRoutes(app, deps) {
|
|
|
324
393
|
if (!r.success)
|
|
325
394
|
throw new Error(r.error || '状态转移失败');
|
|
326
395
|
releaseFeeStake(db, { orderId: req.params.id });
|
|
396
|
+
// D3 库存回补:经唯一守卫入口(仅 pre-ship 来源放行;已出库走退货验收,绝不直接回补 —— 见 direct-pay-stock.ts 情形矩阵)
|
|
397
|
+
restorePreShipDirectPayStock(db, { fromStatus: fromStatusCancel, productId: order.product_id, quantity: Number(order.quantity) || 1 });
|
|
327
398
|
})();
|
|
328
399
|
}
|
|
329
400
|
catch (e) {
|
|
@@ -332,10 +403,65 @@ export function registerOrdersActionRoutes(app, deps) {
|
|
|
332
403
|
notifyTransition(db, req.params.id, fromStatusCancel, 'cancelled');
|
|
333
404
|
return void res.json({ success: true, status: 'cancelled', fee_stake_released: true });
|
|
334
405
|
}
|
|
406
|
+
// 争议协商收口(买家侧):买家撤诉并确认收货 —— 限【delivered 来源履约争议 + 争议发起人本人 + 裁定前】。
|
|
407
|
+
// 为什么存在:买家发起"未收到货"争议后包裹晚到/找到代收点,双方合意收口不该只剩仲裁(卖家侧收口=协商取消)。
|
|
408
|
+
// 货款争议(payment_query 来源)不走这里 —— 那是 pq_withdraw 的撤回语义(回协商,不确认收货)。
|
|
409
|
+
// 全原子(Codex P1 模式):争议 dismissed + disputed→confirmed→completed + settleOrder 同一 db.transaction,
|
|
410
|
+
// 任一步失败整体回滚(订单仍 disputed、争议仍 open,可重试);两轨同块(escrow 释放托管货款,dp 计提平台费)。
|
|
411
|
+
if (action === 'dispute_withdraw_confirm') {
|
|
412
|
+
if (uid !== buyerId)
|
|
413
|
+
return void res.status(403).json({ error: '你不是本订单的买家', error_code: 'NOT_ORDER_BUYER' });
|
|
414
|
+
if (order.status !== 'disputed')
|
|
415
|
+
return void res.status(409).json({ error: `订单状态 ${order.status} 不可撤诉确认收货(仅争议中)`, error_code: 'ORDER_NOT_DISPUTED' });
|
|
416
|
+
// RFC-016:两条纯预检读 → 异步 seam;事务内的 disputes UPDATE + transition + settle 写序列仍同步(原子性)
|
|
417
|
+
const dsp = await dbOne("SELECT id, initiator_id FROM disputes WHERE order_id = ? AND status IN ('open','in_review') ORDER BY created_at DESC, rowid DESC LIMIT 1", [req.params.id]);
|
|
418
|
+
if (!dsp)
|
|
419
|
+
return void res.status(409).json({ error: '争议已裁定或不存在,不可撤诉', error_code: 'DISPUTE_ALREADY_RULED' });
|
|
420
|
+
if (dsp.initiator_id !== uid)
|
|
421
|
+
return void res.status(403).json({ error: '仅争议发起人可撤诉', error_code: 'NOT_DISPUTE_INITIATOR' });
|
|
422
|
+
const lastDisputedFrom = await dbOne("SELECT from_status FROM order_state_history WHERE order_id = ? AND to_status = 'disputed' ORDER BY created_at DESC, rowid DESC LIMIT 1", [req.params.id]);
|
|
423
|
+
if (lastDisputedFrom?.from_status !== 'delivered') {
|
|
424
|
+
return void res.status(409).json({ error: '仅"已投递后未收到货/货有问题"类争议可撤诉确认收货;货款争议请用"撤回仲裁·回到协商"', error_code: 'NOT_FULFILMENT_DISPUTE' });
|
|
425
|
+
}
|
|
426
|
+
// dp = RISK 动作(确认收货即完成结算、计提平台费):所有只读预检过后才消费 Passkey token(与 confirm 同门)
|
|
427
|
+
if (order.payment_rail === 'direct_p2p') {
|
|
428
|
+
const g = directPayActionGate(req.params.id, 'dispute_withdraw_confirm', uid, req.body?.webauthn_token);
|
|
429
|
+
if (!g.ok)
|
|
430
|
+
return void res.status(g.status).json({ error: g.error, error_code: g.error_code });
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
433
|
+
db.transaction(() => {
|
|
434
|
+
db.prepare("UPDATE disputes SET status = 'dismissed', verdict_reason = ?, resolved_at = datetime('now') WHERE id = ? AND status IN ('open','in_review')")
|
|
435
|
+
.run(notes ? `买家撤诉并确认收货:${notes}` : '买家撤诉并确认收货', dsp.id);
|
|
436
|
+
const r1 = transition(db, req.params.id, 'confirmed', uid, [], notes);
|
|
437
|
+
if (!r1.success)
|
|
438
|
+
throw new Error(r1.error || 'confirmed transition failed');
|
|
439
|
+
const r2 = transition(db, req.params.id, 'completed', 'sys_protocol', [], '系统自动结算');
|
|
440
|
+
if (!r2.success)
|
|
441
|
+
throw new Error(r2.error || 'completed transition failed');
|
|
442
|
+
settleOrder(req.params.id);
|
|
443
|
+
})();
|
|
444
|
+
}
|
|
445
|
+
catch (e) {
|
|
446
|
+
return void res.status(409).json({ error: `撤诉确认收货失败,订单未变更(仍在争议中,可重试):${e.message}`, error_code: 'DISPUTE_CLOSE_SETTLE_FAILED' });
|
|
447
|
+
}
|
|
448
|
+
notifyTransition(db, req.params.id, 'confirmed', 'completed');
|
|
449
|
+
try {
|
|
450
|
+
createNotification(db, sellerId, req.params.id, 'dispute_withdrawn_confirmed', '✅ 买家已撤诉并确认收货', '买家撤回争议并确认收货,订单已完成结算,双方信誉不受影响。', { templateKey: 'dispute_withdrawn_confirmed', params: {} });
|
|
451
|
+
}
|
|
452
|
+
catch { /* 通知失败不阻断 */ }
|
|
453
|
+
try {
|
|
454
|
+
broadcastSystemEvent('order_completed', '✓', `订单完成 ${req.params.id}`, req.params.id);
|
|
455
|
+
}
|
|
456
|
+
catch { }
|
|
457
|
+
return void res.json({ success: true, status: 'completed', dispute_dismissed: true });
|
|
458
|
+
}
|
|
335
459
|
const actionMap = {
|
|
336
460
|
accept: 'accepted', ship: 'shipped', pickup: 'picked_up',
|
|
337
461
|
transit: 'in_transit', deliver: 'delivered', confirm: 'confirmed', dispute: 'disputed',
|
|
338
462
|
mark_paid: 'accepted', // Rail1 直付:买家声明"我已付款" → accepted(汇入既有卖家发货流程;协议不验真实付款)
|
|
463
|
+
// Rail1 货款协商:卖家报未收款→协商;卖家确认已收→恢复;升级→举证仲裁;撤回→回协商(pq_withdraw 由上方原子块 early-return 处理,列此仅为通过 !toStatus 校验)。
|
|
464
|
+
report_nonpayment: 'payment_query', confirm_received: 'accepted', pq_escalate: 'disputed', pq_withdraw: 'payment_query',
|
|
339
465
|
};
|
|
340
466
|
const toStatus = actionMap[action];
|
|
341
467
|
if (!toStatus)
|
|
@@ -375,11 +501,58 @@ export function registerOrdersActionRoutes(app, deps) {
|
|
|
375
501
|
catch { }
|
|
376
502
|
return void res.json({ success: true, status: 'completed', settlement: { rail: 'direct_p2p', fee_accrued: true } });
|
|
377
503
|
}
|
|
504
|
+
// Rail1 撤回仲裁:disputed→payment_query + 【同一事务】关闭活跃 dispute(否则订单回协商但仲裁视图仍见活跃争议 → 脏状态,
|
|
505
|
+
// 仲裁员可能对已回协商的单裁定)。原子后重建买家响应宽限,让 PR-B2 的宽限/超时 cron 有截止时间可依赖。
|
|
506
|
+
if (action === 'pq_withdraw') {
|
|
507
|
+
try {
|
|
508
|
+
db.transaction(() => {
|
|
509
|
+
const r = transition(db, req.params.id, 'payment_query', user.id, [], notes);
|
|
510
|
+
if (!r.success)
|
|
511
|
+
throw new Error(r.error || 'withdraw transition failed');
|
|
512
|
+
db.prepare("UPDATE disputes SET status = 'dismissed', verdict_reason = COALESCE(verdict_reason, '撤回仲裁,回到协商'), resolved_at = datetime('now') WHERE order_id = ? AND status IN ('open', 'in_review')").run(req.params.id);
|
|
513
|
+
})();
|
|
514
|
+
}
|
|
515
|
+
catch (e) {
|
|
516
|
+
return void res.status(409).json({ error: `撤回仲裁失败:${e.message}`, error_code: 'WITHDRAW_FAILED' });
|
|
517
|
+
}
|
|
518
|
+
let gh = 72;
|
|
519
|
+
try {
|
|
520
|
+
const p = await dbOne("SELECT value FROM protocol_params WHERE key = 'direct_pay.payment_query_grace_hours'");
|
|
521
|
+
if (p)
|
|
522
|
+
gh = Math.max(1, Number(p.value) || 72);
|
|
523
|
+
}
|
|
524
|
+
catch { }
|
|
525
|
+
await dbRun(`UPDATE orders SET payment_query_deadline = datetime('now', ? ), payment_query_cancel_deadline = NULL WHERE id = ?`, [`+${gh} hours`, req.params.id]);
|
|
526
|
+
notifyTransition(db, req.params.id, 'disputed', 'payment_query');
|
|
527
|
+
return void res.json({ success: true, status: 'payment_query', dispute_withdrawn: true });
|
|
528
|
+
}
|
|
378
529
|
const fromStatus = order.status;
|
|
379
530
|
const result = transition(db, req.params.id, toStatus, user.id, evidenceIds, notes);
|
|
380
531
|
if (!result.success)
|
|
381
532
|
return void res.json({ error: result.error });
|
|
382
533
|
notifyTransition(db, req.params.id, fromStatus, toStatus);
|
|
534
|
+
// 审计项 B(N2):买家标记付款 → 通知卖家核款发货(direct_pay_window→accepted 不在 notifyTransition RULES,
|
|
535
|
+
// 此前卖家全程收不到"已付款"信号只能手动刷)。notes 即 D1/D2/E 的权威对账串(参考号+应付+多单预警),直接复用。
|
|
536
|
+
if (action === 'mark_paid') {
|
|
537
|
+
try {
|
|
538
|
+
createNotification(db, sellerId, req.params.id, 'direct_pay_marked_paid', '💰 买家已标记付款,请核对后发货', `${notes}。请核对银行/收款App流水后再发货;未收到请点"未收到货款"。`, { templateKey: 'dp_marked_paid', params: { detail: notes } });
|
|
539
|
+
}
|
|
540
|
+
catch { /* 通知失败不阻断 */ }
|
|
541
|
+
}
|
|
542
|
+
// Rail1 货款协商:卖家报未收款 → 设买家响应宽限(PR-B2 卖家据此才可请求取消);确认已收/升级 → 清协商截止。
|
|
543
|
+
if (action === 'report_nonpayment') {
|
|
544
|
+
let gh = 72;
|
|
545
|
+
try {
|
|
546
|
+
const p = await dbOne("SELECT value FROM protocol_params WHERE key = 'direct_pay.payment_query_grace_hours'");
|
|
547
|
+
if (p)
|
|
548
|
+
gh = Math.max(1, Number(p.value) || 72);
|
|
549
|
+
}
|
|
550
|
+
catch { }
|
|
551
|
+
await dbRun(`UPDATE orders SET payment_query_deadline = datetime('now', ? ) WHERE id = ?`, [`+${gh} hours`, req.params.id]);
|
|
552
|
+
}
|
|
553
|
+
if (action === 'confirm_received' || action === 'pq_escalate') {
|
|
554
|
+
await dbRun(`UPDATE orders SET payment_query_deadline = NULL, payment_query_cancel_deadline = NULL WHERE id = ?`, [req.params.id]);
|
|
555
|
+
}
|
|
383
556
|
if (toStatus === 'disputed') {
|
|
384
557
|
createDispute(db, req.params.id, user.id, notes || evidence_description || '买家发起争议', evidenceIds);
|
|
385
558
|
try {
|
|
@@ -3,6 +3,9 @@ import { buildCartMandate, buildPaymentMandate, signMandate } from './ap2-mandat
|
|
|
3
3
|
import { toUnits, toDecimal, mulQty, mulRate } from '../../money.js';
|
|
4
4
|
import { createDirectPayResponse } from '../../direct-pay-create.js'; // PR-4c: direct_p2p 建单分叉(生产门+收款指令门+原子建单;本金不入协议)
|
|
5
5
|
import { applyWalletDelta } from '../../ledger.js';
|
|
6
|
+
import { gateShippingForCreate } from '../../shipping-templates.js';
|
|
7
|
+
import { buildTradeTermsSnapshot, writeTradeTermsSnapshot } from '../../trade-terms.js';
|
|
8
|
+
import { gateSaleRegionForCreate } from '../../sale-regions.js'; // PR-2 运费守门 + S0 条款快照 + S1 可售门(意愿/合规先于物流)
|
|
6
9
|
import { dbOne } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam(仅下单事务外的预检查;事务内 escrow/INSERT 保持同步)
|
|
7
10
|
// 店铺推荐 → 商品三级归因的【懒升级】(sync,跑在下单事务内、getProductShareChain 之前)。
|
|
8
11
|
// 严格门槛(任一不满足则不升级,绝不覆盖已有有效直接归因):
|
|
@@ -229,7 +232,12 @@ export function registerOrdersCreateRoutes(app, deps) {
|
|
|
229
232
|
const priceAfterCouponU = Math.max(0, subtotalU - toUnits(couponDiscount));
|
|
230
233
|
const insuranceRate = getProtocolParam('order_insurance_rate', 0.01);
|
|
231
234
|
const insurancePremiumU = buy_insurance ? mulRate(priceAfterCouponU, insuranceRate) : 0;
|
|
232
|
-
|
|
235
|
+
if (!gateSaleRegionForCreate(db, res, product, String(product.seller_uid), req.body?.ship_to_region, getProtocolParam))
|
|
236
|
+
return;
|
|
237
|
+
const _ship = gateShippingForCreate(db, res, product, String(product.seller_uid), req.body?.ship_to_region, String(req.body?.payment_rail || '') === 'direct_p2p' ? 'direct_p2p' : 'escrow', priceAfterCouponU);
|
|
238
|
+
if (!_ship)
|
|
239
|
+
return; // PR-2 运费模板:有模板必选地区且须命中(400/409 已写);无模板=原行为
|
|
240
|
+
const totalAmountU = Math.max(0, priceAfterCouponU + insurancePremiumU + _ship.feeU); // 运费并入总额(与保险费同惯例;快照 orders.shipping_fee)
|
|
233
241
|
// B5 主动捐赠 — 按订单总额 × 比例算(额外扣款,进 charity_fund)
|
|
234
242
|
const donationAmountU = donationPctNum > 0 ? mulRate(totalAmountU, donationPctNum) : 0;
|
|
235
243
|
// decimal 视图(落库 / 下游 AP2 / 响应,均为 base-unit 干净值)
|
|
@@ -237,9 +245,10 @@ export function registerOrdersCreateRoutes(app, deps) {
|
|
|
237
245
|
const insurancePremium = toDecimal(insurancePremiumU);
|
|
238
246
|
const totalAmount = toDecimal(totalAmountU);
|
|
239
247
|
const donationAmount = toDecimal(donationAmountU);
|
|
248
|
+
const shippingFee = toDecimal(_ship.feeU);
|
|
240
249
|
// PR-4c:direct_p2p 分叉 —— 本金不入协议,跳过下方 escrow 预检/事务,改走直付建单(生产门+收款指令门+原子建单,仅锁卖家 fee-stake)。
|
|
241
250
|
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 } });
|
|
251
|
+
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 }, shipping: { region: _ship.region, fee: _ship.fee, estDays: _ship.estDays, quoteRequired: _ship.quoteRequired, freeThresholdApplied: _ship.freeThresholdApplied } });
|
|
243
252
|
// 友好预检查(读):真正的守恒在下面的同步事务内(applyWalletDelta 绝对值落库)。
|
|
244
253
|
const wallet = await dbOne('SELECT balance FROM wallets WHERE user_id = ?', [user.id]);
|
|
245
254
|
if (!wallet)
|
|
@@ -249,6 +258,7 @@ export function registerOrdersCreateRoutes(app, deps) {
|
|
|
249
258
|
const now = new Date();
|
|
250
259
|
const orderId = generateId('ord');
|
|
251
260
|
let autoAccepted = false;
|
|
261
|
+
const _acceptModeAuto = ((product.accept_mode ?? (await dbOne('SELECT store_accept_mode FROM users WHERE id = ?', [product.seller_uid]))?.store_accept_mode) === 'auto'); // 接单模式 v16:单品??店铺默认;'auto'→paid 后系统自动接单;escrow manual/未设=原流程(托管中间态+24h 超时退款)零钱路改动;tx 外 seam 读
|
|
252
262
|
// P0: 整个下单流程原子化 — INSERT order + UPDATE wallet + UPDATE products + transition 任一步抛错全部回滚
|
|
253
263
|
try {
|
|
254
264
|
db.transaction(() => {
|
|
@@ -304,8 +314,9 @@ export function registerOrdersCreateRoutes(app, deps) {
|
|
|
304
314
|
l1_uid, l2_uid, l3_uid, snapshot_commission_rate, buyer_region, content_hash_at_order,
|
|
305
315
|
delivery_window, variant_id, variant_options_snapshot,
|
|
306
316
|
gift_recipient_name, gift_recipient_phone, gift_message, insurance_premium,
|
|
307
|
-
anonymous_recipient, recipient_code, donation_amount, stake_backing
|
|
308
|
-
) VALUES (?,?,?,?,?,?,?,?,'created'
|
|
317
|
+
anonymous_recipient, recipient_code, donation_amount, stake_backing, ship_to_region, shipping_fee, shipping_est_days
|
|
318
|
+
) VALUES (?,?,?,?,?,?,?,?,'created',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(orderId, product.id, user.id, product.seller_uid, reqQty, basePrice, totalAmount, totalAmount, shipping_address, notes || null, addHours(now, 24), addHours(now, 48), addHours(now, 120), addHours(now, 168), addHours(now, 336), addHours(now, 408), l1, l2, l3, snapshotRate, buyerRegionSnapshot, contentHashSnapshot, deliveryWindowJson, variant ? variant.id : null, variant ? variant.options_json : null, giftRecipientName, giftRecipientPhone, giftMessage, insurancePremium, anonymousFlag, recipientCode, donationAmount, stakeBacking, _ship.region, _ship.feeU > 0 || _ship.region ? shippingFee : null, _ship.estDays);
|
|
319
|
+
writeTradeTermsSnapshot(db, orderId, buildTradeTermsSnapshot(db, { productId: String(product.id), sellerId: String(product.seller_uid), shipping: { source: _ship.region ? 'template' : 'none', region: _ship.region ?? null, fee: _ship.region ? shippingFee : null, estDays: _ship.estDays ?? null, freeThresholdApplied: _ship.freeThresholdApplied }, acceptModeEffective: _acceptModeAuto ? 'auto' : null })); // S0 条款快照:冻结下单时效/退货/清关/税责声明(fail-soft,非计价输入)
|
|
309
320
|
// 协议层:写 genesis 事件 — order 创建(必然是 buyer 自己)
|
|
310
321
|
try {
|
|
311
322
|
appendOrderEvent(db, {
|
|
@@ -382,11 +393,11 @@ export function registerOrdersCreateRoutes(app, deps) {
|
|
|
382
393
|
catch (e) {
|
|
383
394
|
console.error('[M3-C audit]', e);
|
|
384
395
|
}
|
|
385
|
-
//
|
|
386
|
-
if (shouldAutoAccept(db, orderId)) {
|
|
396
|
+
// 自动接单:卖家接单模式 'auto'(v16)或 auto_accept Skill。
|
|
397
|
+
if (_acceptModeAuto || shouldAutoAccept(db, orderId)) {
|
|
387
398
|
const sysUser = db.prepare("SELECT id FROM users WHERE id = 'sys_protocol'").get();
|
|
388
399
|
if (sysUser) {
|
|
389
|
-
const ar = transition(db, orderId, 'accepted', sysUser.id, [], '⚡ auto_accept Skill 自动接单');
|
|
400
|
+
const ar = transition(db, orderId, 'accepted', sysUser.id, [], _acceptModeAuto ? '⚡ 自动接单(卖家接单模式设置)' : '⚡ auto_accept Skill 自动接单');
|
|
390
401
|
if (ar.success) {
|
|
391
402
|
notifyTransition(db, orderId, 'paid', 'accepted');
|
|
392
403
|
autoAccepted = true;
|