@seasonkoh/webaz 0.1.29 → 0.1.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bond-refund-blockers.js +50 -0
- package/dist/bond-slash.js +100 -0
- package/dist/bond-terms.js +31 -0
- package/dist/direct-pay-bond-rail-clearance.js +10 -5
- package/dist/direct-pay-cancel-refund.js +160 -0
- package/dist/direct-pay-create.js +78 -9
- package/dist/direct-pay-disclosures.js +17 -11
- package/dist/direct-pay-fee-ar.js +2 -2
- package/dist/direct-pay-fee-prepay-request.js +80 -0
- package/dist/direct-pay-returns.js +104 -0
- package/dist/direct-pay-stock.js +9 -0
- package/dist/direct-receive-deferral.js +30 -1
- package/dist/direct-receive-deposits.js +122 -17
- package/dist/free-shipping.js +37 -0
- package/dist/fx-rates.js +7 -0
- package/dist/layer0-foundation/L0-1-database/schema.js +246 -0
- package/dist/layer0-foundation/L0-2-state-machine/engine.js +1 -0
- package/dist/layer0-foundation/L0-2-state-machine/transitions.js +73 -0
- package/dist/layer1-agent/L1-1-mcp-server/server.js +81 -16
- package/dist/layer2-business/L2-6-notifications/notification-engine.js +110 -41
- package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +133 -31
- package/dist/layer3-trust/L3-1-dispute-engine/evidence-storage.js +16 -4
- package/dist/layer3-trust/L3-1-dispute-engine/mutual-cancel.js +156 -0
- package/dist/platform-receive-accounts.js +94 -0
- package/dist/pwa/arbitration-read-admin.js +37 -0
- package/dist/pwa/arbitrator-lifecycle.js +100 -0
- package/dist/pwa/contract-fingerprint.js +15 -0
- package/dist/pwa/direct-pay-order-redaction.js +20 -2
- package/dist/pwa/human-presence.js +2 -6
- package/dist/pwa/public/app-account.js +9 -9
- package/dist/pwa/public/app-admin-disputes.js +55 -0
- package/dist/pwa/public/app-agent-appeal.js +90 -0
- package/dist/pwa/public/app-agent-approvals.js +93 -0
- package/dist/pwa/public/app-agent-pair.js +127 -0
- package/dist/pwa/public/app-arbitrator-admin.js +87 -0
- package/dist/pwa/public/app-arbitrator-entry.js +9 -0
- package/dist/pwa/public/app-bond-deferral-ui.js +9 -0
- package/dist/pwa/public/app-bond-refund-ui.js +66 -0
- package/dist/pwa/public/app-bond-slash-ui.js +74 -0
- package/dist/pwa/public/app-bond-terms-ui.js +23 -0
- package/dist/pwa/public/app-bond-ui.js +108 -0
- package/dist/pwa/public/app-chat-poll.js +6 -6
- package/dist/pwa/public/app-contribution-hub.js +23 -0
- package/dist/pwa/public/app-direct-pay-accounts.js +1 -1
- package/dist/pwa/public/app-direct-pay-buyer.js +1 -1
- package/dist/pwa/public/app-direct-pay-cancel-refund.js +72 -0
- package/dist/pwa/public/app-direct-pay-copy.js +12 -0
- package/dist/pwa/public/app-direct-pay-fee-history.js +39 -0
- package/dist/pwa/public/app-direct-pay-fee-ops.js +1 -1
- package/dist/pwa/public/app-direct-pay-fee-request.js +81 -0
- package/dist/pwa/public/app-direct-pay-fee-requests-admin.js +70 -0
- package/dist/pwa/public/app-direct-pay-memo.js +14 -0
- package/dist/pwa/public/app-direct-pay-negotiation.js +35 -0
- package/dist/pwa/public/app-direct-pay-pay.js +15 -0
- package/dist/pwa/public/app-direct-pay-paymodal.js +32 -0
- package/dist/pwa/public/app-direct-pay-reconcile.js +19 -0
- package/dist/pwa/public/app-direct-pay-returns.js +56 -0
- package/dist/pwa/public/app-direct-pay-reveal.js +82 -0
- package/dist/pwa/public/app-direct-pay-sales-report.js +70 -0
- package/dist/pwa/public/app-direct-pay.js +36 -37
- package/dist/pwa/public/app-dispute-close-ui.js +38 -0
- package/dist/pwa/public/app-free-shipping-ui.js +29 -0
- package/dist/pwa/public/app-gmv-rail-split.js +12 -0
- package/dist/pwa/public/app-listing-commerce-ui.js +72 -0
- package/dist/pwa/public/app-mutual-cancel.js +54 -0
- package/dist/pwa/public/app-notif-templates-orders.js +43 -0
- package/dist/pwa/public/app-notif-templates.js +22 -0
- package/dist/pwa/public/app-order-accept-ui.js +158 -0
- package/dist/pwa/public/app-order-errors.js +50 -0
- package/dist/pwa/public/app-order-labels.js +19 -0
- package/dist/pwa/public/app-order-rail-filter.js +26 -0
- package/dist/pwa/public/app-platform-receive-accounts.js +140 -0
- package/dist/pwa/public/app-poll-governor.js +22 -0
- package/dist/pwa/public/app-profile.js +4 -4
- package/dist/pwa/public/app-purchase-terms-ui.js +68 -0
- package/dist/pwa/public/app-sale-regions-ui.js +38 -0
- package/dist/pwa/public/app-seller.js +1 -1
- package/dist/pwa/public/app-trade-tax-ui.js +33 -0
- package/dist/pwa/public/app.js +133 -160
- package/dist/pwa/public/i18n.js +675 -5
- package/dist/pwa/public/index.html +41 -0
- package/dist/pwa/public/openapi.json +709 -11
- package/dist/pwa/public/style.css +3 -0
- package/dist/pwa/routes/admin-direct-receive-deposits.js +215 -3
- package/dist/pwa/routes/admin-protocol-params.js +16 -0
- package/dist/pwa/routes/admin-reports.js +31 -5
- package/dist/pwa/routes/agent-governance.js +1 -1
- package/dist/pwa/routes/agent-grants.js +253 -32
- package/dist/pwa/routes/analytics.js +2 -0
- package/dist/pwa/routes/arbitrator.js +67 -14
- package/dist/pwa/routes/bond-seller.js +162 -0
- package/dist/pwa/routes/direct-pay-cancel-refund.js +111 -0
- package/dist/pwa/routes/direct-pay-disclosure-acks.js +9 -4
- package/dist/pwa/routes/direct-pay-pending-accept.js +222 -0
- package/dist/pwa/routes/direct-pay-returns.js +74 -0
- package/dist/pwa/routes/direct-pay-timeouts.js +186 -9
- package/dist/pwa/routes/disputes-read.js +20 -6
- package/dist/pwa/routes/disputes-write.js +91 -33
- package/dist/pwa/routes/external-anchors.js +4 -2
- package/dist/pwa/routes/fee-prepay-requests.js +66 -0
- package/dist/pwa/routes/governance-onboarding.js +46 -8
- package/dist/pwa/routes/logistics.js +4 -4
- package/dist/pwa/routes/me-data.js +2 -2
- package/dist/pwa/routes/mutual-cancel.js +62 -0
- package/dist/pwa/routes/orders-action.js +182 -9
- package/dist/pwa/routes/orders-create.js +18 -7
- package/dist/pwa/routes/orders-read.js +76 -14
- package/dist/pwa/routes/platform-receive-accounts.js +111 -0
- package/dist/pwa/routes/products-create.js +13 -4
- package/dist/pwa/routes/products-update.js +15 -1
- package/dist/pwa/routes/profile-identity.js +4 -2
- package/dist/pwa/routes/returns.js +39 -8
- package/dist/pwa/routes/seller-directpay-report.js +110 -0
- package/dist/pwa/routes/seller-quota.js +5 -1
- package/dist/pwa/routes/shipping-templates.js +219 -0
- package/dist/pwa/routes/snf.js +4 -1
- package/dist/pwa/routes/webauthn.js +3 -3
- package/dist/pwa/server.js +59 -36
- package/dist/runtime/agent-grant-scopes.js +69 -1
- package/dist/runtime/webaz-schema-helpers.js +57 -3
- package/dist/sale-regions.js +116 -0
- package/dist/shipping-templates.js +119 -0
- package/dist/trade-tax.js +99 -0
- package/dist/trade-terms.js +76 -0
- package/dist/version.js +1 -1
- package/package.json +59 -2
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { openDeposit, getSellerLatestDeposit, expireDeposit, requiredBondUnits, cancelPendingDeposit, requestBondRefund, cancelBondRefundRequest, DIRECT_PAY_BOND_JURISDICTIONS } from '../../direct-receive-deposits.js';
|
|
2
|
+
import { enumerateBondRefundBlockers } from '../../bond-refund-blockers.js'; // B2:§5 unlock blockers(fail-closed)
|
|
3
|
+
import { listBondSlashProposals } from '../../bond-slash.js'; // B3:待复核罚没提案(卖家须被告知)
|
|
4
|
+
import { BOND_TERMS } from '../../bond-terms.js'; // 条款(缴纳前强制同意;版本快照进 deposit 行)
|
|
5
|
+
import { getPlatformAccount } from '../../platform-receive-accounts.js';
|
|
6
|
+
import { bondRailClearanceBlockers, isBondRailClearedForProduction } from '../../direct-pay-bond-rail-clearance.js';
|
|
7
|
+
import { getActiveDeferral } from '../../direct-receive-deferral.js';
|
|
8
|
+
import { listActivePlatformAccounts } from '../../platform-receive-accounts.js';
|
|
9
|
+
import { toDecimal } from '../../money.js';
|
|
10
|
+
import { createNotification } from '../../layer2-business/L2-6-notifications/notification-engine.js';
|
|
11
|
+
import { dbAll } from '../../layer0-foundation/L0-1-database/db.js';
|
|
12
|
+
/** operator_attested 生产轨是否已放行 —— 按【平台收款主体法域】(DIRECT_PAY_BOND_JURISDICTIONS,非卖家法域;
|
|
13
|
+
* 卖家侧资格由 KYB/制裁/AML 门独立把守)判 Lock A+B 全过。2026-07-05 起 SG 放行(#240 决策 B)。 */
|
|
14
|
+
function bondRailCleared() {
|
|
15
|
+
return DIRECT_PAY_BOND_JURISDICTIONS.some(j => isBondRailClearedForProduction('operator_attested', j));
|
|
16
|
+
}
|
|
17
|
+
export function registerBondSellerRoutes(app, deps) {
|
|
18
|
+
const { db, auth, generateId, errorRes, getProtocolParam } = deps;
|
|
19
|
+
const coolingDays = () => Math.max(0, Number(getProtocolParam('direct_pay.bond_refund_cooling_days', 14)) || 14);
|
|
20
|
+
function requireSeller(req, res) {
|
|
21
|
+
const user = auth(req, res);
|
|
22
|
+
if (!user)
|
|
23
|
+
return null;
|
|
24
|
+
if (user.role !== 'seller') {
|
|
25
|
+
errorRes(res, 403, 'SELLER_ONLY', '仅卖家有履约保证金');
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
return user;
|
|
29
|
+
}
|
|
30
|
+
app.get('/api/direct-receive/bond-status', (req, res) => {
|
|
31
|
+
const user = requireSeller(req, res);
|
|
32
|
+
if (!user)
|
|
33
|
+
return;
|
|
34
|
+
const sellerId = user.id;
|
|
35
|
+
// lazy expiry:pending 超 TTL 顺手过期(无 cron 依赖)
|
|
36
|
+
const latest0 = getSellerLatestDeposit(db, sellerId);
|
|
37
|
+
if (latest0 && latest0.status === 'pending')
|
|
38
|
+
expireDeposit(db, { depositId: latest0.id, nowIso: new Date().toISOString() });
|
|
39
|
+
const latest = getSellerLatestDeposit(db, sellerId);
|
|
40
|
+
const deferral = getActiveDeferral(db, sellerId, new Date().toISOString());
|
|
41
|
+
const cleared = bondRailCleared();
|
|
42
|
+
return void res.json({
|
|
43
|
+
required: { tier: 'T0', units: requiredBondUnits('T0'), display: toDecimal(requiredBondUnits('T0')), currency: 'USDC', note: '固定 token 数(档位参考 ≈ S$500,非汇率换算);治理可调' },
|
|
44
|
+
deposit: latest ? {
|
|
45
|
+
id: latest.id, status: latest.status, amount: latest.amount, required_amount: latest.required_amount,
|
|
46
|
+
rail: latest.deposit_rail, evidence_ref: latest.external_ref, reject_note: latest.reject_note,
|
|
47
|
+
production_confirmed: latest.production_receipt_confirmed_at != null, refund_evidence_ref: latest.refund_evidence_ref,
|
|
48
|
+
created_at: latest.created_at, locked_at: latest.locked_at,
|
|
49
|
+
} : null,
|
|
50
|
+
deferral: deferral ? { id: deferral.id, expires_at: deferral.expiresAt, grace_until: deferral.graceUntil, in_grace: deferral.inGrace, reduced_quota_factor: deferral.reducedQuotaFactor } : null,
|
|
51
|
+
// B2:退出退还视图 —— locked 时预览 blockers(能不能申请);refunding 时给冷静期/可执行时间
|
|
52
|
+
refund: latest && latest.status === 'locked' ? { can_request: enumerateBondRefundBlockers(db, sellerId).length === 0, blockers: enumerateBondRefundBlockers(db, sellerId), cooling_days: coolingDays() }
|
|
53
|
+
: latest && latest.status === 'refunding' ? { requested_at: latest.refund_requested_at, cooling_days: coolingDays() } : null,
|
|
54
|
+
// B3:待复核罚没提案(冷静期=卖家申诉窗,必须让卖家看见)
|
|
55
|
+
pending_slash: (() => { try {
|
|
56
|
+
const p = listBondSlashProposals(db, { sellerId, status: 'proposed' })[0];
|
|
57
|
+
return p ? { id: p.id, dispute_id: p.dispute_id, cooling_until: p.cooling_until, reason: p.reason } : null;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
} })(),
|
|
62
|
+
rail_cleared: cleared,
|
|
63
|
+
rail_blockers: cleared ? [] : bondRailClearanceBlockers('operator_attested').filter(b => b !== 'NO_PRODUCTION_RECEIPT'),
|
|
64
|
+
payment_accounts: cleared ? listActivePlatformAccounts(db) : [], // 放行前不展示收款方式(不引导无法生效的转账);多币种账户由 admin 在 #admin/platform-receive 维护
|
|
65
|
+
terms: { version: BOND_TERMS.version, zh: BOND_TERMS.zh, en: BOND_TERMS.en }, // 缴纳前必须同意(版本快照)
|
|
66
|
+
note: cleared
|
|
67
|
+
? '按平台收款方式转账后提交申报(凭据必填);运营核实到账并确认后保证金正式锁定、直付入场门即满足。'
|
|
68
|
+
: '保证金缴纳通道待平台放行(合规审查中)。当前可通过【缓交申请】先行入场(额度受限);放行后再补缴转正式。',
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
app.post('/api/direct-receive/bond-deposit', async (req, res) => {
|
|
72
|
+
const user = requireSeller(req, res);
|
|
73
|
+
if (!user)
|
|
74
|
+
return;
|
|
75
|
+
const sellerId = user.id;
|
|
76
|
+
// fail-closed:Lock B 未放行 → 不收申报(收了也无法核实生效,只会积压误导)
|
|
77
|
+
if (!bondRailCleared())
|
|
78
|
+
return void errorRes(res, 409, 'BOND_RAIL_NOT_CLEARED', '保证金缴纳通道待平台放行(合规审查中);当前请使用缓交申请入场');
|
|
79
|
+
const evidence = String(req.body?.evidence_ref ?? '').trim();
|
|
80
|
+
if (!evidence || evidence.length > 120)
|
|
81
|
+
return void errorRes(res, 400, 'EVIDENCE_REQUIRED', '付款凭据号必填(≤120 字;转账单号/链上 tx 等,运营据此核对到账)');
|
|
82
|
+
// 条款强制同意(合同依据;版本精确匹配,旧版本同意无效)
|
|
83
|
+
if (String(req.body?.agree_terms_version ?? '') !== BOND_TERMS.version) {
|
|
84
|
+
return void res.status(428).json({ error: '缴纳前须阅读并同意当前版本保证金条款', error_code: 'TERMS_NOT_AGREED', terms: { version: BOND_TERMS.version, zh: BOND_TERMS.zh, en: BOND_TERMS.en } });
|
|
85
|
+
}
|
|
86
|
+
// 多币种:必须选一个 active 平台收款账户;币种由账户推导(USDC/USDT→usdc,其余→fiat)
|
|
87
|
+
const acc = getPlatformAccount(db, String(req.body?.platform_account_id ?? ''));
|
|
88
|
+
if (!acc || acc.status !== 'active')
|
|
89
|
+
return void errorRes(res, 400, 'PLATFORM_ACCOUNT_REQUIRED', '须选择缴纳到的平台收款账户(有效且启用)');
|
|
90
|
+
const currency = ['USDC', 'USDT'].includes(String(acc.currency ?? '').toUpperCase()) ? 'usdc' : 'fiat';
|
|
91
|
+
const latest0 = getSellerLatestDeposit(db, sellerId);
|
|
92
|
+
if (latest0 && latest0.status === 'pending')
|
|
93
|
+
expireDeposit(db, { depositId: latest0.id, nowIso: new Date().toISOString() }); // lazy expiry
|
|
94
|
+
const latest = getSellerLatestDeposit(db, sellerId);
|
|
95
|
+
if (latest && ['pending', 'confirmed', 'insufficient'].includes(latest.status))
|
|
96
|
+
return void errorRes(res, 409, 'DEPOSIT_IN_FLIGHT', `已有在途申报(${latest.status}),请等待运营核实或先撤回`);
|
|
97
|
+
if (latest && latest.status === 'locked')
|
|
98
|
+
return void errorRes(res, 409, 'BOND_ALREADY_LOCKED', '保证金已锁定生效,无需重复缴纳');
|
|
99
|
+
const depositId = generateId('bond');
|
|
100
|
+
const r = openDeposit(db, { depositId, userId: sellerId, tier: 'T0', currency, depositRail: 'operator_attested', externalRef: evidence, termsVersion: BOND_TERMS.version, platformAccountId: acc.id });
|
|
101
|
+
if (!r.ok)
|
|
102
|
+
return void errorRes(res, 400, 'BOND_OPEN_FAILED', r.reason);
|
|
103
|
+
// N3 同款:通知 root admin 有待核实申报(best-effort)
|
|
104
|
+
try {
|
|
105
|
+
const roots = await dbAll("SELECT id FROM users WHERE role = 'admin' AND (admin_type = 'root' OR admin_type IS NULL)");
|
|
106
|
+
for (const a of roots) {
|
|
107
|
+
createNotification(db, a.id, null, 'bond_deposit_submitted', '🏦 新保证金缴纳申报待核实', `卖家 ${String(user.name ?? sellerId)} 申报已缴纳履约保证金(T0,凭据 ${evidence.slice(0, 40)})。请核对真实到账后在 admin 后台确认(ROOT+Passkey)。`, { templateKey: 'bond_deposit_submitted', params: { seller: String(user.name ?? sellerId), evidence: evidence.slice(0, 40) } });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
console.warn('[bond-deposit notify]', e.message);
|
|
112
|
+
}
|
|
113
|
+
return void res.json({ success: true, deposit_id: depositId, status: 'pending', note: '申报已提交;运营核实真实到账并确认后生效。申报本身不授予任何入场资格。' });
|
|
114
|
+
});
|
|
115
|
+
// ── B2:退出退还 —— 申请(§5 blockers fail-closed)→ 冷静期 → admin 执行;申请期间直付资格暂停,可撤销 ──
|
|
116
|
+
app.post('/api/direct-receive/bond-refund-request', async (req, res) => {
|
|
117
|
+
const user = requireSeller(req, res);
|
|
118
|
+
if (!user)
|
|
119
|
+
return;
|
|
120
|
+
const sellerId = user.id;
|
|
121
|
+
const latest = getSellerLatestDeposit(db, sellerId);
|
|
122
|
+
if (!latest || latest.status !== 'locked')
|
|
123
|
+
return void errorRes(res, 409, 'NO_LOCKED_BOND', '没有已锁定的保证金可退');
|
|
124
|
+
const blockers = enumerateBondRefundBlockers(db, sellerId);
|
|
125
|
+
if (blockers.length > 0)
|
|
126
|
+
return void res.status(409).json({ error: '有未了结的直付责任,暂不能申请退还', error_code: 'REFUND_BLOCKED', blockers });
|
|
127
|
+
const r = requestBondRefund(db, { depositId: latest.id, userId: sellerId });
|
|
128
|
+
if (!r.ok)
|
|
129
|
+
return void errorRes(res, 409, 'REFUND_REQUEST_FAILED', r.reason);
|
|
130
|
+
try {
|
|
131
|
+
const roots = await dbAll("SELECT id FROM users WHERE role = 'admin' AND (admin_type = 'root' OR admin_type IS NULL)");
|
|
132
|
+
for (const a of roots)
|
|
133
|
+
createNotification(db, a.id, null, 'bond_refund_requested', '↩️ 保证金退出申请待处理', `卖家 ${String(user.name ?? sellerId)} 申请退还履约保证金(冷静期 ${coolingDays()} 天,期间其直付资格已暂停)。冷静期满且复核无未了结责任后,场外退还并在 admin 后台记录执行。`, { templateKey: 'bond_refund_requested', params: { seller: String(user.name ?? sellerId), days: coolingDays() } });
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
console.warn('[bond-refund notify]', e.message);
|
|
137
|
+
}
|
|
138
|
+
return void res.json({ success: true, status: 'refunding', cooling_days: coolingDays(), note: '申请已提交:冷静期内你的直付资格暂停(不可接新直付单);冷静期满、复核无未了结责任后,平台在协议外退还并记录。可随时撤销申请恢复资格。' });
|
|
139
|
+
});
|
|
140
|
+
app.post('/api/direct-receive/bond-refund-request/cancel', (req, res) => {
|
|
141
|
+
const user = requireSeller(req, res);
|
|
142
|
+
if (!user)
|
|
143
|
+
return;
|
|
144
|
+
const latest = getSellerLatestDeposit(db, user.id);
|
|
145
|
+
if (!latest)
|
|
146
|
+
return void errorRes(res, 404, 'DEPOSIT_NOT_FOUND', '无保证金记录');
|
|
147
|
+
const r = cancelBondRefundRequest(db, { depositId: latest.id, userId: user.id });
|
|
148
|
+
if (!r.ok)
|
|
149
|
+
return void errorRes(res, 409, 'REFUND_CANCEL_FAILED', r.reason);
|
|
150
|
+
return void res.json({ success: true, status: 'locked', note: '退出申请已撤销,直付资格已恢复。' });
|
|
151
|
+
});
|
|
152
|
+
app.post('/api/direct-receive/bond-deposit/:id/cancel', (req, res) => {
|
|
153
|
+
const user = requireSeller(req, res);
|
|
154
|
+
if (!user)
|
|
155
|
+
return;
|
|
156
|
+
// 单一 writer 边界:deposits 写只经域模块(guard:direct-pay-deposit 机器强制,route 零裸写)
|
|
157
|
+
const r = cancelPendingDeposit(db, { depositId: req.params.id, userId: user.id });
|
|
158
|
+
if (!r.ok)
|
|
159
|
+
return void errorRes(res, r.reason === 'deposit not found' ? 404 : 409, 'BOND_CANCEL_FAILED', r.reason);
|
|
160
|
+
return void res.json({ success: true });
|
|
161
|
+
});
|
|
162
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { requestCancelRefund, declineCancelRefund, markRefunded, withdrawCancelRefund, confirmRefundReceived, getCancelRefundState, } from '../../direct-pay-cancel-refund.js';
|
|
2
|
+
import { requireDirectPayHumanPasskey } from '../direct-pay-guards.js';
|
|
3
|
+
import { dbOne } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam(通知收件人读)
|
|
4
|
+
import { transition } from '../../layer0-foundation/L0-2-state-machine/engine.js';
|
|
5
|
+
import { createNotification } from '../../layer2-business/L2-6-notifications/notification-engine.js';
|
|
6
|
+
export function registerDirectPayCancelRefundRoutes(app, deps) {
|
|
7
|
+
const { db, auth, generateId, errorRes, consumeGateToken } = deps;
|
|
8
|
+
const httpFor = (code) => code === 'ORDER_NOT_FOUND' ? 404
|
|
9
|
+
: code === 'NOT_A_PARTY' || code === 'NOT_ORDER_BUYER' || code === 'NOT_ORDER_SELLER' ? 403
|
|
10
|
+
: code === 'SYS_MISSING' ? 500
|
|
11
|
+
: 409;
|
|
12
|
+
const notify = (userId, orderId, type, title, body) => {
|
|
13
|
+
try {
|
|
14
|
+
createNotification(db, userId, orderId, type, title, body);
|
|
15
|
+
}
|
|
16
|
+
catch { /* 通知失败不阻断业务 */ }
|
|
17
|
+
};
|
|
18
|
+
const party = async (orderId) => await dbOne('SELECT buyer_id, seller_id FROM orders WHERE id = ?', [orderId]);
|
|
19
|
+
app.get('/api/orders/:id/cancel-refund', (req, res) => {
|
|
20
|
+
const user = auth(req, res);
|
|
21
|
+
if (!user)
|
|
22
|
+
return;
|
|
23
|
+
const r = getCancelRefundState(db, req.params.id, user.id);
|
|
24
|
+
if (!r.ok)
|
|
25
|
+
return void errorRes(res, httpFor(r.error_code), r.error_code || 'CANCEL_REFUND_STATE_ERROR', r.error || '读取失败');
|
|
26
|
+
res.json({ success: true, request: r.request ?? null, can_request: !!r.can_request, can_respond: !!r.can_respond, can_confirm: !!r.can_confirm, can_withdraw: !!r.can_withdraw });
|
|
27
|
+
});
|
|
28
|
+
app.post('/api/orders/:id/cancel-refund/request', async (req, res) => {
|
|
29
|
+
const user = auth(req, res);
|
|
30
|
+
if (!user)
|
|
31
|
+
return;
|
|
32
|
+
const reason = typeof req.body?.reason === 'string' ? req.body.reason : null;
|
|
33
|
+
const r = requestCancelRefund(db, { orderId: req.params.id, buyerId: user.id, reason, requestId: generateId('dpcr') });
|
|
34
|
+
if (!r.ok)
|
|
35
|
+
return void errorRes(res, httpFor(r.error_code), r.error_code || 'CANCEL_REFUND_REQUEST_ERROR', r.error || '发起失败');
|
|
36
|
+
const p = await party(req.params.id);
|
|
37
|
+
if (p)
|
|
38
|
+
notify(p.seller_id, req.params.id, 'direct_pay_cancel_requested', '↩️ 买家申请取消订单并退款', `买家已付款但希望取消订单${reason ? `(理由:${String(reason).slice(0, 80)})` : ''}。请在期限内响应:同意则场外退款后点"我已退款",不同意可拒绝(继续发货)。直付非托管,退款在协议外完成。`);
|
|
39
|
+
return void res.json({ success: true, status: r.status, request: r.request ?? null });
|
|
40
|
+
});
|
|
41
|
+
app.post('/api/orders/:id/cancel-refund/decline', async (req, res) => {
|
|
42
|
+
const user = auth(req, res);
|
|
43
|
+
if (!user)
|
|
44
|
+
return;
|
|
45
|
+
const r = declineCancelRefund(db, { orderId: req.params.id, sellerId: user.id });
|
|
46
|
+
if (!r.ok)
|
|
47
|
+
return void errorRes(res, httpFor(r.error_code), r.error_code || 'CANCEL_REFUND_DECLINE_ERROR', r.error || '拒绝失败');
|
|
48
|
+
const p = await party(req.params.id);
|
|
49
|
+
if (p)
|
|
50
|
+
notify(p.buyer_id, req.params.id, 'direct_pay_cancel_declined', '❌ 卖家拒绝了取消退款请求', '卖家选择继续履约发货。如有异议可与卖家沟通,或在有证据时发起争议。');
|
|
51
|
+
return void res.json({ success: true, status: r.status });
|
|
52
|
+
});
|
|
53
|
+
app.post('/api/orders/:id/cancel-refund/mark-refunded', async (req, res) => {
|
|
54
|
+
const user = auth(req, res);
|
|
55
|
+
if (!user)
|
|
56
|
+
return;
|
|
57
|
+
const refundReference = typeof req.body?.refund_reference === 'string' ? req.body.refund_reference : null;
|
|
58
|
+
const r = markRefunded(db, { orderId: req.params.id, sellerId: user.id, refundReference });
|
|
59
|
+
if (!r.ok)
|
|
60
|
+
return void errorRes(res, httpFor(r.error_code), r.error_code || 'CANCEL_REFUND_MARK_ERROR', r.error || '声明退款失败');
|
|
61
|
+
const p = await party(req.params.id);
|
|
62
|
+
if (p)
|
|
63
|
+
notify(p.buyer_id, req.params.id, 'direct_pay_refund_marked', '💸 卖家已声明退款', `卖家声明已在协议外向你退款${refundReference ? `(退款参考:${String(refundReference).slice(0, 80)})` : ''}。请核实到账后在订单页确认(需 Passkey),订单将无责取消;若未收到退款请勿确认,可发起争议。`);
|
|
64
|
+
return void res.json({ success: true, status: r.status });
|
|
65
|
+
});
|
|
66
|
+
app.post('/api/orders/:id/cancel-refund/withdraw', async (req, res) => {
|
|
67
|
+
const user = auth(req, res);
|
|
68
|
+
if (!user)
|
|
69
|
+
return;
|
|
70
|
+
const r = withdrawCancelRefund(db, { orderId: req.params.id, buyerId: user.id });
|
|
71
|
+
if (!r.ok)
|
|
72
|
+
return void errorRes(res, httpFor(r.error_code), r.error_code || 'CANCEL_REFUND_WITHDRAW_ERROR', r.error || '撤回失败');
|
|
73
|
+
const p = await party(req.params.id);
|
|
74
|
+
if (p)
|
|
75
|
+
notify(p.seller_id, req.params.id, 'direct_pay_cancel_withdrawn', '↩️ 买家撤回了取消退款请求', '订单继续正常履约,请按流程发货。');
|
|
76
|
+
return void res.json({ success: true, status: r.status });
|
|
77
|
+
});
|
|
78
|
+
app.post('/api/orders/:id/cancel-refund/confirm', async (req, res) => {
|
|
79
|
+
const user = auth(req, res);
|
|
80
|
+
if (!user)
|
|
81
|
+
return;
|
|
82
|
+
const uid = user.id;
|
|
83
|
+
// 只读预检(不消费 token):必须存在 refund_marked 的请求且订单仍 accepted,否则不浪费一次性 Passkey token
|
|
84
|
+
const pre = getCancelRefundState(db, req.params.id, uid);
|
|
85
|
+
if (!pre.ok)
|
|
86
|
+
return void errorRes(res, httpFor(pre.error_code), pre.error_code || 'CANCEL_REFUND_STATE_ERROR', pre.error || '读取失败');
|
|
87
|
+
if (!pre.can_confirm)
|
|
88
|
+
return void errorRes(res, 409, 'REFUND_NOT_MARKED', '当前不可确认(卖家尚未声明退款,或订单已不在待发货阶段)');
|
|
89
|
+
// RISK 门:确认收款并关单 = 终局动作 → 现场真人 Passkey(agent 无 Passkey 硬拒)
|
|
90
|
+
const gate = requireDirectPayHumanPasskey({ db, consumeGateToken }, {
|
|
91
|
+
userId: uid, webauthnToken: req.body?.webauthn_token, purpose: 'direct_pay_order_action',
|
|
92
|
+
validate: (data) => { const d = data; return !!d && d.order_id === req.params.id && d.action === 'cancel_refund_confirm'; },
|
|
93
|
+
});
|
|
94
|
+
if (!gate.ok)
|
|
95
|
+
return void errorRes(res, 403, gate.error_code || 'HUMAN_PRESENCE_REQUIRED', gate.reason || '需现场真人 Passkey 确认');
|
|
96
|
+
let r;
|
|
97
|
+
try {
|
|
98
|
+
// 状态手术:请求行 CAS + accepted→cancelled + 库存恢复,同一原子边界(域内重校验防 ship/争议竞态)
|
|
99
|
+
r = db.transaction(() => confirmRefundReceived(db, { orderId: req.params.id, buyerId: uid }, transition))();
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
return void errorRes(res, 409, 'CANCEL_REFUND_CONFIRM_FAILED', e.message);
|
|
103
|
+
}
|
|
104
|
+
if (!r.ok)
|
|
105
|
+
return void errorRes(res, httpFor(r.error_code), r.error_code || 'CANCEL_REFUND_CONFIRM_ERROR', r.error || '确认失败');
|
|
106
|
+
const p = await party(req.params.id);
|
|
107
|
+
if (p)
|
|
108
|
+
notify(p.seller_id, req.params.id, 'direct_pay_cancel_settled', '✅ 取消退款握手完成', '买家已确认收到退款,订单已无责取消,库存已恢复。双方信誉均不受影响。');
|
|
109
|
+
return void res.json({ success: true, status: r.status });
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -25,14 +25,18 @@ export function registerDirectPayDisclosureAckRoutes(app, deps) {
|
|
|
25
25
|
}
|
|
26
26
|
return order;
|
|
27
27
|
}
|
|
28
|
-
// POST —
|
|
28
|
+
// POST — 记录 ack。stage = pre_select | pre_confirm | both。需现场真人(Passkey + gate token)。幂等(INSERT OR IGNORE)。
|
|
29
|
+
// 'both'(2026-07-04 用户决策,contract v14):两屏披露【仍各自展示并确认】(文本证据不减),但一次真人 ceremony
|
|
30
|
+
// 可覆盖两个 stage → 落库仍是【两行】ack(各带 notice_version+acked_at,requireBothDisclosuresAcked 证据模型不变);
|
|
31
|
+
// 变化仅是"人在场证明"从两次合并为一次(首单 Passkey 3→2;mark_paid 的独立 RISK 门不变)。token purpose_data 绑
|
|
32
|
+
// stage:'both' —— 单 stage token 冒充不了 both,both token 也重放不了单 stage(一次性消费 + validate 精确匹配)。
|
|
29
33
|
app.post('/api/direct-pay/disclosure-acks', async (req, res) => {
|
|
30
34
|
const user = auth(req, res);
|
|
31
35
|
if (!user)
|
|
32
36
|
return;
|
|
33
37
|
const stage = req.body?.stage;
|
|
34
|
-
if (!VALID_STAGES.includes(stage))
|
|
35
|
-
return void res.status(400).json({ error: `无效 stage(只允许 ${VALID_STAGES.join('|')})`, error_code: 'INVALID_STAGE' });
|
|
38
|
+
if (stage !== 'both' && !VALID_STAGES.includes(stage))
|
|
39
|
+
return void res.status(400).json({ error: `无效 stage(只允许 ${VALID_STAGES.join('|')}|both)`, error_code: 'INVALID_STAGE' });
|
|
36
40
|
const order = await requireOwnDirectPayOrder(req.body?.order_id, res, user);
|
|
37
41
|
if (!order)
|
|
38
42
|
return;
|
|
@@ -45,7 +49,8 @@ export function registerDirectPayDisclosureAckRoutes(app, deps) {
|
|
|
45
49
|
});
|
|
46
50
|
if (!gate.ok)
|
|
47
51
|
return void res.status(403).json({ error: gate.reason, error_code: gate.error_code });
|
|
48
|
-
|
|
52
|
+
for (const s of (stage === 'both' ? VALID_STAGES : [stage]))
|
|
53
|
+
recordDisclosureAck(db, { orderId: order.id, buyerId: user.id, stage: s, ackId: generateId('dpa') });
|
|
49
54
|
return void res.json({
|
|
50
55
|
ok: true, stage,
|
|
51
56
|
both: requireBothDisclosuresAcked(db, order.id).ok,
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { transition } from '../../layer0-foundation/L0-2-state-machine/engine.js';
|
|
2
|
+
import { restorePreShipDirectPayStock } from '../../direct-pay-stock.js';
|
|
3
|
+
import { createNotification } from '../../layer2-business/L2-6-notifications/notification-engine.js';
|
|
4
|
+
import { dbOne, dbRun } from '../../layer0-foundation/L0-1-database/db.js';
|
|
5
|
+
import { toUnits, toDecimal } from '../../money.js';
|
|
6
|
+
import { buildPayableSnapshot } from '../../direct-pay-create.js';
|
|
7
|
+
import { safeRunDirectPayAmlMonitor } from '../../direct-pay-aml-monitor.js';
|
|
8
|
+
import { updateSnapshotShippingQuote } from '../../trade-terms.js'; // S0:询价确认后补记条款快照 shipping 槽(quote_pending→quote,fail-soft)
|
|
9
|
+
export function registerDirectPayPendingAcceptRoutes(app, deps) {
|
|
10
|
+
const { db, auth, errorRes, getProtocolParam } = deps;
|
|
11
|
+
async function loadPendingOrder(req, res) {
|
|
12
|
+
const order = await dbOne('SELECT id, buyer_id, seller_id, status, payment_rail, product_id, quantity, total_amount, shipping_quote_required, shipping_quote_fee, shipping_quote_est_days, direct_pay_per_tx_cap_units_snapshot, direct_pay_account_snapshot FROM orders WHERE id = ?', [req.params.id]);
|
|
13
|
+
if (!order) {
|
|
14
|
+
errorRes(res, 404, 'ORDER_NOT_FOUND', '订单不存在');
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
if (order.payment_rail !== 'direct_p2p') {
|
|
18
|
+
errorRes(res, 409, 'NOT_DIRECT_PAY', '仅直付订单有待接单阶段');
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
if (order.status !== 'pending_accept') {
|
|
22
|
+
errorRes(res, 409, 'NOT_PENDING_ACCEPT', `当前状态 ${order.status},不在待接单阶段`);
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return order;
|
|
26
|
+
}
|
|
27
|
+
const notify = (userId, orderId, type, title, body, tpl) => {
|
|
28
|
+
try {
|
|
29
|
+
createNotification(db, userId, orderId, type, title, body, tpl);
|
|
30
|
+
}
|
|
31
|
+
catch { /* 通知失败不阻断 */ }
|
|
32
|
+
};
|
|
33
|
+
// ── 接单模式设置(卖家):店铺默认 + 单品覆盖。值 ∈ {'auto','manual',null=清除(单品回落店铺默认/店铺回落 auto)}。
|
|
34
|
+
// 只影响【之后】的新订单(下单时快照);对两轨生效(escrow 'auto'=付款后系统自动接单;dp 'manual'=先进 pending_accept)。
|
|
35
|
+
const MODE_OK = (v) => v === null || v === 'auto' || v === 'manual';
|
|
36
|
+
app.post('/api/seller/accept-mode', async (req, res) => {
|
|
37
|
+
const user = auth(req, res);
|
|
38
|
+
if (!user)
|
|
39
|
+
return;
|
|
40
|
+
if (user.role !== 'seller')
|
|
41
|
+
return void errorRes(res, 403, 'SELLER_ONLY', '仅卖家可设置接单模式');
|
|
42
|
+
const b = req.body || {};
|
|
43
|
+
const touched = {};
|
|
44
|
+
if ('store_accept_mode' in b) {
|
|
45
|
+
if (!MODE_OK(b.store_accept_mode))
|
|
46
|
+
return void errorRes(res, 400, 'BAD_ACCEPT_MODE', "store_accept_mode 只允许 'auto'|'manual'|null");
|
|
47
|
+
await dbRun('UPDATE users SET store_accept_mode = ? WHERE id = ?', [b.store_accept_mode, user.id]);
|
|
48
|
+
touched.store_accept_mode = b.store_accept_mode;
|
|
49
|
+
}
|
|
50
|
+
if ('product_id' in b || 'accept_mode' in b) {
|
|
51
|
+
if (!b.product_id)
|
|
52
|
+
return void errorRes(res, 400, 'MISSING_PRODUCT_ID', '设置单品接单模式须带 product_id');
|
|
53
|
+
if (!MODE_OK(b.accept_mode))
|
|
54
|
+
return void errorRes(res, 400, 'BAD_ACCEPT_MODE', "accept_mode 只允许 'auto'|'manual'|null");
|
|
55
|
+
const p = await dbOne('SELECT seller_id FROM products WHERE id = ?', [b.product_id]);
|
|
56
|
+
if (!p)
|
|
57
|
+
return void errorRes(res, 404, 'PRODUCT_NOT_FOUND', '商品不存在');
|
|
58
|
+
if (p.seller_id !== user.id)
|
|
59
|
+
return void errorRes(res, 403, 'NOT_PRODUCT_OWNER', '只能设置自己商品的接单模式');
|
|
60
|
+
await dbRun('UPDATE products SET accept_mode = ? WHERE id = ?', [b.accept_mode, b.product_id]);
|
|
61
|
+
touched.product_accept_mode = b.accept_mode;
|
|
62
|
+
}
|
|
63
|
+
if (Object.keys(touched).length === 0)
|
|
64
|
+
return void errorRes(res, 400, 'NOTHING_TO_SET', '未提供任何设置项');
|
|
65
|
+
return void res.json({ success: true, ...touched });
|
|
66
|
+
});
|
|
67
|
+
// 卖家确认接单 → 开付款窗口(deadline 此刻起表;收款信息此刻起买家可见 —— orders-read 状态门放行)
|
|
68
|
+
app.post('/api/orders/:id/pending-accept/accept', async (req, res) => {
|
|
69
|
+
const user = auth(req, res);
|
|
70
|
+
if (!user)
|
|
71
|
+
return;
|
|
72
|
+
const order = await loadPendingOrder(req, res);
|
|
73
|
+
if (!order)
|
|
74
|
+
return;
|
|
75
|
+
if (order.seller_id !== user.id)
|
|
76
|
+
return void errorRes(res, 403, 'NOT_ORDER_SELLER', '只有订单卖家可接单');
|
|
77
|
+
// 询价单不可裸接单(会绕过报价 → 无运费进付款窗):必须 quote → 买家 confirm-quote(PR-3)
|
|
78
|
+
if (Number(order.shipping_quote_required) === 1)
|
|
79
|
+
return void errorRes(res, 409, 'QUOTE_REQUIRED', '本单收货地区在运费模板外,请先报价(运费+时效),买家确认后自动进入付款');
|
|
80
|
+
const windowHours = Math.max(1, Number(getProtocolParam('direct_pay.payment_window_hours', 4)) || 4);
|
|
81
|
+
try {
|
|
82
|
+
db.transaction(() => {
|
|
83
|
+
const t = transition(db, order.id, 'direct_pay_window', user.id, [], '卖家确认接单 → 进入直付付款窗口');
|
|
84
|
+
if (!t.success)
|
|
85
|
+
throw new Error(t.error || 'TRANSITION_FAILED');
|
|
86
|
+
db.prepare('UPDATE orders SET direct_pay_window_deadline = ? WHERE id = ?')
|
|
87
|
+
.run(new Date(Date.now() + windowHours * 3600_000).toISOString(), order.id);
|
|
88
|
+
})();
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
return void errorRes(res, 409, 'ACCEPT_FAILED', e.message);
|
|
92
|
+
}
|
|
93
|
+
notify(order.buyer_id, order.id, 'direct_pay_accepted_by_seller', '✅ 卖家已确认接单,请付款', `卖家已确认可发货并接单。请在 ${windowHours} 小时内完成风险确认后查看收款方式并付款;逾期未付订单将进入超时流程。`, { templateKey: 'dp_pending_accept_accepted', params: { hours: windowHours } });
|
|
94
|
+
return void res.json({ success: true, status: 'direct_pay_window' });
|
|
95
|
+
});
|
|
96
|
+
// 卖家谢绝(无法发货/物流不可达等;理由可选,买家可见)→ 无责取消 + 回补库存
|
|
97
|
+
app.post('/api/orders/:id/pending-accept/decline', async (req, res) => {
|
|
98
|
+
const user = auth(req, res);
|
|
99
|
+
if (!user)
|
|
100
|
+
return;
|
|
101
|
+
const order = await loadPendingOrder(req, res);
|
|
102
|
+
if (!order)
|
|
103
|
+
return;
|
|
104
|
+
if (order.seller_id !== user.id)
|
|
105
|
+
return void errorRes(res, 403, 'NOT_ORDER_SELLER', '只有订单卖家可谢绝');
|
|
106
|
+
const reason = typeof req.body?.reason === 'string' ? req.body.reason.trim().slice(0, 200) : '';
|
|
107
|
+
try {
|
|
108
|
+
db.transaction(() => {
|
|
109
|
+
const t = transition(db, order.id, 'cancelled', user.id, [], `卖家谢绝接单(付款前,无责取消)${reason ? `:${reason}` : ''}`);
|
|
110
|
+
if (!t.success)
|
|
111
|
+
throw new Error(t.error || 'TRANSITION_FAILED');
|
|
112
|
+
restorePreShipDirectPayStock(db, { fromStatus: 'pending_accept', productId: order.product_id, quantity: Number(order.quantity) || 1 });
|
|
113
|
+
})();
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
return void errorRes(res, 409, 'DECLINE_FAILED', e.message);
|
|
117
|
+
}
|
|
118
|
+
notify(order.buyer_id, order.id, 'direct_pay_accept_declined', '❌ 卖家未能接单,订单已取消', `卖家未能确认发货${reason ? `(${reason})` : ''}。订单已无责取消 —— 你尚未付款,无需任何操作;双方信誉均不受影响。`, { templateKey: 'dp_pending_accept_declined', params: { reason } });
|
|
119
|
+
return void res.json({ success: true, status: 'cancelled' });
|
|
120
|
+
});
|
|
121
|
+
// ── 询价握手(PR-3,模板外地区)──────────────────────────────────────────
|
|
122
|
+
// 卖家报价:运费+预计时效(+备注,买家可见)。只能加"运费"一个科目,不能动货款单价(防坐地起价披运费皮);
|
|
123
|
+
// 货款+运费受【建单快照】单笔上限约束(cap 是买家最大裸损口径,必须含运费;用快照防事后调参绕过)。
|
|
124
|
+
// 可重复报价(买家确认前修正);每次报价重置响应窗(param direct_pay.quote_confirm_hours,默认 48h)。
|
|
125
|
+
app.post('/api/orders/:id/pending-accept/quote', async (req, res) => {
|
|
126
|
+
const user = auth(req, res);
|
|
127
|
+
if (!user)
|
|
128
|
+
return;
|
|
129
|
+
const order = await loadPendingOrder(req, res);
|
|
130
|
+
if (!order)
|
|
131
|
+
return;
|
|
132
|
+
if (order.seller_id !== user.id)
|
|
133
|
+
return void errorRes(res, 403, 'NOT_ORDER_SELLER', '只有订单卖家可报价');
|
|
134
|
+
if (Number(order.shipping_quote_required) !== 1)
|
|
135
|
+
return void errorRes(res, 409, 'NOT_QUOTE_ORDER', '本单不需询价(模板已覆盖或走普通接单)');
|
|
136
|
+
const fee = Number(req.body?.shipping_fee);
|
|
137
|
+
if (!Number.isFinite(fee) || fee < 0 || fee > 1_000_000)
|
|
138
|
+
return void errorRes(res, 400, 'BAD_QUOTE_FEE', '运费必须是 0~1000000 的数字');
|
|
139
|
+
const feeR = Math.round(fee * 100) / 100;
|
|
140
|
+
const cap = Number(order.direct_pay_per_tx_cap_units_snapshot);
|
|
141
|
+
if (Number.isFinite(cap) && cap > 0 && toUnits(Number(order.total_amount)) + toUnits(feeR) > cap) {
|
|
142
|
+
return void errorRes(res, 409, 'QUOTE_EXCEEDS_CAP', `货款+运费超出本单单笔上限(${toDecimal(cap)} USDC),不能以运费形式突破小额直付边界`);
|
|
143
|
+
}
|
|
144
|
+
const est = req.body?.est_days == null ? null : String(req.body.est_days).trim().slice(0, 20) || null;
|
|
145
|
+
const note = req.body?.note == null ? null : String(req.body.note).trim().slice(0, 200) || null;
|
|
146
|
+
const confirmHours = Math.max(1, Number(getProtocolParam('direct_pay.quote_confirm_hours', 48)) || 48);
|
|
147
|
+
await dbRun(`UPDATE orders SET shipping_quote_fee = ?, shipping_quote_est_days = ?, shipping_quote_note = ?, shipping_quote_at = datetime('now'), pending_accept_deadline = ? WHERE id = ? AND status = 'pending_accept'`, [feeR, est, note, new Date(Date.now() + confirmHours * 3600_000).toISOString(), order.id]);
|
|
148
|
+
notify(order.buyer_id, order.id, 'direct_pay_quote_submitted', '📦 卖家已报价运费,请确认', `卖家确认可发货并报价:运费 ${feeR} USDC${est ? `,预计时效 ${est} 天` : ''}${note ? `(${note})` : ''}。新总额 ${Math.round((Number(order.total_amount) + feeR) * 100) / 100} USDC。请在 ${confirmHours} 小时内确认(确认后进入付款环节)或撤单;逾期订单自动取消。`, { templateKey: 'dp_quote_submitted', params: { fee: feeR, est: est ?? '', note: note ?? '', total: Math.round((Number(order.total_amount) + feeR) * 100) / 100, hours: confirmHours } });
|
|
149
|
+
return void res.json({ success: true, shipping_quote_fee: feeR, est_days: est, new_total: Math.round((Number(order.total_amount) + feeR) * 100) / 100 });
|
|
150
|
+
});
|
|
151
|
+
// 买家确认报价 → 运费并入 total_amount(整数 units 精确加)+ 快照三列 + 重建 payable 参考换算 → 进付款窗。
|
|
152
|
+
// CAS:仅 pending_accept 且已报价;总额变更与状态转移同一 db.transaction(要么全生效要么全回滚)。
|
|
153
|
+
app.post('/api/orders/:id/pending-accept/confirm-quote', async (req, res) => {
|
|
154
|
+
const user = auth(req, res);
|
|
155
|
+
if (!user)
|
|
156
|
+
return;
|
|
157
|
+
const order = await loadPendingOrder(req, res);
|
|
158
|
+
if (!order)
|
|
159
|
+
return;
|
|
160
|
+
if (order.buyer_id !== user.id)
|
|
161
|
+
return void errorRes(res, 403, 'NOT_ORDER_BUYER', '只有订单买家可确认报价');
|
|
162
|
+
if (Number(order.shipping_quote_required) !== 1 || order.shipping_quote_fee == null)
|
|
163
|
+
return void errorRes(res, 409, 'QUOTE_NOT_SUBMITTED', '卖家尚未报价,不可确认');
|
|
164
|
+
const feeR = Number(order.shipping_quote_fee);
|
|
165
|
+
const newTotal = toDecimal(toUnits(Number(order.total_amount)) + toUnits(feeR));
|
|
166
|
+
const windowHours = Math.max(1, Number(getProtocolParam('direct_pay.payment_window_hours', 4)) || 4);
|
|
167
|
+
// payable 参考换算按新总额重建(display-only;账户快照缺失/坏 JSON → 保持原样,零阻断)
|
|
168
|
+
let snapJson = order.direct_pay_account_snapshot;
|
|
169
|
+
try {
|
|
170
|
+
if (snapJson) {
|
|
171
|
+
const snap = JSON.parse(snapJson);
|
|
172
|
+
snapJson = JSON.stringify({ ...snap, ...buildPayableSnapshot(newTotal, snap.currency ?? null) });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
snapJson = order.direct_pay_account_snapshot;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
db.transaction(() => {
|
|
180
|
+
const u = db.prepare(`UPDATE orders SET total_amount = ?, shipping_fee = ?, shipping_est_days = ?, direct_pay_account_snapshot = ?, direct_pay_window_deadline = ? WHERE id = ? AND status = 'pending_accept' AND shipping_quote_fee IS NOT NULL`)
|
|
181
|
+
.run(newTotal, feeR, order.shipping_quote_est_days, snapJson, new Date(Date.now() + windowHours * 3600_000).toISOString(), order.id);
|
|
182
|
+
if (u.changes !== 1)
|
|
183
|
+
throw new Error('QUOTE_CONFIRM_RACE');
|
|
184
|
+
const t = transition(db, order.id, 'direct_pay_window', 'sys_protocol', [], `买家确认运费报价(${feeR} USDC,新总额 ${newTotal})→ 进入直付付款窗口`);
|
|
185
|
+
if (!t.success)
|
|
186
|
+
throw new Error(t.error || 'TRANSITION_FAILED');
|
|
187
|
+
})();
|
|
188
|
+
}
|
|
189
|
+
catch (e) {
|
|
190
|
+
return void errorRes(res, 409, 'QUOTE_CONFIRM_FAILED', e.message);
|
|
191
|
+
}
|
|
192
|
+
// 总额变更后补跑 AML 监控(fail-soft,绝不回流为失败);条款快照补记运费裁决(quote_pending→quote)
|
|
193
|
+
safeRunDirectPayAmlMonitor(db, { sellerId: order.seller_id, orderId: order.id, nowIso: new Date().toISOString(), getProtocolParam });
|
|
194
|
+
updateSnapshotShippingQuote(db, order.id, feeR, order.shipping_quote_est_days ?? null);
|
|
195
|
+
notify(order.seller_id, order.id, 'direct_pay_quote_confirmed', '✅ 买家已确认运费报价', `买家已确认新总额 ${newTotal} USDC(含运费 ${feeR}),订单进入付款窗口。买家完成场外付款并标记后你会收到发货提醒。`, { templateKey: 'dp_quote_confirmed', params: { total: newTotal, fee: feeR } });
|
|
196
|
+
return void res.json({ success: true, status: 'direct_pay_window', total_amount: newTotal });
|
|
197
|
+
});
|
|
198
|
+
// 买家撤单(接单前反悔)→ 无责取消 + 回补库存
|
|
199
|
+
app.post('/api/orders/:id/pending-accept/cancel', async (req, res) => {
|
|
200
|
+
const user = auth(req, res);
|
|
201
|
+
if (!user)
|
|
202
|
+
return;
|
|
203
|
+
const order = await loadPendingOrder(req, res);
|
|
204
|
+
if (!order)
|
|
205
|
+
return;
|
|
206
|
+
if (order.buyer_id !== user.id)
|
|
207
|
+
return void errorRes(res, 403, 'NOT_ORDER_BUYER', '只有订单买家可撤单');
|
|
208
|
+
try {
|
|
209
|
+
db.transaction(() => {
|
|
210
|
+
const t = transition(db, order.id, 'cancelled', user.id, [], '买家在卖家接单前撤单(付款前,无责取消)');
|
|
211
|
+
if (!t.success)
|
|
212
|
+
throw new Error(t.error || 'TRANSITION_FAILED');
|
|
213
|
+
restorePreShipDirectPayStock(db, { fromStatus: 'pending_accept', productId: order.product_id, quantity: Number(order.quantity) || 1 });
|
|
214
|
+
})();
|
|
215
|
+
}
|
|
216
|
+
catch (e) {
|
|
217
|
+
return void errorRes(res, 409, 'CANCEL_FAILED', e.message);
|
|
218
|
+
}
|
|
219
|
+
notify(order.seller_id, order.id, 'direct_pay_accept_cancelled', '↩️ 买家已撤单', '买家在你确认接单前撤回了订单。订单已无责取消,库存已恢复。', { templateKey: 'dp_pending_accept_cancelled', params: {} });
|
|
220
|
+
return void res.json({ success: true, status: 'cancelled' });
|
|
221
|
+
});
|
|
222
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { markReturnRefunded, confirmReturnRefundReceived } from '../../direct-pay-returns.js';
|
|
2
|
+
import { requireDirectPayHumanPasskey } from '../direct-pay-guards.js';
|
|
3
|
+
import { recordRepEvent } from '../../layer4-economics/L4-3-reputation/reputation-engine.js';
|
|
4
|
+
import { createNotification } from '../../layer2-business/L2-6-notifications/notification-engine.js';
|
|
5
|
+
import { dbOne } from '../../layer0-foundation/L0-1-database/db.js';
|
|
6
|
+
export function registerDirectPayReturnsRoutes(app, deps) {
|
|
7
|
+
const { db, auth, generateId, errorRes, consumeGateToken } = deps;
|
|
8
|
+
const httpFor = (code) => code === 'RETURN_NOT_FOUND' ? 404
|
|
9
|
+
: code === 'NOT_ORDER_BUYER' || code === 'NOT_ORDER_SELLER' ? 403
|
|
10
|
+
: 409;
|
|
11
|
+
const notify = (userId, orderId, type, title, body) => {
|
|
12
|
+
try {
|
|
13
|
+
createNotification(db, userId, orderId, type, title, body);
|
|
14
|
+
}
|
|
15
|
+
catch { /* 通知失败不阻断业务 */ }
|
|
16
|
+
};
|
|
17
|
+
app.post('/api/return-requests/:id/mark-refunded', async (req, res) => {
|
|
18
|
+
const user = auth(req, res);
|
|
19
|
+
if (!user)
|
|
20
|
+
return;
|
|
21
|
+
const refundReference = typeof req.body?.refund_reference === 'string' ? req.body.refund_reference : null;
|
|
22
|
+
const r = markReturnRefunded(db, { returnId: req.params.id, sellerId: user.id, refundReference, messageId: generateId('rmsg') });
|
|
23
|
+
if (!r.ok)
|
|
24
|
+
return void errorRes(res, httpFor(r.error_code), r.error_code || 'RETURN_MARK_REFUNDED_ERROR', r.error || '声明退款失败');
|
|
25
|
+
const rr = await dbOne('SELECT buyer_id, order_id FROM return_requests WHERE id = ?', [req.params.id]);
|
|
26
|
+
if (rr)
|
|
27
|
+
notify(rr.buyer_id, rr.order_id, 'direct_pay_return_refund_marked', '💸 卖家已声明退货退款', `卖家声明已在协议外向你退款${refundReference ? `(退款参考:${String(refundReference).slice(0, 80)})` : ''}。请核实到账后在订单页确认(需 Passkey),退货即完成;若未收到退款请勿确认,可发起争议。`);
|
|
28
|
+
return void res.json({ success: true, status: r.status });
|
|
29
|
+
});
|
|
30
|
+
app.post('/api/return-requests/:id/confirm-refund', async (req, res) => {
|
|
31
|
+
const user = auth(req, res);
|
|
32
|
+
if (!user)
|
|
33
|
+
return;
|
|
34
|
+
const uid = user.id;
|
|
35
|
+
// 只读预检(不消费一次性 Passkey token):必须是本人 buyer 的 refund_marked 直付退货
|
|
36
|
+
const pre = await dbOne('SELECT r.buyer_id, r.status, r.order_id, o.payment_rail FROM return_requests r JOIN orders o ON o.id = r.order_id WHERE r.id = ?', [req.params.id]);
|
|
37
|
+
if (!pre)
|
|
38
|
+
return void errorRes(res, 404, 'RETURN_NOT_FOUND', '退货请求不存在');
|
|
39
|
+
if (pre.buyer_id !== uid)
|
|
40
|
+
return void errorRes(res, 403, 'NOT_ORDER_BUYER', '只有订单买家可确认收到退款');
|
|
41
|
+
if (pre.payment_rail !== 'direct_p2p' || pre.status !== 'refund_marked') {
|
|
42
|
+
return void errorRes(res, 409, 'REFUND_NOT_MARKED', '当前不可确认(卖家尚未声明退款,或非直付退货)');
|
|
43
|
+
}
|
|
44
|
+
// RISK 门:确认收款并终结退货 = 终局动作 → 现场真人 Passkey(agent 无 Passkey 硬拒)
|
|
45
|
+
const gate = requireDirectPayHumanPasskey({ db, consumeGateToken }, {
|
|
46
|
+
userId: uid, webauthnToken: req.body?.webauthn_token, purpose: 'direct_pay_order_action',
|
|
47
|
+
validate: (data) => { const d = data; return !!d && d.order_id === pre.order_id && d.action === 'return_refund_confirm'; },
|
|
48
|
+
});
|
|
49
|
+
if (!gate.ok)
|
|
50
|
+
return void errorRes(res, 403, gate.error_code || 'HUMAN_PRESENCE_REQUIRED', gate.reason || '需现场真人 Passkey 确认');
|
|
51
|
+
let r;
|
|
52
|
+
try {
|
|
53
|
+
// CAS 终态翻转 + 全额退款社交计数 -1,同一原子边界(零资金零库存)
|
|
54
|
+
r = db.transaction(() => confirmReturnRefundReceived(db, { returnId: req.params.id, buyerId: uid, messageId: generateId('rmsg') }))();
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
return void errorRes(res, 409, 'RETURN_CONFIRM_FAILED', e.message);
|
|
58
|
+
}
|
|
59
|
+
if (!r.ok)
|
|
60
|
+
return void errorRes(res, httpFor(r.error_code), r.error_code || 'RETURN_CONFIRM_ERROR', r.error || '确认失败');
|
|
61
|
+
// 卖家过错原因 → rep event(与 escrow executeReturnRefund 同口径,tx 外 best-effort)
|
|
62
|
+
try {
|
|
63
|
+
if (r.seller_fault_reason && r.seller_id) {
|
|
64
|
+
recordRepEvent(db, r.seller_id, 'claim_upheld_against', `退货接受 (reason=${r.seller_fault_reason}, return=${req.params.id})`, r.order_id || undefined);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
console.error('[dp return rep event]', e);
|
|
69
|
+
}
|
|
70
|
+
if (r.seller_id && r.order_id)
|
|
71
|
+
notify(r.seller_id, r.order_id, 'direct_pay_return_settled', '✅ 退货已完成', '买家已确认收到场外退款,退货流程结束。提示:退回货物须经验收后方可重新上架(库存不会自动恢复)。');
|
|
72
|
+
return void res.json({ success: true, status: r.status });
|
|
73
|
+
});
|
|
74
|
+
}
|