Checkpoint: v17 混合支付框架完整实现:

- 数据库:fiat_orders 表(支付宝/微信/PayPal 订单记录)
- 后端服务:alipayService.ts / wechatPayService.ts / paypalService.ts(含商户密钥预留位置)
- 后端路由:payment 路由集成(创建/回调/查询 10个接口)
- 前端组件:AlipayPayment.tsx / WechatPayment.tsx / PaypalPayment.tsx
- Bridge.tsx 集成法币支付选项卡(三键切换)
- 所有渠道回调调用 tokenDistributionService.creditXic()
- 13个环境变量预留(ALIPAY_APP_ID/WECHAT_MCH_ID/PAYPAL_CLIENT_ID 等)
- 29/29 Vitest 测试通过
This commit is contained in:
Manus 2026-03-10 09:05:36 -04:00
parent d24d39e2bf
commit 3589e67e33
14 changed files with 3504 additions and 2 deletions

View File

@ -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<string | null>(null);
const [paymentUrl, setPaymentUrl] = useState<string | null>(null);
const [paymentStatus, setPaymentStatus] = useState<"idle" | "waiting" | "success" | "failed">("idle");
const pollRef = useRef<ReturnType<typeof setInterval> | 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 (
<div className="space-y-4 text-center py-6">
<div className="text-5xl mb-3">🎉</div>
<h3 className="text-xl font-bold" style={{ color: "#00e676", fontFamily: "'Space Grotesk', sans-serif" }}>
Payment Successful!
</h3>
<p style={{ color: "rgba(255,255,255,0.7)" }}>
<span style={{ color: "#f0b429", fontFamily: "'JetBrains Mono', monospace" }}>
{xicAmount.toLocaleString()}
</span>{" "}
XIC tokens are being distributed to your address.
</p>
<button
onClick={handleReset}
className="px-6 py-2 rounded-lg text-sm font-semibold transition-all"
style={{ background: "rgba(0,212,255,0.15)", border: "1px solid rgba(0,212,255,0.4)", color: "#00d4ff" }}
>
Make Another Purchase
</button>
</div>
);
}
// ── Waiting State ──────────────────────────────────────────────────────────
if (paymentStatus === "waiting" && orderId) {
return (
<div className="space-y-4">
<div className="rounded-xl p-4 text-center" style={{ background: "rgba(240,180,41,0.08)", border: "1px solid rgba(240,180,41,0.3)" }}>
<div className="text-3xl mb-2"></div>
<p className="font-semibold mb-1" style={{ color: "#f0b429" }}>Waiting for Payment</p>
<p className="text-sm mb-3" style={{ color: "rgba(255,255,255,0.6)" }}>
Order ID: <span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: "0.75rem" }}>{orderId}</span>
</p>
{!isMobile && paymentUrl && (
<button
onClick={() => window.open(paymentUrl, "_blank", "noopener,noreferrer")}
className="w-full py-2 rounded-lg text-sm font-semibold mb-2 transition-all"
style={{ background: "rgba(0,212,255,0.15)", border: "1px solid rgba(0,212,255,0.4)", color: "#00d4ff" }}
>
Reopen Alipay Payment Page
</button>
)}
<p className="text-xs" style={{ color: "rgba(255,255,255,0.4)" }}>
Checking payment status every 5 seconds...
</p>
</div>
<button
onClick={handleReset}
className="w-full py-2 rounded-lg text-sm transition-all"
style={{ background: "transparent", border: "1px solid rgba(255,255,255,0.1)", color: "rgba(255,255,255,0.4)" }}
>
Cancel / Start Over
</button>
</div>
);
}
// ── Input State ────────────────────────────────────────────────────────────
return (
<div className="space-y-4">
{/* Amount Input */}
<div className="space-y-2">
<label className="text-sm font-medium" style={{ color: "rgba(255,255,255,0.7)" }}>
Payment Amount (CNY ¥)
</label>
<div className="relative">
<span
className="absolute left-3 top-1/2 -translate-y-1/2 text-sm font-bold"
style={{ color: "#f0b429" }}
>
¥
</span>
<input
type="number"
value={cnyInput}
onChange={(e) => 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"
/>
</div>
{/* Quick amount buttons */}
<div className="flex gap-2">
{[100, 500, 1000, 5000].map((amt) => (
<button
key={amt}
onClick={() => setCnyInput(String(amt))}
className="flex-1 py-1.5 rounded-lg text-xs font-semibold transition-all"
style={{
background: cnyAmount === amt ? "rgba(240,180,41,0.2)" : "rgba(255,255,255,0.05)",
border: `1px solid ${cnyAmount === amt ? "rgba(240,180,41,0.5)" : "rgba(255,255,255,0.1)"}`,
color: cnyAmount === amt ? "#f0b429" : "rgba(255,255,255,0.5)",
}}
>
¥{amt}
</button>
))}
</div>
</div>
{/* Conversion Preview */}
{cnyAmount > 0 && (
<div
className="rounded-xl p-3 space-y-1"
style={{ background: "rgba(0,212,255,0.05)", border: "1px solid rgba(0,212,255,0.15)" }}
>
<div className="flex justify-between text-sm">
<span style={{ color: "rgba(255,255,255,0.5)" }}>CNY Amount</span>
<span style={{ color: "rgba(255,255,255,0.8)", fontFamily: "'JetBrains Mono', monospace" }}>¥{cnyAmount.toFixed(2)}</span>
</div>
<div className="flex justify-between text-sm">
<span style={{ color: "rgba(255,255,255,0.5)" }}> USD</span>
<span style={{ color: "rgba(255,255,255,0.8)", fontFamily: "'JetBrains Mono', monospace" }}>${usdEquivalent}</span>
</div>
<div className="flex justify-between text-sm font-semibold">
<span style={{ color: "rgba(255,255,255,0.7)" }}>XIC Tokens</span>
<span style={{ color: "#f0b429", fontFamily: "'JetBrains Mono', monospace" }}>{xicAmount.toLocaleString()} XIC</span>
</div>
<p className="text-xs" style={{ color: "rgba(255,255,255,0.3)" }}>
Rate: ¥1 ${CNY_USD_RATE} · XIC price: ${XIC_PRICE_USD}
</p>
</div>
)}
{/* Pay Button */}
<button
onClick={handlePay}
disabled={createOrder.isPending || cnyAmount < 1 || !xicReceiveAddress}
className="w-full py-3 rounded-xl text-base font-bold transition-all"
style={{
background: createOrder.isPending
? "rgba(255,255,255,0.1)"
: "linear-gradient(135deg, #1677FF 0%, #0958d9 100%)",
color: "white",
cursor: createOrder.isPending ? "not-allowed" : "pointer",
fontFamily: "'Space Grotesk', sans-serif",
}}
>
{createOrder.isPending ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" strokeDasharray="30 70" />
</svg>
Creating Order...
</span>
) : (
<span className="flex items-center justify-center gap-2">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<rect x="2" y="5" width="20" height="14" rx="2" fill="#1677FF" stroke="white" strokeWidth="1.5"/>
<path d="M2 10h20" stroke="white" strokeWidth="1.5"/>
<circle cx="7" cy="15" r="1.5" fill="white"/>
</svg>
Pay with Alipay
</span>
)}
</button>
{/* Info */}
<div
className="rounded-lg p-3 text-xs"
style={{ background: "rgba(22,119,255,0.08)", border: "1px solid rgba(22,119,255,0.2)", color: "rgba(255,255,255,0.5)" }}
>
<p> Supports Alipay PC Web and H5 mobile payment</p>
<p> XIC tokens distributed within 15 minutes after payment confirmation</p>
<p> CNY/USD rate is approximate; final XIC amount calculated at time of payment</p>
</div>
</div>
);
}

