/** * Alipay Payment Service * ───────────────────────────────────────────────────────────────────────────── * Supports: PC Web Payment (alipay.trade.page.pay) * H5 Mobile Payment (alipay.trade.wap.pay) * Order Query (alipay.trade.query) * Refund (alipay.trade.refund) * * Configuration (set via environment variables — DO NOT hardcode): * ALIPAY_APP_ID — Alipay Open Platform App ID * ALIPAY_PRIVATE_KEY — RSA2 private key (PKCS8, no header/footer, single line) * ALIPAY_PUBLIC_KEY — Alipay public key (for signature verification) * ALIPAY_NOTIFY_URL — Async callback URL (must be publicly accessible) * ALIPAY_RETURN_URL — Sync redirect URL after payment * ALIPAY_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://opendocs.alipay.com/open/270/105899 */ 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 Alipay Open Platform // https://open.alipay.com/develop/manage const ALIPAY_CONFIG = { appId: process.env.ALIPAY_APP_ID || "PLACEHOLDER_ALIPAY_APP_ID", privateKey: process.env.ALIPAY_PRIVATE_KEY || "PLACEHOLDER_RSA2_PRIVATE_KEY", alipayPublicKey: process.env.ALIPAY_PUBLIC_KEY || "PLACEHOLDER_ALIPAY_PUBLIC_KEY", notifyUrl: process.env.ALIPAY_NOTIFY_URL || "https://pre-sale.newassetchain.io/api/payment/alipay/notify", returnUrl: process.env.ALIPAY_RETURN_URL || "https://pre-sale.newassetchain.io/payment/success", // Sandbox: https://openapi-sandbox.dl.alipaydev.com/gateway.do // Production: https://openapi.alipay.com/gateway.do gatewayUrl: process.env.ALIPAY_SANDBOX === "true" ? "https://openapi-sandbox.dl.alipaydev.com/gateway.do" : "https://openapi.alipay.com/gateway.do", sandbox: process.env.ALIPAY_SANDBOX === "true", }; // ─── Types ──────────────────────────────────────────────────────────────────── export interface AlipayOrderParams { orderId: string; // our internal order ID subject: string; // order subject (e.g. "XIC Token Purchase") totalAmount: string; // CNY amount, e.g. "100.00" xicReceiveAddress: string; // BSC address to receive XIC userId?: string; isMobile?: boolean; // true → H5 payment, false → PC payment } export interface AlipayOrderResult { success: boolean; paymentUrl?: string; // redirect URL for PC/H5 payment orderId?: string; error?: string; } export interface AlipayQueryResult { success: boolean; tradeStatus?: "WAIT_BUYER_PAY" | "TRADE_CLOSED" | "TRADE_SUCCESS" | "TRADE_FINISHED"; totalAmount?: string; buyerPayAmount?: string; tradeNo?: string; error?: string; } // ─── Helpers ────────────────────────────────────────────────────────────────── /** Generate unique order ID with ALIPAY prefix */ export function generateAlipayOrderId(): string { const ts = Date.now().toString(); const rand = Math.random().toString(36).substring(2, 8).toUpperCase(); return `ALIPAY-${ts}-${rand}`; } /** CNY to USD conversion (approximate, replace with real-time rate in production) */ function cnyToUsd(cny: number): number { const CNY_USD_RATE = 0.138; // TODO: fetch real-time rate from exchange API return parseFloat((cny * CNY_USD_RATE).toFixed(6)); } /** * Build Alipay RSA2 signature. * Signs the sorted parameter string with RSA2 (SHA256withRSA). */ function buildSign(params: Record): string { if (ALIPAY_CONFIG.privateKey === "PLACEHOLDER_RSA2_PRIVATE_KEY") { console.warn("[Alipay] Using placeholder private key — signature will be invalid"); return "PLACEHOLDER_SIGNATURE"; } const sortedKeys = Object.keys(params).sort(); const signStr = sortedKeys .filter(k => k !== "sign" && params[k] !== "" && params[k] !== undefined) .map(k => `${k}=${params[k]}`) .join("&"); const privateKey = `-----BEGIN RSA PRIVATE KEY-----\n${ALIPAY_CONFIG.privateKey}\n-----END RSA PRIVATE KEY-----`; const sign = crypto.createSign("RSA-SHA256"); sign.update(signStr, "utf8"); return sign.sign(privateKey, "base64"); } /** * Verify Alipay async callback signature. * Returns true if signature is valid. */ export function verifyAlipaySign(params: Record): boolean { if (ALIPAY_CONFIG.alipayPublicKey === "PLACEHOLDER_ALIPAY_PUBLIC_KEY") { console.warn("[Alipay] Using placeholder public key — skipping signature verification (SANDBOX MODE)"); return true; // Allow in sandbox/test mode } const sign = params.sign; if (!sign) return false; const sortedKeys = Object.keys(params).sort(); const signStr = sortedKeys .filter(k => k !== "sign" && k !== "sign_type" && params[k] !== "") .map(k => `${k}=${params[k]}`) .join("&"); const publicKey = `-----BEGIN PUBLIC KEY-----\n${ALIPAY_CONFIG.alipayPublicKey}\n-----END PUBLIC KEY-----`; const verify = crypto.createVerify("RSA-SHA256"); verify.update(signStr, "utf8"); return verify.verify(publicKey, sign, "base64"); } // ─── Core Functions ─────────────────────────────────────────────────────────── /** * Create an Alipay payment order. * Returns a redirect URL for PC (page pay) or H5 (wap pay). */ export async function createAlipayOrder(params: AlipayOrderParams): Promise { const { orderId, subject, totalAmount, xicReceiveAddress, userId, isMobile = false } = params; const cnyAmount = parseFloat(totalAmount); const usdEquivalent = cnyToUsd(cnyAmount); const xicAmount = calcXicAmount(usdEquivalent); // Build Alipay request parameters const method = isMobile ? "alipay.trade.wap.pay" : "alipay.trade.page.pay"; const bizContent = JSON.stringify({ out_trade_no: orderId, product_code: isMobile ? "QUICK_WAP_WAY" : "FAST_INSTANT_TRADE_PAY", total_amount: totalAmount, subject, body: `XIC Token Presale — ${xicAmount} XIC`, timeout_express: "30m", // passback_params: encodeURIComponent(JSON.stringify({ xicReceiveAddress })), }); const commonParams: Record = { app_id: ALIPAY_CONFIG.appId, method, format: "JSON", charset: "utf-8", sign_type: "RSA2", timestamp: new Date().toISOString().replace("T", " ").substring(0, 19), version: "1.0", notify_url: ALIPAY_CONFIG.notifyUrl, return_url: ALIPAY_CONFIG.returnUrl, biz_content: bizContent, }; const sign = buildSign(commonParams); const allParams = { ...commonParams, sign }; // Build redirect URL (GET form submit) const queryString = Object.entries(allParams) .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) .join("&"); const paymentUrl = `${ALIPAY_CONFIG.gatewayUrl}?${queryString}`; try { const db = await getDb(); if (!db) return { success: false, error: "Database not available" }; // Save order to database await db.insert(fiatOrders).values({ orderId, channel: "alipay", userId: userId || null, xicReceiveAddress, usdtEquivalent: usdEquivalent.toString(), currency: "CNY", originalAmount: totalAmount, xicAmount: xicAmount.toString(), status: "pending", paymentUrl, expiredAt: new Date(Date.now() + 30 * 60 * 1000), // 30 minutes }); console.log(`[Alipay] Order created: ${orderId} | CNY ${totalAmount} → ${xicAmount} XIC → ${xicReceiveAddress}`); return { success: true, paymentUrl, orderId }; } catch (err) { console.error("[Alipay] Failed to create order:", err); return { success: false, error: String(err) }; } } /** * Query Alipay order status via API. * Used for polling when async callback is not received. */ export async function queryAlipayOrder(orderId: string): Promise { const bizContent = JSON.stringify({ out_trade_no: orderId }); const commonParams: Record = { app_id: ALIPAY_CONFIG.appId, method: "alipay.trade.query", format: "JSON", charset: "utf-8", sign_type: "RSA2", timestamp: new Date().toISOString().replace("T", " ").substring(0, 19), version: "1.0", biz_content: bizContent, }; const sign = buildSign(commonParams); try { const response = await fetch(ALIPAY_CONFIG.gatewayUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: Object.entries({ ...commonParams, sign }) .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) .join("&"), }); const data = await response.json() as any; const result = data["alipay_trade_query_response"]; if (result?.code === "10000") { return { success: true, tradeStatus: result.trade_status, totalAmount: result.total_amount, buyerPayAmount: result.buyer_pay_amount, tradeNo: result.trade_no, }; } return { success: false, error: result?.sub_msg || "Query failed" }; } catch (err) { return { success: false, error: String(err) }; } } /** * Process Alipay async callback (notify). * Called by the backend route when Alipay POSTs to ALIPAY_NOTIFY_URL. * * Flow: * 1. Verify signature * 2. Check trade_status === "TRADE_SUCCESS" * 3. Update fiat_orders status to "paid" * 4. Call creditXic() to distribute XIC tokens * 5. Return "success" to Alipay (prevents retry) */ export async function handleAlipayCallback(params: Record): Promise<{ ok: boolean; message: string }> { // Step 1: Verify signature if (!verifyAlipaySign(params)) { console.error("[Alipay] Callback signature verification failed"); return { ok: false, message: "signature_invalid" }; } const { trade_status, out_trade_no, trade_no, total_amount, buyer_id } = params; // Step 2: Only process successful payments if (trade_status !== "TRADE_SUCCESS" && trade_status !== "TRADE_FINISHED") { console.log(`[Alipay] Callback ignored — trade_status=${trade_status} for order ${out_trade_no}`); return { ok: true, message: "ignored" }; } try { const db = await getDb(); if (!db) return { ok: false, message: "db_unavailable" }; // Step 3: Find the order const orders = await db.select().from(fiatOrders) .where(eq(fiatOrders.orderId, out_trade_no)) .limit(1); const order = orders[0]; if (!order) { console.error(`[Alipay] Order not found: ${out_trade_no}`); return { ok: false, message: "order_not_found" }; } // Idempotency: skip if already processed if (order.status === "distributed" || order.status === "paid") { console.log(`[Alipay] Order ${out_trade_no} already processed, skipping`); return { ok: true, message: "already_processed" }; } // Step 4: Update order status to "paid" await db.update(fiatOrders) .set({ status: "paid", gatewayOrderId: trade_no, callbackPayload: JSON.stringify(params), updatedAt: new Date(), }) .where(eq(fiatOrders.orderId, out_trade_no)); // Step 5: Distribute XIC tokens via unified service const creditResult = await creditXic({ txHash: `ALIPAY-${trade_no}`, chainType: "ALIPAY", fromAddress: buyer_id || "alipay_buyer", toAddress: "alipay_merchant", usdtAmount: parseFloat(order.usdtEquivalent), xicAmount: parseFloat(order.xicAmount), xicReceiveAddress: order.xicReceiveAddress || undefined, remark: `Alipay order ${out_trade_no}, CNY ${total_amount}`, }); if (creditResult.success) { await db.update(fiatOrders) .set({ status: "distributed", distributedAt: new Date() }) .where(eq(fiatOrders.orderId, out_trade_no)); console.log(`[Alipay] ✅ XIC distributed for order ${out_trade_no}`); } else { console.error(`[Alipay] ❌ XIC distribution failed for order ${out_trade_no}:`, creditResult.error); } return { ok: true, message: "success" }; } catch (err) { console.error("[Alipay] Callback processing error:", err); return { ok: false, message: String(err) }; } } /** * Request Alipay refund. * TODO: Implement when refund workflow is defined. */ export async function refundAlipayOrder(orderId: string, refundAmount: string, reason: string): Promise<{ success: boolean; error?: string }> { // TODO: Implement alipay.trade.refund API call // Reference: https://opendocs.alipay.com/open/028sm9 console.warn(`[Alipay] Refund requested for ${orderId} — not yet implemented`); return { success: false, error: "Refund not yet implemented" }; }