@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
|
@@ -2,14 +2,26 @@
|
|
|
2
2
|
import { listOrderEventsSince } from '../../layer0-foundation/L0-2-state-machine/order-chain.js';
|
|
3
3
|
import { dbOne, dbAll } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
|
|
4
4
|
import { requireBothDisclosuresAcked } from '../../direct-pay-disclosures.js'; // PR-4f-b: direct_p2p 收款说明响应契约门
|
|
5
|
-
import {
|
|
5
|
+
import { projectDirectPayTargetForViewer } from '../direct-pay-order-redaction.js'; // 收款目标披露门:按查看者一次分派(买家=ack 门/卖家=收款方保留/第三方=剥离),所有 orders reader 必过
|
|
6
|
+
import { getMutualCancelState } from '../../layer3-trust/L3-1-dispute-engine/mutual-cancel.js'; // 协商取消(无责合意)可达性 + 当前提议(仅 disputed 计算,UI 便利字段)
|
|
7
|
+
import { getCancelRefundState } from '../../direct-pay-cancel-refund.js'; // 直付取消退款握手状态(仅 direct_p2p+accepted 计算,UI 便利字段)
|
|
8
|
+
import { isEligibleArbitrator } from '../arbitrator-lifecycle.js'; // 白名单仲裁员可查【争议中】订单(裁定所需);不看 legacy role==='arbitrator'
|
|
6
9
|
import { getQrImageForOwner } from '../../direct-receive-account-qr.js'; // Rail1 D2:ack 门后按订单快照 qr_ref 取收款码字节((ref,seller_id) 域内)
|
|
10
|
+
import { readTradeTermsSnapshot } from '../../trade-terms.js'; // S0:下单冻结的交易条款(时效/退货/清关/税责),争议书面依据
|
|
7
11
|
export function registerOrdersReadRoutes(app, deps) {
|
|
8
12
|
const { db, auth, getOrderStatus, getOrderChain, verifyOrderChain, getOrderDispute } = deps;
|
|
13
|
+
// 仲裁员订单可见性(详情 + 签名链共用同一谓词,防两路漂移):active 白名单(唯一能力源,不看 legacy user.role)
|
|
14
|
+
// 且【该订单存在争议记录】—— 含已裁定/已驳回:裁定后订单离开 disputed,但仲裁员复盘/申诉处理自己的案件仍需可见,
|
|
15
|
+
// 与 disputes-read"任意 active 仲裁员可读任意争议(含已结)"同口径。无争议记录的订单一律不可见 → 不可枚举任意订单。
|
|
16
|
+
const arbitratorCanViewOrder = async (orderId, userId) => isEligibleArbitrator(db, userId).ok && !!(await dbOne('SELECT 1 FROM disputes WHERE order_id = ? LIMIT 1', [orderId]));
|
|
9
17
|
app.get('/api/orders', async (req, res) => {
|
|
10
18
|
const user = auth(req, res);
|
|
11
19
|
if (!user)
|
|
12
20
|
return;
|
|
21
|
+
// 可选按支付轨筛选(escrow=托管 / direct_p2p=直接收款);非法值忽略。参与方 OR 必须整体括起,否则 AND 优先级会绑错。
|
|
22
|
+
const rail = req.query.rail === 'direct_p2p' ? 'direct_p2p' : req.query.rail === 'escrow' ? 'escrow' : null;
|
|
23
|
+
const railClause = rail ? ' AND o.payment_rail = ?' : '';
|
|
24
|
+
const railParams = rail ? [rail] : [];
|
|
13
25
|
const orders = await dbAll(`
|
|
14
26
|
SELECT o.*, p.title as product_title, p.images,
|
|
15
27
|
ub.name as buyer_name, us.name as seller_name
|
|
@@ -17,9 +29,9 @@ export function registerOrdersReadRoutes(app, deps) {
|
|
|
17
29
|
JOIN products p ON o.product_id = p.id
|
|
18
30
|
JOIN users ub ON o.buyer_id = ub.id
|
|
19
31
|
JOIN users us ON o.seller_id = us.id
|
|
20
|
-
WHERE o.buyer_id = ? OR o.seller_id = ? OR o.logistics_id = ?
|
|
32
|
+
WHERE (o.buyer_id = ? OR o.seller_id = ? OR o.logistics_id = ?)${railClause}
|
|
21
33
|
ORDER BY o.created_at DESC LIMIT 50
|
|
22
|
-
`, [user.id, user.id, user.id]);
|
|
34
|
+
`, [user.id, user.id, user.id, ...railParams]);
|
|
23
35
|
// B2 隐私购物:列表里也做相同 mask(防 seller/logistics 通过列表绕过详情 mask)
|
|
24
36
|
for (const o of orders) {
|
|
25
37
|
if (Number(o.anonymous_recipient) === 1 && o.buyer_id !== user.id) {
|
|
@@ -28,7 +40,8 @@ export function registerOrdersReadRoutes(app, deps) {
|
|
|
28
40
|
delete o.recipient_code;
|
|
29
41
|
o.buyer_name = '🔒 ' + (typeof code === 'string' ? code : 'PR-?????');
|
|
30
42
|
}
|
|
31
|
-
|
|
43
|
+
projectDirectPayTargetForViewer(db, o, user.id); // #179/#218 审计:收款目标按查看者投影(买家=ack 门/卖家保留/第三方剥离),列表不得旁路
|
|
44
|
+
delete o.trade_terms_snapshot; // S0:列表不下放条款快照原始串(详情页解析后下放,列表省流量)
|
|
32
45
|
}
|
|
33
46
|
res.json(orders);
|
|
34
47
|
});
|
|
@@ -41,6 +54,7 @@ export function registerOrdersReadRoutes(app, deps) {
|
|
|
41
54
|
const field = role === 'seller' ? 'o.seller_id' : 'o.buyer_id';
|
|
42
55
|
const from = req.query.from ? String(req.query.from) : null;
|
|
43
56
|
const to = req.query.to ? String(req.query.to) : null;
|
|
57
|
+
const rail = req.query.rail === 'direct_p2p' ? 'direct_p2p' : req.query.rail === 'escrow' ? 'escrow' : null;
|
|
44
58
|
const where = [`${field} = ?`];
|
|
45
59
|
const params = [user.id];
|
|
46
60
|
if (from) {
|
|
@@ -51,10 +65,15 @@ export function registerOrdersReadRoutes(app, deps) {
|
|
|
51
65
|
where.push(`o.created_at <= ?`);
|
|
52
66
|
params.push(to);
|
|
53
67
|
}
|
|
68
|
+
if (rail) {
|
|
69
|
+
where.push(`o.payment_rail = ?`);
|
|
70
|
+
params.push(rail);
|
|
71
|
+
} // 按支付轨导出(对账:托管 vs 直接收款分开)
|
|
54
72
|
const EXPORT_LIMIT = 5000;
|
|
55
73
|
const rows = await dbAll(`
|
|
56
74
|
SELECT o.id, o.created_at, o.status, o.quantity, o.unit_price, o.total_amount,
|
|
57
75
|
o.coupon_discount, o.variant_options_snapshot, o.shipping_address,
|
|
76
|
+
COALESCE(o.payment_rail, 'escrow') as payment_rail,
|
|
58
77
|
p.title as product_title, p.category,
|
|
59
78
|
ub.handle as buyer_handle, ub.name as buyer_name,
|
|
60
79
|
us.handle as seller_handle, us.name as seller_name
|
|
@@ -76,7 +95,7 @@ export function registerOrdersReadRoutes(app, deps) {
|
|
|
76
95
|
}
|
|
77
96
|
return s;
|
|
78
97
|
};
|
|
79
|
-
const headers = ['order_id', 'created_at', 'status', 'product', 'category', 'qty', 'unit_price', 'total', 'coupon_discount', 'variant', 'buyer', 'seller', 'address'];
|
|
98
|
+
const headers = ['order_id', 'created_at', 'status', 'payment_rail', 'product', 'category', 'qty', 'unit_price', 'total', 'coupon_discount', 'variant', 'buyer', 'seller', 'address'];
|
|
80
99
|
const lines = [headers.join(',')];
|
|
81
100
|
for (const r of rows) {
|
|
82
101
|
let variantStr = '';
|
|
@@ -87,7 +106,7 @@ export function registerOrdersReadRoutes(app, deps) {
|
|
|
87
106
|
}
|
|
88
107
|
catch { }
|
|
89
108
|
lines.push([
|
|
90
|
-
r.id, r.created_at, r.status, r.product_title, r.category,
|
|
109
|
+
r.id, r.created_at, r.status, r.payment_rail, r.product_title, r.category,
|
|
91
110
|
r.quantity, r.unit_price, r.total_amount, r.coupon_discount || 0,
|
|
92
111
|
variantStr, r.buyer_handle, r.seller_handle, r.shipping_address,
|
|
93
112
|
].map(csvEscape).join(','));
|
|
@@ -103,7 +122,7 @@ export function registerOrdersReadRoutes(app, deps) {
|
|
|
103
122
|
// UTF-8 BOM 帮 Excel 识别
|
|
104
123
|
res.send('' + lines.join('\n'));
|
|
105
124
|
});
|
|
106
|
-
// 订单签名链 — 当事人 +
|
|
125
|
+
// 订单签名链 — 当事人 + 白名单仲裁员(涉争议订单) + admin 可查
|
|
107
126
|
app.get('/api/orders/:id/chain', async (req, res) => {
|
|
108
127
|
const user = auth(req, res);
|
|
109
128
|
if (!user)
|
|
@@ -112,9 +131,12 @@ export function registerOrdersReadRoutes(app, deps) {
|
|
|
112
131
|
if (!order)
|
|
113
132
|
return void res.status(404).json({ error: '订单不存在' });
|
|
114
133
|
const uid = user.id;
|
|
115
|
-
|
|
116
|
-
|
|
134
|
+
// 仲裁员链访问与订单详情同一谓词(arbitratorCanViewOrder):裁定需验签名链;legacy role==='arbitrator' 旁路已移除
|
|
135
|
+
// (否则 role-only/已吊销账号可读任意订单链,而真·白名单仲裁员 role=buyer 反被 403,链徽标在裁定页显示"链异常")。
|
|
136
|
+
const isParty = uid === order.buyer_id || uid === order.seller_id || uid === order.logistics_id;
|
|
137
|
+
if (!isParty && user.role !== 'admin' && !(await arbitratorCanViewOrder(req.params.id, uid))) {
|
|
117
138
|
return void res.status(403).json({ error: '无权查看此订单链' });
|
|
139
|
+
}
|
|
118
140
|
const chain = await getOrderChain(db, req.params.id);
|
|
119
141
|
const verification = await verifyOrderChain(db, req.params.id);
|
|
120
142
|
res.json({ chain, verification });
|
|
@@ -145,7 +167,12 @@ export function registerOrdersReadRoutes(app, deps) {
|
|
|
145
167
|
const order = statusInfo.order;
|
|
146
168
|
const isLogisticsPickup = user.role === 'logistics' &&
|
|
147
169
|
!order.logistics_id && order.status === 'shipped';
|
|
148
|
-
|
|
170
|
+
// 白名单仲裁员(role 可能是 buyer,非 legacy 'arbitrator')需查看涉争议订单以裁定/复盘。能力源【唯一】= active
|
|
171
|
+
// arbitrator_whitelist;legacy role 旁路已移除(否则 role-only/已吊销账号可读任意订单)。谓词= arbitratorCanViewOrder:
|
|
172
|
+
// 订单须【存在争议记录】(含已裁定/已驳回 —— 只放行 disputed 会让仲裁员在裁定落地、订单离开 disputed 的瞬间失去访问,
|
|
173
|
+
// 已结 tab 的"查看订单"全部 403);无争议记录的订单不可见 → 不可枚举。
|
|
174
|
+
const isParty = order.buyer_id === user.id || order.seller_id === user.id || order.logistics_id === user.id;
|
|
175
|
+
if (!isParty && !isLogisticsPickup && !(await arbitratorCanViewOrder(req.params.id, user.id))) {
|
|
149
176
|
return void res.status(403).json({ error: '无权查看此订单' });
|
|
150
177
|
}
|
|
151
178
|
// M8: 二手订单从 secondhand_items 查;商家订单从 products 查
|
|
@@ -198,9 +225,42 @@ export function registerOrdersReadRoutes(app, deps) {
|
|
|
198
225
|
delete order.recipient_code;
|
|
199
226
|
}
|
|
200
227
|
}
|
|
201
|
-
// Direct Pay
|
|
202
|
-
//
|
|
203
|
-
|
|
228
|
+
// Direct Pay 响应契约门:收款目标按查看者投影 —— 买家在 D1/D2 both-acked 前不得拿到(非仅 UI 软门);卖家=收款方保留;
|
|
229
|
+
// 非买卖双方第三方(disputed-order 仲裁员、logistics-pickup)一律剥离(#179/#218 审计;仲裁若确需看收款目标,
|
|
230
|
+
// 应另做显式、可审计、受指派/案件/目的约束的 reveal 路径)。与 /api/orders 列表共用同一投影器,防旁路。
|
|
231
|
+
projectDirectPayTargetForViewer(db, order, user.id);
|
|
232
|
+
// Rail1 撤回仲裁可达性(与 orders-action pq_withdraw 权威门同谓词):仅当【当前 disputed + 最近一次进入
|
|
233
|
+
// disputed 是 from payment_query + 争议未裁定】。前端据此才显示"撤回仲裁"按钮 —— 履约类争议(货损/货不对版
|
|
234
|
+
// 的 delivered→disputed)不给该按钮。UI 便利字段,真正拦截仍在路由。
|
|
235
|
+
const disputedFroms = history.filter(h => h.to_status === 'disputed').map(h => h.from_status);
|
|
236
|
+
order.can_withdraw_payment_query_dispute =
|
|
237
|
+
order.status === 'disputed' && disputedFroms[disputedFroms.length - 1] === 'payment_query' && !!dispute;
|
|
238
|
+
// 争议协商收口·买家侧(与 orders-action dispute_withdraw_confirm 权威门同谓词):仅当【当前 disputed +
|
|
239
|
+
// 最近一次进入 disputed 是 from delivered(履约争议)+ 争议未裁定 + 查看者=买家=争议发起人】。
|
|
240
|
+
// 前端据此才显示"我已收到货·撤诉并确认收货";UI 便利字段,真正拦截仍在路由。
|
|
241
|
+
order.can_confirm_receipt_close_dispute =
|
|
242
|
+
order.status === 'disputed' && disputedFroms[disputedFroms.length - 1] === 'delivered'
|
|
243
|
+
&& !!dispute && dispute.initiator_id === user.id && order.buyer_id === user.id;
|
|
244
|
+
// 协商取消(无责·双方合意)握手状态 —— 仅 disputed 单计算,供订单页同步渲染 propose/accept/decline/withdraw。真正边界在 mutual-cancel 路由。
|
|
245
|
+
if (order.status === 'disputed') {
|
|
246
|
+
const mc = getMutualCancelState(db, req.params.id, user.id);
|
|
247
|
+
order.mutual_cancel = mc.ok ? { proposal: mc.proposal ?? null, can_propose: !!mc.can_propose, can_accept: !!mc.can_accept, can_decline: !!mc.can_decline, can_withdraw: !!mc.can_withdraw } : null;
|
|
248
|
+
}
|
|
249
|
+
// 直付取消退款握手状态(审计项 C)—— 仅 direct_p2p + accepted(付款后·发货前)计算,供订单页同步渲染。
|
|
250
|
+
// party-gated(域内);非当事方(仲裁员等)拿 null。真正边界在 direct-pay-cancel-refund 路由。
|
|
251
|
+
if (order.payment_rail === 'direct_p2p' && order.status === 'accepted') {
|
|
252
|
+
const cr = getCancelRefundState(db, req.params.id, user.id);
|
|
253
|
+
order.cancel_refund = cr.ok ? { request: cr.request ?? null, can_request: !!cr.can_request, can_respond: !!cr.can_respond, can_confirm: !!cr.can_confirm, can_withdraw: !!cr.can_withdraw } : null;
|
|
254
|
+
// 审计项 F:卖家对账辅助 —— 同买家·同金额其它在途直付单计数(与 mark_paid D2 预警同口径,发货前每次打开订单页都能看到)。
|
|
255
|
+
// 仅卖家视角计算;买家/第三方不下发(无对账用途,少一分敞口)。
|
|
256
|
+
if (order.seller_id === user.id) {
|
|
257
|
+
const dupRow = await dbOne(`SELECT COUNT(*) n FROM orders WHERE buyer_id = ? AND seller_id = ? AND payment_rail = 'direct_p2p' AND total_amount = ? AND id != ? AND status IN ('accepted','shipped','picked_up','in_transit','delivered')`, [order.buyer_id, order.seller_id, order.total_amount, req.params.id]);
|
|
258
|
+
order.duplicate_amount_alert = dupRow?.n || 0;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// S0 交易条款快照:parse-don't-validate 后下放(坏 JSON/pre-S0 旧单 → null;原始串不下放)
|
|
262
|
+
order.trade_terms = readTradeTermsSnapshot(order.trade_terms_snapshot);
|
|
263
|
+
delete order.trade_terms_snapshot;
|
|
204
264
|
res.json({ ...statusInfo, history, product, dispute, trackingInfo });
|
|
205
265
|
});
|
|
206
266
|
// Rail1 D2:直付订单收款二维码(硬化转发)。仅【订单买家】+ 两次披露 both-acked 后可取;按建单时快照的 (qr_ref, seller_id)
|
|
@@ -210,9 +270,11 @@ export function registerOrdersReadRoutes(app, deps) {
|
|
|
210
270
|
if (!user)
|
|
211
271
|
return;
|
|
212
272
|
const orderId = String(req.params.id);
|
|
213
|
-
const order = await dbOne('SELECT buyer_id, seller_id, payment_rail, direct_pay_account_snapshot FROM orders WHERE id = ?', [orderId]);
|
|
273
|
+
const order = await dbOne('SELECT buyer_id, seller_id, payment_rail, status, direct_pay_account_snapshot FROM orders WHERE id = ?', [orderId]);
|
|
214
274
|
if (!order || order.payment_rail !== 'direct_p2p' || order.buyer_id !== user.id)
|
|
215
275
|
return void res.status(404).end();
|
|
276
|
+
if (order.status === 'pending_accept')
|
|
277
|
+
return void res.status(404).end(); // 手动接单待确认:状态门,接单前不出示收款码(v16)
|
|
216
278
|
if (!requireBothDisclosuresAcked(db, orderId).ok)
|
|
217
279
|
return void res.status(404).end(); // 未 ack:与"无 QR"同样 404,不泄露存在性
|
|
218
280
|
let snap = {};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { listPlatformAccounts, getPlatformAccount, addPlatformAccount, updatePlatformAccount, deactivatePlatformAccount } from '../../platform-receive-accounts.js';
|
|
3
|
+
import { requireDirectPayHumanPasskey } from '../direct-pay-guards.js';
|
|
4
|
+
const sha256hex = (s) => createHash('sha256').update(s).digest('hex');
|
|
5
|
+
/** 稳定序列化(键排序)—— purpose_data 与请求体重算 payload 逐字比对,不受键序影响。 */
|
|
6
|
+
function stable(o) {
|
|
7
|
+
return JSON.stringify(Object.keys(o).sort().reduce((a, k) => { a[k] = o[k]; return a; }, {}));
|
|
8
|
+
}
|
|
9
|
+
const GATE_PURPOSE = 'platform_receive_account_manage';
|
|
10
|
+
export function registerPlatformReceiveAccountsRoutes(app, deps) {
|
|
11
|
+
const { db, requireRootAdmin, generateId, consumeGateToken, logAdminAction } = deps;
|
|
12
|
+
/** 从请求体重算【本次写入内容】的 canonical gate payload —— Passkey token 必须绑定它,证明 admin 确认的就是这份收款内容。
|
|
13
|
+
* 绑定字段:action, account_id?, instruction/method/currency/label(原文,与 body 逐字一致), qr_mode(keep|clear|set)+qr_sha256(set 时;绑 QR 内容而非塞 64KB 原文)。 */
|
|
14
|
+
function gateContentPayload(req, action, accountId) {
|
|
15
|
+
const b = req.body || {};
|
|
16
|
+
const p = { action };
|
|
17
|
+
if (accountId)
|
|
18
|
+
p.account_id = accountId;
|
|
19
|
+
if (action === 'add' || action === 'update') {
|
|
20
|
+
p.instruction = String(b.instruction ?? '');
|
|
21
|
+
p.method = String(b.method ?? '');
|
|
22
|
+
p.currency = String(b.currency ?? '');
|
|
23
|
+
p.label = String(b.label ?? '');
|
|
24
|
+
if (!('qr_data_uri' in b))
|
|
25
|
+
p.qr_mode = 'keep';
|
|
26
|
+
else if (b.qr_data_uri == null || String(b.qr_data_uri).trim() === '')
|
|
27
|
+
p.qr_mode = 'clear';
|
|
28
|
+
else {
|
|
29
|
+
p.qr_mode = 'set';
|
|
30
|
+
p.qr_sha256 = sha256hex(String(b.qr_data_uri));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return p;
|
|
34
|
+
}
|
|
35
|
+
/** ROOT + 现场真人 Passkey(写操作)。token 的 purpose_data 必须与本次请求体重算的 canonical payload 逐字相等
|
|
36
|
+
* —— 否则(如用批 A 的 token 写 B 的收款地址,或 body 被篡改)拒。返回 true=通过(否则已写 403)。 */
|
|
37
|
+
function passkeyOk(req, res, adminId, action, accountId) {
|
|
38
|
+
const expected = stable(gateContentPayload(req, action, accountId));
|
|
39
|
+
const gate = requireDirectPayHumanPasskey({ db, consumeGateToken }, {
|
|
40
|
+
userId: adminId, webauthnToken: req.body?.webauthn_token,
|
|
41
|
+
purpose: GATE_PURPOSE,
|
|
42
|
+
validate: (data) => !!data && typeof data === 'object' && stable(data) === expected,
|
|
43
|
+
});
|
|
44
|
+
if (!gate.ok) {
|
|
45
|
+
res.status(403).json({ error: gate.reason, error_code: gate.error_code });
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
const bodyInput = (req) => {
|
|
51
|
+
const b = req.body || {};
|
|
52
|
+
const out = { instruction: b.instruction, label: b.label, method: b.method, currency: b.currency };
|
|
53
|
+
if ('qr_data_uri' in b)
|
|
54
|
+
out.qrDataUri = b.qr_data_uri; // 缺省不改;给了(含 null/'')才动 qr
|
|
55
|
+
return out;
|
|
56
|
+
};
|
|
57
|
+
/** 审计摘要(canonical,不含 raw QR):便于事后还原"当时展示给卖家的收款内容"。instruction/qr 用 sha256。 */
|
|
58
|
+
const summarize = (a) => a == null ? null : { method: a.method ?? null, currency: a.currency ?? null, label: a.label ?? null, instruction_sha256: a.instruction ? sha256hex(a.instruction) : null, qr_sha256: a.qr_data_uri ? sha256hex(a.qr_data_uri) : null, had_qr: !!a.qr_data_uri };
|
|
59
|
+
// ── list(ROOT 读;含 inactive + qr_data_uri)──
|
|
60
|
+
app.get('/api/admin/platform-receive-accounts', (req, res) => {
|
|
61
|
+
const admin = requireRootAdmin(req, res);
|
|
62
|
+
if (!admin)
|
|
63
|
+
return;
|
|
64
|
+
res.json({ accounts: listPlatformAccounts(db, { includeInactive: true }) });
|
|
65
|
+
});
|
|
66
|
+
// ── add(ROOT + Passkey)──
|
|
67
|
+
app.post('/api/admin/platform-receive-accounts', (req, res) => {
|
|
68
|
+
const admin = requireRootAdmin(req, res);
|
|
69
|
+
if (!admin)
|
|
70
|
+
return;
|
|
71
|
+
if (!passkeyOk(req, res, admin.id, 'add'))
|
|
72
|
+
return;
|
|
73
|
+
const r = addPlatformAccount(db, bodyInput(req), generateId);
|
|
74
|
+
if (!r.ok)
|
|
75
|
+
return void res.status(400).json({ error: r.reason, error_code: 'PLATFORM_ACCOUNT_INPUT_INVALID' });
|
|
76
|
+
logAdminAction?.(admin.id, 'platform_receive_account_add', 'platform_receive_account', r.account.id, { new: summarize(r.account) });
|
|
77
|
+
res.json({ ok: true, account: r.account });
|
|
78
|
+
});
|
|
79
|
+
// ── update(ROOT + Passkey + 存在性)──
|
|
80
|
+
app.put('/api/admin/platform-receive-accounts/:id', (req, res) => {
|
|
81
|
+
const admin = requireRootAdmin(req, res);
|
|
82
|
+
if (!admin)
|
|
83
|
+
return;
|
|
84
|
+
const id = String(req.params.id);
|
|
85
|
+
const before = getPlatformAccount(db, id);
|
|
86
|
+
if (!before)
|
|
87
|
+
return void res.status(404).json({ error: '平台收款方式不存在', error_code: 'PLATFORM_ACCOUNT_NOT_FOUND' });
|
|
88
|
+
if (!passkeyOk(req, res, admin.id, 'update', id))
|
|
89
|
+
return;
|
|
90
|
+
const r = updatePlatformAccount(db, id, bodyInput(req));
|
|
91
|
+
if (!r.ok)
|
|
92
|
+
return void res.status(400).json({ error: r.reason, error_code: 'PLATFORM_ACCOUNT_INPUT_INVALID' });
|
|
93
|
+
logAdminAction?.(admin.id, 'platform_receive_account_update', 'platform_receive_account', id, { old: summarize(before), new: summarize(getPlatformAccount(db, id)) });
|
|
94
|
+
res.json({ ok: true, changed: r.changed });
|
|
95
|
+
});
|
|
96
|
+
// ── deactivate(ROOT + Passkey + 存在性)──
|
|
97
|
+
app.delete('/api/admin/platform-receive-accounts/:id', (req, res) => {
|
|
98
|
+
const admin = requireRootAdmin(req, res);
|
|
99
|
+
if (!admin)
|
|
100
|
+
return;
|
|
101
|
+
const id = String(req.params.id);
|
|
102
|
+
const before = getPlatformAccount(db, id);
|
|
103
|
+
if (!before)
|
|
104
|
+
return void res.status(404).json({ error: '平台收款方式不存在', error_code: 'PLATFORM_ACCOUNT_NOT_FOUND' });
|
|
105
|
+
if (!passkeyOk(req, res, admin.id, 'deactivate', id))
|
|
106
|
+
return;
|
|
107
|
+
const changed = deactivatePlatformAccount(db, id);
|
|
108
|
+
logAdminAction?.(admin.id, 'platform_receive_account_deactivate', 'platform_receive_account', id, { old: summarize(before) });
|
|
109
|
+
res.json({ ok: true, changed });
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -23,9 +23,10 @@ export function registerProductsCreateRoutes(app, deps) {
|
|
|
23
23
|
const quotaCheck = checkSellerCanList(user);
|
|
24
24
|
if (!quotaCheck.ok)
|
|
25
25
|
return void res.json({ error: quotaCheck.reason });
|
|
26
|
-
const { title, description, price, stock = 1, category = '', specs, brand, model, source_url, source_price, external_title, weight_kg, ship_regions = '全国', handling_hours = 24, estimated_days, fragile = 0, return_days = 7, return_condition = '', warranty_days = 0, low_stock_threshold = 3, auto_delist_on_zero = 1, commission_rate, product_type = 'retail', // 里程碑 6
|
|
26
|
+
const { title, description, price, stock = 1, category = '', specs, brand, model, source_url, source_price, external_title, weight_kg, ship_regions = '全国', handling_hours = 24, estimated_days, fragile = 0, return_days = 7, return_condition = '', warranty_days = 0, low_stock_threshold = 3, auto_delist_on_zero = 1, commission_rate, product_type = 'retail', create_status, // 里程碑 6 / S4:create_status='warehouse' 让含单品覆盖的新品先落仓库,覆盖全落定再激活(避免公开 active 但缺可售/税费声明)
|
|
27
27
|
aliases = [], // 里程碑 7.2:上架时同步声明的 alias 集合
|
|
28
28
|
image_hashes = [], // 商品图片 — 只存 hash(64 hex),实际 blob 在卖家节点 IDB
|
|
29
|
+
package_size, origin_country, country_of_origin, customs_description, hs_code, // S0 跨境清关/物流证据字段(全可选,零计费逻辑;进条款快照)
|
|
29
30
|
} = req.body;
|
|
30
31
|
// 协议化原则:服务端只存 hash 引用,不存图片字节。校验仅做格式 + 数量。
|
|
31
32
|
let imagesJsonForInsert = null;
|
|
@@ -69,6 +70,12 @@ export function registerProductsCreateRoutes(app, deps) {
|
|
|
69
70
|
const priceNum = Number(price);
|
|
70
71
|
const stakeDiscount = await getStakeDiscount(db, user.id);
|
|
71
72
|
const stakeRate = Math.max(0.05, 0.15 - stakeDiscount);
|
|
73
|
+
// S0 清关字段轻校验(可选;长度界 + 区码大写 + HS 编码字符集)
|
|
74
|
+
const _cc = (x) => (typeof x === 'string' && x.trim()) ? x.trim().toUpperCase().slice(0, 8) : null;
|
|
75
|
+
const _tx = (x, n) => (typeof x === 'string' && x.trim()) ? x.trim().slice(0, n) : null;
|
|
76
|
+
const _hs = _tx(hs_code, 12);
|
|
77
|
+
if (_hs && !/^[0-9.]{4,12}$/.test(_hs))
|
|
78
|
+
return void res.status(400).json({ error: 'hs_code 须为 4-12 位数字(可含 .)', error_code: 'INVALID_HS_CODE' });
|
|
72
79
|
const stakeAmount = Math.round(priceNum * stakeRate * 100) / 100;
|
|
73
80
|
const now = new Date().toISOString();
|
|
74
81
|
const id = generateId('prd');
|
|
@@ -82,8 +89,9 @@ export function registerProductsCreateRoutes(app, deps) {
|
|
|
82
89
|
return_days, return_condition, warranty_days,
|
|
83
90
|
low_stock_threshold, auto_delist_on_zero,
|
|
84
91
|
commitment_hash, description_hash, price_hash, hashed_at,
|
|
85
|
-
commission_rate, product_type, images, currency
|
|
86
|
-
|
|
92
|
+
commission_rate, product_type, images, currency,
|
|
93
|
+
package_size, origin_country, country_of_origin, customs_description, hs_code, status
|
|
94
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'WAZ',?,?,?,?,?,?)`, [
|
|
87
95
|
id, user.id, title, description, priceNum, Number(stock), category, stakeAmount,
|
|
88
96
|
specsJson, brand ?? null, model ?? null,
|
|
89
97
|
source_url ?? null, source_price ? Number(source_price) : null, source_price ? now : null,
|
|
@@ -92,7 +100,8 @@ export function registerProductsCreateRoutes(app, deps) {
|
|
|
92
100
|
Math.max(0, Math.floor(Number(low_stock_threshold) || 0)), auto_delist_on_zero ? 1 : 0,
|
|
93
101
|
makeCommitmentHash(pFields), makeDescriptionHash({ title, description, specs: specsJson }),
|
|
94
102
|
makePriceHash(priceNum, now), now,
|
|
95
|
-
commissionRateNum, product_type, imagesJsonForInsert
|
|
103
|
+
commissionRateNum, product_type, imagesJsonForInsert,
|
|
104
|
+
_tx(package_size, 40), _cc(origin_country), _cc(country_of_origin), _tx(customs_description, 120), _hs, create_status === 'warehouse' ? 'warehouse' : 'active'
|
|
96
105
|
]);
|
|
97
106
|
// M7.2.6:免质押上架 — 不再扣 stake;首单成交时 settleOrder 自动从订单 escrow 锁定
|
|
98
107
|
// M7.2-6: 上架时同步入 aliases(卖家已勾选的 candidates)
|
|
@@ -10,7 +10,8 @@ export function registerProductsUpdateRoutes(app, deps) {
|
|
|
10
10
|
const product = await dbOne('SELECT * FROM products WHERE id = ? AND seller_id = ?', [req.params.id, user.id]);
|
|
11
11
|
if (!product)
|
|
12
12
|
return void res.status(404).json({ error: '商品不存在或无权限' });
|
|
13
|
-
const { title, description, price, stock, specs, brand, model, handling_hours, ship_regions, estimated_days, fragile, return_days, return_condition, warranty_days, low_stock_threshold, auto_delist_on_zero, origin_claims, i18n_titles, i18n_descs,
|
|
13
|
+
const { title, description, price, stock, specs, brand, model, handling_hours, ship_regions, estimated_days, fragile, return_days, return_condition, warranty_days, low_stock_threshold, auto_delist_on_zero, origin_claims, i18n_titles, i18n_descs, weight_kg, package_size, origin_country, country_of_origin, customs_description, hs_code, // S0 跨境清关/物流证据字段(可选;进后续订单条款快照,不影响在途单)
|
|
14
|
+
} = req.body;
|
|
14
15
|
const now = new Date().toISOString();
|
|
15
16
|
const specsJson = specs != null ? (typeof specs === 'object' ? JSON.stringify(specs) : specs) : product.specs;
|
|
16
17
|
const estJson = estimated_days != null ? (typeof estimated_days === 'object' ? JSON.stringify(estimated_days) : String(estimated_days)) : product.estimated_days;
|
|
@@ -80,6 +81,12 @@ export function registerProductsUpdateRoutes(app, deps) {
|
|
|
80
81
|
}
|
|
81
82
|
return Object.keys(out).length > 0 ? JSON.stringify(out) : null;
|
|
82
83
|
};
|
|
84
|
+
// S0 清关字段(可选;与 create 同校验;null 显式清除,undefined 保留)
|
|
85
|
+
const _cc = (x) => x === undefined ? undefined : ((typeof x === 'string' && x.trim()) ? x.trim().toUpperCase().slice(0, 8) : null);
|
|
86
|
+
const _tx = (x, n) => x === undefined ? undefined : ((typeof x === 'string' && x.trim()) ? x.trim().slice(0, n) : null);
|
|
87
|
+
const _hs = _tx(hs_code, 12);
|
|
88
|
+
if (typeof _hs === 'string' && !/^[0-9.]{4,12}$/.test(_hs))
|
|
89
|
+
return void res.status(400).json({ error: 'hs_code 须为 4-12 位数字(可含 .)', error_code: 'INVALID_HS_CODE' });
|
|
83
90
|
const titlesResult = validateI18n(i18n_titles);
|
|
84
91
|
const descsResult = validateI18n(i18n_descs);
|
|
85
92
|
const newI18nTitles = titlesResult === undefined ? product.i18n_titles : titlesResult;
|
|
@@ -92,6 +99,7 @@ export function registerProductsUpdateRoutes(app, deps) {
|
|
|
92
99
|
low_stock_alerted_at = CASE WHEN ?=1 THEN NULL ELSE low_stock_alerted_at END,
|
|
93
100
|
origin_claims=?,
|
|
94
101
|
i18n_titles=?, i18n_descs=?,
|
|
102
|
+
weight_kg=?, package_size=?, origin_country=?, country_of_origin=?, customs_description=?, hs_code=?,
|
|
95
103
|
commitment_hash=?, description_hash=?, price_hash=?, hashed_at=?,
|
|
96
104
|
updated_at=datetime('now')
|
|
97
105
|
WHERE id=?`, [
|
|
@@ -102,6 +110,12 @@ export function registerProductsUpdateRoutes(app, deps) {
|
|
|
102
110
|
newLowThreshold, newAutoDelist, resetAlert,
|
|
103
111
|
newOriginClaims,
|
|
104
112
|
newI18nTitles, newI18nDescs,
|
|
113
|
+
weight_kg === undefined ? product.weight_kg : (weight_kg === null || weight_kg === '' ? null : Number(weight_kg)),
|
|
114
|
+
_tx(package_size, 40) === undefined ? product.package_size : _tx(package_size, 40),
|
|
115
|
+
_cc(origin_country) === undefined ? product.origin_country : _cc(origin_country),
|
|
116
|
+
_cc(country_of_origin) === undefined ? product.country_of_origin : _cc(country_of_origin),
|
|
117
|
+
_tx(customs_description, 120) === undefined ? product.customs_description : _tx(customs_description, 120),
|
|
118
|
+
_hs === undefined ? product.hs_code : _hs,
|
|
105
119
|
makeCommitmentHash(pFields),
|
|
106
120
|
makeDescriptionHash({ title: newTitle, description: newDesc, specs: specsJson }),
|
|
107
121
|
makePriceHash(newPrice, now), now,
|
|
@@ -59,8 +59,10 @@ export function registerProfileIdentityRoutes(app, deps) {
|
|
|
59
59
|
const roles = safeRoles(user);
|
|
60
60
|
if (!roles.includes(role))
|
|
61
61
|
return void res.json({ error: '你还没有该角色,请先添加' });
|
|
62
|
-
// 防御:已是 admin/verifier 的用户不能切到 buyer/seller
|
|
63
|
-
|
|
62
|
+
// 防御:已是 admin/verifier 的用户不能切到 buyer/seller(防遗留多角色账户绕过)。
|
|
63
|
+
// 锁的是【交易面目标】,不是一切切换 —— 此前 `!ROLE_LOCKED_ROLES.includes(role)` 连 verifier→arbitrator/logistics
|
|
64
|
+
// (同为受信/治理身份,零交易能力)都拦 → 切到审核员后被永久卡死在审核员(梦想者1号案:回不到仲裁员)。
|
|
65
|
+
if (roles.some(r => ROLE_LOCKED_ROLES.includes(r)) && ['buyer', 'seller'].includes(role)) {
|
|
64
66
|
return void res.json({
|
|
65
67
|
error: '受信角色不能切换为 buyer / seller',
|
|
66
68
|
hint: '权责分离原则;如需买卖请用其他账号。'
|
|
@@ -2,6 +2,9 @@ import { recordRepEvent } from '../../layer4-economics/L4-3-reputation/reputatio
|
|
|
2
2
|
// RFC-016 Phase 1 — 端点校验读/列表读 + 状态翻转/消息/通知单写 → async seam;
|
|
3
3
|
// executeReturnRefund 退款 db.transaction(钱+库存)与 escalate 建争议 tx 保持同步(Phase 3 迁 pg)。
|
|
4
4
|
import { dbOne, dbAll, dbRun } from '../../layer0-foundation/L0-1-database/db.js';
|
|
5
|
+
// 直付(direct_p2p)退货:物流/协商骨架共用,退款执行环节换成场外握手(零资金零库存回补)。
|
|
6
|
+
// mark-refunded / confirm-refund 端点在 routes/direct-pay-returns.ts。
|
|
7
|
+
import { enterAwaitRefund, directPayReturnEscalatable, returnRefundRespondDays } from '../../direct-pay-returns.js';
|
|
5
8
|
const VALID_RETURN_REASONS = new Set(['quality', 'wrong_item', 'damaged', 'no_longer_needed', 'other']);
|
|
6
9
|
const RETURN_REASON_DEFAULT_LABEL = {
|
|
7
10
|
quality: '质量问题',
|
|
@@ -97,12 +100,10 @@ export function registerReturnsRoutes(app, deps) {
|
|
|
97
100
|
return void res.status(404).json({ error: '订单不存在' });
|
|
98
101
|
if (order.buyer_id !== user.id)
|
|
99
102
|
return void res.status(403).json({ error: '仅买家可申请退货' });
|
|
100
|
-
// 直付(Rail1)=
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
// P0-1: 只允许 completed 退货 — escrow 已结算
|
|
103
|
+
// 直付(Rail1)= 非托管:退货【物流/协商骨架照用】,但退款执行环节换成场外握手(src/direct-pay-returns.ts,
|
|
104
|
+
// accept → await_refund → 卖家 mark_refunded → 买家 Passkey confirm → refunded)。零资金零库存回补
|
|
105
|
+
// (已出库=B 类,退货验收上架);平台费应收的冲销仍由治理/admin 调整处理(非此路径)。
|
|
106
|
+
// P0-1: 只允许 completed 退货 — escrow 已结算(直付同口径:确认收货后)
|
|
106
107
|
if (order.status !== 'completed') {
|
|
107
108
|
return void res.status(400).json({ error: '仅订单完成后可申请退货(确认收货后)' });
|
|
108
109
|
}
|
|
@@ -115,7 +116,7 @@ export function registerReturnsRoutes(app, deps) {
|
|
|
115
116
|
return void res.status(400).json({ error: `已超过 ${returnDays} 天退货窗口` });
|
|
116
117
|
}
|
|
117
118
|
const existing = await dbOne(`
|
|
118
|
-
SELECT id, status FROM return_requests WHERE order_id = ? AND status IN ('pending', 'accepted') LIMIT 1
|
|
119
|
+
SELECT id, status FROM return_requests WHERE order_id = ? AND status IN ('pending', 'accepted', 'accepted_pickup_pending', 'picked_up', 'await_refund', 'refund_marked') LIMIT 1
|
|
119
120
|
`, [order.id]);
|
|
120
121
|
if (existing)
|
|
121
122
|
return void res.status(400).json({ error: `已存在退货请求 (${existing.status})` });
|
|
@@ -221,6 +222,10 @@ export function registerReturnsRoutes(app, deps) {
|
|
|
221
222
|
const response = req.body?.response ? String(req.body.response).slice(0, 500) : null;
|
|
222
223
|
if (decision === 'reject' && !response)
|
|
223
224
|
return void res.status(400).json({ error: '拒绝时必须填写说明' });
|
|
225
|
+
// 直付(direct_p2p):同意退货后不走 escrow 钱包退款,进入场外退款握手(await_refund)。取件流照用
|
|
226
|
+
// (accepted_pickup_pending → picked_up → received 时再进 await_refund)。
|
|
227
|
+
const railRow = await dbOne('SELECT payment_rail FROM orders WHERE id = ?', [rr.order_id]);
|
|
228
|
+
const isDirectPay = railRow?.payment_rail === 'direct_p2p';
|
|
224
229
|
if (decision === 'accept') {
|
|
225
230
|
if (Number(rr.pickup_requested) === 1) {
|
|
226
231
|
await dbRun(`UPDATE return_requests SET status = 'accepted_pickup_pending', seller_response = ? WHERE id = ?`, [response, rr.id]);
|
|
@@ -234,6 +239,16 @@ export function registerReturnsRoutes(app, deps) {
|
|
|
234
239
|
catch { }
|
|
235
240
|
return void res.json({ success: true, status: 'accepted_pickup_pending' });
|
|
236
241
|
}
|
|
242
|
+
if (isDirectPay) {
|
|
243
|
+
const r = enterAwaitRefund(db, { returnId: String(rr.id), fromStatus: 'pending', sellerResponse: response, messageId: generateId('rmsg') });
|
|
244
|
+
if (!r.ok)
|
|
245
|
+
return void res.status(409).json({ error: r.error, error_code: r.error_code });
|
|
246
|
+
try {
|
|
247
|
+
await dbRun(`INSERT INTO notifications (id, user_id, type, title, body, order_id) VALUES (?,?,?,?,?,?)`, [generateId('ntf'), rr.buyer_id, 'direct_pay_return_await_refund', '✓ 退货已同意 · 等待卖家场外退款', '直付订单非托管:卖家将在协议外向你退款并声明,请核实到账后在订单页确认(需 Passkey)。卖家超期未退款可升级争议。', rr.order_id]);
|
|
248
|
+
}
|
|
249
|
+
catch { }
|
|
250
|
+
return void res.json({ success: true, status: 'await_refund' });
|
|
251
|
+
}
|
|
237
252
|
try {
|
|
238
253
|
executeReturnRefund(rr, response, 'pending');
|
|
239
254
|
}
|
|
@@ -402,6 +417,18 @@ export function registerReturnsRoutes(app, deps) {
|
|
|
402
417
|
if (rr.status !== 'picked_up')
|
|
403
418
|
return void res.status(400).json({ error: `当前状态 ${rr.status},不可确认(应在 picked_up 状态)` });
|
|
404
419
|
const note = req.body?.note ? String(req.body.note).slice(0, 300) : null;
|
|
420
|
+
// 直付:收到退货后进入场外退款握手(不走 escrow 钱包退款)
|
|
421
|
+
const railRow = await dbOne('SELECT payment_rail FROM orders WHERE id = ?', [rr.order_id]);
|
|
422
|
+
if (railRow?.payment_rail === 'direct_p2p') {
|
|
423
|
+
const r = enterAwaitRefund(db, { returnId: String(rr.id), fromStatus: 'picked_up', sellerResponse: note, messageId: generateId('rmsg') });
|
|
424
|
+
if (!r.ok)
|
|
425
|
+
return void res.status(409).json({ error: r.error, error_code: r.error_code });
|
|
426
|
+
try {
|
|
427
|
+
await dbRun(`INSERT INTO notifications (id, user_id, type, title, body, order_id) VALUES (?,?,?,?,?,?)`, [generateId('ntf'), rr.buyer_id, 'direct_pay_return_await_refund', '✓ 卖家已收到退货 · 等待场外退款', '直付订单非托管:卖家将在协议外向你退款并声明,请核实到账后在订单页确认(需 Passkey)。卖家超期未退款可升级争议。', rr.order_id]);
|
|
428
|
+
}
|
|
429
|
+
catch { }
|
|
430
|
+
return void res.json({ success: true, status: 'await_refund' });
|
|
431
|
+
}
|
|
405
432
|
try {
|
|
406
433
|
executeReturnRefund(rr, note, 'picked_up');
|
|
407
434
|
}
|
|
@@ -474,7 +501,11 @@ export function registerReturnsRoutes(app, deps) {
|
|
|
474
501
|
return void res.status(404).json({ error: '不存在' });
|
|
475
502
|
if (rr.buyer_id !== user.id)
|
|
476
503
|
return void res.status(403).json({ error: '仅买家可升级' });
|
|
477
|
-
|
|
504
|
+
// 直付握手状态可升级:refund_marked 随时(声明≠到账);await_refund 超 respond 窗(卖家同意却不退款)
|
|
505
|
+
const isDpEsc = ['await_refund', 'refund_marked'].includes(String(rr.status)) && directPayReturnEscalatable(db, rr);
|
|
506
|
+
if (rr.status !== 'rejected' && rr.status !== 'pending' && !isDpEsc) {
|
|
507
|
+
if (rr.status === 'await_refund')
|
|
508
|
+
return void res.status(400).json({ error: `卖家有 ${returnRefundRespondDays(db)} 天场外退款窗口,超期后可升级` });
|
|
478
509
|
return void res.status(400).json({ error: `当前状态 ${rr.status},无法升级` });
|
|
479
510
|
}
|
|
480
511
|
if (rr.status === 'pending') {
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { dbOne, dbAll } from '../../layer0-foundation/L0-1-database/db.js';
|
|
2
|
+
// 直付订单状态桶(与状态机一致):已完成(计提平台费)/ 在途 / 已取消·退款。
|
|
3
|
+
const COMPLETED = ['completed', 'confirmed'];
|
|
4
|
+
const IN_FLIGHT = ['pending_accept', 'direct_pay_window', 'accepted', 'shipped', 'picked_up', 'in_transit', 'delivered', 'payment_query', 'direct_expired_unconfirmed'];
|
|
5
|
+
const CLOSED_NEG = ['cancelled', 'expired', 'refunded_full', 'refunded_partial', 'dispute_dismissed', 'resolved_for_seller', 'fault_seller', 'fault_buyer', 'fault_logistics'];
|
|
6
|
+
const inList = (col, arr) => `${col} IN (${arr.map(() => '?').join(',')})`;
|
|
7
|
+
export function registerSellerDirectPayReportRoutes(app, deps) {
|
|
8
|
+
const { db, auth } = deps;
|
|
9
|
+
function requireSeller(req, res) {
|
|
10
|
+
const user = auth(req, res);
|
|
11
|
+
if (!user)
|
|
12
|
+
return null;
|
|
13
|
+
const roles = (() => { try {
|
|
14
|
+
return JSON.parse(String(user.roles ?? '[]'));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return [];
|
|
18
|
+
} })();
|
|
19
|
+
if (user.role !== 'seller' && !(Array.isArray(roles) && roles.includes('seller'))) {
|
|
20
|
+
res.status(403).json({ error: '仅卖家可查看', error_code: 'SELLER_ONLY' });
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return user;
|
|
24
|
+
}
|
|
25
|
+
// GET /api/sellers/me/direct-pay-report?from=YYYY-MM-DD&to=YYYY-MM-DD
|
|
26
|
+
// from/to 均可选(闭区间,按日期比较 substr(created_at,1,10));返回汇总 + 按月 + 逐单(含平台费明细)。
|
|
27
|
+
app.get('/api/sellers/me/direct-pay-report', async (req, res) => {
|
|
28
|
+
const user = requireSeller(req, res);
|
|
29
|
+
if (!user)
|
|
30
|
+
return;
|
|
31
|
+
const sellerId = user.id;
|
|
32
|
+
const from = typeof req.query.from === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(req.query.from) ? req.query.from : null;
|
|
33
|
+
const to = typeof req.query.to === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(req.query.to) ? req.query.to : null;
|
|
34
|
+
const range = [];
|
|
35
|
+
const rp = [];
|
|
36
|
+
if (from) {
|
|
37
|
+
range.push('substr(o.created_at,1,10) >= ?');
|
|
38
|
+
rp.push(from);
|
|
39
|
+
}
|
|
40
|
+
if (to) {
|
|
41
|
+
range.push('substr(o.created_at,1,10) <= ?');
|
|
42
|
+
rp.push(to);
|
|
43
|
+
}
|
|
44
|
+
const rangeClause = range.length ? ' AND ' + range.join(' AND ') : '';
|
|
45
|
+
// ① 汇总(单查:订单数 / 销售额 / 各桶计数与已完成销售额)
|
|
46
|
+
const summary = (await dbOne(`
|
|
47
|
+
SELECT
|
|
48
|
+
COUNT(*) as order_count,
|
|
49
|
+
COALESCE(SUM(o.total_amount), 0) as gross_order_total,
|
|
50
|
+
SUM(CASE WHEN ${inList('o.status', COMPLETED)} THEN 1 ELSE 0 END) as completed_count,
|
|
51
|
+
COALESCE(SUM(CASE WHEN ${inList('o.status', COMPLETED)} THEN o.total_amount ELSE 0 END), 0) as completed_sales,
|
|
52
|
+
SUM(CASE WHEN ${inList('o.status', IN_FLIGHT)} THEN 1 ELSE 0 END) as in_flight_count,
|
|
53
|
+
SUM(CASE WHEN ${inList('o.status', CLOSED_NEG)} THEN 1 ELSE 0 END) as closed_count
|
|
54
|
+
FROM orders o
|
|
55
|
+
WHERE o.seller_id = ? AND o.payment_rail = 'direct_p2p'${rangeClause}
|
|
56
|
+
`, [...COMPLETED, ...COMPLETED, ...IN_FLIGHT, ...CLOSED_NEG, sellerId, ...rp]));
|
|
57
|
+
// ② 区间内已计提平台费合计(逐单应收 join 订单,按订单下单日筛)
|
|
58
|
+
const feeAgg = (await dbOne(`
|
|
59
|
+
SELECT COALESCE(SUM(r.amount), 0) as fee_accrued, COUNT(*) as fee_count
|
|
60
|
+
FROM direct_pay_fee_receivables r JOIN orders o ON o.id = r.order_id
|
|
61
|
+
WHERE o.seller_id = ? AND o.payment_rail = 'direct_p2p'${rangeClause}
|
|
62
|
+
`, [sellerId, ...rp]));
|
|
63
|
+
// ③ 按月(最多 24 个月)—— 订单总额(含各状态)与完成单销售额分列,不混
|
|
64
|
+
const byMonth = await dbAll(`
|
|
65
|
+
SELECT substr(o.created_at, 1, 7) as month, COUNT(*) as order_count,
|
|
66
|
+
COALESCE(SUM(o.total_amount), 0) as gross_order_total,
|
|
67
|
+
COALESCE(SUM(CASE WHEN ${inList('o.status', COMPLETED)} THEN o.total_amount ELSE 0 END), 0) as completed_sales
|
|
68
|
+
FROM orders o
|
|
69
|
+
WHERE o.seller_id = ? AND o.payment_rail = 'direct_p2p'${rangeClause}
|
|
70
|
+
GROUP BY month ORDER BY month DESC LIMIT 24
|
|
71
|
+
`, [...COMPLETED, sellerId, ...rp]);
|
|
72
|
+
// ④ 逐单明细(含平台费:LEFT JOIN 应收表 —— 未完成单尚无 fee 行,fee 为 null)。上限 500。
|
|
73
|
+
const LIMIT = 500;
|
|
74
|
+
const rows = await dbAll(`
|
|
75
|
+
SELECT o.id, o.created_at, o.status, o.total_amount, o.ship_to_region, p.title as product_title,
|
|
76
|
+
r.amount as fee_amount, r.accrued_at as fee_accrued_at
|
|
77
|
+
FROM orders o
|
|
78
|
+
LEFT JOIN products p ON p.id = o.product_id
|
|
79
|
+
LEFT JOIN direct_pay_fee_receivables r ON r.order_id = o.id
|
|
80
|
+
WHERE o.seller_id = ? AND o.payment_rail = 'direct_p2p'${rangeClause}
|
|
81
|
+
ORDER BY o.created_at DESC LIMIT ?
|
|
82
|
+
`, [sellerId, ...rp, LIMIT + 1]);
|
|
83
|
+
const truncated = rows.length > LIMIT;
|
|
84
|
+
if (truncated)
|
|
85
|
+
rows.length = LIMIT;
|
|
86
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
87
|
+
res.json({
|
|
88
|
+
range: { from, to },
|
|
89
|
+
currency_note: '销售额=下单计价币(买家应付金额);平台服务费=USDC。两者不同币种,分列展示。',
|
|
90
|
+
summary: {
|
|
91
|
+
order_count: Number(summary.order_count) || 0,
|
|
92
|
+
gross_order_total: Number(summary.gross_order_total) || 0, // 所有状态订单总额(含在途/已关闭)—— 非真实销售
|
|
93
|
+
completed_count: Number(summary.completed_count) || 0,
|
|
94
|
+
completed_sales: Number(summary.completed_sales) || 0, // 真实销售(仅 completed/confirmed)
|
|
95
|
+
in_flight_count: Number(summary.in_flight_count) || 0,
|
|
96
|
+
closed_count: Number(summary.closed_count) || 0,
|
|
97
|
+
fee_accrued_total: Number(feeAgg.fee_accrued) || 0, // 区间已计提平台费(USDC)
|
|
98
|
+
fee_order_count: Number(feeAgg.fee_count) || 0,
|
|
99
|
+
},
|
|
100
|
+
by_month: byMonth.map(m => ({ month: m.month, order_count: Number(m.order_count) || 0, gross_order_total: Number(m.gross_order_total) || 0, completed_sales: Number(m.completed_sales) || 0 })),
|
|
101
|
+
orders: rows.map(r => ({
|
|
102
|
+
id: r.id, created_at: r.created_at, status: r.status, total_amount: Number(r.total_amount) || 0,
|
|
103
|
+
ship_to_region: r.ship_to_region || null, product_title: r.product_title || null,
|
|
104
|
+
fee_amount: r.fee_amount == null ? null : Number(r.fee_amount), // null=未完成尚未计提
|
|
105
|
+
fee_accrued_at: r.fee_accrued_at || null,
|
|
106
|
+
})),
|
|
107
|
+
truncated,
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|