forked from wangziqi/gongxue-base
feat: handle refund notifications
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user