@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.
Files changed (126) hide show
  1. package/dist/bond-refund-blockers.js +50 -0
  2. package/dist/bond-slash.js +100 -0
  3. package/dist/bond-terms.js +31 -0
  4. package/dist/direct-pay-bond-rail-clearance.js +10 -5
  5. package/dist/direct-pay-cancel-refund.js +160 -0
  6. package/dist/direct-pay-create.js +78 -9
  7. package/dist/direct-pay-disclosures.js +17 -11
  8. package/dist/direct-pay-fee-ar.js +2 -2
  9. package/dist/direct-pay-fee-prepay-request.js +80 -0
  10. package/dist/direct-pay-returns.js +104 -0
  11. package/dist/direct-pay-stock.js +9 -0
  12. package/dist/direct-receive-deferral.js +30 -1
  13. package/dist/direct-receive-deposits.js +122 -17
  14. package/dist/free-shipping.js +37 -0
  15. package/dist/fx-rates.js +7 -0
  16. package/dist/layer0-foundation/L0-1-database/schema.js +246 -0
  17. package/dist/layer0-foundation/L0-2-state-machine/engine.js +1 -0
  18. package/dist/layer0-foundation/L0-2-state-machine/transitions.js +73 -0
  19. package/dist/layer1-agent/L1-1-mcp-server/server.js +81 -16
  20. package/dist/layer2-business/L2-6-notifications/notification-engine.js +110 -41
  21. package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +133 -31
  22. package/dist/layer3-trust/L3-1-dispute-engine/evidence-storage.js +16 -4
  23. package/dist/layer3-trust/L3-1-dispute-engine/mutual-cancel.js +156 -0
  24. package/dist/platform-receive-accounts.js +94 -0
  25. package/dist/pwa/arbitration-read-admin.js +37 -0
  26. package/dist/pwa/arbitrator-lifecycle.js +100 -0
  27. package/dist/pwa/contract-fingerprint.js +15 -0
  28. package/dist/pwa/direct-pay-order-redaction.js +20 -2
  29. package/dist/pwa/human-presence.js +2 -6
  30. package/dist/pwa/public/app-account.js +9 -9
  31. package/dist/pwa/public/app-admin-disputes.js +55 -0
  32. package/dist/pwa/public/app-agent-appeal.js +90 -0
  33. package/dist/pwa/public/app-agent-approvals.js +93 -0
  34. package/dist/pwa/public/app-agent-pair.js +127 -0
  35. package/dist/pwa/public/app-arbitrator-admin.js +87 -0
  36. package/dist/pwa/public/app-arbitrator-entry.js +9 -0
  37. package/dist/pwa/public/app-bond-deferral-ui.js +9 -0
  38. package/dist/pwa/public/app-bond-refund-ui.js +66 -0
  39. package/dist/pwa/public/app-bond-slash-ui.js +74 -0
  40. package/dist/pwa/public/app-bond-terms-ui.js +23 -0
  41. package/dist/pwa/public/app-bond-ui.js +108 -0
  42. package/dist/pwa/public/app-chat-poll.js +6 -6
  43. package/dist/pwa/public/app-contribution-hub.js +23 -0
  44. package/dist/pwa/public/app-direct-pay-accounts.js +1 -1
  45. package/dist/pwa/public/app-direct-pay-buyer.js +1 -1
  46. package/dist/pwa/public/app-direct-pay-cancel-refund.js +72 -0
  47. package/dist/pwa/public/app-direct-pay-copy.js +12 -0
  48. package/dist/pwa/public/app-direct-pay-fee-history.js +39 -0
  49. package/dist/pwa/public/app-direct-pay-fee-ops.js +1 -1
  50. package/dist/pwa/public/app-direct-pay-fee-request.js +81 -0
  51. package/dist/pwa/public/app-direct-pay-fee-requests-admin.js +70 -0
  52. package/dist/pwa/public/app-direct-pay-memo.js +14 -0
  53. package/dist/pwa/public/app-direct-pay-negotiation.js +35 -0
  54. package/dist/pwa/public/app-direct-pay-pay.js +15 -0
  55. package/dist/pwa/public/app-direct-pay-paymodal.js +32 -0
  56. package/dist/pwa/public/app-direct-pay-reconcile.js +19 -0
  57. package/dist/pwa/public/app-direct-pay-returns.js +56 -0
  58. package/dist/pwa/public/app-direct-pay-reveal.js +82 -0
  59. package/dist/pwa/public/app-direct-pay-sales-report.js +70 -0
  60. package/dist/pwa/public/app-direct-pay.js +36 -37
  61. package/dist/pwa/public/app-dispute-close-ui.js +38 -0
  62. package/dist/pwa/public/app-free-shipping-ui.js +29 -0
  63. package/dist/pwa/public/app-gmv-rail-split.js +12 -0
  64. package/dist/pwa/public/app-listing-commerce-ui.js +72 -0
  65. package/dist/pwa/public/app-mutual-cancel.js +54 -0
  66. package/dist/pwa/public/app-notif-templates-orders.js +43 -0
  67. package/dist/pwa/public/app-notif-templates.js +22 -0
  68. package/dist/pwa/public/app-order-accept-ui.js +158 -0
  69. package/dist/pwa/public/app-order-errors.js +50 -0
  70. package/dist/pwa/public/app-order-labels.js +19 -0
  71. package/dist/pwa/public/app-order-rail-filter.js +26 -0
  72. package/dist/pwa/public/app-platform-receive-accounts.js +140 -0
  73. package/dist/pwa/public/app-poll-governor.js +22 -0
  74. package/dist/pwa/public/app-profile.js +4 -4
  75. package/dist/pwa/public/app-purchase-terms-ui.js +68 -0
  76. package/dist/pwa/public/app-sale-regions-ui.js +38 -0
  77. package/dist/pwa/public/app-seller.js +1 -1
  78. package/dist/pwa/public/app-trade-tax-ui.js +33 -0
  79. package/dist/pwa/public/app.js +133 -160
  80. package/dist/pwa/public/i18n.js +675 -5
  81. package/dist/pwa/public/index.html +41 -0
  82. package/dist/pwa/public/openapi.json +709 -11
  83. package/dist/pwa/public/style.css +3 -0
  84. package/dist/pwa/routes/admin-direct-receive-deposits.js +215 -3
  85. package/dist/pwa/routes/admin-protocol-params.js +16 -0
  86. package/dist/pwa/routes/admin-reports.js +31 -5
  87. package/dist/pwa/routes/agent-governance.js +1 -1
  88. package/dist/pwa/routes/agent-grants.js +253 -32
  89. package/dist/pwa/routes/analytics.js +2 -0
  90. package/dist/pwa/routes/arbitrator.js +67 -14
  91. package/dist/pwa/routes/bond-seller.js +162 -0
  92. package/dist/pwa/routes/direct-pay-cancel-refund.js +111 -0
  93. package/dist/pwa/routes/direct-pay-disclosure-acks.js +9 -4
  94. package/dist/pwa/routes/direct-pay-pending-accept.js +222 -0
  95. package/dist/pwa/routes/direct-pay-returns.js +74 -0
  96. package/dist/pwa/routes/direct-pay-timeouts.js +186 -9
  97. package/dist/pwa/routes/disputes-read.js +20 -6
  98. package/dist/pwa/routes/disputes-write.js +91 -33
  99. package/dist/pwa/routes/external-anchors.js +4 -2
  100. package/dist/pwa/routes/fee-prepay-requests.js +66 -0
  101. package/dist/pwa/routes/governance-onboarding.js +46 -8
  102. package/dist/pwa/routes/logistics.js +4 -4
  103. package/dist/pwa/routes/me-data.js +2 -2
  104. package/dist/pwa/routes/mutual-cancel.js +62 -0
  105. package/dist/pwa/routes/orders-action.js +182 -9
  106. package/dist/pwa/routes/orders-create.js +18 -7
  107. package/dist/pwa/routes/orders-read.js +76 -14
  108. package/dist/pwa/routes/platform-receive-accounts.js +111 -0
  109. package/dist/pwa/routes/products-create.js +13 -4
  110. package/dist/pwa/routes/products-update.js +15 -1
  111. package/dist/pwa/routes/profile-identity.js +4 -2
  112. package/dist/pwa/routes/returns.js +39 -8
  113. package/dist/pwa/routes/seller-directpay-report.js +110 -0
  114. package/dist/pwa/routes/seller-quota.js +5 -1
  115. package/dist/pwa/routes/shipping-templates.js +219 -0
  116. package/dist/pwa/routes/snf.js +4 -1
  117. package/dist/pwa/routes/webauthn.js +3 -3
  118. package/dist/pwa/server.js +59 -36
  119. package/dist/runtime/agent-grant-scopes.js +69 -1
  120. package/dist/runtime/webaz-schema-helpers.js +57 -3
  121. package/dist/sale-regions.js +116 -0
  122. package/dist/shipping-templates.js +119 -0
  123. package/dist/trade-tax.js +99 -0
  124. package/dist/trade-terms.js +76 -0
  125. package/dist/version.js +1 -1
  126. package/package.json +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
