feat: submit provider refunds

This commit is contained in:
Codex
2026-06-29 05:05:42 +08:00
parent 24fd788b48
commit 8d752881c1
12 changed files with 397 additions and 23 deletions

View File

@@ -323,6 +323,21 @@ async function startFakeWechatPayServer() {
body,
});
if (url.pathname === '/v3/refund/domestic/refunds') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
refund_id: `refund-${body.out_refund_no || 'unknown'}`,
out_refund_no: body.out_refund_no,
out_trade_no: body.out_trade_no,
transaction_id: body.transaction_id,
status: 'PROCESSING',
amount: body.amount,
}),
);
return;
}
if (url.pathname !== '/v3/pay/transactions/jsapi') {
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ code: 'NOT_FOUND' }));
@@ -341,6 +356,7 @@ async function startFakeWechatPayServer() {
return {
endpoint: `${baseUrl}/v3/pay/transactions/jsapi`,
refundEndpoint: `${baseUrl}/v3/refund/domestic/refunds`,
requests,
};
}
@@ -374,6 +390,56 @@ function signAlipayParams(params) {
return crypto.createSign('RSA-SHA256').update(canonical).sign(paymentFixture.alipayPlatformPrivateKey, 'base64');
}
async function startFakeAlipayServer() {
const port = await getFreePort();
const baseUrl = `http://127.0.0.1:${port}`;
const requests = [];
const server = http.createServer((req, res) => {
let raw = '';
req.on('data', chunk => {
raw += chunk.toString();
});
req.on('end', () => {
const params = Object.fromEntries(new URLSearchParams(raw).entries());
requests.push({
method: req.method,
pathname: req.url || '/',
params,
});
if (params.method !== 'alipay.trade.refund') {
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error_response: { code: '404', msg: 'not found' } }));
return;
}
const biz = JSON.parse(params.biz_content || '{}');
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
alipay_trade_refund_response: {
code: '10000',
msg: 'Success',
trade_no: biz.trade_no || `ali-trade-${biz.out_trade_no || 'unknown'}`,
out_trade_no: biz.out_trade_no,
out_request_no: biz.out_request_no,
refund_fee: biz.refund_amount,
},
}),
);
});
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(port, '127.0.0.1', resolve);
});
return {
endpoint: `${baseUrl}/gateway.do`,
requests,
close: () => server.close(),
};
}
async function createSupabaseJwt(authUserId, options = {}) {
const secret = new TextEncoder().encode(options.secret || AUTH_JWT_SECRET);
const now = Math.floor(Date.now() / 1000);
@@ -1539,6 +1605,7 @@ async function testCommerce() {
merchantSerialNo: 'serial-smoke',
notifyUrl: 'https://pay.example.test/wechat/notify',
endpoint: fakeWechatPay.endpoint,
refundEndpoint: fakeWechatPay.refundEndpoint,
wechatpayPublicKey: paymentFixture.wechatPlatformPublicKey,
},
secret: {
@@ -1622,6 +1689,41 @@ async function testCommerce() {
});
assert.equal(wechatNotifyAgain.item?.idempotent, true, 'duplicate WeChat Pay notify should be idempotent');
const wechatRefund = await request('/api/commerce/refunds', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
orderNo: wechatOrder.item.orderNo,
refundNo: 'RF-WECHAT-PROVIDER-001',
amountCents: 100,
reason: 'provider refund smoke',
},
});
await request('/api/commerce/refunds/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
refundId: wechatRefund.item.id,
action: 'approve',
},
});
const wechatSubmittedRefund = await request('/api/commerce/refunds/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
refundId: wechatRefund.item.id,
action: 'submit_provider_refund',
providerNotifyUrl: 'https://pay.example.test/wechat/refund-notify',
},
});
assert.equal(wechatSubmittedRefund.item?.status, 'processing', 'WeChat provider refund should enter processing until refund notify/query confirms');
assert.equal(wechatSubmittedRefund.item?.providerRefundNo, 'refund-RF-WECHAT-PROVIDER-001', 'WeChat refund should record provider refund id');
const wechatRefundRequest = fakeWechatPay.requests.find(item => item.pathname === '/v3/refund/domestic/refunds');
assert.equal(wechatRefundRequest?.body?.out_refund_no, 'RF-WECHAT-PROVIDER-001', 'WeChat refund should use out_refund_no');
assert.equal(wechatRefundRequest?.body?.amount?.refund, 100, 'WeChat refund should send refund cents');
assert.equal(wechatRefundRequest?.body?.amount?.total, wechatOrder.item.amountCents, 'WeChat refund should send total cents');
const fakeAlipay = await startFakeAlipayServer();
const alipayAccount = await request('/api/tenant-admin/payment-accounts', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
@@ -1634,6 +1736,7 @@ async function testCommerce() {
appId: 'alipay-smoke-appid',
notifyUrl: 'https://pay.example.test/alipay/notify',
returnUrl: 'https://app.example.test/pay/success',
endpoint: fakeAlipay.endpoint,
},
secret: {
secretJson: {
@@ -1688,6 +1791,43 @@ async function testCommerce() {
assert.equal(alipayNotify.item?.status, 'paid', 'Alipay notify should mark order paid');
assert.ok(alipayNotify.item?.entitlement?.id, 'Alipay notify should grant entitlement');
const alipayRefund = await request('/api/commerce/refunds', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
orderNo: alipayOrder.item.orderNo,
refundNo: 'RF-ALIPAY-PROVIDER-001',
amountCents: alipayOrder.item.amountCents,
reason: 'alipay provider refund smoke',
},
});
await request('/api/commerce/refunds/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
refundId: alipayRefund.item.id,
action: 'approve',
},
});
const alipaySubmittedRefund = await request('/api/commerce/refunds/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
refundId: alipayRefund.item.id,
action: 'submit_provider_refund',
},
});
assert.equal(alipaySubmittedRefund.item?.status, 'succeeded', 'Alipay provider refund should finish synchronously on success');
assert.equal(alipaySubmittedRefund.item?.providerRefundNo, `ali-trade-${alipayOrder.item.orderNo}`, 'Alipay refund should record provider trade number');
const alipayRefundRequest = fakeAlipay.requests.find(item => item.params?.method === 'alipay.trade.refund');
const alipayRefundBiz = JSON.parse(alipayRefundRequest?.params?.biz_content || '{}');
assert.equal(alipayRefundBiz.out_request_no, 'RF-ALIPAY-PROVIDER-001', 'Alipay refund should use out_request_no');
assert.equal(alipayRefundBiz.refund_amount, (alipayOrder.item.amountCents / 100).toFixed(2), 'Alipay refund should send yuan amount');
const alipayRefundedStatus = await request('/api/commerce/orders/status', {
query: { orderNo: alipayOrder.item.orderNo },
});
assert.equal(alipayRefundedStatus.item?.status, 'refunded', 'synchronous Alipay refund should update order status');
const tamperedAlipayNotify = await request('/api/commerce/payments/notify/alipay', {
userId: false,
method: 'POST',
@@ -1699,6 +1839,7 @@ async function testCommerce() {
expectStatus: 400,
});
assert.equal(tamperedAlipayNotify.code, 'PAYMENT_SIGNATURE_INVALID', 'tampered Alipay notify must fail signature verification');
fakeAlipay.close();
}
async function testTenantIsolation() {