From 0f5178056696e0649fe59a9442655c66d0413449 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Fri, 13 Mar 2026 08:15:27 +0800 Subject: [PATCH] feat: Introduce premium map dashboard with dark theme, pro features, and payment processing. --- .env.example | 4 + docs/SUPABASE_SETUP_ZH.md | 6 +- frontend/components/account/AccountCenter.tsx | 1165 ++++++++++------- .../components/dashboard/Dashboard.module.css | 19 + frontend/components/dashboard/DetailPanel.tsx | 275 ++-- .../dashboard/ProFeaturePaywall.tsx | 141 +- src/payments/contract_checkout.py | 319 ++++- src/payments/contract_checkout.py.bak | 1118 ++++++++++++++++ web/app.py | 6 +- 9 files changed, 2388 insertions(+), 665 deletions(-) create mode 100644 src/payments/contract_checkout.py.bak diff --git a/.env.example b/.env.example index 5762378b..ee9a0f3a 100644 --- a/.env.example +++ b/.env.example @@ -67,6 +67,10 @@ POLYWEATHER_PAYMENT_HTTP_TIMEOUT_SEC=10 POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC=4 POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50 POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED=true +# Payment points redemption +POLYWEATHER_PAYMENT_POINTS_ENABLED=true +POLYWEATHER_PAYMENT_POINTS_PER_USDC=500 +POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3 # Comma-separated allowed plans for checkout UI + backend validation. # Default is monthly-only launch. POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly diff --git a/docs/SUPABASE_SETUP_ZH.md b/docs/SUPABASE_SETUP_ZH.md index a09f0dc0..8155f805 100644 --- a/docs/SUPABASE_SETUP_ZH.md +++ b/docs/SUPABASE_SETUP_ZH.md @@ -75,8 +75,12 @@ POLYWEATHER_PAYMENT_HTTP_TIMEOUT_SEC=10 POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC=4 POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50 POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED=true +# 支付积分抵扣(500 积分 = 1 USDC,最高抵扣 3 USDC) +POLYWEATHER_PAYMENT_POINTS_ENABLED=true +POLYWEATHER_PAYMENT_POINTS_PER_USDC=500 +POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3 # JSON 示例: -# {"pro_monthly":{"plan_id":101,"amount_usdc":"29","duration_days":30}} +# {"pro_monthly":{"plan_id":101,"amount_usdc":"5","duration_days":30}} POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON= ``` diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index cb6e11a5..d4bad9b7 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -4,37 +4,46 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import type { User } from "@supabase/supabase-js"; -import type { LucideIcon } from "lucide-react"; import { - ArrowRight, - BadgeCheck, - Bot, - CheckCircle2, - ChevronLeft, - Clock, - Coins, - Copy, - Crown, - CreditCard, - Fingerprint, - Hash, - Info, - Loader2, - Lock, - LogIn, - LogOut, - Mail, - RefreshCw, - Shield, - ShieldCheck, - Sparkles, - Trophy, User as UserIcon, + Shield, + Fingerprint, + Bot, + RefreshCw, + LogOut, + ChevronLeft, + Copy, + CheckCircle2, UserCheck, + Mail, + Hash, + LogIn, + Clock, + Crown, + ExternalLink, + Trophy, + Coins, + TrendingUp, + Info, Wallet, + Zap, + ArrowRight, + Minus, + ShieldCheck, + BarChart3, + Sparkles, + Lock, X, + ChevronRight, + Loader2, + CreditCard, } from "lucide-react"; -import { getSupabaseBrowserClient, hasSupabasePublicEnv } from "@/lib/supabase/client"; +import { + getSupabaseBrowserClient, + hasSupabasePublicEnv, +} from "@/lib/supabase/client"; + +// --- Types --- type AuthMeResponse = { authenticated?: boolean; @@ -53,6 +62,12 @@ type PaymentPlan = { duration_days: number; }; +type PointsRedemptionConfig = { + enabled?: boolean; + points_per_usdc?: number; + max_discount_usdc?: number; +}; + type PaymentConfig = { enabled?: boolean; configured?: boolean; @@ -61,6 +76,7 @@ type PaymentConfig = { token_decimals?: number; receiver_contract?: string; confirmations?: number; + points_redemption?: PointsRedemptionConfig; plans?: PaymentPlan[]; }; @@ -93,17 +109,31 @@ type CreatedIntent = { declare global { interface Window { ethereum?: { - request: (args: { method: string; params?: any[] | object }) => Promise; + request: (args: { + method: string; + params?: any[] | object; + }) => Promise; }; } } -type InfoItemProps = { - icon: LucideIcon; - label: string; - value: string; - status?: "default" | "primary"; -}; +// --- Helpers --- + +const InfoRow = ({ icon: Icon, label, value, isPrimary = false }) => ( +
+
+
+ {Icon && } +
+ {label} +
+ + {value} + +
+); function formatTime(value: string | undefined | null, locale: string) { if (!value) return "--"; @@ -114,44 +144,27 @@ function formatTime(value: string | undefined | null, locale: string) { year: "numeric", month: "2-digit", day: "2-digit", - hour: "2-digit", - minute: "2-digit", }).format(dt); } catch { return "--"; } } -function normalizeProvider(user: User | null) { - const provider = String(user?.app_metadata?.provider || "").trim().toLowerCase(); - if (provider) return provider; - const providers = user?.app_metadata?.providers; - if (Array.isArray(providers) && providers.length) { - return String(providers[0] || "").trim().toLowerCase(); - } - return ""; -} - function shortAddress(address: string) { const text = String(address || ""); if (!text.startsWith("0x") || text.length < 12) return text || "--"; return `${text.slice(0, 8)}...${text.slice(-6)}`; } -function planDisplayName(planCode: string) { - const code = String(planCode || "").trim().toLowerCase(); - if (code === "pro_monthly") return "Pro 月付"; - if (code === "pro_quarterly") return "Pro 季付"; - if (code === "pro_yearly") return "Pro 年付"; - return planCode || "--"; -} - function toPaddedHex(value: bigint) { return value.toString(16).padStart(64, "0"); } function toPaddedAddress(address: string) { - return String(address || "").toLowerCase().replace(/^0x/, "").padStart(64, "0"); + return String(address || "") + .toLowerCase() + .replace(/^0x/, "") + .padStart(64, "0"); } function buildAllowanceCalldata(owner: string, spender: string) { @@ -162,21 +175,7 @@ function buildApproveCalldata(spender: string, amount: bigint) { return `0x095ea7b3${toPaddedAddress(spender)}${toPaddedHex(amount)}`; } -function InfoItem({ icon: Icon, label, value, status = "default" }: InfoItemProps) { - return ( -
-
-
- -
- {label} -
- - {value} - -
- ); -} +// --- Main Component --- export function AccountCenter() { const router = useRouter(); @@ -190,7 +189,9 @@ export function AccountCenter() { const [updatedAt, setUpdatedAt] = useState(""); const [user, setUser] = useState(null); const [backend, setBackend] = useState(null); - const [paymentConfig, setPaymentConfig] = useState(null); + const [paymentConfig, setPaymentConfig] = useState( + null, + ); const [boundWallets, setBoundWallets] = useState([]); const [walletAddress, setWalletAddress] = useState(""); const [selectedPlanCode, setSelectedPlanCode] = useState("pro_monthly"); @@ -203,19 +204,24 @@ export function AccountCenter() { const supabaseReady = hasSupabasePublicEnv(); - const buildAuthedHeaders = useCallback(async (withJson = false): Promise> => { - const headers: Record = {}; - if (withJson) headers["Content-Type"] = "application/json"; - if (!supabaseReady) return headers; - try { - const { data: { session } } = await getSupabaseBrowserClient().auth.getSession(); - const accessToken = String(session?.access_token || "").trim(); - if (accessToken) headers.Authorization = `Bearer ${accessToken}`; - } catch { - // no-op - } - return headers; - }, [supabaseReady]); + const buildAuthedHeaders = useCallback( + async (withJson = false): Promise> => { + const headers: Record = {}; + if (withJson) headers["Content-Type"] = "application/json"; + if (!supabaseReady) return headers; + try { + const { + data: { session }, + } = await getSupabaseBrowserClient().auth.getSession(); + const accessToken = String(session?.access_token || "").trim(); + if (accessToken) headers.Authorization = `Bearer ${accessToken}`; + } catch { + // no-op + } + return headers; + }, + [supabaseReady], + ); const loadPaymentSnapshot = useCallback(async () => { if (!backend?.authenticated) { @@ -226,8 +232,14 @@ export function AccountCenter() { try { const authHeaders = await buildAuthedHeaders(false); const [configRes, walletsRes] = await Promise.all([ - fetch("/api/payments/config", { cache: "no-store", headers: authHeaders }), - fetch("/api/payments/wallets", { cache: "no-store", headers: authHeaders }), + fetch("/api/payments/config", { + cache: "no-store", + headers: authHeaders, + }), + fetch("/api/payments/wallets", { + cache: "no-store", + headers: authHeaders, + }), ]); if (configRes.ok) { const configJson = (await configRes.json()) as PaymentConfig; @@ -237,18 +249,25 @@ export function AccountCenter() { } } if (walletsRes.ok) { - const walletsJson = (await walletsRes.json()) as { wallets?: BoundWallet[] }; - const wallets = Array.isArray(walletsJson.wallets) ? walletsJson.wallets : []; + const walletsJson = (await walletsRes.json()) as { + wallets?: BoundWallet[]; + }; + const wallets = Array.isArray(walletsJson.wallets) + ? walletsJson.wallets + : []; setBoundWallets(wallets); - if (wallets.length && !selectedWallet) setSelectedWallet(wallets[0].address); - } - if (configRes.status === 401 || walletsRes.status === 401) { - setPaymentError("登录会话已过期,请重新登录后再进行钱包绑定或支付。"); + if (wallets.length && !selectedWallet) + setSelectedWallet(wallets[0].address); } } catch { // ignore } - }, [backend?.authenticated, buildAuthedHeaders, selectedPlanCode, selectedWallet]); + }, [ + backend?.authenticated, + buildAuthedHeaders, + selectedPlanCode, + selectedWallet, + ]); const loadSnapshot = useCallback(async () => { setErrorText(""); @@ -257,8 +276,14 @@ export function AccountCenter() { ? getSupabaseBrowserClient().auth.getUser() : Promise.resolve({ data: { user: null as User | null } }); const authHeaders = await buildAuthedHeaders(false); - const backendPromise = fetch("/api/auth/me", { cache: "no-store", headers: authHeaders }); - const [userResult, backendResult] = await Promise.all([userPromise, backendPromise]); + const backendPromise = fetch("/api/auth/me", { + cache: "no-store", + headers: authHeaders, + }); + const [userResult, backendResult] = await Promise.all([ + userPromise, + backendPromise, + ]); setUser(userResult.data?.user ?? null); if (!backendResult.ok) { const raw = (await backendResult.text()).slice(0, 260); @@ -307,82 +332,127 @@ export function AccountCenter() { router.replace("/"); }; + // --- Derived State --- const userId = backend?.user_id || user?.id || ""; const isAuthenticated = Boolean(userId); const email = backend?.email || user?.email || ""; - const displayName = String(user?.user_metadata?.full_name || "").trim() || (email ? String(email).split("@")[0] : "") || "PolyWeather 用户"; + const displayName = + String(user?.user_metadata?.full_name || "").trim() || + (email ? String(email).split("@")[0] : "") || + "PolyWeather 用户"; const initials = (displayName.slice(0, 2) || "PW").toUpperCase(); - const providerRaw = normalizeProvider(user); - const provider = providerRaw ? providerRaw.toUpperCase() : "--"; - const locale = typeof navigator !== "undefined" ? navigator.language : "zh-CN"; - const lastSignIn = formatTime(user?.last_sign_in_at, locale); - const joinedAt = formatTime(user?.created_at, locale); - const updatedAtLabel = formatTime(updatedAt, locale); - - const modeLabel = useMemo(() => { - const mode = String(backend?.entitlement_mode || "").trim().toLowerCase(); - if (mode === "supabase_required") return "Supabase 强制登录"; - if (mode === "supabase_optional") return "Supabase 可选登录"; - if (mode === "legacy_token") return "Legacy Token 鉴权"; - if (mode === "disabled") return "未启用鉴权"; - return "未知模式"; - }, [backend?.entitlement_mode]); - - const backendStatus = useMemo(() => { - if (backend?.authenticated) return "通过"; - if (backend?.auth_required) return "未登录"; - return "游客模式"; - }, [backend?.authenticated, backend?.auth_required]); - - const subscriptionRequirement = backend?.subscription_required ? "已启用订阅校验" : "当前未强制订阅"; - const subscriptionResult = !backend?.subscription_required ? "当前未强制订阅" : backend?.subscription_active ? "有效订阅" : "无有效订阅"; + const joinedAt = formatTime(user?.created_at, "zh-CN"); const isSubscribed = Boolean(backend?.subscription_active); + const proExpiry = user?.user_metadata?.pro_expiry || "暂无 Pro 订阅"; - const bindCommand = userId ? `/bind ${userId}${email ? ` ${email}` : ""}` : "/bind "; - - const copyBindCommand = async () => { - try { - await navigator.clipboard.writeText(bindCommand); - setCopied(true); - window.setTimeout(() => setCopied(false), 2000); - } catch { - // ignore - } - }; - - const planList = paymentConfig?.plans || []; - const monthlyPlanList = planList.filter((plan) => String(plan.plan_code || "").trim().toLowerCase() === "pro_monthly"); - const effectivePlanList = monthlyPlanList.length ? monthlyPlanList : planList; - const selectedPlan = effectivePlanList.find((plan) => plan.plan_code === selectedPlanCode) || effectivePlanList[0]; - const paymentFeatureReady = Boolean(paymentConfig?.enabled && paymentConfig?.configured); - const hasPayingWallet = Boolean(String(selectedWallet || walletAddress || boundWallets[0]?.address || "").trim()); - - const pointsRaw = Number(user?.user_metadata?.points ?? user?.user_metadata?.total_points ?? 0); + // Points Logic + const pointsRaw = Number( + user?.user_metadata?.points ?? user?.user_metadata?.total_points ?? 0, + ); const weeklyPointsRaw = Number(user?.user_metadata?.weekly_points ?? 0); const weeklyRankRaw = user?.user_metadata?.weekly_rank; const totalPoints = Number.isFinite(pointsRaw) ? Math.max(0, pointsRaw) : 0; - const weeklyPoints = Number.isFinite(weeklyPointsRaw) ? Math.max(0, weeklyPointsRaw) : 0; + const weeklyPoints = Number.isFinite(weeklyPointsRaw) + ? Math.max(0, weeklyPointsRaw) + : 0; const weeklyRank = weeklyRankRaw == null ? "--" : String(weeklyRankRaw); + const planList = paymentConfig?.plans || []; + const monthlyPlanList = planList.filter( + (plan) => + String(plan.plan_code || "") + .trim() + .toLowerCase() === "pro_monthly", + ); + const effectivePlanList = monthlyPlanList.length ? monthlyPlanList : planList; + const selectedPlan = + effectivePlanList.find((plan) => plan.plan_code === selectedPlanCode) || + effectivePlanList[0]; + const paymentFeatureReady = Boolean( + paymentConfig?.enabled && paymentConfig?.configured, + ); + const hasPayingWallet = Boolean( + String( + selectedWallet || walletAddress || boundWallets[0]?.address || "", + ).trim(), + ); + const billing = useMemo(() => { - const price = Number(selectedPlan?.amount_usdc || 5); - const pointsPerUsdc = 500; - const maxDiscount = 3; - const pointsUsed = Math.min(totalPoints, pointsPerUsdc * maxDiscount); - const discountAmount = usePoints ? Math.min(maxDiscount, pointsUsed / pointsPerUsdc) : 0; - return { price, pointsUsed, discountAmount, payAmount: Math.max(0, price - discountAmount) }; - }, [selectedPlan?.amount_usdc, totalPoints, usePoints]); + const parsedPlanAmount = Number(selectedPlan?.amount_usdc ?? 5); + const planAmount = + Number.isFinite(parsedPlanAmount) && parsedPlanAmount > 0 + ? parsedPlanAmount + : 5; - useEffect(() => { - if (isSubscribed) setShowOverlay(false); - }, [isSubscribed]); + const pointsCfg = paymentConfig?.points_redemption || {}; + const pointsEnabled = pointsCfg.enabled !== false; + const pointsPerUsdcRaw = Number(pointsCfg.points_per_usdc ?? 500); + const pointsPerUsdc = + Number.isFinite(pointsPerUsdcRaw) && pointsPerUsdcRaw > 0 + ? Math.floor(pointsPerUsdcRaw) + : 500; - const waitForReceipt = async (txHash: string, timeoutMs = 120000, pollMs = 3000) => { + const maxDiscountRaw = Number(pointsCfg.max_discount_usdc ?? 3); + const maxDiscountUsdc = Math.max( + 0, + Math.min( + Math.floor(Number.isFinite(maxDiscountRaw) ? maxDiscountRaw : 3), + Math.floor(planAmount), + ), + ); + + const maxRedeemablePoints = pointsPerUsdc * maxDiscountUsdc; + const actualRedeem = pointsEnabled + ? Math.min(totalPoints, maxRedeemablePoints) + : 0; + const discountUnits = Math.floor(actualRedeem / pointsPerUsdc); + const pointsUsed = discountUnits * pointsPerUsdc; + const canRedeem = pointsEnabled && maxDiscountUsdc > 0 && totalPoints >= pointsPerUsdc; + const applyDiscount = usePoints && canRedeem && pointsUsed > 0; + + return { + planAmount, + pointsEnabled, + pointsPerUsdc, + maxDiscountUsdc, + pointsUsed, + discountAmount: discountUnits, + payAmount: planAmount - (applyDiscount ? discountUnits : 0), + canRedeem, + }; + }, [ + paymentConfig?.points_redemption, + selectedPlan?.amount_usdc, + totalPoints, + usePoints, + ]); + + const bindCommand = userId + ? `/bind ${userId}${email ? ` ${email}` : ""}` + : "/bind "; + + const handleCopy = (text: string) => { + navigator.clipboard.writeText(text).then(() => { + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + }); + }; + + // --- Payment Logic (preserved) --- + + const waitForReceipt = async ( + txHash: string, + timeoutMs = 120000, + pollMs = 3000, + ) => { const eth = window.ethereum; if (!eth) throw new Error("MetaMask not found"); const started = Date.now(); while (Date.now() - started < timeoutMs) { - const receipt = (await eth.request({ method: "eth_getTransactionReceipt", params: [txHash] })) as { status?: string } | null; + const receipt = (await eth.request({ + method: "eth_getTransactionReceipt", + params: [txHash], + })) as { status?: string } | null; if (receipt && receipt.status) { if (receipt.status === "0x1") return receipt; throw new Error(`transaction reverted: ${txHash}`); @@ -407,12 +477,15 @@ export function AccountCenter() { setPaymentBusy(true); try { - const accounts = (await eth.request({ method: "eth_requestAccounts" })) as string[]; + const accounts = (await eth.request({ + method: "eth_requestAccounts", + })) as string[]; const address = String(accounts?.[0] || "").toLowerCase(); if (!address) throw new Error("钱包账户为空"); const authHeaders = await buildAuthedHeaders(true); - if (!authHeaders.Authorization) throw new Error("登录会话失效,请重新登录后再绑定钱包。"); + if (!authHeaders.Authorization) + throw new Error("登录会话失效,请重新登录后再绑定钱包。"); setWalletAddress(address); const challengeRes = await fetch("/api/payments/wallets/challenge", { @@ -425,12 +498,18 @@ export function AccountCenter() { throw new Error(`challenge failed: ${raw}`); } - const challengeJson = (await challengeRes.json()) as { nonce?: string; message?: string }; + const challengeJson = (await challengeRes.json()) as { + nonce?: string; + message?: string; + }; const message = String(challengeJson.message || ""); const nonce = String(challengeJson.nonce || ""); if (!message || !nonce) throw new Error("challenge payload invalid"); - const signature = (await eth.request({ method: "personal_sign", params: [message, address] })) as string; + const signature = (await eth.request({ + method: "personal_sign", + params: [message, address], + })) as string; const verifyRes = await fetch("/api/payments/wallets/verify", { method: "POST", headers: authHeaders, @@ -468,7 +547,9 @@ export function AccountCenter() { return; } - const payingWallet = String(selectedWallet || walletAddress || boundWallets[0]?.address || "").toLowerCase(); + const payingWallet = String( + selectedWallet || walletAddress || boundWallets[0]?.address || "", + ).toLowerCase(); if (!payingWallet) { setPaymentError("请先绑定钱包。"); return; @@ -477,13 +558,19 @@ export function AccountCenter() { setPaymentBusy(true); try { const authHeaders = await buildAuthedHeaders(true); - if (!authHeaders.Authorization) throw new Error("登录会话失效,请重新登录后再支付。"); + if (!authHeaders.Authorization) + throw new Error("登录会话失效,请重新登录后再支付。"); - const currentChainIdHex = String((await eth.request({ method: "eth_chainId" })) || ""); + const currentChainIdHex = String( + (await eth.request({ method: "eth_chainId" })) || "", + ); const targetChainId = Number(paymentConfig.chain_id || 137); const targetChainHex = `0x${targetChainId.toString(16)}`; if (currentChainIdHex.toLowerCase() !== targetChainHex.toLowerCase()) { - await eth.request({ method: "wallet_switchEthereumChain", params: [{ chainId: targetChainHex }] }); + await eth.request({ + method: "wallet_switchEthereumChain", + params: [{ chainId: targetChainHex }], + }); } const createRes = await fetch("/api/payments/intents", { @@ -493,6 +580,8 @@ export function AccountCenter() { plan_code: selectedPlan?.plan_code || "pro_monthly", payment_mode: "strict", allowed_wallet: payingWallet, + use_points: billing.canRedeem && usePoints, + points_to_consume: billing.canRedeem && usePoints ? billing.pointsUsed : 0, metadata: { source: "account_center" }, }), }); @@ -504,16 +593,24 @@ export function AccountCenter() { const created = (await createRes.json()) as CreatedIntent; const intentId = String(created.intent?.intent_id || ""); const txPayload = created.tx_payload; - if (!intentId || !txPayload?.to || !txPayload?.data) throw new Error("intent payload invalid"); + if (!intentId || !txPayload?.to || !txPayload?.data) + throw new Error("intent payload invalid"); setLastIntentId(intentId); const tokenAddress = String(txPayload.token_address || "").toLowerCase(); const amountUnits = BigInt(String(txPayload.amount_units || "0")); - if (!tokenAddress.startsWith("0x") || amountUnits <= 0n) throw new Error("intent token/amount invalid"); + if (!tokenAddress.startsWith("0x") || amountUnits <= 0n) + throw new Error("intent token/amount invalid"); const allowanceHex = (await eth.request({ method: "eth_call", - params: [{ to: tokenAddress, data: buildAllowanceCalldata(payingWallet, txPayload.to) }, "latest"], + params: [ + { + to: tokenAddress, + data: buildAllowanceCalldata(payingWallet, txPayload.to), + }, + "latest", + ], })) as string; const allowance = BigInt(String(allowanceHex || "0x0")); @@ -521,7 +618,14 @@ export function AccountCenter() { setPaymentInfo("检测到授权不足,正在发起 USDC 授权..."); const approveHash = (await eth.request({ method: "eth_sendTransaction", - params: [{ from: payingWallet, to: tokenAddress, data: buildApproveCalldata(txPayload.to, amountUnits), value: "0x0" }], + params: [ + { + from: payingWallet, + to: tokenAddress, + data: buildApproveCalldata(txPayload.to, amountUnits), + value: "0x0", + }, + ], })) as string; await waitForReceipt(String(approveHash || "")); setPaymentInfo("USDC 授权成功,正在发起支付..."); @@ -531,26 +635,42 @@ export function AccountCenter() { const txHash = (await eth.request({ method: "eth_sendTransaction", - params: [{ from: payingWallet, to: txPayload.to, data: txPayload.data, value: txPayload.value || "0x0" }], + params: [ + { + from: payingWallet, + to: txPayload.to, + data: txPayload.data, + value: txPayload.value || "0x0", + }, + ], })) as string; const txHashNorm = String(txHash || "").toLowerCase(); setLastTxHash(txHashNorm); - const submitRes = await fetch(`/api/payments/intents/${intentId}/submit`, { - method: "POST", - headers: authHeaders, - body: JSON.stringify({ tx_hash: txHashNorm, from_address: payingWallet }), - }); + const submitRes = await fetch( + `/api/payments/intents/${intentId}/submit`, + { + method: "POST", + headers: authHeaders, + body: JSON.stringify({ + tx_hash: txHashNorm, + from_address: payingWallet, + }), + }, + ); if (!submitRes.ok) { const raw = (await submitRes.text()).slice(0, 350); throw new Error(`submit tx failed: ${raw}`); } - const confirmRes = await fetch(`/api/payments/intents/${intentId}/confirm`, { - method: "POST", - headers: authHeaders, - body: JSON.stringify({ tx_hash: txHashNorm }), - }); + const confirmRes = await fetch( + `/api/payments/intents/${intentId}/confirm`, + { + method: "POST", + headers: authHeaders, + body: JSON.stringify({ tx_hash: txHashNorm }), + }, + ); if (!confirmRes.ok) { const raw = (await confirmRes.text()).slice(0, 350); setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待确认中。`); @@ -569,11 +689,7 @@ export function AccountCenter() { const handleOverlayCheckout = async () => { if (!isAuthenticated) { - router.push("/auth/login?next=%2Faccount"); - return; - } - if (!paymentFeatureReady) { - setPaymentError("支付服务未配置完成,请稍后再试。"); + setPaymentError("请先登录后再支付。"); return; } if (!hasPayingWallet) { @@ -583,314 +699,469 @@ export function AccountCenter() { await createIntentAndPay(); }; + // --- Render --- + + if (loading && !refreshing) { + return ( +
+
+ +

加载账户信息中...

+
+
+ ); + } + return ( -
-
-
+
+ {/* Aurora Shadows */} +
+
-
-
-
- - - -
-

账户中心

-

积分体系 v4.3 · 管理身份、订阅与 Bot 绑定

-
+ {/* Header */} +
+
+ + + +
+

+ 账户中心 +

+

+ 积分体系 v4.3 · 管理身份与订阅计划 +

-
- {!isSubscribed && paymentFeatureReady ? ( - - ) : null} -
+
+ {!isSubscribed && !showOverlay && paymentFeatureReady && ( + - {isAuthenticated ? ( - + )} + + {isAuthenticated ? ( + + ) : ( + + 登录 + + )} +
+
+ +
+ {/* User Card */} +
+
+
+ {initials} +
+
+ +
-
- - {errorText ?
加载失败: {errorText}
: null} - {!supabaseReady ?
NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY 未配置。
: null} - -
-
-
最近同步: {updatedAtLabel}
-
-
-
{initials}
-
+
+
+

{displayName}

+ + {isSubscribed ? "PRO MEMBER" : "FREE TIER"} + +
+

+ {email || "游客用户"} +

+
+
+ {" "} + + {userId ? `${userId.substring(0, 12)}...` : "--"} +
-
-
-

{displayName}

- {isSubscribed ? "PRO MEMBER" : "FREE TIER"} - {isAuthenticated ? "已登录" : "游客"} -
-

{email || "--"}

-
- {userId ? `${userId.slice(0, 12)}...` : "--"} - 加入时间: {joinedAt} -
-
-
-

总积分

{totalPoints.toLocaleString("en-US")}

-

周排行

#{weeklyRank}

本周积分: {weeklyPoints}

+
+ 加入时间: {joinedAt}
-
- -
-
-

周榜奖励

-
Top 1+500 pts & 7D Pro
Top 2-3+300 pts & 3D Pro
Top 4-10+150 pts
+
+
+
+

+ 总积分 (荣誉) +

+

+ {" "} + {totalPoints.toLocaleString()} +

-

周榜每周一结算。发言积分永久累计,可用于订阅抵扣。

-
+
+

+ 周排行 (竞技) +

+

+ #{weeklyRank} +

+
+
+
-
-

会员与权限

- - - - -
+ {/* Weekly Ranking Motivation */} +
+
+

+ 周榜奖励 +

+
+
+ +
+ 1 +
{" "} + Top 1 +
+ + +500 pts & 7D Pro + +
+
+ +
+ 2 +
{" "} + Top 2-3 +
+ + +300 pts & 3D Pro + +
+
+ +
+ 4 +
{" "} + Top 4-10 +
+ + +150 pts + +
+
+
+
+ +

+ 积分规则:群内有效发言(自动防刷检测)。每周一零点结算并重置周积分榜。 +

+
+
-
-

身份信息

- - - - -
+ {/* Subscription Info & Paywall */} +
+
+
+

+ 会员权限详情 +

+ + + + +
+
+

+ 身份状态 +

+ + + + +
+
-
-
-
-
-

- 已绑定钱包 -

- + +
+ +
+ +

+ 开启 PolyWeather Pro +

+

+ 解锁 15 + 天高精度趋势分析、实时雷达与闪电追踪。尊享全平台无广告体验。 +

+ +
+ {/* Plan Card */} +
+
+ 月付套餐 +
+

+ PRO PLAN +

+
+ + ${billing.planAmount.toFixed(2)} + + / 月 +
+
+ + {/* Points Card */} +
- 连接并绑定 MetaMask - +
+ + 积分抵扣 + + +
+
+ + -${billing.discountAmount.toFixed(2)} + + OFF +
+

+ {!billing.pointsEnabled + ? "积分抵扣未开启" + : !billing.canRedeem + ? `积分不足 (当前 ${totalPoints},至少 ${billing.pointsPerUsdc})` + : usePoints + ? `已自动消耗 ${billing.pointsUsed} 积分` + : `最多抵扣 $${billing.maxDiscountUsdc.toFixed(2)}`} +

+
- {!isAuthenticated ? ( -

请先登录,再进行钱包绑定与支付。

- ) : boundWallets.length ? ( -
- {boundWallets.map((wallet) => { - const active = String(selectedWallet || walletAddress).toLowerCase() === String(wallet.address || "").toLowerCase(); - return ( - - ); - })} -
- ) : ( -

暂无已绑定钱包。

- )} -
- -
-
-

- 选择套餐并支付 -

- - Chain #{paymentConfig?.chain_id ?? 137} - -
- - {paymentFeatureReady ? ( - <> -
- {effectivePlanList.length ? ( - effectivePlanList.map((plan) => { - const checked = (selectedPlan?.plan_code || "") === plan.plan_code; - return ( - - ); - }) - ) : ( -
- 套餐配置为空,请检查后端计划配置。 -
- )} -
- -
-
- 积分抵扣 - -
-
- 最多可抵扣 $3(500 分 = $1) - 消耗积分: {usePoints ? billing.pointsUsed : 0} -
-
- 应付金额 - ${billing.payAmount.toFixed(2)} -
-
- - - - ) : ( -
- 支付功能暂未启用或未配置完成。 -
- )} - - {paymentInfo ?
{paymentInfo}
: null} - {paymentError ?
{paymentError}
: null} - {lastIntentId ?
Intent: {lastIntentId}
: null} - {lastTxHash ?
Tx: {shortAddress(lastTxHash)}
: null} -
-
- - {!isSubscribed && showOverlay ? ( -
-
- - -
- -
- -

开启 PolyWeather Pro

-

- 解锁今日日内分析、历史对账、未来日期分析等 Pro 功能。 -

- -
-
今日日内分析
-
历史对账
-
未来日期分析
-
无广告体验
-
- -
-
- 月付套餐 - USD {selectedPlan?.amount_usdc || "5"} / 30天 -
-
- 积分抵扣 - -USD {billing.discountAmount.toFixed(2)} -
-
- 应付总计 - USD {billing.payAmount.toFixed(2)} -
+
+
+ 应付总计: + + ${billing.payAmount.toFixed(2)} +
-
- 所有交易均在链上确认并加密传输 +
+ + 安全加密支付 + + + 常见问题 (FAQ) +
+ + {paymentError && ( +
+ {paymentError} +
+ )} + {paymentInfo && ( +
+ {paymentInfo} +
+ )}
- ) : null} -
+
+ )} +
-
- + {/* Telegram Bot Section */} +
+
+
-

- Bot 绑定 +

+ Telegram Bot 绑定

-

- 发送以下命令到 Telegram Bot,绑定网页身份并同步权限状态。 +

+ 将下方命令发送给 Bot,实现全平台气象推送与权限同步。

-
- +
+ {bindCommand}

- -
- PolyWeather Global Meteorological Engine -
-
+ {/* Payment Details / Wallet Management */} +
+
+

+ 支付管理 +

+ {boundWallets.length ? ( +
+ {boundWallets.map((w) => ( +
+
+ + {shortAddress(w.address)} + + {w.is_primary && ( + + Primary + + )} +
+
Polygon Chain
+
+ ))} +
+ ) : ( +

+ 未绑定任何收件钱包 +

+ )} +
+ + +
+
+ + +
+ PolyWeather Global Meteorological Engine · Powered by AI +
); } + +function PlusIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index e0fbe487..a4b76f2b 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -549,6 +549,25 @@ flex-wrap: wrap; } +.root :global(.pro-locked) { + filter: grayscale(0.8) opacity(0.7); + position: relative; +} + +.root :global(.pro-locked::after) { + content: "PRO"; + position: absolute; + top: -4px; + right: -4px; + background: var(--accent-blue); + color: white; + font-size: 7px; + padding: 1px 3px; + border-radius: 3px; + font-weight: 900; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); +} + .root :global(.risk-badge) { font-size: 11px; font-weight: 600; diff --git a/frontend/components/dashboard/DetailPanel.tsx b/frontend/components/dashboard/DetailPanel.tsx index 37b8b91c..a015edb7 100644 --- a/frontend/components/dashboard/DetailPanel.tsx +++ b/frontend/components/dashboard/DetailPanel.tsx @@ -14,103 +14,102 @@ import { getRiskBadgeLabel, getTemperatureChartData, } from "@/lib/dashboard-utils"; +import { ChevronRight, Crown } from "lucide-react"; +import { useRouter } from "next/navigation"; function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) { const { locale, t } = useI18n(); const chartData = getTemperatureChartData(detail, locale); - const canvasRef = useChart( - () => { - if (!chartData) { - return { - data: { datasets: [], labels: [] }, - type: "line", - } satisfies ChartConfiguration<"line">; - } - - const forecastPoints = chartData.datasets.hasMgmHourly - ? chartData.datasets.mgmHourlyPoints - : chartData.datasets.debPast.map( - (value, index) => value ?? chartData.datasets.debFuture[index], - ); - + const canvasRef = useChart(() => { + if (!chartData) { return { - data: { - datasets: [ - { - borderColor: chartData.datasets.hasMgmHourly - ? "rgba(250, 204, 21, 0.92)" - : "rgba(52, 211, 153, 0.86)", - borderWidth: 1.8, - data: forecastPoints, - fill: false, - label: chartData.datasets.hasMgmHourly - ? locale === "en-US" - ? "MGM Forecast" - : "MGM 预测" - : locale === "en-US" - ? "DEB Forecast" - : "DEB 预测", - pointRadius: 0, - spanGaps: true, - tension: 0.28, - }, - { - backgroundColor: "#22d3ee", - borderColor: "#22d3ee", - borderWidth: 0, - data: chartData.datasets.metarPoints, - fill: false, - label: locale === "en-US" ? "METAR Observation" : "METAR 实测", - pointHoverRadius: 6, - pointRadius: 3.8, - showLine: false, - }, - ], - labels: chartData.times, - }, - options: { - interaction: { intersect: false, mode: "index" }, - maintainAspectRatio: false, - plugins: { - legend: { display: false }, - tooltip: { - backgroundColor: "rgba(15, 23, 42, 0.95)", - borderColor: "rgba(34, 211, 238, 0.25)", - borderWidth: 1, - }, - }, - responsive: true, - scales: { - x: { - grid: { color: "rgba(255,255,255,0.03)" }, - ticks: { - callback: (_value, index) => - typeof index === "number" && index % 4 === 0 - ? chartData.times[index] - : "", - color: "#64748b", - font: { size: 10 }, - maxRotation: 0, - }, - }, - y: { - grid: { color: "rgba(255,255,255,0.03)" }, - max: chartData.max, - min: chartData.min, - ticks: { - callback: (value) => `${value}${detail.temp_symbol || "°C"}`, - color: "#64748b", - font: { size: 10 }, - }, - }, - }, - }, + data: { datasets: [], labels: [] }, type: "line", } satisfies ChartConfiguration<"line">; - }, - [chartData, detail.temp_symbol, locale], - ); + } + + const forecastPoints = chartData.datasets.hasMgmHourly + ? chartData.datasets.mgmHourlyPoints + : chartData.datasets.debPast.map( + (value, index) => value ?? chartData.datasets.debFuture[index], + ); + + return { + data: { + datasets: [ + { + borderColor: chartData.datasets.hasMgmHourly + ? "rgba(250, 204, 21, 0.92)" + : "rgba(52, 211, 153, 0.86)", + borderWidth: 1.8, + data: forecastPoints, + fill: false, + label: chartData.datasets.hasMgmHourly + ? locale === "en-US" + ? "MGM Forecast" + : "MGM 预测" + : locale === "en-US" + ? "DEB Forecast" + : "DEB 预测", + pointRadius: 0, + spanGaps: true, + tension: 0.28, + }, + { + backgroundColor: "#22d3ee", + borderColor: "#22d3ee", + borderWidth: 0, + data: chartData.datasets.metarPoints, + fill: false, + label: locale === "en-US" ? "METAR Observation" : "METAR 实测", + pointHoverRadius: 6, + pointRadius: 3.8, + showLine: false, + }, + ], + labels: chartData.times, + }, + options: { + interaction: { intersect: false, mode: "index" }, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: "rgba(15, 23, 42, 0.95)", + borderColor: "rgba(34, 211, 238, 0.25)", + borderWidth: 1, + }, + }, + responsive: true, + scales: { + x: { + grid: { color: "rgba(255,255,255,0.03)" }, + ticks: { + callback: (_value, index) => + typeof index === "number" && index % 4 === 0 + ? chartData.times[index] + : "", + color: "#64748b", + font: { size: 10 }, + maxRotation: 0, + }, + }, + y: { + grid: { color: "rgba(255,255,255,0.03)" }, + max: chartData.max, + min: chartData.min, + ticks: { + callback: (value) => `${value}${detail.temp_symbol || "°C"}`, + color: "#64748b", + font: { size: 10 }, + }, + }, + }, + }, + type: "line", + } satisfies ChartConfiguration<"line">; + }, [chartData, detail.temp_symbol, locale]); return (
@@ -126,6 +125,7 @@ function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) { export function DetailPanel() { const store = useDashboardStore(); + const router = useRouter(); const { locale, t } = useI18n(); const detail = store.selectedDetail; const isPro = store.proAccess.subscriptionActive; @@ -194,39 +194,40 @@ export function DetailPanel() { {getRiskBadgeLabel(detail?.risk?.level, locale)} - - {detail - ? `${detail.local_date} ${detail.local_time}` - : t("detail.waitSelect")} - - - + : `${t("detail.todayAnalysis")} · Pro`} + + +
@@ -268,7 +269,9 @@ export function DetailPanel() { ) : (
- {detail.display_name} + + {detail.display_name} + {t("detail.sceneryTitle")} @@ -291,9 +294,33 @@ export function DetailPanel() {
-
+

{t("detail.todayMiniTrend")}

- +
+ +
+ {!isPro && ( +
+
+ Pro Feature +
+

+ 解锁日内高精趋势图 +

+ +
+ )}
diff --git a/frontend/components/dashboard/ProFeaturePaywall.tsx b/frontend/components/dashboard/ProFeaturePaywall.tsx index 905b10af..4ca33adb 100644 --- a/frontend/components/dashboard/ProFeaturePaywall.tsx +++ b/frontend/components/dashboard/ProFeaturePaywall.tsx @@ -13,7 +13,7 @@ import { } from "lucide-react"; import { useI18n } from "@/hooks/useI18n"; import { useDashboardStore } from "@/hooks/useDashboardStore"; -import { useState } from "react"; +import { useMemo, useState } from "react"; type ProFeaturePaywallProps = { feature: "today" | "history" | "future"; @@ -32,148 +32,143 @@ export function ProFeaturePaywall({ // Redemption logic: 500 points = $1 discount // Max discount $3 (requires 1500 points) - const maxRedeemablePoints = 1500; + const PRO_PRICE = 5; + const POINTS_VAL = 500; + const MAX_DISCOUNT = 3; + const pointsAvailable = proAccess.points || 0; - const effectivePoints = Math.min(pointsAvailable, maxRedeemablePoints); - const discountAmount = usePoints ? Math.floor(effectivePoints / 500) : 0; - const originalPrice = 5.0; - const finalPrice = originalPrice - discountAmount; - const pointsToConsume = discountAmount * 500; + + const billing = useMemo(() => { + const maxRedeemable = MAX_DISCOUNT * POINTS_VAL; + const actualRedeem = Math.min(pointsAvailable, maxRedeemable); + const discount = Math.floor(actualRedeem / POINTS_VAL); + return { + pointsUsed: discount * POINTS_VAL, + discountAmount: discount, + payAmount: PRO_PRICE - (usePoints ? discount : 0), + }; + }, [usePoints, pointsAvailable]); return ( -
-
+
+
+ {/* 关闭按钮 */} {onClose && ( )} {/* Crown Badge */} -
- +
+
-
-

- {isEn ? "Unlock PolyWeather Pro" : "开启 PolyWeather Pro"} -

-

- {isEn - ? "Unlock 15-day precision trends, real-time radar, and ad-free experience across all platforms." - : "解锁 15 天高精度趋势分析、实时雷达与闪电追踪。尊享全平台无广告体验。"} -

-
+

+ {isEn ? "Unlock PolyWeather Pro" : "开启 PolyWeather Pro"} +

+

+ {isEn + ? "Unlock 15-day precision trends, real-time radar, and ad-free experience across all platforms." + : "解锁 15 天高精度趋势分析、实时雷达与闪电追踪。尊享全平台无广告体验。"} +

- {/* Pricing Cards */} -
- {/* Plan Card */} -
-
+
+ {/* 订阅方案卡片 */} +
+
{isEn ? "Monthly" : "月付套餐"}
-
+

PRO PLAN -

-
+

+
$5.00 - + / {isEn ? "mo" : "月"}
- {/* Points Card */} + {/* 积分抵扣卡片 */}
= 500 - ? "border-indigo-500/30 bg-indigo-500/5" - : "border-white/5 bg-white/5 opacity-60" - }`} + className={`p-6 rounded-3xl border transition-all ${usePoints && pointsAvailable >= 500 ? "bg-indigo-600/20 border-indigo-500/50" : "bg-white/5 border-white/10"}`} > -
+
= 500 - ? "text-indigo-400" - : "text-slate-500" - }`} + className={`text-[10px] font-bold uppercase tracking-widest ${usePoints && pointsAvailable >= 500 ? "text-indigo-400" : "text-slate-500"}`} > {isEn ? "Points Credit" : "积分抵扣"}
-
+
= 500 ? "text-emerald-400" : "text-slate-500"}`} > - -${discountAmount.toFixed(2)} + -${billing.discountAmount.toFixed(2)} - + OFF
-
+

{pointsAvailable < 500 ? isEn - ? "Need 500+ points" + ? `Need 500+ points (Current: ${pointsAvailable})` : `积分不足 (当前 ${pointsAvailable})` - : isEn - ? `Auto-consume ${pointsToConsume} points` - : `已自动消耗 ${pointsToConsume} 积分`} -

+ : usePoints + ? isEn + ? `Consumed ${billing.pointsUsed} points` + : `已自动消耗 ${billing.pointsUsed} 积分` + : isEn + ? "Up to $3.00 off" + : "开启后最多抵扣 $3.00"} +

- {/* Total & Action */} -
-
- +
+
+ {isEn ? "Total Due:" : "应付总计:"} - ${finalPrice.toFixed(2)} + ${billing.payAmount.toFixed(2)}
{isEn ? "Connect Wallet & Pay" : "连接钱包并支付"} -
- - +
+ + {" "} {isEn ? "Secured Payment" : "安全加密支付"} {isEn ? "FAQ" : "常见问题 (FAQ)"} diff --git a/src/payments/contract_checkout.py b/src/payments/contract_checkout.py index 5f60c7aa..fd5a1c52 100644 --- a/src/payments/contract_checkout.py +++ b/src/payments/contract_checkout.py @@ -6,7 +6,7 @@ import secrets import threading from dataclasses import dataclass from datetime import datetime, timedelta, timezone -from decimal import Decimal, InvalidOperation +from decimal import Decimal, InvalidOperation, ROUND_FLOOR from typing import Any, Dict, List, Optional import requests @@ -196,6 +196,7 @@ class PaymentIntentRecord: allowed_wallet: Optional[str] expires_at: str tx_hash: Optional[str] + metadata: Dict[str, Any] class PaymentCheckoutError(Exception): @@ -254,6 +255,11 @@ class PaymentContractCheckoutService: self.notify_telegram = _env_bool( "POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED", True ) + self.points_enabled = _env_bool("POLYWEATHER_PAYMENT_POINTS_ENABLED", True) + self.points_per_usdc = max(1, _env_int("POLYWEATHER_PAYMENT_POINTS_PER_USDC", 500)) + self.points_max_discount_usdc = max( + 0, _env_int("POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC", 3) + ) self._w3_lock = threading.Lock() self._w3: Optional[Web3] = None self._event_topic = Web3.keccak( @@ -327,6 +333,245 @@ class PaymentContractCheckoutService: except Exception: return None + def _admin_auth_headers(self) -> Dict[str, str]: + return { + "apikey": self.supabase_service_role_key, + "Authorization": f"Bearer {self.supabase_service_role_key}", + "Accept": "application/json", + "Content-Type": "application/json", + } + + def _auth_admin_request( + self, + method: str, + path: str, + *, + payload: Optional[Dict[str, Any]] = None, + allowed_status: Optional[List[int]] = None, + ) -> Any: + url = f"{self.supabase_url}/auth/v1{path}" + status_ok = allowed_status or [200] + try: + response = requests.request( + method=method.upper(), + url=url, + json=payload, + headers=self._admin_auth_headers(), + timeout=self.timeout_sec, + ) + except Exception as exc: + raise PaymentCheckoutError(503, f"supabase auth request failed: {exc}") from exc + if response.status_code not in status_ok: + detail = response.text[:350] if response.text else response.reason + raise PaymentCheckoutError( + 502, + ( + f"supabase auth {method.upper()} {path} failed: " + f"{response.status_code} {detail}" + ), + ) + if not response.content: + return None + try: + return response.json() + except Exception: + return None + + def _extract_user_metadata(self, user_payload: Any) -> Dict[str, Any]: + if not isinstance(user_payload, dict): + return {} + if isinstance(user_payload.get("user_metadata"), dict): + return dict(user_payload.get("user_metadata") or {}) + user_obj = user_payload.get("user") + if isinstance(user_obj, dict) and isinstance(user_obj.get("user_metadata"), dict): + return dict(user_obj.get("user_metadata") or {}) + return {} + + def _extract_points_from_metadata(self, metadata: Dict[str, Any]) -> int: + if not isinstance(metadata, dict): + return 0 + for key in ("points", "total_points"): + raw = metadata.get(key) + if raw is None: + continue + try: + return max(0, int(raw)) + except Exception: + continue + return 0 + + def _auth_admin_get_user(self, user_id: str) -> Dict[str, Any]: + user_id_text = str(user_id or "").strip() + if not user_id_text: + raise PaymentCheckoutError(400, "user_id required") + data = self._auth_admin_request( + "GET", + f"/admin/users/{user_id_text}", + allowed_status=[200], + ) + if isinstance(data, dict): + user_obj = data.get("user") + if isinstance(user_obj, dict): + return user_obj + return data + return {} + + def _auth_admin_update_user_metadata( + self, + user_id: str, + metadata: Dict[str, Any], + ) -> Dict[str, Any]: + user_id_text = str(user_id or "").strip() + if not user_id_text: + raise PaymentCheckoutError(400, "user_id required") + payload = {"user_metadata": metadata or {}} + data = self._auth_admin_request( + "PUT", + f"/admin/users/{user_id_text}", + payload=payload, + allowed_status=[200], + ) + if isinstance(data, dict): + user_obj = data.get("user") + if isinstance(user_obj, dict): + return user_obj + return data + return {} + + def _build_points_redemption( + self, + *, + user_id: str, + plan_amount_usdc: Decimal, + use_points: bool, + requested_points_to_consume: Optional[int], + ) -> Dict[str, Any]: + base = { + "enabled": bool(self.points_enabled), + "applied": False, + "points_per_usdc": int(self.points_per_usdc), + "max_discount_usdc": int(self.points_max_discount_usdc), + "points_balance_snapshot": 0, + "points_to_consume": 0, + "discount_usdc": "0", + "pay_amount_usdc": plan_amount_usdc, + } + if not self.points_enabled: + return base + if not use_points: + return base + if plan_amount_usdc <= 0: + return base + user_obj = self._auth_admin_get_user(user_id) + metadata = self._extract_user_metadata(user_obj) + balance = self._extract_points_from_metadata(metadata) + base["points_balance_snapshot"] = balance + if balance <= 0: + return base + + max_discount_usdc = min( + Decimal(int(self.points_max_discount_usdc)), + plan_amount_usdc, + ) + max_points_by_plan = int( + (max_discount_usdc * Decimal(int(self.points_per_usdc))).to_integral_value( + rounding=ROUND_FLOOR + ) + ) + if max_points_by_plan <= 0: + return base + + desired_points = max_points_by_plan + if requested_points_to_consume is not None: + try: + desired_points = max(0, int(requested_points_to_consume)) + except Exception: + desired_points = 0 + candidate_points = min(balance, max_points_by_plan, desired_points) + if candidate_points <= 0: + return base + + normalized_points = (candidate_points // int(self.points_per_usdc)) * int( + self.points_per_usdc + ) + if normalized_points <= 0: + return base + discount_units = normalized_points // int(self.points_per_usdc) + discount_usdc = Decimal(discount_units) + pay_amount = plan_amount_usdc - discount_usdc + if pay_amount <= 0: + return base + + base["applied"] = True + base["points_to_consume"] = int(normalized_points) + base["discount_usdc"] = _format_decimal(discount_usdc) + base["pay_amount_usdc"] = pay_amount + return base + + def _consume_points_for_intent( + self, + user_id: str, + intent: PaymentIntentRecord, + ) -> Dict[str, Any]: + result = { + "enabled": bool(self.points_enabled), + "applied": False, + "points_per_usdc": int(self.points_per_usdc), + "points_redeemed": 0, + "points_before": 0, + "points_after": 0, + "discount_usdc": "0", + } + if not self.points_enabled: + return result + + metadata = dict(intent.metadata or {}) + redemption = metadata.get("points_redemption") + if not isinstance(redemption, dict): + return result + if not bool(redemption.get("applied")): + return result + if bool(redemption.get("consumed")): + result["applied"] = True + result["points_redeemed"] = int(redemption.get("consumed_points") or 0) + result["points_after"] = int(redemption.get("points_after") or 0) + result["discount_usdc"] = str(redemption.get("discount_usdc") or "0") + return result + + planned_points = int(redemption.get("points_to_consume") or 0) + if planned_points <= 0: + return result + + user_obj = self._auth_admin_get_user(user_id) + user_metadata = self._extract_user_metadata(user_obj) + points_before = self._extract_points_from_metadata(user_metadata) + if points_before <= 0: + return result + + redeemable = min(points_before, planned_points) + redeemable = (redeemable // int(self.points_per_usdc)) * int(self.points_per_usdc) + if redeemable <= 0: + return result + + points_after = points_before - redeemable + updated_metadata = dict(user_metadata or {}) + if "points" in updated_metadata: + updated_metadata["points"] = points_after + if "total_points" in updated_metadata: + updated_metadata["total_points"] = points_after + if "points" not in updated_metadata and "total_points" not in updated_metadata: + updated_metadata["points"] = points_after + updated_metadata["total_points"] = points_after + self._auth_admin_update_user_metadata(user_id, updated_metadata) + + discount_usdc = Decimal(redeemable // int(self.points_per_usdc)) + result["applied"] = True + result["points_redeemed"] = int(redeemable) + result["points_before"] = int(points_before) + result["points_after"] = int(points_after) + result["discount_usdc"] = _format_decimal(discount_usdc) + return result + def _get_web3(self) -> Web3: with self._w3_lock: if self._w3 is None: @@ -355,6 +600,11 @@ class PaymentContractCheckoutService: "intent_ttl_sec": self.intent_ttl_sec, "event_name": "OrderPaid", "event_topic0": self._event_topic, + "points_redemption": { + "enabled": bool(self.points_enabled), + "points_per_usdc": int(self.points_per_usdc), + "max_discount_usdc": int(self.points_max_discount_usdc), + }, "plans": [ { "plan_code": plan_code, @@ -386,6 +636,7 @@ class PaymentContractCheckoutService: allowed_wallet=_normalize_address(row.get("allowed_wallet") or "") or None, expires_at=str(row.get("expires_at")), tx_hash=str(row.get("tx_hash") or "") or None, + metadata=dict(row.get("metadata") or {}) if isinstance(row.get("metadata"), dict) else {}, ) def list_wallets(self, user_id: str) -> List[WalletBindingRecord]: @@ -645,17 +896,17 @@ class PaymentContractCheckoutService: payment_mode: str = "strict", allowed_wallet: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, + use_points: bool = False, + points_to_consume: Optional[int] = None, ) -> Dict[str, Any]: self._ensure_enabled() plan = self._select_plan(plan_code) mode = str(payment_mode or "strict").strip().lower() if mode not in {"strict", "flex"}: raise PaymentCheckoutError(400, "payment_mode must be strict or flex") - bound_wallets = self.list_wallets(user_id) if not bound_wallets: raise PaymentCheckoutError(403, "bind wallet first") - target_wallet = _normalize_address(allowed_wallet or "") if mode == "strict": if target_wallet: @@ -668,8 +919,29 @@ class PaymentContractCheckoutService: target_wallet = primary.address if primary else bound_wallets[0].address elif target_wallet: self._require_user_wallet(user_id, target_wallet) - - amount_units = _decimal_to_units(plan["amount_usdc_decimal"], self.token_decimals) + plan_amount_usdc = plan["amount_usdc_decimal"] + redemption = self._build_points_redemption( + user_id=user_id, + plan_amount_usdc=plan_amount_usdc, + use_points=bool(use_points), + requested_points_to_consume=points_to_consume, + ) + final_amount_usdc = redemption["pay_amount_usdc"] + amount_units = _decimal_to_units(final_amount_usdc, self.token_decimals) + if amount_units <= 0: + raise PaymentCheckoutError(400, "invalid final payment amount") + combined_metadata = dict(metadata or {}) + combined_metadata["amount_before_discount_usdc"] = _format_decimal(plan_amount_usdc) + combined_metadata["amount_after_discount_usdc"] = _format_decimal(final_amount_usdc) + combined_metadata["points_redemption"] = { + "enabled": bool(redemption.get("enabled")), + "applied": bool(redemption.get("applied")), + "points_per_usdc": int(redemption.get("points_per_usdc") or self.points_per_usdc), + "max_discount_usdc": int(redemption.get("max_discount_usdc") or self.points_max_discount_usdc), + "points_balance_snapshot": int(redemption.get("points_balance_snapshot") or 0), + "points_to_consume": int(redemption.get("points_to_consume") or 0), + "discount_usdc": str(redemption.get("discount_usdc") or "0"), + } order_id_hex = "0x" + secrets.token_hex(32) now = _now_utc() expires_at = now + timedelta(seconds=self.intent_ttl_sec) @@ -689,7 +961,7 @@ class PaymentContractCheckoutService: "order_id_hex": order_id_hex, "status": "created", "expires_at": _to_iso(expires_at), - "metadata": metadata or {}, + "metadata": combined_metadata, "created_at": _to_iso(now), "updated_at": _to_iso(now), }, @@ -706,9 +978,16 @@ class PaymentContractCheckoutService: "plan_code": plan["plan_code"], "plan_id": plan["plan_id"], "duration_days": plan["duration_days"], + "amount_before_discount_usdc": _format_decimal(plan_amount_usdc), + "amount_after_discount_usdc": _format_decimal(final_amount_usdc), + }, + "points_redemption": { + "applied": bool(redemption.get("applied")), + "points_to_consume": int(redemption.get("points_to_consume") or 0), + "discount_usdc": str(redemption.get("discount_usdc") or "0"), + "points_balance_snapshot": int(redemption.get("points_balance_snapshot") or 0), }, } - def get_intent(self, user_id: str, intent_id: str) -> PaymentIntentRecord: self._ensure_enabled() rows = self._rest( @@ -717,7 +996,7 @@ class PaymentContractCheckoutService: params={ "select": ( "id,user_id,plan_code,plan_id,chain_id,token_address,receiver_address," - "amount_units,payment_mode,allowed_wallet,order_id_hex,status,expires_at,tx_hash" + "amount_units,payment_mode,allowed_wallet,order_id_hex,status,expires_at,tx_hash,metadata" ), "id": f"eq.{intent_id}", "user_id": f"eq.{user_id}", @@ -992,24 +1271,20 @@ class PaymentContractCheckoutService: return {"intent": intent.__dict__, "already_confirmed": True} if intent.status in {"failed", "cancelled", "expired"}: raise PaymentCheckoutError(409, f"intent status is {intent.status}") - tx_hash_text = str(tx_hash or intent.tx_hash or "").strip().lower() if not tx_hash_text: raise PaymentCheckoutError(400, "tx_hash required") if not (tx_hash_text.startswith("0x") and len(tx_hash_text) == 66): raise PaymentCheckoutError(400, "invalid tx_hash") - w3 = self._get_web3() if not w3.is_connected(): raise PaymentCheckoutError(503, "cannot connect payment rpc") if int(w3.eth.chain_id) != int(self.chain_id): raise PaymentCheckoutError(503, "payment rpc chain mismatch") - try: tx = w3.eth.get_transaction(tx_hash_text) except Exception: raise PaymentCheckoutError(404, "tx not found on chain") - tx_to = _normalize_address(tx.get("to")) tx_from = _normalize_address(tx.get("from")) if tx_to != intent.receiver_address: @@ -1025,11 +1300,9 @@ class PaymentContractCheckoutService: ) else: self._require_user_wallet(user_id, tx_from) - receipt = self._wait_receipt(tx_hash_text) if int(receipt.get("status") or 0) != 1: raise PaymentCheckoutError(400, "tx reverted") - block_number = int(receipt.get("blockNumber") or 0) latest_block = int(w3.eth.block_number) confirmations = max(0, latest_block - block_number + 1) if block_number else 0 @@ -1037,15 +1310,22 @@ class PaymentContractCheckoutService: raise PaymentCheckoutError( 409, f"confirmations not enough: {confirmations}/{self.confirmations}" ) - event_match = self._extract_matching_event(receipt, intent) if not event_match: raise PaymentCheckoutError( 400, "OrderPaid event mismatch; ensure contract emits OrderPaid(orderId,payer,planId,token,amount)", ) - + points_result = self._consume_points_for_intent(user_id, intent) now_iso = _to_iso(_now_utc()) + confirmed_metadata = dict(intent.metadata or {}) + redemption_meta = confirmed_metadata.get("points_redemption") + if isinstance(redemption_meta, dict): + redemption_meta["consumed"] = bool(points_result.get("points_redeemed")) + redemption_meta["consumed_points"] = int(points_result.get("points_redeemed") or 0) + redemption_meta["points_after"] = points_result.get("points_after") + redemption_meta["consumed_at"] = now_iso + confirmed_metadata["points_redemption"] = redemption_meta self._rest( "PATCH", "payment_intents", @@ -1054,6 +1334,7 @@ class PaymentContractCheckoutService: "status": "confirmed", "tx_hash": tx_hash_text, "confirmed_at": now_iso, + "metadata": confirmed_metadata, "updated_at": now_iso, }, prefer="return=representation", @@ -1078,12 +1359,12 @@ class PaymentContractCheckoutService: prefer="resolution=merge-duplicates,return=representation", allowed_status=[200, 201], ) - payload = { "tx_hash": tx_hash_text, "block_number": block_number, "confirmations": confirmations, "event": event_match, + "points_redemption": points_result, } plan = self._select_plan(intent.plan_code) payment_row = self._insert_payment_record( @@ -1111,8 +1392,8 @@ class PaymentContractCheckoutService: "transaction": tx_rows[0] if isinstance(tx_rows, list) and tx_rows else None, "payment": payment_row, "subscription": subscription_row, + "points_redemption": points_result, "tx": payload, } - - PAYMENT_CHECKOUT = PaymentContractCheckoutService() + diff --git a/src/payments/contract_checkout.py.bak b/src/payments/contract_checkout.py.bak new file mode 100644 index 00000000..5f60c7aa --- /dev/null +++ b/src/payments/contract_checkout.py.bak @@ -0,0 +1,1118 @@ +from __future__ import annotations + +import json +import os +import secrets +import threading +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from decimal import Decimal, InvalidOperation +from typing import Any, Dict, List, Optional + +import requests +from eth_account import Account +from eth_account.messages import encode_defunct +from web3 import Web3 + +DEFAULT_POLYGON_CHAIN_ID = 137 +DEFAULT_USDC_E_ADDRESS = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" + +PAYMENT_CONTRACT_ABI = [ + { + "inputs": [ + {"internalType": "bytes32", "name": "orderId", "type": "bytes32"}, + {"internalType": "uint256", "name": "planId", "type": "uint256"}, + {"internalType": "uint256", "name": "amount", "type": "uint256"}, + {"internalType": "address", "name": "token", "type": "address"}, + ], + "name": "pay", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "anonymous": False, + "inputs": [ + { + "indexed": True, + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32", + }, + { + "indexed": True, + "internalType": "address", + "name": "payer", + "type": "address", + }, + { + "indexed": True, + "internalType": "uint256", + "name": "planId", + "type": "uint256", + }, + { + "indexed": False, + "internalType": "address", + "name": "token", + "type": "address", + }, + { + "indexed": False, + "internalType": "uint256", + "name": "amount", + "type": "uint256", + }, + ], + "name": "OrderPaid", + "type": "event", + }, +] + +DEFAULT_PLAN_CATALOG: Dict[str, Dict[str, Any]] = { + "pro_monthly": {"plan_id": 101, "amount_usdc": "5", "duration_days": 30}, + "pro_quarterly": {"plan_id": 102, "amount_usdc": "79", "duration_days": 90}, + "pro_yearly": {"plan_id": 103, "amount_usdc": "279", "duration_days": 365}, +} + + +def _env_bool(name: str, default: bool = False) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return str(raw).strip().lower() in {"1", "true", "yes", "on"} + + +def _env_int(name: str, default: int) -> int: + raw = os.getenv(name) + if raw is None: + return default + try: + return int(raw) + except Exception: + return default + + +def _normalize_address(address: Any) -> str: + text = str(address or "").strip() + if not text or not Web3.is_address(text): + return "" + return Web3.to_checksum_address(text).lower() + + +def _now_utc() -> datetime: + return datetime.now(timezone.utc) + + +def _to_iso(dt: datetime) -> str: + return dt.astimezone(timezone.utc).isoformat() + + +def _parse_decimal(value: Any, default: Decimal = Decimal("0")) -> Decimal: + try: + return Decimal(str(value)) + except (InvalidOperation, ValueError, TypeError): + return default + + +def _decimal_to_units(amount: Decimal, decimals: int) -> int: + q = Decimal(10) ** Decimal(max(0, int(decimals))) + normalized = (amount * q).quantize(Decimal("1")) + return int(normalized) + + +def _units_to_decimal(units: int, decimals: int) -> Decimal: + q = Decimal(10) ** Decimal(max(0, int(decimals))) + return Decimal(int(units)) / q + + +def _format_decimal(value: Decimal, places: int = 6) -> str: + raw = f"{value:.{places}f}" + return raw.rstrip("0").rstrip(".") or "0" + + +def _parse_plan_catalog(raw: str) -> Dict[str, Dict[str, Any]]: + if not raw: + return dict(DEFAULT_PLAN_CATALOG) + try: + parsed = json.loads(raw) + except Exception: + return dict(DEFAULT_PLAN_CATALOG) + if not isinstance(parsed, dict): + return dict(DEFAULT_PLAN_CATALOG) + + out: Dict[str, Dict[str, Any]] = {} + for plan_code, row in parsed.items(): + code = str(plan_code or "").strip().lower() + if not code or not isinstance(row, dict): + continue + plan_id = int(row.get("plan_id") or 0) + duration_days = int(row.get("duration_days") or 0) + amount_usdc = _parse_decimal(row.get("amount_usdc"), Decimal("0")) + if plan_id <= 0 or duration_days <= 0 or amount_usdc <= 0: + continue + out[code] = { + "plan_id": plan_id, + "duration_days": duration_days, + "amount_usdc": _format_decimal(amount_usdc), + } + return out or dict(DEFAULT_PLAN_CATALOG) + + +def _parse_allowed_plan_codes(raw: str) -> List[str]: + text = str(raw or "").strip() + if not text: + return ["pro_monthly"] + out: List[str] = [] + for part in text.split(","): + code = str(part or "").strip().lower() + if code and code not in out: + out.append(code) + return out or ["pro_monthly"] + + +@dataclass +class WalletBindingRecord: + chain_id: int + address: str + status: str + is_primary: bool + verified_at: Optional[str] + + +@dataclass +class PaymentIntentRecord: + intent_id: str + order_id_hex: str + plan_code: str + plan_id: int + chain_id: int + amount_units: int + amount_usdc: str + token_address: str + receiver_address: str + status: str + payment_mode: str + allowed_wallet: Optional[str] + expires_at: str + tx_hash: Optional[str] + + +class PaymentCheckoutError(Exception): + def __init__(self, status_code: int, detail: str): + self.status_code = int(status_code) + self.detail = str(detail) + super().__init__(self.detail) + + +class PaymentContractCheckoutService: + def __init__(self): + self.enabled = _env_bool("POLYWEATHER_PAYMENT_ENABLED", False) + self.supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/") + self.supabase_service_role_key = str( + os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "" + ).strip() + self.chain_id = _env_int("POLYWEATHER_PAYMENT_CHAIN_ID", DEFAULT_POLYGON_CHAIN_ID) + self.token_decimals = _env_int("POLYWEATHER_PAYMENT_TOKEN_DECIMALS", 6) + self.rpc_url = str(os.getenv("POLYWEATHER_PAYMENT_RPC_URL") or "").strip() + self.receiver_contract = _normalize_address( + os.getenv("POLYWEATHER_PAYMENT_RECEIVER_CONTRACT") or "" + ) + self.token_address = _normalize_address( + os.getenv("POLYWEATHER_PAYMENT_TOKEN_ADDRESS") or DEFAULT_USDC_E_ADDRESS + ) + self.intent_ttl_sec = max(300, _env_int("POLYWEATHER_PAYMENT_INTENT_TTL_SEC", 1800)) + self.challenge_ttl_sec = max( + 60, _env_int("POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC", 600) + ) + self.confirmations = max( + 1, _env_int("POLYWEATHER_PAYMENT_CONFIRMATIONS", 2) + ) + self.timeout_sec = max(5, _env_int("POLYWEATHER_PAYMENT_HTTP_TIMEOUT_SEC", 10)) + self.poll_interval_sec = max( + 2, _env_int("POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC", 4) + ) + self.max_wait_sec = max(10, _env_int("POLYWEATHER_PAYMENT_MAX_WAIT_SEC", 50)) + self.plan_catalog = _parse_plan_catalog( + os.getenv("POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON") or "" + ) + self.allowed_plan_codes = _parse_allowed_plan_codes( + os.getenv("POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES") or "" + ) + filtered_catalog = { + code: row + for code, row in self.plan_catalog.items() + if code in self.allowed_plan_codes + } + if filtered_catalog: + self.plan_catalog = filtered_catalog + elif "pro_monthly" in self.plan_catalog: + self.plan_catalog = {"pro_monthly": self.plan_catalog["pro_monthly"]} + elif self.plan_catalog: + first_code = sorted(self.plan_catalog.keys())[0] + self.plan_catalog = {first_code: self.plan_catalog[first_code]} + self.notify_telegram = _env_bool( + "POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED", True + ) + self._w3_lock = threading.Lock() + self._w3: Optional[Web3] = None + self._event_topic = Web3.keccak( + text="OrderPaid(bytes32,address,uint256,address,uint256)" + ).hex() + + @property + def configured(self) -> bool: + return bool( + self.supabase_url + and self.supabase_service_role_key + and self.rpc_url + and self.receiver_contract + and self.token_address + ) + + def _ensure_enabled(self) -> None: + if not self.enabled: + raise PaymentCheckoutError(503, "payment feature disabled") + if not self.configured: + raise PaymentCheckoutError( + 503, + "payment feature not configured: require SUPABASE + RPC + contract + token", + ) + + def _service_headers(self, prefer: Optional[str] = None) -> Dict[str, str]: + headers = { + "apikey": self.supabase_service_role_key, + "Authorization": f"Bearer {self.supabase_service_role_key}", + "Accept": "application/json", + "Content-Type": "application/json", + } + if prefer: + headers["Prefer"] = prefer + return headers + + def _rest( + self, + method: str, + table: str, + *, + params: Optional[Dict[str, Any]] = None, + payload: Optional[Any] = None, + prefer: Optional[str] = None, + allowed_status: Optional[List[int]] = None, + ) -> Any: + url = f"{self.supabase_url}/rest/v1/{table}" + status_ok = allowed_status or [200, 201, 204] + try: + response = requests.request( + method=method.upper(), + url=url, + params=params, + json=payload, + headers=self._service_headers(prefer=prefer), + timeout=self.timeout_sec, + ) + except Exception as exc: + raise PaymentCheckoutError(503, f"supabase request failed: {exc}") from exc + + if response.status_code not in status_ok: + detail = response.text[:350] if response.text else response.reason + raise PaymentCheckoutError( + 502, + f"supabase {method.upper()} {table} failed: {response.status_code} {detail}", + ) + if not response.content: + return None + try: + return response.json() + except Exception: + return None + + def _get_web3(self) -> Web3: + with self._w3_lock: + if self._w3 is None: + self._w3 = Web3( + Web3.HTTPProvider(self.rpc_url, request_kwargs={"timeout": self.timeout_sec}) + ) + assert self._w3 is not None + return self._w3 + + def _get_contract(self): + w3 = self._get_web3() + return w3.eth.contract( + address=Web3.to_checksum_address(self.receiver_contract), + abi=PAYMENT_CONTRACT_ABI, + ) + + def get_config_payload(self) -> Dict[str, Any]: + return { + "enabled": self.enabled, + "configured": self.configured, + "chain_id": self.chain_id, + "token_address": self.token_address, + "token_decimals": self.token_decimals, + "receiver_contract": self.receiver_contract, + "confirmations": self.confirmations, + "intent_ttl_sec": self.intent_ttl_sec, + "event_name": "OrderPaid", + "event_topic0": self._event_topic, + "plans": [ + { + "plan_code": plan_code, + "plan_id": int(row.get("plan_id") or 0), + "amount_usdc": str(row.get("amount_usdc")), + "duration_days": int(row.get("duration_days") or 0), + } + for plan_code, row in sorted(self.plan_catalog.items()) + ], + } + + def _serialize_intent(self, row: Dict[str, Any]) -> PaymentIntentRecord: + amount_units = int(_parse_decimal(row.get("amount_units"), Decimal("0"))) + amount_display = _units_to_decimal(amount_units, self.token_decimals) + return PaymentIntentRecord( + intent_id=str(row.get("id")), + order_id_hex=str(row.get("order_id_hex")), + plan_code=str(row.get("plan_code")), + plan_id=int(row.get("plan_id") or 0), + chain_id=int(row.get("chain_id") or self.chain_id), + amount_units=amount_units, + amount_usdc=_format_decimal(amount_display), + token_address=_normalize_address(row.get("token_address") or self.token_address), + receiver_address=_normalize_address( + row.get("receiver_address") or self.receiver_contract + ), + status=str(row.get("status") or "created"), + payment_mode=str(row.get("payment_mode") or "strict"), + allowed_wallet=_normalize_address(row.get("allowed_wallet") or "") or None, + expires_at=str(row.get("expires_at")), + tx_hash=str(row.get("tx_hash") or "") or None, + ) + + def list_wallets(self, user_id: str) -> List[WalletBindingRecord]: + self._ensure_enabled() + rows = self._rest( + "GET", + "user_wallets", + params={ + "select": "chain_id,address,status,is_primary,verified_at", + "user_id": f"eq.{user_id}", + "chain_id": f"eq.{self.chain_id}", + "order": "is_primary.desc,verified_at.desc", + }, + allowed_status=[200], + ) + if not isinstance(rows, list): + return [] + out: List[WalletBindingRecord] = [] + for row in rows: + out.append( + WalletBindingRecord( + chain_id=int(row.get("chain_id") or self.chain_id), + address=_normalize_address(row.get("address") or ""), + status=str(row.get("status") or "active"), + is_primary=bool(row.get("is_primary")), + verified_at=row.get("verified_at"), + ) + ) + return out + + def _require_user_wallet(self, user_id: str, address: str) -> Dict[str, Any]: + normalized = _normalize_address(address) + if not normalized: + raise PaymentCheckoutError(400, "invalid wallet address") + rows = self._rest( + "GET", + "user_wallets", + params={ + "select": "id,user_id,address,chain_id,status,is_primary", + "user_id": f"eq.{user_id}", + "chain_id": f"eq.{self.chain_id}", + "address": f"eq.{normalized}", + "limit": "1", + }, + allowed_status=[200], + ) + if not isinstance(rows, list) or not rows: + raise PaymentCheckoutError(403, "wallet not bound to current user") + row = rows[0] + if str(row.get("status") or "active") != "active": + raise PaymentCheckoutError(403, "wallet is not active") + return row + + def create_wallet_challenge(self, user_id: str, address: str) -> Dict[str, Any]: + self._ensure_enabled() + normalized = _normalize_address(address) + if not normalized: + raise PaymentCheckoutError(400, "invalid wallet address") + now = _now_utc() + expires = now + timedelta(seconds=self.challenge_ttl_sec) + nonce = secrets.token_urlsafe(24) + message = ( + "PolyWeather Wallet Binding\n" + f"User: {user_id}\n" + f"Address: {normalized}\n" + f"ChainId: {self.chain_id}\n" + f"Nonce: {nonce}\n" + f"IssuedAt: {_to_iso(now)}\n" + f"ExpiresAt: {_to_iso(expires)}" + ) + self._rest( + "POST", + "wallet_link_challenges", + payload={ + "user_id": user_id, + "chain_id": self.chain_id, + "address": normalized, + "nonce": nonce, + "message": message, + "expires_at": _to_iso(expires), + }, + prefer="return=representation", + allowed_status=[201], + ) + return { + "address": normalized, + "chain_id": self.chain_id, + "nonce": nonce, + "message": message, + "expires_at": _to_iso(expires), + } + + def verify_wallet_binding( + self, + user_id: str, + address: str, + nonce: str, + signature: str, + ) -> WalletBindingRecord: + self._ensure_enabled() + normalized = _normalize_address(address) + nonce_text = str(nonce or "").strip() + signature_text = str(signature or "").strip() + if not normalized: + raise PaymentCheckoutError(400, "invalid wallet address") + if not nonce_text: + raise PaymentCheckoutError(400, "nonce required") + if not signature_text: + raise PaymentCheckoutError(400, "signature required") + + challenge_rows = self._rest( + "GET", + "wallet_link_challenges", + params={ + "select": "id,user_id,address,nonce,message,expires_at,consumed_at", + "user_id": f"eq.{user_id}", + "chain_id": f"eq.{self.chain_id}", + "address": f"eq.{normalized}", + "nonce": f"eq.{nonce_text}", + "consumed_at": "is.null", + "order": "created_at.desc", + "limit": "1", + }, + allowed_status=[200], + ) + if not isinstance(challenge_rows, list) or not challenge_rows: + raise PaymentCheckoutError(400, "wallet challenge not found or already used") + + challenge = challenge_rows[0] + try: + expires_at = datetime.fromisoformat(str(challenge.get("expires_at"))) + except Exception: + expires_at = _now_utc() - timedelta(seconds=1) + if expires_at <= _now_utc(): + raise PaymentCheckoutError(400, "wallet challenge expired") + + message = str(challenge.get("message") or "") + if not message: + raise PaymentCheckoutError(400, "wallet challenge message invalid") + + try: + recovered = Account.recover_message( + encode_defunct(text=message), signature=signature_text + ) + except Exception: + raise PaymentCheckoutError(400, "invalid wallet signature") + if _normalize_address(recovered) != normalized: + raise PaymentCheckoutError(400, "signature does not match target wallet") + + existing = self._rest( + "GET", + "user_wallets", + params={ + "select": "id,user_id,address,status,is_primary", + "chain_id": f"eq.{self.chain_id}", + "address": f"eq.{normalized}", + "limit": "1", + }, + allowed_status=[200], + ) + if isinstance(existing, list) and existing: + owner_id = str(existing[0].get("user_id") or "") + if owner_id and owner_id != user_id and str(existing[0].get("status")) == "active": + raise PaymentCheckoutError(409, "wallet already bound by another account") + + has_primary = self._rest( + "GET", + "user_wallets", + params={ + "select": "id", + "user_id": f"eq.{user_id}", + "chain_id": f"eq.{self.chain_id}", + "status": "eq.active", + "is_primary": "eq.true", + "limit": "1", + }, + allowed_status=[200], + ) + should_primary = not (isinstance(has_primary, list) and len(has_primary) > 0) + now_iso = _to_iso(_now_utc()) + self._rest( + "POST", + "user_wallets", + params={"on_conflict": "chain_id,address"}, + payload={ + "user_id": user_id, + "chain_id": self.chain_id, + "address": normalized, + "status": "active", + "is_primary": should_primary, + "verified_at": now_iso, + "updated_at": now_iso, + }, + prefer="resolution=merge-duplicates,return=representation", + allowed_status=[200, 201], + ) + self._rest( + "PATCH", + "wallet_link_challenges", + params={"id": f"eq.{challenge.get('id')}"}, + payload={"consumed_at": now_iso}, + prefer="return=representation", + allowed_status=[200], + ) + return WalletBindingRecord( + chain_id=self.chain_id, + address=normalized, + status="active", + is_primary=should_primary, + verified_at=now_iso, + ) + + def _select_plan(self, plan_code: str) -> Dict[str, Any]: + code = str(plan_code or "").strip().lower() or "pro_monthly" + row = self.plan_catalog.get(code) + if not row: + available = ", ".join(sorted(self.plan_catalog.keys())) + raise PaymentCheckoutError( + 400, f"unknown plan_code={code}; available={available}" + ) + amount_dec = _parse_decimal(row.get("amount_usdc"), Decimal("0")) + if amount_dec <= 0: + raise PaymentCheckoutError(500, f"invalid plan amount for {code}") + return { + "plan_code": code, + "plan_id": int(row.get("plan_id") or 0), + "duration_days": int(row.get("duration_days") or 0), + "amount_usdc_decimal": amount_dec, + } + + def _build_tx_payload(self, intent: PaymentIntentRecord) -> Dict[str, Any]: + contract = self._get_contract() + tx_data = contract.encode_abi( + "pay", + args=[ + intent.order_id_hex, + int(intent.plan_id), + int(intent.amount_units), + Web3.to_checksum_address(intent.token_address), + ], + ) + return { + "chain_id": self.chain_id, + "to": Web3.to_checksum_address(intent.receiver_address), + "data": tx_data, + "value": "0x0", + "order_id_hex": intent.order_id_hex, + "amount_units": str(intent.amount_units), + "amount_usdc": intent.amount_usdc, + "token_address": Web3.to_checksum_address(intent.token_address), + } + + def create_intent( + self, + user_id: str, + plan_code: str, + payment_mode: str = "strict", + allowed_wallet: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + self._ensure_enabled() + plan = self._select_plan(plan_code) + mode = str(payment_mode or "strict").strip().lower() + if mode not in {"strict", "flex"}: + raise PaymentCheckoutError(400, "payment_mode must be strict or flex") + + bound_wallets = self.list_wallets(user_id) + if not bound_wallets: + raise PaymentCheckoutError(403, "bind wallet first") + + target_wallet = _normalize_address(allowed_wallet or "") + if mode == "strict": + if target_wallet: + self._require_user_wallet(user_id, target_wallet) + else: + primary = next( + (w for w in bound_wallets if w.is_primary and w.status == "active"), + None, + ) + target_wallet = primary.address if primary else bound_wallets[0].address + elif target_wallet: + self._require_user_wallet(user_id, target_wallet) + + amount_units = _decimal_to_units(plan["amount_usdc_decimal"], self.token_decimals) + order_id_hex = "0x" + secrets.token_hex(32) + now = _now_utc() + expires_at = now + timedelta(seconds=self.intent_ttl_sec) + rows = self._rest( + "POST", + "payment_intents", + payload={ + "user_id": user_id, + "plan_code": plan["plan_code"], + "plan_id": plan["plan_id"], + "chain_id": self.chain_id, + "token_address": self.token_address, + "receiver_address": self.receiver_contract, + "amount_units": str(amount_units), + "payment_mode": mode, + "allowed_wallet": target_wallet or None, + "order_id_hex": order_id_hex, + "status": "created", + "expires_at": _to_iso(expires_at), + "metadata": metadata or {}, + "created_at": _to_iso(now), + "updated_at": _to_iso(now), + }, + prefer="return=representation", + allowed_status=[201], + ) + if not isinstance(rows, list) or not rows: + raise PaymentCheckoutError(500, "failed to create payment intent") + intent = self._serialize_intent(rows[0]) + return { + "intent": intent.__dict__, + "tx_payload": self._build_tx_payload(intent), + "plan": { + "plan_code": plan["plan_code"], + "plan_id": plan["plan_id"], + "duration_days": plan["duration_days"], + }, + } + + def get_intent(self, user_id: str, intent_id: str) -> PaymentIntentRecord: + self._ensure_enabled() + rows = self._rest( + "GET", + "payment_intents", + params={ + "select": ( + "id,user_id,plan_code,plan_id,chain_id,token_address,receiver_address," + "amount_units,payment_mode,allowed_wallet,order_id_hex,status,expires_at,tx_hash" + ), + "id": f"eq.{intent_id}", + "user_id": f"eq.{user_id}", + "limit": "1", + }, + allowed_status=[200], + ) + if not isinstance(rows, list) or not rows: + raise PaymentCheckoutError(404, "payment intent not found") + return self._serialize_intent(rows[0]) + + def submit_intent_tx( + self, + user_id: str, + intent_id: str, + tx_hash: str, + from_address: str, + ) -> Dict[str, Any]: + self._ensure_enabled() + intent = self.get_intent(user_id, intent_id) + if intent.status not in {"created", "submitted"}: + raise PaymentCheckoutError(409, f"intent status is {intent.status}, cannot submit") + + tx_hash_text = str(tx_hash or "").strip().lower() + from_addr = _normalize_address(from_address) + if not (tx_hash_text.startswith("0x") and len(tx_hash_text) == 66): + raise PaymentCheckoutError(400, "invalid tx_hash") + if not from_addr: + raise PaymentCheckoutError(400, "invalid from_address") + + now = _now_utc() + try: + expires_at = datetime.fromisoformat(intent.expires_at) + except Exception: + expires_at = now - timedelta(seconds=1) + if expires_at <= now: + self._rest( + "PATCH", + "payment_intents", + params={"id": f"eq.{intent.intent_id}", "user_id": f"eq.{user_id}"}, + payload={"status": "expired", "updated_at": _to_iso(now)}, + prefer="return=representation", + allowed_status=[200], + ) + raise PaymentCheckoutError(409, "payment intent expired") + + if intent.payment_mode == "strict" and intent.allowed_wallet: + if from_addr != intent.allowed_wallet: + raise PaymentCheckoutError( + 400, + f"strict mode requires allowed wallet {intent.allowed_wallet}", + ) + else: + self._require_user_wallet(user_id, from_addr) + + now_iso = _to_iso(now) + self._rest( + "PATCH", + "payment_intents", + params={"id": f"eq.{intent.intent_id}", "user_id": f"eq.{user_id}"}, + payload={ + "status": "submitted", + "tx_hash": tx_hash_text, + "updated_at": now_iso, + }, + prefer="return=representation", + allowed_status=[200], + ) + tx_rows = self._rest( + "POST", + "payment_transactions", + params={"on_conflict": "tx_hash"}, + payload={ + "intent_id": intent.intent_id, + "chain_id": self.chain_id, + "tx_hash": tx_hash_text, + "from_address": from_addr, + "to_address": intent.receiver_address, + "status": "submitted", + "updated_at": now_iso, + }, + prefer="resolution=merge-duplicates,return=representation", + allowed_status=[200, 201], + ) + return { + "intent_id": intent.intent_id, + "status": "submitted", + "tx_hash": tx_hash_text, + "from_address": from_addr, + "transaction": tx_rows[0] if isinstance(tx_rows, list) and tx_rows else None, + } + + def _wait_receipt(self, tx_hash: str) -> Any: + import time as _time + + w3 = self._get_web3() + start = _now_utc() + while (_now_utc() - start).total_seconds() < self.max_wait_sec: + try: + receipt = w3.eth.get_transaction_receipt(tx_hash) + except Exception: + receipt = None + if receipt and receipt.get("blockNumber"): + return receipt + _time.sleep(self.poll_interval_sec) + raise PaymentCheckoutError(408, "tx receipt timeout") + + def _extract_matching_event( + self, receipt: Any, intent: PaymentIntentRecord + ) -> Optional[Dict[str, Any]]: + contract = self._get_contract() + try: + events = contract.events.OrderPaid().process_receipt(receipt) + except Exception: + events = [] + if not events: + return None + + for ev in events: + args = ev.get("args") if isinstance(ev, dict) else getattr(ev, "args", None) + if not args: + continue + order_id_hex = str(Web3.to_hex(args.get("orderId"))).lower() + payer = _normalize_address(args.get("payer")) + plan_id = int(args.get("planId") or 0) + token = _normalize_address(args.get("token")) + amount = int(args.get("amount") or 0) + if ( + order_id_hex == intent.order_id_hex.lower() + and plan_id == int(intent.plan_id) + and token == intent.token_address + and amount == int(intent.amount_units) + ): + if intent.payment_mode == "strict" and intent.allowed_wallet: + if payer != intent.allowed_wallet: + continue + return { + "order_id_hex": order_id_hex, + "payer": payer, + "plan_id": plan_id, + "token_address": token, + "amount_units": amount, + } + return None + + def _insert_payment_record( + self, + user_id: str, + tx_hash: str, + amount_units: int, + payload: Dict[str, Any], + ) -> Dict[str, Any]: + amount_dec = _units_to_decimal(amount_units, self.token_decimals) + rows = self._rest( + "POST", + "payments", + params={"on_conflict": "tx_hash"}, + payload={ + "user_id": user_id, + "amount": str(amount_dec), + "currency": "USDC", + "chain": "polygon", + "tx_hash": tx_hash, + "status": "confirmed", + "raw_payload": payload, + "updated_at": _to_iso(_now_utc()), + }, + prefer="resolution=merge-duplicates,return=representation", + allowed_status=[200, 201], + ) + return rows[0] if isinstance(rows, list) and rows else {} + + def _grant_subscription( + self, + user_id: str, + plan_code: str, + duration_days: int, + tx_hash: str, + payload: Dict[str, Any], + ) -> Dict[str, Any]: + now = _now_utc() + latest_rows = self._rest( + "GET", + "subscriptions", + params={ + "select": "id,expires_at,status", + "user_id": f"eq.{user_id}", + "status": "eq.active", + "order": "expires_at.desc", + "limit": "1", + }, + allowed_status=[200], + ) + starts = now + if isinstance(latest_rows, list) and latest_rows: + try: + latest_exp = datetime.fromisoformat(str(latest_rows[0].get("expires_at"))) + if latest_exp > starts: + starts = latest_exp + except Exception: + pass + expires = starts + timedelta(days=max(1, duration_days)) + sub_rows = self._rest( + "POST", + "subscriptions", + payload={ + "user_id": user_id, + "plan_code": plan_code, + "status": "active", + "starts_at": _to_iso(starts), + "expires_at": _to_iso(expires), + "source": "payment_contract", + "created_at": _to_iso(now), + "updated_at": _to_iso(now), + }, + prefer="return=representation", + allowed_status=[201], + ) + self._rest( + "POST", + "entitlement_events", + payload={ + "user_id": user_id, + "action": "subscription_granted", + "reason": "payment_confirmed", + "actor": "payment_contract_checkout", + "payload": {"tx_hash": tx_hash, **payload}, + "created_at": _to_iso(now), + }, + prefer="return=representation", + allowed_status=[201], + ) + return sub_rows[0] if isinstance(sub_rows, list) and sub_rows else {} + + def _notify_telegram(self, user_id: str, plan_code: str, amount_usdc: str, tx_hash: str) -> None: + if not self.notify_telegram: + return + token = str(os.getenv("TELEGRAM_BOT_TOKEN") or "").strip() + chat_id = str(os.getenv("TELEGRAM_CHAT_ID") or "").strip() + if not token or not chat_id: + return + short_hash = tx_hash[:10] + "..." + tx_hash[-8:] if len(tx_hash) > 20 else tx_hash + text = ( + "✅ PolyWeather 支付确认\n" + f"用户: {user_id}\n" + f"套餐: {plan_code}\n" + f"金额: {amount_usdc} USDC\n" + f"Tx: {short_hash}" + ) + try: + requests.post( + f"https://api.telegram.org/bot{token}/sendMessage", + json={ + "chat_id": chat_id, + "text": text, + "disable_web_page_preview": True, + }, + timeout=8, + ) + except Exception: + return + + def confirm_intent_tx( + self, + user_id: str, + intent_id: str, + tx_hash: Optional[str] = None, + ) -> Dict[str, Any]: + self._ensure_enabled() + intent = self.get_intent(user_id, intent_id) + if intent.status == "confirmed": + return {"intent": intent.__dict__, "already_confirmed": True} + if intent.status in {"failed", "cancelled", "expired"}: + raise PaymentCheckoutError(409, f"intent status is {intent.status}") + + tx_hash_text = str(tx_hash or intent.tx_hash or "").strip().lower() + if not tx_hash_text: + raise PaymentCheckoutError(400, "tx_hash required") + if not (tx_hash_text.startswith("0x") and len(tx_hash_text) == 66): + raise PaymentCheckoutError(400, "invalid tx_hash") + + w3 = self._get_web3() + if not w3.is_connected(): + raise PaymentCheckoutError(503, "cannot connect payment rpc") + if int(w3.eth.chain_id) != int(self.chain_id): + raise PaymentCheckoutError(503, "payment rpc chain mismatch") + + try: + tx = w3.eth.get_transaction(tx_hash_text) + except Exception: + raise PaymentCheckoutError(404, "tx not found on chain") + + tx_to = _normalize_address(tx.get("to")) + tx_from = _normalize_address(tx.get("from")) + if tx_to != intent.receiver_address: + raise PaymentCheckoutError( + 400, + f"tx to mismatch: got={tx_to} expected={intent.receiver_address}", + ) + if intent.payment_mode == "strict" and intent.allowed_wallet: + if tx_from != intent.allowed_wallet: + raise PaymentCheckoutError( + 400, + f"tx sender mismatch: got={tx_from} expected={intent.allowed_wallet}", + ) + else: + self._require_user_wallet(user_id, tx_from) + + receipt = self._wait_receipt(tx_hash_text) + if int(receipt.get("status") or 0) != 1: + raise PaymentCheckoutError(400, "tx reverted") + + block_number = int(receipt.get("blockNumber") or 0) + latest_block = int(w3.eth.block_number) + confirmations = max(0, latest_block - block_number + 1) if block_number else 0 + if confirmations < self.confirmations: + raise PaymentCheckoutError( + 409, f"confirmations not enough: {confirmations}/{self.confirmations}" + ) + + event_match = self._extract_matching_event(receipt, intent) + if not event_match: + raise PaymentCheckoutError( + 400, + "OrderPaid event mismatch; ensure contract emits OrderPaid(orderId,payer,planId,token,amount)", + ) + + now_iso = _to_iso(_now_utc()) + self._rest( + "PATCH", + "payment_intents", + params={"id": f"eq.{intent.intent_id}", "user_id": f"eq.{user_id}"}, + payload={ + "status": "confirmed", + "tx_hash": tx_hash_text, + "confirmed_at": now_iso, + "updated_at": now_iso, + }, + prefer="return=representation", + allowed_status=[200], + ) + tx_rows = self._rest( + "POST", + "payment_transactions", + params={"on_conflict": "tx_hash"}, + payload={ + "intent_id": intent.intent_id, + "tx_hash": tx_hash_text, + "chain_id": self.chain_id, + "from_address": tx_from, + "to_address": tx_to, + "block_number": block_number, + "status": "confirmed", + "raw_receipt": json.loads(Web3.to_json(receipt)), + "raw_tx": json.loads(Web3.to_json(tx)), + "updated_at": now_iso, + }, + prefer="resolution=merge-duplicates,return=representation", + allowed_status=[200, 201], + ) + + payload = { + "tx_hash": tx_hash_text, + "block_number": block_number, + "confirmations": confirmations, + "event": event_match, + } + plan = self._select_plan(intent.plan_code) + payment_row = self._insert_payment_record( + user_id=user_id, + tx_hash=tx_hash_text, + amount_units=intent.amount_units, + payload=payload, + ) + subscription_row = self._grant_subscription( + user_id=user_id, + plan_code=intent.plan_code, + duration_days=plan["duration_days"], + tx_hash=tx_hash_text, + payload=payload, + ) + self._notify_telegram( + user_id=user_id, + plan_code=intent.plan_code, + amount_usdc=intent.amount_usdc, + tx_hash=tx_hash_text, + ) + refreshed = self.get_intent(user_id, intent.intent_id) + return { + "intent": refreshed.__dict__, + "transaction": tx_rows[0] if isinstance(tx_rows, list) and tx_rows else None, + "payment": payment_row, + "subscription": subscription_row, + "tx": payload, + } + + +PAYMENT_CHECKOUT = PaymentContractCheckoutService() diff --git a/web/app.py b/web/app.py index 7f4602bb..e67a25c9 100644 --- a/web/app.py +++ b/web/app.py @@ -1,4 +1,4 @@ -""" +""" PolyWeather Web Map API ~~~~~~~~~~~~~~~~~~~~~~~ FastAPI backend that reuses existing weather data collection and analysis modules. @@ -194,6 +194,8 @@ class CreatePaymentIntentRequest(BaseModel): plan_code: str = Field(default="pro_monthly", min_length=2) payment_mode: str = Field(default="strict") allowed_wallet: Optional[str] = None + use_points: bool = False + points_to_consume: Optional[int] = None metadata: Dict[str, Any] = Field(default_factory=dict) @@ -1173,6 +1175,8 @@ async def payment_create_intent(request: Request, body: CreatePaymentIntentReque plan_code=body.plan_code, payment_mode=body.payment_mode, allowed_wallet=body.allowed_wallet, + use_points=body.use_points, + points_to_consume=body.points_to_consume, metadata=body.metadata, ) except PaymentCheckoutError as exc: