trustbase-seller-backend 0.2.3 → 0.2.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trustbase-seller-backend",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "TrustBase Seller Backend (merchant edition) — 商品上链/订单/支付/质押 write API for TrustBase 保障链生态. 单卖家自托管形态: 签名 key 即卖家身份, 私钥不出门.",
5
5
  "main": "run-unified.js",
6
6
  "bin": {
@@ -147,4 +147,41 @@ async function queryProfitsharing(outOrderNo, opts) {
147
147
  return r.mock ? { mock: true, out_order_no: String(outOrderNo) } : r;
148
148
  }
149
149
 
150
- module.exports = { addReceivers, requestProfitsharing, queryProfitsharing, assertShareAmount, MAX_SHARE_BP };
150
+ /** M3-1: 分账接收方注册台账 (幂等标记; 已注册不重复调微信 — 重复调会报 "接收方已存在" 类错误) */
151
+ function ensurePsReceiverTable(db) {
152
+ db.exec(`
153
+ CREATE TABLE IF NOT EXISTS sp_ps_receivers (
154
+ account TEXT PRIMARY KEY, -- 接收方商户号 (SP 自身 mchid)
155
+ registered_at INTEGER NOT NULL,
156
+ mock INTEGER NOT NULL DEFAULT 0
157
+ );
158
+ `);
159
+ }
160
+
161
+ /** M3-1: 注册默认分账接收方 (SP 自身 mchid, type MERCHANT_ID)。
162
+ * 调用时机: SP 服务启动 (createSpNotifyRouter 挂载) + 进件 FINISHED 钩子 (sp-applyment)。
163
+ * 幂等: 台账有记录 → {registered:false, reason:'already'} 不调微信; mock 模式同样落台账。 */
164
+ async function registerDefaultReceivers(db) {
165
+ ensurePsReceiverTable(db);
166
+ const c = wxCfg();
167
+ const account = c.spMchid || c.mchid;
168
+ if (!account) {
169
+ console.warn("[profitsharing] 未配 WXPAY_SP_MCHID/WXPAY_MCHID, 跳过接收方注册");
170
+ return { registered: false, reason: "no_mchid" };
171
+ }
172
+ const row = db.prepare("SELECT account FROM sp_ps_receivers WHERE account = ?").get(account);
173
+ if (row) return { registered: false, reason: "already" };
174
+ const r = await addReceivers([{ account, type: "MERCHANT_ID", name: process.env.SP_NAME || "服务商" }]);
175
+ const one = Array.isArray(r) ? r[0] : r;
176
+ const ok = !!(one && (one.mock || one.status === 200));
177
+ if (ok) {
178
+ db.prepare("INSERT OR IGNORE INTO sp_ps_receivers (account, registered_at, mock) VALUES (?,?,?)")
179
+ .run(account, Math.floor(Date.now() / 1000), one.mock ? 1 : 0);
180
+ console.log(`[profitsharing] 分账接收方已注册 account=${account} mock=${!!one.mock}`);
181
+ return { registered: true, mock: !!one.mock };
182
+ }
183
+ console.warn(`[profitsharing] 接收方注册未成功 account=${account}: ${JSON.stringify(one && one.json || one).slice(0, 200)}`);
184
+ return { registered: false, reason: "wx_error", detail: one && one.json };
185
+ }
186
+
187
+ module.exports = { addReceivers, requestProfitsharing, queryProfitsharing, assertShareAmount, MAX_SHARE_BP, registerDefaultReceivers, ensurePsReceiverTable };
@@ -46,6 +46,8 @@ const crypto = require("crypto");
46
46
  const fs = require("fs");
47
47
  const agentPay = require("./agent-pay");
48
48
  const chainWrites = require("./chain-writes");
49
+ const spCommission = require("./sp-commission");
50
+ const profitsharing = require("../payments/wechat/profitsharing");
49
51
 
50
52
  const HttpError = chainWrites.HttpError;
51
53
 
@@ -630,10 +632,10 @@ function createSpApplymentRouter(deps) {
630
632
  }
631
633
 
632
634
  /** GET /info — 服务商公开名片 (2026-10-03 xiaomu: 卖家入驻页要显示服务商是谁)
633
- * 公开只读, 不要 admin key — 名片本来就是给商户看的公开信息 */
635
+ * 公开只读, 不要 admin key — 名片本来就是给商户看的公开信息
636
+ * M3 🟡-1: 公示比例读 sp_commission_rates 默认行 (__default__) 优先, env 只作首次种子 */
634
637
  router.get("/info", (req, res) => {
635
- const rateBp = Number(process.env.SP_DEFAULT_COMMISSION_BP || 100); // 默认 1%
636
- const bp = Number.isInteger(rateBp) && rateBp >= 0 ? rateBp : 100;
638
+ const bp = spCommission.getDefaultRateBp(db);
637
639
  res.json({
638
640
  name: process.env.SP_NAME || "TrustBase 官方服务商",
639
641
  desc: process.env.SP_DESC || "",
@@ -719,12 +721,14 @@ function createSpApplymentRouter(deps) {
719
721
  if (r.status !== 200 || !(r.json.applyment_id != null)) return wxError(res, r, "提交进件失败");
720
722
  const applymentId = String(r.json.applyment_id);
721
723
  // 台账快照: 敏感字段脱敏 (身份证/卡号只留后 4 位; already_encrypted 时已是密文, 脱敏无妨)
724
+ // M3 🟡-1: 进件时刻的公示抽佣比例快照进 subject_json (事后对账依据)
722
725
  const snapshot = {
723
726
  ...f,
724
727
  legal_id_number: maskTail(f.legal_id_number),
725
728
  account_number: maskTail(f.account_number),
726
729
  business_code: undefined,
727
730
  already_encrypted: undefined,
731
+ commission_bp_at_apply: spCommission.getDefaultRateBp(db),
728
732
  };
729
733
  const now = nowSec();
730
734
  // upsert: 驳回重提时更新已有行 (同 business_code 覆盖), 否则插新行
@@ -791,6 +795,8 @@ function createSpApplymentRouter(deps) {
791
795
  ON CONFLICT(sub_mchid) DO UPDATE SET seller_address=excluded.seller_address, shop_name=excluded.shop_name`)
792
796
  .run(String(j.sub_mchid), row.tb_address || "", String(snap.merchant_shortname || ""), now);
793
797
  console.log(`[sp-applyment] FINISHED 钩子: sub_mchid=${j.sub_mchid} 已登记回调路由 seller=${(row.tb_address || "").slice(0, 14)}…`);
798
+ // M3-1: 进件通过时也确认分账接收方已注册 (启动时注册失败的兜底; 台账幂等)
799
+ await profitsharing.registerDefaultReceivers(db);
794
800
  if (row.tb_address) {
795
801
  const g = await notifyOracleGrant(deps, { tb_address: row.tb_address, license_number: snap.license_number });
796
802
  if (g.ok) {
@@ -35,6 +35,21 @@ CREATE TABLE IF NOT EXISTS sp_commission_rates (
35
35
  /** rate_bp → 人性化百分比 ("1.50%") */
36
36
  function ratePct(rateBp) { return (rateBp / 100).toFixed(2) + "%"; }
37
37
 
38
+ /** M3 🟡-1: 公示默认抽佣比例 (sub_mchid='__default__' 特殊行) — 读表优先,
39
+ * env SP_DEFAULT_COMMISSION_BP 只作首次种子 (表里没有时落一行, 之后 PUT 覆盖生效) */
40
+ const DEFAULT_ROW_KEY = "__default__";
41
+ function getDefaultRateBp(db) {
42
+ ensureCommissionTables(db);
43
+ const row = db.prepare("SELECT rate_bp FROM sp_commission_rates WHERE sub_mchid = ?").get(DEFAULT_ROW_KEY);
44
+ if (row) return row.rate_bp;
45
+ let seed = Number(process.env.SP_DEFAULT_COMMISSION_BP || 100);
46
+ if (!Number.isInteger(seed) || seed < 0 || seed > MAX_RATE_BP) seed = 100;
47
+ const now = nowSec();
48
+ db.prepare("INSERT OR IGNORE INTO sp_commission_rates (sub_mchid, provider, rate_bp, updated_by, created_at, updated_at) VALUES (?,?,?,?,?,?)")
49
+ .run(DEFAULT_ROW_KEY, "", seed, "env-seed", now, now);
50
+ return seed;
51
+ }
52
+
38
53
  function createSpCommissionRouter(deps) {
39
54
  const router = express.Router();
40
55
  const { db } = deps;
@@ -101,4 +116,4 @@ function createSpCommissionRouter(deps) {
101
116
  return router;
102
117
  }
103
118
 
104
- module.exports = { createSpCommissionRouter, ensureCommissionTables, ratePct, MAX_RATE_BP };
119
+ module.exports = { createSpCommissionRouter, ensureCommissionTables, ratePct, MAX_RATE_BP, getDefaultRateBp, DEFAULT_ROW_KEY };
@@ -12,6 +12,9 @@
12
12
  * 6. 上链 MsgSubmitPaymentFact (失败 warn 不阻塞, chain_txhash 留空等 retryPending 补)
13
13
  * 7. 转发商户 webhook (3s 超时, 结果记 forward_status)
14
14
  * 8. 按登记的抽佣比例触发微信分账 (默认 mock, WXPAY_PROFITSHARING_ENABLED=true 才真调)
15
+ * 9. M3 (2026-10-03): 分账台账列 (ps_*) + 触发落行 pending→done/mock/failed + 5min 补扫
16
+ * (retryProfitsharingSweep 同步微信真实状态/同号重发) + 启动/进件时注册分账接收方
17
+ * (profitsharing.registerDefaultReceivers, 台账幂等)
15
18
  *
16
19
  * 与直连模式 (agent-pay E2) 的差异: 回调只推到服务商, 服务商验签解密后按 sub_mchid
17
20
  * 分发 —— 上链提交支付事实 + 转发/落账给对应商户。
@@ -51,6 +54,16 @@ CREATE TABLE IF NOT EXISTS sp_sub_merchants (
51
54
  created_at INTEGER NOT NULL
52
55
  );
53
56
  `);
57
+ // M3-2: 分账台账列 (老库兼容: PRAGMA 检查后 ALTER)
58
+ const cols = db.prepare("PRAGMA table_info(sp_payment_facts)").all().map((c) => c.name);
59
+ for (const [col, ddl] of [
60
+ ["ps_out_order_no", "ALTER TABLE sp_payment_facts ADD COLUMN ps_out_order_no TEXT DEFAULT ''"],
61
+ ["ps_status", "ALTER TABLE sp_payment_facts ADD COLUMN ps_status TEXT DEFAULT ''"], // ''/pending/mock/done/failed
62
+ ["ps_amount", "ALTER TABLE sp_payment_facts ADD COLUMN ps_amount INTEGER"],
63
+ ["ps_at", "ALTER TABLE sp_payment_facts ADD COLUMN ps_at INTEGER"],
64
+ ]) {
65
+ if (!cols.includes(col)) db.exec(ddl);
66
+ }
54
67
  }
55
68
 
56
69
  /** 上链 MsgSubmitPaymentFact (真实通道; 异常上抛, 由调用方降级为 warn 不阻塞)
@@ -102,12 +115,73 @@ async function retryPending(db, opts) {
102
115
  return { scanned: rows.length, retried, failed };
103
116
  }
104
117
 
118
+ /** M3-2: 分账补扫 — 扫 ps_status='pending'/'failed' 的台账行:
119
+ * 先 queryProfitsharing 同步微信侧真实状态 (FINISHED → done); 仍 failed 的用同 out_order_no
120
+ * 重发一笔 (微信侧幂等)。mock 模式: pending → mock, failed → 重发 mock 后记 mock。 */
121
+ async function retryProfitsharingSweep(db, opts) {
122
+ const rows = db.prepare(`SELECT txn_id, sub_mchid, amount, ps_out_order_no, ps_amount, ps_status
123
+ FROM sp_payment_facts WHERE ps_status IN ('pending','failed') AND ps_out_order_no <> '' LIMIT 20`).all();
124
+ let synced = 0, retried = 0, failed = 0;
125
+ const receiverAccount = process.env.WXPAY_SP_MCHID || process.env.WXPAY_MCHID || "";
126
+ for (const row of rows) {
127
+ try {
128
+ const q = await profitsharing.queryProfitsharing(row.ps_out_order_no, { subMchid: row.sub_mchid });
129
+ if (q && q.mock) {
130
+ // mock: 无真实状态可同步; failed 行重发一笔 mock 分账
131
+ if (row.ps_status === "failed") {
132
+ await profitsharing.requestProfitsharing({
133
+ outTradeNo: row.ps_out_order_no, transactionId: row.txn_id, tradeAmount: row.amount,
134
+ subMchid: row.sub_mchid,
135
+ receivers: [{ account: receiverAccount, amount: row.ps_amount, description: "服务商抽佣" }],
136
+ });
137
+ retried++;
138
+ } else {
139
+ synced++;
140
+ }
141
+ db.prepare("UPDATE sp_payment_facts SET ps_status='mock', ps_at=? WHERE txn_id=?").run(nowSec(), row.txn_id);
142
+ continue;
143
+ }
144
+ if (q && q.status === 200 && q.json) {
145
+ const st = String(q.json.state || "");
146
+ if (/FINISHED|SUCCESS/i.test(st)) {
147
+ db.prepare("UPDATE sp_payment_facts SET ps_status='done', ps_at=? WHERE txn_id=?").run(nowSec(), row.txn_id);
148
+ synced++;
149
+ } else if (row.ps_status === "failed") {
150
+ // 微信侧未成功 → 同 out_order_no 重发 (幂等)
151
+ const r2 = await profitsharing.requestProfitsharing({
152
+ outTradeNo: row.ps_out_order_no, transactionId: row.txn_id, tradeAmount: row.amount,
153
+ subMchid: row.sub_mchid,
154
+ receivers: [{ account: receiverAccount, amount: row.ps_amount, description: "服务商抽佣" }],
155
+ });
156
+ const ok = r2 && r2.status === 200;
157
+ db.prepare("UPDATE sp_payment_facts SET ps_status=?, ps_at=? WHERE txn_id=?").run(ok ? "pending" : "failed", nowSec(), row.txn_id);
158
+ if (ok) retried++; else failed++;
159
+ }
160
+ // pending 且微信侧还在处理中: 保持 pending 下轮再扫
161
+ } else {
162
+ failed++;
163
+ console.warn(`[sp-notify] 分账补扫查询未成功 out_order_no=${row.ps_out_order_no} status=${q && q.status}`);
164
+ }
165
+ } catch (e) {
166
+ failed++;
167
+ console.warn(`[sp-notify] 分账补扫失败 txn=${row.txn_id}:`, e && e.message);
168
+ }
169
+ }
170
+ return { scanned: rows.length, synced, retried, failed };
171
+ }
172
+
105
173
  function createSpNotifyRouter(deps) {
106
174
  const router = express.Router();
107
175
  const { db } = deps;
108
176
  ensureTables(db);
109
177
  spCommission.ensureCommissionTables(db); // 分账触发要查抽佣表
110
178
  const injectedSendTx = deps.sendTx || null; // 测试注入 (绕过真实链)
179
+ // M3-1: 启动即注册分账接收方 (SP 自身 mchid; 幂等, 台账标记, mock 也落行)
180
+ profitsharing.registerDefaultReceivers(db)
181
+ .catch((e) => console.warn("[sp-notify] 分账接收方注册失败 (进件 FINISHED 钩子会再试):", e && e.message));
182
+ // M3-2: 分账补扫定时器 (5min; 扫 ps_status pending/failed 同步微信真实状态/重发)
183
+ const psTimer = setInterval(() => { retryProfitsharingSweep(db).catch((e) => console.warn("[sp-notify] 分账补扫异常:", e && e.message)); }, 5 * 60 * 1000);
184
+ psTimer.unref && psTimer.unref();
111
185
 
112
186
  /** 上链统一入口: 禁用开关 > 测试注入 > 真实通道 */
113
187
  async function sendFact(fact) {
@@ -116,7 +190,8 @@ function createSpNotifyRouter(deps) {
116
190
  return submitPaymentFactToChain(fact);
117
191
  }
118
192
 
119
- /** 抽佣分账触发: 按 sp_commission_rates 登记比例, 失败只 warn (mock 默认, 不阻塞回调) */
193
+ /** 抽佣分账触发: 按 sp_commission_rates 登记比例, 失败只 warn (mock 默认, 不阻塞回调)
194
+ * M3-2: 台账落行 pending → 微信/mock 返回后 done/mock/failed; failed/pending 由 retryProfitsharingSweep 补扫 */
120
195
  function maybeTriggerProfitsharing(f) {
121
196
  try {
122
197
  const rate = db.prepare("SELECT rate_bp FROM sp_commission_rates WHERE sub_mchid = ?").get(f.subMchid);
@@ -124,15 +199,22 @@ function createSpNotifyRouter(deps) {
124
199
  const commission = Math.floor(f.amount * rate.rate_bp / 10000);
125
200
  if (!(commission > 0)) return;
126
201
  const receiverAccount = process.env.WXPAY_SP_MCHID || process.env.WXPAY_MCHID || "";
202
+ const outOrderNo = "ps-" + f.txnId; // 分账单号 (微信侧 out_order_no, 幂等键)
203
+ // 台账先落 pending (即使后续调用失败也留痕可补扫)
204
+ db.prepare("UPDATE sp_payment_facts SET ps_out_order_no=?, ps_amount=?, ps_status='pending', ps_at=? WHERE txn_id=?")
205
+ .run(outOrderNo, commission, nowSec(), f.txnId);
127
206
  profitsharing.requestProfitsharing({
128
- outTradeNo: "ps-" + f.txnId, // 分账单号 (微信侧 out_order_no, 幂等键)
207
+ outTradeNo: outOrderNo,
129
208
  transactionId: f.txnId,
130
209
  tradeAmount: f.amount,
131
210
  subMchid: f.subMchid,
132
211
  receivers: [{ account: receiverAccount, amount: commission, description: "服务商抽佣" }],
133
212
  }).then((r) => {
134
- console.log(`[sp-notify] 分账触发 txn=${f.txnId} commission=${commission} mock=${!!(r && r.mock)}`);
213
+ const st = (r && r.mock) ? "mock" : (r && r.status === 200 ? "done" : "failed");
214
+ db.prepare("UPDATE sp_payment_facts SET ps_status=?, ps_at=? WHERE txn_id=?").run(st, nowSec(), f.txnId);
215
+ console.log(`[sp-notify] 分账触发 txn=${f.txnId} commission=${commission} ps_status=${st} mock=${!!(r && r.mock)}`);
135
216
  }).catch((e) => {
217
+ db.prepare("UPDATE sp_payment_facts SET ps_status='failed', ps_at=? WHERE txn_id=?").run(nowSec(), f.txnId);
136
218
  console.warn(`[sp-notify] 分账触发失败 txn=${f.txnId}:`, e && e.message);
137
219
  });
138
220
  } catch (e) {
@@ -249,4 +331,4 @@ function createSpNotifyRouter(deps) {
249
331
  return router;
250
332
  }
251
333
 
252
- module.exports = { createSpNotifyRouter, ensureTables, retryPending, submitPaymentFactToChain };
334
+ module.exports = { createSpNotifyRouter, ensureTables, retryPending, retryProfitsharingSweep, submitPaymentFactToChain };
@@ -154,6 +154,8 @@ test("submit mock: 落台账 + 敏感字段加密 (非明文且可还原) + 重
154
154
  assert.ok(!row.subject_json.includes("440300199001011234"), "快照不含明文身份证");
155
155
  assert.ok(!row.subject_json.includes("6225880212345678"), "快照不含明文卡号");
156
156
  assert.ok(row.subject_json.includes("1234"), "脱敏后留后4位");
157
+ // M3 🟡-1: 进件时刻公示比例快照
158
+ assert.ok(JSON.parse(row.subject_json).commission_bp_at_apply >= 0, "快照含 commission_bp_at_apply");
157
159
 
158
160
  // 敏感字段加密断言: 用同一入口 buildApplymentBody 组装, 密文非明文且测试私钥可还原
159
161
  const pem = fs.readFileSync(pubKeyPath, "utf8");
@@ -230,24 +232,57 @@ test("GET /api/sp/info 公开名片: 无 admin key 200 + env 覆盖生效", asyn
230
232
  assert.strictEqual(j.commission_rate_bp, 100, "默认 1%");
231
233
  assert.strictEqual(j.rate_pct, "1.00%");
232
234
  assert.ok(j.gateway.includes("127.0.0.1"), "gateway 从 req host 推断");
233
- // env 覆盖
235
+ // env 覆盖: 文本类即时生效; SP_DEFAULT_COMMISSION_BP 只作首次种子 (M3 🟡-1),
236
+ // 当前库已被首次调用种子为 100 → 需新库验证种子语义
234
237
  process.env.SP_NAME = "无锡小播鼠网络科技";
235
238
  process.env.SP_DESC = "TrustBase 官方支付服务商";
236
239
  process.env.SP_OFFICIAL_URL = "https://trustbase.example.com";
237
240
  process.env.SP_CONTACT = "微信: xiaomu";
238
- process.env.SP_DEFAULT_COMMISSION_BP = "150";
239
241
  process.env.SP_CHAIN_ADDR = "tct1zg7wa9v2gkwp3yvmxrjlmwgfu0tw9vwcuhwwg7";
240
242
  r = await fetch(base + "/api/sp/info");
241
243
  j = await r.json();
242
244
  assert.strictEqual(j.name, "无锡小播鼠网络科技");
243
245
  assert.strictEqual(j.contact, "微信: xiaomu");
244
- assert.strictEqual(j.commission_rate_bp, 150);
245
- assert.strictEqual(j.rate_pct, "1.50%");
246
+ assert.strictEqual(j.commission_rate_bp, 100, "种子已落表, env 改不动公示比例 (读表优先)");
246
247
  assert.strictEqual(j.chain_address, "tct1zg7wa9v2gkwp3yvmxrjlmwgfu0tw9vwcuhwwg7");
247
248
  } finally {
248
249
  srv.close();
249
250
  delete process.env.SP_NAME; delete process.env.SP_DESC; delete process.env.SP_OFFICIAL_URL;
250
- delete process.env.SP_CONTACT; delete process.env.SP_DEFAULT_COMMISSION_BP; delete process.env.SP_CHAIN_ADDR;
251
+ delete process.env.SP_CONTACT; delete process.env.SP_CHAIN_ADDR;
252
+ }
253
+ });
254
+
255
+ test("M3 🟡-1: /info 公示比例 env 首种子 + 表行优先 (PUT __default__ 覆盖生效)", async () => {
256
+ // 新库 + env 种子 250 → 首次读 2.50% 且落表; 之后 admin PUT __default__=200 → 2.00% (env 不再生效)
257
+ process.env.SP_DEFAULT_COMMISSION_BP = "250";
258
+ const spCommission = require("./routes/sp-commission");
259
+ const db = new Database(":memory:");
260
+ const app = express();
261
+ app.use(express.json());
262
+ app.use("/api/sp", spApplyment.createSpApplymentRouter({ db }));
263
+ app.use("/api/sp", spCommission.createSpCommissionRouter({ db }));
264
+ const srv = app.listen(0);
265
+ await new Promise((r) => srv.once("listening", r));
266
+ const base = `http://127.0.0.1:${srv.address().port}`;
267
+ try {
268
+ let j = await (await fetch(base + "/api/sp/info")).json();
269
+ assert.strictEqual(j.commission_rate_bp, 250);
270
+ assert.strictEqual(j.rate_pct, "2.50%");
271
+ const seedRow = db.prepare("SELECT * FROM sp_commission_rates WHERE sub_mchid='__default__'").get();
272
+ assert.ok(seedRow, "env 首种子落表");
273
+ assert.strictEqual(seedRow.rate_bp, 250);
274
+ // admin 改公示比例 → 读表优先
275
+ const put = await fetch(base + "/api/sp/commission-rate", {
276
+ method: "PUT", headers: { "Content-Type": "application/json", "x-sp-admin-key": ADMIN_KEY },
277
+ body: JSON.stringify({ sub_mchid: "__default__", rate_bp: 200 }),
278
+ });
279
+ assert.strictEqual(put.status, 200, JSON.stringify(await put.json()));
280
+ j = await (await fetch(base + "/api/sp/info")).json();
281
+ assert.strictEqual(j.commission_rate_bp, 200, "PUT __default__ 覆盖生效");
282
+ assert.strictEqual(j.rate_pct, "2.00%");
283
+ } finally {
284
+ srv.close();
285
+ delete process.env.SP_DEFAULT_COMMISSION_BP;
251
286
  }
252
287
  });
253
288
 
@@ -359,3 +359,77 @@ test("retryPending: 上链失败台账留空 → 扫出重试补 txhash", async
359
359
  process.env.SP_CHAIN_DISABLED = "1";
360
360
  }
361
361
  });
362
+
363
+ test("M3-1: 分账接收方注册幂等 (台账标记, 已注册不重复调)", async () => {
364
+ const db = new Database(":memory:");
365
+ spNotify.ensureTables(db);
366
+ // 首次注册 (mock 模式, WXPAY_SP_MCHID=1000686974 已在 env)
367
+ const r1 = await profitsharing.registerDefaultReceivers(db);
368
+ assert.strictEqual(r1.registered, true);
369
+ assert.strictEqual(r1.mock, true);
370
+ // 台账落行
371
+ const row = db.prepare("SELECT * FROM sp_ps_receivers WHERE account='1000686974'").get();
372
+ assert.ok(row, "接收方台账落行");
373
+ assert.strictEqual(row.mock, 1);
374
+ // 第二次 → 幂等跳过
375
+ const r2 = await profitsharing.registerDefaultReceivers(db);
376
+ assert.strictEqual(r2.registered, false);
377
+ assert.strictEqual(r2.reason, "already");
378
+ assert.strictEqual(db.prepare("SELECT COUNT(*) n FROM sp_ps_receivers").get().n, 1);
379
+ });
380
+
381
+ test("M3-2: 分账台账落行 (pending→mock) + 补扫重试 failed", async () => {
382
+ const { db, srv, merchantSrv, base } = await startServers();
383
+ try {
384
+ insertMerchant(db, SUB_MCHID, "");
385
+ // 登记抽佣 1% (100bp)
386
+ await fetch(base + "/commission-rate", {
387
+ method: "PUT", headers: { "content-type": "application/json", "x-sp-admin-key": ADMIN_KEY },
388
+ body: JSON.stringify({ sub_mchid: SUB_MCHID, rate_bp: 100 }),
389
+ });
390
+ const body = makeNotifyBody({
391
+ out_trade_no: "tb-ps-1", transaction_id: "4200009999000100",
392
+ trade_state: "SUCCESS", amount: { total: 10000, currency: "CNY" }, sub_mchid: SUB_MCHID,
393
+ });
394
+ const r = await fetch(base + "/notify/wechat", { method: "POST", headers: wxSignHeaders(body), body });
395
+ assert.strictEqual(r.status, 200);
396
+ // 分账 fire-and-forget → 等台账 ps_status 落定 (mock 秒回)
397
+ let row = null;
398
+ for (let i = 0; i < 50; i++) {
399
+ row = db.prepare("SELECT * FROM sp_payment_facts WHERE txn_id = ?").get("4200009999000100");
400
+ if (row && row.ps_status && row.ps_status !== "pending") break;
401
+ await new Promise((rs) => setTimeout(rs, 20));
402
+ }
403
+ assert.strictEqual(row.ps_out_order_no, "ps-4200009999000100", "分账单号落台账");
404
+ assert.strictEqual(row.ps_amount, 100, "1% 抽佣 = 100 分");
405
+ assert.strictEqual(row.ps_status, "mock", "mock 模式落 mock");
406
+ assert.ok(row.ps_at > 0);
407
+ // 手动置 failed → 补扫重发 (mock) → 回 mock
408
+ db.prepare("UPDATE sp_payment_facts SET ps_status='failed' WHERE txn_id=?").run("4200009999000100");
409
+ const res = await spNotify.retryProfitsharingSweep(db);
410
+ assert.strictEqual(res.scanned, 1);
411
+ assert.strictEqual(res.retried, 1, "failed 行重发一笔");
412
+ const row2 = db.prepare("SELECT ps_status FROM sp_payment_facts WHERE txn_id = ?").get("4200009999000100");
413
+ assert.strictEqual(row2.ps_status, "mock");
414
+ // 再扫: 无 pending/failed
415
+ const res2 = await spNotify.retryProfitsharingSweep(db);
416
+ assert.strictEqual(res2.scanned, 0);
417
+ } finally { srv.close(); merchantSrv.close(); }
418
+ });
419
+
420
+ test("M3-2: 无抽佣登记的商户回调 → 分账列留空 (不触发)", async () => {
421
+ const { db, srv, merchantSrv, base } = await startServers();
422
+ try {
423
+ insertMerchant(db, SUB_MCHID, "");
424
+ const body = makeNotifyBody({
425
+ out_trade_no: "tb-ps-2", transaction_id: "4200009999000101",
426
+ trade_state: "SUCCESS", amount: { total: 500 }, sub_mchid: SUB_MCHID,
427
+ });
428
+ const r = await fetch(base + "/notify/wechat", { method: "POST", headers: wxSignHeaders(body), body });
429
+ assert.strictEqual(r.status, 200);
430
+ await new Promise((rs) => setTimeout(rs, 100));
431
+ const row = db.prepare("SELECT * FROM sp_payment_facts WHERE txn_id = ?").get("4200009999000101");
432
+ assert.strictEqual(row.ps_status, "", "未登记抽佣不触发分账");
433
+ assert.strictEqual(row.ps_out_order_no, "");
434
+ } finally { srv.close(); merchantSrv.close(); }
435
+ });