@seasonkoh/webaz 0.1.27 → 0.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +4 -3
  2. package/README.zh-CN.md +7 -6
  3. package/dist/currency.js +16 -0
  4. package/dist/deposit-rails.js +52 -0
  5. package/dist/direct-pay-aml-monitor.js +67 -0
  6. package/dist/direct-pay-aml-review.js +40 -0
  7. package/dist/direct-pay-base-bond-entry.js +5 -0
  8. package/dist/direct-pay-bond-rail-clearance.js +94 -0
  9. package/dist/direct-pay-compliance-ingress.js +145 -0
  10. package/dist/direct-pay-controls.js +136 -0
  11. package/dist/direct-pay-create.js +162 -0
  12. package/dist/direct-pay-deferral-quota.js +43 -0
  13. package/dist/direct-pay-disclosures.js +44 -0
  14. package/dist/direct-pay-eligibility.js +46 -0
  15. package/dist/direct-pay-fee-ar.js +241 -0
  16. package/dist/direct-pay-launch-readiness.js +108 -0
  17. package/dist/direct-pay-launch-summary.js +68 -0
  18. package/dist/direct-pay-ledger.js +94 -0
  19. package/dist/direct-receive-account-qr.js +77 -0
  20. package/dist/direct-receive-accounts.js +82 -0
  21. package/dist/direct-receive-deferral.js +142 -0
  22. package/dist/direct-receive-deposits.js +260 -0
  23. package/dist/direct-receive-payment-instruction.js +26 -0
  24. package/dist/fx-rates.js +71 -0
  25. package/dist/layer0-foundation/L0-1-database/schema.js +531 -1
  26. package/dist/layer0-foundation/L0-2-state-machine/genuine-sale.js +15 -5
  27. package/dist/layer0-foundation/L0-2-state-machine/transitions.js +41 -0
  28. package/dist/layer0-foundation/L0-5-manifest/manifest.js +1 -1
  29. package/dist/layer1-agent/L1-1-mcp-server/cli.js +64 -0
  30. package/dist/layer1-agent/L1-1-mcp-server/network-mode.js +80 -0
  31. package/dist/layer1-agent/L1-1-mcp-server/server.js +251 -69
  32. package/dist/layer4-economics/L4-4-skill-market/skill-engine.js +4 -4
  33. package/dist/ledger.js +12 -3
  34. package/dist/mcp.js +23 -4
  35. package/dist/merchant-bond-domain.js +64 -0
  36. package/dist/merchant-bond-exposure.js +78 -0
  37. package/dist/merchant-bond-watcher.js +26 -0
  38. package/dist/payment-rails.js +29 -0
  39. package/dist/product-verification.js +93 -0
  40. package/dist/pwa/acp-feed.js +3 -2
  41. package/dist/pwa/contract-fingerprint.js +3 -0
  42. package/dist/pwa/direct-pay-guards.js +20 -0
  43. package/dist/pwa/direct-pay-order-redaction.js +24 -0
  44. package/dist/pwa/endpoint-actions.js +3 -0
  45. package/dist/pwa/public/app-account.js +977 -0
  46. package/dist/pwa/public/app-admin.js +608 -0
  47. package/dist/pwa/public/app-agents.js +63 -0
  48. package/dist/pwa/public/app-ai.js +2162 -0
  49. package/dist/pwa/public/app-chat-poll.js +29 -0
  50. package/dist/pwa/public/app-contribution.js +836 -0
  51. package/dist/pwa/public/app-create-kinds.js +17 -0
  52. package/dist/pwa/public/app-direct-pay-accounts.js +141 -0
  53. package/dist/pwa/public/app-direct-pay-buyer.js +72 -0
  54. package/dist/pwa/public/app-direct-pay-compliance.js +67 -0
  55. package/dist/pwa/public/app-direct-pay-deferral-admin.js +72 -0
  56. package/dist/pwa/public/app-direct-pay-deferral.js +61 -0
  57. package/dist/pwa/public/app-direct-pay-fee-center.js +33 -0
  58. package/dist/pwa/public/app-direct-pay-fee-ops.js +112 -0
  59. package/dist/pwa/public/app-direct-pay-product-verify.js +103 -0
  60. package/dist/pwa/public/app-direct-pay-readiness.js +38 -0
  61. package/dist/pwa/public/app-direct-pay-store-verify.js +100 -0
  62. package/dist/pwa/public/app-direct-pay.js +227 -0
  63. package/dist/pwa/public/app-discover.js +1296 -0
  64. package/dist/pwa/public/app-external-links.js +32 -0
  65. package/dist/pwa/public/app-listings.js +226 -0
  66. package/dist/pwa/public/app-prelaunch-waz.js +39 -0
  67. package/dist/pwa/public/app-price.js +55 -0
  68. package/dist/pwa/public/app-product-media.js +15 -0
  69. package/dist/pwa/public/app-profile.js +1692 -0
  70. package/dist/pwa/public/app-seller.js +199 -0
  71. package/dist/pwa/public/app-shop.js +1145 -0
  72. package/dist/pwa/public/app.js +13388 -22277
  73. package/dist/pwa/public/i18n.js +413 -196
  74. package/dist/pwa/public/index.html +27 -0
  75. package/dist/pwa/public/openapi.json +579 -1
  76. package/dist/pwa/routes/admin-analytics.js +4 -2
  77. package/dist/pwa/routes/admin-direct-receive-deposits.js +321 -0
  78. package/dist/pwa/routes/agent-grants.js +255 -0
  79. package/dist/pwa/routes/buyer-feeds.js +2 -1
  80. package/dist/pwa/routes/chat.js +6 -1
  81. package/dist/pwa/routes/dashboards.js +2 -1
  82. package/dist/pwa/routes/direct-pay-availability.js +196 -0
  83. package/dist/pwa/routes/direct-pay-disclosure-acks.js +74 -0
  84. package/dist/pwa/routes/direct-pay-timeouts.js +71 -0
  85. package/dist/pwa/routes/direct-receive-accounts.js +155 -0
  86. package/dist/pwa/routes/direct-receive-payment-instructions.js +45 -0
  87. package/dist/pwa/routes/fx.js +12 -0
  88. package/dist/pwa/routes/leaderboard.js +47 -9
  89. package/dist/pwa/routes/listings.js +2 -2
  90. package/dist/pwa/routes/logistics.js +4 -0
  91. package/dist/pwa/routes/manifests.js +38 -0
  92. package/dist/pwa/routes/me-data.js +5 -2
  93. package/dist/pwa/routes/orders-action.js +117 -9
  94. package/dist/pwa/routes/orders-create.js +4 -0
  95. package/dist/pwa/routes/orders-read.js +36 -0
  96. package/dist/pwa/routes/p2p-products.js +2 -2
  97. package/dist/pwa/routes/products-create.js +5 -3
  98. package/dist/pwa/routes/products-links.js +34 -0
  99. package/dist/pwa/routes/products-list.js +2 -1
  100. package/dist/pwa/routes/products-update.js +22 -2
  101. package/dist/pwa/routes/promoter.js +3 -0
  102. package/dist/pwa/routes/referral.js +4 -0
  103. package/dist/pwa/routes/returns.js +26 -1
  104. package/dist/pwa/routes/rewards-apply.js +10 -5
  105. package/dist/pwa/routes/rewards-clearing-mature.js +96 -0
  106. package/dist/pwa/routes/rewards-escrow-expire.js +6 -2
  107. package/dist/pwa/routes/shops.js +2 -1
  108. package/dist/pwa/routes/url-claim.js +2 -2
  109. package/dist/pwa/routes/users-public.js +8 -0
  110. package/dist/pwa/routes/wallet-read.js +3 -0
  111. package/dist/pwa/routes/webauthn.js +7 -2
  112. package/dist/pwa/server-schema.js +9 -0
  113. package/dist/pwa/server.js +261 -1706
  114. package/dist/runtime/agent-grant-scopes.js +128 -0
  115. package/dist/runtime/agent-grant-verifier.js +67 -0
  116. package/dist/runtime/agent-pairing.js +60 -0
  117. package/dist/runtime/apply-webaz-runtime-schema.js +15 -0
  118. package/dist/runtime/webaz-schema-helpers.js +1952 -0
  119. package/dist/store-verification.js +77 -0
  120. package/dist/version.js +1 -1
  121. package/package.json +80 -2
