lovart3/lib/billing/wechat.ts
Your Name f874b3464f
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add wechat pay channel to unified billing flow
2026-02-12 13:32:13 +08:00

230 lines
5.0 KiB
TypeScript

import {
createDecipheriv,
createSign,
createVerify,
randomBytes,
} from "node:crypto";
const WECHAT_PAY_API_BASE = "https://api.mch.weixin.qq.com";
type WechatNativeOrderParams = {
appId: string;
mchId: string;
privateKey: string;
serialNo: string;
description: string;
outTradeNo: string;
notifyUrl: string;
amountFen: number;
attach?: string;
};
type WechatNativeOrderResponse = {
code_url: string;
};
type WechatPayNotifyEnvelope = {
id: string;
create_time: string;
event_type: string;
resource_type: string;
summary: string;
resource: {
algorithm: string;
ciphertext: string;
associated_data?: string;
nonce: string;
};
};
export class WechatPayError extends Error {
status: number;
constructor(message: string, status = 500) {
super(message);
this.status = status;
}
}
function normalizePem(pem: string) {
return pem.replace(/\\n/g, "\n").trim();
}
function buildWechatPaySignature({
method,
path,
timestamp,
nonce,
body,
privateKey,
}: {
method: string;
path: string;
timestamp: string;
nonce: string;
body: string;
privateKey: string;
}) {
const message = `${method}\n${path}\n${timestamp}\n${nonce}\n${body}\n`;
const signer = createSign("RSA-SHA256");
signer.update(message);
signer.end();
return signer.sign(normalizePem(privateKey), "base64");
}
function buildWechatAuthorization({
mchId,
serialNo,
timestamp,
nonce,
signature,
}: {
mchId: string;
serialNo: string;
timestamp: string;
nonce: string;
signature: string;
}) {
return `WECHATPAY2-SHA256-RSA2048 mchid=\"${mchId}\",nonce_str=\"${nonce}\",timestamp=\"${timestamp}\",serial_no=\"${serialNo}\",signature=\"${signature}\"`;
}
export function generateWechatOutTradeNo() {
const suffix = randomBytes(4).toString("hex");
return `LVT${Date.now()}${suffix}`.slice(0, 32);
}
export async function createWechatNativeOrder(params: WechatNativeOrderParams) {
const path = "/v3/pay/transactions/native";
const timestamp = `${Math.floor(Date.now() / 1000)}`;
const nonce = randomBytes(16).toString("hex");
const body = JSON.stringify({
appid: params.appId,
mchid: params.mchId,
description: params.description,
out_trade_no: params.outTradeNo,
notify_url: params.notifyUrl,
amount: {
total: params.amountFen,
currency: "CNY",
},
attach: params.attach,
});
const signature = buildWechatPaySignature({
method: "POST",
path,
timestamp,
nonce,
body,
privateKey: params.privateKey,
});
const authorization = buildWechatAuthorization({
mchId: params.mchId,
serialNo: params.serialNo,
timestamp,
nonce,
signature,
});
const response = await fetch(`${WECHAT_PAY_API_BASE}${path}`, {
method: "POST",
headers: {
Authorization: authorization,
Accept: "application/json",
"Content-Type": "application/json",
},
body,
});
const data = await response.json();
if (!response.ok) {
throw new WechatPayError(
typeof data?.message === "string" ? data.message : "Failed to create WeChat native order",
response.status
);
}
if (!data?.code_url) {
throw new WechatPayError("WeChat order response missing code_url", 502);
}
return data as WechatNativeOrderResponse;
}
export function verifyWechatPaySignature({
body,
timestamp,
nonce,
signature,
platformPublicKey,
}: {
body: string;
timestamp: string;
nonce: string;
signature: string;
platformPublicKey: string;
}) {
const message = `${timestamp}\n${nonce}\n${body}\n`;
const verifier = createVerify("RSA-SHA256");
verifier.update(message);
verifier.end();
return verifier.verify(normalizePem(platformPublicKey), signature, "base64");
}
export function decryptWechatPayResource({
ciphertext,
nonce,
associatedData,
apiV3Key,
}: {
ciphertext: string;
nonce: string;
associatedData?: string;
apiV3Key: string;
}) {
const key = Buffer.from(apiV3Key, "utf8");
if (key.length !== 32) {
throw new WechatPayError("WECHAT_PAY_API_V3_KEY must be 32 bytes", 500);
}
const data = Buffer.from(ciphertext, "base64");
const authTag = data.subarray(data.length - 16);
const encryptedData = data.subarray(0, data.length - 16);
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(nonce, "utf8"));
if (associatedData) {
decipher.setAAD(Buffer.from(associatedData, "utf8"));
}
decipher.setAuthTag(authTag);
const plaintext = Buffer.concat([decipher.update(encryptedData), decipher.final()]).toString("utf8");
return plaintext;
}
export function parseWechatNotifyPayload({
body,
apiV3Key,
}: {
body: string;
apiV3Key: string;
}) {
const envelope = JSON.parse(body) as WechatPayNotifyEnvelope;
const resource = envelope.resource;
const plaintext = decryptWechatPayResource({
ciphertext: resource.ciphertext,
nonce: resource.nonce,
associatedData: resource.associated_data,
apiV3Key,
});
return {
envelope,
transaction: JSON.parse(plaintext) as Record<string, unknown>,
};
}