- if (!currentRoles.includes(role)) {
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. 重读 users.roles(进事务后) 验证 role 仍存在再操作
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
- if (!roles.includes(role))
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
- roles = roles.filter(r => r !== role);
549
- db.prepare("UPDATE users SET roles = ? WHERE id = ?").run(JSON.stringify(roles), userId);
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 { stripDirectPayPaymentTarget } from '../direct-pay-order-redaction.js'; // 披露门:物流第三方绝不该见收款目标
2
+ import { projectDirectPayTargetForViewer } from '../direct-pay-order-redaction.js'; // 披露门:按查看者投影(物流=第三方 → 剥离收款目标)
3
3
  export function registerLogisticsRoutes(app, deps) {
4
- // db 已走 RFC-016 异步 seam(dbAll),不再直接用 deps.db
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
- stripDirectPayPaymentTarget(o);
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 { redactUnackedDirectPayTarget } from '../direct-pay-order-redaction.js'; // 披露门:自导出的 orders 也不得旁路
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
- redactUnackedDirectPayTarget(db, o, uid); // 披露门:自导出也不得在 D1/D2 ack 前泄露 direct_p2p 收款目标(JSON + CSV 同源)
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, notes = '', evidence_description = '', logistics_company_id = '' } = req.body;
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
- 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' });
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
- const totalAmountU = Math.max(0, priceAfterCouponU + insurancePremiumU);
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',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).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);
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
- // 检查卖家是否有 auto_accept Skill
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;