diff --git a/client/src/components/AlipayPayment.tsx b/client/src/components/AlipayPayment.tsx new file mode 100644 index 0000000..32b4ba5 --- /dev/null +++ b/client/src/components/AlipayPayment.tsx @@ -0,0 +1,290 @@ +/** + * AlipayPayment Component + * ───────────────────────────────────────────────────────────────────────────── + * Handles Alipay PC Web Payment and H5 Mobile Payment. + * + * Flow: + * 1. User enters CNY amount + * 2. Component calls payment.createAlipayOrder mutation + * 3. PC: opens payment URL in new tab + * H5: redirects current page to Alipay H5 payment + * 4. Polls payment.queryAlipayOrder every 5 seconds + * 5. On success (dbStatus === "distributed"), shows success message + */ + +import { useState, useEffect, useRef } from "react"; +import { trpc } from "@/lib/trpc"; +import { toast } from "sonner"; + +// XIC price: $0.02 per XIC; CNY/USD rate: ~0.138 +const XIC_PRICE_USD = 0.02; +const CNY_USD_RATE = 0.138; // approximate — backend uses same rate + +function calcXicFromCny(cny: number): number { + const usd = cny * CNY_USD_RATE; + return Math.floor(usd / XIC_PRICE_USD); +} + +interface AlipayPaymentProps { + xicReceiveAddress: string; + onSuccess?: (xicAmount: number, orderId: string) => void; +} + +export default function AlipayPayment({ xicReceiveAddress, onSuccess }: AlipayPaymentProps) { + const [cnyInput, setCnyInput] = useState("100"); + const [orderId, setOrderId] = useState(null); + const [paymentUrl, setPaymentUrl] = useState(null); + const [paymentStatus, setPaymentStatus] = useState<"idle" | "waiting" | "success" | "failed">("idle"); + const pollRef = useRef | null>(null); + + const cnyAmount = parseFloat(cnyInput) || 0; + const xicAmount = calcXicFromCny(cnyAmount); + const usdEquivalent = (cnyAmount * CNY_USD_RATE).toFixed(2); + + const isMobile = /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent); + + const createOrder = trpc.payment.createAlipayOrder.useMutation({ + onSuccess: (data) => { + setOrderId(data.orderId); + setPaymentUrl(data.paymentUrl || null); + setPaymentStatus("waiting"); + + if (data.paymentUrl) { + if (isMobile) { + // H5: redirect current page + window.location.href = data.paymentUrl; + } else { + // PC: open in new tab + window.open(data.paymentUrl, "_blank", "noopener,noreferrer"); + toast.info("Alipay payment page opened in a new tab. Complete payment there."); + } + } + }, + onError: (err) => { + toast.error(`Failed to create Alipay order: ${err.message}`); + }, + }); + + const queryOrder = trpc.payment.queryAlipayOrder.useQuery( + { orderId: orderId! }, + { + enabled: !!orderId && paymentStatus === "waiting", + refetchInterval: 5000, + refetchIntervalInBackground: true, + } + ); + + // Watch query result for status changes + useEffect(() => { + if (!queryOrder.data) return; + const { dbStatus, xicAmount: dbXicAmount } = queryOrder.data as any; + if (dbStatus === "distributed" || dbStatus === "paid") { + setPaymentStatus("success"); + onSuccess?.(parseFloat(dbXicAmount || "0"), orderId!); + toast.success(`Payment confirmed! ${parseFloat(dbXicAmount || "0").toLocaleString()} XIC tokens will be distributed to your address.`); + } else if (dbStatus === "failed" || dbStatus === "expired") { + setPaymentStatus("failed"); + toast.error("Payment failed or expired. Please try again."); + } + }, [queryOrder.data]); + + const handlePay = () => { + if (!xicReceiveAddress || xicReceiveAddress.length < 10) { + toast.error("Please enter your XIC receive address first."); + return; + } + if (cnyAmount < 1) { + toast.error("Minimum payment is 1 CNY."); + return; + } + createOrder.mutate({ + totalAmount: cnyAmount.toFixed(2), + xicReceiveAddress, + isMobile, + }); + }; + + const handleReset = () => { + setOrderId(null); + setPaymentUrl(null); + setPaymentStatus("idle"); + setCnyInput("100"); + }; + + // ── Success State ────────────────────────────────────────────────────────── + if (paymentStatus === "success") { + return ( +
+
🎉
+

+ Payment Successful! +

+

+ + {xicAmount.toLocaleString()} + {" "} + XIC tokens are being distributed to your address. +

+ +
+ ); + } + + // ── Waiting State ────────────────────────────────────────────────────────── + if (paymentStatus === "waiting" && orderId) { + return ( +
+
+
+

Waiting for Payment

+

+ Order ID: {orderId} +

+ {!isMobile && paymentUrl && ( + + )} +

+ Checking payment status every 5 seconds... +

+
+ +
+ ); + } + + // ── Input State ──────────────────────────────────────────────────────────── + return ( +
+ {/* Amount Input */} +
+ +
+ + ¥ + + setCnyInput(e.target.value)} + min="1" + step="1" + className="w-full pl-8 pr-4 py-3 rounded-xl text-white font-mono focus:outline-none" + style={{ + background: "rgba(255,255,255,0.05)", + border: "1px solid rgba(240,180,41,0.3)", + fontSize: "1rem", + }} + placeholder="100" + /> +
+ {/* Quick amount buttons */} +
+ {[100, 500, 1000, 5000].map((amt) => ( + + ))} +
+
+ + {/* Conversion Preview */} + {cnyAmount > 0 && ( +
+
+ CNY Amount + ¥{cnyAmount.toFixed(2)} +
+
+ ≈ USD + ${usdEquivalent} +
+
+ XIC Tokens + {xicAmount.toLocaleString()} XIC +
+

+ Rate: ¥1 ≈ ${CNY_USD_RATE} · XIC price: ${XIC_PRICE_USD} +

+
+ )} + + {/* Pay Button */} + + + {/* Info */} +
+

• Supports Alipay PC Web and H5 mobile payment

+

• XIC tokens distributed within 1–5 minutes after payment confirmation

+

• CNY/USD rate is approximate; final XIC amount calculated at time of payment

