@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.
- package/README.md +3 -2
- package/README.zh-CN.md +7 -6
- package/dist/bond-refund-blockers.js +50 -0
- package/dist/bond-slash.js +100 -0
- package/dist/bond-terms.js +31 -0
- package/dist/currency.js +16 -0
- package/dist/deposit-rails.js +52 -0
- package/dist/direct-pay-aml-monitor.js +67 -0
- package/dist/direct-pay-aml-review.js +40 -0
- package/dist/direct-pay-base-bond-entry.js +5 -0
- package/dist/direct-pay-bond-rail-clearance.js +99 -0
- package/dist/direct-pay-cancel-refund.js +160 -0
- package/dist/direct-pay-compliance-ingress.js +145 -0
- package/dist/direct-pay-controls.js +136 -0
- package/dist/direct-pay-create.js +231 -0
- package/dist/direct-pay-deferral-quota.js +43 -0
- package/dist/direct-pay-disclosures.js +50 -0
- package/dist/direct-pay-eligibility.js +46 -0
- package/dist/direct-pay-fee-ar.js +241 -0
- package/dist/direct-pay-fee-prepay-request.js +80 -0
- package/dist/direct-pay-launch-readiness.js +108 -0
- package/dist/direct-pay-launch-summary.js +68 -0
- package/dist/direct-pay-ledger.js +94 -0
- package/dist/direct-pay-returns.js +104 -0
- package/dist/direct-pay-stock.js +9 -0
- package/dist/direct-receive-account-qr.js +77 -0
- package/dist/direct-receive-accounts.js +82 -0
- package/dist/direct-receive-deferral.js +171 -0
- package/dist/direct-receive-deposits.js +365 -0
- package/dist/direct-receive-payment-instruction.js +26 -0
- package/dist/free-shipping.js +37 -0
- package/dist/fx-rates.js +78 -0
- package/dist/layer0-foundation/L0-1-database/schema.js +777 -1
- package/dist/layer0-foundation/L0-2-state-machine/engine.js +1 -0
- package/dist/layer0-foundation/L0-2-state-machine/genuine-sale.js +15 -5
- package/dist/layer0-foundation/L0-2-state-machine/transitions.js +114 -0
- package/dist/layer0-foundation/L0-5-manifest/manifest.js +1 -1
- package/dist/layer1-agent/L1-1-mcp-server/cli.js +64 -0
- package/dist/layer1-agent/L1-1-mcp-server/network-mode.js +11 -0
- package/dist/layer1-agent/L1-1-mcp-server/server.js +99 -32
- package/dist/layer2-business/L2-6-notifications/notification-engine.js +110 -41
- package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +133 -31
- package/dist/layer3-trust/L3-1-dispute-engine/evidence-storage.js +16 -4
- package/dist/layer3-trust/L3-1-dispute-engine/mutual-cancel.js +156 -0
- package/dist/layer4-economics/L4-4-skill-market/skill-engine.js +4 -4
- package/dist/ledger.js +12 -3
- package/dist/mcp.js +23 -4
- package/dist/merchant-bond-domain.js +64 -0
- package/dist/merchant-bond-exposure.js +78 -0
- package/dist/merchant-bond-watcher.js +26 -0
- package/dist/payment-rails.js +29 -0
- package/dist/platform-receive-accounts.js +94 -0
- package/dist/product-verification.js +93 -0
- package/dist/pwa/acp-feed.js +3 -2
- package/dist/pwa/arbitration-read-admin.js +37 -0
- package/dist/pwa/arbitrator-lifecycle.js +100 -0
- package/dist/pwa/contract-fingerprint.js +18 -0
- package/dist/pwa/direct-pay-guards.js +20 -0
- package/dist/pwa/direct-pay-order-redaction.js +42 -0
- package/dist/pwa/endpoint-actions.js +3 -0
- package/dist/pwa/human-presence.js +2 -6
- package/dist/pwa/public/app-account.js +9 -9
- package/dist/pwa/public/app-admin-disputes.js +55 -0
- package/dist/pwa/public/app-agent-appeal.js +90 -0
- package/dist/pwa/public/app-agent-approvals.js +93 -0
- package/dist/pwa/public/app-agent-pair.js +127 -0
- package/dist/pwa/public/app-ai.js +10 -10
- package/dist/pwa/public/app-arbitrator-admin.js +87 -0
- package/dist/pwa/public/app-arbitrator-entry.js +9 -0
- package/dist/pwa/public/app-bond-deferral-ui.js +9 -0
- package/dist/pwa/public/app-bond-refund-ui.js +66 -0
- package/dist/pwa/public/app-bond-slash-ui.js +74 -0
- package/dist/pwa/public/app-bond-terms-ui.js +23 -0
- package/dist/pwa/public/app-bond-ui.js +108 -0
- package/dist/pwa/public/app-chat-poll.js +29 -0
- package/dist/pwa/public/app-contribution-hub.js +23 -0
- package/dist/pwa/public/app-create-kinds.js +17 -0
- package/dist/pwa/public/app-direct-pay-accounts.js +141 -0
- package/dist/pwa/public/app-direct-pay-buyer.js +72 -0
- package/dist/pwa/public/app-direct-pay-cancel-refund.js +72 -0
- package/dist/pwa/public/app-direct-pay-compliance.js +67 -0
- package/dist/pwa/public/app-direct-pay-copy.js +12 -0
- package/dist/pwa/public/app-direct-pay-deferral-admin.js +72 -0
- package/dist/pwa/public/app-direct-pay-deferral.js +61 -0
- package/dist/pwa/public/app-direct-pay-fee-center.js +33 -0
- package/dist/pwa/public/app-direct-pay-fee-history.js +39 -0
- package/dist/pwa/public/app-direct-pay-fee-ops.js +112 -0
- package/dist/pwa/public/app-direct-pay-fee-request.js +81 -0
- package/dist/pwa/public/app-direct-pay-fee-requests-admin.js +70 -0
- package/dist/pwa/public/app-direct-pay-memo.js +14 -0
- package/dist/pwa/public/app-direct-pay-negotiation.js +35 -0
- package/dist/pwa/public/app-direct-pay-pay.js +15 -0
- package/dist/pwa/public/app-direct-pay-paymodal.js +32 -0
- package/dist/pwa/public/app-direct-pay-product-verify.js +103 -0
- package/dist/pwa/public/app-direct-pay-readiness.js +38 -0
- package/dist/pwa/public/app-direct-pay-reconcile.js +19 -0
- package/dist/pwa/public/app-direct-pay-returns.js +56 -0
- package/dist/pwa/public/app-direct-pay-reveal.js +82 -0
- package/dist/pwa/public/app-direct-pay-sales-report.js +70 -0
- package/dist/pwa/public/app-direct-pay-store-verify.js +100 -0
- package/dist/pwa/public/app-direct-pay.js +226 -0
- package/dist/pwa/public/app-discover.js +20 -20
- package/dist/pwa/public/app-dispute-close-ui.js +38 -0
- package/dist/pwa/public/app-external-links.js +32 -0
- package/dist/pwa/public/app-free-shipping-ui.js +29 -0
- package/dist/pwa/public/app-gmv-rail-split.js +12 -0
- package/dist/pwa/public/app-listing-commerce-ui.js +72 -0
- package/dist/pwa/public/app-listings.js +4 -4
- package/dist/pwa/public/app-mutual-cancel.js +54 -0
- package/dist/pwa/public/app-notif-templates-orders.js +43 -0
- package/dist/pwa/public/app-notif-templates.js +22 -0
- package/dist/pwa/public/app-order-accept-ui.js +158 -0
- package/dist/pwa/public/app-order-errors.js +50 -0
- package/dist/pwa/public/app-order-labels.js +19 -0
- package/dist/pwa/public/app-order-rail-filter.js +26 -0
- package/dist/pwa/public/app-platform-receive-accounts.js +140 -0
- package/dist/pwa/public/app-poll-governor.js +22 -0
- package/dist/pwa/public/app-prelaunch-waz.js +39 -0
- package/dist/pwa/public/app-price.js +55 -0
- package/dist/pwa/public/app-product-media.js +15 -0
- package/dist/pwa/public/app-profile.js +10 -10
- package/dist/pwa/public/app-purchase-terms-ui.js +68 -0
- package/dist/pwa/public/app-sale-regions-ui.js +38 -0
- package/dist/pwa/public/app-seller.js +5 -5
- package/dist/pwa/public/app-shop.js +19 -19
- package/dist/pwa/public/app-trade-tax-ui.js +33 -0
- package/dist/pwa/public/app.js +264 -295
- package/dist/pwa/public/i18n.js +1068 -197
- package/dist/pwa/public/index.html +58 -0
- package/dist/pwa/public/openapi.json +1845 -653
- package/dist/pwa/public/style.css +3 -0
- package/dist/pwa/routes/admin-analytics.js +4 -2
- package/dist/pwa/routes/admin-direct-receive-deposits.js +533 -0
- package/dist/pwa/routes/admin-protocol-params.js +16 -0
- package/dist/pwa/routes/admin-reports.js +31 -5
- package/dist/pwa/routes/agent-governance.js +1 -1
- package/dist/pwa/routes/agent-grants.js +253 -32
- package/dist/pwa/routes/analytics.js +2 -0
- package/dist/pwa/routes/arbitrator.js +67 -14
- package/dist/pwa/routes/bond-seller.js +162 -0
- package/dist/pwa/routes/buyer-feeds.js +2 -1
- package/dist/pwa/routes/chat.js +6 -1
- package/dist/pwa/routes/dashboards.js +2 -1
- package/dist/pwa/routes/direct-pay-availability.js +196 -0
- package/dist/pwa/routes/direct-pay-cancel-refund.js +111 -0
- package/dist/pwa/routes/direct-pay-disclosure-acks.js +79 -0
- package/dist/pwa/routes/direct-pay-pending-accept.js +222 -0
- package/dist/pwa/routes/direct-pay-returns.js +74 -0
- package/dist/pwa/routes/direct-pay-timeouts.js +248 -0
- package/dist/pwa/routes/direct-receive-accounts.js +155 -0
- package/dist/pwa/routes/direct-receive-payment-instructions.js +45 -0
- package/dist/pwa/routes/disputes-read.js +20 -6
- package/dist/pwa/routes/disputes-write.js +91 -33
- package/dist/pwa/routes/external-anchors.js +4 -2
- package/dist/pwa/routes/fee-prepay-requests.js +66 -0
- package/dist/pwa/routes/fx.js +12 -0
- package/dist/pwa/routes/governance-onboarding.js +46 -8
- package/dist/pwa/routes/leaderboard.js +47 -9
- package/dist/pwa/routes/listings.js +2 -2
- package/dist/pwa/routes/logistics.js +6 -2
- package/dist/pwa/routes/manifests.js +38 -0
- package/dist/pwa/routes/me-data.js +5 -2
- package/dist/pwa/routes/mutual-cancel.js +62 -0
- package/dist/pwa/routes/orders-action.js +297 -16
- package/dist/pwa/routes/orders-create.js +21 -6
- package/dist/pwa/routes/orders-read.js +106 -8
- package/dist/pwa/routes/p2p-products.js +2 -2
- package/dist/pwa/routes/platform-receive-accounts.js +111 -0
- package/dist/pwa/routes/products-create.js +15 -4
- package/dist/pwa/routes/products-links.js +34 -0
- package/dist/pwa/routes/products-list.js +2 -1
- package/dist/pwa/routes/products-update.js +37 -3
- package/dist/pwa/routes/profile-identity.js +4 -2
- package/dist/pwa/routes/promoter.js +3 -0
- package/dist/pwa/routes/referral.js +4 -0
- package/dist/pwa/routes/returns.js +60 -4
- package/dist/pwa/routes/rewards-apply.js +10 -5
- package/dist/pwa/routes/rewards-clearing-mature.js +96 -0
- package/dist/pwa/routes/rewards-escrow-expire.js +6 -2
- package/dist/pwa/routes/seller-directpay-report.js +110 -0
- package/dist/pwa/routes/seller-quota.js +5 -1
- package/dist/pwa/routes/shipping-templates.js +219 -0
- package/dist/pwa/routes/shops.js +2 -1
- package/dist/pwa/routes/snf.js +4 -1
- package/dist/pwa/routes/url-claim.js +2 -2
- package/dist/pwa/routes/users-public.js +8 -0
- package/dist/pwa/routes/wallet-read.js +3 -0
- package/dist/pwa/routes/webauthn.js +3 -3
- package/dist/pwa/server.js +112 -132
- package/dist/runtime/agent-grant-scopes.js +69 -1
- package/dist/runtime/webaz-schema-helpers.js +161 -3
- package/dist/sale-regions.js +116 -0
- package/dist/shipping-templates.js +119 -0
- package/dist/store-verification.js +77 -0
- package/dist/trade-tax.js +99 -0
- package/dist/trade-terms.js +76 -0
- package/dist/version.js +1 -1
- package/package.json +128 -2
|
@@ -0,0 +1,171 @@
|
|
|
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
|
+
// ───── B4:缓交收口 —— 到期前提醒 / 缴清转正式 / 到期停权 helper ─────────────────────────────
|
|
125
|
+
/** 即将到期且未提醒过的 granted 缓交(expires_at 在 now..now+withinDays 内)。cron 提醒用;提醒后 markDeferralReminded 去重。 */
|
|
126
|
+
export function listExpiringDeferrals(db, nowIso, withinDays) {
|
|
127
|
+
const now = Date.parse(nowIso);
|
|
128
|
+
if (!Number.isFinite(now))
|
|
129
|
+
return [];
|
|
130
|
+
const rows = db.prepare("SELECT id, user_id, expires_at FROM direct_receive_deferrals WHERE status = 'granted' AND reminder_sent_at IS NULL AND expires_at IS NOT NULL").all();
|
|
131
|
+
const horizon = now + Math.max(0, withinDays) * 86_400_000;
|
|
132
|
+
return rows.filter(r => { const e = Date.parse(r.expires_at); return Number.isFinite(e) && e > now && e <= horizon; });
|
|
133
|
+
}
|
|
134
|
+
export function markDeferralReminded(db, deferralId) {
|
|
135
|
+
db.prepare("UPDATE direct_receive_deferrals SET reminder_sent_at = datetime('now') WHERE id = ? AND reminder_sent_at IS NULL").run(deferralId);
|
|
136
|
+
}
|
|
137
|
+
/** 缴清转正式(B4):生产保证金确认后调用 —— 该 user 的 granted 缓交 → satisfied(解除缓交额度压低;
|
|
138
|
+
* 入场门此后经 bond 满足)。返回转化条数。 */
|
|
139
|
+
export function satisfyDeferralOnBond(db, userId) {
|
|
140
|
+
return db.prepare("UPDATE direct_receive_deferrals SET status = 'satisfied', satisfied_at = datetime('now') WHERE user_id = ? AND status = 'granted'").run(userId).changes;
|
|
141
|
+
}
|
|
142
|
+
/** 到期停权(B4):expireDeferrals 的调用方用 —— 该 user 无生产 bond 时把 privilege 置 suspended(理由 deferral_expired)。
|
|
143
|
+
* 有生产 bond(缓交期间已缴清但 satisfied 漏转的兜底)→ 不停权。 */
|
|
144
|
+
export function suspendPrivilegeOnDeferralExpiry(db, userId) {
|
|
145
|
+
const hasBond = !!db.prepare("SELECT 1 FROM direct_receive_deposits WHERE user_id = ? AND status = 'locked' AND production_receipt_confirmed_at IS NOT NULL LIMIT 1").get(userId);
|
|
146
|
+
if (hasBond)
|
|
147
|
+
return false;
|
|
148
|
+
db.prepare(`INSERT INTO direct_receive_privileges (user_id, status, tier, suspended_reason, updated_at)
|
|
149
|
+
VALUES (?, 'suspended', 'T0', 'deferral_expired', datetime('now'))
|
|
150
|
+
ON CONFLICT(user_id) DO UPDATE SET status='suspended', suspended_reason='deferral_expired', updated_at=datetime('now')`).run(userId);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
/** 到期清理(cron):granted 且【超过 grace_until】→ expired。返回受影响的 user_id(调用方据此停权;本模块不改 privileges —— 停权用 suspendPrivilegeOnDeferralExpiry)。 */
|
|
154
|
+
export function expireDeferrals(db, nowIso) {
|
|
155
|
+
const now = Date.parse(nowIso);
|
|
156
|
+
if (!Number.isFinite(now))
|
|
157
|
+
return { expired: [] };
|
|
158
|
+
const rows = db.prepare("SELECT id, user_id, grace_until FROM direct_receive_deferrals WHERE status = 'granted'").all();
|
|
159
|
+
const expired = [];
|
|
160
|
+
const tx = db.transaction(() => {
|
|
161
|
+
for (const r of rows) {
|
|
162
|
+
const grace = r.grace_until ? Date.parse(r.grace_until) : NaN;
|
|
163
|
+
if (!Number.isFinite(grace) || grace <= now) {
|
|
164
|
+
db.prepare("UPDATE direct_receive_deferrals SET status = 'expired' WHERE id = ?").run(r.id);
|
|
165
|
+
expired.push(r.user_id);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
tx();
|
|
170
|
+
return { expired };
|
|
171
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { toUnits, toDecimal } from './money.js';
|
|
2
|
+
import { getDepositRail, assertProductionDepositRail } from './deposit-rails.js';
|
|
3
|
+
import { assertBondRailCleared } from './direct-pay-bond-rail-clearance.js';
|
|
4
|
+
import { recordBaseBondSlash } from './direct-pay-ledger.js';
|
|
5
|
+
import { getPlatformAccount } from './platform-receive-accounts.js'; // P1:确认时账户快照必须可解析(审计链)
|
|
6
|
+
/** LOCKED 保守默认(治理可调)。T0 = 固定 token 数(标签 ≈ S$500,非 FX)。 */
|
|
7
|
+
export const DEFAULT_BASE_BOND_CONFIG = {
|
|
8
|
+
tierRequiredUnits: { T0: toUnits(500) },
|
|
9
|
+
pendingTtlDays: 7,
|
|
10
|
+
};
|
|
11
|
+
const isNonNegUnits = (x) => typeof x === 'number' && Number.isSafeInteger(x) && x >= 0;
|
|
12
|
+
const getRow = (db, id) => db.prepare('SELECT id, user_id, tier, required_amount, amount, currency, deposit_rail, status, production_receipt_confirmed_at, terms_version, platform_account_id, created_at FROM direct_receive_deposits WHERE id = ?').get(id);
|
|
13
|
+
/** 某档要求的【固定 token 数】(整数 base-units)。未配置的档 → 抛(T1/T2 在 PR-5 才支持)。 */
|
|
14
|
+
export function requiredBondUnits(tier, config = DEFAULT_BASE_BOND_CONFIG) {
|
|
15
|
+
const u = config.tierRequiredUnits[tier];
|
|
16
|
+
if (!isNonNegUnits(u) || u <= 0)
|
|
17
|
+
throw new Error(`requiredBondUnits: tier '${tier}' 未配置或非正整数 units(4b-min 仅支持已配置档,通常 T0)`);
|
|
18
|
+
return u;
|
|
19
|
+
}
|
|
20
|
+
/** 开新存款:创建 pending 行。required = 该档固定 token 数。amount=0(待 confirm)。 */
|
|
21
|
+
export function openDeposit(db, args) {
|
|
22
|
+
const { depositId, userId, tier, currency, depositRail, config } = args;
|
|
23
|
+
if (!depositId || !userId)
|
|
24
|
+
return { ok: false, reason: 'missing depositId/userId' };
|
|
25
|
+
// WAZ NOT enabled:base bond 只接受【外部资产币种】usdc | fiat(生产收款仍 GATED);'waz' 一律 fail-closed,
|
|
26
|
+
// 杜绝把 schema 默认 currency='waz' 固化成合法路径(防 4c/4f 误接成"WAZ 担保物可用")。
|
|
27
|
+
if (!['usdc', 'fiat'].includes(currency))
|
|
28
|
+
return { ok: false, reason: `currency '${currency}' not allowed for base bond (only usdc|fiat; WAZ not enabled)` };
|
|
29
|
+
if (!['manual', 'operator_attested', 'usdc_onchain', 'fiat_psp'].includes(depositRail))
|
|
30
|
+
return { ok: false, reason: `invalid deposit_rail '${depositRail}'` };
|
|
31
|
+
if (getRow(db, depositId))
|
|
32
|
+
return { ok: false, reason: 'deposit already exists' };
|
|
33
|
+
let required;
|
|
34
|
+
try {
|
|
35
|
+
required = requiredBondUnits(tier, config);
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
return { ok: false, reason: e.message };
|
|
39
|
+
}
|
|
40
|
+
db.prepare(`INSERT INTO direct_receive_deposits (id, user_id, tier, required_amount, amount, currency, deposit_rail, status, external_ref, terms_version, platform_account_id, created_at, updated_at)
|
|
41
|
+
VALUES (?,?,?,?,0,?,?, 'pending', ?, ?, ?, datetime('now'), datetime('now'))`)
|
|
42
|
+
.run(depositId, userId, tier, toDecimal(required), currency, depositRail, args.externalRef ?? null, args.termsVersion ?? null, args.platformAccountId ?? null);
|
|
43
|
+
return { ok: true, status: 'pending' };
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* 确认到账(经 deposit-rail 网关)。
|
|
47
|
+
* - manual rail:仅 test/non-production —— 置 amount + status=confirmed,**绝不**设 production_receipt_confirmed_at。
|
|
48
|
+
* - usdc_onchain / fiat_psp:getDepositRail().confirmReceipt() 直接【抛】(GATED,fail-closed),本函数不吞。
|
|
49
|
+
* production_receipt_confirmed_at 只由【未来的 legal-cleared 生产 rail】设置(当前不存在)→ 本 PR 后恒 NULL。
|
|
50
|
+
*/
|
|
51
|
+
export function confirmDepositReceipt(db, args) {
|
|
52
|
+
const { depositId, expectedAmountUnits, externalRef } = args;
|
|
53
|
+
const row = getRow(db, depositId);
|
|
54
|
+
if (!row)
|
|
55
|
+
return { ok: false, reason: 'deposit not found' };
|
|
56
|
+
// 生产 rail(operator_attested / usdc_onchain / fiat_psp)只能走 confirmProductionReceipt(ROOT + Passkey + Lock B)。
|
|
57
|
+
// confirmDepositReceipt 是【非生产 / manual 专用】路径 —— 任何生产 rail 在此即抛(fail-closed),杜绝被
|
|
58
|
+
// confirmDepositReceipt + lockBond 锁成 active privilege、绕过生产确认门(operator_attested 的 record-only
|
|
59
|
+
// confirmReceipt 不会自己抛,故必须在此显式拦)。
|
|
60
|
+
if (getDepositRail(row.deposit_rail).isProduction) {
|
|
61
|
+
throw new Error(`deposit-rail '${row.deposit_rail}' is a production rail — must use confirmProductionReceipt (ROOT + Passkey); confirmDepositReceipt is manual/non-production only`);
|
|
62
|
+
}
|
|
63
|
+
if (row.status === 'confirmed' || row.status === 'locked')
|
|
64
|
+
return { ok: true, status: row.status, already: true }; // 幂等
|
|
65
|
+
if (row.status !== 'pending' && row.status !== 'insufficient')
|
|
66
|
+
return { ok: false, reason: `cannot confirm from status '${row.status}'` };
|
|
67
|
+
if (!isNonNegUnits(expectedAmountUnits) || expectedAmountUnits <= 0)
|
|
68
|
+
return { ok: false, reason: 'expectedAmount must be a positive integer base-units' };
|
|
69
|
+
// 网关:manual → confirmed(test);usdc/fiat → 抛(GATED)。本函数不 try/catch 网关,让 GATED 抛穿透(fail-closed)。
|
|
70
|
+
const conf = getDepositRail(row.deposit_rail)
|
|
71
|
+
.confirmReceipt({ depositId, expectedAmount: expectedAmountUnits, currency: row.currency, externalRef });
|
|
72
|
+
if (!conf.confirmed)
|
|
73
|
+
return { ok: false, reason: conf.reason || 'receipt not confirmed' };
|
|
74
|
+
// manual = 非生产 → 绝不写 production_receipt_confirmed_at(保持 NULL)。
|
|
75
|
+
db.prepare(`UPDATE direct_receive_deposits SET amount = ?, status = 'confirmed', confirmed_at = datetime('now'),
|
|
76
|
+
external_ref = COALESCE(?, external_ref), updated_at = datetime('now') WHERE id = ?`)
|
|
77
|
+
.run(toDecimal(expectedAmountUnits), conf.externalRef ?? externalRef ?? null, depositId);
|
|
78
|
+
return { ok: true, status: 'confirmed' };
|
|
79
|
+
}
|
|
80
|
+
/** 锁定:confirmed 且 amount ≥ required → locked + 激活 privilege(单事务)。WAZ NOT enabled → 不动任何余额。 */
|
|
81
|
+
export function lockBond(db, args) {
|
|
82
|
+
const row = getRow(db, args.depositId);
|
|
83
|
+
if (!row)
|
|
84
|
+
return { ok: false, reason: 'deposit not found' };
|
|
85
|
+
if (row.status === 'locked')
|
|
86
|
+
return { ok: true, status: 'locked', already: true }; // 幂等
|
|
87
|
+
if (row.status !== 'confirmed')
|
|
88
|
+
return { ok: false, reason: `cannot lock from status '${row.status}' (must be confirmed)` };
|
|
89
|
+
if (toUnits(row.amount) < toUnits(row.required_amount))
|
|
90
|
+
return { ok: false, reason: 'insufficient: amount < required' };
|
|
91
|
+
db.transaction(() => {
|
|
92
|
+
db.prepare("UPDATE direct_receive_deposits SET status = 'locked', locked_at = datetime('now'), updated_at = datetime('now') WHERE id = ?").run(args.depositId);
|
|
93
|
+
// ⚠️ privilege=active 在 4b-min 是【非生产】(manual/test;本 PR 的 lock 不带 production_receipt_confirmed_at)。
|
|
94
|
+
// active 本身【不是】production go-live 的充分条件 —— 生产门是 isProductionBaseBondLocked()(production receipt 非 NULL),
|
|
95
|
+
// 由 4c 强制;active 仅表示"已走完非生产 lock 流程"。
|
|
96
|
+
db.prepare(`INSERT INTO direct_receive_privileges (user_id, status, tier, granted_at, updated_at)
|
|
97
|
+
VALUES (?, 'active', ?, datetime('now'), datetime('now'))
|
|
98
|
+
ON CONFLICT(user_id) DO UPDATE SET status='active', tier=excluded.tier, suspended_reason=NULL, granted_at=COALESCE(direct_receive_privileges.granted_at, excluded.granted_at), updated_at=datetime('now')`)
|
|
99
|
+
.run(row.user_id, row.tier);
|
|
100
|
+
})();
|
|
101
|
+
return { ok: true, status: 'locked' };
|
|
102
|
+
}
|
|
103
|
+
/** 标记不足:confirmed 但 amount < required → insufficient(待 top-up)。 */
|
|
104
|
+
export function markInsufficient(db, args) {
|
|
105
|
+
const row = getRow(db, args.depositId);
|
|
106
|
+
if (!row)
|
|
107
|
+
return { ok: false, reason: 'deposit not found' };
|
|
108
|
+
if (row.status === 'insufficient')
|
|
109
|
+
return { ok: true, status: 'insufficient', already: true };
|
|
110
|
+
if (row.status !== 'confirmed')
|
|
111
|
+
return { ok: false, reason: `cannot mark insufficient from status '${row.status}'` };
|
|
112
|
+
if (toUnits(row.amount) >= toUnits(row.required_amount))
|
|
113
|
+
return { ok: false, reason: 'amount already meets required' };
|
|
114
|
+
db.prepare("UPDATE direct_receive_deposits SET status = 'insufficient', updated_at = datetime('now') WHERE id = ?").run(args.depositId);
|
|
115
|
+
return { ok: true, status: 'insufficient' };
|
|
116
|
+
}
|
|
117
|
+
/** 补足:amount += add(整数 units)。若达/超 required 且当前 insufficient/confirmed → 置 confirmed(可再 lock)。 */
|
|
118
|
+
export function topUp(db, args) {
|
|
119
|
+
const row = getRow(db, args.depositId);
|
|
120
|
+
if (!row)
|
|
121
|
+
return { ok: false, reason: 'deposit not found' };
|
|
122
|
+
if (!isNonNegUnits(args.addUnits) || args.addUnits <= 0)
|
|
123
|
+
return { ok: false, reason: 'addUnits must be a positive integer base-units' };
|
|
124
|
+
if (!['confirmed', 'insufficient'].includes(row.status))
|
|
125
|
+
return { ok: false, reason: `cannot top-up from status '${row.status}'` };
|
|
126
|
+
const newAmount = toUnits(row.amount) + args.addUnits;
|
|
127
|
+
db.prepare("UPDATE direct_receive_deposits SET amount = ?, status = 'confirmed', updated_at = datetime('now') WHERE id = ?")
|
|
128
|
+
.run(toDecimal(newAmount), args.depositId);
|
|
129
|
+
return { ok: true, status: 'confirmed' };
|
|
130
|
+
}
|
|
131
|
+
/** 过期:pending 且超过 TTL → expired。 */
|
|
132
|
+
export function expireDeposit(db, args) {
|
|
133
|
+
const row = getRow(db, args.depositId);
|
|
134
|
+
if (!row)
|
|
135
|
+
return { ok: false, reason: 'deposit not found' };
|
|
136
|
+
if (row.status !== 'pending')
|
|
137
|
+
return { ok: false, reason: `only pending can expire (got '${row.status}')` };
|
|
138
|
+
const ttlDays = args.config?.pendingTtlDays ?? DEFAULT_BASE_BOND_CONFIG.pendingTtlDays;
|
|
139
|
+
const created = Date.parse(row.created_at), now = Date.parse(args.nowIso);
|
|
140
|
+
if (!Number.isFinite(created) || !Number.isFinite(now))
|
|
141
|
+
return { ok: false, reason: 'unparseable timestamps' };
|
|
142
|
+
if (now - created < ttlDays * 86_400_000)
|
|
143
|
+
return { ok: false, reason: 'not yet past TTL' };
|
|
144
|
+
db.prepare("UPDATE direct_receive_deposits SET status = 'expired', updated_at = datetime('now') WHERE id = ?").run(args.depositId);
|
|
145
|
+
return { ok: true, status: 'expired' };
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* 罚没(卖家违约):locked → slashed,仅记 provenance(recordBaseBondSlash:total_base_bond_slash + txn),
|
|
149
|
+
* **不动任何 balance / 无 outflow**(WAZ NOT enabled),并吊销 privilege(suspended)。单事务,幂等。
|
|
150
|
+
*/
|
|
151
|
+
export function slashBond(db, args) {
|
|
152
|
+
const row = getRow(db, args.depositId);
|
|
153
|
+
if (!row)
|
|
154
|
+
return { ok: false, reason: 'deposit not found' };
|
|
155
|
+
if (row.status === 'slashed')
|
|
156
|
+
return { ok: true, status: 'slashed', already: true }; // 幂等:不重复记 provenance
|
|
157
|
+
// B3:refunding(退出冷静期中)也可罚 —— 退出申请不能成为躲避罚没的通道;罚没即终结退还流。
|
|
158
|
+
if (row.status !== 'locked' && row.status !== 'refunding')
|
|
159
|
+
return { ok: false, reason: `can only slash a locked/refunding bond (got '${row.status}')` };
|
|
160
|
+
db.transaction(() => {
|
|
161
|
+
recordBaseBondSlash(db, { userId: row.user_id, amountUnits: toUnits(row.amount), txnId: args.txnId, reason: args.reason }); // provenance only
|
|
162
|
+
db.prepare("UPDATE direct_receive_deposits SET status = 'slashed', updated_at = datetime('now') WHERE id = ?").run(args.depositId);
|
|
163
|
+
db.prepare(`INSERT INTO direct_receive_privileges (user_id, status, tier, suspended_reason, updated_at)
|
|
164
|
+
VALUES (?, 'suspended', ?, ?, datetime('now'))
|
|
165
|
+
ON CONFLICT(user_id) DO UPDATE SET status='suspended', suspended_reason=excluded.suspended_reason, updated_at=datetime('now')`)
|
|
166
|
+
.run(row.user_id, row.tier, args.reason ?? 'base_bond_slashed');
|
|
167
|
+
})();
|
|
168
|
+
return { ok: true, status: 'slashed' };
|
|
169
|
+
}
|
|
170
|
+
// ───── PR-4b-3: 生产 receipt 写入(唯一 writer)─────────────────────────────────────────────────
|
|
171
|
+
/** base-bond/合规 policy 版本 —— 【服务端生成】(非客户端入参)。2026-07-05 起 = 条款版本(bond-terms.ts,
|
|
172
|
+
* 缴纳前强制同意;Holden 决策 B 放行 operator_attested,详见 bond-rail-clearance 文件头)。 */
|
|
173
|
+
export const DIRECT_PAY_BASE_BOND_POLICY_VERSION = 'bond-terms.v1.2026-07-05';
|
|
174
|
+
/** 生产 receipt 允许的法域【严格白名单】。语义(P2 澄清):这是【平台收款主体法域】(WebAZ 以哪个法域主体
|
|
175
|
+
* 接收担保物;决定 registry 放行口径),【不是】卖家法域 —— 卖家侧资格由 KYB/制裁/AML 门独立把守。
|
|
176
|
+
* 2026-07-05:SG(随 operator_attested 放行)。 */
|
|
177
|
+
export const DIRECT_PAY_BOND_JURISDICTIONS = ['SG'];
|
|
178
|
+
/**
|
|
179
|
+
* 生产保证金 receipt【唯一】writer(PR-4b-3 scaffold)。这是【整个协议中】唯一允许写 production_receipt_confirmed_at
|
|
180
|
+
* 的函数(#100 guard 机器强制:写赋值必在本函数内 且 本函数必调 assertProductionDepositRail)。
|
|
181
|
+
*
|
|
182
|
+
* 2026-07-05 起:operator_attested 轨已放行(SG;Holden 决策 B,详见 bond-rail-clearance 文件头)→ 本函数
|
|
183
|
+
* 对该轨【真实可写】(仍要求 ROOT+Passkey 路由门 + 双锁 assert + jurisdiction 白名单)。manual(非生产)与
|
|
184
|
+
* usdc/fiat 自动收款轨照旧被 Lock A/B 拒。不碰 buyer wallet/escrow/order/settlement/refund。
|
|
185
|
+
*
|
|
186
|
+
* 硬约束(即便未来 rail 放行也必须守):
|
|
187
|
+
* - 拒绝把【非生产 locked】(manual/test lock,无 production receipt)升级成生产 —— 旧/测试行不得冒充生产到位。
|
|
188
|
+
* - jurisdiction 必须 ∈ DIRECT_PAY_BOND_JURISDICTIONS(严格白名单);policy_version 由服务端常量盖章,非入参。
|
|
189
|
+
* - assert 在任何写之前;原子 confirm+lock(amount + status=locked + production receipt + provenance 快照 + privilege)。
|
|
190
|
+
*/
|
|
191
|
+
export function confirmProductionReceipt(db, args) {
|
|
192
|
+
const { depositId, railId, expectedAmountUnits, receiptRef, jurisdiction } = args;
|
|
193
|
+
// ⚠️ 生产/法务硬闸 —— assert【真正第一】:在任何 row 读取 / 幂等 / 拒绝 / 写之前。未放行 rail 一律抛。
|
|
194
|
+
// 置于最前的语义意义:即便某 deposit 之前在【已 cleared 的 rail】下确认过,若该 rail 后被【撤回】(legalCleared→false),
|
|
195
|
+
// 重新确认也不再被当"幂等已确认"放过 —— 必须重新通过闸。#100 guard 要求本 helper body 必含此调用。
|
|
196
|
+
assertProductionDepositRail(getDepositRail(railId));
|
|
197
|
+
// Lock B(Phase 4 scaffold):rail-clearance registry 放行闸 —— legal_cleared + production_ready + 非占位 policy_version +
|
|
198
|
+
// jurisdiction ∈ allowlist。与 Lock A【独立】,缺一即拒;置于任何 row 读/写之前。
|
|
199
|
+
assertBondRailCleared(railId, jurisdiction);
|
|
200
|
+
// ───── 以下在 legal-cleared 生产 rail 落地前【全部不可达】(两把闸已抛)─────
|
|
201
|
+
const row = getRow(db, depositId);
|
|
202
|
+
if (!row)
|
|
203
|
+
return { ok: false, reason: 'deposit not found' };
|
|
204
|
+
if (railId !== row.deposit_rail)
|
|
205
|
+
return { ok: false, reason: 'rail_id does not match the deposit rail' };
|
|
206
|
+
// 幂等:已生产确认 → already(此时 rail 仍 cleared,assert 已过)
|
|
207
|
+
if (row.status === 'locked' && row.production_receipt_confirmed_at != null)
|
|
208
|
+
return { ok: true, status: 'locked', already: true };
|
|
209
|
+
// 拒绝把【非生产 locked】(manual/test lock,无 production receipt)升级成生产
|
|
210
|
+
if (row.status === 'locked')
|
|
211
|
+
return { ok: false, reason: 'deposit is locked WITHOUT a production receipt (manual/test) — cannot be upgraded to production' };
|
|
212
|
+
if (!receiptRef)
|
|
213
|
+
return { ok: false, reason: 'missing production receipt ref' };
|
|
214
|
+
if (!DIRECT_PAY_BOND_JURISDICTIONS.includes(jurisdiction))
|
|
215
|
+
return { ok: false, reason: `jurisdiction '${jurisdiction}' not in allowlist` };
|
|
216
|
+
if (!['pending', 'confirmed', 'insufficient'].includes(row.status))
|
|
217
|
+
return { ok: false, reason: `cannot production-confirm from status '${row.status}'` };
|
|
218
|
+
// P1(#240 审计):生产确认强制【条款同意 + 平台收款账户】—— 没有当前版本条款同意 = 罚没/退还无合同依据;
|
|
219
|
+
// 没有账户快照 = 收款审计链断。旧申报/绕过 seller route 的 openDeposit 行在此被拒(须重新申报)。
|
|
220
|
+
if (row.terms_version !== DIRECT_PAY_BASE_BOND_POLICY_VERSION) {
|
|
221
|
+
return { ok: false, reason: `deposit lacks CURRENT terms agreement (has '${row.terms_version ?? 'none'}', require '${DIRECT_PAY_BASE_BOND_POLICY_VERSION}') — seller must re-declare with terms consent` };
|
|
222
|
+
}
|
|
223
|
+
if (!row.platform_account_id)
|
|
224
|
+
return { ok: false, reason: 'deposit lacks a platform receiving account snapshot — seller must re-declare selecting an account' };
|
|
225
|
+
if (!getPlatformAccount(db, row.platform_account_id))
|
|
226
|
+
return { ok: false, reason: `platform account '${row.platform_account_id}' not found — audit chain broken, reject` };
|
|
227
|
+
if (!isNonNegUnits(expectedAmountUnits) || expectedAmountUnits <= 0)
|
|
228
|
+
return { ok: false, reason: 'expectedAmount must be a positive integer base-units' };
|
|
229
|
+
if (expectedAmountUnits < toUnits(row.required_amount))
|
|
230
|
+
return { ok: false, reason: 'insufficient: amount < required' };
|
|
231
|
+
db.transaction(() => {
|
|
232
|
+
db.prepare(`UPDATE direct_receive_deposits SET amount = ?, status = 'locked', confirmed_at = COALESCE(confirmed_at, datetime('now')),
|
|
233
|
+
locked_at = datetime('now'), production_receipt_confirmed_at = datetime('now'),
|
|
234
|
+
production_receipt_ref = ?, production_rail_id = ?, production_jurisdiction = ?, production_policy_version = ?,
|
|
235
|
+
external_ref = COALESCE(?, external_ref), updated_at = datetime('now') WHERE id = ?`)
|
|
236
|
+
.run(toDecimal(expectedAmountUnits), receiptRef, railId, jurisdiction, DIRECT_PAY_BASE_BOND_POLICY_VERSION, receiptRef, depositId);
|
|
237
|
+
db.prepare(`INSERT INTO direct_receive_privileges (user_id, status, tier, granted_at, updated_at)
|
|
238
|
+
VALUES (?, 'active', ?, datetime('now'), datetime('now'))
|
|
239
|
+
ON CONFLICT(user_id) DO UPDATE SET status='active', tier=excluded.tier, suspended_reason=NULL, granted_at=COALESCE(direct_receive_privileges.granted_at, excluded.granted_at), updated_at=datetime('now')`)
|
|
240
|
+
.run(row.user_id, row.tier);
|
|
241
|
+
})();
|
|
242
|
+
return { ok: true, status: 'locked' };
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* 生产门读取:某存款是否【生产级】锁定 = status='locked' 且 production_receipt_confirmed_at 非 NULL。
|
|
246
|
+
* 4b-min 永远 false(manual rail 不设该列;无生产 rail)。供后续 4c 生产 go-live 事实装配【单一真相】,杜绝 manual 冒充。
|
|
247
|
+
*/
|
|
248
|
+
export function isProductionBaseBondLocked(db, args) {
|
|
249
|
+
const row = getRow(db, args.depositId);
|
|
250
|
+
return !!row && row.status === 'locked' && row.production_receipt_confirmed_at != null;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* 卖家级生产门(PR-4c go-live 充分必要的担保侧条件):该卖家是否有任一【生产级】锁定 base bond
|
|
254
|
+
* (status='locked' 且 production_receipt_confirmed_at 非 NULL)。**非仅看 direct_receive_privileges.status='active'**。
|
|
255
|
+
* 2026-07-05 起 operator_attested 放行 → 经运营确认的保证金使本门为真(直付整体开关 direct_pay.enabled 另判)。
|
|
256
|
+
* 一个生产级 receipt 只会发给已 KYC + 制裁筛查的商户,故该门也承接了 KYC/sanctions(独立 4a 事实门待其系统就绪再接)。
|
|
257
|
+
*/
|
|
258
|
+
export function sellerHasProductionBaseBondLocked(db, sellerId) {
|
|
259
|
+
return !!db.prepare("SELECT 1 FROM direct_receive_deposits WHERE user_id = ? AND status = 'locked' AND production_receipt_confirmed_at IS NOT NULL LIMIT 1").get(sellerId);
|
|
260
|
+
}
|
|
261
|
+
/** admin 驳回申报(B1):pending|insufficient|confirmed(未 lock)→ expired + 驳回说明。locked/生产确认后不可驳。幂等。 */
|
|
262
|
+
export function rejectDeposit(db, args) {
|
|
263
|
+
const row = getRow(db, args.depositId);
|
|
264
|
+
if (!row)
|
|
265
|
+
return { ok: false, reason: 'deposit not found' };
|
|
266
|
+
if (row.status === 'expired')
|
|
267
|
+
return { ok: true, status: 'expired', already: true };
|
|
268
|
+
if (!['pending', 'insufficient', 'confirmed'].includes(row.status))
|
|
269
|
+
return { ok: false, reason: `cannot reject from status '${row.status}'` };
|
|
270
|
+
db.prepare(`UPDATE direct_receive_deposits SET status = 'expired', reject_note = ?, updated_at = datetime('now') WHERE id = ?`)
|
|
271
|
+
.run(args.note ? String(args.note).slice(0, 300) : null, args.depositId);
|
|
272
|
+
return { ok: true, status: 'expired' };
|
|
273
|
+
}
|
|
274
|
+
// ───── B2:退还(refund-on-exit)状态流。零真实出款 —— 退还动作发生在场外,协议只编排/记录。 ─────
|
|
275
|
+
/** 卖家申请退出退还:locked → refunding(CAS)+ 冷静期锚点 + 暂停直付资格(退出中不得接新直付单)。
|
|
276
|
+
* 调用方【必须先过 unlock blockers 枚举】(bond-refund-blockers,fail-closed);本函数只做状态手术。 */
|
|
277
|
+
export function requestBondRefund(db, args) {
|
|
278
|
+
const row = getRow(db, args.depositId);
|
|
279
|
+
if (!row || row.user_id !== args.userId)
|
|
280
|
+
return { ok: false, reason: 'deposit not found' };
|
|
281
|
+
if (row.status !== 'locked')
|
|
282
|
+
return { ok: false, reason: `can only request refund on a locked bond (got '${row.status}')` };
|
|
283
|
+
db.transaction(() => {
|
|
284
|
+
const r = db.prepare("UPDATE direct_receive_deposits SET status = 'refunding', refund_requested_at = datetime('now'), updated_at = datetime('now') WHERE id = ? AND status = 'locked'").run(args.depositId);
|
|
285
|
+
if (r.changes !== 1)
|
|
286
|
+
throw new Error('refund request race: already processed');
|
|
287
|
+
db.prepare(`INSERT INTO direct_receive_privileges (user_id, status, tier, suspended_reason, updated_at)
|
|
288
|
+
VALUES (?, 'suspended', ?, 'bond_refund_requested', datetime('now'))
|
|
289
|
+
ON CONFLICT(user_id) DO UPDATE SET status='suspended', suspended_reason='bond_refund_requested', updated_at=datetime('now')`)
|
|
290
|
+
.run(row.user_id, row.tier);
|
|
291
|
+
})();
|
|
292
|
+
return { ok: true, status: 'refunding' };
|
|
293
|
+
}
|
|
294
|
+
/** 卖家撤销退出申请:refunding → locked + 恢复直付资格。仅 admin 尚未执行退还前。 */
|
|
295
|
+
export function cancelBondRefundRequest(db, args) {
|
|
296
|
+
const row = getRow(db, args.depositId);
|
|
297
|
+
if (!row || row.user_id !== args.userId)
|
|
298
|
+
return { ok: false, reason: 'deposit not found' };
|
|
299
|
+
if (row.status !== 'refunding')
|
|
300
|
+
return { ok: false, reason: `no refund request in progress (got '${row.status}')` };
|
|
301
|
+
db.transaction(() => {
|
|
302
|
+
const r = db.prepare("UPDATE direct_receive_deposits SET status = 'locked', refund_requested_at = NULL, updated_at = datetime('now') WHERE id = ? AND status = 'refunding'").run(args.depositId);
|
|
303
|
+
if (r.changes !== 1)
|
|
304
|
+
throw new Error('cancel race: already processed');
|
|
305
|
+
db.prepare(`INSERT INTO direct_receive_privileges (user_id, status, tier, suspended_reason, granted_at, updated_at)
|
|
306
|
+
VALUES (?, 'active', ?, NULL, datetime('now'), datetime('now'))
|
|
307
|
+
ON CONFLICT(user_id) DO UPDATE SET status='active', suspended_reason=NULL, updated_at=datetime('now')`)
|
|
308
|
+
.run(row.user_id, row.tier);
|
|
309
|
+
})();
|
|
310
|
+
return { ok: true, status: 'locked' };
|
|
311
|
+
}
|
|
312
|
+
/** admin 执行退还(场外退款完成后记录):refunding + 冷静期已满 → refunded + 凭据 + released_at。
|
|
313
|
+
* 资格保持 suspended(bond 已退,入场门 sellerHasProductionBaseBondLocked 自然为 false)。凭据必填。 */
|
|
314
|
+
export function executeBondRefund(db, args) {
|
|
315
|
+
const row = getRow(db, args.depositId);
|
|
316
|
+
if (!row)
|
|
317
|
+
return { ok: false, reason: 'deposit not found' };
|
|
318
|
+
if (row.status === 'refunded')
|
|
319
|
+
return { ok: true, status: 'refunded', already: true };
|
|
320
|
+
if (row.status !== 'refunding')
|
|
321
|
+
return { ok: false, reason: `can only execute on a refunding bond (got '${row.status}')` };
|
|
322
|
+
if (!args.evidenceRef || !args.evidenceRef.trim())
|
|
323
|
+
return { ok: false, reason: 'refund evidence ref required(场外退还凭据必填)' };
|
|
324
|
+
const reqAt = db.prepare('SELECT refund_requested_at r FROM direct_receive_deposits WHERE id = ?').get(args.depositId).r;
|
|
325
|
+
const reqMs = reqAt ? Date.parse(reqAt.includes('T') ? reqAt : reqAt.replace(' ', 'T') + 'Z') : NaN;
|
|
326
|
+
const nowMs = Date.parse(args.nowIso);
|
|
327
|
+
const cooling = Math.max(0, args.coolingDays) * 86_400_000;
|
|
328
|
+
if (!Number.isFinite(reqMs) || !Number.isFinite(nowMs))
|
|
329
|
+
return { ok: false, reason: 'unparseable refund timestamps' };
|
|
330
|
+
if (nowMs - reqMs < cooling)
|
|
331
|
+
return { ok: false, reason: `cooling window not over (need ${args.coolingDays}d from request)` };
|
|
332
|
+
db.prepare(`UPDATE direct_receive_deposits SET status = 'refunded', released_at = datetime('now'), refund_evidence_ref = ?, updated_at = datetime('now') WHERE id = ? AND status = 'refunding'`)
|
|
333
|
+
.run(args.evidenceRef.trim().slice(0, 200), args.depositId);
|
|
334
|
+
return { ok: true, status: 'refunded' };
|
|
335
|
+
}
|
|
336
|
+
/** 卖家自行撤回自己的 pending 申报(B1)。owner-scoped CAS;非 pending 不动。 */
|
|
337
|
+
export function cancelPendingDeposit(db, args) {
|
|
338
|
+
const row = getRow(db, args.depositId);
|
|
339
|
+
if (!row || row.user_id !== args.userId)
|
|
340
|
+
return { ok: false, reason: 'deposit not found' };
|
|
341
|
+
if (row.status !== 'pending')
|
|
342
|
+
return { ok: false, reason: `cannot cancel from status '${row.status}'` };
|
|
343
|
+
const r = db.prepare("UPDATE direct_receive_deposits SET status = 'expired', reject_note = '卖家自行撤回', updated_at = datetime('now') WHERE id = ? AND user_id = ? AND status = 'pending'")
|
|
344
|
+
.run(args.depositId, args.userId);
|
|
345
|
+
return r.changes === 1 ? { ok: true, status: 'expired' } : { ok: false, reason: 'cancel race: already processed' };
|
|
346
|
+
}
|
|
347
|
+
/** 卖家最新一笔保证金存款(任意状态;B1 状态卡/去重用)。 */
|
|
348
|
+
export function getSellerLatestDeposit(db, sellerId) {
|
|
349
|
+
return db.prepare(`SELECT id, user_id, tier, required_amount, amount, currency, deposit_rail, status, production_receipt_confirmed_at,
|
|
350
|
+
external_ref, reject_note, confirmed_at, locked_at, refund_requested_at, refund_evidence_ref, terms_version, platform_account_id, created_at
|
|
351
|
+
FROM direct_receive_deposits WHERE user_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1`).get(sellerId) ?? null; // rowid=插入序 tiebreak(id 字典序会被混合前缀坑)
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* refund-on-exit 阻止原因(纯判断占位 —— 真实退款状态流 + RFC-018 clearing lock 集成 = 4b-2)。
|
|
355
|
+
* 返回阻止原因码;null = 在这些条件下【不被阻止】(但本 PR 不执行任何退款)。无副作用,不读库,不出款。
|
|
356
|
+
*/
|
|
357
|
+
export function refundOnExitBlockedReason(facts) {
|
|
358
|
+
if (facts.status !== 'locked')
|
|
359
|
+
return 'NOT_LOCKED';
|
|
360
|
+
if (facts.hasOpenDispute === true)
|
|
361
|
+
return 'OPEN_DISPUTE';
|
|
362
|
+
if (facts.withinCoolingWindow === true)
|
|
363
|
+
return 'COOLING_WINDOW';
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** instruction / label 文本上限(纯展示文本,防滥用;非业务语义)。 */
|
|
2
|
+
export const MAX_INSTRUCTION_LEN = 500;
|
|
3
|
+
export const MAX_LABEL_LEN = 40;
|
|
4
|
+
/** 卖家当前 active 收款说明(最新一条)。无则 null → 调用方必须 fail-closed,不创建直付订单。 */
|
|
5
|
+
export function getActivePaymentInstruction(db, sellerId) {
|
|
6
|
+
const r = db.prepare("SELECT id, instruction, label FROM direct_receive_payment_instructions WHERE seller_id = ? AND status = 'active' ORDER BY updated_at DESC, created_at DESC LIMIT 1").get(sellerId);
|
|
7
|
+
return r ?? null;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* 设置/替换卖家当前 active 收款说明。原子:同一同步事务内先把该 seller 所有 active 置 inactive,再插入一条新 active。
|
|
11
|
+
* 保证至多一条 active。instruction/label 调用方需先 trim + 校验非空/长度;此处只信任已校验入参并落库。
|
|
12
|
+
* 只写 direct_receive_payment_instructions,绝不碰 wallet/escrow/settlement/order。
|
|
13
|
+
*/
|
|
14
|
+
export function setActivePaymentInstruction(db, sellerId, args, generateId) {
|
|
15
|
+
const id = generateId('dri');
|
|
16
|
+
db.transaction(() => {
|
|
17
|
+
db.prepare("UPDATE direct_receive_payment_instructions SET status = 'inactive', updated_at = datetime('now') WHERE seller_id = ? AND status = 'active'").run(sellerId);
|
|
18
|
+
db.prepare("INSERT INTO direct_receive_payment_instructions (id, seller_id, instruction, label, status) VALUES (?, ?, ?, ?, 'active')").run(id, sellerId, args.instruction, args.label);
|
|
19
|
+
})();
|
|
20
|
+
return { id, instruction: args.instruction, label: args.label };
|
|
21
|
+
}
|
|
22
|
+
/** 停用卖家当前所有 active 收款说明。返回是否有行被停用(false = 本来就没有 active)。 */
|
|
23
|
+
export function deactivatePaymentInstruction(db, sellerId) {
|
|
24
|
+
const info = db.prepare("UPDATE direct_receive_payment_instructions SET status = 'inactive', updated_at = datetime('now') WHERE seller_id = ? AND status = 'active'").run(sellerId);
|
|
25
|
+
return info.changes > 0;
|
|
26
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { toUnits } from './money.js';
|
|
2
|
+
const MAX_THRESHOLD = 10_000_000;
|
|
3
|
+
/** 写入校验:null/空=清除;返回规范化数值或 {error}。 */
|
|
4
|
+
export function validateFreeShippingThreshold(raw) {
|
|
5
|
+
if (raw === null || raw === undefined || raw === '')
|
|
6
|
+
return { value: null };
|
|
7
|
+
if (typeof raw !== 'number' && typeof raw !== 'string')
|
|
8
|
+
return { error: '免邮阈值必须是数字' }; // 审计 P3:拒 boolean/数组(Number(true)=1 会误存)
|
|
9
|
+
const t = Number(raw);
|
|
10
|
+
if (!Number.isFinite(t) || t <= 0 || t > MAX_THRESHOLD)
|
|
11
|
+
return { error: `免邮阈值必须是 0~${MAX_THRESHOLD} 的正数` };
|
|
12
|
+
return { value: Math.round(t * 100) / 100 };
|
|
13
|
+
}
|
|
14
|
+
/** 生效阈值:商品 ?? 店铺 ?? null(与接单/运费/可售同层级约定)。 */
|
|
15
|
+
export function effectiveFreeShippingThreshold(db, product, sellerId) {
|
|
16
|
+
const own = product.free_shipping_threshold;
|
|
17
|
+
if (own !== null && own !== undefined && Number.isFinite(Number(own)) && Number(own) > 0)
|
|
18
|
+
return Number(own);
|
|
19
|
+
try {
|
|
20
|
+
const row = db.prepare('SELECT store_free_shipping_threshold FROM users WHERE id = ?').get(sellerId);
|
|
21
|
+
const st = row?.store_free_shipping_threshold;
|
|
22
|
+
return (st !== null && st !== undefined && Number.isFinite(Number(st)) && Number(st) > 0) ? Number(st) : null;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** 判免:券后货款(整数 units)≥ 生效阈值。读失败 fail-open 到不免(促销缺席≠事故,与合规门相反方向)。 */
|
|
29
|
+
export function freeShippingWaives(db, product, sellerId, goodsSubtotalU) {
|
|
30
|
+
try {
|
|
31
|
+
const t = effectiveFreeShippingThreshold(db, product, sellerId);
|
|
32
|
+
return t !== null && goodsSubtotalU >= toUnits(t);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|