feat: reconcile commerce payments

This commit is contained in:
Codex
2026-06-29 05:58:08 +08:00
parent ad8e32fa6b
commit 4c9f742b31
13 changed files with 1956 additions and 22 deletions

View File

@@ -0,0 +1,397 @@
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import http from 'node:http';
import pg from 'pg';
import { spawn } from 'node:child_process';
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
const tenantId = '00000000-0000-0000-0000-000000000001';
const studentUserId = '00000000-0000-0000-0000-000000000101';
const planId = '00000000-0000-0000-0000-000000000201';
const regionId = '00000000-0000-0000-0000-000000000301';
const ids = {
paymentOrder: '10000000-0000-0000-0000-000000000701',
payment: '10000000-0000-0000-0000-000000000702',
refundOrder: '10000000-0000-0000-0000-000000000703',
refundPayment: '10000000-0000-0000-0000-000000000704',
refundRequest: '10000000-0000-0000-0000-000000000705',
refundEntitlement: '10000000-0000-0000-0000-000000000706',
};
const orderNos = {
payment: 'CW-WX-PAY-001',
refund: 'CW-WX-REFUND-001',
};
const refundNo = 'RF-CW-WX-001';
const paymentFixture = (() => {
const wechatMerchant = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
return {
wechatMerchantPrivateKey: wechatMerchant.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
wechatApiV3Key: '12345678901234567890123456789012',
};
})();
function getFreePort() {
return new Promise((resolve, reject) => {
const server = http.createServer();
server.listen(0, '127.0.0.1', () => {
const address = server.address();
server.close(() => resolve(address.port));
});
server.on('error', reject);
});
}
async function startFakeWechatPayServer() {
const port = await getFreePort();
const baseUrl = `http://127.0.0.1:${port}`;
const requests = [];
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 ? JSON.parse(raw) : {} });
if (req.method === 'GET' && url.pathname.startsWith('/v3/pay/transactions/out-trade-no/')) {
const outTradeNo = decodeURIComponent(url.pathname.split('/').pop() || '');
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({
appid: 'wx-worker-appid',
mchid: 'wx-worker-mchid',
out_trade_no: outTradeNo,
transaction_id: `wx-trade-${outTradeNo}`,
trade_state: 'SUCCESS',
success_time: '2026-06-29T08:00:00+08:00',
amount: { total: outTradeNo === orderNos.refund ? 500 : 990, currency: 'CNY' },
}));
return;
}
if (req.method === 'GET' && url.pathname.startsWith('/v3/refund/domestic/refunds/')) {
const outRefundNo = decodeURIComponent(url.pathname.split('/').pop() || '');
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({
refund_id: `refund-${outRefundNo}`,
out_refund_no: outRefundNo,
status: 'SUCCESS',
amount: { refund: 500, total: 500, currency: 'CNY' },
success_time: '2026-06-29T09:00:00+08:00',
}));
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 {
paymentQueryEndpoint: `${baseUrl}/v3/pay/transactions/out-trade-no`,
refundEndpoint: `${baseUrl}/v3/refund/domestic/refunds`,
requests,
close: () => new Promise(resolve => server.close(resolve)),
};
}
async function runWorkerOnce() {
const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'commerce'], {
cwd: process.cwd(),
env: {
...process.env,
DATABASE_URL: databaseUrl,
WORKER_COMMERCE_BATCH_SIZE: '20',
WORKER_COMMERCE_MIN_AGE_SECONDS: '0',
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, `worker should exit 0\n${output}`);
assert.match(output, /commerce batch processed=\d+/, 'worker output should include commerce summary');
assert.ok(!output.includes(paymentFixture.wechatApiV3Key), 'worker output must not leak WeChat API v3 key');
assert.ok(!output.includes('PRIVATE KEY'), 'worker output must not leak merchant private key');
return output;
}
async function cleanup(pool) {
await pool.query(
`
delete from public.payment_events
where tenant_id = $1
and (
event_id like $2
or event_id like $3
)
`,
[tenantId, `${tenantId}:payment-query:CW-WX-%`, `${tenantId}:refund-query:RF-CW-%`],
);
await pool.query(
`
delete from public.commerce_refund_events
where tenant_id = $1 and refund_request_id = $2
`,
[tenantId, ids.refundRequest],
);
await pool.query(
`
delete from public.commerce_refund_requests
where tenant_id = $1 and id = $2
`,
[tenantId, ids.refundRequest],
);
await pool.query(
`
delete from public.entitlements
where tenant_id = $1
and (
id = $2
or source_id in ($3::uuid, $4::uuid)
)
`,
[tenantId, ids.refundEntitlement, ids.paymentOrder, ids.refundOrder],
);
await pool.query(
`
delete from public.payments
where tenant_id = $1 and id in ($2::uuid, $3::uuid)
`,
[tenantId, ids.payment, ids.refundPayment],
);
await pool.query(
`
delete from public.orders
where tenant_id = $1 and id in ($2::uuid, $3::uuid)
`,
[tenantId, ids.paymentOrder, ids.refundOrder],
);
}
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', 'wechat_pay', $2::jsonb, 'wechat_pay', 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.wechatMerchantPrivateKey,
apiV3Key: paymentFixture.wechatApiV3Key,
})],
);
await pool.query(
`
insert into public.tenant_payment_accounts (
tenant_id, provider, mode, display_name, status, config_public
)
values ($1, 'wechat_pay', 'tenant_collect', 'Worker WeChat Pay', '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: 'wx-worker-appid',
merchantId: 'wx-worker-mchid',
merchantSerialNo: 'wx-worker-serial',
paymentQueryEndpoint: fakeWechat.paymentQueryEndpoint,
refundEndpoint: fakeWechat.refundEndpoint,
refundQueryEndpoint: fakeWechat.refundEndpoint,
secretRef: 'app_private.tenant_secrets:payment:wechat_pay',
})],
);
await pool.query(
`
insert into public.orders (
id, tenant_id, user_id, order_no, status, product_type, product_name,
amount_cents, pay_method, pay_provider, plan_id, days, region_id, raw_payload,
created_at, updated_at
)
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')
`,
[ids.paymentOrder, tenantId, studentUserId, orderNos.payment, planId, regionId, ids.refundOrder, orderNos.refund],
);
await pool.query(
`
insert into public.payments (
id, tenant_id, order_id, provider, method, status, amount_cents,
provider_trade_no, paid_at, raw_payload, created_at, updated_at
)
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')
`,
[ids.payment, tenantId, ids.paymentOrder, ids.refundPayment, ids.refundOrder, `wx-trade-${orderNos.refund}`],
);
await pool.query(
`
insert into public.entitlements (
id, tenant_id, user_id, entitlement_type, scope_type, scope_id,
source_type, source_id, starts_at, expires_at, status, metadata
)
values ($1, $2, $3, 'svip', 'region', $4, 'order', $5, now() - interval '9 minutes', now() + interval '30 days', 'active', '{"source":"commerce-worker-test"}'::jsonb)
`,
[ids.refundEntitlement, tenantId, studentUserId, regionId, ids.refundOrder],
);
await pool.query(
`
insert into public.commerce_refund_requests (
id, tenant_id, order_id, payment_id, refund_no, provider, provider_refund_no,
status, amount_cents, reason, entitlement_action,
requested_by, reviewed_by, processed_by,
requested_at, reviewed_at, processed_at, metadata
)
values (
$1, $2, $3, $4, $5, 'wechat_pay', null,
'processing', 500, 'commerce worker integration refund', 'revoke_on_success',
null, null, null,
now() - interval '8 minutes', now() - interval '8 minutes', now() - interval '8 minutes',
'{"source":"commerce-worker-test"}'::jsonb
)
`,
[ids.refundRequest, tenantId, ids.refundOrder, ids.refundPayment, refundNo],
);
}
async function main() {
const fakeWechat = await startFakeWechatPayServer();
const pool = new pg.Pool({ connectionString: databaseUrl });
let seeded = false;
try {
await pool.query('begin');
await cleanup(pool);
await seed(pool, fakeWechat);
await pool.query('commit');
seeded = true;
const output = await runWorkerOnce();
assert.match(output, /paid=1/, 'worker should reconcile one paid payment');
assert.match(output, /succeeded=1/, 'worker should reconcile one succeeded refund');
assert.ok(
fakeWechat.requests.some(item => item.method === 'GET' && item.pathname.endsWith(`/${orderNos.payment}`)),
'worker should query WeChat payment by out_trade_no',
);
assert.ok(
fakeWechat.requests.some(item => item.method === 'GET' && item.pathname.endsWith(`/${refundNo}`)),
'worker should query WeChat refund by out_refund_no',
);
const paymentOrder = await pool.query(
`
select o.status, o.trade_no, p.status as payment_status, p.provider_trade_no,
count(e.id)::int as entitlement_count
from public.orders o
join public.payments p on p.tenant_id = o.tenant_id and p.order_id = o.id
left join public.entitlements e on e.tenant_id = o.tenant_id and e.source_type = 'order' and e.source_id = o.id
where o.tenant_id = $1 and o.id = $2
group by o.status, o.trade_no, p.status, p.provider_trade_no
`,
[tenantId, ids.paymentOrder],
);
assert.equal(paymentOrder.rows[0]?.status, 'paid', 'payment compensation should mark order paid');
assert.equal(paymentOrder.rows[0]?.payment_status, 'paid', 'payment compensation should mark payment paid');
assert.equal(paymentOrder.rows[0]?.trade_no, `wx-trade-${orderNos.payment}`, 'payment compensation should record trade number');
assert.equal(paymentOrder.rows[0]?.entitlement_count, 1, 'payment compensation should grant one order entitlement');
const refund = await pool.query(
`
select rr.status, rr.provider_refund_no, o.status as order_status,
o.refunded_amount_cents, p.status as payment_status,
p.refunded_amount_cents as payment_refunded_amount_cents,
e.status as entitlement_status
from public.commerce_refund_requests rr
join public.orders o on o.tenant_id = rr.tenant_id and o.id = rr.order_id
join public.payments p on p.tenant_id = rr.tenant_id and p.id = rr.payment_id
left join public.entitlements e on e.tenant_id = rr.tenant_id and e.source_type = 'order' and e.source_id = rr.order_id
where rr.tenant_id = $1 and rr.id = $2
`,
[tenantId, ids.refundRequest],
);
assert.equal(refund.rows[0]?.status, 'succeeded', 'refund compensation should mark refund succeeded');
assert.equal(refund.rows[0]?.provider_refund_no, `refund-${refundNo}`, 'refund compensation should record provider refund number');
assert.equal(refund.rows[0]?.order_status, 'refunded', 'full refund compensation should mark order refunded');
assert.equal(refund.rows[0]?.payment_status, 'refunded', 'full refund compensation should mark payment refunded');
assert.equal(refund.rows[0]?.refunded_amount_cents, 500, 'full refund compensation should update order refunded amount');
assert.equal(refund.rows[0]?.payment_refunded_amount_cents, 500, 'full refund compensation should update payment refunded amount');
assert.equal(refund.rows[0]?.entitlement_status, 'revoked', 'full refund compensation should revoke order entitlement');
await runWorkerOnce();
const secondPass = await pool.query(
`
select count(*)::int as entitlement_count
from public.entitlements
where tenant_id = $1 and source_type = 'order' and source_id = $2
`,
[tenantId, ids.paymentOrder],
);
assert.equal(secondPass.rows[0]?.entitlement_count, 1, 'second worker pass must not grant duplicate entitlement');
const events = await pool.query(
`
select payload, error
from public.payment_events
where tenant_id = $1
and (
event_id = $2
or event_id = $3
)
order by created_at desc
`,
[
tenantId,
`${tenantId}:payment-query:${orderNos.payment}`,
`${tenantId}:refund-query:${refundNo}`,
],
);
assert.ok(events.rows.length >= 2, 'worker should record payment events for reconciliation');
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');
console.log('Commerce worker integration test complete.');
} catch (error) {
await pool.query('rollback').catch(() => {});
throw error;
} finally {
if (seeded) {
await cleanup(pool).catch(() => {});
}
await pool.end();
await fakeWechat.close();
}
}
main().catch(error => {
console.error(error);
process.exit(1);
});