@seasonkoh/webaz 0.1.28 → 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 (198) hide show
  1. package/README.md +3 -2
  2. package/README.zh-CN.md +7 -6
  3. package/dist/bond-refund-blockers.js +50 -0
  4. package/dist/bond-slash.js +100 -0
  5. package/dist/bond-terms.js +31 -0
  6. package/dist/currency.js +16 -0
  7. package/dist/deposit-rails.js +52 -0
  8. package/dist/direct-pay-aml-monitor.js +67 -0
  9. package/dist/direct-pay-aml-review.js +40 -0
  10. package/dist/direct-pay-base-bond-entry.js +5 -0
  11. package/dist/direct-pay-bond-rail-clearance.js +99 -0
  12. package/dist/direct-pay-cancel-refund.js +160 -0
  13. package/dist/direct-pay-compliance-ingress.js +145 -0
  14. package/dist/direct-pay-controls.js +136 -0
  15. package/dist/direct-pay-create.js +231 -0
  16. package/dist/direct-pay-deferral-quota.js +43 -0
  17. package/dist/direct-pay-disclosures.js +50 -0
  18. package/dist/direct-pay-eligibility.js +46 -0
  19. package/dist/direct-pay-fee-ar.js +241 -0
  20. package/dist/direct-pay-fee-prepay-request.js +80 -0
  21. package/dist/direct-pay-launch-readiness.js +108 -0
  22. package/dist/direct-pay-launch-summary.js +68 -0
  23. package/dist/direct-pay-ledger.js +94 -0
  24. package/dist/direct-pay-returns.js +104 -0
  25. package/dist/direct-pay-stock.js +9 -0
  26. package/dist/direct-receive-account-qr.js +77 -0
  27. package/dist/direct-receive-accounts.js +82 -0
  28. package/dist/direct-receive-deferral.js +171 -0
  29. package/dist/direct-receive-deposits.js +365 -0
  30. package/dist/direct-receive-payment-instruction.js +26 -0
  31. package/dist/free-shipping.js +37 -0
  32. package/dist/fx-rates.js +78 -0
  33. package/dist/layer0-foundation/L0-1-database/schema.js +777 -1
  34. package/dist/layer0-foundation/L0-2-state-machine/engine.js +1 -0
  35. package/dist/layer0-foundation/L0-2-state-machine/genuine-sale.js +15 -5
  36. package/dist/layer0-foundation/L0-2-state-machine/transitions.js +114 -0
  37. package/dist/layer0-foundation/L0-5-manifest/manifest.js +1 -1
  38. package/dist/layer1-agent/L1-1-mcp-server/cli.js +64 -0
  39. package/dist/layer1-agent/L1-1-mcp-server/network-mode.js +11 -0
  40. package/dist/layer1-agent/L1-1-mcp-server/server.js +99 -32
  41. package/dist/layer2-business/L2-6-notifications/notification-engine.js +110 -41
  42. package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +133 -31
  43. package/dist/layer3-trust/L3-1-dispute-engine/evidence-storage.js +16 -4
  44. package/dist/layer3-trust/L3-1-dispute-engine/mutual-cancel.js +156 -0
  45. package/dist/layer4-economics/L4-4-skill-market/skill-engine.js +4 -4
  46. package/dist/ledger.js +12 -3
  47. package/dist/mcp.js +23 -4
  48. package/dist/merchant-bond-domain.js +64 -0
  49. package/dist/merchant-bond-exposure.js +78 -0
  50. package/dist/merchant-bond-watcher.js +26 -0
  51. package/dist/payment-rails.js +29 -0
  52. package/dist/platform-receive-accounts.js +94 -0
  53. package/dist/product-verification.js +93 -0
  54. package/dist/pwa/acp-feed.js +3 -2
  55. package/dist/pwa/arbitration-read-admin.js +37 -0
  56. package/dist/pwa/arbitrator-lifecycle.js +100 -0
  57. package/dist/pwa/contract-fingerprint.js +18 -0
  58. package/dist/pwa/direct-pay-guards.js +20 -0
  59. package/dist/pwa/direct-pay-order-redaction.js +42 -0
  60. package/dist/pwa/endpoint-actions.js +3 -0
  61. package/dist/pwa/human-presence.js +2 -6
  62. package/dist/pwa/public/app-account.js +9 -9
  63. package/dist/pwa/public/app-admin-disputes.js +55 -0
  64. package/dist/pwa/public/app-agent-appeal.js +90 -0
  65. package/dist/pwa/public/app-agent-approvals.js +93 -0
  66. package/dist/pwa/public/app-agent-pair.js +127 -0
  67. package/dist/pwa/public/app-ai.js +10 -10
  68. package/dist/pwa/public/app-arbitrator-admin.js +87 -0
  69. package/dist/pwa/public/app-arbitrator-entry.js +9 -0
  70. package/dist/pwa/public/app-bond-deferral-ui.js +9 -0
  71. package/dist/pwa/public/app-bond-refund-ui.js +66 -0
  72. package/dist/pwa/public/app-bond-slash-ui.js +74 -0
  73. package/dist/pwa/public/app-bond-terms-ui.js +23 -0
  74. package/dist/pwa/public/app-bond-ui.js +108 -0
  75. package/dist/pwa/public/app-chat-poll.js +29 -0
  76. package/dist/pwa/public/app-contribution-hub.js +23 -0
  77. package/dist/pwa/public/app-create-kinds.js +17 -0
  78. package/dist/pwa/public/app-direct-pay-accounts.js +141 -0
  79. package/dist/pwa/public/app-direct-pay-buyer.js +72 -0
  80. package/dist/pwa/public/app-direct-pay-cancel-refund.js +72 -0
  81. package/dist/pwa/public/app-direct-pay-compliance.js +67 -0
  82. package/dist/pwa/public/app-direct-pay-copy.js +12 -0
  83. package/dist/pwa/public/app-direct-pay-deferral-admin.js +72 -0
  84. package/dist/pwa/public/app-direct-pay-deferral.js +61 -0
  85. package/dist/pwa/public/app-direct-pay-fee-center.js +33 -0
  86. package/dist/pwa/public/app-direct-pay-fee-history.js +39 -0
  87. package/dist/pwa/public/app-direct-pay-fee-ops.js +112 -0
  88. package/dist/pwa/public/app-direct-pay-fee-request.js +81 -0
  89. package/dist/pwa/public/app-direct-pay-fee-requests-admin.js +70 -0
  90. package/dist/pwa/public/app-direct-pay-memo.js +14 -0
  91. package/dist/pwa/public/app-direct-pay-negotiation.js +35 -0
  92. package/dist/pwa/public/app-direct-pay-pay.js +15 -0
  93. package/dist/pwa/public/app-direct-pay-paymodal.js +32 -0
  94. package/dist/pwa/public/app-direct-pay-product-verify.js +103 -0
  95. package/dist/pwa/public/app-direct-pay-readiness.js +38 -0
  96. package/dist/pwa/public/app-direct-pay-reconcile.js +19 -0
  97. package/dist/pwa/public/app-direct-pay-returns.js +56 -0
  98. package/dist/pwa/public/app-direct-pay-reveal.js +82 -0
  99. package/dist/pwa/public/app-direct-pay-sales-report.js +70 -0
  100. package/dist/pwa/public/app-direct-pay-store-verify.js +100 -0
  101. package/dist/pwa/public/app-direct-pay.js +226 -0
  102. package/dist/pwa/public/app-discover.js +20 -20
  103. package/dist/pwa/public/app-dispute-close-ui.js +38 -0
  104. package/dist/pwa/public/app-external-links.js +32 -0
  105. package/dist/pwa/public/app-free-shipping-ui.js +29 -0
  106. package/dist/pwa/public/app-gmv-rail-split.js +12 -0
  107. package/dist/pwa/public/app-listing-commerce-ui.js +72 -0
  108. package/dist/pwa/public/app-listings.js +4 -4
  109. package/dist/pwa/public/app-mutual-cancel.js +54 -0
  110. package/dist/pwa/public/app-notif-templates-orders.js +43 -0
  111. package/dist/pwa/public/app-notif-templates.js +22 -0
  112. package/dist/pwa/public/app-order-accept-ui.js +158 -0
  113. package/dist/pwa/public/app-order-errors.js +50 -0
  114. package/dist/pwa/public/app-order-labels.js +19 -0
  115. package/dist/pwa/public/app-order-rail-filter.js +26 -0
  116. package/dist/pwa/public/app-platform-receive-accounts.js +140 -0
  117. package/dist/pwa/public/app-poll-governor.js +22 -0
  118. package/dist/pwa/public/app-prelaunch-waz.js +39 -0
  119. package/dist/pwa/public/app-price.js +55 -0
  120. package/dist/pwa/public/app-product-media.js +15 -0
  121. package/dist/pwa/public/app-profile.js +10 -10
  122. package/dist/pwa/public/app-purchase-terms-ui.js +68 -0
  123. package/dist/pwa/public/app-sale-regions-ui.js +38 -0
  124. package/dist/pwa/public/app-seller.js +5 -5
  125. package/dist/pwa/public/app-shop.js +19 -19
  126. package/dist/pwa/public/app-trade-tax-ui.js +33 -0
  127. package/dist/pwa/public/app.js +264 -295
  128. package/dist/pwa/public/i18n.js +1068 -197
  129. package/dist/pwa/public/index.html +58 -0
  130. package/dist/pwa/public/openapi.json +1845 -653
  131. package/dist/pwa/public/style.css +3 -0
  132. package/dist/pwa/routes/admin-analytics.js +4 -2
  133. package/dist/pwa/routes/admin-direct-receive-deposits.js +533 -0
  134. package/dist/pwa/routes/admin-protocol-params.js +16 -0
  135. package/dist/pwa/routes/admin-reports.js +31 -5
  136. package/dist/pwa/routes/agent-governance.js +1 -1
  137. package/dist/pwa/routes/agent-grants.js +253 -32
  138. package/dist/pwa/routes/analytics.js +2 -0
  139. package/dist/pwa/routes/arbitrator.js +67 -14
  140. package/dist/pwa/routes/bond-seller.js +162 -0
  141. package/dist/pwa/routes/buyer-feeds.js +2 -1
  142. package/dist/pwa/routes/chat.js +6 -1
  143. package/dist/pwa/routes/dashboards.js +2 -1
  144. package/dist/pwa/routes/direct-pay-availability.js +196 -0
  145. package/dist/pwa/routes/direct-pay-cancel-refund.js +111 -0
  146. package/dist/pwa/routes/direct-pay-disclosure-acks.js +79 -0
  147. package/dist/pwa/routes/direct-pay-pending-accept.js +222 -0
  148. package/dist/pwa/routes/direct-pay-returns.js +74 -0
  149. package/dist/pwa/routes/direct-pay-timeouts.js +248 -0
  150. package/dist/pwa/routes/direct-receive-accounts.js +155 -0
  151. package/dist/pwa/routes/direct-receive-payment-instructions.js +45 -0
  152. package/dist/pwa/routes/disputes-read.js +20 -6
  153. package/dist/pwa/routes/disputes-write.js +91 -33
  154. package/dist/pwa/routes/external-anchors.js +4 -2
  155. package/dist/pwa/routes/fee-prepay-requests.js +66 -0
  156. package/dist/pwa/routes/fx.js +12 -0
  157. package/dist/pwa/routes/governance-onboarding.js +46 -8
  158. package/dist/pwa/routes/leaderboard.js +47 -9
  159. package/dist/pwa/routes/listings.js +2 -2
  160. package/dist/pwa/routes/logistics.js +6 -2
  161. package/dist/pwa/routes/manifests.js +38 -0
  162. package/dist/pwa/routes/me-data.js +5 -2
  163. package/dist/pwa/routes/mutual-cancel.js +62 -0
  164. package/dist/pwa/routes/orders-action.js +297 -16
  165. package/dist/pwa/routes/orders-create.js +21 -6
  166. package/dist/pwa/routes/orders-read.js +106 -8
  167. package/dist/pwa/routes/p2p-products.js +2 -2
  168. package/dist/pwa/routes/platform-receive-accounts.js +111 -0
  169. package/dist/pwa/routes/products-create.js +15 -4
  170. package/dist/pwa/routes/products-links.js +34 -0
  171. package/dist/pwa/routes/products-list.js +2 -1
  172. package/dist/pwa/routes/products-update.js +37 -3
  173. package/dist/pwa/routes/profile-identity.js +4 -2
  174. package/dist/pwa/routes/promoter.js +3 -0
  175. package/dist/pwa/routes/referral.js +4 -0
  176. package/dist/pwa/routes/returns.js +60 -4
  177. package/dist/pwa/routes/rewards-apply.js +10 -5
  178. package/dist/pwa/routes/rewards-clearing-mature.js +96 -0
  179. package/dist/pwa/routes/rewards-escrow-expire.js +6 -2
  180. package/dist/pwa/routes/seller-directpay-report.js +110 -0
  181. package/dist/pwa/routes/seller-quota.js +5 -1
  182. package/dist/pwa/routes/shipping-templates.js +219 -0
  183. package/dist/pwa/routes/shops.js +2 -1
  184. package/dist/pwa/routes/snf.js +4 -1
  185. package/dist/pwa/routes/url-claim.js +2 -2
  186. package/dist/pwa/routes/users-public.js +8 -0
  187. package/dist/pwa/routes/wallet-read.js +3 -0
  188. package/dist/pwa/routes/webauthn.js +3 -3
  189. package/dist/pwa/server.js +112 -132
  190. package/dist/runtime/agent-grant-scopes.js +69 -1
  191. package/dist/runtime/webaz-schema-helpers.js +161 -3
  192. package/dist/sale-regions.js +116 -0
  193. package/dist/shipping-templates.js +119 -0
  194. package/dist/store-verification.js +77 -0
  195. package/dist/trade-tax.js +99 -0
  196. package/dist/trade-terms.js +76 -0
  197. package/dist/version.js +1 -1
  198. package/package.json +128 -2
