345 lines
13 KiB
TypeScript
345 lines
13 KiB
TypeScript
/**
|
|
* 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, string>): 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<string, string>): 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<AlipayOrderResult> {
|
|
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<string, string> = {
|
|
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<AlipayQueryResult> {
|
|
const bizContent = JSON.stringify({ out_trade_no: orderId });
|
|
const commonParams: Record<string, string> = {
|
|
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<string, string>): 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" };
|
|
}
|