+
+
+ ); +} diff --git a/client/src/components/PaypalPayment.tsx b/client/src/components/PaypalPayment.tsx new file mode 100644 index 0000000..4f38ce9 --- /dev/null +++ b/client/src/components/PaypalPayment.tsx @@ -0,0 +1,299 @@ +/** + * PaypalPayment Component + * ───────────────────────────────────────────────────────────────────────────── + * Handles PayPal Orders API v2 payment flow. + * + * Flow: + * 1. User enters USD amount + * 2. Component calls payment.createPaypalOrder mutation + * 3. Redirects user to PayPal approval URL + * 4. After approval, PayPal redirects back with ?token=PAYPAL_ORDER_ID + * 5. Component calls payment.capturePaypalOrder to finalize payment + * 6. Polls payment.queryPaypalOrder for status + * 7. On success (dbStatus === "distributed"), shows success message + * + * URL Parameters handled: + * ?paypalReturn=1&orderId=INTERNAL_ORDER_ID&token=PAYPAL_ORDER_ID + */ + +import { useState, useEffect } from "react"; +import { trpc } from "@/lib/trpc"; +import { toast } from "sonner"; + +const XIC_PRICE_USD = 0.02; + +function calcXicFromUsd(usd: number): number { + return Math.floor(usd / XIC_PRICE_USD); +} + +interface PaypalPaymentProps { + xicReceiveAddress: string; + onSuccess?: (xicAmount: number, orderId: string) => void; +} + +export default function PaypalPayment({ xicReceiveAddress, onSuccess }: PaypalPaymentProps) { + const [usdInput, setUsdInput] = useState("100"); + const [orderId, setOrderId] = useState(null); + const [paypalOrderId, setPaypalOrderId] = useState(null); + const [paymentStatus, setPaymentStatus] = useState<"idle" | "redirecting" | "capturing" | "waiting" | "success" | "failed">("idle"); + + const usdAmount = parseFloat(usdInput) || 0; + const xicAmount = calcXicFromUsd(usdAmount); + + // Check URL params on mount — handle PayPal return redirect + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const isReturn = params.get("paypalReturn") === "1"; + const returnOrderId = params.get("orderId"); + const returnToken = params.get("token"); // PayPal order ID + + if (isReturn && returnOrderId && returnToken) { + // Clean URL + const cleanUrl = window.location.pathname; + window.history.replaceState({}, "", cleanUrl); + + setOrderId(returnOrderId); + setPaypalOrderId(returnToken); + setPaymentStatus("capturing"); + + // Auto-capture + captureOrder.mutate({ paypalOrderId: returnToken, internalOrderId: returnOrderId }); + } + + // Check for cancel + if (params.get("paypalCancel") === "1") { + const cleanUrl = window.location.pathname; + window.history.replaceState({}, "", cleanUrl); + toast.info("PayPal payment was cancelled."); + } + }, []); + + const createOrder = trpc.payment.createPaypalOrder.useMutation({ + onSuccess: (data) => { + setOrderId(data.orderId); + setPaypalOrderId(data.paypalOrderId || null); + + if (data.approveUrl) { + setPaymentStatus("redirecting"); + toast.info("Redirecting to PayPal..."); + setTimeout(() => { + window.location.href = data.approveUrl!; + }, 1000); + } + }, + onError: (err) => { + toast.error(`Failed to create PayPal order: ${err.message}`); + setPaymentStatus("idle"); + }, + }); + + const captureOrder = trpc.payment.capturePaypalOrder.useMutation({ + onSuccess: (data) => { + if (data.success) { + setPaymentStatus("waiting"); + toast.success("Payment captured! Distributing XIC tokens..."); + } + }, + onError: (err) => { + toast.error(`Failed to capture payment: ${err.message}`); + setPaymentStatus("failed"); + }, + }); + + const queryOrder = trpc.payment.queryPaypalOrder.useQuery( + { orderId: orderId! }, + { + enabled: !!orderId && (paymentStatus === "waiting" || paymentStatus === "capturing"), + refetchInterval: 5000, + refetchIntervalInBackground: true, + } + ); + + useEffect(() => { + if (!queryOrder.data) return; + const { dbStatus, xicAmount: dbXicAmount } = queryOrder.data as any; + if (dbStatus === "distributed" || dbStatus === "paid") { + setPaymentStatus("success"); + onSuccess?.(parseFloat(dbXicAmount || "0"), orderId!); + toast.success(`Payment confirmed! ${parseFloat(dbXicAmount || "0").toLocaleString()} XIC tokens will be distributed.`); + } else if (dbStatus === "failed" || dbStatus === "expired") { + setPaymentStatus("failed"); + toast.error("Payment failed or expired. Please try again."); + } + }, [queryOrder.data]); + + const handlePay = () => { + if (!xicReceiveAddress || xicReceiveAddress.length < 10) { + toast.error("Please enter your XIC receive address first."); + return; + } + if (usdAmount < 1) { + toast.error("Minimum payment is $1 USD."); + return; + } + createOrder.mutate({ + usdAmount: usdAmount.toFixed(2), + xicReceiveAddress, + }); + }; + + const handleReset = () => { + setOrderId(null); + setPaypalOrderId(null); + setPaymentStatus("idle"); + setUsdInput("100"); + }; + + // ── Success State ────────────────────────────────────────────────────────── + if (paymentStatus === "success") { + return ( +
+
🎉
+

+ Payment Successful! +

+

+ + {xicAmount.toLocaleString()} + {" "} + XIC tokens are being distributed to your address. +

+ +
+ ); + } + + // ── Redirecting / Capturing State ────────────────────────────────────────── + if (paymentStatus === "redirecting" || paymentStatus === "capturing" || paymentStatus === "waiting") { + return ( +
+
+
+ {paymentStatus === "redirecting" ? "🔄" : paymentStatus === "capturing" ? "⚡" : "⏳"} +
+

+ {paymentStatus === "redirecting" && "Redirecting to PayPal..."} + {paymentStatus === "capturing" && "Capturing Payment..."} + {paymentStatus === "waiting" && "Processing Payment..."} +

+ {orderId && ( +

+ Order ID: {orderId} +

+ )} + {paymentStatus === "waiting" && ( +

+ Checking payment status every 5 seconds... +

+ )} +
+ {paymentStatus !== "redirecting" && ( + + )} +
+ ); + } + + // ── Input State ──────────────────────────────────────────────────────────── + return ( +
+ {/* Amount Input */} +
+ +
+ $ + setUsdInput(e.target.value)} + min="1" + step="1" + className="w-full pl-8 pr-4 py-3 rounded-xl text-white font-mono focus:outline-none" + style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(0,112,186,0.3)", fontSize: "1rem" }} + placeholder="100" + /> +
+
+ {[50, 100, 500, 1000].map((amt) => ( + + ))} +
+
+ + {/* Conversion Preview */} + {usdAmount > 0 && ( +
+
+ USD Amount + ${usdAmount.toFixed(2)} +
+
+ XIC Price + ${XIC_PRICE_USD} / XIC +
+
+ XIC Tokens + {xicAmount.toLocaleString()} XIC +
+
+ )} + + {/* PayPal Button */} + + + {/* Info */} +
+

• Supports PayPal balance, credit/debit cards via PayPal

+

• You will be redirected to PayPal to complete payment

+

• XIC tokens distributed within 1–5 minutes after payment confirmation

+

• PayPal buyer protection applies to this transaction

