/** * 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 { 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 = { 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 = { 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) }; } }