View File

@ -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<string | null>(null);
const [paypalOrderId, setPaypalOrderId] = useState<string | null>(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 (
<div className="space-y-4 text-center py-6">
<div className="text-5xl mb-3">🎉</div>
<h3 className="text-xl font-bold" style={{ color: "#00e676", fontFamily: "'Space Grotesk', sans-serif" }}>
Payment Successful!
</h3>
<p style={{ color: "rgba(255,255,255,0.7)" }}>
<span style={{ color: "#f0b429", fontFamily: "'JetBrains Mono', monospace" }}>
{xicAmount.toLocaleString()}
</span>{" "}
XIC tokens are being distributed to your address.
</p>
<button onClick={handleReset} className="px-6 py-2 rounded-lg text-sm font-semibold transition-all"
style={{ background: "rgba(0,212,255,0.15)", border: "1px solid rgba(0,212,255,0.4)", color: "#00d4ff" }}>
Make Another Purchase
</button>
</div>
);
}
// ── Redirecting / Capturing State ──────────────────────────────────────────
if (paymentStatus === "redirecting" || paymentStatus === "capturing" || paymentStatus === "waiting") {
return (
<div className="space-y-4">
<div className="rounded-xl p-4 text-center" style={{ background: "rgba(0,112,186,0.08)", border: "1px solid rgba(0,112,186,0.3)" }}>
<div className="text-3xl mb-2">
{paymentStatus === "redirecting" ? "🔄" : paymentStatus === "capturing" ? "⚡" : "⏳"}
</div>
<p className="font-semibold mb-1" style={{ color: "#009cde" }}>
{paymentStatus === "redirecting" && "Redirecting to PayPal..."}
{paymentStatus === "capturing" && "Capturing Payment..."}
{paymentStatus === "waiting" && "Processing Payment..."}
</p>
{orderId && (
<p className="text-xs" style={{ color: "rgba(255,255,255,0.3)" }}>
Order ID: <span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{orderId}</span>
</p>
)}
{paymentStatus === "waiting" && (
<p className="text-xs mt-2" style={{ color: "rgba(255,255,255,0.4)" }}>
Checking payment status every 5 seconds...
</p>
)}
</div>
{paymentStatus !== "redirecting" && (
<button onClick={handleReset} className="w-full py-2 rounded-lg text-sm transition-all"
style={{ background: "transparent", border: "1px solid rgba(255,255,255,0.1)", color: "rgba(255,255,255,0.4)" }}>
Cancel / Start Over
</button>
)}
</div>
);
}
// ── Input State ────────────────────────────────────────────────────────────
return (
<div className="space-y-4">
{/* Amount Input */}
<div className="space-y-2">
<label className="text-sm font-medium" style={{ color: "rgba(255,255,255,0.7)" }}>
Payment Amount (USD $)
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm font-bold" style={{ color: "#009cde" }}>$</span>
<input
type="number"
value={usdInput}
onChange={(e) => 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"
/>
</div>
<div className="flex gap-2">
{[50, 100, 500, 1000].map((amt) => (
<button key={amt} onClick={() => setUsdInput(String(amt))}
className="flex-1 py-1.5 rounded-lg text-xs font-semibold transition-all"
style={{
background: usdAmount === amt ? "rgba(0,112,186,0.2)" : "rgba(255,255,255,0.05)",
border: `1px solid ${usdAmount === amt ? "rgba(0,112,186,0.5)" : "rgba(255,255,255,0.1)"}`,
color: usdAmount === amt ? "#009cde" : "rgba(255,255,255,0.5)",
}}>
${amt}
</button>
))}
</div>
</div>
{/* Conversion Preview */}
{usdAmount > 0 && (
<div className="rounded-xl p-3 space-y-1" style={{ background: "rgba(0,112,186,0.05)", border: "1px solid rgba(0,112,186,0.15)" }}>
<div className="flex justify-between text-sm">
<span style={{ color: "rgba(255,255,255,0.5)" }}>USD Amount</span>
<span style={{ color: "rgba(255,255,255,0.8)", fontFamily: "'JetBrains Mono', monospace" }}>${usdAmount.toFixed(2)}</span>
</div>
<div className="flex justify-between text-sm">
<span style={{ color: "rgba(255,255,255,0.5)" }}>XIC Price</span>
<span style={{ color: "rgba(255,255,255,0.8)", fontFamily: "'JetBrains Mono', monospace" }}>${XIC_PRICE_USD} / XIC</span>
</div>
<div className="flex justify-between text-sm font-semibold">
<span style={{ color: "rgba(255,255,255,0.7)" }}>XIC Tokens</span>
<span style={{ color: "#f0b429", fontFamily: "'JetBrains Mono', monospace" }}>{xicAmount.toLocaleString()} XIC</span>
</div>
</div>
)}
{/* PayPal Button */}
<button
onClick={handlePay}
disabled={createOrder.isPending || usdAmount < 1 || !xicReceiveAddress}
className="w-full py-3 rounded-xl text-base font-bold transition-all"
style={{
background: createOrder.isPending ? "rgba(255,255,255,0.1)" : "#ffc439",
color: "#003087",
cursor: createOrder.isPending ? "not-allowed" : "pointer",
fontFamily: "'Space Grotesk', sans-serif",
}}
>
{createOrder.isPending ? (
<span className="flex items-center justify-center gap-2" style={{ color: "#003087" }}>
<svg className="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" strokeDasharray="30 70" />
</svg>
Creating Order...
</span>
) : (
<span className="flex items-center justify-center gap-2">
{/* PayPal logo */}
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="12" fill="#003087"/>
<path d="M9 7h4.5C15.5 7 17 8.5 17 10.5C17 13 15 14.5 12.5 14.5H11L10 18H8L9 7Z" fill="white"/>
<path d="M10.5 8.5H13C14.5 8.5 15.5 9.5 15.5 11C15.5 12.5 14.5 13 13 13H11.5L10.5 8.5Z" fill="#009cde"/>
</svg>
Pay with PayPal
</span>
)}
</button>
{/* Info */}
<div className="rounded-lg p-3 text-xs"
style={{ background: "rgba(0,112,186,0.08)", border: "1px solid rgba(0,112,186,0.2)", color: "rgba(255,255,255,0.5)" }}>
<p> Supports PayPal balance, credit/debit cards via PayPal</p>
<p> You will be redirected to PayPal to complete payment</p>
<p> XIC tokens distributed within 15 minutes after payment confirmation</p>
<p> PayPal buyer protection applies to this transaction</p>
</div>
</div>
);
}

