Files
PolyWeather/frontend/components/account/useBilling.ts
2026-05-30 18:04:36 +08:00

431 lines
16 KiB
TypeScript

"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 { trackAppEvent } from "@/lib/app-analytics";
// ============================================================
export interface UseBillingParams {
isEn: boolean;
copy: Record<string, string>;
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<string>;
buildAuthedHeaders: (withJson?: boolean, requireAuth?: boolean) => Promise<Record<string, string>>;
loadSnapshot: () => Promise<void>;
refreshEntitlementAfterPayment: () => Promise<void>;
loadPaymentSnapshot: () => Promise<void>;
// 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("");
// ── 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 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 &&
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,
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],
);
// ── 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);
}
};
// ── 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 };
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 {
reconcileBusy,
telegramBindUrl,
setTelegramBindUrl,
reconcileLatestPayment,
handleSubmit409,
openTelegramBotBindLink,
paymentReadyForRecovery,
hasRecentPaymentRecovery,
allowedPaymentHosts,
currentPaymentHost,
paymentHostAllowed,
totalPoints,
billing,
};
}