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:
parent
d24d39e2bf
commit
3589e67e33
|
|
@ -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 1–5 minutes after payment confirmation</p>
|
||||||
|
<p>• CNY/USD rate is approximate; final XIC amount calculated at time of payment</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 1–5 minutes after payment confirmation</p>
|
||||||
|
<p>• PayPal buyer protection applies to this transaction</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 1–5 minutes after confirmation</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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">
|
||||||
{[
|
{[
|
||||||
|
|
|
||||||
|
|
@ -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`)
|
||||||
|
);
|
||||||
|
|
@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 }) => {
|
||||||
|
|
|
||||||
|
|
@ -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" };
|
||||||
|
}
|
||||||
|
|
@ -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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
14
todo.md
|
|
@ -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 库
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue