389 lines
15 KiB
TypeScript
389 lines
15 KiB
TypeScript
/**
|
|
* WeChat Pay Service
|
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
* Supports: Native Pay (NATIVE) — QR code, for PC/Web
|
|
* H5 Pay (H5) — for mobile browsers outside WeChat
|
|
* JSAPI Pay — for WeChat built-in browser (requires openid)
|
|
*
|
|
* Configuration (set via environment variables — DO NOT hardcode):
|
|
* WECHAT_APP_ID — WeChat Official Account / Mini Program App ID
|
|
* WECHAT_MCH_ID — WeChat Pay Merchant ID
|
|
* WECHAT_API_V3_KEY — API v3 key (32 bytes, set in WeChat Pay console)
|
|
* WECHAT_CERT_SERIAL_NO — API certificate serial number
|
|
* WECHAT_PRIVATE_KEY — API certificate private key (PEM, single line)
|
|
* WECHAT_NOTIFY_URL — Async callback URL (must be publicly accessible)
|
|
* WECHAT_SANDBOX — "true" to use sandbox environment
|
|
*
|
|
* Integration point:
|
|
* After verifying the async callback, call tokenDistributionService.creditXic()
|
|
* to distribute XIC tokens to the buyer.
|
|
*
|
|
* Docs: https://pay.weixin.qq.com/wiki/doc/apiv3/open/pay/chapter2_7_0.shtml
|
|
*/
|
|
|
|
import crypto from "crypto";
|
|
import { getDb } from "../db";
|
|
import { fiatOrders } from "../../drizzle/schema";
|
|
import { eq } from "drizzle-orm";
|
|
import { creditXic, calcXicAmount } from "../tokenDistributionService";
|
|
|
|
// ─── Configuration ────────────────────────────────────────────────────────────
|
|
// TODO: Replace placeholder values with real credentials from WeChat Pay console
|
|
// https://pay.weixin.qq.com/index.php/core/account/info
|
|
const WECHAT_CONFIG = {
|
|
appId: process.env.WECHAT_APP_ID || "PLACEHOLDER_WECHAT_APP_ID",
|
|
mchId: process.env.WECHAT_MCH_ID || "PLACEHOLDER_WECHAT_MCH_ID",
|
|
apiV3Key: process.env.WECHAT_API_V3_KEY || "PLACEHOLDER_WECHAT_API_V3_KEY_32BYTES",
|
|
certSerialNo: process.env.WECHAT_CERT_SERIAL_NO || "PLACEHOLDER_CERT_SERIAL_NO",
|
|
privateKey: process.env.WECHAT_PRIVATE_KEY || "PLACEHOLDER_WECHAT_PRIVATE_KEY",
|
|
notifyUrl: process.env.WECHAT_NOTIFY_URL || "https://pre-sale.newassetchain.io/api/payment/wechat/notify",
|
|
// WeChat Pay API v3 base URL (same for sandbox and production, use different credentials)
|
|
baseUrl: "https://api.mch.weixin.qq.com",
|
|
sandbox: process.env.WECHAT_SANDBOX === "true",
|
|
};
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
export interface WechatOrderParams {
|
|
orderId: string;
|
|
description: string;
|
|
totalFen: number; // amount in CNY fen (e.g. 10000 = 100.00 CNY)
|
|
xicReceiveAddress: string;
|
|
userId?: string;
|
|
payType?: "NATIVE" | "H5" | "JSAPI";
|
|
openId?: string; // required for JSAPI
|
|
clientIp?: string; // required for H5
|
|
}
|
|
|
|
export interface WechatOrderResult {
|
|
success: boolean;
|
|
qrCodeUrl?: string; // for NATIVE pay
|
|
h5Url?: string; // for H5 pay
|
|
prepayId?: string; // for JSAPI pay
|
|
jsapiParams?: WechatJsapiParams;
|
|
orderId?: string;
|
|
error?: string;
|
|
}
|
|
|
|
export interface WechatJsapiParams {
|
|
appId: string;
|
|
timeStamp: string;
|
|
nonceStr: string;
|
|
package: string;
|
|
signType: "RSA";
|
|
paySign: string;
|
|
}
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
/** Generate unique order ID with WECHAT prefix */
|
|
export function generateWechatOrderId(): string {
|
|
const ts = Date.now().toString();
|
|
const rand = Math.random().toString(36).substring(2, 8).toUpperCase();
|
|
return `WECHAT-${ts}-${rand}`;
|
|
}
|
|
|
|
/** CNY fen to USD conversion */
|
|
function fenToUsd(fen: number): number {
|
|
const CNY_USD_RATE = 0.138; // TODO: fetch real-time rate
|
|
return parseFloat(((fen / 100) * CNY_USD_RATE).toFixed(6));
|
|
}
|
|
|
|
/**
|
|
* Build WeChat Pay API v3 authorization header.
|
|
* Uses RSA-SHA256 signature scheme.
|
|
* Docs: https://pay.weixin.qq.com/wiki/doc/apiv3/wechatpay/wechatpay4_0.shtml
|
|
*/
|
|
function buildAuthHeader(method: string, url: string, body: string): string {
|
|
if (WECHAT_CONFIG.privateKey === "PLACEHOLDER_WECHAT_PRIVATE_KEY") {
|
|
console.warn("[WeChat] Using placeholder private key — auth header will be invalid");
|
|
return `WECHATPAY2-SHA256-RSA2048 mchid="${WECHAT_CONFIG.mchId}",nonce_str="placeholder",timestamp="${Math.floor(Date.now() / 1000)}",serial_no="${WECHAT_CONFIG.certSerialNo}",signature="PLACEHOLDER_SIGNATURE"`;
|
|
}
|
|
|
|
const timestamp = Math.floor(Date.now() / 1000).toString();
|
|
const nonceStr = crypto.randomBytes(16).toString("hex");
|
|
const urlObj = new URL(url);
|
|
const canonicalUrl = urlObj.pathname + (urlObj.search || "");
|
|
|
|
const message = `${method}\n${canonicalUrl}\n${timestamp}\n${nonceStr}\n${body}\n`;
|
|
const privateKey = `-----BEGIN PRIVATE KEY-----\n${WECHAT_CONFIG.privateKey}\n-----END PRIVATE KEY-----`;
|
|
const sign = crypto.createSign("RSA-SHA256");
|
|
sign.update(message);
|
|
const signature = sign.sign(privateKey, "base64");
|
|
|
|
return `WECHATPAY2-SHA256-RSA2048 mchid="${WECHAT_CONFIG.mchId}",nonce_str="${nonceStr}",timestamp="${timestamp}",serial_no="${WECHAT_CONFIG.certSerialNo}",signature="${signature}"`;
|
|
}
|
|
|
|
/**
|
|
* Verify WeChat Pay callback AES-GCM decryption.
|
|
* WeChat encrypts the resource field with AES-256-GCM using apiV3Key.
|
|
*/
|
|
export function decryptWechatCallback(
|
|
associatedData: string,
|
|
nonce: string,
|
|
ciphertext: string
|
|
): string | null {
|
|
if (WECHAT_CONFIG.apiV3Key === "PLACEHOLDER_WECHAT_API_V3_KEY_32BYTES") {
|
|
console.warn("[WeChat] Using placeholder API v3 key — decryption will fail");
|
|
return null;
|
|
}
|
|
try {
|
|
const key = Buffer.from(WECHAT_CONFIG.apiV3Key, "utf8");
|
|
const ciphertextBuf = Buffer.from(ciphertext, "base64");
|
|
const authTag = ciphertextBuf.slice(ciphertextBuf.length - 16);
|
|
const data = ciphertextBuf.slice(0, ciphertextBuf.length - 16);
|
|
|
|
const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(nonce, "utf8"));
|
|
decipher.setAuthTag(authTag);
|
|
decipher.setAAD(Buffer.from(associatedData, "utf8"));
|
|
|
|
const decrypted = Buffer.concat([decipher.update(data), decipher.final()]);
|
|
return decrypted.toString("utf8");
|
|
} catch (err) {
|
|
console.error("[WeChat] Decryption failed:", err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ─── Core Functions ───────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Create a WeChat Pay order.
|
|
* Supports NATIVE (QR), H5, and JSAPI payment types.
|
|
*/
|
|
export async function createWechatOrder(params: WechatOrderParams): Promise<WechatOrderResult> {
|
|
const { orderId, description, totalFen, xicReceiveAddress, userId, payType = "NATIVE", openId, clientIp } = params;
|
|
|
|
const usdEquivalent = fenToUsd(totalFen);
|
|
const xicAmount = calcXicAmount(usdEquivalent);
|
|
|
|
// Determine API endpoint based on pay type
|
|
const endpointMap: Record<string, string> = {
|
|
NATIVE: "/v3/pay/transactions/native",
|
|
H5: "/v3/pay/transactions/h5",
|
|
JSAPI: "/v3/pay/transactions/jsapi",
|
|
};
|
|
const endpoint = endpointMap[payType];
|
|
const url = `${WECHAT_CONFIG.baseUrl}${endpoint}`;
|
|
|
|
// Build request body
|
|
const requestBody: Record<string, any> = {
|
|
appid: WECHAT_CONFIG.appId,
|
|
mchid: WECHAT_CONFIG.mchId,
|
|
description,
|
|
out_trade_no: orderId,
|
|
notify_url: WECHAT_CONFIG.notifyUrl,
|
|
amount: { total: totalFen, currency: "CNY" },
|
|
attach: xicReceiveAddress, // pass XIC receive address as attach field
|
|
};
|
|
|
|
if (payType === "JSAPI" && openId) {
|
|
requestBody.payer = { openid: openId };
|
|
}
|
|
if (payType === "H5" && clientIp) {
|
|
requestBody.scene_info = {
|
|
payer_client_ip: clientIp,
|
|
h5_info: { type: "Wap", wap_url: "https://pre-sale.newassetchain.io", wap_name: "NAC XIC Presale" },
|
|
};
|
|
}
|
|
|
|
const body = JSON.stringify(requestBody);
|
|
const authHeader = buildAuthHeader("POST", url, body);
|
|
|
|
try {
|
|
const db = await getDb();
|
|
if (!db) return { success: false, error: "Database not available" };
|
|
|
|
// Save order to database first
|
|
await db.insert(fiatOrders).values({
|
|
orderId,
|
|
channel: "wechat",
|
|
userId: userId || null,
|
|
xicReceiveAddress,
|
|
usdtEquivalent: usdEquivalent.toString(),
|
|
currency: "CNY",
|
|
originalAmount: (totalFen / 100).toFixed(2),
|
|
xicAmount: xicAmount.toString(),
|
|
status: "pending",
|
|
expiredAt: new Date(Date.now() + 30 * 60 * 1000),
|
|
});
|
|
|
|
// Call WeChat Pay API
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Authorization": authHeader,
|
|
"Accept": "application/json",
|
|
},
|
|
body,
|
|
});
|
|
|
|
const data = await response.json() as any;
|
|
|
|
if (!response.ok) {
|
|
console.error("[WeChat] API error:", data);
|
|
return { success: false, error: data.message || "WeChat API error" };
|
|
}
|
|
|
|
let result: WechatOrderResult = { success: true, orderId };
|
|
|
|
if (payType === "NATIVE" && data.code_url) {
|
|
result.qrCodeUrl = data.code_url;
|
|
await db.update(fiatOrders)
|
|
.set({ qrCodeUrl: data.code_url })
|
|
.where(eq(fiatOrders.orderId, orderId));
|
|
} else if (payType === "H5" && data.h5_url) {
|
|
result.h5Url = data.h5_url;
|
|
await db.update(fiatOrders)
|
|
.set({ paymentUrl: data.h5_url })
|
|
.where(eq(fiatOrders.orderId, orderId));
|
|
} else if (payType === "JSAPI" && data.prepay_id) {
|
|
result.prepayId = data.prepay_id;
|
|
// Build JSAPI parameters for frontend
|
|
const timestamp = Math.floor(Date.now() / 1000).toString();
|
|
const nonceStr = crypto.randomBytes(16).toString("hex");
|
|
const packageStr = `prepay_id=${data.prepay_id}`;
|
|
const signMessage = `${WECHAT_CONFIG.appId}\n${timestamp}\n${nonceStr}\n${packageStr}\n`;
|
|
const privateKey = `-----BEGIN PRIVATE KEY-----\n${WECHAT_CONFIG.privateKey}\n-----END PRIVATE KEY-----`;
|
|
const sign = crypto.createSign("RSA-SHA256");
|
|
sign.update(signMessage);
|
|
const paySign = sign.sign(privateKey, "base64");
|
|
|
|
result.jsapiParams = {
|
|
appId: WECHAT_CONFIG.appId,
|
|
timeStamp: timestamp,
|
|
nonceStr,
|
|
package: packageStr,
|
|
signType: "RSA",
|
|
paySign,
|
|
};
|
|
}
|
|
|
|
console.log(`[WeChat] Order created: ${orderId} | CNY ${(totalFen / 100).toFixed(2)} → ${xicAmount} XIC → ${xicReceiveAddress}`);
|
|
return result;
|
|
} catch (err) {
|
|
console.error("[WeChat] Failed to create order:", err);
|
|
return { success: false, error: String(err) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process WeChat Pay async callback (notify).
|
|
* WeChat sends a POST request to WECHAT_NOTIFY_URL when payment is completed.
|
|
*
|
|
* Flow:
|
|
* 1. Decrypt the resource field using AES-256-GCM
|
|
* 2. Verify trade_state === "SUCCESS"
|
|
* 3. Update fiat_orders status to "paid"
|
|
* 4. Call creditXic() to distribute XIC tokens
|
|
* 5. Return { code: "SUCCESS" } to WeChat
|
|
*/
|
|
export async function handleWechatCallback(body: any): Promise<{ ok: boolean; code: string; message: string }> {
|
|
const { event_type, resource } = body;
|
|
|
|
if (event_type !== "TRANSACTION.SUCCESS") {
|
|
return { ok: true, code: "SUCCESS", message: "ignored" };
|
|
}
|
|
|
|
// Step 1: Decrypt resource
|
|
const decrypted = decryptWechatCallback(
|
|
resource.associated_data,
|
|
resource.nonce,
|
|
resource.ciphertext
|
|
);
|
|
|
|
if (!decrypted) {
|
|
// In sandbox/test mode with placeholder keys, parse raw body
|
|
console.warn("[WeChat] Decryption failed — attempting to process as plaintext (test mode)");
|
|
return { ok: false, code: "FAIL", message: "decryption_failed" };
|
|
}
|
|
|
|
let transaction: any;
|
|
try {
|
|
transaction = JSON.parse(decrypted);
|
|
} catch {
|
|
return { ok: false, code: "FAIL", message: "invalid_json" };
|
|
}
|
|
|
|
const { trade_state, out_trade_no, transaction_id, amount, payer, attach } = transaction;
|
|
|
|
if (trade_state !== "SUCCESS") {
|
|
return { ok: true, code: "SUCCESS", message: "ignored" };
|
|
}
|
|
|
|
try {
|
|
const db = await getDb();
|
|
if (!db) return { ok: false, code: "FAIL", message: "db_unavailable" };
|
|
|
|
const orders = await db.select().from(fiatOrders)
|
|
.where(eq(fiatOrders.orderId, out_trade_no))
|
|
.limit(1);
|
|
const order = orders[0];
|
|
if (!order) return { ok: false, code: "FAIL", message: "order_not_found" };
|
|
|
|
if (order.status === "distributed" || order.status === "paid") {
|
|
return { ok: true, code: "SUCCESS", message: "already_processed" };
|
|
}
|
|
|
|
// Update to paid
|
|
await db.update(fiatOrders)
|
|
.set({
|
|
status: "paid",
|
|
gatewayOrderId: transaction_id,
|
|
payerOpenId: payer?.openid || null,
|
|
callbackPayload: decrypted,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(fiatOrders.orderId, out_trade_no));
|
|
|
|
// Distribute XIC
|
|
const creditResult = await creditXic({
|
|
txHash: `WECHAT-${transaction_id}`,
|
|
chainType: "WECHAT",
|
|
fromAddress: payer?.openid || "wechat_payer",
|
|
toAddress: "wechat_merchant",
|
|
usdtAmount: parseFloat(order.usdtEquivalent),
|
|
xicAmount: parseFloat(order.xicAmount),
|
|
xicReceiveAddress: order.xicReceiveAddress || attach || undefined,
|
|
remark: `WeChat order ${out_trade_no}, CNY ${(amount?.total / 100).toFixed(2)}`,
|
|
});
|
|
|
|
if (creditResult.success) {
|
|
await db.update(fiatOrders)
|
|
.set({ status: "distributed", distributedAt: new Date() })
|
|
.where(eq(fiatOrders.orderId, out_trade_no));
|
|
console.log(`[WeChat] ✅ XIC distributed for order ${out_trade_no}`);
|
|
}
|
|
|
|
return { ok: true, code: "SUCCESS", message: "success" };
|
|
} catch (err) {
|
|
console.error("[WeChat] Callback processing error:", err);
|
|
return { ok: false, code: "FAIL", message: String(err) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Query WeChat Pay order status.
|
|
* Used for polling when async callback is delayed.
|
|
*/
|
|
export async function queryWechatOrder(orderId: string): Promise<{ success: boolean; tradeState?: string; error?: string }> {
|
|
const url = `${WECHAT_CONFIG.baseUrl}/v3/pay/transactions/out-trade-no/${orderId}?mchid=${WECHAT_CONFIG.mchId}`;
|
|
const authHeader = buildAuthHeader("GET", url, "");
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
"Authorization": authHeader,
|
|
"Accept": "application/json",
|
|
},
|
|
});
|
|
const data = await response.json() as any;
|
|
if (response.ok) {
|
|
return { success: true, tradeState: data.trade_state };
|
|
}
|
|
return { success: false, error: data.message };
|
|
} catch (err) {
|
|
return { success: false, error: String(err) };
|
|
}
|
|
}
|