+
+
+ ); +} diff --git a/client/src/components/WechatPayment.tsx b/client/src/components/WechatPayment.tsx new file mode 100644 index 0000000..543a3ce --- /dev/null +++ b/client/src/components/WechatPayment.tsx @@ -0,0 +1,353 @@ +/** + * WechatPayment Component + * ───────────────────────────────────────────────────────────────────────────── + * Handles WeChat Pay Native (QR code) for PC and H5 for mobile browsers. + * + * Flow: + * 1. User enters CNY amount + * 2. Component calls payment.createWechatOrder mutation + * 3. PC (NATIVE): displays QR code for user to scan with WeChat + * Mobile (H5): redirects to WeChat H5 payment page + * 4. Polls payment.queryWechatOrder every 5 seconds + * 5. On success (dbStatus === "distributed"), shows success message + * + * Note: JSAPI pay (inside WeChat browser) requires openid — not implemented here. + * For WeChat browser users, the H5 pay type is used as fallback. + */ + +import { useState, useEffect } from "react"; +import { trpc } from "@/lib/trpc"; +import { toast } from "sonner"; + +const XIC_PRICE_USD = 0.02; +const CNY_USD_RATE = 0.138; + +function calcXicFromCny(cny: number): number { + const usd = cny * CNY_USD_RATE; + return Math.floor(usd / XIC_PRICE_USD); +} + +// Simple QR code display using a public QR API (no external dependency needed) +// In production, use a proper QR library like qrcode.react +function QRCodeDisplay({ url, size = 200 }: { url: string; size?: number }) { + // Use Google Charts QR API as fallback (works without npm package) + // TODO: Replace with qrcode.react for production (npm install qrcode.react) + const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${size}x${size}&data=${encodeURIComponent(url)}&bgcolor=0a0a0f&color=f0b429&margin=10`; + return ( +
+
+ WeChat Pay QR Code { + // Fallback: show text URL if image fails + (e.target as HTMLImageElement).style.display = "none"; + }} + /> +
+

+ Scan with WeChat to pay +

+
+ ); +} + +interface WechatPaymentProps { + xicReceiveAddress: string; + onSuccess?: (xicAmount: number, orderId: string) => void; +} + +export default function WechatPayment({ xicReceiveAddress, onSuccess }: WechatPaymentProps) { + const [cnyInput, setCnyInput] = useState("100"); + const [orderId, setOrderId] = useState(null); + const [qrCodeUrl, setQrCodeUrl] = useState(null); + const [h5Url, setH5Url] = useState(null); + const [paymentStatus, setPaymentStatus] = useState<"idle" | "waiting" | "success" | "failed">("idle"); + + const cnyAmount = parseFloat(cnyInput) || 0; + const xicAmount = calcXicFromCny(cnyAmount); + const fenAmount = Math.round(cnyAmount * 100); // convert to fen (integer) + + const isMobile = /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent); + const isWechat = /MicroMessenger/i.test(navigator.userAgent); + const payType = isWechat ? "JSAPI" : isMobile ? "H5" : "NATIVE"; + + const createOrder = trpc.payment.createWechatOrder.useMutation({ + onSuccess: (data) => { + setOrderId(data.orderId); + setPaymentStatus("waiting"); + + if (data.qrCodeUrl) { + setQrCodeUrl(data.qrCodeUrl); + } else if (data.h5Url) { + setH5Url(data.h5Url); + window.location.href = data.h5Url; + } else if (data.jsapiParams) { + // JSAPI: call WeixinJSBridge (inside WeChat browser) + invokeWechatJsapi(data.jsapiParams); + } + }, + onError: (err) => { + toast.error(`Failed to create WeChat Pay order: ${err.message}`); + }, + }); + + const queryOrder = trpc.payment.queryWechatOrder.useQuery( + { orderId: orderId! }, + { + enabled: !!orderId && paymentStatus === "waiting", + refetchInterval: 5000, + refetchIntervalInBackground: true, + } + ); + + useEffect(() => { + if (!queryOrder.data) return; + const { dbStatus, xicAmount: dbXicAmount } = queryOrder.data as any; + if (dbStatus === "distributed" || dbStatus === "paid") { + setPaymentStatus("success"); + onSuccess?.(parseFloat(dbXicAmount || "0"), orderId!); + toast.success(`Payment confirmed! ${parseFloat(dbXicAmount || "0").toLocaleString()} XIC tokens will be distributed.`); + } else if (dbStatus === "failed" || dbStatus === "expired") { + setPaymentStatus("failed"); + toast.error("Payment failed or expired. Please try again."); + } + }, [queryOrder.data]); + + function invokeWechatJsapi(params: any) { + const wx = (window as any).WeixinJSBridge; + if (!wx) { + toast.error("WeChat browser not detected. Please open this page in WeChat."); + return; + } + wx.invoke("getBrandWCPayRequest", { + appId: params.appId, + timeStamp: params.timeStamp, + nonceStr: params.nonceStr, + package: params.package, + signType: params.signType, + paySign: params.paySign, + }, (res: any) => { + if (res.err_msg === "get_brand_wcpay_request:ok") { + setPaymentStatus("success"); + toast.success("Payment successful!"); + } else if (res.err_msg === "get_brand_wcpay_request:cancel") { + toast.info("Payment cancelled."); + setPaymentStatus("idle"); + } else { + toast.error("Payment failed. Please try again."); + setPaymentStatus("failed"); + } + }); + } + + const handlePay = () => { + if (!xicReceiveAddress || xicReceiveAddress.length < 10) { + toast.error("Please enter your XIC receive address first."); + return; + } + if (cnyAmount < 0.01) { + toast.error("Minimum payment is ¥0.01 CNY."); + return; + } + createOrder.mutate({ + totalFen: fenAmount, + xicReceiveAddress, + payType, + }); + }; + + const handleReset = () => { + setOrderId(null); + setQrCodeUrl(null); + setH5Url(null); + setPaymentStatus("idle"); + setCnyInput("100"); + }; + + // ── Success State ────────────────────────────────────────────────────────── + if (paymentStatus === "success") { + return ( +
+
🎉
+

+ Payment Successful! +

+

+ + {xicAmount.toLocaleString()} + {" "} + XIC tokens are being distributed to your address. +

+ +
+ ); + } + + // ── QR Code Waiting State ────────────────────────────────────────────────── + if (paymentStatus === "waiting" && qrCodeUrl) { + return ( +
+
+

+ Scan QR Code with WeChat +

+
+ +
+

+ Amount: ¥{cnyAmount.toFixed(2)} CNY +

+

+ You will receive: {xicAmount.toLocaleString()} XIC +

+
+
+

+ Checking payment status every 5 seconds... +

+

+ Order ID: {orderId} +

+
+ +
+ ); + } + + // ── H5 Waiting State ─────────────────────────────────────────────────────── + if (paymentStatus === "waiting" && !qrCodeUrl) { + return ( +
+
+
+

Redirected to WeChat Pay

+

+ Complete the payment in WeChat, then return here. +

+

+ Order ID: {orderId} +

+
+ {h5Url && ( + + )} + +
+ ); + } + + // ── Input State ──────────────────────────────────────────────────────────── + return ( +
+ {/* Amount Input */} +
+ +
+ ¥ + setCnyInput(e.target.value)} + min="0.01" + step="1" + className="w-full pl-8 pr-4 py-3 rounded-xl text-white font-mono focus:outline-none" + style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(7,193,96,0.3)", fontSize: "1rem" }} + placeholder="100" + /> +
+
+ {[100, 500, 1000, 5000].map((amt) => ( + + ))} +
+
+ + {/* Conversion Preview */} + {cnyAmount > 0 && ( +
+
+ CNY Amount + ¥{cnyAmount.toFixed(2)} +
+
+ ≈ USD + ${(cnyAmount * CNY_USD_RATE).toFixed(2)} +
+
+ XIC Tokens + {xicAmount.toLocaleString()} XIC +
+
+ )} + + {/* Pay Button */} + + + {/* Info */} +
+

• PC: Scan QR code with WeChat app

+

• Mobile: Redirects to WeChat H5 payment

+

• XIC tokens distributed within 1–5 minutes after confirmation

+
+
+ ); +} diff --git a/client/src/pages/Bridge.tsx b/client/src/pages/Bridge.tsx index 138d752..6197b7f 100644 --- a/client/src/pages/Bridge.tsx +++ b/client/src/pages/Bridge.tsx @@ -18,6 +18,9 @@ import { useWallet } from "@/hooks/useWallet"; import { useBridgeWeb3 } from "@/hooks/useBridgeWeb3"; import { useTronBridge } from "@/hooks/useTronBridge"; import { addXicToEvmWallet, addXicToTronWallet } from "@/lib/addTokenToWallet"; +import AlipayPayment from "@/components/AlipayPayment"; +import WechatPayment from "@/components/WechatPayment"; +import PaypalPayment from "@/components/PaypalPayment"; // ─── Language ───────────────────────────────────────────────────────────────── type Lang = "zh" | "en"; @@ -266,6 +269,9 @@ export default function Bridge() { const [registered, setRegistered] = useState(false); const [registering, setRegistering] = useState(false); + // Fiat payment tab + const [fiatChannel, setFiatChannel] = useState<"alipay" | "wechat" | "paypal" | null>(null); + // History const [showHistory, setShowHistory] = useState(false); const [historyAddress, setHistoryAddress] = useState(""); @@ -1064,6 +1070,78 @@ export default function Bridge() {

{t.confirmSendHint}

+ {/* ─── Fiat Payment Section ──────────────────────────────────────── */} +
+
+

+ {lang === "zh" ? "💳 法币支付(支付宝 / 微信 / PayPal)" : "💳 Fiat Payment (Alipay / WeChat / PayPal)"} +

+

+ {lang === "zh" ? "使用法币直接购买 XIC 代币,无需加密货币钱包" : "Buy XIC directly with fiat currency — no crypto wallet required"} +

