"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import type { User } from "@supabase/supabase-js"; import { getAllowedPaymentHosts, getCurrentPaymentHost, isPaymentHostAllowed, } from "@/lib/payment-host"; import type { AuthMeResponse, PaymentConfig, PaymentRecoveryState, TelegramPricing, } from "./types"; import { PAYMENT_RECOVERY_STORAGE_KEY, PAYMENT_RECOVERY_TTL_MS, } from "./constants"; import { clearStoredPaymentRecovery, shortAddress } from "./formatters"; import { normalizePaymentError } from "./payment-utils"; import { isTelegramPrivateGroupPriceEligible } from "./telegram-pricing"; import { trackAppEvent } from "@/lib/app-analytics"; // ============================================================ type TelegramBotBindPayload = { bot_command?: string; bot_url?: string; start_param?: string; }; export interface UseBillingParams { isEn: boolean; copy: Record; backend: AuthMeResponse | null; supabaseReady: boolean; // Shared state from master paymentConfig: PaymentConfig | null; authUserId: string; authIsAuthenticated: boolean; usePoints: boolean; setUsePoints: (v: boolean) => void; // Payment UI state from usePaymentState lastIntentId: string; lastTxHash: string; lastPaymentStartedAt: number; setLastIntentId: (v: string) => void; setLastTxHash: (v: string) => void; setLastPaymentStartedAt: (v: number) => void; setPaymentBusy: (v: boolean) => void; setPaymentInfo: (v: string) => void; setPaymentError: (v: string) => void; setTelegramBindOpening: (v: boolean) => void; clearPaymentState: () => void; // Derived / callbacks from master selectedPlan?: { plan_code?: string; amount_usdc?: string } | undefined; getValidAccessToken: () => Promise; buildAuthedHeaders: (withJson?: boolean, requireAuth?: boolean) => Promise>; loadSnapshot: () => Promise; refreshEntitlementAfterPayment: () => Promise; loadPaymentSnapshot: () => Promise; // User for metadata points user: User | null; } // ============================================================ export function useBilling(params: UseBillingParams) { const { isEn, copy, backend, supabaseReady, paymentConfig, authUserId, authIsAuthenticated, usePoints, setUsePoints, lastIntentId, lastTxHash, lastPaymentStartedAt, setLastIntentId, setLastTxHash, setLastPaymentStartedAt, setPaymentBusy, setPaymentInfo, setPaymentError, setTelegramBindOpening, clearPaymentState, selectedPlan, getValidAccessToken, buildAuthedHeaders, loadSnapshot, refreshEntitlementAfterPayment, loadPaymentSnapshot, user, } = params; // ── Billing-specific state ──────────────────────────────── const [reconcileBusy, setReconcileBusy] = useState(false); const [telegramBindUrl, setTelegramBindUrl] = useState(""); const [telegramBindCommand, setTelegramBindCommand] = useState(""); // ── Derived values ────────────────────────────────────── 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]); // ── 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 listAmountRaw = Number(selectedPlan?.amount_usdc ?? 29.9); const listAmount = Number.isFinite(listAmountRaw) && listAmountRaw > 0 ? listAmountRaw : 29.9; const selectedPlanCode = String(selectedPlan?.plan_code || "").toLowerCase(); const telegramGroupPriceApplies = Boolean( selectedPlanCode === "pro_monthly" && isTelegramPrivateGroupPriceEligible(backend?.telegram_pricing), ); const referral = backend?.referral; const referralPending = Boolean( referral?.applied_code || String(referral?.attribution_status || "").toLowerCase() === "pending", ); const referralDiscountRaw = Number(referral?.discount_usdc ?? 0); const referralDiscount = Number.isFinite(referralDiscountRaw) ? Math.max(0, referralDiscountRaw) : 0; const discountedMonthlyRaw = Number( referral?.discounted_monthly_amount_usdc ?? 0, ); const referralApplies = selectedPlanCode === "pro_monthly" && referralPending && !telegramGroupPriceApplies && backend?.subscription_active !== true; const planAmount = referralApplies ? Number.isFinite(discountedMonthlyRaw) && discountedMonthlyRaw > 0 ? discountedMonthlyRaw : Math.max(0, listAmount - referralDiscount) : listAmount; 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 maxDiscountByPlan = pointsCfg.max_discount_usdc_by_plan || {}; const planMaxDiscountRaw = maxDiscountByPlan[selectedPlanCode] ?? pointsCfg.max_discount_usdc ?? 3; const maxDiscountRaw = Number(planMaxDiscountRaw); const maxDiscountUsdc = Math.max( 0, Math.min( Math.floor(Number.isFinite(maxDiscountRaw) ? maxDiscountRaw : 3), Math.floor(planAmount), ), ); const maxRedeemablePoints = pointsPerUsdc * maxDiscountUsdc; const pointsCanApply = pointsEnabled && !referralApplies; const actualRedeem = pointsCanApply ? Math.min(totalPoints, maxRedeemablePoints) : 0; const discountUnits = Math.floor(actualRedeem / pointsPerUsdc); const pointsUsed = discountUnits * pointsPerUsdc; const canRedeem = pointsCanApply && maxDiscountUsdc > 0 && totalPoints >= pointsPerUsdc; const applyDiscount = usePoints && canRedeem && pointsUsed > 0; return { planAmount, listAmount, referralApplied: referralApplies, referralDiscountAmount: referralApplies ? listAmount - planAmount : 0, pointsEnabled, pointsPerUsdc, maxDiscountUsdc, pointsUsed, discountAmount: discountUnits, payAmount: planAmount - (applyDiscount ? discountUnits : 0), canRedeem, }; }, [ paymentConfig?.points_redemption, backend?.referral, backend?.subscription_active, backend?.telegram_pricing, selectedPlan?.plan_code, 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 refreshEntitlementAfterPayment(); await loadPaymentSnapshot(); return true; } return false; } catch { return false; } finally { setReconcileBusy(false); } }, [ authIsAuthenticated, buildAuthedHeaders, copy.walletRecoveryDone, loadPaymentSnapshot, refreshEntitlementAfterPayment, 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 refreshEntitlementAfterPayment(); 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 refreshEntitlementAfterPayment(); 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, loadPaymentSnapshot, refreshEntitlementAfterPayment, reconcileLatestPayment], ); // ── Telegram bot bind helpers ───────────────────────────── const requestTelegramBotBindPayload = useCallback(async () => { 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 TelegramBotBindPayload; const botUrl = String(data.bot_url || "").trim(); const startParam = String(data.start_param || "").trim(); const botCommand = String( data.bot_command || (startParam ? `/start ${startParam}` : ""), ).trim(); if (!botUrl && !botCommand) throw new Error(copy.telegramBindLinkMissing); setTelegramBindUrl(botUrl); setTelegramBindCommand(botCommand); return { botCommand, botUrl }; }, [ buildAuthedHeaders, copy.telegramBindFailed, copy.telegramBindLinkMissing, ]); const createTelegramBotBindCommand = async () => { setTelegramBindOpening(true); setPaymentError(""); setTelegramBindUrl(""); setTelegramBindCommand(""); try { const { botCommand } = await requestTelegramBotBindPayload(); if (!botCommand) throw new Error(copy.telegramBindLinkMissing); setPaymentInfo(copy.telegramBindCommandCopied); return botCommand; } catch (error) { setPaymentError(normalizePaymentError(error).message); return ""; } finally { setTelegramBindOpening(false); } }; const openTelegramBotBindLink = async () => { setTelegramBindOpening(true); setPaymentError(""); setTelegramBindUrl(""); setTelegramBindCommand(""); try { const { botUrl, botCommand } = await requestTelegramBotBindPayload(); if (!botUrl) throw new Error(copy.telegramBindLinkMissing); // Open directly — skip about:blank to avoid browser popup blockers const win = window.open(botUrl, "_blank", "noopener,noreferrer"); if (win) { setPaymentInfo(copy.telegramBindClickHint); } else { // Popup blocked — show fallback link setPaymentInfo(copy.telegramPopupBlocked); setTelegramBindUrl(botUrl); setTelegramBindCommand(botCommand); } } catch (error) { setPaymentError(normalizePaymentError(error).message); } finally { setTelegramBindOpening(false); } }; // ── 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]); // ── 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, ]); // ── 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 }; const telegramPricing = data.telegram_pricing; if (isTelegramPrivateGroupPriceEligible(telegramPricing)) { const amount = telegramPricing?.amount_usdc || "10"; setPaymentInfo(copy.telegramVerifySuccess.replace("{amount}", amount)); } await loadSnapshot(); await loadPaymentSnapshot(); } catch (error) { setPaymentError(normalizePaymentError(error).message); } })(); }, [authIsAuthenticated, buildAuthedHeaders, loadPaymentSnapshot, loadSnapshot]); // ========================================================== return { reconcileBusy, telegramBindUrl, telegramBindCommand, setTelegramBindUrl, reconcileLatestPayment, handleSubmit409, createTelegramBotBindCommand, openTelegramBotBindLink, paymentReadyForRecovery, hasRecentPaymentRecovery, allowedPaymentHosts, currentPaymentHost, paymentHostAllowed, totalPoints, billing, }; }