"use client"; import { useCallback, useMemo } from "react"; import type { AuthMeResponse, BoundWallet, ConnectBindOptions, CreatedIntent, EvmProvider, IntentStatusResponse, PaymentChainOption, PaymentConfig, PaymentTokenOption, ProviderMode, ProviderSelection, } from "./types"; import { WALLET_TRANSACTION_REQUEST_TIMEOUT_MS, } from "./constants"; import { chainIdToDisplayName, clearStoredPaymentRecovery, shortAddress } from "./formatters"; import { buildAllowanceCalldata, buildApproveCalldata, buildBalanceOfCalldata, formatTokenUnits, normalizePaymentError, requestWalletWithTimeout, } from "./payment-utils"; import { trackAppEvent } from "@/lib/app-analytics"; import { assertExpectedPaymentReceiver, EXPECTED_PAYMENT_RECEIVER_ADDRESS, } from "@/lib/payment-receiver"; import type { PaymentTxValidationState } from "./usePaymentState"; // ============================================================ export interface UsePaymentFlowParams { isEn: boolean; copy: Record; backend: AuthMeResponse | null; // Shared payment config state paymentConfig: PaymentConfig | null; setPaymentConfig: React.Dispatch>; selectedPlanCode: string; setSelectedPlanCode: React.Dispatch>; selectedPaymentChainId: number; setSelectedPaymentChainId: React.Dispatch>; selectedTokenAddress: string; setSelectedTokenAddress: React.Dispatch>; // Shared wallet state (read-only) boundWallets: BoundWallet[]; selectedWallet: string; walletAddress: string; providerMode: ProviderMode; selectedInjectedProviderKey: string; // Payment UI state + setters manualTxHash: string; manualPayment: CreatedIntent["direct_payment"] | null; lastIntentId: string; setPaymentBusy: (v: boolean) => void; setPaymentInfo: (v: string) => void; setPaymentError: (v: string) => void; setLastIntentId: (v: string) => void; setLastTxHash: (v: string) => void; setLastPaymentStartedAt: (v: number) => void; setShowOverlay: (v: boolean) => void; setManualPayment: (v: CreatedIntent["direct_payment"] | null) => void; setManualTxHash: (v: string) => void; setTxValidation: (v: PaymentTxValidationState) => void; setPaymentMethodTab: (v: "wallet" | "manual") => void; clearPaymentMessages: () => void; clearPaymentState: () => void; // Setters for shared state setSelectedWallet: React.Dispatch>; setProviderMode: React.Dispatch>; // Derived values from master selectedPlan?: { plan_code?: string; amount_usdc?: string }; billing: { planAmount: number; pointsEnabled: boolean; pointsPerUsdc: number; maxDiscountUsdc: number; pointsUsed: number; discountAmount: number; payAmount: number; canRedeem: boolean; }; usePoints: boolean; currentPaymentHost: string; paymentHostAllowed: boolean; allowedPaymentHosts: string[]; authIsAuthenticated: boolean; hasPayingWallet: boolean; // Callbacks from master getValidAccessToken: () => Promise; buildAuthedHeaders: (withJson?: boolean, requireAuth?: boolean) => Promise>; loadSnapshot: () => Promise; refreshEntitlementAfterPayment: () => Promise; loadPaymentSnapshot: () => Promise; waitForReceipt: (txHash: string, provider?: EvmProvider, timeoutMs?: number, pollMs?: number) => Promise; ensureTargetChain: (eth: EvmProvider, targetChainId: number, chain?: PaymentChainOption) => Promise; // Ref-wrapped cross-hook callbacks connectAndBindWalletRef: React.MutableRefObject<((mode?: ProviderMode, options?: ConnectBindOptions) => Promise) | null>; handleSubmit409Ref: React.MutableRefObject<((intentId: string, txHashNorm: string, raw: string) => Promise) | null>; // Wallet provider helpers resolvePaymentProvider: (mode?: ProviderMode, preferredInjectedKey?: string) => Promise; } // ============================================================ export function usePaymentFlow(params: UsePaymentFlowParams) { const { isEn, copy, backend, paymentConfig, setPaymentConfig, selectedPlanCode, setSelectedPlanCode, selectedPaymentChainId, setSelectedPaymentChainId, selectedTokenAddress, setSelectedTokenAddress, boundWallets, selectedWallet, walletAddress, providerMode, selectedInjectedProviderKey, setPaymentBusy, setPaymentInfo, setPaymentError, setLastIntentId, setLastTxHash, setLastPaymentStartedAt, setShowOverlay, setManualPayment, setManualTxHash, setTxValidation, manualTxHash, manualPayment, lastIntentId, setPaymentMethodTab, clearPaymentMessages, clearPaymentState, setSelectedWallet, setProviderMode, billing, usePoints, currentPaymentHost, paymentHostAllowed, allowedPaymentHosts, authIsAuthenticated, hasPayingWallet, getValidAccessToken, buildAuthedHeaders, loadSnapshot, refreshEntitlementAfterPayment, loadPaymentSnapshot, waitForReceipt, ensureTargetChain, connectAndBindWalletRef, handleSubmit409Ref, resolvePaymentProvider, } = params; // ── Derived payment values ────────────────────────────── const planList = paymentConfig?.plans || []; const effectivePlanList = planList; const selectedPlan = effectivePlanList.find((plan) => plan.plan_code === selectedPlanCode) || effectivePlanList[0]; const trackPaymentStart = useCallback((payload: Record) => { trackAppEvent("checkout_started", payload); trackAppEvent("payment_start", payload); }, []); const trackPaymentSuccess = useCallback((payload: Record) => { trackAppEvent("checkout_succeeded", payload); trackAppEvent("payment_success", payload); }, []); const verifyPaymentAuthReady = useCallback(async () => { const accessToken = await getValidAccessToken(); const authHeaders: Record = { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}`, }; const authRes = await fetch("/api/auth/me", { cache: "no-store", headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" }, }); if (!authRes.ok) { const raw = (await authRes.text().catch(() => "")).slice(0, 240); throw new Error( isEn ? `Authentication check failed before payment (${authRes.status}). ${raw}` : `支付前登录态校验失败 (${authRes.status})。${raw}`, ); } const profile = (await authRes.json()) as AuthMeResponse; if (profile.authenticated !== true) { throw new Error(copy.loginBeforePay); } return { authHeaders, auth_confirmed_at: new Date().toISOString(), profile, }; }, [copy.loginBeforePay, getValidAccessToken, isEn]); const availableChainList: PaymentChainOption[] = useMemo(() => { const configured = Array.isArray(paymentConfig?.chains) ? paymentConfig?.chains || [] : []; const clean = configured .map((row) => ({ ...row, chain_id: Number(row.chain_id), name: String(row.name || chainIdToDisplayName(Number(row.chain_id))), })) .filter((row) => Number.isFinite(row.chain_id) && row.chain_id > 0); if (clean.length) return clean; const chainIds = new Set(); const defaultChainId = Number(paymentConfig?.default_chain_id || paymentConfig?.chain_id || 137); if (Number.isFinite(defaultChainId) && defaultChainId > 0) chainIds.add(defaultChainId); (paymentConfig?.tokens || []).forEach((token) => { const chainId = Number(token.chain_id || defaultChainId); if (Number.isFinite(chainId) && chainId > 0) chainIds.add(chainId); }); return Array.from(chainIds).sort((a, b) => a - b).map((chainId) => ({ chain_id: chainId, name: chainIdToDisplayName(chainId), is_default: chainId === defaultChainId, })); }, [paymentConfig]); const selectedPaymentChain = availableChainList.find((chain) => chain.chain_id === selectedPaymentChainId) || availableChainList.find((chain) => chain.is_default) || availableChainList[0]; const effectivePaymentChainId = Number( selectedPaymentChain?.chain_id || selectedPaymentChainId || paymentConfig?.default_chain_id || paymentConfig?.chain_id || 137, ); 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"), chain_id: Number(row.chain_id || effectivePaymentChainId), decimals: Number.isFinite(Number(row.decimals)) ? Number(row.decimals) : Number(paymentConfig?.token_decimals ?? 6), })) .filter((row) => Number(row.chain_id) === effectivePaymentChainId); 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, chain_id: effectivePaymentChainId, decimals: Number(paymentConfig?.token_decimals ?? 6), receiver_contract: paymentConfig?.receiver_contract, is_default: true, }]; }, [effectivePaymentChainId, paymentConfig]); const resolvedSelectedTokenAddress = String( ( selectedTokenAddress && availableTokenList.some((row) => row.address === String(selectedTokenAddress).toLowerCase()) ? selectedTokenAddress : "" ) || availableTokenList.find((row) => row.is_default)?.address || 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; // ── fetchLatestPaymentConfig ──────────────────────────── const fetchLatestPaymentConfig = useCallback( async (authHeaders?: Record, syncState = true): Promise => { 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 chainOptions = Array.isArray(configJson.chains) ? configJson.chains.filter((row) => Number(row?.chain_id) > 0) : []; const defaultChainId = Number( configJson.default_chain_id || chainOptions.find((row) => row.is_default)?.chain_id || configJson.chain_id || 137, ); const supportedChainIds = new Set( (chainOptions.length ? chainOptions : [{ chain_id: defaultChainId }]) .map((row) => Number(row.chain_id)) .filter((value) => Number.isFinite(value) && value > 0), ); setSelectedPaymentChainId((prev) => prev && supportedChainIds.has(prev) ? prev : defaultChainId, ); const activeChainId = selectedPaymentChainId && supportedChainIds.has(selectedPaymentChainId) ? selectedPaymentChainId : defaultChainId; const tokenOptions = Array.isArray(configJson.tokens) ? configJson.tokens.filter((row) => typeof row?.address === "string" && String(row.address).startsWith("0x")) : []; const tokenOptionsForChain = tokenOptions.filter( (row) => Number(row.chain_id || activeChainId) === activeChainId, ); const defaultTokenAddress = String( configJson.default_token_address || tokenOptionsForChain.find((row) => row.is_default)?.address || tokenOptionsForChain[0]?.address || tokenOptions.find((row) => row.is_default)?.address || tokenOptions[0]?.address || configJson.token_address || "", ).toLowerCase(); if (defaultTokenAddress) { const tokenSet = new Set(tokenOptionsForChain.map((row) => String(row.address).toLowerCase())); setSelectedTokenAddress((prev: string) => prev && tokenSet.has(String(prev).toLowerCase()) ? prev : defaultTokenAddress, ); } } return configJson; }, [buildAuthedHeaders, selectedPaymentChainId, selectedPlanCode], ); // ── pollIntentUntilConfirmed ──────────────────────────── const pollIntentUntilConfirmed = useCallback( async (intentId: string, authHeaders: Record, 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))); trackPaymentSuccess({ entry: "account_center", plan_code: selectedPlan?.plan_code || "pro_monthly", intent_id: intentId, tx_hash: txHash || null, }); await refreshEntitlementAfterPayment(); 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, refreshEntitlementAfterPayment, selectedPlan?.plan_code, trackPaymentSuccess], ); // ── createIntentAndPay ────────────────────────────────── const createIntentAndPay = async () => { clearPaymentMessages(); clearPaymentState(); clearStoredPaymentRecovery(); if (!paymentHostAllowed) { setPaymentError(copy.paymentHostBlocked.replace("{host}", allowedPaymentHosts[0] || "polyweather.top")); 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 authReady = await verifyPaymentAuthReady(); const authHeaders = authReady.authHeaders; const providerSelection = await resolvePaymentProvider(providerMode, selectedInjectedProviderKey); const eth = providerSelection.provider; const activeAccounts = await requestWalletWithTimeout( 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); const latestConfig = await fetchLatestPaymentConfig(authHeaders, true); if (!latestConfig?.enabled || !latestConfig?.configured) throw new Error(copy.payNotReady); const targetChainId = Number(selectedPaymentChainId || latestConfig.default_chain_id || latestConfig.chain_id || 137); const latestChains = Array.isArray(latestConfig.chains) ? latestConfig.chains : []; const targetChain = latestChains.find((chain) => Number(chain.chain_id) === targetChainId) || selectedPaymentChain; const latestTokens = Array.isArray(latestConfig.tokens) ? latestConfig.tokens : []; const selectedLatestToken = latestTokens.find( (token) => Number(token.chain_id || targetChainId) === targetChainId && String(token.address || "").toLowerCase() === resolvedSelectedTokenAddress, ) || latestTokens.find( (token) => Number(token.chain_id || targetChainId) === targetChainId && token.is_default, ) || latestTokens.find((token) => Number(token.chain_id || targetChainId) === targetChainId); if (selectedLatestToken?.supports_contract_checkout === false) { throw new Error( isEn ? `${selectedLatestToken.chain_name || chainIdToDisplayName(targetChainId)} ${selectedLatestToken.symbol || "USDC"} supports manual transfer only.` : `${selectedLatestToken.chain_name || chainIdToDisplayName(targetChainId)} ${selectedLatestToken.symbol || "USDC"} 仅支持手动转账。`, ); } const expectedReceiver = String(selectedLatestToken?.receiver_contract || 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))); } await ensureTargetChain(eth, targetChainId, targetChain); 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, chain_id: targetChainId, token_address: String(selectedLatestToken?.address || resolvedSelectedTokenAddress || "").toLowerCase() || 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, auth_confirmed_at: authReady.auth_confirmed_at, }, }), }); 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); trackPaymentStart({ 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( 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( 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 = { from: payingWallet, to: tokenAddress, data: buildApproveCalldata(txPayload.to, amountUnits), }; const approveHash = await requestWalletWithTimeout( 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 = { 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( eth, { method: "eth_sendTransaction", params: [payParams] }, "发起支付交易", WALLET_TRANSACTION_REQUEST_TIMEOUT_MS, ); const txHashNorm = String(txHash || "").toLowerCase(); setLastTxHash(txHashNorm); setLastPaymentStartedAt(Date.now()); setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待链上回执...`); await waitForReceipt(txHashNorm, eth); 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) { if (handleSubmit409Ref.current) await handleSubmit409Ref.current(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))); trackPaymentSuccess({ entry: "account_center", plan_code: selectedPlan?.plan_code || "pro_monthly", intent_id: intentId, tx_hash: txHashNorm, }); await refreshEntitlementAfterPayment(); 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.top")); return; } if (!authIsAuthenticated) { setPaymentError(copy.loginBeforePay); return; } if (!paymentConfig?.configured) { setPaymentError(copy.payNotReady); return; } setPaymentBusy(true); try { const authHeaders = await buildAuthedHeaders(true, true); const createRes = await fetch("/api/payments/intents", { method: "POST", headers: authHeaders, body: JSON.stringify({ plan_code: selectedPlan?.plan_code || "pro_monthly", payment_mode: "direct", chain_id: effectivePaymentChainId, 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); const chainName = direct.chain_name || chainIdToDisplayName(direct.chain_id); setPaymentInfo( copy.manualOrderCreated .replace("{amount}", direct.amount_usdc) .replace("{symbol}", direct.token_symbol || selectedTokenLabel) .replace("{chain}", chainName) .replace("{receiver}", shortAddress(direct.receiver_address)), ); trackPaymentStart({ 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 intentIdVal = String(lastIntentId || manualPayment?.intent_id || "").trim(); if (!intentIdVal || !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, true); const submitRes = await fetch(`/api/payments/intents/${intentIdVal}/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) { if (handleSubmit409Ref.current) await handleSubmit409Ref.current(intentIdVal, txHashNorm, raw); return; } throw new Error(copy.submitTxFailed.replace("{raw}", raw)); } const confirmRes = await fetch(`/api/payments/intents/${intentIdVal}/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(intentIdVal, 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 }); trackPaymentSuccess({ entry: "account_center_manual_transfer", plan_code: selectedPlan?.plan_code || "pro_monthly", intent_id: intentIdVal, tx_hash: txHashNorm, }); await refreshEntitlementAfterPayment(); 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, true); 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; }; 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.top")); return; } if (!authIsAuthenticated) { setPaymentError(copy.loginBeforePay); return; } if (!hasPayingWallet) { setPaymentInfo(copy.openBindFlow); if (connectAndBindWalletRef.current) { const bound = await connectAndBindWalletRef.current(providerMode, { openOverlayAfterBind: true }); if (!bound) return; } setPaymentInfo(copy.walletBoundCreatingOrder); await createIntentAndPay(); return; } await createIntentAndPay(); }; // ========================================================== return { fetchLatestPaymentConfig, pollIntentUntilConfirmed, createIntentAndPay, createManualPaymentIntent, submitManualPaymentTx, validateTxHash, handleOverlayCheckout, availableTokenList, availableChainList, selectedPaymentChain, resolvedSelectedTokenAddress, selectedPaymentToken, selectedTokenLabel, paymentReceiverAddress, }; }