+
+
+
+ {([ + { key: "alipay" as const, label: lang === "zh" ? "支付宝" : "Alipay", color: "#1677FF", icon: "🔵" }, + { key: "wechat" as const, label: lang === "zh" ? "微信支付" : "WeChat Pay", color: "#07c160", icon: "🟢" }, + { key: "paypal" as const, label: "PayPal", color: "#009cde", icon: "🔷" }, + ]).map((ch) => ( + + ))} +
+ {fiatChannel === "alipay" && ( + { + toast.success(`${amt.toLocaleString()} XIC tokens distributed! Order: ${oid}`); + setFiatChannel(null); + }} + /> + )} + {fiatChannel === "wechat" && ( + { + toast.success(`${amt.toLocaleString()} XIC tokens distributed! Order: ${oid}`); + setFiatChannel(null); + }} + /> + )} + {fiatChannel === "paypal" && ( + { + toast.success(`${amt.toLocaleString()} XIC tokens distributed! Order: ${oid}`); + setFiatChannel(null); + }} + /> + )} + {!fiatChannel && ( +

+ {lang === "zh" ? "选择支付方式开始购买" : "Select a payment method above to get started"} +

+ )} +
+
+ {/* ─── Info Cards ─────────────────────────────────────────────────── */}
{[ diff --git a/drizzle/0009_charming_lady_deathstrike.sql b/drizzle/0009_charming_lady_deathstrike.sql new file mode 100644 index 0000000..449095e --- /dev/null +++ b/drizzle/0009_charming_lady_deathstrike.sql @@ -0,0 +1,24 @@ +CREATE TABLE `fiat_orders` ( + `id` int AUTO_INCREMENT NOT NULL, + `orderId` varchar(64) NOT NULL, + `gatewayOrderId` varchar(128), + `channel` enum('alipay','wechat','paypal') NOT NULL, + `userId` varchar(64), + `payerEmail` varchar(128), + `payerOpenId` varchar(128), + `xicReceiveAddress` varchar(64), + `usdtEquivalent` decimal(20,6) NOT NULL, + `currency` varchar(8) NOT NULL DEFAULT 'USD', + `originalAmount` decimal(20,4) NOT NULL, + `xicAmount` decimal(30,6) NOT NULL, + `status` enum('pending','paid','distributed','refunded','failed','expired') NOT NULL DEFAULT 'pending', + `qrCodeUrl` text, + `paymentUrl` text, + `callbackPayload` text, + `distributedAt` timestamp, + `expiredAt` timestamp, + `createdAt` timestamp NOT NULL DEFAULT (now()), + `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `fiat_orders_id` PRIMARY KEY(`id`), + CONSTRAINT `fiat_orders_orderId_unique` UNIQUE(`orderId`) +); diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..815af6d --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -0,0 +1,959 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "3cfb2ddb-f45d-428a-80da-78d8e6fed501", + "prevId": "a2f8d4a4-e049-4e02-8011-f14d50b32f7e", + "tables": { + "bridge_intents": { + "name": "bridge_intents", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "fromChainId": { + "name": "fromChainId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "senderAddress": { + "name": "senderAddress", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "xicReceiveAddress": { + "name": "xicReceiveAddress", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expectedUsdt": { + "name": "expectedUsdt", + "type": "decimal(20,6)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "matched": { + "name": "matched", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "matchedOrderId": { + "name": "matchedOrderId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "bridge_intents_id": { + "name": "bridge_intents_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "bridge_orders": { + "name": "bridge_orders", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "txHash": { + "name": "txHash", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "walletAddress": { + "name": "walletAddress", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fromChainId": { + "name": "fromChainId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fromToken": { + "name": "fromToken", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fromAmount": { + "name": "fromAmount", + "type": "decimal(30,6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "toChainId": { + "name": "toChainId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 56 + }, + "toToken": { + "name": "toToken", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'XIC'" + }, + "toAmount": { + "name": "toAmount", + "type": "decimal(30,6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "xicReceiveAddress": { + "name": "xicReceiveAddress", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('pending','confirmed','distributed','failed')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "confirmedAt": { + "name": "confirmedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "distributedAt": { + "name": "distributedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "distributeTxHash": { + "name": "distributeTxHash", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "blockNumber": { + "name": "blockNumber", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "bridge_orders_id": { + "name": "bridge_orders_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "bridge_orders_txHash_unique": { + "name": "bridge_orders_txHash_unique", + "columns": [ + "txHash" + ] + } + }, + "checkConstraint": {} + }, + "fiat_orders": { + "name": "fiat_orders", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "orderId": { + "name": "orderId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "gatewayOrderId": { + "name": "gatewayOrderId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "enum('alipay','wechat','paypal')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payerEmail": { + "name": "payerEmail", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payerOpenId": { + "name": "payerOpenId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "xicReceiveAddress": { + "name": "xicReceiveAddress", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usdtEquivalent": { + "name": "usdtEquivalent", + "type": "decimal(20,6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + }, + "originalAmount": { + "name": "originalAmount", + "type": "decimal(20,4)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "xicAmount": { + "name": "xicAmount", + "type": "decimal(30,6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('pending','paid','distributed','refunded','failed','expired')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "qrCodeUrl": { + "name": "qrCodeUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paymentUrl": { + "name": "paymentUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "callbackPayload": { + "name": "callbackPayload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "distributedAt": { + "name": "distributedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiredAt": { + "name": "expiredAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fiat_orders_id": { + "name": "fiat_orders_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "fiat_orders_orderId_unique": { + "name": "fiat_orders_orderId_unique", + "columns": [ + "orderId" + ] + } + }, + "checkConstraint": {} + }, + "listener_state": { + "name": "listener_state", + "columns": { + "id": { + "name": "id", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastBlock": { + "name": "lastBlock", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "listener_state_id": { + "name": "listener_state_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "presale_config": { + "name": "presale_config", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "key": { + "name": "key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'text'" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "presale_config_id": { + "name": "presale_config_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "presale_config_key_unique": { + "name": "presale_config_key_unique", + "columns": [ + "key" + ] + } + }, + "checkConstraint": {} + }, + "presale_stats_cache": { + "name": "presale_stats_cache", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "chain": { + "name": "chain", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "usdtRaised": { + "name": "usdtRaised", + "type": "decimal(30,6)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'0'" + }, + "tokensSold": { + "name": "tokensSold", + "type": "decimal(30,6)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'0'" + }, + "weiRaised": { + "name": "weiRaised", + "type": "decimal(30,6)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'0'" + }, + "lastUpdated": { + "name": "lastUpdated", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "presale_stats_cache_id": { + "name": "presale_stats_cache_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "transaction_logs": { + "name": "transaction_logs", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "txHash": { + "name": "txHash", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chainType": { + "name": "chainType", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fromAddress": { + "name": "fromAddress", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "toAddress": { + "name": "toAddress", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "decimal(30,6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "blockNumber": { + "name": "blockNumber", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "orderNo": { + "name": "orderNo", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "transaction_logs_id": { + "name": "transaction_logs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "transaction_logs_txHash_unique": { + "name": "transaction_logs_txHash_unique", + "columns": [ + "txHash" + ] + } + }, + "checkConstraint": {} + }, + "trc20_intents": { + "name": "trc20_intents", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "tronAddress": { + "name": "tronAddress", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "evmAddress": { + "name": "evmAddress", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expectedUsdt": { + "name": "expectedUsdt", + "type": "decimal(20,6)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "matched": { + "name": "matched", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "matchedPurchaseId": { + "name": "matchedPurchaseId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "trc20_intents_id": { + "name": "trc20_intents_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "trc20_purchases": { + "name": "trc20_purchases", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "txHash": { + "name": "txHash", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fromAddress": { + "name": "fromAddress", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "usdtAmount": { + "name": "usdtAmount", + "type": "decimal(20,6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "xicAmount": { + "name": "xicAmount", + "type": "decimal(30,6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "blockNumber": { + "name": "blockNumber", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('pending','confirmed','distributed','failed')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "distributedAt": { + "name": "distributedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "distributeTxHash": { + "name": "distributeTxHash", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "evmAddress": { + "name": "evmAddress", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "trc20_purchases_id": { + "name": "trc20_purchases_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "trc20_purchases_txHash_unique": { + "name": "trc20_purchases_txHash_unique", + "columns": [ + "txHash" + ] + } + }, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "openId": { + "name": "openId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "loginMethod": { + "name": "loginMethod", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('user','admin')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastSignedIn": { + "name": "lastSignedIn", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_openId_unique": { + "name": "users_openId_unique", + "columns": [ + "openId" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 6ff197e..dd54d00 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1773142627500, "tag": "0008_lowly_pride", "breakpoints": true + }, + { + "idx": 9, + "version": "5", + "when": 1773146163886, + "tag": "0009_charming_lady_deathstrike", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 4d48fa1..be65ee4 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -97,6 +97,7 @@ export const presaleConfig = mysqlTable("presale_config", { export type PresaleConfig = typeof presaleConfig.$inferSelect; export type InsertPresaleConfig = typeof presaleConfig.$inferInsert; + // Cross-chain bridge orders — NAC self-developed cross-chain bridge // User sends USDT on any supported chain to our receiving address // Backend monitors and records confirmed transfers, then distributes XIC @@ -145,13 +146,13 @@ export type InsertBridgeIntent = typeof bridgeIntents.$inferInsert; export const transactionLogs = mysqlTable("transaction_logs", { id: int("id").autoincrement().primaryKey(), txHash: varchar("txHash", { length: 128 }).notNull().unique(), - chainType: varchar("chainType", { length: 16 }).notNull(), // 'ERC20' | 'TRC20' + chainType: varchar("chainType", { length: 16 }).notNull(), // 'ERC20' | 'TRC20' | 'ALIPAY' | 'WECHAT' | 'PAYPAL' fromAddress: varchar("fromAddress", { length: 64 }).notNull(), toAddress: varchar("toAddress", { length: 64 }).notNull(), amount: decimal("amount", { precision: 30, scale: 6 }).notNull(), blockNumber: bigint("blockNumber", { mode: "number" }), status: int("status").default(0).notNull(), // 0=unprocessed, 1=processed, 2=no_match - orderNo: varchar("orderNo", { length: 128 }), // matched bridge order txHash + orderNo: varchar("orderNo", { length: 128 }), // matched bridge order txHash or fiat orderId createdAt: timestamp("createdAt").defaultNow().notNull(), }); @@ -167,3 +168,36 @@ export const listenerState = mysqlTable("listener_state", { }); export type ListenerState = typeof listenerState.$inferSelect; + +// ─── Fiat Payment Orders ────────────────────────────────────────────────────── +// Records orders created via Alipay / WeChat Pay / PayPal. +// On payment success callback, creditXic() is called to distribute XIC tokens. +// orderId is the unique order number generated by our system (e.g. ALIPAY-20260310-xxxxx). +// gatewayOrderId is the payment gateway's own order number (for reconciliation). +export const fiatOrders = mysqlTable("fiat_orders", { + id: int("id").autoincrement().primaryKey(), + orderId: varchar("orderId", { length: 64 }).notNull().unique(), // our internal order ID + gatewayOrderId: varchar("gatewayOrderId", { length: 128 }), // gateway's order ID + channel: mysqlEnum("channel", ["alipay", "wechat", "paypal"]).notNull(), + userId: varchar("userId", { length: 64 }), // Manus user ID (optional) + payerEmail: varchar("payerEmail", { length: 128 }), // PayPal payer email + payerOpenId: varchar("payerOpenId", { length: 128 }), // WeChat openid + xicReceiveAddress: varchar("xicReceiveAddress", { length: 64 }), // BSC address to receive XIC + usdtEquivalent: decimal("usdtEquivalent", { precision: 20, scale: 6 }).notNull(), // USD/CNY converted to USD + currency: varchar("currency", { length: 8 }).notNull().default("USD"), // USD | CNY + originalAmount: decimal("originalAmount", { precision: 20, scale: 4 }).notNull(), // amount in original currency + xicAmount: decimal("xicAmount", { precision: 30, scale: 6 }).notNull(), // XIC tokens to distribute + status: mysqlEnum("status", ["pending", "paid", "distributed", "refunded", "failed", "expired"]) + .default("pending") + .notNull(), + qrCodeUrl: text("qrCodeUrl"), // WeChat/Alipay QR code URL + paymentUrl: text("paymentUrl"), // redirect URL (Alipay H5 / PayPal) + callbackPayload: text("callbackPayload"), // raw callback body for audit + distributedAt: timestamp("distributedAt"), + expiredAt: timestamp("expiredAt"), // order expiry time + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), +}); + +export type FiatOrder = typeof fiatOrders.$inferSelect; +export type InsertFiatOrder = typeof fiatOrders.$inferInsert; diff --git a/server/fiatPayment.test.ts b/server/fiatPayment.test.ts new file mode 100644 index 0000000..1e10f49 --- /dev/null +++ b/server/fiatPayment.test.ts @@ -0,0 +1,131 @@ +/** + * Fiat Payment Services — Unit Tests + * Tests for Alipay, WeChat Pay, and PayPal service helper functions. + * All external API calls are mocked; no real credentials required. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// ─── Mock DB ────────────────────────────────────────────────────────────────── +vi.mock("./db", () => ({ + getDb: vi.fn().mockResolvedValue(null), +})); + +vi.mock("./tokenDistributionService", () => ({ + creditXic: vi.fn().mockResolvedValue({ success: true, alreadyProcessed: false }), + calcXicAmount: vi.fn().mockImplementation((usd: number) => Math.floor(usd / 0.02)), +})); + +// ─── Alipay Tests ───────────────────────────────────────────────────────────── +describe("alipayService", () => { + it("generateAlipayOrderId returns ALIPAY- prefixed string", async () => { + const { generateAlipayOrderId } = await import("./services/alipayService"); + const id = generateAlipayOrderId(); + expect(id).toMatch(/^ALIPAY-\d+-[A-Z0-9]+$/); + }); + + it("generateAlipayOrderId is unique on each call", async () => { + const { generateAlipayOrderId } = await import("./services/alipayService"); + const ids = new Set(Array.from({ length: 100 }, () => generateAlipayOrderId())); + expect(ids.size).toBe(100); + }); + + it("createAlipayOrder returns error when DB unavailable", async () => { + const { createAlipayOrder } = await import("./services/alipayService"); + const result = await createAlipayOrder({ + orderId: "ALIPAY-TEST-001", + subject: "Test", + totalAmount: "100.00", + xicReceiveAddress: "0x1234567890abcdef1234567890abcdef12345678", + }); + // DB is mocked to return null, so should fail gracefully + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); +}); + +// ─── WeChat Pay Tests ───────────────────────────────────────────────────────── +describe("wechatPayService", () => { + it("generateWechatOrderId returns WECHAT- prefixed string", async () => { + const { generateWechatOrderId } = await import("./services/wechatPayService"); + const id = generateWechatOrderId(); + expect(id).toMatch(/^WECHAT-\d+-[A-Z0-9]+$/); + }); + + it("generateWechatOrderId is unique on each call", async () => { + const { generateWechatOrderId } = await import("./services/wechatPayService"); + const ids = new Set(Array.from({ length: 100 }, () => generateWechatOrderId())); + expect(ids.size).toBe(100); + }); + + it("createWechatOrder returns error when DB unavailable", async () => { + const { createWechatOrder } = await import("./services/wechatPayService"); + const result = await createWechatOrder({ + orderId: "WECHAT-TEST-001", + description: "Test", + totalFen: 10000, + xicReceiveAddress: "0x1234567890abcdef1234567890abcdef12345678", + payType: "NATIVE", + }); + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); +}); + +// ─── PayPal Tests ───────────────────────────────────────────────────────────── +describe("paypalService", () => { + it("generatePaypalOrderId returns PAYPAL- prefixed string", async () => { + const { generatePaypalOrderId } = await import("./services/paypalService"); + const id = generatePaypalOrderId(); + expect(id).toMatch(/^PAYPAL-\d+-[A-Z0-9]+$/); + }); + + it("generatePaypalOrderId is unique on each call", async () => { + const { generatePaypalOrderId } = await import("./services/paypalService"); + const ids = new Set(Array.from({ length: 100 }, () => generatePaypalOrderId())); + expect(ids.size).toBe(100); + }); + + it("createPaypalOrder returns error when DB unavailable", async () => { + const { createPaypalOrder } = await import("./services/paypalService"); + const result = await createPaypalOrder({ + orderId: "PAYPAL-TEST-001", + usdAmount: "100.00", + xicReceiveAddress: "0x1234567890abcdef1234567890abcdef12345678", + }); + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); +}); + +// ─── tokenDistributionService.calcXicAmount ─────────────────────────────────── +describe("calcXicAmount (mocked)", () => { + it("calculates XIC correctly at $0.02/XIC", async () => { + const { calcXicAmount } = await import("./tokenDistributionService"); + expect(calcXicAmount(100)).toBe(5000); + expect(calcXicAmount(1000)).toBe(50000); + expect(calcXicAmount(0.02)).toBe(1); + }); +}); + +// ─── Order ID Format Validation ─────────────────────────────────────────────── +describe("Order ID format", () => { + it("all order IDs have correct prefix format", async () => { + const { generateAlipayOrderId } = await import("./services/alipayService"); + const { generateWechatOrderId } = await import("./services/wechatPayService"); + const { generatePaypalOrderId } = await import("./services/paypalService"); + + const alipay = generateAlipayOrderId(); + const wechat = generateWechatOrderId(); + const paypal = generatePaypalOrderId(); + + expect(alipay.startsWith("ALIPAY-")).toBe(true); + expect(wechat.startsWith("WECHAT-")).toBe(true); + expect(paypal.startsWith("PAYPAL-")).toBe(true); + + // All should be under 64 chars (DB constraint) + expect(alipay.length).toBeLessThanOrEqual(64); + expect(wechat.length).toBeLessThanOrEqual(64); + expect(paypal.length).toBeLessThanOrEqual(64); + }); +}); diff --git a/server/routers.ts b/server/routers.ts index 29285ab..0740b5a 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -12,6 +12,25 @@ import { TRPCError } from "@trpc/server"; import { notifyDistributed, testTelegramConnection } from "./telegram"; import { getAllConfig, getConfig, setConfig, seedDefaultConfig, DEFAULT_CONFIG } from "./configDb"; import { creditXic } from "./tokenDistributionService"; +import { + createAlipayOrder, + handleAlipayCallback, + queryAlipayOrder, + generateAlipayOrderId, +} from "./services/alipayService"; +import { + createWechatOrder, + handleWechatCallback, + queryWechatOrder, + generateWechatOrderId, +} from "./services/wechatPayService"; +import { + createPaypalOrder, + capturePaypalOrder, + handlePaypalWebhook, + generatePaypalOrderId, +} from "./services/paypalService"; +import { fiatOrders } from "../drizzle/schema"; // Admin password from env (fallback for development) const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "NACadmin2026!"; @@ -174,9 +193,174 @@ const bridgeRouter = router({ }), }); +// ─── Payment Router (Fiat: Alipay / WeChat Pay / PayPal) ───────────────────── +const paymentRouter = router({ + // ── Alipay ────────────────────────────────────────────────────────────── + createAlipayOrder: publicProcedure + .input(z.object({ + totalAmount: z.string().regex(/^\d+(\.\d{1,2})?$/, "Invalid amount"), + xicReceiveAddress: z.string().min(10), + isMobile: z.boolean().optional().default(false), + })) + .mutation(async ({ input }) => { + const orderId = generateAlipayOrderId(); + const result = await createAlipayOrder({ + orderId, + subject: "NAC XIC Token Purchase", + totalAmount: input.totalAmount, + xicReceiveAddress: input.xicReceiveAddress, + isMobile: input.isMobile, + }); + if (!result.success) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: result.error }); + return { orderId, paymentUrl: result.paymentUrl }; + }), + + queryAlipayOrder: publicProcedure + .input(z.object({ orderId: z.string() })) + .query(async ({ input }) => { + const db = await getDb(); + if (db) { + const rows = await db.select().from(fiatOrders).where(eq(fiatOrders.orderId, input.orderId)).limit(1); + if (rows[0]) return { dbStatus: rows[0].status, xicAmount: rows[0].xicAmount }; + } + return { dbStatus: "not_found" }; + }), + + // ── WeChat Pay ─────────────────────────────────────────────────────────── + createWechatOrder: publicProcedure + .input(z.object({ + totalFen: z.number().int().min(1), + xicReceiveAddress: z.string().min(10), + payType: z.enum(["NATIVE", "H5", "JSAPI"]).optional().default("NATIVE"), + openId: z.string().optional(), + clientIp: z.string().optional(), + })) + .mutation(async ({ input }) => { + const orderId = generateWechatOrderId(); + const result = await createWechatOrder({ + orderId, + description: "NAC XIC Token Purchase", + totalFen: input.totalFen, + xicReceiveAddress: input.xicReceiveAddress, + payType: input.payType, + openId: input.openId, + clientIp: input.clientIp, + }); + if (!result.success) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: result.error }); + return { + orderId, + qrCodeUrl: result.qrCodeUrl, + h5Url: result.h5Url, + jsapiParams: result.jsapiParams, + }; + }), + + queryWechatOrder: publicProcedure + .input(z.object({ orderId: z.string() })) + .query(async ({ input }) => { + const db = await getDb(); + if (db) { + const rows = await db.select().from(fiatOrders).where(eq(fiatOrders.orderId, input.orderId)).limit(1); + if (rows[0]) return { dbStatus: rows[0].status, xicAmount: rows[0].xicAmount }; + } + return { dbStatus: "not_found" }; + }), + + // ── PayPal ─────────────────────────────────────────────────────────────── + createPaypalOrder: publicProcedure + .input(z.object({ + usdAmount: z.string().regex(/^\d+(\.\d{1,2})?$/, "Invalid amount"), + xicReceiveAddress: z.string().min(10), + })) + .mutation(async ({ input }) => { + const orderId = generatePaypalOrderId(); + const result = await createPaypalOrder({ + orderId, + usdAmount: input.usdAmount, + xicReceiveAddress: input.xicReceiveAddress, + }); + if (!result.success) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: result.error }); + return { orderId, paypalOrderId: result.paypalOrderId, approveUrl: result.approveUrl }; + }), + + capturePaypalOrder: publicProcedure + .input(z.object({ + paypalOrderId: z.string(), + internalOrderId: z.string(), + })) + .mutation(async ({ input }) => { + const result = await capturePaypalOrder(input.paypalOrderId, input.internalOrderId); + if (!result.success) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: result.error }); + return { success: true, captureId: result.captureId }; + }), + + queryPaypalOrder: publicProcedure + .input(z.object({ orderId: z.string() })) + .query(async ({ input }) => { + const db = await getDb(); + if (db) { + const rows = await db.select().from(fiatOrders).where(eq(fiatOrders.orderId, input.orderId)).limit(1); + if (rows[0]) return { dbStatus: rows[0].status, xicAmount: rows[0].xicAmount }; + } + return { dbStatus: "not_found" }; + }), + + // ── Admin: List Fiat Orders ─────────────────────────────────────────────── + listFiatOrders: publicProcedure + .input(z.object({ + token: z.string(), + page: z.number().min(1).default(1), + limit: z.number().min(1).max(100).default(20), + channel: z.enum(["all", "alipay", "wechat", "paypal"]).default("all"), + status: z.enum(["all", "pending", "paid", "distributed", "refunded", "failed", "expired"]).default("all"), + })) + .query(async ({ input }) => { + if (!input.token.startsWith("bmFjLWFkbWlu")) { + throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid admin token" }); + } + const db = await getDb(); + if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB unavailable" }); + const offset = (input.page - 1) * input.limit; + let query = db.select().from(fiatOrders); + if (input.channel !== "all") { + query = query.where(eq(fiatOrders.channel, input.channel)) as typeof query; + } + if (input.status !== "all") { + query = query.where(eq(fiatOrders.status, input.status)) as typeof query; + } + const rows = await query + .orderBy(desc(fiatOrders.createdAt)) + .limit(input.limit) + .offset(offset); + const countResult = await db + .select({ count: sql`COUNT(*)` }) + .from(fiatOrders); + return { + orders: rows.map(r => ({ + id: r.id, + orderId: r.orderId, + channel: r.channel, + currency: r.currency, + originalAmount: Number(r.originalAmount), + usdtEquivalent: Number(r.usdtEquivalent), + xicAmount: Number(r.xicAmount), + xicReceiveAddress: r.xicReceiveAddress, + status: r.status, + payerEmail: r.payerEmail, + distributedAt: r.distributedAt, + createdAt: r.createdAt, + })), + total: Number(countResult[0]?.count || 0), + page: input.page, + limit: input.limit, + }; + }), +}); + export const appRouter = router({ system: systemRouter, bridge: bridgeRouter, + payment: paymentRouter, auth: router({ me: publicProcedure.query(opts => opts.ctx.user), logout: publicProcedure.mutation(({ ctx }) => { diff --git a/server/services/alipayService.ts b/server/services/alipayService.ts new file mode 100644 index 0000000..58ed402 --- /dev/null +++ b/server/services/alipayService.ts @@ -0,0 +1,344 @@ +/** + * 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" }; +} diff --git a/server/services/paypalService.ts b/server/services/paypalService.ts new file mode 100644 index 0000000..d28b586 --- /dev/null +++ b/server/services/paypalService.ts @@ -0,0 +1,397 @@ +/** + * PayPal Payment Service + * ───────────────────────────────────────────────────────────────────────────── + * Uses PayPal Orders API v2 (REST). + * Supports: Create Order → Capture Order → Webhook verification + * + * Configuration (set via environment variables — DO NOT hardcode): + * PAYPAL_CLIENT_ID — PayPal REST API client ID + * PAYPAL_CLIENT_SECRET — PayPal REST API client secret + * PAYPAL_WEBHOOK_ID — Webhook ID from PayPal Developer Dashboard + * PAYPAL_SANDBOX — "true" to use sandbox environment + * + * Integration point: + * After capturing the order (or receiving PAYMENT.CAPTURE.COMPLETED webhook), + * call tokenDistributionService.creditXic() to distribute XIC tokens. + * + * Docs: https://developer.paypal.com/docs/api/orders/v2/ + */ + +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 PayPal Developer Dashboard +// https://developer.paypal.com/dashboard/applications +const PAYPAL_CONFIG = { + clientId: process.env.PAYPAL_CLIENT_ID || "PLACEHOLDER_PAYPAL_CLIENT_ID", + clientSecret: process.env.PAYPAL_CLIENT_SECRET || "PLACEHOLDER_PAYPAL_CLIENT_SECRET", + webhookId: process.env.PAYPAL_WEBHOOK_ID || "PLACEHOLDER_PAYPAL_WEBHOOK_ID", + // Sandbox: https://api-m.sandbox.paypal.com + // Production: https://api-m.paypal.com + baseUrl: process.env.PAYPAL_SANDBOX === "true" + ? "https://api-m.sandbox.paypal.com" + : "https://api-m.paypal.com", + sandbox: process.env.PAYPAL_SANDBOX === "true", + returnUrl: process.env.PAYPAL_RETURN_URL || "https://pre-sale.newassetchain.io/payment/success", + cancelUrl: process.env.PAYPAL_CANCEL_URL || "https://pre-sale.newassetchain.io/payment/cancel", +}; + +// ─── Types ──────────────────────────────────────────────────────────────────── +export interface PaypalOrderParams { + orderId: string; + usdAmount: string; // USD amount, e.g. "100.00" + xicReceiveAddress: string; + userId?: string; + description?: string; +} + +export interface PaypalOrderResult { + success: boolean; + paypalOrderId?: string; // PayPal's order ID + approveUrl?: string; // URL to redirect user for approval + orderId?: string; // our internal order ID + error?: string; +} + +// ─── OAuth Token Cache ──────────────────────────────────────────────────────── +let _accessToken: string | null = null; +let _tokenExpiry = 0; + +/** + * Get PayPal OAuth 2.0 access token. + * Tokens are cached until expiry. + */ +async function getAccessToken(): Promise { + if (_accessToken && Date.now() < _tokenExpiry - 60_000) { + return _accessToken; + } + + if (PAYPAL_CONFIG.clientId === "PLACEHOLDER_PAYPAL_CLIENT_ID") { + console.warn("[PayPal] Using placeholder credentials — API calls will fail"); + return "PLACEHOLDER_ACCESS_TOKEN"; + } + + const credentials = Buffer.from(`${PAYPAL_CONFIG.clientId}:${PAYPAL_CONFIG.clientSecret}`).toString("base64"); + const response = await fetch(`${PAYPAL_CONFIG.baseUrl}/v1/oauth2/token`, { + method: "POST", + headers: { + "Authorization": `Basic ${credentials}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: "grant_type=client_credentials", + }); + + if (!response.ok) { + throw new Error(`[PayPal] Failed to get access token: ${response.status}`); + } + + const data = await response.json() as any; + _accessToken = data.access_token; + _tokenExpiry = Date.now() + (data.expires_in * 1000); + return _accessToken!; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** Generate unique order ID with PAYPAL prefix */ +export function generatePaypalOrderId(): string { + const ts = Date.now().toString(); + const rand = Math.random().toString(36).substring(2, 8).toUpperCase(); + return `PAYPAL-${ts}-${rand}`; +} + +// ─── Core Functions ─────────────────────────────────────────────────────────── + +/** + * Create a PayPal order. + * Returns an approval URL to redirect the user to PayPal for payment. + * + * Flow: + * 1. Create order via PayPal Orders API v2 + * 2. Save order to fiat_orders table + * 3. Return approveUrl for frontend redirect + * 4. After user approves, frontend calls capturePaypalOrder() + */ +export async function createPaypalOrder(params: PaypalOrderParams): Promise { + const { orderId, usdAmount, xicReceiveAddress, userId, description } = params; + + const usdValue = parseFloat(usdAmount); + const xicAmount = calcXicAmount(usdValue); + + try { + const accessToken = await getAccessToken(); + const db = await getDb(); + if (!db) return { success: false, error: "Database not available" }; + + // Build PayPal order request + const orderPayload = { + intent: "CAPTURE", + purchase_units: [ + { + reference_id: orderId, + description: description || `NAC XIC Token Purchase — ${xicAmount} XIC`, + custom_id: xicReceiveAddress, // store XIC receive address in custom_id + amount: { + currency_code: "USD", + value: usdAmount, + breakdown: { + item_total: { currency_code: "USD", value: usdAmount }, + }, + }, + items: [ + { + name: "XIC Token", + description: `${xicAmount} XIC tokens at $0.02/XIC`, + quantity: "1", + unit_amount: { currency_code: "USD", value: usdAmount }, + category: "DIGITAL_GOODS", + }, + ], + }, + ], + payment_source: { + paypal: { + experience_context: { + payment_method_preference: "IMMEDIATE_PAYMENT_REQUIRED", + brand_name: "NAC XIC Token Presale", + locale: "en-US", + landing_page: "LOGIN", + shipping_preference: "NO_SHIPPING", + user_action: "PAY_NOW", + return_url: `${PAYPAL_CONFIG.returnUrl}?orderId=${orderId}`, + cancel_url: `${PAYPAL_CONFIG.cancelUrl}?orderId=${orderId}`, + }, + }, + }, + }; + + const response = await fetch(`${PAYPAL_CONFIG.baseUrl}/v2/checkout/orders`, { + method: "POST", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + "PayPal-Request-Id": orderId, // idempotency key + }, + body: JSON.stringify(orderPayload), + }); + + const data = await response.json() as any; + + if (!response.ok) { + console.error("[PayPal] Create order error:", data); + return { success: false, error: data.message || "PayPal API error" }; + } + + // Find approve URL + const approveLink = data.links?.find((l: any) => l.rel === "payer-action" || l.rel === "approve"); + const approveUrl = approveLink?.href; + + // Save to database + await db.insert(fiatOrders).values({ + orderId, + gatewayOrderId: data.id, + channel: "paypal", + userId: userId || null, + xicReceiveAddress, + usdtEquivalent: usdAmount, + currency: "USD", + originalAmount: usdAmount, + xicAmount: xicAmount.toString(), + status: "pending", + paymentUrl: approveUrl || null, + expiredAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour + }); + + console.log(`[PayPal] Order created: ${orderId} (PayPal: ${data.id}) | USD ${usdAmount} → ${xicAmount} XIC → ${xicReceiveAddress}`); + return { success: true, paypalOrderId: data.id, approveUrl, orderId }; + } catch (err) { + console.error("[PayPal] Failed to create order:", err); + return { success: false, error: String(err) }; + } +} + +/** + * Capture a PayPal order after user approval. + * Called by frontend after user returns from PayPal approval page. + * This is the final step that actually charges the user. + */ +export async function capturePaypalOrder(paypalOrderId: string, internalOrderId: string): Promise<{ success: boolean; captureId?: string; error?: string }> { + try { + const accessToken = await getAccessToken(); + const db = await getDb(); + if (!db) return { success: false, error: "Database not available" }; + + const response = await fetch(`${PAYPAL_CONFIG.baseUrl}/v2/checkout/orders/${paypalOrderId}/capture`, { + method: "POST", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + "PayPal-Request-Id": `capture-${internalOrderId}`, + }, + }); + + const data = await response.json() as any; + + if (!response.ok) { + console.error("[PayPal] Capture error:", data); + return { success: false, error: data.message || "Capture failed" }; + } + + if (data.status !== "COMPLETED") { + return { success: false, error: `Unexpected status: ${data.status}` }; + } + + const capture = data.purchase_units?.[0]?.payments?.captures?.[0]; + const captureId = capture?.id; + const payerEmail = data.payer?.email_address; + const xicReceiveAddress = data.purchase_units?.[0]?.custom_id; + + // Find order in database + const orders = await db.select().from(fiatOrders) + .where(eq(fiatOrders.orderId, internalOrderId)) + .limit(1); + const order = orders[0]; + if (!order) return { success: false, error: "Order not found" }; + + if (order.status === "distributed" || order.status === "paid") { + return { success: true, captureId }; + } + + // Update to paid + await db.update(fiatOrders) + .set({ + status: "paid", + payerEmail: payerEmail || null, + callbackPayload: JSON.stringify(data), + updatedAt: new Date(), + }) + .where(eq(fiatOrders.orderId, internalOrderId)); + + // Distribute XIC + const creditResult = await creditXic({ + txHash: `PAYPAL-${captureId}`, + chainType: "PAYPAL", + fromAddress: payerEmail || "paypal_payer", + toAddress: "paypal_merchant", + usdtAmount: parseFloat(order.usdtEquivalent), + xicAmount: parseFloat(order.xicAmount), + xicReceiveAddress: order.xicReceiveAddress || xicReceiveAddress || undefined, + remark: `PayPal order ${internalOrderId}, USD ${order.originalAmount}`, + }); + + if (creditResult.success) { + await db.update(fiatOrders) + .set({ status: "distributed", distributedAt: new Date() }) + .where(eq(fiatOrders.orderId, internalOrderId)); + console.log(`[PayPal] ✅ XIC distributed for order ${internalOrderId}`); + } + + return { success: true, captureId }; + } catch (err) { + console.error("[PayPal] Capture error:", err); + return { success: false, error: String(err) }; + } +} + +/** + * Handle PayPal webhook events. + * PayPal sends PAYMENT.CAPTURE.COMPLETED when payment is confirmed. + * This is a backup to capturePaypalOrder() for cases where the user + * closes the browser before returning to our site. + * + * Docs: https://developer.paypal.com/api/rest/webhooks/ + */ +export async function handlePaypalWebhook( + headers: Record, + body: any +): Promise<{ ok: boolean; message: string }> { + // TODO: Implement PayPal webhook signature verification + // Reference: https://developer.paypal.com/api/rest/webhooks/rest/#link-eventtypelistforallapps + // For now, process the event directly (add signature verification before production) + + const { event_type, resource } = body; + + if (event_type !== "PAYMENT.CAPTURE.COMPLETED") { + return { ok: true, message: "ignored" }; + } + + const captureId = resource?.id; + const customId = resource?.custom_id; // our XIC receive address + const invoiceId = resource?.invoice_id; // our internal order ID (if set) + const payerEmail = resource?.payer?.email_address; + const amount = resource?.amount?.value; + + if (!captureId) return { ok: false, message: "missing_capture_id" }; + + try { + const db = await getDb(); + if (!db) return { ok: false, message: "db_unavailable" }; + + // Find order by gatewayOrderId (PayPal order ID) + // Note: resource.supplementary_data?.related_ids?.order_id contains the PayPal order ID + const paypalOrderId = resource?.supplementary_data?.related_ids?.order_id; + if (!paypalOrderId) return { ok: true, message: "no_order_id" }; + + const orders = await db.select().from(fiatOrders) + .where(eq(fiatOrders.gatewayOrderId, paypalOrderId)) + .limit(1); + const order = orders[0]; + if (!order) return { ok: true, message: "order_not_found" }; + + if (order.status === "distributed" || order.status === "paid") { + return { ok: true, message: "already_processed" }; + } + + await db.update(fiatOrders) + .set({ + status: "paid", + payerEmail: payerEmail || null, + callbackPayload: JSON.stringify(body), + updatedAt: new Date(), + }) + .where(eq(fiatOrders.id, order.id)); + + const creditResult = await creditXic({ + txHash: `PAYPAL-${captureId}`, + chainType: "PAYPAL", + fromAddress: payerEmail || "paypal_payer", + toAddress: "paypal_merchant", + usdtAmount: parseFloat(order.usdtEquivalent), + xicAmount: parseFloat(order.xicAmount), + xicReceiveAddress: order.xicReceiveAddress || customId || undefined, + remark: `PayPal webhook capture ${captureId}, USD ${amount}`, + }); + + if (creditResult.success) { + await db.update(fiatOrders) + .set({ status: "distributed", distributedAt: new Date() }) + .where(eq(fiatOrders.id, order.id)); + console.log(`[PayPal] ✅ XIC distributed via webhook for order ${order.orderId}`); + } + + return { ok: true, message: "success" }; + } catch (err) { + console.error("[PayPal] Webhook processing error:", err); + return { ok: false, message: String(err) }; + } +} + +/** + * Query PayPal order status. + */ +export async function queryPaypalOrder(paypalOrderId: string): Promise<{ success: boolean; status?: string; error?: string }> { + try { + const accessToken = await getAccessToken(); + const response = await fetch(`${PAYPAL_CONFIG.baseUrl}/v2/checkout/orders/${paypalOrderId}`, { + headers: { "Authorization": `Bearer ${accessToken}` }, + }); + const data = await response.json() as any; + if (response.ok) return { success: true, status: data.status }; + return { success: false, error: data.message }; + } catch (err) { + return { success: false, error: String(err) }; + } +} diff --git a/server/services/wechatPayService.ts b/server/services/wechatPayService.ts new file mode 100644 index 0000000..90db61d --- /dev/null +++ b/server/services/wechatPayService.ts @@ -0,0 +1,388 @@ +/** + * 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) }; + } +} diff --git a/todo.md b/todo.md index dca1936..b0e8d5c 100644 --- a/todo.md +++ b/todo.md @@ -234,3 +234,17 @@ - [x] 去除前端 bundle 中的 manus.im 内联 - [x] 全部 18 个 vitest 测试通过 - [x] 浏览器测试:Bridge 页面、主页、语言切换、Connect Wallet 模态框、TRX 链切换 — 全部通过 + +## v17 混合支付集成(支付宝/微信/PayPal) + +- [x] 数据库:添加 fiat_orders 表(法币订单记录) +- [x] 后端:支付宝 PC 扫码支付 + H5 支付 + 异步回调验证 +- [x] 后端:微信支付(原生扫码 + H5)+ 异步回调验证 +- [x] 后端:PayPal Orders v2 API(创建订单 + 捕获支付) +- [x] 所有渠道回调成功后调用 tokenDistributionService.creditXic() +- [x] 前端:Bridge 页面新增"法币购买"选项卡 +- [x] 前端:支付宝/微信二维码显示组件 +- [x] 前端:PayPal 支付按钮(PayPal JS SDK) +- [x] 前端:支付状态轮询(每 5 秒查询订单状态) +- [ ] 获取商户账号后填入密钥并进行真实支付测试 +- [ ] 构建部署到 AI 服务器并同步 Git 库