172 lines
5.7 KiB
JavaScript
172 lines
5.7 KiB
JavaScript
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' },
|
|
{
|
|
out_bill_no: 'CLP501',
|
|
transfer_bill_no: 'wx-transfer-501',
|
|
state: 'WAIT_USER_CONFIRM',
|
|
package_info: 'package-info'
|
|
},
|
|
{
|
|
mch_id: '1900000109',
|
|
out_bill_no: 'CLP501',
|
|
transfer_bill_no: 'wx-transfer-501',
|
|
state: 'SUCCESS',
|
|
transfer_amount: 800
|
|
}
|
|
];
|
|
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 transfer = await client.createMerchantTransfer(credential, {
|
|
outBillNo: 'CLP501',
|
|
openid: 'openid-sanitized',
|
|
amountCents: 800,
|
|
remark: '保洁结算501',
|
|
sceneId: '1000',
|
|
reportInfos: [{ infoType: '岗位类型', infoContent: '保洁员' }]
|
|
});
|
|
assert.equal(transfer.state, 'WAIT_USER_CONFIRM');
|
|
assert.equal(transfer.packageInfo, 'package-info');
|
|
assert.match(requests[4].url, /\/v3\/fund-app\/mch-transfer\/transfer-bills$/);
|
|
assert.match(requests[4].body, /"out_bill_no":"CLP501"/);
|
|
assert.match(requests[4].body, /"transfer_amount":800/);
|
|
assert.equal(requests[4].headers.Authorization.includes(merchantKeys.privateKey), false);
|
|
|
|
const queriedTransfer = await client.queryMerchantTransferByOutBillNo(credential, 'CLP501');
|
|
assert.equal(queriedTransfer.state, 'SUCCESS');
|
|
assert.equal(queriedTransfer.amountCents, 800);
|
|
assert.match(requests[5].url, /\/v3\/fund-app\/mch-transfer\/transfer-bills\/out-bill-no\/CLP501$/);
|
|
assert.equal(requests[5].headers.Authorization.includes(merchantKeys.privateKey), false);
|
|
|
|
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');
|
|
}
|