View File

@ -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 (
<div className="flex flex-col items-center gap-2">
<div
className="rounded-xl overflow-hidden"
style={{ border: "2px solid rgba(240,180,41,0.4)", padding: "8px", background: "#0a0a0f" }}
>
<img
src={qrUrl}
alt="WeChat Pay QR Code"
width={size}
height={size}
style={{ display: "block" }}
onError={(e) => {
// Fallback: show text URL if image fails
(e.target as HTMLImageElement).style.display = "none";
}}
/>
</div>
<p className="text-xs text-center" style={{ color: "rgba(255,255,255,0.4)" }}>
Scan with WeChat to pay
</p>
</div>
);
}
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<string | null>(null);
const [qrCodeUrl, setQrCodeUrl] = useState<string | null>(null);
const [h5Url, setH5Url] = useState<string | null>(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 (
<div className="space-y-4 text-center py-6">
<div className="text-5xl mb-3">🎉</div>
<h3 className="text-xl font-bold" style={{ color: "#00e676", fontFamily: "'Space Grotesk', sans-serif" }}>
Payment Successful!
</h3>
<p style={{ color: "rgba(255,255,255,0.7)" }}>
<span style={{ color: "#f0b429", fontFamily: "'JetBrains Mono', monospace" }}>
{xicAmount.toLocaleString()}
</span>{" "}
XIC tokens are being distributed to your address.
</p>
<button onClick={handleReset} className="px-6 py-2 rounded-lg text-sm font-semibold transition-all"
style={{ background: "rgba(0,212,255,0.15)", border: "1px solid rgba(0,212,255,0.4)", color: "#00d4ff" }}>
Make Another Purchase
</button>
</div>
);
}
// ── QR Code Waiting State ──────────────────────────────────────────────────
if (paymentStatus === "waiting" && qrCodeUrl) {
return (
<div className="space-y-4">
<div className="rounded-xl p-4 text-center" style={{ background: "rgba(7,193,96,0.08)", border: "1px solid rgba(7,193,96,0.3)" }}>
<p className="font-semibold mb-3" style={{ color: "#07c160" }}>
Scan QR Code with WeChat
</p>
<div className="flex justify-center mb-3">
<QRCodeDisplay url={qrCodeUrl} size={180} />
</div>
<p className="text-sm mb-1" style={{ color: "rgba(255,255,255,0.6)" }}>
Amount: <strong style={{ color: "#f0b429" }}>¥{cnyAmount.toFixed(2)} CNY</strong>
</p>
<p className="text-sm" style={{ color: "rgba(255,255,255,0.6)" }}>
You will receive: <strong style={{ color: "#f0b429" }}>{xicAmount.toLocaleString()} XIC</strong>
</p>
</div>
<div className="text-center">
<p className="text-xs mb-2" style={{ color: "rgba(255,255,255,0.4)" }}>
Checking payment status every 5 seconds...
</p>
<p className="text-xs" style={{ color: "rgba(255,255,255,0.3)" }}>
Order ID: <span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{orderId}</span>
</p>
</div>
<button onClick={handleReset} className="w-full py-2 rounded-lg text-sm transition-all"
style={{ background: "transparent", border: "1px solid rgba(255,255,255,0.1)", color: "rgba(255,255,255,0.4)" }}>
Cancel / Start Over
</button>
</div>
);
}
// ── H5 Waiting State ───────────────────────────────────────────────────────
if (paymentStatus === "waiting" && !qrCodeUrl) {
return (
<div className="space-y-4">
<div className="rounded-xl p-4 text-center" style={{ background: "rgba(7,193,96,0.08)", border: "1px solid rgba(7,193,96,0.3)" }}>
<div className="text-3xl mb-2"></div>
<p className="font-semibold mb-1" style={{ color: "#07c160" }}>Redirected to WeChat Pay</p>
<p className="text-sm" style={{ color: "rgba(255,255,255,0.6)" }}>
Complete the payment in WeChat, then return here.
</p>
<p className="text-xs mt-2" style={{ color: "rgba(255,255,255,0.3)" }}>
Order ID: <span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{orderId}</span>
</p>
</div>
{h5Url && (
<button onClick={() => window.location.href = h5Url!}
className="w-full py-2 rounded-lg text-sm font-semibold transition-all"
style={{ background: "rgba(7,193,96,0.15)", border: "1px solid rgba(7,193,96,0.4)", color: "#07c160" }}>
Reopen WeChat Pay
</button>
)}
<button onClick={handleReset} className="w-full py-2 rounded-lg text-sm transition-all"
style={{ background: "transparent", border: "1px solid rgba(255,255,255,0.1)", color: "rgba(255,255,255,0.4)" }}>
Cancel / Start Over
</button>
</div>
);
}
// ── Input State ────────────────────────────────────────────────────────────
return (
<div className="space-y-4">
{/* Amount Input */}
<div className="space-y-2">
<label className="text-sm font-medium" style={{ color: "rgba(255,255,255,0.7)" }}>
Payment Amount (CNY ¥)
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm font-bold" style={{ color: "#07c160" }}>¥</span>
<input
type="number"
value={cnyInput}
onChange={(e) => 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"
/>
</div>
<div className="flex gap-2">
{[100, 500, 1000, 5000].map((amt) => (
<button key={amt} onClick={() => setCnyInput(String(amt))}
className="flex-1 py-1.5 rounded-lg text-xs font-semibold transition-all"
style={{
background: cnyAmount === amt ? "rgba(7,193,96,0.2)" : "rgba(255,255,255,0.05)",
border: `1px solid ${cnyAmount === amt ? "rgba(7,193,96,0.5)" : "rgba(255,255,255,0.1)"}`,
color: cnyAmount === amt ? "#07c160" : "rgba(255,255,255,0.5)",
}}>
¥{amt}
</button>
))}
</div>
</div>
{/* Conversion Preview */}
{cnyAmount > 0 && (
<div className="rounded-xl p-3 space-y-1" style={{ background: "rgba(7,193,96,0.05)", border: "1px solid rgba(7,193,96,0.15)" }}>
<div className="flex justify-between text-sm">
<span style={{ color: "rgba(255,255,255,0.5)" }}>CNY Amount</span>
<span style={{ color: "rgba(255,255,255,0.8)", fontFamily: "'JetBrains Mono', monospace" }}>¥{cnyAmount.toFixed(2)}</span>
</div>
<div className="flex justify-between text-sm">
<span style={{ color: "rgba(255,255,255,0.5)" }}> USD</span>
<span style={{ color: "rgba(255,255,255,0.8)", fontFamily: "'JetBrains Mono', monospace" }}>${(cnyAmount * CNY_USD_RATE).toFixed(2)}</span>
</div>
<div className="flex justify-between text-sm font-semibold">
<span style={{ color: "rgba(255,255,255,0.7)" }}>XIC Tokens</span>
<span style={{ color: "#f0b429", fontFamily: "'JetBrains Mono', monospace" }}>{xicAmount.toLocaleString()} XIC</span>
</div>
</div>
)}
{/* Pay Button */}
<button
onClick={handlePay}
disabled={createOrder.isPending || cnyAmount < 0.01 || !xicReceiveAddress}
className="w-full py-3 rounded-xl text-base font-bold transition-all"
style={{
background: createOrder.isPending ? "rgba(255,255,255,0.1)" : "linear-gradient(135deg, #07c160 0%, #059a4a 100%)",
color: "white",
cursor: createOrder.isPending ? "not-allowed" : "pointer",
fontFamily: "'Space Grotesk', sans-serif",
}}
>
{createOrder.isPending ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" strokeDasharray="30 70" />
</svg>
Creating Order...
</span>
) : (
<span className="flex items-center justify-center gap-2">
{/* WeChat icon */}
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="12" fill="#07c160"/>
<path d="M8 10.5C8 8.015 10.015 6 12.5 6C14.985 6 17 8.015 17 10.5C17 12.985 14.985 15 12.5 15C11.89 15 11.31 14.87 10.79 14.64L8.5 15.5L9.14 13.5C8.43 12.73 8 11.67 8 10.5Z" fill="white"/>
</svg>
Pay with WeChat Pay
</span>
)}
</button>
{/* Info */}
<div className="rounded-lg p-3 text-xs"
style={{ background: "rgba(7,193,96,0.08)", border: "1px solid rgba(7,193,96,0.2)", color: "rgba(255,255,255,0.5)" }}>
<p> PC: Scan QR code with WeChat app</p>
<p> Mobile: Redirects to WeChat H5 payment</p>
<p> XIC tokens distributed within 15 minutes after confirmation</p>
</div>
</div>
);
}