@@ -784,6 +784,17 @@ export function initArbitratorReviewSchema(db) {
784
784
  stake_amount INTEGER DEFAULT 0
785
785
  )
786
786
  `);
787
+ // PR-B 生产仲裁员生命周期字段(ADD COLUMN 必在 CREATE 后 —— schema 铁律)。legacy 行 status NULL 视为 active。
788
+ for (const alter of [
789
+ `ALTER TABLE arbitrator_whitelist ADD COLUMN status TEXT DEFAULT 'active'`, // active | suspended | revoked
790
+ `ALTER TABLE arbitrator_whitelist ADD COLUMN suspended_at TEXT`,
791
+ `ALTER TABLE arbitrator_whitelist ADD COLUMN revoked_at TEXT`,
792
+ ]) {
793
+ try {
794
+ db.exec(alter);
795
+ }
796
+ catch { /* 列已存在 */ }
797
+ }
787
798
  try {
788
799
  db.exec('CREATE INDEX IF NOT EXISTS idx_arb_apps_status ON arbitrator_applications(status)');
789
800
  }
@@ -1220,7 +1231,7 @@ export function initReturnRequestsSchema(db) {
1220
1231
  reason TEXT NOT NULL, -- 'quality' | 'wrong_item' | 'damaged' | 'no_longer_needed' | 'other'
1221
1232
  reason_text TEXT,
1222
1233
  refund_amount DECIMAL(18,2), -- 默认 = order.total_amount
1223
- status TEXT NOT NULL DEFAULT 'pending', -- pending | accepted | rejected | refunded | escalated | cancelled
1234
+ status TEXT NOT NULL DEFAULT 'pending', -- pending | accepted | rejected | refunded | escalated | cancelled | (direct_p2p) await_refund | refund_marked
1224
1235
  seller_response TEXT,
1225
1236
  escalated_dispute_id TEXT,
1226
1237
  created_at TEXT DEFAULT (datetime('now')),
@@ -1239,6 +1250,15 @@ export function initReturnRequestsSchema(db) {
1239
1250
  db.exec('CREATE INDEX IF NOT EXISTS idx_returns_order ON return_requests(order_id)');
1240
1251
  }
1241
1252
  catch { }
1253
+ // 直付(direct_p2p)送达后退货·场外退款握手(src/direct-pay-returns.ts)。ALTER 必须在 CREATE 之后(fresh DB silent-fail 铁律)。
1254
+ try {
1255
+ db.exec('ALTER TABLE return_requests ADD COLUMN refund_reference TEXT');
1256
+ }
1257
+ catch { /* 已存在 */ }
1258
+ try {
1259
+ db.exec('ALTER TABLE return_requests ADD COLUMN await_refund_since TEXT');
1260
+ }
1261
+ catch { /* 已存在 */ }
1242
1262
  }
1243
1263
  // ─── W2 售后协商时间线(flagged/flag_reasons ALTER 刻意留 server.ts 原位)──
1244
1264
  export function initReturnMessagesSchema(db) {
@@ -1784,13 +1804,47 @@ export function initAgentDelegationGrantsSchema(db) {
1784
1804
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
1785
1805
  expires_at TEXT NOT NULL, -- short-lived (clamped, RFC-020 bearer-first)
1786
1806
  revoked_at TEXT,
1787
- revoked_reason TEXT
1807
+ revoked_reason TEXT,
1808
+ permission_bundle TEXT -- named bundle key if issued/expanded via a bundle (e.g. 'catalog_agent'); NULL = ad-hoc scopes
1788
1809
  )
1789
1810
  `);
