Files
PolyWeather/frontend/components/account/useAccountPayment.ts
T

2395 lines
69 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { User } from "@supabase/supabase-js";
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
import {
getAllowedPaymentHosts,
getCurrentPaymentHost,
isPaymentHostAllowed,
} from "@/lib/payment-host";
import {
assertExpectedPaymentReceiver,
EXPECTED_PAYMENT_RECEIVER_ADDRESS,
} from "@/lib/payment-receiver";
import { trackAppEvent } from "@/lib/app-analytics";
import type {
AuthMeResponse,
BoundWallet,
ConnectBindOptions,
CreatedIntent,
Eip6963ProviderDetail,
EvmProvider,
InjectedProviderOption,
IntentStatusResponse,
PaymentConfig,
PaymentTokenOption,
PaymentRecoveryState,
ProviderMode,
ProviderSelection,
TelegramPricing,
} from "./types";
import {
PAYMENT_RECOVERY_STORAGE_KEY,
PAYMENT_RECOVERY_TTL_MS,
WALLETCONNECT_POLYGON_RPC_URL,
WALLET_TRANSACTION_REQUEST_TIMEOUT_MS,
} from "./constants";
import { clearStoredPaymentRecovery, shortAddress } from "./formatters";
import {
buildAllowanceCalldata,
buildApproveCalldata,
buildBalanceOfCalldata,
formatTokenUnits,
normalizePaymentError,
requestWalletWithTimeout,
} from "./payment-utils";
import { usePaymentState } from "./usePaymentState";
import {
eip6963Providers,
getEvmProvider,
getEvmWalletLabel,
getWalletConnectProvider,
isWalletConnectResetError,
listInjectedProviders,
resetWalletConnectProvider,
} from "./wallet";
// ============================================================
export interface UseAccountPaymentParams {
isEn: boolean;
supabaseReady: boolean;
walletConnectEnabled: boolean;
copy: Record<string, string>;
backend: AuthMeResponse | null;
user: User | null;
setUser: (user: User | null) => void;
setBackend: (backend: AuthMeResponse | null) => void;
setErrorText: (text: string) => void;
setUpdatedAt: (text: string) => void;
showOverlay: boolean;
setShowOverlay: (v: boolean) => void;
usePoints: boolean;
setUsePoints: (v: boolean) => void;
}
// ============================================================
export function useAccountPayment(params: UseAccountPaymentParams) {
const {
isEn,
supabaseReady,
walletConnectEnabled,
copy,
backend,
user,
setUser,
setBackend,
setErrorText,
setUpdatedAt,
showOverlay,
setShowOverlay,
usePoints,
setUsePoints,
} = params;
// ── Base payment state (from usePaymentState) ──────────────
const {
paymentBusy,
setPaymentBusy,
paymentInfo,
setPaymentInfo,
paymentError,
setPaymentError,
lastIntentId,
setLastIntentId,
lastTxHash,
setLastTxHash,
telegramBindOpening,
setTelegramBindOpening,
manualPayment,
setManualPayment,
manualTxHash,
setManualTxHash,
txValidation,
setTxValidation,
paymentMethodTab,
setPaymentMethodTab,
lastPaymentStartedAt,
setLastPaymentStartedAt,
clearPaymentMessages,
clearPaymentState,
} = usePaymentState();
// ── Additional payment/wallet state ──────────────────────
const [paymentConfig, setPaymentConfig] = useState<PaymentConfig | null>(
null,
);
const [boundWallets, setBoundWallets] = useState<BoundWallet[]>([]);
const [walletAddress, setWalletAddress] = useState("");
const [selectedPlanCode, setSelectedPlanCode] = useState("pro_monthly");
const [selectedTokenAddress, setSelectedTokenAddress] = useState("");
const [selectedWallet, setSelectedWallet] = useState("");
const [providerMode, setProviderMode] = useState<ProviderMode>("auto");
const [injectedProviderOptions, setInjectedProviderOptions] = useState<
InjectedProviderOption[]
>([]);
const [selectedInjectedProviderKey, setSelectedInjectedProviderKey] =
useState("");
const [reconcileBusy, setReconcileBusy] = useState(false);
const [telegramBindUrl, setTelegramBindUrl] = useState("");
// ── Derived values ──────────────────────────────────────
const authUserId = backend?.user_id || user?.id || "";
const authIsAuthenticated = Boolean(authUserId);
const paymentReadyForRecovery = Boolean(
paymentConfig?.enabled && paymentConfig?.configured,
);
const hasRecentPaymentRecovery =
Boolean(
lastIntentId && lastTxHash && authUserId && lastPaymentStartedAt,
) &&
Date.now() - lastPaymentStartedAt <= PAYMENT_RECOVERY_TTL_MS;
const allowedPaymentHosts = useMemo(() => getAllowedPaymentHosts(), []);
const currentPaymentHost = useMemo(() => getCurrentPaymentHost(), []);
const paymentHostAllowed = useMemo(
() => isPaymentHostAllowed(currentPaymentHost),
[currentPaymentHost],
);
// ── EIP-6963 Provider sync ──────────────────────────────
useEffect(() => {
const syncProviders = () => {
const nextOptions = listInjectedProviders();
setInjectedProviderOptions(nextOptions);
setSelectedInjectedProviderKey((current) => {
if (current && nextOptions.some((row) => row.key === current)) {
return current;
}
return nextOptions[0]?.key || "";
});
};
const handleAnnounce = (event: Event) => {
const customEvent = event as CustomEvent<Eip6963ProviderDetail>;
const detail = customEvent.detail;
if (
!detail?.provider ||
typeof detail.provider.request !== "function"
) {
return;
}
const uuid = String(detail.info?.uuid || "").trim();
const fallbackKey = `${String(detail.info?.rdns || "wallet").toLowerCase()}:${String(
detail.info?.name || "wallet",
).toLowerCase()}`;
eip6963Providers.set(uuid || fallbackKey, detail);
syncProviders();
};
syncProviders();
if (typeof window === "undefined") return;
window.addEventListener(
"eip6963:announceProvider",
handleAnnounce as EventListener,
);
window.dispatchEvent(new Event("eip6963:requestProvider"));
window.addEventListener(
"ethereum#initialized",
syncProviders as EventListener,
{ once: false },
);
return () => {
window.removeEventListener(
"eip6963:announceProvider",
handleAnnounce as EventListener,
);
window.removeEventListener(
"ethereum#initialized",
syncProviders as EventListener,
);
};
}, []);
// ── getValidAccessToken ──────────────────────────────────
const getValidAccessToken = useCallback(async (): Promise<string> => {
if (!supabaseReady)
throw new Error(
isEn
? "Supabase is not configured. Unable to get auth token."
: "Supabase 未配置,无法获取登录凭证。",
);
const client = getSupabaseBrowserClient();
const {
data: { session: cached },
} = await client.auth.getSession();
const cachedToken = String(cached?.access_token || "").trim();
const expiresAtSec = Number(cached?.expires_at || 0);
const nowSec = Math.floor(Date.now() / 1000);
const refreshLeadSec = 90;
if (
cachedToken &&
Number.isFinite(expiresAtSec) &&
expiresAtSec > nowSec + refreshLeadSec
) {
return cachedToken;
}
if (
cachedToken &&
(!Number.isFinite(expiresAtSec) || expiresAtSec <= 0)
) {
return cachedToken;
}
const {
data: { session: refreshed },
error,
} = await client.auth.refreshSession();
const refreshedToken = String(refreshed?.access_token || "").trim();
if (refreshedToken) return refreshedToken;
if (
cachedToken &&
Number.isFinite(expiresAtSec) &&
expiresAtSec > nowSec
) {
return cachedToken;
}
throw new Error(
error?.message
? isEn
? `Session expired (${error.message}). Please sign out and sign in again.`
: `登录会话已失效 (${error.message}),请退出后重新登录。`
: isEn
? "Session expired. Please sign out and sign in again."
: "登录会话已失效,请退出后重新登录。",
);
}, [isEn, supabaseReady]);
// ── buildAuthedHeaders ──────────────────────────────────
const buildAuthedHeaders = useCallback(
async (
withJson = false,
requireAuth = false,
): Promise<Record<string, string>> => {
const headers: Record<string, string> = {};
if (withJson) headers["Content-Type"] = "application/json";
if (!supabaseReady) return headers;
try {
const token = await getValidAccessToken();
headers.Authorization = `Bearer ${token}`;
} catch (error) {
if (requireAuth) throw error;
try {
const {
data: { session },
} = await getSupabaseBrowserClient().auth.getSession();
const fallbackToken = String(session?.access_token || "").trim();
if (fallbackToken) {
headers.Authorization = `Bearer ${fallbackToken}`;
}
} catch {
// Non-authenticated page load — silently skip.
}
}
return headers;
},
[supabaseReady, getValidAccessToken],
);
// ── resolvePaymentProvider ──────────────────────────────
const resolvePaymentProvider = useCallback(
async (
mode: ProviderMode = "auto",
preferredInjectedKey = "",
): Promise<ProviderSelection> => {
const targetChainId = Number(paymentConfig?.chain_id || 137);
if (mode !== "walletconnect") {
const injectedOptions = listInjectedProviders();
const injected =
injectedOptions.find((row) => row.key === preferredInjectedKey)
?.provider || getEvmProvider();
const label =
injectedOptions.find((row) => row.key === preferredInjectedKey)
?.label || getEvmWalletLabel(injected);
if (injected) {
return { provider: injected, label, mode: "auto" };
}
}
if (!walletConnectEnabled) {
throw new Error(
"未检测到浏览器扩展钱包,且 WalletConnect 未启用。请配置 NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID 或安装 EVM 钱包扩展。",
);
}
const wcProvider = await getWalletConnectProvider(
targetChainId,
WALLETCONNECT_POLYGON_RPC_URL,
);
const existingAccounts = (await wcProvider
.request({ method: "eth_accounts" })
.catch(() => [])) as string[];
if (
!Array.isArray(existingAccounts) ||
existingAccounts.length === 0
) {
if (typeof wcProvider.connect === "function") {
try {
await wcProvider.connect({ chains: [targetChainId] });
} catch (err) {
if (!isWalletConnectResetError(err)) throw err;
await resetWalletConnectProvider();
const freshProvider = await getWalletConnectProvider(
targetChainId,
WALLETCONNECT_POLYGON_RPC_URL,
);
if (typeof freshProvider.connect === "function") {
await freshProvider.connect({ chains: [targetChainId] });
}
return {
provider: freshProvider,
label: "WalletConnect",
mode: "walletconnect",
};
}
}
}
return {
provider: wcProvider,
label: "WalletConnect",
mode: "walletconnect",
};
},
[paymentConfig?.chain_id, walletConnectEnabled],
);
// ── loadPaymentSnapshot ──────────────────────────────────
const loadPaymentSnapshot = useCallback(async () => {
if (!backend?.authenticated) {
setPaymentConfig(null);
setBoundWallets([]);
return;
}
try {
const authHeadersPromise = buildAuthedHeaders(false);
const [configRes, walletsRes] = await Promise.all([
authHeadersPromise.then((headers) =>
fetch("/api/payments/config", { cache: "no-store", headers }),
),
authHeadersPromise.then((headers) =>
fetch("/api/payments/wallets", { cache: "no-store", headers }),
),
]);
if (configRes.ok) {
const configJson = (await configRes.json()) as PaymentConfig;
setPaymentConfig(configJson);
if (!selectedPlanCode && configJson.plans?.length) {
setSelectedPlanCode(configJson.plans[0].plan_code);
}
const tokenOptions = Array.isArray(configJson.tokens)
? configJson.tokens.filter(
(row) =>
typeof row?.address === "string" &&
String(row.address).startsWith("0x"),
)
: [];
const defaultTokenAddress = String(
configJson.default_token_address ||
tokenOptions.find((row) => row.is_default)?.address ||
tokenOptions[0]?.address ||
configJson.token_address ||
"",
).toLowerCase();
if (defaultTokenAddress) {
setSelectedTokenAddress(
(prev) => prev || defaultTokenAddress,
);
}
}
if (walletsRes.ok) {
const walletsJson = (await walletsRes.json()) as {
wallets?: BoundWallet[];
};
const wallets = (
Array.isArray(walletsJson.wallets) ? walletsJson.wallets : []
)
.filter((row) => {
const status = String(row?.status || "active").toLowerCase();
const address = String(row?.address || "");
return status === "active" && address.startsWith("0x");
})
.map((row) => ({
...row,
address: String(row.address || "").toLowerCase(),
}));
setBoundWallets(wallets);
if (wallets.length) {
const currentSelected = String(
selectedWallet || "",
).toLowerCase();
const hasCurrent = wallets.some(
(row) =>
String(row.address || "").toLowerCase() === currentSelected,
);
const fallback =
wallets.find((row) => Boolean(row.is_primary))?.address ||
wallets[0].address;
if (!currentSelected || !hasCurrent) {
setSelectedWallet(fallback);
}
const currentWalletAddress = String(
walletAddress || "",
).toLowerCase();
const hasWalletAddress = wallets.some(
(row) =>
String(row.address || "").toLowerCase() ===
currentWalletAddress,
);
if (!currentWalletAddress || !hasWalletAddress) {
setWalletAddress(fallback);
}
} else {
setSelectedWallet("");
setWalletAddress("");
}
}
} catch {
// ignore
}
}, [
backend?.authenticated,
buildAuthedHeaders,
selectedPlanCode,
selectedWallet,
walletAddress,
]);
// ── fetchLatestPaymentConfig ────────────────────────────
const fetchLatestPaymentConfig = useCallback(
async (
authHeaders?: Record<string, string>,
syncState = true,
): Promise<PaymentConfig> => {
const headers = authHeaders || (await buildAuthedHeaders(false));
const configRes = await fetch("/api/payments/config", {
cache: "no-store",
headers,
});
if (!configRes.ok) {
const raw = (await configRes.text()).slice(0, 350);
throw new Error(copy.loadConfigFailed.replace("{raw}", raw));
}
const configJson = (await configRes.json()) as PaymentConfig;
if (syncState) {
setPaymentConfig(configJson);
if (!selectedPlanCode && configJson.plans?.length) {
setSelectedPlanCode(configJson.plans[0].plan_code);
}
const tokenOptions = Array.isArray(configJson.tokens)
? configJson.tokens.filter(
(row) =>
typeof row?.address === "string" &&
String(row.address).startsWith("0x"),
)
: [];
const defaultTokenAddress = String(
configJson.default_token_address ||
tokenOptions.find((row) => row.is_default)?.address ||
tokenOptions[0]?.address ||
configJson.token_address ||
"",
).toLowerCase();
if (defaultTokenAddress) {
setSelectedTokenAddress(
(prev) => prev || defaultTokenAddress,
);
}
}
return configJson;
},
[buildAuthedHeaders, selectedPlanCode],
);
// ── loadSnapshot ──────────────────────────────────────────
const loadSnapshot = useCallback(async () => {
setErrorText("");
const attempt = async (retry: boolean): Promise<void> => {
const userPromise = supabaseReady
? getSupabaseBrowserClient().auth.getUser()
: Promise.resolve({ data: { user: null as User | null } });
const authHeadersPromise = buildAuthedHeaders(false);
const backendPromise = authHeadersPromise.then((headers) =>
fetch("/api/auth/me", {
cache: "no-store",
headers,
}),
);
const [userResult, backendResult] = await Promise.all([
userPromise,
backendPromise,
]);
setUser(userResult.data?.user ?? null);
if (!backendResult.ok) {
if (retry && backendResult.status === 401) {
await new Promise((r) => setTimeout(r, 1200));
return attempt(false);
}
const raw = (await backendResult.text()).slice(0, 260);
throw new Error(
copy.httpError
.replace("{status}", String(backendResult.status))
.replace("{raw}", raw),
);
}
const backendJson =
(await backendResult.json()) as AuthMeResponse;
setBackend(backendJson);
setUpdatedAt(new Date().toISOString());
};
try {
await attempt(true);
} catch (error) {
setErrorText(String(error));
}
}, [buildAuthedHeaders, supabaseReady, copy.httpError]);
// ── loadPaymentSnapshot effect ────────────────────────────
useEffect(() => {
void loadPaymentSnapshot();
}, [loadPaymentSnapshot]);
// ── Recovery save effect ────────────────────────────────
useEffect(() => {
if (typeof window === "undefined") return;
if (
!(
lastIntentId &&
lastTxHash &&
authUserId &&
lastPaymentStartedAt
)
) {
clearStoredPaymentRecovery();
return;
}
const payload: PaymentRecoveryState = {
intentId: lastIntentId,
txHash: lastTxHash,
userId: authUserId,
createdAt: lastPaymentStartedAt,
};
window.sessionStorage.setItem(
PAYMENT_RECOVERY_STORAGE_KEY,
JSON.stringify(payload),
);
}, [authUserId, lastIntentId, lastPaymentStartedAt, lastTxHash]);
// ── Recovery restore effect ──────────────────────────────
useEffect(() => {
if (typeof window === "undefined") return;
if (!authUserId) return;
if (lastIntentId && lastTxHash && lastPaymentStartedAt) return;
const raw =
window.sessionStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY);
if (!raw) return;
try {
const parsed = JSON.parse(raw) as PaymentRecoveryState;
const userId = String(parsed?.userId || "").trim();
const intentId = String(parsed?.intentId || "").trim();
const txHash = String(parsed?.txHash || "")
.trim()
.toLowerCase();
const createdAt = Number(parsed?.createdAt || 0);
const expired =
!createdAt ||
Date.now() - createdAt > PAYMENT_RECOVERY_TTL_MS;
if (
expired ||
!intentId ||
!txHash ||
!userId ||
userId !== authUserId
) {
clearStoredPaymentRecovery();
return;
}
setLastIntentId(intentId);
setLastTxHash(txHash);
setLastPaymentStartedAt(createdAt);
} catch {
clearStoredPaymentRecovery();
}
}, [authUserId, lastIntentId, lastPaymentStartedAt, lastTxHash]);
// ── Subscription cleanup effect ──────────────────────────
useEffect(() => {
if (!backend?.subscription_active) return;
clearPaymentState();
clearStoredPaymentRecovery();
}, [backend?.subscription_active]);
// ── Derived payment values ──────────────────────────────
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 availableTokenList: PaymentTokenOption[] = useMemo(() => {
const configured = Array.isArray(paymentConfig?.tokens)
? paymentConfig?.tokens || []
: [];
const clean = configured
.filter(
(row) =>
row &&
typeof row.address === "string" &&
row.address.startsWith("0x"),
)
.map((row) => ({
...row,
address: String(row.address).toLowerCase(),
symbol: String(row.symbol || "USDC"),
name: String(row.name || row.symbol || "USDC"),
code: String(row.code || "usdc"),
decimals: Number.isFinite(Number(row.decimals))
? Number(row.decimals)
: Number(paymentConfig?.token_decimals ?? 6),
}));
if (clean.length) return clean;
const fallbackAddress = String(
paymentConfig?.token_address || "",
).toLowerCase();
if (!fallbackAddress.startsWith("0x")) return [];
return [
{
code: "usdc",
symbol: "USDC",
name: "USDC",
address: fallbackAddress,
decimals: Number(paymentConfig?.token_decimals ?? 6),
receiver_contract: paymentConfig?.receiver_contract,
is_default: true,
},
];
}, [paymentConfig]);
const resolvedSelectedTokenAddress = String(
selectedTokenAddress ||
paymentConfig?.default_token_address ||
availableTokenList.find((row) => row.is_default)?.address ||
availableTokenList[0]?.address ||
paymentConfig?.token_address ||
"",
).toLowerCase();
const selectedPaymentToken =
availableTokenList.find(
(row) => row.address === resolvedSelectedTokenAddress,
) || availableTokenList[0];
const selectedTokenLabel =
selectedPaymentToken?.symbol ||
(resolvedSelectedTokenAddress.startsWith("0x")
? shortAddress(resolvedSelectedTokenAddress)
: "USDC");
const paymentReceiverAddress = EXPECTED_PAYMENT_RECEIVER_ADDRESS;
const paymentWalletLabel = String(
selectedWallet ||
walletAddress ||
boundWallets.find((row) => row.is_primary)?.address ||
boundWallets[0]?.address ||
"",
).toLowerCase();
const hasPayingWallet = Boolean(
String(
selectedWallet || walletAddress || boundWallets[0]?.address || "",
).trim(),
);
// ── Points computation ──────────────────────────────────
const backendPointsRaw = Number(backend?.points);
const metadataPointsRaw = Number(
user?.user_metadata?.points ??
user?.user_metadata?.total_points ??
0,
);
const metadataPointsSafe = Number.isFinite(metadataPointsRaw)
? metadataPointsRaw
: 0;
const pointsRaw = Number.isFinite(backendPointsRaw)
? Math.max(backendPointsRaw, metadataPointsSafe)
: metadataPointsSafe;
const totalPoints = Number.isFinite(pointsRaw)
? Math.max(0, pointsRaw)
: 0;
// ── Billing ──────────────────────────────────────────────
const billing = useMemo(() => {
const parsedPlanAmount = Number(
backend?.telegram_pricing?.amount_usdc ??
selectedPlan?.amount_usdc ??
10,
);
const planAmount =
Number.isFinite(parsedPlanAmount) && parsedPlanAmount > 0
? parsedPlanAmount
: 10;
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 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,
backend?.telegram_pricing?.amount_usdc,
selectedPlan?.amount_usdc,
totalPoints,
usePoints,
]);
// ── reconcileLatestPayment ──────────────────────────────
const reconcileLatestPayment = useCallback(async () => {
if (!authIsAuthenticated || reconcileBusy) return false;
setReconcileBusy(true);
try {
const headers = await buildAuthedHeaders(true, true);
const res = await fetch("/api/payments/reconcile-latest", {
method: "POST",
headers,
});
if (!res.ok) return false;
const json = (await res.json()) as {
ok?: boolean;
action?: string;
subscription?: { plan_code?: string | null } | null;
};
if (json.ok) {
setPaymentInfo(copy.walletRecoveryDone);
setPaymentError("");
await loadSnapshot();
await loadPaymentSnapshot();
return true;
}
return false;
} catch {
return false;
} finally {
setReconcileBusy(false);
}
}, [
authIsAuthenticated,
buildAuthedHeaders,
copy.walletRecoveryDone,
loadPaymentSnapshot,
loadSnapshot,
reconcileBusy,
]);
// ── handleSubmit409 ────────────────────────────────────
const handleSubmit409 = useCallback(
async (intentId: string, txHashNorm: string, raw: string) => {
const lowerRaw = raw.toLowerCase();
if (
lowerRaw.includes("已支付") ||
lowerRaw.includes("already confirmed") ||
lowerRaw.includes("already paid")
) {
const ok = await reconcileLatestPayment();
if (ok) return;
setPaymentInfo(copy.orderAlreadyPaid);
await loadSnapshot();
await loadPaymentSnapshot();
return;
}
if (lowerRaw.includes("expired")) {
throw new Error(copy.orderExpired);
}
try {
const headers = await buildAuthedHeaders(true, false);
const intentRes = await fetch(
`/api/payments/intents/${intentId}`,
{
headers,
cache: "no-store",
},
);
if (intentRes.ok) {
const intentJson = (await intentRes.json()) as {
intent?: { status?: string; tx_hash?: string };
};
const status = intentJson.intent?.status;
if (status === "confirmed") {
await reconcileLatestPayment();
const txHash =
intentJson.intent?.tx_hash || txHashNorm;
setPaymentInfo(
`支付已确认,交易: ${shortAddress(txHash)}`,
);
setPaymentError("");
await loadSnapshot();
await loadPaymentSnapshot();
return;
}
if (status === "expired") {
throw new Error(copy.orderExpired);
}
}
} catch (e) {
if (e instanceof Error && e.message !== raw) throw e;
}
throw new Error(
copy.submitTxFailed.replace("{raw}", raw),
);
},
[
buildAuthedHeaders,
copy.orderAlreadyPaid,
copy.orderExpired,
copy.submitTxFailed,
loadPaymentSnapshot,
loadSnapshot,
reconcileLatestPayment,
],
);
// ── Auto-reconcile effect ──────────────────────────────
useEffect(() => {
if (!authIsAuthenticated) return;
if (backend?.subscription_active) return;
if (!paymentReadyForRecovery) return;
if (!hasRecentPaymentRecovery) return;
let cancelled = false;
const run = async () => {
setPaymentInfo(copy.walletRecoveryBusy);
const repaired = await reconcileLatestPayment();
if (cancelled) return;
if (!repaired && !backend?.subscription_active) {
setPaymentInfo("");
setPaymentError(copy.walletRecoveryFailed);
}
};
void run();
return () => {
cancelled = true;
};
}, [
backend?.subscription_active,
authIsAuthenticated,
copy.walletRecoveryBusy,
copy.walletRecoveryFailed,
hasRecentPaymentRecovery,
paymentReadyForRecovery,
reconcileLatestPayment,
]);
// ── Low-level helpers ──────────────────────────────────
const waitForReceipt = async (
txHash: string,
provider?: EvmProvider,
timeoutMs = 120000,
pollMs = 3000,
) => {
const eth = provider || getEvmProvider();
if (!eth) throw new Error(copy.noWalletProvider);
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const receipt =
await requestWalletWithTimeout<{
status?: string;
} | null>(
eth,
{
method: "eth_getTransactionReceipt",
params: [txHash],
},
"查询授权交易确认",
15_000,
);
if (receipt && receipt.status) {
if (receipt.status === "0x1") return receipt;
throw new Error(
copy.txReverted.replace("{txHash}", txHash),
);
}
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
throw new Error(
copy.txConfirmTimeout.replace("{txHash}", txHash),
);
};
const signBindMessage = async (
eth: EvmProvider,
address: string,
message: string,
): Promise<string> => {
try {
return (await eth.request({
method: "personal_sign",
params: [message, address],
})) as string;
} catch {
return (await eth.request({
method: "personal_sign",
params: [address, message],
})) as string;
}
};
const ensureTargetChain = async (
eth: EvmProvider,
targetChainId: number,
): Promise<void> => {
const currentChainIdHex = String(
(await requestWalletWithTimeout<string>(
eth,
{ method: "eth_chainId" },
copy.chainReadError,
)) || "",
);
const targetChainHex = `0x${targetChainId.toString(16)}`;
if (
currentChainIdHex.toLowerCase() === targetChainHex.toLowerCase()
)
return;
try {
await requestWalletWithTimeout(
eth,
{
method: "wallet_switchEthereumChain",
params: [{ chainId: targetChainHex }],
},
copy.chainSwitchError,
);
} catch (err: any) {
const code = Number(err?.code);
if (code === 4902 || targetChainId === 137) {
try {
await requestWalletWithTimeout(
eth,
{
method: "wallet_addEthereumChain",
params: [
{
chainId: "0x89",
chainName: "Polygon Mainnet",
nativeCurrency: {
name: "POL",
symbol: "POL",
decimals: 18,
},
rpcUrls: ["https://polygon-rpc.com"],
blockExplorerUrls: ["https://polygonscan.com"],
},
],
},
copy.chainAddPolygon,
);
return;
} catch (addErr: any) {
err = addErr;
}
}
throw new Error(
`${copy.chainSwitchPrompt} (${err?.message || (isEn ? "Network switch failed" : "网络切换失败")})`,
);
}
};
// ── pollIntentUntilConfirmed ────────────────────────────
const pollIntentUntilConfirmed = useCallback(
async (
intentId: string,
authHeaders: Record<string, string>,
txHashHint = "",
timeoutMs = 180000,
pollMs = 5000,
) => {
const startedAt = Date.now();
const shortTx = shortAddress(txHashHint);
while (Date.now() - startedAt < timeoutMs) {
const statusRes = await fetch(
`/api/payments/intents/${intentId}`,
{
method: "GET",
headers: authHeaders,
cache: "no-store",
},
);
if (!statusRes.ok) {
if (statusRes.status >= 500 || statusRes.status === 429) {
await new Promise((resolve) =>
setTimeout(resolve, pollMs),
);
continue;
}
const raw = (await statusRes.text()).slice(0, 260);
throw new Error(
copy.queryIntentFailed.replace("{raw}", raw),
);
}
const statusJson =
(await statusRes.json()) as IntentStatusResponse;
const intent = statusJson.intent || {};
const status = String(intent.status || "").toLowerCase();
const txHash = String(
intent.tx_hash || txHashHint || "",
).toLowerCase();
if (status === "confirmed") {
setPaymentError("");
setPaymentInfo(
copy.paymentConfirmed.replace(
"{txHash}",
shortAddress(txHash),
),
);
trackAppEvent("checkout_succeeded", {
entry: "account_center",
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
tx_hash: txHash || null,
});
await loadSnapshot();
await loadPaymentSnapshot();
return;
}
if (
status === "failed" ||
status === "cancelled" ||
status === "expired"
) {
throw new Error(
copy.paymentStatus.replace("{status}", status),
);
}
setPaymentInfo(
copy.txSubmitted
.replace("{txHash}", shortTx)
.replace("{status}", status || "submitted"),
);
await new Promise((resolve) =>
setTimeout(resolve, pollMs),
);
}
throw new Error(copy.paymentPendingTimeout);
},
[
loadPaymentSnapshot,
loadSnapshot,
selectedPlan?.plan_code,
],
);
// ── connectAndBindWallet ────────────────────────────────
const connectAndBindWallet = async (
mode: ProviderMode = "auto",
options: ConnectBindOptions = {},
): Promise<boolean> => {
clearPaymentMessages();
if (!paymentHostAllowed) {
setPaymentError(
copy.paymentHostBlocked.replace(
"{host}",
allowedPaymentHosts[0] ||
"polyweather-pro.vercel.app",
),
);
return false;
}
if (!authIsAuthenticated) {
setPaymentError(copy.loginBeforeBind);
return false;
}
setPaymentBusy(true);
try {
const providerSelection = await resolvePaymentProvider(
mode,
selectedInjectedProviderKey,
);
const eth = providerSelection.provider;
const walletLabel = providerSelection.label;
const binanceBindHint = walletLabel
.toLowerCase()
.includes("binance")
? copy.binanceBindHint
: "";
let accessToken: string;
try {
accessToken = await getValidAccessToken();
} catch (tokenErr) {
setPaymentError(normalizePaymentError(tokenErr).message);
setPaymentBusy(false);
return false;
}
const authHeaders: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
};
const accounts = await requestWalletWithTimeout<string[]>(
eth,
{ method: "eth_requestAccounts" },
"连接绑定钱包",
);
const address = String(accounts?.[0] || "").toLowerCase();
if (!address)
throw new Error(
isEn
? "Wallet account is empty."
: "钱包账户为空",
);
const existingWallet = boundWallets.find(
(w) =>
String(w.address || "").toLowerCase() === address,
);
if (existingWallet) {
setWalletAddress(address);
setSelectedWallet(address);
setPaymentInfo(
`${walletLabel} 已绑定: ${shortAddress(address)}${binanceBindHint || "现在可点击“立即订阅并激活服务”。"}`,
);
await Promise.all([
loadSnapshot(),
loadPaymentSnapshot(),
]);
if (options.openOverlayAfterBind) setShowOverlay(true);
setPaymentBusy(false);
return true;
}
setWalletAddress(address);
const challengeRes = await fetch(
"/api/payments/wallets/challenge",
{
method: "POST",
headers: authHeaders,
body: JSON.stringify({ address }),
},
);
if (!challengeRes.ok) {
const raw = (await challengeRes.text()).slice(0, 300);
throw new Error(
copy.challengeFailed.replace("{raw}", raw),
);
}
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(copy.challengeInvalid);
const signature = await signBindMessage(
eth,
address,
message,
);
const verifyRes = await fetch(
"/api/payments/wallets/verify",
{
method: "POST",
headers: authHeaders,
body: JSON.stringify({
address,
nonce,
signature,
}),
},
);
if (!verifyRes.ok) {
const raw = (await verifyRes.text()).slice(0, 300);
throw new Error(
copy.verifyFailedRaw.replace("{raw}", raw),
);
}
setPaymentInfo(
`${walletLabel} 绑定成功: ${shortAddress(address)}${binanceBindHint || "现在可点击“立即订阅并激活服务”。"}`,
);
setProviderMode(providerSelection.mode);
if (options.openOverlayAfterBind) setShowOverlay(true);
await Promise.all([
loadSnapshot(),
loadPaymentSnapshot(),
]);
return true;
} catch (error) {
setPaymentInfo("");
setPaymentError(
normalizePaymentError(error).message,
);
return false;
} finally {
setPaymentBusy(false);
}
};
// ── handleUnbindWallet ──────────────────────────────────
const handleUnbindWallet = async (address: string) => {
const target = String(address || "").toLowerCase();
if (!target) return;
if (!authIsAuthenticated) {
setPaymentError(copy.loginBeforeBind);
return;
}
const confirmed = window.confirm(
copy.unbindConfirm.replace(
"{address}",
shortAddress(target),
),
);
if (!confirmed) return;
setPaymentBusy(true);
clearPaymentMessages();
try {
const headers = await buildAuthedHeaders(true, false);
const res = await fetch("/api/payments/wallets", {
method: "DELETE",
headers,
body: JSON.stringify({ address: target }),
});
const raw = await res.text();
if (!res.ok) {
let detail = raw;
try {
const parsed = JSON.parse(raw);
detail = String(
parsed?.detail || parsed?.error || raw,
);
if (detail.trim().startsWith("{")) {
try {
const nested = JSON.parse(detail);
detail = String(
nested?.detail ||
nested?.error ||
detail,
);
} catch {
// ignore nested parse failure
}
}
} catch {
// ignore
}
throw new Error(
detail ||
copy.httpError
.replace("{status}", String(res.status))
.replace("{raw}", ""),
);
}
let data: Record<string, unknown> = {};
try {
data = raw
? (JSON.parse(raw) as Record<string, unknown>)
: {};
} catch {
data = {};
}
const newPrimary = String(
data?.new_primary || "",
).toLowerCase();
const selectedWalletNorm = String(
selectedWallet || "",
).toLowerCase();
const walletAddressNorm = String(
walletAddress || "",
).toLowerCase();
if (selectedWalletNorm === target) {
setSelectedWallet(newPrimary || "");
}
if (walletAddressNorm === target) {
setWalletAddress(newPrimary || "");
}
setBoundWallets((prev) =>
prev.filter(
(row) =>
String(row.address || "").toLowerCase() !==
String(target),
),
);
await loadPaymentSnapshot();
setPaymentInfo(
newPrimary
? copy.unbindDonePrimary.replace(
"{address}",
shortAddress(newPrimary),
)
: copy.unbindDone,
);
} catch (error) {
const message = normalizePaymentError(error)
.message;
const lower = String(message || "").toLowerCase();
if (
lower.includes("unauthorized") ||
lower.includes("session required") ||
lower.includes("401")
) {
setPaymentError(
`${copy.unbindFailed}: ${copy.authExpired}`,
);
return;
}
setPaymentError(
`${copy.unbindFailed}: ${message}`,
);
} finally {
setPaymentBusy(false);
}
};
// ── createIntentAndPay ──────────────────────────────────
const createIntentAndPay = async () => {
clearPaymentMessages();
clearPaymentState();
clearStoredPaymentRecovery();
if (!paymentHostAllowed) {
setPaymentError(
copy.paymentHostBlocked.replace(
"{host}",
allowedPaymentHosts[0] ||
"polyweather-pro.vercel.app",
),
);
return;
}
if (!authIsAuthenticated) {
setPaymentError(copy.loginBeforePay);
return;
}
if (!paymentConfig?.configured) {
setPaymentError(copy.payNotReady);
return;
}
const fallbackWallet = String(
selectedWallet ||
walletAddress ||
boundWallets[0]?.address ||
"",
).toLowerCase();
if (!fallbackWallet) {
setPaymentError(copy.bindFirstBeforePay);
return;
}
setPaymentBusy(true);
let approvedInThisRun = false;
try {
const providerSelection = await resolvePaymentProvider(
providerMode,
selectedInjectedProviderKey,
);
const eth = providerSelection.provider;
const activeAccounts =
await requestWalletWithTimeout<string[]>(
eth,
{ method: "eth_requestAccounts" },
"连接付款钱包",
);
const activeAddress = String(
activeAccounts?.[0] || "",
).toLowerCase();
if (!activeAddress)
throw new Error(
isEn
? "Wallet account is empty."
: "钱包账户为空",
);
const boundAddrSet = new Set(
boundWallets.map((row) =>
String(row.address || "").toLowerCase(),
),
);
if (
boundAddrSet.size > 0 &&
!boundAddrSet.has(activeAddress)
) {
throw new Error(
`当前连接钱包 ${shortAddress(activeAddress)} 未绑定,请先绑定该地址后支付。`,
);
}
const payingWallet = boundAddrSet.has(activeAddress)
? activeAddress
: fallbackWallet;
setSelectedWallet(payingWallet);
setProviderMode(providerSelection.mode);
let accessToken: string;
try {
accessToken = await getValidAccessToken();
} catch (tokenErr) {
setPaymentError(
normalizePaymentError(tokenErr).message,
);
setPaymentBusy(false);
return;
}
const authHeaders: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
};
const latestConfig = await fetchLatestPaymentConfig(
authHeaders,
true,
);
if (!latestConfig?.enabled || !latestConfig?.configured) {
throw new Error(copy.payNotReady);
}
const expectedReceiver = String(
latestConfig.receiver_contract || "",
).toLowerCase();
assertExpectedPaymentReceiver(
expectedReceiver,
"payment receiver contract",
);
if (
paymentConfig?.receiver_contract &&
String(paymentConfig.receiver_contract).toLowerCase() !==
expectedReceiver
) {
setPaymentInfo(
copy.paymentConfigUpdated.replace(
"{address}",
shortAddress(expectedReceiver),
),
);
} else {
setPaymentInfo(
copy.currentReceiver.replace(
"{address}",
shortAddress(expectedReceiver),
),
);
}
const targetChainId = Number(
latestConfig.chain_id || 137,
);
await ensureTargetChain(eth, targetChainId);
const createRes = await fetch(
"/api/payments/intents",
{
method: "POST",
headers: authHeaders,
body: JSON.stringify({
plan_code:
selectedPlan?.plan_code || "pro_monthly",
payment_mode: "strict",
allowed_wallet: payingWallet,
token_address:
resolvedSelectedTokenAddress || undefined,
use_points: billing.canRedeem && usePoints,
points_to_consume:
billing.canRedeem && usePoints
? billing.pointsUsed
: 0,
metadata: {
source: "account_center",
frontend_host:
currentPaymentHost || null,
account_email: backend?.email || null,
},
}),
},
);
if (!createRes.ok) {
const raw = (await createRes.text()).slice(0, 350);
throw new Error(
copy.createIntentFailed.replace(
"{raw}",
raw,
),
);
}
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(copy.intentPayloadInvalid);
trackAppEvent("checkout_started", {
entry: "account_center",
plan_code:
selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
use_points: billing.canRedeem && usePoints,
pay_amount_usd: billing.payAmount,
});
const intentReceiver = String(
txPayload.to || "",
).toLowerCase();
if (intentReceiver !== expectedReceiver) {
throw new Error(
`payment receiver changed: expected ${expectedReceiver}, got ${intentReceiver}. 请刷新页面后重试。`,
);
}
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(copy.intentTokenInvalid);
const tokenSymbol = String(
txPayload.token_symbol ||
selectedPaymentToken?.symbol ||
selectedTokenLabel ||
"USDC",
);
const tokenDecimals = Number(
txPayload.token_decimals ??
selectedPaymentToken?.decimals ??
latestConfig?.token_decimals ??
6,
);
const balanceHex =
await requestWalletWithTimeout<string>(
eth,
{
method: "eth_call",
params: [
{
to: tokenAddress,
data: buildBalanceOfCalldata(
payingWallet,
),
},
"latest",
],
},
`读取 ${tokenSymbol} 余额`,
);
const tokenBalance = BigInt(
String(balanceHex || "0x0"),
);
if (tokenBalance < amountUnits) {
const need = formatTokenUnits(
amountUnits,
tokenDecimals,
);
const have = formatTokenUnits(
tokenBalance,
tokenDecimals,
);
throw new Error(
`支付代币余额不足:需要 ${need} ${tokenSymbol},当前 ${have} ${tokenSymbol}。请确认你钱包里持有该支付币种。`,
);
}
const allowanceHex =
await requestWalletWithTimeout<string>(
eth,
{
method: "eth_call",
params: [
{
to: tokenAddress,
data: buildAllowanceCalldata(
payingWallet,
txPayload.to,
),
},
"latest",
],
},
`读取 ${tokenSymbol} 授权额度`,
);
const allowance = BigInt(
String(allowanceHex || "0x0"),
);
if (allowance < amountUnits) {
setPaymentInfo(
copy.approvalDetected.replace(
"{symbol}",
tokenSymbol,
),
);
const approveParams: Record<string, any> = {
from: payingWallet,
to: tokenAddress,
data: buildApproveCalldata(
txPayload.to,
amountUnits,
),
};
const approveHash =
await requestWalletWithTimeout<string>(
eth,
{
method: "eth_sendTransaction",
params: [approveParams],
},
`发起 ${tokenSymbol} 授权`,
WALLET_TRANSACTION_REQUEST_TIMEOUT_MS,
);
await waitForReceipt(
String(approveHash || ""),
eth,
);
approvedInThisRun = true;
setPaymentInfo(
copy.approvalDone.replace(
"{symbol}",
tokenSymbol,
),
);
} else {
setPaymentInfo(copy.approvalSufficient);
}
const payParams: Record<string, any> = {
from: payingWallet,
to: txPayload.to,
data: txPayload.data,
};
if (
txPayload.value &&
txPayload.value !== "0x0" &&
txPayload.value !== "0"
) {
payParams.value = txPayload.value;
}
const txHash =
await requestWalletWithTimeout<string>(
eth,
{
method: "eth_sendTransaction",
params: [payParams],
},
"发起支付交易",
WALLET_TRANSACTION_REQUEST_TIMEOUT_MS,
);
const txHashNorm = String(
txHash || "",
).toLowerCase();
setLastTxHash(txHashNorm);
setLastPaymentStartedAt(Date.now());
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,
);
if (submitRes.status === 409) {
await handleSubmit409(
intentId,
txHashNorm,
raw,
);
return;
}
throw new Error(
copy.submitTxFailed.replace(
"{raw}",
raw,
),
);
}
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,
);
const lowerRaw = raw.toLowerCase();
const maybePending =
(confirmRes.status === 404 &&
!lowerRaw.includes(
"payment intent not found",
)) ||
confirmRes.status === 408 ||
(confirmRes.status === 409 &&
(lowerRaw.includes(
"confirmations not enough",
) ||
lowerRaw.includes(
"tx indexed partially",
)));
if (maybePending) {
setPaymentInfo(
`交易已提交: ${shortAddress(txHashNorm)},等待链上确认中...`,
);
await pollIntentUntilConfirmed(
intentId,
authHeaders,
txHashNorm,
);
return;
}
throw new Error(
copy.confirmFailed.replace("{raw}", raw),
);
}
setPaymentInfo(
copy.paymentConfirmed.replace(
"{txHash}",
shortAddress(txHashNorm),
),
);
trackAppEvent("checkout_succeeded", {
entry: "account_center",
plan_code:
selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
tx_hash: txHashNorm,
});
await loadSnapshot();
await loadPaymentSnapshot();
} catch (error) {
const normalized =
normalizePaymentError(error);
if (normalized.pending) {
setPaymentError(normalized.message);
} else if (normalized.userRejected) {
setPaymentInfo(
approvedInThisRun
? `${selectedTokenLabel} 授权已完成,本次支付已取消,可直接再次点击支付。`
: "",
);
setPaymentError(normalized.message);
} else {
setPaymentInfo(
approvedInThisRun
? `${selectedTokenLabel} 授权已完成,但支付未完成,请重试。`
: "",
);
setPaymentError(normalized.message);
}
} finally {
setPaymentBusy(false);
}
};
// ── createManualPaymentIntent ────────────────────────────
const createManualPaymentIntent = async () => {
clearPaymentMessages();
setManualPayment(null);
setManualTxHash("");
setLastIntentId("");
setLastTxHash("");
if (!paymentHostAllowed) {
setPaymentError(
copy.paymentHostBlocked.replace(
"{host}",
allowedPaymentHosts[0] ||
"polyweather-pro.vercel.app",
),
);
return;
}
if (!authIsAuthenticated) {
setPaymentError(copy.loginBeforePay);
return;
}
if (!paymentConfig?.configured) {
setPaymentError(copy.payNotReady);
return;
}
setPaymentBusy(true);
try {
const authHeaders = await buildAuthedHeaders(
true,
false,
);
const createRes = await fetch(
"/api/payments/intents",
{
method: "POST",
headers: authHeaders,
body: JSON.stringify({
plan_code:
selectedPlan?.plan_code || "pro_monthly",
payment_mode: "direct",
token_address:
resolvedSelectedTokenAddress ||
undefined,
use_points: billing.canRedeem && usePoints,
points_to_consume:
billing.canRedeem && usePoints
? billing.pointsUsed
: 0,
metadata: {
source:
"account_center_manual_transfer",
frontend_host:
currentPaymentHost || null,
account_email: backend?.email || null,
},
}),
},
);
if (!createRes.ok) {
const raw = (await createRes.text()).slice(
0,
350,
);
throw new Error(
copy.createManualIntentFailed.replace(
"{raw}",
raw,
),
);
}
const created =
(await createRes.json()) as CreatedIntent;
const direct = created.direct_payment;
const intentId = String(
created.intent?.intent_id ||
direct?.intent_id ||
"",
);
if (
!intentId ||
!direct?.receiver_address ||
!direct?.amount_usdc
) {
throw new Error(copy.manualPaymentInvalid);
}
assertExpectedPaymentReceiver(
direct.receiver_address,
"manual payment receiver",
);
setLastIntentId(intentId);
setManualPayment(direct);
setPaymentMethodTab("manual");
setShowOverlay(false);
setPaymentInfo(
`手动转账订单已创建:请在 Polygon 网络转 ${direct.amount_usdc} ${direct.token_symbol || selectedTokenLabel}${direct.receiver_address},请在下方【手动转账】面板中查看详情并复制地址,完成后提交 tx hash。`,
);
trackAppEvent("checkout_started", {
entry: "account_center_manual_transfer",
plan_code:
selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
payment_mode: "direct",
use_points: billing.canRedeem && usePoints,
pay_amount_usd: billing.payAmount,
});
} catch (error) {
setPaymentError(
normalizePaymentError(error).message,
);
} finally {
setPaymentBusy(false);
}
};
// ── submitManualPaymentTx ──────────────────────────────
const submitManualPaymentTx = async () => {
const txHashNorm = String(manualTxHash || "")
.trim()
.toLowerCase();
const intentId = String(
lastIntentId || manualPayment?.intent_id || "",
).trim();
if (!intentId || !manualPayment) {
setPaymentError(copy.manualOrderRequired);
return;
}
if (
!txHashNorm.startsWith("0x") ||
txHashNorm.length !== 66
) {
setPaymentError(copy.txHashRequired);
return;
}
setPaymentBusy(true);
setPaymentError("");
try {
const authHeaders = await buildAuthedHeaders(
true,
false,
);
const submitRes = await fetch(
`/api/payments/intents/${intentId}/submit`,
{
method: "POST",
headers: authHeaders,
body: JSON.stringify({
tx_hash: txHashNorm,
}),
},
);
if (!submitRes.ok) {
const raw = (await submitRes.text()).slice(
0,
350,
);
if (submitRes.status === 409) {
await handleSubmit409(
intentId,
txHashNorm,
raw,
);
return;
}
throw new Error(
copy.submitTxFailed.replace(
"{raw}",
raw,
),
);
}
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,
);
const lowerRaw = raw.toLowerCase();
const maybePending =
confirmRes.status === 408 ||
(confirmRes.status === 409 &&
(lowerRaw.includes(
"confirmations not enough",
) ||
lowerRaw.includes(
"tx indexed partially",
)));
if (maybePending) {
setPaymentInfo(
`交易已提交: ${shortAddress(txHashNorm)},等待链上确认中...`,
);
await pollIntentUntilConfirmed(
intentId,
authHeaders,
txHashNorm,
);
return;
}
throw new Error(
copy.confirmFailed.replace(
"{raw}",
raw,
),
);
}
setLastTxHash(txHashNorm);
setPaymentInfo(
copy.paymentConfirmed.replace(
"{txHash}",
shortAddress(txHashNorm),
),
);
setManualPayment(null);
setManualTxHash("");
setTxValidation({
loading: false,
checked: false,
});
trackAppEvent("checkout_succeeded", {
entry:
"account_center_manual_transfer",
plan_code:
selectedPlan?.plan_code ||
"pro_monthly",
intent_id: intentId,
tx_hash: txHashNorm,
});
await loadSnapshot();
await loadPaymentSnapshot();
} catch (error) {
setPaymentError(
normalizePaymentError(error).message,
);
} finally {
setPaymentBusy(false);
}
};
// ── validateTxHash ────────────────────────────────────
const validateTxHash = useCallback(
async (intentId: string, hash: string) => {
const hashNorm = String(hash || "")
.trim()
.toLowerCase();
if (
!hashNorm.startsWith("0x") ||
hashNorm.length !== 66
) {
setTxValidation({
loading: false,
checked: false,
});
return;
}
setTxValidation({
loading: true,
checked: false,
});
try {
const headers = await buildAuthedHeaders(
true,
false,
);
const res = await fetch(
`/api/payments/intents/${intentId}/validate`,
{
method: "POST",
headers,
body: JSON.stringify({
tx_hash: hashNorm,
}),
},
);
const json = (await res.json()) as {
valid?: boolean;
reason?: string;
detail?: string;
checks?: Record<string, unknown>;
};
setTxValidation({
loading: false,
checked: true,
valid: Boolean(json.valid),
reason: json.reason,
detail: json.detail,
checks: json.checks,
});
} catch {
setTxValidation({
loading: false,
checked: false,
});
}
},
[buildAuthedHeaders],
);
// ── handleOverlayCheckout ──────────────────────────────
const handleOverlayCheckout = async () => {
if (!paymentHostAllowed) {
setPaymentError(
copy.paymentHostBlocked.replace(
"{host}",
allowedPaymentHosts[0] ||
"polyweather-pro.vercel.app",
),
);
return;
}
if (!authIsAuthenticated) {
setPaymentError(copy.loginBeforePay);
return;
}
if (!hasPayingWallet) {
setPaymentInfo(copy.openBindFlow);
const bound = await connectAndBindWallet(
providerMode,
{ openOverlayAfterBind: true },
);
if (!bound) return;
setPaymentInfo(copy.walletBoundCreatingOrder);
await createIntentAndPay();
return;
}
await createIntentAndPay();
};
// ── openTelegramBotBindLink ──────────────────────────────
const openTelegramBotBindLink = async () => {
setTelegramBindOpening(true);
setPaymentError("");
setTelegramBindUrl("");
const popup = window.open(
"about:blank",
"_blank",
"noopener,noreferrer",
);
try {
const authHeaders = await buildAuthedHeaders(
true,
false,
);
const res = await fetch(
"/api/auth/telegram/bot-bind-link",
{
method: "POST",
headers: authHeaders,
},
);
if (!res.ok) {
const raw = (await res.text()).slice(0, 300);
throw new Error(
raw || copy.telegramBindFailed,
);
}
const data = (await res.json()) as {
bot_url?: string;
};
const botUrl = String(data.bot_url || "").trim();
if (!botUrl)
throw new Error(copy.telegramBindLinkMissing);
if (popup && !popup.closed) {
popup.location.href = botUrl;
setPaymentInfo(copy.telegramBindClickHint);
} else {
setPaymentInfo(copy.telegramPopupBlocked);
setTelegramBindUrl(botUrl);
}
} catch (error) {
if (popup && !popup.closed) popup.close();
setPaymentError(
normalizePaymentError(error).message,
);
} finally {
setTelegramBindOpening(false);
}
};
// ── bind_token URL effect ──────────────────────────────
useEffect(() => {
if (!authIsAuthenticated) return;
const params = new URLSearchParams(
window.location.search,
);
const token = params.get("bind_token");
if (!token) return;
const url = new URL(window.location.href);
url.searchParams.delete("bind_token");
window.history.replaceState(
null,
"",
url.toString(),
);
(async () => {
setPaymentError("");
setPaymentInfo("");
try {
const authHeaders = await buildAuthedHeaders(
true,
false,
);
const res = await fetch(
"/api/auth/telegram/bind-by-token",
{
method: "POST",
headers: authHeaders,
body: JSON.stringify({ token }),
},
);
if (!res.ok) {
const raw = (await res.text()).slice(
0,
350,
);
throw new Error(
copy.bindFailed.replace("{raw}", raw),
);
}
const data = (await res.json()) as {
telegram_pricing?: TelegramPricing | null;
};
if (
data.telegram_pricing?.is_group_member
) {
const amount =
data.telegram_pricing.amount_usdc ||
"10";
setPaymentInfo(
copy.telegramVerifySuccess.replace(
"{amount}",
amount,
),
);
}
await loadSnapshot();
await loadPaymentSnapshot();
} catch (error) {
setPaymentError(
normalizePaymentError(error).message,
);
}
})();
}, [
authIsAuthenticated,
buildAuthedHeaders,
loadPaymentSnapshot,
loadSnapshot,
]);
// ==========================================================
return {
// State from usePaymentState
paymentBusy,
paymentInfo,
paymentError,
lastIntentId,
lastTxHash,
lastPaymentStartedAt,
telegramBindOpening,
telegramBindUrl,
manualPayment,
manualTxHash,
txValidation,
paymentMethodTab,
clearPaymentMessages,
clearPaymentState,
// Setters from usePaymentState (needed by component render)
setPaymentBusy,
setPaymentInfo,
setPaymentError,
setLastIntentId,
setLastTxHash,
setLastPaymentStartedAt,
setTelegramBindOpening,
setPaymentMethodTab,
setManualPayment,
setManualTxHash,
setTxValidation,
// Additional state
paymentConfig,
boundWallets,
walletAddress,
selectedPlanCode,
selectedTokenAddress,
selectedWallet,
providerMode,
injectedProviderOptions,
selectedInjectedProviderKey,
reconcileBusy,
// Setters for shared state
setSelectedTokenAddress,
setSelectedWallet,
setSelectedInjectedProviderKey,
setProviderMode,
// Derived values
authUserId,
authIsAuthenticated,
paymentReadyForRecovery,
hasRecentPaymentRecovery,
allowedPaymentHosts,
currentPaymentHost,
paymentHostAllowed,
selectedPlan,
selectedPaymentToken,
selectedTokenLabel,
availableTokenList,
effectivePlanList,
resolvedSelectedTokenAddress,
paymentReceiverAddress,
paymentWalletLabel,
hasPayingWallet,
totalPoints,
billing,
// Callbacks
loadSnapshot,
loadPaymentSnapshot,
connectAndBindWallet,
handleUnbindWallet,
createIntentAndPay,
createManualPaymentIntent,
submitManualPaymentTx,
validateTxHash,
handleOverlayCheckout,
openTelegramBotBindLink,
};
}