View File

@ -18,6 +18,9 @@ import { useWallet } from "@/hooks/useWallet";
import { useBridgeWeb3 } from "@/hooks/useBridgeWeb3"; import { useBridgeWeb3 } from "@/hooks/useBridgeWeb3";
import { useTronBridge } from "@/hooks/useTronBridge"; import { useTronBridge } from "@/hooks/useTronBridge";
import { addXicToEvmWallet, addXicToTronWallet } from "@/lib/addTokenToWallet"; import { addXicToEvmWallet, addXicToTronWallet } from "@/lib/addTokenToWallet";
import AlipayPayment from "@/components/AlipayPayment";
import WechatPayment from "@/components/WechatPayment";
import PaypalPayment from "@/components/PaypalPayment";
// ─── Language ───────────────────────────────────────────────────────────────── // ─── Language ─────────────────────────────────────────────────────────────────
type Lang = "zh" | "en"; type Lang = "zh" | "en";
@ -266,6 +269,9 @@ export default function Bridge() {
const [registered, setRegistered] = useState(false); const [registered, setRegistered] = useState(false);
const [registering, setRegistering] = useState(false); const [registering, setRegistering] = useState(false);
// Fiat payment tab
const [fiatChannel, setFiatChannel] = useState<"alipay" | "wechat" | "paypal" | null>(null);
// History // History
const [showHistory, setShowHistory] = useState(false); const [showHistory, setShowHistory] = useState(false);
const [historyAddress, setHistoryAddress] = useState(""); const [historyAddress, setHistoryAddress] = useState("");
@ -1064,6 +1070,78 @@ export default function Bridge() {
<p className="text-xs text-white/30 text-center">{t.confirmSendHint}</p> <p className="text-xs text-white/30 text-center">{t.confirmSendHint}</p>
</div> </div>
{/* ─── Fiat Payment Section ──────────────────────────────────────── */}
<div
className="rounded-2xl overflow-hidden"
style={{ border: "1px solid rgba(255,255,255,0.08)" }}
>
<div
className="px-5 py-4"
style={{ background: "rgba(255,255,255,0.03)", borderBottom: "1px solid rgba(255,255,255,0.06)" }}
>
<p className="text-sm font-semibold text-white/70">
{lang === "zh" ? "💳 法币支付(支付宝 / 微信 / PayPal" : "💳 Fiat Payment (Alipay / WeChat / PayPal)"}
</p>
<p className="text-xs text-white/30 mt-0.5">
{lang === "zh" ? "使用法币直接购买 XIC 代币,无需加密货币钱包" : "Buy XIC directly with fiat currency — no crypto wallet required"}
</p>
</div>
<div className="p-4 space-y-4">
<div className="flex gap-2">
{([
{ 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) => (
<button
key={ch.key}
onClick={() => setFiatChannel(fiatChannel === ch.key ? null : ch.key)}
className="flex-1 py-2 rounded-xl text-xs font-semibold transition-all"
style={{
background: fiatChannel === ch.key ? `${ch.color}22` : "rgba(255,255,255,0.05)",
border: `1px solid ${fiatChannel === ch.key ? ch.color : "rgba(255,255,255,0.1)"}`,
color: fiatChannel === ch.key ? ch.color : "rgba(255,255,255,0.5)",
}}
>
{ch.icon} {ch.label}
</button>
))}
</div>
{fiatChannel === "alipay" && (
<AlipayPayment
xicReceiveAddress={xicReceiveAddress}
onSuccess={(amt, oid) => {
toast.success(`${amt.toLocaleString()} XIC tokens distributed! Order: ${oid}`);
setFiatChannel(null);
}}
/>
)}
{fiatChannel === "wechat" && (
<WechatPayment
xicReceiveAddress={xicReceiveAddress}
onSuccess={(amt, oid) => {
toast.success(`${amt.toLocaleString()} XIC tokens distributed! Order: ${oid}`);
setFiatChannel(null);
}}
/>
)}
{fiatChannel === "paypal" && (
<PaypalPayment
xicReceiveAddress={xicReceiveAddress}
onSuccess={(amt, oid) => {
toast.success(`${amt.toLocaleString()} XIC tokens distributed! Order: ${oid}`);
setFiatChannel(null);
}}
/>
)}
{!fiatChannel && (
<p className="text-xs text-center" style={{ color: "rgba(255,255,255,0.3)" }}>
{lang === "zh" ? "选择支付方式开始购买" : "Select a payment method above to get started"}
</p>
)}
</div>
</div>
{/* ─── Info Cards ─────────────────────────────────────────────────── */} {/* ─── Info Cards ─────────────────────────────────────────────────── */}
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
{[ {[

View File

@ -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`)
);

View File

@ -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": {}
}
}

View File

@ -64,6 +64,13 @@
"when": 1773142627500, "when": 1773142627500,
"tag": "0008_lowly_pride", "tag": "0008_lowly_pride",
"breakpoints": true "breakpoints": true
},
{
"idx": 9,
"version": "5",
"when": 1773146163886,
"tag": "0009_charming_lady_deathstrike",
"breakpoints": true
} }
] ]
} }

View File

@ -97,6 +97,7 @@ export const presaleConfig = mysqlTable("presale_config", {
export type PresaleConfig = typeof presaleConfig.$inferSelect; export type PresaleConfig = typeof presaleConfig.$inferSelect;
export type InsertPresaleConfig = typeof presaleConfig.$inferInsert; export type InsertPresaleConfig = typeof presaleConfig.$inferInsert;
// Cross-chain bridge orders — NAC self-developed cross-chain bridge // Cross-chain bridge orders — NAC self-developed cross-chain bridge
// User sends USDT on any supported chain to our receiving address // User sends USDT on any supported chain to our receiving address
// Backend monitors and records confirmed transfers, then distributes XIC // 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", { export const transactionLogs = mysqlTable("transaction_logs", {
id: int("id").autoincrement().primaryKey(), id: int("id").autoincrement().primaryKey(),
txHash: varchar("txHash", { length: 128 }).notNull().unique(), 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(), fromAddress: varchar("fromAddress", { length: 64 }).notNull(),
toAddress: varchar("toAddress", { length: 64 }).notNull(), toAddress: varchar("toAddress", { length: 64 }).notNull(),
amount: decimal("amount", { precision: 30, scale: 6 }).notNull(), amount: decimal("amount", { precision: 30, scale: 6 }).notNull(),
blockNumber: bigint("blockNumber", { mode: "number" }), blockNumber: bigint("blockNumber", { mode: "number" }),
status: int("status").default(0).notNull(), // 0=unprocessed, 1=processed, 2=no_match 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(), createdAt: timestamp("createdAt").defaultNow().notNull(),
}); });
@ -167,3 +168,36 @@ export const listenerState = mysqlTable("listener_state", {
}); });
export type ListenerState = typeof listenerState.$inferSelect; 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;

131
server/fiatPayment.test.ts Normal file
View File

@ -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);
});
});

View File

@ -12,6 +12,25 @@ import { TRPCError } from "@trpc/server";
import { notifyDistributed, testTelegramConnection } from "./telegram"; import { notifyDistributed, testTelegramConnection } from "./telegram";
import { getAllConfig, getConfig, setConfig, seedDefaultConfig, DEFAULT_CONFIG } from "./configDb"; import { getAllConfig, getConfig, setConfig, seedDefaultConfig, DEFAULT_CONFIG } from "./configDb";
import { creditXic } from "./tokenDistributionService"; 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) // Admin password from env (fallback for development)
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "NACadmin2026!"; 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<number>`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({ export const appRouter = router({
system: systemRouter, system: systemRouter,
bridge: bridgeRouter, bridge: bridgeRouter,
payment: paymentRouter,
auth: router({ auth: router({
me: publicProcedure.query(opts => opts.ctx.user), me: publicProcedure.query(opts => opts.ctx.user),
logout: publicProcedure.mutation(({ ctx }) => { logout: publicProcedure.mutation(({ ctx }) => {

View File

@ -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, string>): string {
if (ALIPAY_CONFIG.privateKey === "PLACEHOLDER_RSA2_PRIVATE_KEY") {
console.warn("[Alipay] Using placeholder private key — signature will be invalid");
return "PLACEHOLDER_SIGNATURE";
}
const sortedKeys = Object.keys(params).sort();
const signStr = sortedKeys
.filter(k => k !== "sign" && params[k] !== "" && params[k] !== undefined)
.map(k => `${k}=${params[k]}`)
.join("&");
const privateKey = `-----BEGIN RSA PRIVATE KEY-----\n${ALIPAY_CONFIG.privateKey}\n-----END RSA PRIVATE KEY-----`;
const sign = crypto.createSign("RSA-SHA256");
sign.update(signStr, "utf8");
return sign.sign(privateKey, "base64");
}
/**
* Verify Alipay async callback signature.
* Returns true if signature is valid.
*/
export function verifyAlipaySign(params: Record<string, string>): boolean {
if (ALIPAY_CONFIG.alipayPublicKey === "PLACEHOLDER_ALIPAY_PUBLIC_KEY") {
console.warn("[Alipay] Using placeholder public key — skipping signature verification (SANDBOX MODE)");
return true; // Allow in sandbox/test mode
}
const sign = params.sign;
if (!sign) return false;
const sortedKeys = Object.keys(params).sort();
const signStr = sortedKeys
.filter(k => k !== "sign" && k !== "sign_type" && params[k] !== "")
.map(k => `${k}=${params[k]}`)
.join("&");
const publicKey = `-----BEGIN PUBLIC KEY-----\n${ALIPAY_CONFIG.alipayPublicKey}\n-----END PUBLIC KEY-----`;
const verify = crypto.createVerify("RSA-SHA256");
verify.update(signStr, "utf8");
return verify.verify(publicKey, sign, "base64");
}
// ─── Core Functions ───────────────────────────────────────────────────────────
/**
* Create an Alipay payment order.
* Returns a redirect URL for PC (page pay) or H5 (wap pay).
*/
export async function createAlipayOrder(params: AlipayOrderParams): Promise<AlipayOrderResult> {
const { orderId, subject, totalAmount, xicReceiveAddress, userId, isMobile = false } = params;
const cnyAmount = parseFloat(totalAmount);
const usdEquivalent = cnyToUsd(cnyAmount);
const xicAmount = calcXicAmount(usdEquivalent);
// Build Alipay request parameters
const method = isMobile ? "alipay.trade.wap.pay" : "alipay.trade.page.pay";
const bizContent = JSON.stringify({
out_trade_no: orderId,
product_code: isMobile ? "QUICK_WAP_WAY" : "FAST_INSTANT_TRADE_PAY",
total_amount: totalAmount,
subject,
body: `XIC Token Presale — ${xicAmount} XIC`,
timeout_express: "30m",
// passback_params: encodeURIComponent(JSON.stringify({ xicReceiveAddress })),
});
const commonParams: Record<string, string> = {
app_id: ALIPAY_CONFIG.appId,
method,
format: "JSON",
charset: "utf-8",
sign_type: "RSA2",
timestamp: new Date().toISOString().replace("T", " ").substring(0, 19),
version: "1.0",
notify_url: ALIPAY_CONFIG.notifyUrl,
return_url: ALIPAY_CONFIG.returnUrl,
biz_content: bizContent,
};
const sign = buildSign(commonParams);
const allParams = { ...commonParams, sign };
// Build redirect URL (GET form submit)
const queryString = Object.entries(allParams)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join("&");
const paymentUrl = `${ALIPAY_CONFIG.gatewayUrl}?${queryString}`;
try {
const db = await getDb();
if (!db) return { success: false, error: "Database not available" };
// Save order to database
await db.insert(fiatOrders).values({
orderId,
channel: "alipay",
userId: userId || null,
xicReceiveAddress,
usdtEquivalent: usdEquivalent.toString(),
currency: "CNY",
originalAmount: totalAmount,
xicAmount: xicAmount.toString(),
status: "pending",
paymentUrl,
expiredAt: new Date(Date.now() + 30 * 60 * 1000), // 30 minutes
});
console.log(`[Alipay] Order created: ${orderId} | CNY ${totalAmount}${xicAmount} XIC → ${xicReceiveAddress}`);
return { success: true, paymentUrl, orderId };
} catch (err) {
console.error("[Alipay] Failed to create order:", err);
return { success: false, error: String(err) };
}
}
/**
* Query Alipay order status via API.
* Used for polling when async callback is not received.
*/
export async function queryAlipayOrder(orderId: string): Promise<AlipayQueryResult> {
const bizContent = JSON.stringify({ out_trade_no: orderId });
const commonParams: Record<string, string> = {
app_id: ALIPAY_CONFIG.appId,
method: "alipay.trade.query",
format: "JSON",
charset: "utf-8",
sign_type: "RSA2",
timestamp: new Date().toISOString().replace("T", " ").substring(0, 19),
version: "1.0",
biz_content: bizContent,
};
const sign = buildSign(commonParams);
try {
const response = await fetch(ALIPAY_CONFIG.gatewayUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: Object.entries({ ...commonParams, sign })
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join("&"),
});
const data = await response.json() as any;
const result = data["alipay_trade_query_response"];
if (result?.code === "10000") {
return {
success: true,
tradeStatus: result.trade_status,
totalAmount: result.total_amount,
buyerPayAmount: result.buyer_pay_amount,
tradeNo: result.trade_no,
};
}
return { success: false, error: result?.sub_msg || "Query failed" };
} catch (err) {
return { success: false, error: String(err) };
}
}
/**
* Process Alipay async callback (notify).
* Called by the backend route when Alipay POSTs to ALIPAY_NOTIFY_URL.
*
* Flow:
* 1. Verify signature
* 2. Check trade_status === "TRADE_SUCCESS"
* 3. Update fiat_orders status to "paid"
* 4. Call creditXic() to distribute XIC tokens
* 5. Return "success" to Alipay (prevents retry)
*/
export async function handleAlipayCallback(params: Record<string, string>): Promise<{ ok: boolean; message: string }> {
// Step 1: Verify signature
if (!verifyAlipaySign(params)) {
console.error("[Alipay] Callback signature verification failed");
return { ok: false, message: "signature_invalid" };
}
const { trade_status, out_trade_no, trade_no, total_amount, buyer_id } = params;
// Step 2: Only process successful payments
if (trade_status !== "TRADE_SUCCESS" && trade_status !== "TRADE_FINISHED") {
console.log(`[Alipay] Callback ignored — trade_status=${trade_status} for order ${out_trade_no}`);
return { ok: true, message: "ignored" };
}
try {
const db = await getDb();
if (!db) return { ok: false, message: "db_unavailable" };
// Step 3: Find the order
const orders = await db.select().from(fiatOrders)
.where(eq(fiatOrders.orderId, out_trade_no))
.limit(1);
const order = orders[0];
if (!order) {
console.error(`[Alipay] Order not found: ${out_trade_no}`);
return { ok: false, message: "order_not_found" };
}
// Idempotency: skip if already processed
if (order.status === "distributed" || order.status === "paid") {
console.log(`[Alipay] Order ${out_trade_no} already processed, skipping`);
return { ok: true, message: "already_processed" };
}
// Step 4: Update order status to "paid"
await db.update(fiatOrders)
.set({
status: "paid",
gatewayOrderId: trade_no,
callbackPayload: JSON.stringify(params),
updatedAt: new Date(),
})
.where(eq(fiatOrders.orderId, out_trade_no));
// Step 5: Distribute XIC tokens via unified service
const creditResult = await creditXic({
txHash: `ALIPAY-${trade_no}`,
chainType: "ALIPAY",
fromAddress: buyer_id || "alipay_buyer",
toAddress: "alipay_merchant",
usdtAmount: parseFloat(order.usdtEquivalent),
xicAmount: parseFloat(order.xicAmount),
xicReceiveAddress: order.xicReceiveAddress || undefined,
remark: `Alipay order ${out_trade_no}, CNY ${total_amount}`,
});
if (creditResult.success) {
await db.update(fiatOrders)
.set({ status: "distributed", distributedAt: new Date() })
.where(eq(fiatOrders.orderId, out_trade_no));
console.log(`[Alipay] ✅ XIC distributed for order ${out_trade_no}`);
} else {
console.error(`[Alipay] ❌ XIC distribution failed for order ${out_trade_no}:`, creditResult.error);
}
return { ok: true, message: "success" };
} catch (err) {
console.error("[Alipay] Callback processing error:", err);
return { ok: false, message: String(err) };
}
}
/**
* Request Alipay refund.
* TODO: Implement when refund workflow is defined.
*/
export async function refundAlipayOrder(orderId: string, refundAmount: string, reason: string): Promise<{ success: boolean; error?: string }> {
// TODO: Implement alipay.trade.refund API call
// Reference: https://opendocs.alipay.com/open/028sm9
console.warn(`[Alipay] Refund requested for ${orderId} — not yet implemented`);
return { success: false, error: "Refund not yet implemented" };
}

View File

@ -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<string> {
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<PaypalOrderResult> {
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<string, string>,
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) };
}
}

View File

@ -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<WechatOrderResult> {
const { orderId, description, totalFen, xicReceiveAddress, userId, payType = "NATIVE", openId, clientIp } = params;
const usdEquivalent = fenToUsd(totalFen);
const xicAmount = calcXicAmount(usdEquivalent);
// Determine API endpoint based on pay type
const endpointMap: Record<string, string> = {
NATIVE: "/v3/pay/transactions/native",
H5: "/v3/pay/transactions/h5",
JSAPI: "/v3/pay/transactions/jsapi",
};
const endpoint = endpointMap[payType];
const url = `${WECHAT_CONFIG.baseUrl}${endpoint}`;
// Build request body
const requestBody: Record<string, any> = {
appid: WECHAT_CONFIG.appId,
mchid: WECHAT_CONFIG.mchId,
description,
out_trade_no: orderId,
notify_url: WECHAT_CONFIG.notifyUrl,
amount: { total: totalFen, currency: "CNY" },
attach: xicReceiveAddress, // pass XIC receive address as attach field
};
if (payType === "JSAPI" && openId) {
requestBody.payer = { openid: openId };
}
if (payType === "H5" && clientIp) {
requestBody.scene_info = {
payer_client_ip: clientIp,
h5_info: { type: "Wap", wap_url: "https://pre-sale.newassetchain.io", wap_name: "NAC XIC Presale" },
};
}
const body = JSON.stringify(requestBody);
const authHeader = buildAuthHeader("POST", url, body);
try {
const db = await getDb();
if (!db) return { success: false, error: "Database not available" };
// Save order to database first
await db.insert(fiatOrders).values({
orderId,
channel: "wechat",
userId: userId || null,
xicReceiveAddress,
usdtEquivalent: usdEquivalent.toString(),
currency: "CNY",
originalAmount: (totalFen / 100).toFixed(2),
xicAmount: xicAmount.toString(),
status: "pending",
expiredAt: new Date(Date.now() + 30 * 60 * 1000),
});
// Call WeChat Pay API
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": authHeader,
"Accept": "application/json",
},
body,
});
const data = await response.json() as any;
if (!response.ok) {
console.error("[WeChat] API error:", data);
return { success: false, error: data.message || "WeChat API error" };
}
let result: WechatOrderResult = { success: true, orderId };
if (payType === "NATIVE" && data.code_url) {
result.qrCodeUrl = data.code_url;
await db.update(fiatOrders)
.set({ qrCodeUrl: data.code_url })
.where(eq(fiatOrders.orderId, orderId));
} else if (payType === "H5" && data.h5_url) {
result.h5Url = data.h5_url;
await db.update(fiatOrders)
.set({ paymentUrl: data.h5_url })
.where(eq(fiatOrders.orderId, orderId));
} else if (payType === "JSAPI" && data.prepay_id) {
result.prepayId = data.prepay_id;
// Build JSAPI parameters for frontend
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonceStr = crypto.randomBytes(16).toString("hex");
const packageStr = `prepay_id=${data.prepay_id}`;
const signMessage = `${WECHAT_CONFIG.appId}\n${timestamp}\n${nonceStr}\n${packageStr}\n`;
const privateKey = `-----BEGIN PRIVATE KEY-----\n${WECHAT_CONFIG.privateKey}\n-----END PRIVATE KEY-----`;
const sign = crypto.createSign("RSA-SHA256");
sign.update(signMessage);
const paySign = sign.sign(privateKey, "base64");
result.jsapiParams = {
appId: WECHAT_CONFIG.appId,
timeStamp: timestamp,
nonceStr,
package: packageStr,
signType: "RSA",
paySign,
};
}
console.log(`[WeChat] Order created: ${orderId} | CNY ${(totalFen / 100).toFixed(2)}${xicAmount} XIC → ${xicReceiveAddress}`);
return result;
} catch (err) {
console.error("[WeChat] Failed to create order:", err);
return { success: false, error: String(err) };
}
}
/**
* Process WeChat Pay async callback (notify).
* WeChat sends a POST request to WECHAT_NOTIFY_URL when payment is completed.
*
* Flow:
* 1. Decrypt the resource field using AES-256-GCM
* 2. Verify trade_state === "SUCCESS"
* 3. Update fiat_orders status to "paid"
* 4. Call creditXic() to distribute XIC tokens
* 5. Return { code: "SUCCESS" } to WeChat
*/
export async function handleWechatCallback(body: any): Promise<{ ok: boolean; code: string; message: string }> {
const { event_type, resource } = body;
if (event_type !== "TRANSACTION.SUCCESS") {
return { ok: true, code: "SUCCESS", message: "ignored" };
}
// Step 1: Decrypt resource
const decrypted = decryptWechatCallback(
resource.associated_data,
resource.nonce,
resource.ciphertext
);
if (!decrypted) {
// In sandbox/test mode with placeholder keys, parse raw body
console.warn("[WeChat] Decryption failed — attempting to process as plaintext (test mode)");
return { ok: false, code: "FAIL", message: "decryption_failed" };
}
let transaction: any;
try {
transaction = JSON.parse(decrypted);
} catch {
return { ok: false, code: "FAIL", message: "invalid_json" };
}
const { trade_state, out_trade_no, transaction_id, amount, payer, attach } = transaction;
if (trade_state !== "SUCCESS") {
return { ok: true, code: "SUCCESS", message: "ignored" };
}
try {
const db = await getDb();
if (!db) return { ok: false, code: "FAIL", message: "db_unavailable" };
const orders = await db.select().from(fiatOrders)
.where(eq(fiatOrders.orderId, out_trade_no))
.limit(1);
const order = orders[0];
if (!order) return { ok: false, code: "FAIL", message: "order_not_found" };
if (order.status === "distributed" || order.status === "paid") {
return { ok: true, code: "SUCCESS", message: "already_processed" };
}
// Update to paid
await db.update(fiatOrders)
.set({
status: "paid",
gatewayOrderId: transaction_id,
payerOpenId: payer?.openid || null,
callbackPayload: decrypted,
updatedAt: new Date(),
})
.where(eq(fiatOrders.orderId, out_trade_no));
// Distribute XIC
const creditResult = await creditXic({
txHash: `WECHAT-${transaction_id}`,
chainType: "WECHAT",
fromAddress: payer?.openid || "wechat_payer",
toAddress: "wechat_merchant",
usdtAmount: parseFloat(order.usdtEquivalent),
xicAmount: parseFloat(order.xicAmount),
xicReceiveAddress: order.xicReceiveAddress || attach || undefined,
remark: `WeChat order ${out_trade_no}, CNY ${(amount?.total / 100).toFixed(2)}`,
});
if (creditResult.success) {
await db.update(fiatOrders)
.set({ status: "distributed", distributedAt: new Date() })
.where(eq(fiatOrders.orderId, out_trade_no));
console.log(`[WeChat] ✅ XIC distributed for order ${out_trade_no}`);
}
return { ok: true, code: "SUCCESS", message: "success" };
} catch (err) {
console.error("[WeChat] Callback processing error:", err);
return { ok: false, code: "FAIL", message: String(err) };
}
}
/**
* Query WeChat Pay order status.
* Used for polling when async callback is delayed.
*/
export async function queryWechatOrder(orderId: string): Promise<{ success: boolean; tradeState?: string; error?: string }> {
const url = `${WECHAT_CONFIG.baseUrl}/v3/pay/transactions/out-trade-no/${orderId}?mchid=${WECHAT_CONFIG.mchId}`;
const authHeader = buildAuthHeader("GET", url, "");
try {
const response = await fetch(url, {
headers: {
"Authorization": authHeader,
"Accept": "application/json",
},
});
const data = await response.json() as any;
if (response.ok) {
return { success: true, tradeState: data.trade_state };
}
return { success: false, error: data.message };
} catch (err) {
return { success: false, error: String(err) };
}
}

14
todo.md
View File

@ -234,3 +234,17 @@
- [x] 去除前端 bundle 中的 manus.im 内联 - [x] 去除前端 bundle 中的 manus.im 内联
- [x] 全部 18 个 vitest 测试通过 - [x] 全部 18 个 vitest 测试通过
- [x] 浏览器测试Bridge 页面、主页、语言切换、Connect Wallet 模态框、TRX 链切换 — 全部通过 - [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 库