feat: add provider bill download reconciliation jobs

This commit is contained in:
Codex
2026-06-29 21:59:42 +08:00
parent aeca84b260
commit f3577e257c
21 changed files with 1691 additions and 158 deletions

View File

@@ -17,18 +17,29 @@ const ids = {
refundPayment: '10000000-0000-0000-0000-000000000704',
refundRequest: '10000000-0000-0000-0000-000000000705',
refundEntitlement: '10000000-0000-0000-0000-000000000706',
providerBillJobWechat: '10000000-0000-0000-0000-000000000707',
providerBillJobAlipay: '10000000-0000-0000-0000-000000000708',
providerBillOrderWechat: '10000000-0000-0000-0000-000000000709',
providerBillPaymentWechat: '10000000-0000-0000-0000-000000000710',
providerBillOrderAlipay: '10000000-0000-0000-0000-000000000711',
providerBillPaymentAlipay: '10000000-0000-0000-0000-000000000712',
};
const orderNos = {
payment: 'CW-WX-PAY-001',
refund: 'CW-WX-REFUND-001',
providerBillWechat: 'CW-WX-BILL-001',
providerBillAlipay: 'CW-ALI-BILL-001',
};
const refundNo = 'RF-CW-WX-001';
const providerBillDate = '2026-06-29';
const paymentFixture = (() => {
const wechatMerchant = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const alipayApp = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
return {
wechatMerchantPrivateKey: wechatMerchant.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
alipayAppPrivateKey: alipayApp.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
wechatApiV3Key: '12345678901234567890123456789012',
};
})();
@@ -48,6 +59,25 @@ async function startFakeWechatPayServer() {
const port = await getFreePort();
const baseUrl = `http://127.0.0.1:${port}`;
const requests = [];
const billRows = [
{
transactionType: 'payment',
orderNo: orderNos.providerBillWechat,
providerTradeNo: `wx-bill-trade-${orderNos.providerBillWechat}`,
amountCents: 990,
providerStatus: 'SUCCESS',
paidAt: '2026-06-29T10:00:00+08:00',
},
{
transactionType: 'payment',
orderNo: 'CW-WX-PROVIDER-ONLY',
providerTradeNo: 'wx-provider-only-trade',
amountCents: 1888,
providerStatus: 'SUCCESS',
},
];
const billBody = Buffer.from(JSON.stringify({ rows: billRows }), 'utf8');
const billHash = crypto.createHash('sha256').update(billBody).digest('hex');
const server = http.createServer((req, res) => {
const url = new URL(req.url || '/', baseUrl);
let raw = '';
@@ -85,6 +115,22 @@ async function startFakeWechatPayServer() {
return;
}
if (req.method === 'GET' && url.pathname === '/v3/bill/tradebill') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({
hash_type: 'SHA256',
hash_value: billHash,
download_url: `${baseUrl}/download/wechat-tradebill.json`,
}));
return;
}
if (req.method === 'GET' && url.pathname === '/download/wechat-tradebill.json') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(billBody);
return;
}
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ code: 'NOT_FOUND' }));
});
@@ -96,6 +142,61 @@ async function startFakeWechatPayServer() {
return {
paymentQueryEndpoint: `${baseUrl}/v3/pay/transactions/out-trade-no`,
refundEndpoint: `${baseUrl}/v3/refund/domestic/refunds`,
tradeBillEndpoint: `${baseUrl}/v3/bill/tradebill`,
requests,
close: () => new Promise(resolve => server.close(resolve)),
};
}
async function startFakeAlipayServer() {
const port = await getFreePort();
const baseUrl = `http://127.0.0.1:${port}`;
const requests = [];
const billRows = [
{
transactionType: 'payment',
orderNo: orderNos.providerBillAlipay,
providerTradeNo: `ali-bill-trade-${orderNos.providerBillAlipay}`,
amountCents: 1288,
providerStatus: 'TRADE_SUCCESS',
paidAt: '2026-06-29 11:00:00',
},
];
const billBody = Buffer.from(JSON.stringify({ rows: billRows }), 'utf8');
const server = http.createServer((req, res) => {
const url = new URL(req.url || '/', baseUrl);
let raw = '';
req.on('data', chunk => {
raw += chunk.toString();
});
req.on('end', () => {
requests.push({ method: req.method, pathname: url.pathname, search: url.search, body: raw });
if (req.method === 'POST' && url.pathname === '/gateway.do') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({
alipay_data_dataservice_bill_downloadurl_query_response: {
code: '10000',
msg: 'Success',
bill_download_url: `${baseUrl}/download/alipay-tradebill.json`,
},
}));
return;
}
if (req.method === 'GET' && url.pathname === '/download/alipay-tradebill.json') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(billBody);
return;
}
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ code: 'NOT_FOUND' }));
});
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(port, '127.0.0.1', resolve);
});
return {
endpoint: `${baseUrl}/gateway.do`,
requests,
close: () => new Promise(resolve => server.close(resolve)),
};
@@ -129,7 +230,55 @@ async function runWorkerOnce() {
return output;
}
async function runProviderBillsWorkerOnce() {
const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'provider-bills'], {
cwd: process.cwd(),
env: {
...process.env,
DATABASE_URL: databaseUrl,
WORKER_PROVIDER_BILL_BATCH_SIZE: '10',
WORKER_PROVIDER_BILL_ID: 'provider-bills-integration-test',
WORKER_COMMERCE_REQUEST_TIMEOUT_MS: '5000',
},
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let output = '';
child.stdout.on('data', chunk => {
output += chunk.toString();
});
child.stderr.on('data', chunk => {
output += chunk.toString();
});
const code = await new Promise(resolve => child.on('exit', resolve));
assert.equal(code, 0, `provider bill worker should exit 0\n${output}`);
assert.match(output, /provider-bills batch processed=\d+/, 'worker output should include provider bill summary');
assert.ok(!output.includes(paymentFixture.wechatApiV3Key), 'provider bill worker output must not leak WeChat API v3 key');
assert.ok(!output.includes('PRIVATE KEY'), 'provider bill worker output must not leak private keys');
return output;
}
async function cleanup(pool) {
await pool.query(
`
delete from public.commerce_bill_download_jobs
where tenant_id = $1
and (
id in ($2::uuid, $3::uuid)
or source_name like 'provider-bill:%:2026-06-29:%'
)
`,
[tenantId, ids.providerBillJobWechat, ids.providerBillJobAlipay],
);
await pool.query(
`
delete from public.commerce_reconciliation_batches
where tenant_id = $1
and source = 'provider_download'
and source_name like 'provider-bill:%:2026-06-29:%'
`,
[tenantId],
);
await pool.query(
`
delete from public.payment_events
@@ -169,20 +318,20 @@ async function cleanup(pool) {
await pool.query(
`
delete from public.payments
where tenant_id = $1 and id in ($2::uuid, $3::uuid)
where tenant_id = $1 and id in ($2::uuid, $3::uuid, $4::uuid, $5::uuid)
`,
[tenantId, ids.payment, ids.refundPayment],
[tenantId, ids.payment, ids.refundPayment, ids.providerBillPaymentWechat, ids.providerBillPaymentAlipay],
);
await pool.query(
`
delete from public.orders
where tenant_id = $1 and id in ($2::uuid, $3::uuid)
where tenant_id = $1 and id in ($2::uuid, $3::uuid, $4::uuid, $5::uuid)
`,
[tenantId, ids.paymentOrder, ids.refundOrder],
[tenantId, ids.paymentOrder, ids.refundOrder, ids.providerBillOrderWechat, ids.providerBillOrderAlipay],
);
}
async function seed(pool, fakeWechat) {
async function seed(pool, fakeWechat, fakeAlipay) {
await pool.query(
`
insert into app_private.tenant_secrets (
@@ -201,6 +350,23 @@ async function seed(pool, fakeWechat) {
})],
);
await pool.query(
`
insert into app_private.tenant_secrets (
tenant_id, secret_scope, secret_key, secret_json, provider, last_rotated_at
)
values ($1, 'payment', 'alipay', $2::jsonb, 'alipay', now())
on conflict (tenant_id, secret_scope, secret_key)
do update set secret_json = excluded.secret_json,
provider = excluded.provider,
last_rotated_at = now(),
updated_at = now()
`,
[tenantId, JSON.stringify({
privateKey: paymentFixture.alipayAppPrivateKey,
})],
);
await pool.query(
`
insert into public.tenant_payment_accounts (
@@ -221,10 +387,32 @@ async function seed(pool, fakeWechat) {
paymentQueryEndpoint: fakeWechat.paymentQueryEndpoint,
refundEndpoint: fakeWechat.refundEndpoint,
refundQueryEndpoint: fakeWechat.refundEndpoint,
tradeBillEndpoint: fakeWechat.tradeBillEndpoint,
secretRef: 'app_private.tenant_secrets:payment:wechat_pay',
})],
);
await pool.query(
`
insert into public.tenant_payment_accounts (
tenant_id, provider, mode, display_name, status, config_public
)
values ($1, 'alipay', 'tenant_collect', 'Worker Alipay', 'active', $2::jsonb)
on conflict (tenant_id, provider)
do update set mode = excluded.mode,
display_name = excluded.display_name,
status = excluded.status,
config_public = excluded.config_public,
updated_at = now()
`,
[tenantId, JSON.stringify({
appId: 'ali-worker-appid',
endpoint: fakeAlipay.endpoint,
billEndpoint: fakeAlipay.endpoint,
secretRef: 'app_private.tenant_secrets:payment:alipay',
})],
);
await pool.query(
`
insert into public.orders (
@@ -234,9 +422,24 @@ async function seed(pool, fakeWechat) {
)
values
($1, $2, $3, $4, 'pending', 'svip', 'Worker 补偿月卡', 990, 'jsapi', 'wechat_pay', $5, 30, $6, '{"source":"commerce-worker-test"}'::jsonb, now() - interval '10 minutes', now() - interval '10 minutes'),
($7, $2, $3, $8, 'paid', 'svip', 'Worker 退款月卡', 500, 'jsapi', 'wechat_pay', $5, 30, $6, '{"source":"commerce-worker-test"}'::jsonb, now() - interval '10 minutes', now() - interval '10 minutes')
($7, $2, $3, $8, 'paid', 'svip', 'Worker 退款月卡', 500, 'jsapi', 'wechat_pay', $5, 30, $6, '{"source":"commerce-worker-test"}'::jsonb, now() - interval '10 minutes', now() - interval '10 minutes'),
($9, $2, $3, $10, 'paid', 'svip', 'Worker 微信账单月卡', 990, 'jsapi', 'wechat_pay', $5, 30, $6, '{"source":"provider-bill-worker-test"}'::jsonb, now() - interval '10 minutes', now() - interval '10 minutes'),
($11, $2, $3, $12, 'paid', 'svip', 'Worker 支付宝账单月卡', 1288, 'wap', 'alipay', $5, 30, $6, '{"source":"provider-bill-worker-test"}'::jsonb, now() - interval '10 minutes', now() - interval '10 minutes')
`,
[ids.paymentOrder, tenantId, studentUserId, orderNos.payment, planId, regionId, ids.refundOrder, orderNos.refund],
[
ids.paymentOrder,
tenantId,
studentUserId,
orderNos.payment,
planId,
regionId,
ids.refundOrder,
orderNos.refund,
ids.providerBillOrderWechat,
orderNos.providerBillWechat,
ids.providerBillOrderAlipay,
orderNos.providerBillAlipay,
],
);
await pool.query(
@@ -247,9 +450,24 @@ async function seed(pool, fakeWechat) {
)
values
($1, $2, $3, 'wechat_pay', 'jsapi', 'pending', 990, null, null, '{"source":"commerce-worker-test"}'::jsonb, now() - interval '10 minutes', now() - interval '10 minutes'),
($4, $2, $5, 'wechat_pay', 'jsapi', 'paid', 500, $6, now() - interval '9 minutes', '{"source":"commerce-worker-test"}'::jsonb, now() - interval '10 minutes', now() - interval '10 minutes')
($4, $2, $5, 'wechat_pay', 'jsapi', 'paid', 500, $6, now() - interval '9 minutes', '{"source":"commerce-worker-test"}'::jsonb, now() - interval '10 minutes', now() - interval '10 minutes'),
($7, $2, $8, 'wechat_pay', 'jsapi', 'paid', 990, $9, now() - interval '9 minutes', '{"source":"provider-bill-worker-test"}'::jsonb, now() - interval '10 minutes', now() - interval '10 minutes'),
($10, $2, $11, 'alipay', 'wap', 'paid', 1288, $12, now() - interval '9 minutes', '{"source":"provider-bill-worker-test"}'::jsonb, now() - interval '10 minutes', now() - interval '10 minutes')
`,
[ids.payment, tenantId, ids.paymentOrder, ids.refundPayment, ids.refundOrder, `wx-trade-${orderNos.refund}`],
[
ids.payment,
tenantId,
ids.paymentOrder,
ids.refundPayment,
ids.refundOrder,
`wx-trade-${orderNos.refund}`,
ids.providerBillPaymentWechat,
ids.providerBillOrderWechat,
`wx-bill-trade-${orderNos.providerBillWechat}`,
ids.providerBillPaymentAlipay,
ids.providerBillOrderAlipay,
`ali-bill-trade-${orderNos.providerBillAlipay}`,
],
);
await pool.query(
@@ -281,16 +499,52 @@ async function seed(pool, fakeWechat) {
`,
[ids.refundRequest, tenantId, ids.refundOrder, ids.refundPayment, refundNo],
);
await pool.query(
`
insert into public.commerce_bill_download_jobs (
id, tenant_id, provider, bill_date, bill_type, status,
source_name, requested_by, metadata
)
values
($1, $3, 'wechat_pay', $4::date, 'payment', 'queued', $5, null, '{"source":"commerce-worker-test"}'::jsonb),
($2, $3, 'alipay', $4::date, 'payment', 'queued', $6, null, '{"source":"commerce-worker-test"}'::jsonb)
on conflict (tenant_id, provider, bill_date, bill_type)
where status in ('queued', 'running', 'completed')
do update set status = 'queued',
source_name = excluded.source_name,
reconciliation_batch_id = null,
source_hash = null,
row_count = 0,
claimed_by = null,
claimed_at = null,
completed_at = null,
failed_at = null,
error_code = null,
error_message = null,
metadata = excluded.metadata,
updated_at = now()
`,
[
ids.providerBillJobWechat,
ids.providerBillJobAlipay,
tenantId,
providerBillDate,
`provider-bill:wechat_pay:${providerBillDate}:payment`,
`provider-bill:alipay:${providerBillDate}:payment`,
],
);
}
async function main() {
const fakeWechat = await startFakeWechatPayServer();
const fakeAlipay = await startFakeAlipayServer();
const pool = new pg.Pool({ connectionString: databaseUrl });
let seeded = false;
try {
await pool.query('begin');
await cleanup(pool);
await seed(pool, fakeWechat);
await seed(pool, fakeWechat, fakeAlipay);
await pool.query('commit');
seeded = true;
@@ -378,6 +632,70 @@ async function main() {
assert.ok(!JSON.stringify(events.rows).includes(paymentFixture.wechatApiV3Key), 'payment event payload must not leak API v3 key');
assert.ok(!JSON.stringify(events.rows).includes('PRIVATE KEY'), 'payment event payload must not leak private key');
const providerBillOutput = await runProviderBillsWorkerOnce();
assert.match(providerBillOutput, /completed=2/, 'provider bill worker should complete two provider bill jobs');
assert.ok(
fakeWechat.requests.some(item => item.method === 'GET' && item.pathname === '/v3/bill/tradebill'),
'provider bill worker should request WeChat trade bill URL',
);
assert.ok(
fakeWechat.requests.some(item => item.method === 'GET' && item.pathname === '/download/wechat-tradebill.json'),
'provider bill worker should download WeChat trade bill',
);
assert.ok(
fakeAlipay.requests.some(item => item.method === 'POST' && item.pathname === '/gateway.do' && item.body.includes('alipay.data.dataservice.bill.downloadurl.query')),
'provider bill worker should request Alipay bill download URL',
);
assert.ok(
fakeAlipay.requests.some(item => item.method === 'GET' && item.pathname === '/download/alipay-tradebill.json'),
'provider bill worker should download Alipay trade bill',
);
const providerJobs = await pool.query(
`
select provider, status, source_hash, row_count, download_url_host,
reconciliation_batch_id, error_code, error_message
from public.commerce_bill_download_jobs
where tenant_id = $1 and id in ($2::uuid, $3::uuid)
order by provider
`,
[tenantId, ids.providerBillJobWechat, ids.providerBillJobAlipay],
);
assert.equal(providerJobs.rows.length, 2, 'provider bill jobs should be persisted');
assert.ok(providerJobs.rows.every(row => row.status === 'completed'), 'provider bill jobs should be completed');
assert.ok(providerJobs.rows.every(row => row.source_hash && !row.error_code && !row.error_message), 'completed provider bill jobs should keep source hash and no errors');
assert.ok(providerJobs.rows.every(row => row.download_url_host === '127.0.0.1'), 'provider bill jobs should persist only download URL host');
const providerBatches = await pool.query(
`
select b.id, b.provider, b.source, b.source_name, b.status,
b.matched_count, b.missing_local_count, b.total_count,
count(i.id)::int as item_count
from public.commerce_reconciliation_batches b
join public.commerce_reconciliation_items i
on i.tenant_id = b.tenant_id and i.batch_id = b.id
where b.tenant_id = $1
and b.source = 'provider_download'
and b.source_name like 'provider-bill:%:2026-06-29:%'
group by b.id
order by b.provider
`,
[tenantId],
);
assert.equal(providerBatches.rows.length, 2, 'provider bill downloads should create two reconciliation batches');
assert.ok(
providerBatches.rows.some(row => row.provider === 'wechat_pay' && row.status === 'completed_with_issues' && row.matched_count >= 1 && row.missing_local_count >= 1),
'WeChat bill batch should include matched and provider-only anomaly rows',
);
assert.ok(
providerBatches.rows.some(row => row.provider === 'alipay' && ['completed', 'completed_with_issues'].includes(row.status) && row.matched_count >= 1),
'Alipay bill batch should include matched rows',
);
const jobPayload = JSON.stringify(providerJobs.rows);
assert.ok(!jobPayload.includes(paymentFixture.wechatApiV3Key), 'provider bill job rows must not leak API v3 key');
assert.ok(!jobPayload.includes('PRIVATE KEY'), 'provider bill job rows must not leak private keys');
console.log('Commerce worker integration test complete.');
} catch (error) {
await pool.query('rollback').catch(() => {});
@@ -388,6 +706,7 @@ async function main() {
}
await pool.end();
await fakeWechat.close();
await fakeAlipay.close();
}
}