feat(M05-B): 完成微信支付退款与对账基础
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createCipheriv, createSign, generateKeyPairSync, randomBytes
|
||||
} from 'node:crypto';
|
||||
import {
|
||||
WechatPayClient, WechatPayError
|
||||
} from '../dist/payments/wechat-pay-client.js';
|
||||
|
||||
const merchantKeys = generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
|
||||
});
|
||||
const platformKeys = generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
|
||||
});
|
||||
const credential = {
|
||||
appId: 'wx-test-app',
|
||||
merchantId: '1900000109',
|
||||
serialNo: 'MERCHANT-SERIAL',
|
||||
privateKeyPem: merchantKeys.privateKey,
|
||||
apiV3Key: '0123456789abcdef0123456789abcdef',
|
||||
platformCertificates: { 'PLATFORM-SERIAL': platformKeys.publicKey }
|
||||
};
|
||||
const requests = [];
|
||||
const responses = [
|
||||
{ prepay_id: 'wx-prepay-test' },
|
||||
{ trade_state: 'SUCCESS', transaction_id: 'wx-transaction-test' },
|
||||
{ refund_id: 'wx-refund-test', status: 'PROCESSING' },
|
||||
{ download_url: 'https://api.mch.weixin.qq.com/v3/billdownload/file?token=sanitized' }
|
||||
];
|
||||
const transport = {
|
||||
async request(input) {
|
||||
requests.push(input);
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: JSON.stringify(responses.shift())
|
||||
};
|
||||
}
|
||||
};
|
||||
const client = new WechatPayClient(transport);
|
||||
const prepay = await client.createJsapiPrepay(credential, {
|
||||
description: 'M05-B sanitized order',
|
||||
outTradeNo: 'PAY-M05B-001',
|
||||
notifyUrl: 'https://api.txyundm.cn/app-api/pay/wechat/notify',
|
||||
amountCents: 3600,
|
||||
payerOpenId: 'openid-sanitized'
|
||||
});
|
||||
assert.equal(prepay.prepayId, 'wx-prepay-test');
|
||||
assert.equal(prepay.paymentParams.package, 'prepay_id=wx-prepay-test');
|
||||
assert.equal(prepay.paymentParams.signType, 'RSA');
|
||||
assert.match(prepay.paymentParams.paySign, /^[A-Za-z0-9+/]+=*$/);
|
||||
assert.match(requests[0].headers.Authorization, /mchid="1900000109"/);
|
||||
assert.equal(requests[0].body.includes(merchantKeys.privateKey), false);
|
||||
|
||||
assert.equal(
|
||||
(await client.queryTransaction(credential, 'PAY-M05B-001')).trade_state,
|
||||
'SUCCESS'
|
||||
);
|
||||
assert.equal((await client.createRefund(credential, {
|
||||
outTradeNo: 'PAY-M05B-001',
|
||||
outRefundNo: 'REF-M05B-001',
|
||||
reason: 'sanitized refund',
|
||||
notifyUrl: 'https://api.txyundm.cn/app-api/pay/wechat/refund-notify',
|
||||
refundCents: 1200,
|
||||
totalCents: 3600
|
||||
})).status, 'PROCESSING');
|
||||
assert.match(requests[2].body, /"refund":1200/);
|
||||
assert.match((await client.downloadTradeBill(
|
||||
credential, '2026-06-21', 'ALL'
|
||||
)).download_url, /^https:/);
|
||||
|
||||
const notification = encryptedNotification({
|
||||
out_trade_no: 'PAY-M05B-001',
|
||||
transaction_id: 'wx-transaction-test',
|
||||
trade_state: 'SUCCESS',
|
||||
amount: { total: 3600, currency: 'CNY' },
|
||||
payer: { openid: 'must-not-be-persisted' }
|
||||
}, credential.apiV3Key);
|
||||
const timestamp = '1782057600';
|
||||
const nonce = 'notification-nonce';
|
||||
const signature = sign(
|
||||
platformKeys.privateKey,
|
||||
`${timestamp}\n${nonce}\n${notification}\n`
|
||||
);
|
||||
const decrypted = client.verifyAndDecrypt(credential, {
|
||||
timestamp,
|
||||
nonce,
|
||||
serial: 'PLATFORM-SERIAL',
|
||||
signature
|
||||
}, notification);
|
||||
assert.equal(decrypted.transaction_id, 'wx-transaction-test');
|
||||
assert.equal(decrypted.amount.total, 3600);
|
||||
assert.throws(
|
||||
() => client.verifyAndDecrypt(credential, {
|
||||
timestamp,
|
||||
nonce,
|
||||
serial: 'PLATFORM-SERIAL',
|
||||
signature
|
||||
}, `${notification} `),
|
||||
(error) => error instanceof WechatPayError
|
||||
&& error.code === 'WECHAT_SIGNATURE_INVALID'
|
||||
);
|
||||
|
||||
console.log('PASS: M05-B Wechat Pay signing, API requests, notification verification and AES-GCM decryption.');
|
||||
|
||||
function encryptedNotification(resource, key) {
|
||||
const nonce = randomBytes(12).toString('base64url').slice(0, 12);
|
||||
const associatedData = 'transaction';
|
||||
const cipher = createCipheriv('aes-256-gcm', Buffer.from(key), Buffer.from(nonce));
|
||||
cipher.setAAD(Buffer.from(associatedData));
|
||||
const ciphertext = Buffer.concat([
|
||||
cipher.update(JSON.stringify(resource), 'utf8'),
|
||||
cipher.final(),
|
||||
cipher.getAuthTag()
|
||||
]).toString('base64');
|
||||
return JSON.stringify({
|
||||
id: 'notification-m05b-001',
|
||||
event_type: 'TRANSACTION.SUCCESS',
|
||||
resource: {
|
||||
algorithm: 'AEAD_AES_256_GCM',
|
||||
ciphertext,
|
||||
nonce,
|
||||
associated_data: associatedData
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function sign(privateKey, message) {
|
||||
const signer = createSign('RSA-SHA256');
|
||||
signer.update(message);
|
||||
signer.end();
|
||||
return signer.sign(privateKey, 'base64');
|
||||
}
|
||||
Reference in New Issue
Block a user