1811
+ try {
1812
+ db.exec(`ALTER TABLE agent_delegation_grants ADD COLUMN permission_bundle TEXT`);
1813
+ }
1814
+ catch { /* existing DB: column already added */ }
1790
1815
  db.exec(`CREATE INDEX IF NOT EXISTS idx_adg_human ON agent_delegation_grants(human_id, status)`);
1791
1816
  db.exec(`CREATE INDEX IF NOT EXISTS idx_adg_token ON agent_delegation_grants(token_hash)`);
1792
1817
  db.exec(`CREATE INDEX IF NOT EXISTS idx_adg_expiry ON agent_delegation_grants(status, expires_at)`);
1793
1818
  }
1819
+ /**
1820
+ * RFC-020 — Agent Permission Requests (JIT + bundle authorization). An already-connected agent (holds a
1821
+ * grant) that hits a missing scope, or wants a job bundle, lodges a REQUEST here; the human sees it in
1822
+ * #agent-approvals and approves/rejects. Approval expands the agent's existing grant (safe scopes only,
1823
+ * duration-capped). Bound to (human_id, grant_id). NO money/order/status columns; a request grants nothing
1824
+ * until a human approves it.
1825
+ */
1826
+ export function initAgentPermissionRequestsSchema(db) {
1827
+ db.exec(`
1828
+ CREATE TABLE IF NOT EXISTS agent_permission_requests (
1829
+ id TEXT PRIMARY KEY, -- apr_xxx
1830
+ human_id TEXT NOT NULL, -- resolved from the requesting agent's grant
1831
+ grant_id TEXT NOT NULL, -- the agent's existing grant that approval expands
1832
+ agent_label TEXT,
1833
+ requested_scopes TEXT NOT NULL DEFAULT '[]', -- JSON string[] — resolved (bundle expanded) safe scopes
1834
+ permission_bundle TEXT, -- bundle key if requested as a bundle; NULL = ad-hoc
1835
+ reason TEXT, -- agent free-text (display only, unverified)
1836
+ task_context TEXT, -- agent free-text about the task (display only)
1837
+ risk_level TEXT NOT NULL DEFAULT 'low', -- low | medium | high (derived, not agent-supplied)
1838
+ duration TEXT NOT NULL DEFAULT '7d', -- once | 1h | 24h | 7d | 30d (capped by risk tier)
1839
+ status TEXT NOT NULL DEFAULT 'pending', -- pending | approved | rejected | expired | revoked
1840
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1841
+ expires_at TEXT NOT NULL, -- request TTL (auto-expire if unanswered)
1842
+ approved_at TEXT
1843
+ )
1844
+ `);
1845
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_apr_human ON agent_permission_requests(human_id, status)`);
1846
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_apr_status ON agent_permission_requests(status, expires_at)`);
1847
+ }
1794
1848
  /**
1795
1849
  * RFC-020 PR-C1 — agent pairing sessions (OAuth device-flow + PKCE shape).
1796
1850
  *
@@ -1812,7 +1866,7 @@ export function initAgentPairingSchema(db) {
1812
1866
  agent_pubkey TEXT, -- RESERVED (PoP); stored if sent, NOT verified in C1
1813
1867
  reason TEXT, -- agent free-text reason (shown in consent)
1814
1868
  capabilities TEXT NOT NULL DEFAULT '[]', -- requested SAFE scopes (validated safe-only at start)
1815
- status TEXT NOT NULL DEFAULT 'pending', -- pending | approved | consumed | expired | revoked
1869
+ status TEXT NOT NULL DEFAULT 'pending', -- pending | approved | consumed | expired | revoked | rejected(human declined)
1816
1870
  human_id TEXT, -- set on approve
1817
1871
  grant_id TEXT, -- set on approve (issued grant; token_hash filled at retrieve)
1818
1872
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
@@ -1846,3 +1900,107 @@ export function initAgentGrantAuthLogSchema(db) {
1846
1900
  db.exec(`CREATE INDEX IF NOT EXISTS idx_agal_grant ON agent_grant_auth_log(grant_id, ts)`);
1847
1901
  db.exec(`CREATE INDEX IF NOT EXISTS idx_agal_ts ON agent_grant_auth_log(ts)`);
1848
1902
  }
1903
+ /**
1904
+ * pending_commission_escrow — opt-out promoter activation queue (§3.5b) + RFC-018 clearing ledger.
1905
+ *
1906
+ * Relocated VERBATIM from src/pwa/server.ts (RFC-018 PR1) so the table's whole schema is built once,
1907
+ * in the early helper batch — never half-here / half-inline (the fresh-DB silent-fail铁律 bites a
1908
+ * half-migration the hardest). RFC-018 adds two things, SCHEMA-ONLY (no settle / escrow read-write
1909
+ * logic touched):
1910
+ * - `matures_at` column — the accrue-then-mature clock (= completed_at + return_days +
1911
+ * settlement.clearing_buffer_days); NULL until the PR2 clearing model writes it.
1912
+ * - `reversed` status value — a return inside the clearing window flips pending→reversed; it is a
1913
+ * TEXT value, so it needs no schema change (documented in the column comment).
1914
+ * order_id stays NULLable for pv_pair rows (PR-1c-b).
1915
+ *
1916
+ * Migration, two independent cases (both idempotent, both skip when already current):
1917
+ * (a) order_id NOT NULL (pre-1c-b) → full rebuild — SQLite ADD COLUMN cannot relax a NOT NULL
1918
+ * constraint. Explicit-column INSERT maps the old rows; FK OFF/ON restores L0's global ON. The
1919
+ * rebuilt table already carries matures_at, so (b) then no-ops.
1920
+ * (b) missing matures_at (pre-RFC-018) → a cheap, NON-destructive `ALTER TABLE ADD COLUMN`. No
1921
+ * DROP, rows + indexes preserved. This is the common existing-DB path (every pre-RFC-018 DB,
1922
+ * incl. prod). We deliberately do NOT rebuild for an additive column — a money-path table on a
1923
+ * persistent prod volume should take the smallest safe migration. (Allowed here because the
1924
+ * complexity ratchet counts DDL in server.ts only, not this helper.)
1925
+ */
1926
+ export function initPendingCommissionEscrowSchema(db) {
1927
+ db.exec(`
1928
+ CREATE TABLE IF NOT EXISTS pending_commission_escrow (
1929
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1930
+ recipient_user_id TEXT NOT NULL,
1931
+ order_id TEXT, -- NULL for pv_pair (PR-1c-b)
1932
+ amount REAL NOT NULL, -- WAZ amount
1933
+ attribution_path TEXT NOT NULL, -- 'L1' | 'L2' | 'L3' | 'pv_pair' | etc.
1934
+ status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'settled' | 'expired' | 'reversed' (RFC-018)
1935
+ created_at INTEGER NOT NULL,
1936
+ expires_at INTEGER NOT NULL,
1937
+ settled_at INTEGER,
1938
+ expired_to_charity_at INTEGER,
1939
+ matures_at INTEGER, -- RFC-018: completed_at + return_days + clearing_buffer_days; NULL until the clearing model writes it
1940
+ FOREIGN KEY (recipient_user_id) REFERENCES users(id),
1941
+ FOREIGN KEY (order_id) REFERENCES orders(id)
1942
+ )
1943
+ `);
1944
+ (function migrateEscrowSchema() {
1945
+ const cols = db.prepare("PRAGMA table_info(pending_commission_escrow)").all();
1946
+ if (cols.length === 0)
1947
+ return; // table absent (shouldn't happen — CREATE above just ran)
1948
+ const orderIdCol = cols.find(c => c.name === 'order_id');
1949
+ const orderIdNotNull = !!orderIdCol && orderIdCol.notnull === 1;
1950
+ // (a) STRUCTURAL (pre-1c-b): order_id NOT NULL → nullable. Unavoidable full rebuild (ADD COLUMN
1951
+ // cannot relax NOT NULL). The rebuilt table already includes matures_at, so (b) below no-ops.
1952
+ if (orderIdNotNull) {
1953
+ console.log('[pc-escrow-migrate] order_id NOT NULL — rebuilding table to allow NULL for pv_pair');
1954
+ db.exec('PRAGMA foreign_keys = OFF');
1955
+ db.transaction(() => {
1956
+ db.exec(`
1957
+ CREATE TABLE pending_commission_escrow_new (
1958
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1959
+ recipient_user_id TEXT NOT NULL,
1960
+ order_id TEXT,
1961
+ amount REAL NOT NULL,
1962
+ attribution_path TEXT NOT NULL,
1963
+ status TEXT NOT NULL DEFAULT 'pending',
1964
+ created_at INTEGER NOT NULL,
1965
+ expires_at INTEGER NOT NULL,
1966
+ settled_at INTEGER,
1967
+ expired_to_charity_at INTEGER,
1968
+ matures_at INTEGER,
1969
+ FOREIGN KEY (recipient_user_id) REFERENCES users(id),
1970
+ FOREIGN KEY (order_id) REFERENCES orders(id)
1971
+ )
1972
+ `);
1973
+ // explicit columns: an older DB lacks matures_at → it defaults to NULL in _new (never SELECT *)
1974
+ db.exec(`INSERT INTO pending_commission_escrow_new
1975
+ (id, recipient_user_id, order_id, amount, attribution_path, status, created_at, expires_at, settled_at, expired_to_charity_at)
1976
+ SELECT id, recipient_user_id, order_id, amount, attribution_path, status, created_at, expires_at, settled_at, expired_to_charity_at
1977
+ FROM pending_commission_escrow`);
1978
+ db.exec('DROP TABLE pending_commission_escrow');
1979
+ db.exec('ALTER TABLE pending_commission_escrow_new RENAME TO pending_commission_escrow');
1980
+ })();
1981
+ db.exec('PRAGMA foreign_keys = ON');
1982
+ }
1983
+ // (b) ADDITIVE (pre-RFC-018): matures_at missing → cheap, non-destructive column add (no DROP;
1984
+ // rows + indexes preserved). Idempotent: throws "duplicate column" once present (fresh DB, or
1985
+ // just-rebuilt in (a)) → swallowed. This is the path every existing prod DB takes.
1986
+ try {
1987
+ db.exec('ALTER TABLE pending_commission_escrow ADD COLUMN matures_at INTEGER');
1988
+ }
1989
+ catch { /* column already exists */ }
1990
+ })();
1991
+ try {
1992
+ db.exec('CREATE INDEX IF NOT EXISTS idx_escrow_recipient ON pending_commission_escrow(recipient_user_id, status, expires_at)');
1993
+ }
1994
+ catch { }
1995
+ try {
1996
+ db.exec('CREATE INDEX IF NOT EXISTS idx_escrow_expiry ON pending_commission_escrow(status, expires_at)');
1997
+ }
1998
+ catch { }
1999
+ // PR-1c-a: UNIQUE (recipient, order, path) defends against double-insert if settleCommission ever retries
2000
+ // Note: NULL order_id (PR-1c-b pv_pair) is distinct in SQLite UNIQUE — idempotency for pv_pair relies
2001
+ // on binary_score_records.settled_at instead (source-side dedup).
2002
+ try {
2003
+ db.exec('CREATE UNIQUE INDEX IF NOT EXISTS uniq_escrow_recipient_order_path ON pending_commission_escrow(recipient_user_id, order_id, attribution_path)');
2004
+ }
2005
+ catch { }
2006
+ }
@@ -0,0 +1,116 @@
1
+ const REGION_RE = /^[A-Z0-9-]{2,8}$/;
2
+ /** parse-don't-validate:坏 JSON/坏形状 → null(= 不限,fail-open 到原行为;写入侧另有严校验)。 */
3
+ export function parseSaleRegionsRule(raw) {
4
+ if (typeof raw !== 'string' || !raw)
5
+ return null;
6
+ try {
7
+ const r = JSON.parse(raw);
8
+ if (!r || (r.mode !== 'all' && r.mode !== 'list'))
9
+ return null;
10
+ const norm = (a) => Array.isArray(a) ? a.map(x => String(x).toUpperCase()).filter(x => REGION_RE.test(x)) : undefined;
11
+ return { mode: r.mode, include: norm(r.include), exclude: norm(r.exclude) };
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
17
+ /** 写入侧严校验:返回规范化 JSON 串,或 {error}。null/空 = 清除(继承上层)。 */
18
+ export function validateSaleRegionsInput(raw) {
19
+ if (raw === null || raw === undefined || raw === '')
20
+ return { json: null };
21
+ const r = raw;
22
+ if (typeof r !== 'object' || (r.mode !== 'all' && r.mode !== 'list'))
23
+ return { error: "sale_regions.mode 必须是 'all' 或 'list'" };
24
+ const norm = (a, name) => {
25
+ if (a === undefined || a === null)
26
+ return undefined;
27
+ if (!Array.isArray(a) || a.length > 64)
28
+ return { error: `sale_regions.${name} 须为 ≤64 项数组` };
29
+ const out = [];
30
+ for (const x of a) {
31
+ const code = String(x).trim().toUpperCase();
32
+ if (!REGION_RE.test(code))
33
+ return { error: `sale_regions.${name} 含非法地区码:${String(x).slice(0, 12)}(2-8 位大写字母/数字/-)` };
34
+ out.push(code);
35
+ }
36
+ return [...new Set(out)];
37
+ };
38
+ const inc = norm(r.include, 'include');
39
+ if (inc && 'error' in inc)
40
+ return inc;
41
+ const exc = norm(r.exclude, 'exclude');
42
+ if (exc && 'error' in exc)
43
+ return exc;
44
+ if (r.mode === 'list' && (!inc || inc.length === 0))
45
+ return { error: "mode='list' 时 include 不能为空(否则等于全不可卖 —— 想全不可卖请直接下架)" };
46
+ return { json: JSON.stringify({ mode: r.mode, ...(inc ? { include: inc } : {}), ...(exc ? { exclude: exc } : {}) }) };
47
+ }
48
+ /** 生效规则:商品 ?? 店铺(与 accept_mode/运费模板同层级约定)。 */
49
+ export function effectiveSaleRegionsRule(db, product, sellerId) {
50
+ const own = parseSaleRegionsRule(product.sale_regions);
51
+ if (own)
52
+ return own;
53
+ try {
54
+ const row = db.prepare('SELECT store_sale_regions FROM users WHERE id = ?').get(sellerId);
55
+ return parseSaleRegionsRule(row?.store_sale_regions);
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ }
61
+ /**
62
+ * 平台合规名单解析(单一真相源:建单门 gateSaleRegionForCreate + S5 预检 shipping-options 共用)。
63
+ * 坏配置(非 JSON / 非数组)→ { ok:false },由调用方 fail-closed(建单 503;预检 sellable=platform_policy_invalid)。
64
+ * 缺省 '[]' → { ok:true, list:[] }。区码统一大写。
65
+ */
66
+ export function parsePlatformBlocklist(raw) {
67
+ try {
68
+ const p = JSON.parse(String(raw ?? '[]'));
69
+ if (!Array.isArray(p))
70
+ return { ok: false };
71
+ return { ok: true, list: p.map(x => String(x).toUpperCase()) };
72
+ }
73
+ catch {
74
+ return { ok: false };
75
+ }
76
+ }
77
+ export function regionAllowedByRule(rule, region) {
78
+ const r = region.toUpperCase();
79
+ if (rule.exclude?.includes(r))
80
+ return false;
81
+ if (rule.mode === 'list')
82
+ return !!rule.include?.includes(r);
83
+ return true;
84
+ }
85
+ /**
86
+ * 建单可售门(orders-create 单行调用,两轨共用,运费 gate 之前、任何写之前)。
87
+ * 返回 false = 已写错误响应。平台 overlay 先裁(商家规则不可放宽合规);规则存在但买家没给地区 → fail-closed 要求补选。
88
+ */
89
+ export function gateSaleRegionForCreate(db, res, product, sellerId, rawRegion, getProtocolParam) {
90
+ const region = (typeof rawRegion === 'string' && rawRegion.trim()) ? rawRegion.trim().toUpperCase() : null;
91
+ // ① 平台合规 overlay(protocol param,DEFAULT_PARAMS 已 seed '[]';admin PATCH 有 JSON+区码校验)。
92
+ // 坏配置【fail-closed】:合规名单读不懂时放行 = 静默解除平台禁售(审计 P2) —— 宁可挡单也不裸奔;
93
+ // admin 写入侧已强校验,坏值只可能来自手改 DB,错误信息直接指路。
94
+ const parsedBlock = parsePlatformBlocklist(getProtocolParam('trade.platform_region_blocklist', '[]'));
95
+ if (!parsedBlock.ok) {
96
+ res.status(503).json({ error: '平台合规配置异常,暂无法下单(运营侧需修复 trade.platform_region_blocklist 参数)', error_code: 'PLATFORM_REGION_POLICY_INVALID' });
97
+ return false;
98
+ }
99
+ const platformBlock = parsedBlock.list;
100
+ const rule = effectiveSaleRegionsRule(db, product, sellerId);
101
+ if (platformBlock.length === 0 && !rule)
102
+ return true; // 无任何限制 = 原行为(地区可选)
103
+ if (!region) {
104
+ res.status(400).json({ error: '该商品设有可售地区限制,请选择收货国家/地区', error_code: 'SHIP_REGION_REQUIRED' });
105
+ return false;
106
+ }
107
+ if (platformBlock.includes(region)) {
108
+ res.status(409).json({ error: '平台合规限制:该商品暂不支持销售到该地区', error_code: 'PRODUCT_RESTRICTED', region });
109
+ return false;
110
+ }
111
+ if (rule && !regionAllowedByRule(rule, region)) {
112
+ res.status(409).json({ error: '卖家未将该地区设为可售范围(与运费无关,不适用询价)', error_code: 'REGION_NOT_FOR_SALE', region });
113
+ return false;
114
+ }
115
+ return true;
116
+ }
@@ -0,0 +1,119 @@
1
+ import { toUnits } from './money.js';
2
+ import { freeShippingWaives } from './free-shipping.js'; // 营销域:满额免邮(S2 返工 —— 促销语义不进成本模板)
3
+ const MAX_ENTRIES = 50;
4
+ const MAX_REGION_LEN = 16;
5
+ const MAX_EST_LEN = 20;
6
+ const MAX_FEE = 1_000_000;
7
+ /** 规范化地区 code:trim + 大写。空/非串 → null。 */
8
+ export function normalizeRegion(raw) {
9
+ if (typeof raw !== 'string')
10
+ return null;
11
+ const r = raw.trim().toUpperCase();
12
+ if (!r || r.length > MAX_REGION_LEN)
13
+ return null;
14
+ return r;
15
+ }
16
+ /** 校验并规范化模板。null/undefined = 清除模板(合法)。返回 {ok, entries} 或 {ok:false, error}。 */
17
+ export function parseShippingTemplate(raw) {
18
+ if (raw == null)
19
+ return { ok: true, entries: null };
20
+ if (!Array.isArray(raw))
21
+ return { ok: false, error: '模板必须是数组' };
22
+ if (raw.length === 0)
23
+ return { ok: true, entries: null }; // 空数组视同清除
24
+ if (raw.length > MAX_ENTRIES)
25
+ return { ok: false, error: `模板最多 ${MAX_ENTRIES} 条` };
26
+ const out = [];
27
+ const seen = new Set();
28
+ for (const e of raw) {
29
+ const region = e && e.region === '*' ? '*' : normalizeRegion(e?.region);
30
+ if (!region)
31
+ return { ok: false, error: '每条须有合法 region(≤16 字大写 code 或 *)' };
32
+ if (seen.has(region))
33
+ return { ok: false, error: `region 重复:${region}` };
34
+ seen.add(region);
35
+ const fee = Number(e?.fee);
36
+ if (!Number.isFinite(fee) || fee < 0 || fee > MAX_FEE)
37
+ return { ok: false, error: `运费必须是 0~${MAX_FEE} 的数字(${region})` };
38
+ const est = e?.est_days == null ? undefined : String(e.est_days).trim().slice(0, MAX_EST_LEN);
39
+ out.push({ region, fee: Math.round(fee * 100) / 100, ...(est ? { est_days: est } : {}) });
40
+ }
41
+ return { ok: true, entries: out };
42
+ }
43
+ /** 解析已存 JSON(容错:坏 JSON/坏结构 → null=无模板,不阻断读路径)。 */
44
+ export function loadTemplateJson(json) {
45
+ if (!json)
46
+ return null;
47
+ try {
48
+ const p = parseShippingTemplate(JSON.parse(json));
49
+ return p.ok ? p.entries : null;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ /** 生效模板:单品覆盖 ?? 店铺默认 ?? null。product 传订单路径已取到的行(含 shipping_template 列)。 */
56
+ export function effectiveShippingTemplate(db, product, sellerId) {
57
+ const own = loadTemplateJson(product.shipping_template);
58
+ if (own)
59
+ return own;
60
+ try {
61
+ const row = db.prepare('SELECT store_shipping_template FROM users WHERE id = ?').get(sellerId);
62
+ return loadTemplateJson(row?.store_shipping_template);
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ /** 按买家地区解析运费:精确 → '*' → 未覆盖。(纯成本结构;营销免邮在 gate 层应用) */
69
+ export function resolveShipping(entries, region) {
70
+ const hit = entries.find(e => e.region === region) ?? entries.find(e => e.region === '*');
71
+ if (!hit)
72
+ return { covered: false, fee: 0, est_days: null, matched: null };
73
+ return { covered: true, fee: hit.fee, est_days: hit.est_days ?? null, matched: hit.region === region ? 'exact' : 'wildcard' };
74
+ }
75
+ /** 询价 opt-in(PR-3):单品 shipping_quote_ok ?? 店铺 store_shipping_quote_ok ?? 关。 */
76
+ export function quoteOutsideTemplateOk(db, product, sellerId) {
77
+ if (product.shipping_quote_ok != null)
78
+ return Number(product.shipping_quote_ok) === 1;
79
+ try {
80
+ const row = db.prepare('SELECT store_shipping_quote_ok FROM users WHERE id = ?').get(sellerId);
81
+ return Number(row?.store_shipping_quote_ok) === 1;
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ }
87
+ /**
88
+ * 建单运费守门(orders-create 单行调用,两轨共用,任何 DB write 之前):
89
+ * - 无模板:不要求地区(给了就规范化快照),运费 0 —— 原行为。
90
+ * - 有模板:必须给合法 ship_to_region(否则 400 SHIP_REGION_REQUIRED);命中 → 返回运费(调用方并入总额)。
91
+ * - 未命中(含无 '*'):卖家开了询价(quoteOutsideTemplateOk)且直付轨 → quoteRequired=true(建单落
92
+ * pending_accept,卖家先报运费/时效、买家确认新总额才进付款窗 —— PR-3);否则(未开询价 / escrow 轨,
93
+ * escrow 询价需付款后置钱路手术,PR-5)→ 409 SHIP_REGION_NOT_COVERED。
94
+ * 返回 null = 已写错误响应,调用方直接 return。
95
+ */
96
+ export function gateShippingForCreate(db, res, product, sellerId, rawRegion, rail = 'escrow', goodsSubtotalU) {
97
+ const region = normalizeRegion(rawRegion);
98
+ const tpl = effectiveShippingTemplate(db, product, sellerId);
99
+ if (!tpl)
100
+ return { feeU: 0, fee: 0, region, estDays: null, quoteRequired: false };
101
+ if (!region) {
102
+ res.status(400).json({ error: '该商品按地区计运费,请选择收货国家/地区', error_code: 'SHIP_REGION_REQUIRED' });
103
+ return null;
104
+ }
105
+ const r = resolveShipping(tpl, region);
106
+ if (r.covered) {
107
+ // 营销域满额免邮(S2 返工:规则在 free-shipping.ts,不在模板)—— 模板费为正 + 券后货款达标 → 免为 0。
108
+ // 人工询价路径不经此分支(quoteRequired 在下方),天然豁免。
109
+ if (r.fee > 0 && goodsSubtotalU !== undefined && freeShippingWaives(db, product, sellerId, goodsSubtotalU)) {
110
+ return { feeU: 0, fee: 0, region, estDays: r.est_days, quoteRequired: false, freeThresholdApplied: true };
111
+ }
112
+ return { feeU: toUnits(r.fee), fee: r.fee, region, estDays: r.est_days, quoteRequired: false };
113
+ }
114
+ if (rail === 'direct_p2p' && quoteOutsideTemplateOk(db, product, sellerId)) {
115
+ return { feeU: 0, fee: 0, region, estDays: null, quoteRequired: true }; // 运费待卖家报价,先不入总额
116
+ }
117
+ res.status(409).json({ error: rail === 'direct_p2p' ? '卖家暂不配送到该地区' : '卖家暂不配送到该地区(该商品支持直付询价的话,可改用直付下单)', error_code: 'SHIP_REGION_NOT_COVERED', region });
118
+ return null;
119
+ }
@@ -0,0 +1,77 @@
1
+ export const MAX_URL_LEN = 2048;
2
+ export const MAX_PLATFORM_LEN = 60;
3
+ export const MAX_NOTES_LEN = 500;
4
+ const ACTIVE = "('issued','submitted','verified')";
5
+ const getActive = (db, userId) => db.prepare(`SELECT * FROM store_verifications WHERE user_id = ? AND status IN ${ACTIVE} ORDER BY created_at DESC, rowid DESC LIMIT 1`).get(userId);
6
+ function isStorableHttpUrl(url) {
7
+ if (typeof url !== 'string' || url.length === 0 || url.length > MAX_URL_LEN)
8
+ return false;
9
+ return /^https?:\/\/[^\s]+$/i.test(url.trim());
10
+ }
11
+ /** 卖家申请店铺认证 → issued(签发 code)。单一活跃 per seller。 */
12
+ export function requestStoreVerification(db, args) {
13
+ const { id, userId, code } = args;
14
+ if (!id || !userId || !code)
15
+ return { ok: false, reason: 'missing id/userId/code' };
16
+ const platform = args.platform ? String(args.platform).trim().slice(0, MAX_PLATFORM_LEN) : null;
17
+ if (getActive(db, userId))
18
+ return { ok: false, reason: '已有进行中的店铺认证' };
19
+ db.prepare(`INSERT INTO store_verifications (id, user_id, code, platform, status, created_at, updated_at)
20
+ VALUES (?,?,?,?, 'issued', datetime('now'), datetime('now'))`).run(id, userId, code, platform);
21
+ return { ok: true, status: 'issued', code };
22
+ }
23
+ /** 卖家提交店铺外链 → submitted。要求有 issued 记录;链接仅存储(不抓取)。 */
24
+ export function submitStoreVerificationLink(db, args) {
25
+ const url = String(args.externalUrl || '').trim();
26
+ if (!isStorableHttpUrl(url))
27
+ return { ok: false, reason: '请提交有效的 http(s) 店铺链接' };
28
+ const active = getActive(db, args.userId);
29
+ if (!active)
30
+ return { ok: false, reason: '请先申请店铺认证获取验证码' };
31
+ if (active.status === 'verified')
32
+ return { ok: false, reason: '店铺认证已通过,无需重复提交' };
33
+ const platform = args.platform != null ? String(args.platform).trim().slice(0, MAX_PLATFORM_LEN) : active.platform;
34
+ db.prepare(`UPDATE store_verifications SET external_url = ?, platform = ?, status = 'submitted', updated_at = datetime('now') WHERE id = ?`).run(url, platform, active.id);
35
+ return { ok: true, status: 'submitted' };
36
+ }
37
+ /** 真人 admin 核对结论 → verified | rejected。verified 时按 perProductExempt 置豁免位(默认 false)。仅从 submitted 流转。 */
38
+ export function reviewStoreVerification(db, args) {
39
+ const { id, reviewerId, decision } = args;
40
+ if (!reviewerId)
41
+ return { ok: false, reason: 'reviewStoreVerification requires a human reviewerId' };
42
+ if (decision !== 'verified' && decision !== 'rejected')
43
+ return { ok: false, reason: 'decision must be verified|rejected' };
44
+ const row = db.prepare('SELECT * FROM store_verifications WHERE id = ?').get(id);
45
+ if (!row)
46
+ return { ok: false, reason: 'store verification not found' };
47
+ if (row.status === decision)
48
+ return { ok: true, status: decision, perProductExempt: row.per_product_exempt === 1, already: true };
49
+ if (row.status !== 'submitted')
50
+ return { ok: false, reason: `cannot review from status '${row.status}' (need submitted)` };
51
+ const exempt = decision === 'verified' && args.perProductExempt === true ? 1 : 0; // 仅 verified 才可置豁免;reject 一律 0
52
+ const notes = args.notes != null ? String(args.notes).slice(0, MAX_NOTES_LEN) : null;
53
+ db.prepare(`UPDATE store_verifications SET status = ?, per_product_exempt = ?, reviewed_by = ?, reviewed_at = datetime('now'), notes = ?, updated_at = datetime('now') WHERE id = ?`)
54
+ .run(decision, exempt, reviewerId, notes, id);
55
+ return { ok: true, status: decision, perProductExempt: exempt === 1 };
56
+ }
57
+ /** 卖家本人最新一条店铺认证(任意状态)。无 → null。 */
58
+ export function getStoreVerification(db, userId) {
59
+ return db.prepare('SELECT * FROM store_verifications WHERE user_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1').get(userId) ?? null;
60
+ }
61
+ /** admin 队列:按 status 过滤(默认全部),最新在前。纯读。 */
62
+ export function listStoreVerifications(db, opts = {}) {
63
+ if (opts.status)
64
+ return db.prepare(`SELECT * FROM store_verifications WHERE status = ? ORDER BY created_at DESC, rowid DESC`).all(opts.status);
65
+ return db.prepare(`SELECT * FROM store_verifications ORDER BY created_at DESC, rowid DESC`).all();
66
+ }
67
+ /** 硬门豁免读取:该卖家是否【已被豁免逐品验证】(店铺 verified 且 per_product_exempt=1)。供 direct-pay gate combiner。 */
68
+ export function sellerExemptFromPerProduct(db, userId) {
69
+ return !!db.prepare("SELECT 1 FROM store_verifications WHERE user_id = ? AND status = 'verified' AND per_product_exempt = 1 LIMIT 1").get(userId);
70
+ }
71
+ export function toSellerStoreVerificationView(row) {
72
+ return {
73
+ id: row.id, code: row.code, platform: row.platform, external_url: row.external_url,
74
+ status: row.status, per_product_exempt: row.per_product_exempt === 1,
75
+ reviewed_at: row.reviewed_at, created_at: row.created_at, updated_at: row.updated_at,
76
+ }; // 故意省略 reviewed_by + notes
77
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * 跨境税费/进口责任【声明+披露】层(S3)—— 卖家 seller-of-record 自我声明,平台【绝不计算/代收/代缴】。
3
+ *
4
+ * 定位纪律(调研结论,2026-07):非托管≠必然免平台义务。EU Art.14a "deemed supplier" 的免责是三肢连接测试
5
+ * (不设条款 且 不授权收款 且 不涉订单/配送),WebAZ 设条款+涉订单 → 极可能被认定;US CA/WA 型 "any-activity"
6
+ * 测试里"传递要约/承诺+接单+上架"各自独立触发,与是否碰钱无关。故本层【只声明只披露】,不碰税款:
7
+ * - import_duty_terms(DDP/DDU):卖家声明谁承担进口关税/税(DDP=卖家已含于价;DDU=买家到境自付)。
8
+ * - tax_lines(仅 'included' 类):卖家声明"价内已含 X 税"(如 SG GST 9%)—— 纯信息披露,不改总额、不入钱路。
9
+ * 刻意【不做】(不上假开关):'added' 类税进总额(钱路,单独 PR 门控真实需求)、自动税率、代收代缴(Rail 3+律师)。
10
+ * 合规义务(即便非 deemed):EU 10 年 / UK 6 年交易留痕由 orders.trade_terms_snapshot 满足(S0 已冻结卖家/货/值/时地/单号)。
11
+ * 跨境进高执法辖区(EU/UK 及 CA/WA 型州)的 deemed-supplier 姿态 → 由 S1 的 trade.platform_region_blocklist 治理门
12
+ * + 律师确认逐区放行(本层不自动放开)。详 docs/COMPLIANCE-CROSS-BORDER-TAX.INTERNAL.md。
13
+ *
14
+ * 层级:products.{import_duty_terms,tax_lines} ?? users.store_{import_duty_terms,tax_lines}(与接单/运费/可售同约定)。
15
+ * 快照:S0 的 trade-terms declarations.{import_duty_terms,tax_lines} 已从这些列自动填充 —— 本模块只管写入校验+生效解析+披露。
16
+ */
17
+ const REGION_RE = /^[A-Z0-9-]{2,8}$/;
18
+ /** DDP/DDU 写入校验:null/空=清除(继承上层);'ddu'|'ddp' 合法。 */
19
+ export function validateImportDutyTerms(raw) {
20
+ if (raw === null || raw === undefined || raw === '')
21
+ return { value: null };
22
+ if (raw === 'ddu' || raw === 'ddp')
23
+ return { value: raw };
24
+ return { error: "import_duty_terms 必须是 'ddu'(买家到境自付)或 'ddp'(卖家已含)或 null" };
25
+ }
26
+ /** 税费科目写入校验:S3 只收 'included'(价内已含,纯披露)。'added'(进总额)明确拒 —— 钱路未开,不上假开关。 */
27
+ export function validateTaxLines(raw) {
28
+ if (raw === null || raw === undefined || raw === '')
29
+ return { value: null };
30
+ if (!Array.isArray(raw))
31
+ return { error: 'tax_lines 必须是数组或 null' };
32
+ if (raw.length > 32)
33
+ return { error: 'tax_lines 最多 32 条' };
34
+ const out = [];
35
+ const seen = new Set();
36
+ for (const e of raw) {
37
+ if (!e || typeof e !== 'object')
38
+ return { error: 'tax_lines 每条须是对象' };
39
+ const r = e;
40
+ if (r.kind !== undefined && r.kind !== 'included') { // 省略 kind = 'included'(S3 唯一允许值);未来开 'added' 钱路时须显式,勿继承此静默默认
41
+ return { error: r.kind === 'added' ? "'added' 税费(进总额)暂不支持 —— 平台不代收税;仅支持 'included'(价内已含,披露)" : "tax_lines.kind 仅支持 'included'" };
42
+ }
43
+ const region = r.region === '*' ? '*' : (typeof r.region === 'string' ? r.region.trim().toUpperCase() : '');
44
+ if (region !== '*' && !REGION_RE.test(region))
45
+ return { error: `tax_lines 含非法 region:${String(r.region).slice(0, 12)}(2-8 位大写码或 *)` };
46
+ if (seen.has(region))
47
+ return { error: `tax_lines region 重复:${region}` };
48
+ seen.add(region);
49
+ const label = typeof r.label === 'string' ? r.label.trim().slice(0, 40) : '';
50
+ if (!label)
51
+ return { error: `tax_lines 每条须有 label(≤40 字,如 GST / VAT)(${region})` };
52
+ let rate_pct;
53
+ if (r.rate_pct !== undefined && r.rate_pct !== null) {
54
+ if (typeof r.rate_pct !== 'number' && typeof r.rate_pct !== 'string')
55
+ return { error: `tax_lines.rate_pct 必须是数字(${region})` }; // 审计:拒 Number(true)=1 类强转
56
+ const n = Number(r.rate_pct);
57
+ if (!Number.isFinite(n) || n < 0 || n > 100)
58
+ return { error: `tax_lines.rate_pct 必须是 0~100(${region})` };
59
+ rate_pct = Math.round(n * 100) / 100;
60
+ }
61
+ const note = typeof r.note === 'string' && r.note.trim() ? r.note.trim().slice(0, 80) : undefined;
62
+ out.push({ region, label, ...(rate_pct !== undefined ? { rate_pct } : {}), ...(note ? { note } : {}), kind: 'included' });
63
+ }
64
+ return { value: JSON.stringify(out) };
65
+ }
66
+ /** parse-don't-validate 读:坏 JSON/坏形状 → null。 */
67
+ export function parseTaxLines(raw) {
68
+ if (typeof raw !== 'string' || !raw)
69
+ return null;
70
+ try {
71
+ const a = JSON.parse(raw);
72
+ if (!Array.isArray(a))
73
+ return null;
74
+ return a.filter((e) => !!e && typeof e === 'object' && typeof e.region === 'string' && typeof e.label === 'string');
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ /** 生效 DDP/DDU:商品 ?? 店铺 ?? null(纯函数;product/store 值由调用方各自的 DB seam 取好传入 —— 消费方是 async seam 路由,不持 sync db)。 */
81
+ export function effectiveImportDutyTerms(productVal, storeVal) {
82
+ const norm = (x) => (x === 'ddu' || x === 'ddp') ? x : null;
83
+ return norm(productVal) ?? norm(storeVal);
84
+ }
85
+ /** 生效税费科目:商品 ?? 店铺 ?? null。 */
86
+ export function effectiveTaxLines(productVal, storeVal) {
87
+ const own = parseTaxLines(productVal);
88
+ if (own && own.length)
89
+ return own;
90
+ const st = parseTaxLines(storeVal);
91
+ return (st && st.length) ? st : null;
92
+ }
93
+ /** 按目的区筛生效税费(精确 region → '*' 兜底,合并;披露用)。 */
94
+ export function taxLinesForRegion(lines, region) {
95
+ if (!lines)
96
+ return [];
97
+ const r = (region || '').toUpperCase();
98
+ return lines.filter(l => l.region === '*' || (r && l.region === r));
99
+ }