@@ -0,0 +1,108 @@
1
+ import { readDirectPayControlsConfig, sellerDirectPayBreakerTripped, sellerDirectPayKybPassed, sellerDirectPaySanctionsClear, sellerDirectPayAmlClear, } from './direct-pay-controls.js';
2
+ import { sellerHasProductionBaseBondLocked } from './direct-receive-deposits.js';
3
+ import { sellerBaseBondEntrySatisfied } from './direct-pay-base-bond-entry.js';
4
+ import { getActiveDeferral } from './direct-receive-deferral.js';
5
+ import { bondRailClearanceBlockers, isBondRailClearedForProduction } from './direct-pay-bond-rail-clearance.js';
6
+ import { getActivePaymentInstruction } from './direct-receive-payment-instruction.js';
7
+ /** 候选生产 base-bond 收款轨(与 #112 registry 一致;manual 是非生产确认轨,不算)。 */
8
+ // 候选生产 base-bond 收款轨。operator_attested(#116,已实现的运营核实轨)是 v1 实际要用的那条,必须纳入诊断,
9
+ // 否则它被 registry 放行后 readiness 仍误报"无 legal-cleared rail"。manual=非生产确认轨,不算。
10
+ const PRODUCTION_BOND_RAILS = ['operator_attested', 'usdc_onchain', 'fiat_psp'];
11
+ /**
12
+ * 只读 readiness 汇总。args.getProtocolParam 读控制面配置;args.sellerId 提供时附加 seller-specific blockers。
13
+ * ready ⟺ blockers 为空。绝不写库。
14
+ */
15
+ export function readDirectPayLaunchReadiness(db, args) {
16
+ const { getProtocolParam, sellerId } = args;
17
+ const cfg = readDirectPayControlsConfig(getProtocolParam);
18
+ const blockers = [];
19
+ // ── global / policy(镜像 evaluateDirectPayLaunchControls 的非订单条件;不短路,收集全部)──
20
+ if (cfg.enabled !== true)
21
+ blockers.push('DIRECT_PAY_NOT_ENABLED');
22
+ if (cfg.railBreakerTripped === true)
23
+ blockers.push('DIRECT_PAY_RAIL_BREAKER_TRIPPED');
24
+ const allow = Array.isArray(cfg.regionAllowlist) ? cfg.regionAllowlist : [];
25
+ if (!allow.length || !cfg.region || !allow.includes(cfg.region))
26
+ blockers.push('DIRECT_PAY_REGION_NOT_ALLOWED');
27
+ if (!(Number.isSafeInteger(cfg.perTxCapUnits) && cfg.perTxCapUnits > 0))
28
+ blockers.push('DIRECT_PAY_PER_TX_CAP_UNSET');
29
+ // ── production bond-rail clearance(#112;rail-level,jurisdiction-aware)── 【诊断 facts,非 launch blocker】
30
+ // 关键:真实建单/可用性门(evaluateDirectPayLaunchControls + direct-pay-create/availability)从不校验 rail-clearance。
31
+ // rail-clearance 只 gate【有人交真实保证金】这一条路(confirmProductionReceipt 的 Lock B);缓交卖家根本不交 bond,
32
+ // 也就不需要任何 cleared rail 即可直付。因此把 rail-clearance 当全局 launch blocker 会让 readiness 误报"未开放"——
33
+ // 与真实门不一致(Codex #117 P1)。这里只【计算并下发为诊断 facts】(anyRailLegalCleared / perRailClearance),
34
+ // 供运营判断"若要接收真实保证金,bonded-deposit 轨是否就绪",但绝不进 blockers / 不影响 ready / PLATFORM_OPEN。
35
+ const perRailClearance = {};
36
+ for (const rid of PRODUCTION_BOND_RAILS)
37
+ perRailClearance[rid] = bondRailClearanceBlockers(rid, { hasProductionReceipt: true });
38
+ // jurisdiction-aware:把【当前部署 region】传进去,校验 region ∈ rail 的 legal jurisdictionAllowlist(coarse 判定会误报 cleared)。
39
+ const anyRailLegalCleared = PRODUCTION_BOND_RAILS.some(rid => isBondRailClearedForProduction(rid, cfg.region));
40
+ // ── seller-specific(仅当传入 sellerId)──
41
+ let sellerSuspended = null, productionBaseBondLocked = null;
42
+ let activeDeferral = null, baseBondSatisfied = null;
43
+ let kybPassed = null, sanctionsClear = null, amlClear = null;
44
+ let paymentInstructionPresent = null;
45
+ if (sellerId) {
46
+ const nowIso = new Date().toISOString();
47
+ sellerSuspended = sellerDirectPayBreakerTripped(db, sellerId);
48
+ productionBaseBondLocked = sellerHasProductionBaseBondLocked(db, sellerId);
49
+ activeDeferral = getActiveDeferral(db, sellerId, nowIso) != null;
50
+ // 镜像 create gate(direct-pay-base-bond-entry):保证金门 = 生产 bond OR 有效缓交。
51
+ baseBondSatisfied = sellerBaseBondEntrySatisfied(db, sellerId, nowIso);
52
+ kybPassed = sellerDirectPayKybPassed(db, sellerId);
53
+ sanctionsClear = sellerDirectPaySanctionsClear(db, sellerId);
54
+ amlClear = sellerDirectPayAmlClear(db, sellerId);
55
+ // 镜像真实建单硬门(direct-pay-create.ts):无 active 收款说明 → create 返回 NO_PAYMENT_INSTRUCTION,绝不建单。
56
+ paymentInstructionPresent = getActivePaymentInstruction(db, sellerId) != null;
57
+ if (sellerSuspended)
58
+ blockers.push('DIRECT_PAY_SELLER_SUSPENDED');
59
+ // 与 create gate 一致:有生产 bond 或有效缓交即满足;两者都无才 blocker。
60
+ if (!baseBondSatisfied)
61
+ blockers.push('DIRECT_PAY_SELLER_NO_PRODUCTION_BASE_BOND');
62
+ if (!kybPassed)
63
+ blockers.push('DIRECT_PAY_SELLER_KYB_NOT_APPROVED');
64
+ if (!sanctionsClear)
65
+ blockers.push('DIRECT_PAY_SELLER_SANCTIONS_NOT_CLEARED');
66
+ if (!amlClear)
67
+ blockers.push('DIRECT_PAY_SELLER_AML_REVIEW_REQUIRED');
68
+ if (!paymentInstructionPresent)
69
+ blockers.push('DIRECT_PAY_SELLER_PAYMENT_INSTRUCTION_MISSING');
70
+ }
71
+ return {
72
+ ready: blockers.length === 0,
73
+ blockers,
74
+ facts: {
75
+ enabled: cfg.enabled, railBreakerTripped: cfg.railBreakerTripped, region: cfg.region,
76
+ regionAllowlist: cfg.regionAllowlist, perTxCapUnits: cfg.perTxCapUnits,
77
+ perRailClearance, anyRailLegalCleared,
78
+ sellerEvaluated: !!sellerId, sellerId: sellerId ?? null,
79
+ sellerSuspended, productionBaseBondLocked, activeDeferral, baseBondSatisfied, kybPassed, sanctionsClear, amlClear, paymentInstructionPresent,
80
+ },
81
+ };
82
+ }
83
+ // PLATFORM_OPEN 只折叠【真实全局建单门】(镜像 evaluateDirectPayLaunchControls 的非订单条件):enabled / rail-breaker /
84
+ // region / per-tx cap。rail-clearance 不在此列(它不 gate 直付,见上),否则缓交卖家会被误显示"平台未开放"。
85
+ const GLOBAL_LAUNCH_BLOCKERS = new Set([
86
+ 'DIRECT_PAY_NOT_ENABLED', 'DIRECT_PAY_RAIL_BREAKER_TRIPPED', 'DIRECT_PAY_REGION_NOT_ALLOWED', 'DIRECT_PAY_PER_TX_CAP_UNSET',
87
+ ]);
88
+ /**
89
+ * 卖家脱敏 readiness 视图。复用 readDirectPayLaunchReadiness(只读),把内部 blockers/facts 折叠成卖家安全的项目集。
90
+ * 输出【不含】任何 raw blocker code、KYB/sanctions/AML 分项或法务/rail 细节。纯读。
91
+ */
92
+ export function sellerDirectPayReadinessView(db, args) {
93
+ const { getProtocolParam, sellerId } = args;
94
+ const r = readDirectPayLaunchReadiness(db, { getProtocolParam, sellerId });
95
+ const f = r.facts;
96
+ const platformOpen = !r.blockers.some(b => GLOBAL_LAUNCH_BLOCKERS.has(b));
97
+ const hasPasskey = !!db.prepare('SELECT 1 FROM webauthn_credentials WHERE user_id = ? LIMIT 1').get(sellerId);
98
+ const complianceCleared = f.kybPassed === true && f.sanctionsClear === true && f.amlClear === true; // 折叠;不分项
99
+ const items = [
100
+ { code: 'PLATFORM_OPEN', ok: platformOpen, actionable: false },
101
+ { code: 'PAYMENT_INSTRUCTION', ok: f.paymentInstructionPresent === true, actionable: true },
102
+ { code: 'PASSKEY', ok: hasPasskey, actionable: true },
103
+ { code: 'BASE_BOND', ok: f.baseBondSatisfied === true, actionable: false }, // 生产 bond 或有效缓交即算满足(与 create gate 一致)
104
+ { code: 'COMPLIANCE_REVIEW', ok: complianceCleared, actionable: false },
105
+ { code: 'NOT_SUSPENDED', ok: f.sellerSuspended === false, actionable: false },
106
+ ];
107
+ return { directPayReady: items.every(i => i.ok), items };
108
+ }
@@ -0,0 +1,68 @@
1
+ import { readDirectPayLaunchReadiness } from './direct-pay-launch-readiness.js';
2
+ import { readDirectPayControlsConfig } from './direct-pay-controls.js';
3
+ import { checkDeferralQuota, readDeferralQuotaConfig } from './direct-pay-deferral-quota.js';
4
+ import { productStoreVerified } from './product-verification.js';
5
+ import { sellerExemptFromPerProduct } from './store-verification.js';
6
+ import { toUnits } from './money.js';
7
+ /** 候选卖家:在任一 direct-pay 相关表里出现过的卖家(有意接入直付者)。 */
8
+ function candidateSellers(db) {
9
+ const rows = db.prepare(`
10
+ SELECT DISTINCT seller_id FROM (
11
+ SELECT seller_id FROM direct_receive_payment_instructions WHERE status = 'active'
12
+ UNION SELECT user_id FROM direct_receive_deferrals
13
+ UNION SELECT user_id FROM direct_receive_deposits
14
+ UNION SELECT seller_id FROM product_verifications
15
+ UNION SELECT user_id FROM store_verifications
16
+ ) WHERE seller_id IS NOT NULL
17
+ `).all();
18
+ return rows.map(r => r.seller_id);
19
+ }
20
+ /** 聚合 go/no-go。纯读。getProtocolParam 由调用方按部署注入(CLI 从 protocol_params 构造)。 */
21
+ export function summarizeDirectPayLaunchReadiness(db, getProtocolParam) {
22
+ const globalRd = readDirectPayLaunchReadiness(db, { getProtocolParam }); // 无 sellerId → 仅全局 blockers
23
+ const global = { ready: globalRd.blockers.length === 0, blockers: globalRd.blockers, facts: globalRd.facts };
24
+ const globalSet = new Set(global.blockers);
25
+ const nowIso = new Date().toISOString();
26
+ const quotaCfg = readDeferralQuotaConfig(getProtocolParam);
27
+ const controlsCfg = readDirectPayControlsConfig(getProtocolParam);
28
+ const sellers = candidateSellers(db).map(sellerId => {
29
+ const rd = readDirectPayLaunchReadiness(db, { getProtocolParam, sellerId });
30
+ // 仅 seller-specific blockers(去掉全局 blockers,避免每个卖家都重复挂全局问题)。
31
+ const sellerBlockers = rd.blockers.filter(b => !globalSet.has(b));
32
+ const ready = sellerBlockers.length === 0;
33
+ const storeExempt = sellerExemptFromPerProduct(db, sellerId);
34
+ const products = db.prepare("SELECT id, price, stock, has_variants FROM products WHERE seller_id = ? AND status = 'active'").all(sellerId);
35
+ // 可直付商品 = 必须能真正走 direct_p2p 建单(镜像 create gate / evaluateDirectPayLaunchControls + 建单库存门),全部满足:
36
+ // ① 简单商品(direct_p2p v1 拒规格商品 has_variants=1);② 逐品 verified 或卖家店铺豁免;
37
+ // ③ 单笔上限:amount>0 且 ≤ perTxCapUnits(且 cap 已配 >0)—— 镜像 DIRECT_PAY_CAP_EXCEEDED;
38
+ // ④ 有货(stock≥1)—— 镜像 createDirectPayOrder 的 `stock >= qty`(否则 'stock depleted');
39
+ // ⑤ 无进行中限时促销(direct_p2p v1 拒 flash_sale=DIRECT_PAY_UNSUPPORTED_OPTION;谓词镜像 getActiveFlashSale@flash-sales.ts 的简单商品分支);
40
+ // ⑥ 通过缓交额度(checkDeferralQuota qty=1 该单价;非缓交卖家=no-op)。否则报告会误判 go=true 而真实下单被拒。
41
+ const flashActive = db.prepare(`SELECT 1 FROM flash_sales WHERE product_id = ? AND is_active = 1
42
+ AND starts_at <= datetime('now') AND ends_at > datetime('now') AND (max_qty = 0 OR sold_count < max_qty) AND variant_id IS NULL LIMIT 1`);
43
+ const eligibleProductCount = products.filter(p => {
44
+ const priceU = toUnits(Number(p.price) || 0);
45
+ return Number(p.has_variants) !== 1
46
+ && (storeExempt || productStoreVerified(db, p.id))
47
+ && controlsCfg.perTxCapUnits > 0 && priceU > 0 && priceU <= controlsCfg.perTxCapUnits
48
+ && Number(p.stock) >= 1
49
+ && !flashActive.get(p.id)
50
+ && checkDeferralQuota(db, sellerId, priceU, nowIso, quotaCfg).ok;
51
+ }).length;
52
+ return { sellerId, ready, blockers: sellerBlockers, storeExempt, activeProductCount: products.length, eligibleProductCount, launchable: ready && eligibleProductCount > 0 };
53
+ });
54
+ const launchableSellerCount = sellers.filter(s => s.launchable).length;
55
+ // PRE-FLIP 自检:忽略【且仅忽略】"总开关未开"这一项,回答"除了翻 enabled,还差什么"。其余(region/cap/卖家/商品)照常要求。
56
+ const preflipReady = global.blockers.filter(b => b !== 'DIRECT_PAY_NOT_ENABLED').length === 0;
57
+ const pendingEnable = global.facts.enabled !== true;
58
+ return {
59
+ go: global.ready && launchableSellerCount > 0, // LIVE(含 enabled)
60
+ preflipGo: preflipReady && launchableSellerCount > 0, // 只差翻 enabled 即就绪
61
+ pendingEnable,
62
+ global, sellers, launchableSellerCount,
63
+ };
64
+ }
65
+ /** 便捷:从控制面配置取关键运营数值(供 CLI 打印)。 */
66
+ export function directPayControlsSnapshot(getProtocolParam) {
67
+ return readDirectPayControlsConfig(getProtocolParam);
68
+ }
@@ -0,0 +1,94 @@
1
+ import { toUnits, toDecimal, allocate } from './money.js';
2
+ import { applyWalletDelta, walletUnits, creditColumns } from './ledger.js';
3
+ /**
4
+ * penalty 科目【唯一】写入口(只进,无对应出账函数)。
5
+ * - source='fee_stake' → 真入 WAZ balance(fee-stake 本就是 WAZ)。
6
+ * - source='base_bond' → 仅记 provenance 计数(外部资产,真实处置 gated;不进 balance)。
7
+ */
8
+ function creditPenalty(db, opts) {
9
+ const { kind, source, fromUserId = null, amountU, relatedOrderId = null, reason = null, txnId } = opts;
10
+ const deltas = source === 'fee_stake'
11
+ ? { balance: amountU, total_fee_stake_slash: amountU } // WAZ 真入池
12
+ : { total_base_bond_slash: amountU }; // 外部资产:仅计数,不进 balance
13
+ creditColumns(db, 'penalty_fund', "id = 'main'", [], deltas);
14
+ db.prepare("UPDATE penalty_fund SET updated_at = datetime('now') WHERE id = 'main'").run();
15
+ db.prepare(`INSERT INTO penalty_fund_txns (id, kind, source, from_user_id, amount, related_order_id, reason, created_at) VALUES (?,?,?,?,?,?,?,datetime('now'))`)
16
+ .run(txnId, kind, source, fromUserId, toDecimal(amountU), relatedOrderId, reason);
17
+ }
18
+ function getStake(db, orderId) {
19
+ return db.prepare('SELECT id, order_id, seller_id, amount, status FROM direct_pay_fee_stakes WHERE order_id = ?')
20
+ .get(orderId);
21
+ }
22
+ /** 只读断言:订单是否有【locked】fee-stake(可在完成时取费)。完成路径预检用,缺则拒绝 direct_p2p 进 completed。 */
23
+ export function hasLockedFeeStake(db, orderId) {
24
+ return getStake(db, orderId)?.status === 'locked';
25
+ }
26
+ /** 锁定逐单费用质押(= 平台费):卖家可用余额 → fee_staked。余额不足返回 {ok:false}(调用方不开单)。 */
27
+ export function lockFeeStake(db, opts) {
28
+ const { orderId, sellerId, feeUnits, stakeId } = opts;
29
+ if (feeUnits <= 0)
30
+ return { ok: false, reason: 'fee must be > 0' };
31
+ if (getStake(db, orderId))
32
+ return { ok: false, reason: 'fee-stake already exists for order' };
33
+ const w = walletUnits(db, sellerId);
34
+ if (w.balance < feeUnits)
35
+ return { ok: false, reason: 'insufficient balance for fee-stake' };
36
+ applyWalletDelta(db, sellerId, { balance: -feeUnits, fee_staked: feeUnits });
37
+ db.prepare(`INSERT INTO direct_pay_fee_stakes (id, order_id, seller_id, amount, status, created_at) VALUES (?,?,?,?, 'locked', datetime('now'))`)
38
+ .run(stakeId, orderId, sellerId, toDecimal(feeUnits));
39
+ return { ok: true };
40
+ }
41
+ /**
42
+ * 完成时取费:整笔 fee-stake → 协议(reserve 50% + sys_protocol 50%,与 settleOrder 协议费去向一致)。
43
+ * **FAIL-CLOSED(钱路不变量):** direct_p2p 单完成必须有可取的 locked fee-stake,否则平台费会被静默落空。
44
+ * - 无 stake 行 / 状态非 locked 且非 fee_taken → **抛错**(调用方 settleOrder 在 db.transaction 内 → 回滚,
45
+ * 拒绝"完成但零平台费")。绝不静默 return(那正是审计 P1-2 指出的 fail-open 漏洞)。
46
+ * - 已 fee_taken → 幂等:返回 'already_taken',不重复取费(支持重结算)。
47
+ * fee-stake == 平台费,故无 remainder(若将来 stake>fee 可在此退差额给卖家)。
48
+ */
49
+ export function takeFeeAtCompletion(db, opts) {
50
+ const s = getStake(db, opts.orderId);
51
+ if (!s)
52
+ throw new Error(`takeFeeAtCompletion: no fee-stake for direct_p2p order ${opts.orderId} — refusing to settle without platform fee (fail-closed)`);
53
+ if (s.status === 'fee_taken')
54
+ return { outcome: 'already_taken' }; // 幂等:重结算不重复取费
55
+ if (s.status !== 'locked')
56
+ throw new Error(`takeFeeAtCompletion: fee-stake for order ${opts.orderId} is '${s.status}', not 'locked' — refusing to settle (fail-closed)`);
57
+ const feeU = toUnits(s.amount);
58
+ applyWalletDelta(db, s.seller_id, { fee_staked: -feeU });
59
+ const [toReserveU, toOpsU] = allocate(feeU, [1, 1]);
60
+ if (toReserveU > 0)
61
+ creditColumns(db, 'protocol_reserve_pool', 'id = 1', [], { balance: toReserveU });
62
+ if (toOpsU > 0)
63
+ applyWalletDelta(db, 'sys_protocol', { balance: toOpsU });
64
+ db.prepare("UPDATE direct_pay_fee_stakes SET status='fee_taken', settled_at=datetime('now') WHERE order_id=? AND status='locked'").run(opts.orderId);
65
+ return { outcome: 'taken' };
66
+ }
67
+ /** 释放(未付/取消/超时):整笔退回卖家可用余额。幂等。 */
68
+ export function releaseFeeStake(db, opts) {
69
+ const s = getStake(db, opts.orderId);
70
+ if (!s || s.status !== 'locked')
71
+ return;
72
+ const feeU = toUnits(s.amount);
73
+ applyWalletDelta(db, s.seller_id, { fee_staked: -feeU, balance: feeU });
74
+ db.prepare("UPDATE direct_pay_fee_stakes SET status='released', settled_at=datetime('now') WHERE order_id=? AND status='locked'").run(opts.orderId);
75
+ }
76
+ /** 违约罚没:整笔 fee-stake → penalty 科目(feeRetainedOnFault=false → WebAZ 不收费,整笔入池)。幂等。 */
77
+ export function slashFeeStakeToPenalty(db, opts) {
78
+ const s = getStake(db, opts.orderId);
79
+ if (!s || s.status !== 'locked')
80
+ return;
81
+ const feeU = toUnits(s.amount);
82
+ applyWalletDelta(db, s.seller_id, { fee_staked: -feeU });
83
+ creditPenalty(db, { kind: 'fee_stake_slash', source: 'fee_stake', fromUserId: s.seller_id, amountU: feeU, relatedOrderId: opts.orderId, reason: opts.reason ?? null, txnId: opts.txnId });
84
+ db.prepare("UPDATE direct_pay_fee_stakes SET status='slashed', settled_at=datetime('now') WHERE order_id=? AND status='locked'").run(opts.orderId);
85
+ }
86
+ /**
87
+ * base bond(外部担保物)罚没:仅记 provenance(total_base_bond_slash + txn),不进 WAZ balance。
88
+ * 真实外部资产(USDC/法币)处置 GATED(deposit-rail 非生产 / 法务)。由 Phase 4 deposit 生命周期调用。
89
+ */
90
+ export function recordBaseBondSlash(db, opts) {
91
+ if (opts.amountUnits <= 0)
92
+ return;
93
+ creditPenalty(db, { kind: 'base_bond_slash', source: 'base_bond', fromUserId: opts.userId, amountU: opts.amountUnits, relatedOrderId: null, reason: opts.reason ?? null, txnId: opts.txnId });
94
+ }
@@ -0,0 +1,77 @@
1
+ import { createHash } from 'crypto';
2
+ export const QR_DATA_URI_RE = /^data:image\/(png|webp);base64,[A-Za-z0-9+/=]+$/;
3
+ export const QR_MAX_BYTES = 64 * 1024;
4
+ /** PNG magic 8 bytes / WEBP RIFF....WEBP。仅接受与声明 mime 一致的字节。 */
5
+ function magicMatches(subtype, buf) {
6
+ if (subtype === 'png')
7
+ return buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
8
+ if (subtype === 'webp')
9
+ return buf.length >= 12 && buf.subarray(0, 4).toString('latin1') === 'RIFF' && buf.subarray(8, 12).toString('latin1') === 'WEBP';
10
+ return false;
11
+ }
12
+ /** PURE: 校验收款码 data-URI。返回 {ok, ...} 或 {ok:false, reason}。绝不解析二维码。 */
13
+ export function validateQrDataUri(dataUri) {
14
+ const s = typeof dataUri === 'string' ? dataUri.trim() : '';
15
+ const m = QR_DATA_URI_RE.exec(s);
16
+ if (!m)
17
+ return { ok: false, reason: 'QR must be a data:image/(png|webp);base64 image (svg/html/text/jpeg rejected)' };
18
+ const subtype = m[1];
19
+ let buf;
20
+ try {
21
+ buf = Buffer.from(s.slice(s.indexOf(',') + 1), 'base64');
22
+ }
23
+ catch {
24
+ return { ok: false, reason: 'QR base64 decode failed' };
25
+ }
26
+ if (buf.length === 0)
27
+ return { ok: false, reason: 'QR is empty' };
28
+ if (buf.length > QR_MAX_BYTES)
29
+ return { ok: false, reason: `QR must decode to ≤ ${QR_MAX_BYTES} bytes` };
30
+ if (!magicMatches(subtype, buf))
31
+ return { ok: false, reason: `QR bytes do not match declared image/${subtype} (magic-byte mismatch)` };
32
+ const sha256 = createHash('sha256').update(buf).digest('hex');
33
+ return { ok: true, value: { mime: `image/${subtype}`, buf, sha256 } };
34
+ }
35
+ /** append-only 审计事件。只记 ref,【绝不】写 raw instruction / raw QR。 */
36
+ export function appendAccountEvent(db, args, generateId) {
37
+ db.prepare('INSERT INTO direct_receive_account_events (id, account_id, seller_id, event_type, qr_ref) VALUES (?,?,?,?,?)')
38
+ .run(generateId('drae'), args.accountId, args.sellerId, args.eventType, args.qrRef ?? null);
39
+ }
40
+ /**
41
+ * 存收款码(owner-scoped)。校验 → sha256 → 【一个同步事务】内:INSERT OR IGNORE 不可变行 + 更新 account.qr_image_ref
42
+ * + append qr_uploaded 事件。account 必须属于 sellerId(不属于 → {ok:false})。返回 ref。
43
+ */
44
+ export function storeQrImage(db, args, generateId) {
45
+ const v = validateQrDataUri(args.dataUri);
46
+ if (!v.ok)
47
+ return v;
48
+ const owns = db.prepare("SELECT 1 FROM direct_receive_accounts WHERE id = ? AND seller_id = ?").get(args.accountId, args.sellerId);
49
+ if (!owns)
50
+ return { ok: false, reason: 'account not found or not owned' };
51
+ const ref = v.value.sha256;
52
+ db.transaction(() => {
53
+ db.prepare('INSERT OR IGNORE INTO direct_receive_account_qr_images (ref, account_id, seller_id, mime, data_b64, byte_len, sha256) VALUES (?,?,?,?,?,?,?)')
54
+ .run(ref, args.accountId, args.sellerId, v.value.mime, v.value.buf.toString('base64'), v.value.buf.length, ref);
55
+ db.prepare("UPDATE direct_receive_accounts SET qr_image_ref = ?, updated_at = datetime('now') WHERE id = ? AND seller_id = ?").run(ref, args.accountId, args.sellerId);
56
+ appendAccountEvent(db, { accountId: args.accountId, sellerId: args.sellerId, eventType: 'qr_uploaded', qrRef: ref }, generateId);
57
+ })();
58
+ return { ok: true, ref };
59
+ }
60
+ /** owner-scoped 取码字节(供硬化端点转发)。非本人 / 不存在 → null(端点统一 404,不枚举)。 */
61
+ export function getQrImageForOwner(db, ref, sellerId) {
62
+ const row = db.prepare('SELECT mime, data_b64 FROM direct_receive_account_qr_images WHERE ref = ? AND seller_id = ?').get(ref, sellerId);
63
+ if (!row)
64
+ return null;
65
+ let buf;
66
+ try {
67
+ buf = Buffer.from(row.data_b64, 'base64');
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ // defense-in-depth: re-validate on read (mime whitelist + magic + size) — never serve unexpected bytes.
73
+ const re = validateQrDataUri(`data:${row.mime};base64,${row.data_b64}`);
74
+ if (!re.ok)
75
+ return null;
76
+ return { mime: row.mime, buf };
77
+ }
@@ -0,0 +1,82 @@
1
+ export const MAX_INSTRUCTION_LEN = 500;
2
+ export const MAX_LABEL_LEN = 40;
3
+ export const MAX_METHOD_LEN = 40;
4
+ export const MAX_QR_REF_LEN = 200;
5
+ // currency:卖家声明的币种码(自由文本,2-8 位大写字母数字)。WebAZ 不限制卖家怎么收钱;买家侧只在 FX 支持时才显换算。
6
+ export const CURRENCY_RE = /^[A-Z0-9]{2,8}$/;
7
+ /** PURE: 校验 + 规范化卖家入参(trim / 长度 / 币种格式 / 大写)。不碰 DB。
8
+ * 长度超限 = 【显式拒绝】(非静默截断)—— 卖家看到的收款标签 / 方式 / 二维码引用不能被悄悄改短。 */
9
+ export function normalizeAccountInput(input) {
10
+ const instruction = String(input?.instruction ?? '').trim();
11
+ if (!instruction)
12
+ return { ok: false, reason: 'instruction required' };
13
+ if (instruction.length > MAX_INSTRUCTION_LEN)
14
+ return { ok: false, reason: `instruction must be ≤ ${MAX_INSTRUCTION_LEN} chars` };
15
+ // trim + reject-if-over-limit (no silent slice). null/absent → null.
16
+ const field = (v, max, name) => {
17
+ if (v == null)
18
+ return { ok: true, v: null };
19
+ const s = String(v).trim();
20
+ if (s.length > max)
21
+ return { ok: false, reason: `${name} must be ≤ ${max} chars` };
22
+ return { ok: true, v: s || null };
23
+ };
24
+ const L = field(input.label, MAX_LABEL_LEN, 'label');
25
+ if (!L.ok)
26
+ return L;
27
+ const M = field(input.method, MAX_METHOD_LEN, 'method');
28
+ if (!M.ok)
29
+ return M;
30
+ const Q = field(input.qrImageRef, MAX_QR_REF_LEN, 'qr image ref');
31
+ if (!Q.ok)
32
+ return Q;
33
+ let currency = null;
34
+ if (input.currency != null && String(input.currency).trim()) {
35
+ currency = String(input.currency).trim().toUpperCase();
36
+ if (!CURRENCY_RE.test(currency))
37
+ return { ok: false, reason: 'currency must be a 2-8 char code (e.g. THB, IDR, USDC)' };
38
+ }
39
+ return { ok: true, value: { instruction, label: L.v, method: M.v, currency, qr_image_ref: Q.v } };
40
+ }
41
+ const COLS = 'id, seller_id, method, currency, instruction, label, qr_image_ref, status';
42
+ /** 卖家的收款账号(默认仅 active;{ includeInactive:true } 取全部)。多行。 */
43
+ export function listSellerAccounts(db, sellerId, opts = {}) {
44
+ const where = opts.includeInactive ? '' : " AND status = 'active'";
45
+ return db.prepare(`SELECT ${COLS} FROM direct_receive_accounts WHERE seller_id = ?${where} ORDER BY created_at ASC, id ASC`).all(sellerId);
46
+ }
47
+ /** 单个账号(任意状态);调用方自行做 owner 校验。 */
48
+ export function getAccount(db, id) {
49
+ return db.prepare(`SELECT ${COLS} FROM direct_receive_accounts WHERE id = ?`).get(id) ?? null;
50
+ }
51
+ /** 买家侧【可选收款账号】(仅 active)—— 只投影元数据 method/currency/label 供结算前选"怎么付"+FX 换算展示。
52
+ * ⚠️【绝不】含 instruction 原文 / qr_image_ref —— 收款目标受披露门保护(D1/D2 ack 后才随订单快照下发)。 */
53
+ export function listSellerAccountOptions(db, sellerId) {
54
+ return db.prepare("SELECT id AS account_id, method, currency, label FROM direct_receive_accounts WHERE seller_id = ? AND status = 'active' ORDER BY created_at ASC, id ASC").all(sellerId);
55
+ }
56
+ /** 新增一个 active 收款账号。入参经 normalizeAccountInput 校验;只写本表。 */
57
+ export function addAccount(db, sellerId, input, generateId) {
58
+ const norm = normalizeAccountInput(input);
59
+ if (!norm.ok)
60
+ return norm;
61
+ const id = generateId('dra');
62
+ const v = norm.value;
63
+ db.prepare(`INSERT INTO direct_receive_accounts (id, seller_id, method, currency, instruction, label, qr_image_ref, status) VALUES (?,?,?,?,?,?,?, 'active')`)
64
+ .run(id, sellerId, v.method, v.currency, v.instruction, v.label, v.qr_image_ref);
65
+ return { ok: true, account: { id, seller_id: sellerId, status: 'active', ...v } };
66
+ }
67
+ /** owner-scoped 文本字段更新(仅该 seller 拥有的行)。返回是否有行被改。
68
+ * ⚠️ 【绝不】碰 qr_image_ref —— 收款码生命周期【只】由 storeQrImage(/qr 端点)管理。
69
+ * 否则卖家改一次收款说明/标签/币种就会把已上传的 QR 解绑(qr_image_ref→null),核心功能掉链子。 */
70
+ export function updateAccount(db, id, sellerId, input) {
71
+ const norm = normalizeAccountInput(input);
72
+ if (!norm.ok)
73
+ return norm;
74
+ const v = norm.value;
75
+ const info = db.prepare(`UPDATE direct_receive_accounts SET method = ?, currency = ?, instruction = ?, label = ?, updated_at = datetime('now') WHERE id = ? AND seller_id = ?`).run(v.method, v.currency, v.instruction, v.label, id, sellerId);
76
+ return { ok: true, changed: info.changes > 0 };
77
+ }
78
+ /** owner-scoped 软停用。返回是否有行被停用。 */
79
+ export function deactivateAccount(db, id, sellerId) {
80
+ const info = db.prepare("UPDATE direct_receive_accounts SET status = 'inactive', updated_at = datetime('now') WHERE id = ? AND seller_id = ? AND status = 'active'").run(id, sellerId);
81
+ return info.changes > 0;
82
+ }
@@ -0,0 +1,142 @@
1
+ export const DEFAULT_DEFERRAL_CONFIG = {
2
+ defaultPeriodDays: 30, defaultGraceDays: 7, minReducedQuotaFactor: 0.1, maxReducedQuotaFactor: 0.9,
3
+ maxPeriodDays: 365, maxGraceDays: 90,
4
+ };
5
+ const isPosInt = (x) => typeof x === 'number' && Number.isInteger(x) && x > 0;
6
+ const getRow = (db, id) => db.prepare('SELECT id, user_id, status, period_days, reduced_quota_factor, expires_at, grace_until FROM direct_receive_deferrals WHERE id = ?').get(id);
7
+ /** 配额系数夹到 [min,max](不零威慑 + 缓交期必压低);非法/缺省 → 配置默认中点。 */
8
+ export function clampReducedQuotaFactor(factor, config = DEFAULT_DEFERRAL_CONFIG) {
9
+ const lo = config.minReducedQuotaFactor, hi = config.maxReducedQuotaFactor;
10
+ const f = typeof factor === 'number' && Number.isFinite(factor) ? factor : (lo + hi) / 2;
11
+ return Math.min(hi, Math.max(lo, f));
12
+ }
13
+ /** 是否有【活跃】缓交(pending,或 granted 且未过 grace_until)。活跃则禁止再申请。 */
14
+ function hasActiveDeferral(db, userId, nowIso) {
15
+ const rows = db.prepare("SELECT status, grace_until FROM direct_receive_deferrals WHERE user_id = ? AND status IN ('pending','granted')").all(userId);
16
+ const now = Date.parse(nowIso);
17
+ return rows.some(r => r.status === 'pending' || !r.grace_until || !Number.isFinite(Date.parse(r.grace_until)) || Date.parse(r.grace_until) > now);
18
+ }
19
+ /** 申请缓交(商户发起)→ pending。绝不自动授予。 */
20
+ export function requestDeferral(db, args) {
21
+ const { deferralId, userId, reason, nowIso } = args;
22
+ const config = args.config ?? DEFAULT_DEFERRAL_CONFIG;
23
+ if (!deferralId || !userId)
24
+ return { ok: false, reason: 'missing deferralId/userId' };
25
+ if (!Number.isFinite(Date.parse(nowIso)))
26
+ return { ok: false, reason: 'unparseable nowIso' };
27
+ if (getRow(db, deferralId))
28
+ return { ok: false, reason: 'deferral id already exists' };
29
+ const periodDays = args.periodDays ?? config.defaultPeriodDays;
30
+ if (!isPosInt(periodDays))
31
+ return { ok: false, reason: 'periodDays must be a positive integer' };
32
+ // 上限校验:超大 periodDays 会在 approve 时令 new Date(now + days*86_400_000) 溢出 → RangeError(approve 500)。
33
+ // 在【申请入口】就 fail-closed,既挡 500 也挡超长期缓交。
34
+ if (periodDays > config.maxPeriodDays)
35
+ return { ok: false, reason: `periodDays exceeds max (${config.maxPeriodDays})` };
36
+ if (hasActiveDeferral(db, userId, nowIso))
37
+ return { ok: false, reason: 'user already has an active deferral' };
38
+ db.prepare(`INSERT INTO direct_receive_deferrals (id, user_id, reason, period_days, status, created_at)
39
+ VALUES (?,?,?,?, 'pending', datetime('now'))`).run(deferralId, userId, reason ?? null, periodDays);
40
+ return { ok: true, status: 'pending' };
41
+ }
42
+ /** 审批通过 → granted。设缓交到期 + 宽限截止 + 压低配额。仅要求 caller 传非空 adminId(无自动批);
43
+ * ROOT/Passkey/human-presence 由【调用方 route】强制,helper 不验证身份。 */
44
+ export function approveDeferral(db, args) {
45
+ const { deferralId, adminId, nowIso } = args;
46
+ const config = args.config ?? DEFAULT_DEFERRAL_CONFIG;
47
+ if (!adminId)
48
+ return { ok: false, reason: 'approveDeferral requires a human adminId (no auto-grant)' };
49
+ const row = getRow(db, deferralId);
50
+ if (!row)
51
+ return { ok: false, reason: 'deferral not found' };
52
+ if (row.status === 'granted')
53
+ return { ok: true, status: 'granted', already: true };
54
+ if (row.status !== 'pending')
55
+ return { ok: false, reason: `cannot approve from status '${row.status}'` };
56
+ const now = Date.parse(nowIso);
57
+ if (!Number.isFinite(now))
58
+ return { ok: false, reason: 'unparseable nowIso' };
59
+ const graceDays = args.graceDays ?? config.defaultGraceDays;
60
+ if (!isPosInt(row.period_days) || !(Number.isInteger(graceDays) && graceDays >= 0))
61
+ return { ok: false, reason: 'invalid period/grace days' };
62
+ // 上限校验(防溢出 / 超长期):period 是申请时存的(理应已被 requestDeferral 卡住,这里防御 legacy/旁路行),
63
+ // grace 是 admin 本次输入。任一超大都会让 new Date(now + (period+grace)*86_400_000) 溢出 → RangeError。
64
+ // 返回【结构化错误】(由 route 转 409),绝不抛 → 杜绝"消耗 Passkey token 后 500"。
65
+ if (row.period_days > config.maxPeriodDays)
66
+ return { ok: false, reason: `periodDays exceeds max (${config.maxPeriodDays})` };
67
+ if (graceDays > config.maxGraceDays)
68
+ return { ok: false, reason: `graceDays exceeds max (${config.maxGraceDays})` };
69
+ const expiresAt = new Date(now + row.period_days * 86_400_000).toISOString().slice(0, 19).replace('T', ' ');
70
+ const graceUntil = new Date(now + (row.period_days + graceDays) * 86_400_000).toISOString().slice(0, 19).replace('T', ' ');
71
+ const factor = clampReducedQuotaFactor(args.reducedQuotaFactor, config);
72
+ db.prepare(`UPDATE direct_receive_deferrals SET status = 'granted', approved_by = ?, approved_at = datetime('now'),
73
+ expires_at = ?, grace_until = ?, reduced_quota_factor = ? WHERE id = ?`)
74
+ .run(adminId, expiresAt, graceUntil, factor, deferralId);
75
+ return { ok: true, status: 'granted' };
76
+ }
77
+ /** 真人 admin 拒绝 → rejected。 */
78
+ export function rejectDeferral(db, args) {
79
+ if (!args.adminId)
80
+ return { ok: false, reason: 'rejectDeferral requires a human adminId' };
81
+ const row = getRow(db, args.deferralId);
82
+ if (!row)
83
+ return { ok: false, reason: 'deferral not found' };
84
+ if (row.status === 'rejected')
85
+ return { ok: true, status: 'rejected', already: true };
86
+ if (row.status !== 'pending')
87
+ return { ok: false, reason: `cannot reject from status '${row.status}'` };
88
+ db.prepare("UPDATE direct_receive_deferrals SET status = 'rejected', approved_by = ?, approved_at = datetime('now') WHERE id = ?").run(args.adminId, args.deferralId);
89
+ return { ok: true, status: 'rejected' };
90
+ }
91
+ /**
92
+ * 取某 user 当前【生效中】的缓交(granted 且 now ≤ grace_until)。无 → null。供 PR2 eligibility / 配额读取。
93
+ * 【FAIL-CLOSED】:grace_until 或 expires_at 为空/不可解析 = 坏 granted 行,**绝不**当作 active(返回 null 跳过)。
94
+ * 语义与 expireDeferrals 一致(坏行视为可清理/不生效),杜绝"坏 granted 行被误认有效缓交"放进 eligibility。
95
+ */
96
+ export function getActiveDeferral(db, userId, nowIso) {
97
+ const now = Date.parse(nowIso);
98
+ if (!Number.isFinite(now))
99
+ return null;
100
+ const rows = db.prepare("SELECT id, reduced_quota_factor, expires_at, grace_until FROM direct_receive_deferrals WHERE user_id = ? AND status = 'granted'").all(userId);
101
+ for (const r of rows) {
102
+ const grace = r.grace_until ? Date.parse(r.grace_until) : NaN;
103
+ const exp = r.expires_at ? Date.parse(r.expires_at) : NaN;
104
+ // fail-closed:两个时钟锚点都必须是合法时间;grace 必须在未来。任一空/坏 → 跳过(不承认为 active)。
105
+ if (Number.isFinite(grace) && Number.isFinite(exp) && grace > now) {
106
+ return { id: r.id, reducedQuotaFactor: r.reduced_quota_factor, expiresAt: r.expires_at, graceUntil: r.grace_until, inGrace: now > exp };
107
+ }
108
+ }
109
+ return null;
110
+ }
111
+ /** 取某 user【最新一条】缓交申请(任意状态)。无 → null。纯读。供卖家自助 status 端点展示当前申请状态。 */
112
+ export function getLatestDeferral(db, userId) {
113
+ // created_at 同秒并列时用 rowid(插入序)兜底,保证"最新"=最后插入,确定性。
114
+ return db.prepare(`SELECT id, status, period_days, reduced_quota_factor, expires_at, grace_until, created_at
115
+ FROM direct_receive_deferrals WHERE user_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1`).get(userId) ?? null;
116
+ }
117
+ export function listDeferrals(db, opts = {}) {
118
+ const cols = 'id, user_id, reason, period_days, reduced_quota_factor, status, approved_by, approved_at, expires_at, grace_until, created_at';
119
+ if (opts.status) {
120
+ return db.prepare(`SELECT ${cols} FROM direct_receive_deferrals WHERE status = ? ORDER BY created_at DESC, rowid DESC`).all(opts.status);
121
+ }
122
+ return db.prepare(`SELECT ${cols} FROM direct_receive_deferrals ORDER BY created_at DESC, rowid DESC`).all();
123
+ }
124
+ /** 到期清理(cron):granted 且【超过 grace_until】→ expired。返回受影响的 user_id(调用方据此停权;本模块不改 privileges)。 */
125
+ export function expireDeferrals(db, nowIso) {
126
+ const now = Date.parse(nowIso);
127
+ if (!Number.isFinite(now))
128
+ return { expired: [] };
129
+ const rows = db.prepare("SELECT id, user_id, grace_until FROM direct_receive_deferrals WHERE status = 'granted'").all();
130
+ const expired = [];
131
+ const tx = db.transaction(() => {
132
+ for (const r of rows) {
133
+ const grace = r.grace_until ? Date.parse(r.grace_until) : NaN;
134
+ if (!Number.isFinite(grace) || grace <= now) {
135
+ db.prepare("UPDATE direct_receive_deferrals SET status = 'expired' WHERE id = ?").run(r.id);
136
+ expired.push(r.user_id);
137
+ }
138
+ }
139
+ });
140
+ tx();
141
+ return { expired };
142
+ }