feat: handle refund notifications

This commit is contained in:
Codex
2026-06-29 05:34:41 +08:00
parent e9cf363df9
commit ad8e32fa6b
12 changed files with 586 additions and 57 deletions

View File

@@ -391,6 +391,21 @@ function encryptWechatResource(plain) {
};
}
function encryptWechatRefundResource(plain) {
const nonce = crypto.randomBytes(12).toString('base64url');
const aad = 'refund';
const cipher = crypto.createCipheriv('aes-256-gcm', Buffer.from(paymentFixture.wechatApiV3Key, 'utf8'), Buffer.from(nonce, 'utf8'));
cipher.setAAD(Buffer.from(aad, 'utf8'));
const encrypted = Buffer.concat([cipher.update(JSON.stringify(plain), 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return {
algorithm: 'AEAD_AES_256_GCM',
nonce,
associated_data: aad,
ciphertext: Buffer.concat([encrypted, authTag]).toString('base64'),
};
}
function signWechatNotification(rawBody, timestamp, nonce) {
const message = `${timestamp}\n${nonce}\n${rawBody}\n`;
return crypto.createSign('RSA-SHA256').update(message).sign(paymentFixture.wechatPlatformPrivateKey, 'base64');
@@ -1755,24 +1770,152 @@ async function testCommerce() {
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 wechatQueriedRefund = await request('/api/commerce/refunds/status', {
const wechatRefundNotifyBody = {
id: `refund-evt-${wechatOrder.item.orderNo}`,
create_time: '2026-06-28T00:00:00+08:00',
event_type: 'REFUND.SUCCESS',
resource_type: 'encrypt-resource',
resource: encryptWechatRefundResource({
mchid: '1900000001',
out_trade_no: wechatOrder.item.orderNo,
transaction_id: `wx-trade-${wechatOrder.item.orderNo}`,
out_refund_no: 'RF-WECHAT-PROVIDER-001',
refund_id: 'refund-RF-WECHAT-PROVIDER-001',
refund_status: 'SUCCESS',
success_time: '2026-06-28T00:00:00+08:00',
amount: { refund: 100, total: wechatOrder.item.amountCents, payer_total: wechatOrder.item.amountCents, payer_refund: 100 },
}),
};
const wechatRefundRaw = JSON.stringify(wechatRefundNotifyBody);
const wechatRefundTimestamp = String(Math.floor(Date.now() / 1000));
const wechatRefundNonce = 'nonce-refund-smoke';
const wechatRefundNotify = await request('/api/commerce/refunds/notify/wechat_pay', {
userId: false,
method: 'POST',
query: { tenantId: MAIN_TENANT_ID },
headers: {
'wechatpay-timestamp': wechatRefundTimestamp,
'wechatpay-nonce': wechatRefundNonce,
'wechatpay-signature': signWechatNotification(wechatRefundRaw, wechatRefundTimestamp, wechatRefundNonce),
'wechatpay-serial': 'platform-serial-smoke',
},
body: wechatRefundNotifyBody,
});
assert.equal(wechatRefundNotify.item?.status, 'succeeded', 'WeChat refund notify should confirm provider success');
assert.equal(wechatRefundNotify.item?.refundNo, 'RF-WECHAT-PROVIDER-001', 'WeChat refund notify should return refund number');
const wechatRefundNotifyAgain = await request('/api/commerce/refunds/notify/wechat_pay', {
userId: false,
method: 'POST',
query: { tenantId: MAIN_TENANT_ID },
headers: {
'wechatpay-timestamp': wechatRefundTimestamp,
'wechatpay-nonce': wechatRefundNonce,
'wechatpay-signature': signWechatNotification(wechatRefundRaw, wechatRefundTimestamp, wechatRefundNonce),
'wechatpay-serial': 'platform-serial-smoke',
},
body: wechatRefundNotifyBody,
});
assert.equal(wechatRefundNotifyAgain.item?.idempotent, true, 'duplicate WeChat refund notify should be idempotent');
const wechatRefundQueryAfterNotify = await request('/api/commerce/refunds/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
refundId: wechatRefund.item.id,
action: 'query_provider_refund',
},
expectStatus: 409,
});
assert.equal(wechatQueriedRefund.item?.status, 'succeeded', 'WeChat refund query should confirm provider success');
assert.equal(wechatQueriedRefund.item?.providerRefundNo, 'refund-RF-WECHAT-PROVIDER-001', 'WeChat refund query should keep provider refund id');
const wechatRefundQueryRequest = fakeWechatPay.requests.find(item => item.method === 'GET' && item.pathname.endsWith('/RF-WECHAT-PROVIDER-001'));
assert.ok(wechatRefundQueryRequest, 'WeChat refund query should call query-by-out-refund-no endpoint');
assert.equal(wechatRefundQueryAfterNotify.code, 'REFUND_STATUS_INVALID', 'succeeded WeChat refund must not be queried and applied twice');
const wechatPartiallyRefundedStatus = await request('/api/commerce/orders/status', {
query: { orderNo: wechatOrder.item.orderNo },
});
assert.equal(wechatPartiallyRefundedStatus.item?.status, 'partially_refunded', 'confirmed WeChat partial refund should update order status');
assert.equal(wechatPartiallyRefundedStatus.item?.refundedAmountCents, 100, 'confirmed WeChat partial refund should update refunded amount');
const wechatQueryOrder = await request('/api/commerce/orders', {
method: 'POST',
body: {
planId: ids.plan,
payProvider: 'wechat_pay',
payMethod: 'jsapi',
regionId: ids.region,
},
});
await request('/api/commerce/payments/create', {
method: 'POST',
body: {
orderNo: wechatQueryOrder.item.orderNo,
provider: 'wechat_pay',
openId: 'openid-pay-query-smoke',
},
});
const wechatQueryNotificationBody = {
id: `evt-${wechatQueryOrder.item.orderNo}`,
create_time: '2026-06-28T00:00:00+08:00',
event_type: 'TRANSACTION.SUCCESS',
resource_type: 'encrypt-resource',
resource: encryptWechatResource({
appid: 'wx-pay-smoke-appid',
mchid: '1900000001',
out_trade_no: wechatQueryOrder.item.orderNo,
transaction_id: `wx-trade-${wechatQueryOrder.item.orderNo}`,
trade_state: 'SUCCESS',
success_time: '2026-06-28T00:00:00+08:00',
amount: { total: wechatQueryOrder.item.amountCents, currency: 'CNY' },
}),
};
const wechatQueryRaw = JSON.stringify(wechatQueryNotificationBody);
const wechatQueryTimestamp = String(Math.floor(Date.now() / 1000));
await request('/api/commerce/payments/notify/wechat_pay', {
userId: false,
method: 'POST',
query: { tenantId: MAIN_TENANT_ID },
headers: {
'wechatpay-timestamp': wechatQueryTimestamp,
'wechatpay-nonce': 'nonce-pay-query-smoke',
'wechatpay-signature': signWechatNotification(wechatQueryRaw, wechatQueryTimestamp, 'nonce-pay-query-smoke'),
'wechatpay-serial': 'platform-serial-smoke',
},
body: wechatQueryNotificationBody,
});
const wechatQueryRefund = await request('/api/commerce/refunds', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
orderNo: wechatQueryOrder.item.orderNo,
refundNo: 'RF-WECHAT-QUERY-001',
amountCents: 100,
reason: 'provider refund query smoke',
},
});
await request('/api/commerce/refunds/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
refundId: wechatQueryRefund.item.id,
action: 'approve',
},
});
await request('/api/commerce/refunds/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
refundId: wechatQueryRefund.item.id,
action: 'submit_provider_refund',
},
});
const wechatQueriedRefund = await request('/api/commerce/refunds/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
refundId: wechatQueryRefund.item.id,
action: 'query_provider_refund',
},
});
assert.equal(wechatQueriedRefund.item?.status, 'succeeded', 'WeChat refund query should confirm provider success');
const wechatRefundQueryRequest = fakeWechatPay.requests.find(item => item.method === 'GET' && item.pathname.endsWith('/RF-WECHAT-QUERY-001'));
assert.ok(wechatRefundQueryRequest, 'WeChat refund query should call query-by-out-refund-no endpoint');
const fakeAlipay = await startFakeAlipayServer();
const alipayAccount = await request('/api/tenant-admin/payment-accounts', {
userId: TENANT_ADMIN_USER_ID,
@@ -1877,6 +2020,32 @@ async function testCommerce() {
query: { orderNo: alipayOrder.item.orderNo },
});
assert.equal(alipayRefundedStatus.item?.status, 'refunded', 'synchronous Alipay refund should update order status');
const alipayRefundNotifyBody = {
notify_id: `alipay-refund-notify-${alipayOrder.item.orderNo}`,
notify_time: '2026-06-28 00:00:00',
notify_type: 'trade_status_sync',
app_id: 'alipay-smoke-appid',
trade_no: `ali-trade-${alipayOrder.item.orderNo}`,
out_trade_no: alipayOrder.item.orderNo,
out_biz_no: 'RF-ALIPAY-PROVIDER-001',
refund_fee: (alipayOrder.item.amountCents / 100).toFixed(2),
fund_change: 'Y',
charset: 'utf-8',
version: '1.0',
};
alipayRefundNotifyBody.sign_type = 'RSA2';
alipayRefundNotifyBody.sign = signAlipayParams(alipayRefundNotifyBody);
const alipayRefundNotify = await request('/api/commerce/refunds/notify/alipay', {
userId: false,
method: 'POST',
query: { tenantId: MAIN_TENANT_ID },
body: alipayRefundNotifyBody,
});
assert.equal(alipayRefundNotify.item?.idempotent, true, 'Alipay refund notify for already succeeded refund should be idempotent');
const alipayRefundedAfterNotify = await request('/api/commerce/orders/status', {
query: { orderNo: alipayOrder.item.orderNo },
});
assert.equal(alipayRefundedAfterNotify.item?.refundedAmountCents, alipayOrder.item.amountCents, 'duplicate Alipay refund notify must not add refunded amount twice');
const alipayRefundQueryRejected = await request('/api/commerce/refunds/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',