From 3534f759d0c723bed82d141020243ab391e7fbcc Mon Sep 17 00:00:00 2001
From: "2569718930@qq.com" <2569718930@qq.com>
Date: Mon, 25 May 2026 02:14:21 +0800
Subject: [PATCH] =?UTF-8?q?=E7=BB=88=E7=AB=AF=E5=86=85=E7=9B=B4=E6=8E=A5?=
=?UTF-8?q?=E5=BC=B9=E5=87=BA=20UnlockProOverlay=EF=BC=9A=E8=AE=A2?=
=?UTF-8?q?=E9=98=85=E6=94=AF=E4=BB=98=E6=97=A0=E9=9C=80=E8=B7=B3=E8=BD=AC?=
=?UTF-8?q?=E8=B4=A6=E6=88=B7=E9=A1=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
新增 useTerminalPay hook 和 billing-utils 纯函数,SubscriptionGate 集成支付弹窗。
用户点击订阅后原地弹窗 → 连接钱包 → 链上支付 → 自动刷新进入终端。
---
.../dashboard/ScanTerminalDashboard.tsx | 131 +++++-
.../components/subscription/useTerminalPay.ts | 418 ++++++++++++++++++
frontend/lib/billing-utils.ts | 69 +++
3 files changed, 611 insertions(+), 7 deletions(-)
create mode 100644 frontend/components/subscription/useTerminalPay.ts
create mode 100644 frontend/lib/billing-utils.ts
diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx
index 23a63cf9..522fe953 100644
--- a/frontend/components/dashboard/ScanTerminalDashboard.tsx
+++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx
@@ -20,7 +20,7 @@ import {
Table2,
UserRound,
} from "lucide-react";
-import { Fragment, useEffect, useMemo, useState } from "react";
+import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
import {
Area,
AreaChart,
@@ -36,6 +36,8 @@ import type { ProAccessState, ScanOpportunityRow } from "@/lib/dashboard-types";
import { getInitialLocaleFromNavigator } from "@/lib/i18n";
import { isBrowserLocalFullAccess } from "@/lib/local-dev-access";
import { sortRowsByUserTime } from "@/components/dashboard/scan-terminal/decision-utils";
+import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay";
+import { useTerminalPay } from "@/components/subscription/useTerminalPay";
import {
type ContinentGroup,
buildContinentGroups,
@@ -888,7 +890,42 @@ function KoyfinWeatherTerminal({
// ─── Layer 2: Authenticated but no active subscription ───────────────────────
// Shown inside the terminal shell after middleware has confirmed the user is
// logged in (Layer 1). Presents a targeted upgrade prompt.
-function SubscriptionGate({ isEn }: { isEn: boolean }) {
+function SubscriptionGate({
+ isEn,
+ points,
+ onPaid,
+}: {
+ isEn: boolean;
+ points: number;
+ onPaid: () => void;
+}) {
+ const [showOverlay, setShowOverlay] = useState(false);
+ const {
+ billing,
+ usePoints,
+ setUsePoints,
+ payBusy,
+ errorText,
+ infoText,
+ txHash,
+ chainId,
+ tokenSymbol,
+ walletAddress,
+ handlePay,
+ } = useTerminalPay({
+ points,
+ planPriceUsd: 10,
+ onPaid: () => {
+ setShowOverlay(false);
+ onPaid();
+ },
+ isEn,
+ });
+
+ const handleSubscribeClick = () => {
+ setShowOverlay(true);
+ };
+
const features = isEn
? [
"Real-time METAR observations across 500+ stations",
@@ -954,13 +991,14 @@ function SubscriptionGate({ isEn }: { isEn: boolean }) {
{/* CTA */}
-
{t("subscribeNow", isEn)}
-
+
{isEn
@@ -980,6 +1018,38 @@ function SubscriptionGate({ isEn }: { isEn: boolean }) {
+
+ {/* Payment overlay */}
+ {showOverlay && (
+
+ setUsePoints((prev) => !prev)}
+ onPay={() => void handlePay()}
+ onClose={() => setShowOverlay(false)}
+ payBusy={payBusy}
+ payLabel={
+ walletAddress
+ ? isEn
+ ? "Subscribe & Activate"
+ : "立即订阅并激活服务"
+ : isEn
+ ? "Connect Wallet & Subscribe"
+ : "连接钱包并订阅"
+ }
+ errorText={errorText || undefined}
+ infoText={infoText || undefined}
+ txHash={txHash || undefined}
+ chainId={chainId}
+ paymentTokenLabel={tokenSymbol}
+ locale={isEn ? "en-US" : "zh-CN"}
+ faqHref="/subscription-help"
+ />
+
+ )}
);
}
@@ -1047,17 +1117,21 @@ function UnauthenticatedGate({
function ProductAccessRequired({
isAuthenticated,
isEn,
+ points,
+ onPaid,
userLocalTime,
}: {
isAuthenticated: boolean;
isEn: boolean;
+ points: number;
+ onPaid: () => void;
userLocalTime: string;
}) {
// Layer 1 fallback (middleware should catch this first in production)
if (!isAuthenticated) {
return ;
}
- // Layer 2: logged in, no subscription
+ // Layer 2: logged in, no subscription — inline pay overlay
return (
@@ -1069,7 +1143,7 @@ function ProductAccessRequired({
{userLocalTime}
-
+
);
@@ -1187,11 +1261,54 @@ function ScanTerminalScreen() {
);
}
+ const refreshAuth = useCallback(() => {
+ fetch("/api/auth/me", {
+ cache: "no-store",
+ headers: { Accept: "application/json" },
+ })
+ .then(async (response) => {
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ return response.json() as Promise<{
+ authenticated?: boolean;
+ user_id?: string | null;
+ subscription_active?: boolean | null;
+ subscription_plan_code?: string | null;
+ subscription_expires_at?: string | null;
+ subscription_total_expires_at?: string | null;
+ subscription_queued_days?: number | null;
+ points?: number | null;
+ }>;
+ })
+ .then((payload) => {
+ setProAccess({
+ loading: false,
+ authenticated: Boolean(payload.authenticated),
+ userId: payload.user_id ?? null,
+ subscriptionActive: payload.subscription_active === true,
+ subscriptionPlanCode: payload.subscription_plan_code ?? null,
+ subscriptionExpiresAt: payload.subscription_expires_at ?? null,
+ subscriptionTotalExpiresAt:
+ payload.subscription_total_expires_at ??
+ payload.subscription_expires_at ??
+ null,
+ subscriptionQueuedDays: Math.max(
+ 0,
+ Number(payload.subscription_queued_days ?? 0),
+ ),
+ points: Number(payload.points ?? 0),
+ error: null,
+ });
+ })
+ .catch(() => {}); // keep current state on error
+ }, []);
+
if (!isAuthenticated || !isPro) {
return (
);
diff --git a/frontend/components/subscription/useTerminalPay.ts b/frontend/components/subscription/useTerminalPay.ts
new file mode 100644
index 00000000..f0452cce
--- /dev/null
+++ b/frontend/components/subscription/useTerminalPay.ts
@@ -0,0 +1,418 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import type { UnlockProBilling } from "@/components/subscription/UnlockProOverlay";
+import { computeBilling } from "@/lib/billing-utils";
+import {
+ WALLETCONNECT_POLYGON_RPC_URL,
+ WALLET_TRANSACTION_REQUEST_TIMEOUT_MS,
+} from "@/components/account/constants";
+import {
+ buildAllowanceCalldata,
+ buildApproveCalldata,
+ buildBalanceOfCalldata,
+ requestWalletWithTimeout,
+} from "@/components/account/payment-utils";
+import { isPaymentHostAllowed, getCurrentPaymentHost } from "@/lib/payment-host";
+import {
+ getEvmProvider,
+ getEvmWalletLabel,
+ getWalletConnectProvider,
+} from "@/components/account/wallet";
+import { getSupabaseBrowserClient } from "@/lib/supabase/client";
+import type { EvmProvider, CreatedIntent } from "@/components/account/types";
+
+const DEFAULT_CHAIN_ID = 137;
+const INTENT_POLL_INTERVAL_MS = 5000;
+const INTENT_POLL_TIMEOUT_MS = 180_000;
+
+function normalizePaymentError(err: unknown, fallback: string): string {
+ if (!err) return fallback;
+ const e = err as Record;
+ return String(
+ e.shortMessage || e.message || e.reason || e.error || fallback,
+ );
+}
+
+export function useTerminalPay(params: {
+ points: number;
+ planPriceUsd: number;
+ onPaid: () => void;
+ isEn: boolean;
+}) {
+ const { points, planPriceUsd, onPaid, isEn } = params;
+
+ const [paymentConfig, setPaymentConfig] = useState | null>(null);
+ const [walletAddress, setWalletAddress] = useState(null);
+ const [usePoints, setUsePoints] = useState(true);
+ const [payBusy, setPayBusy] = useState(false);
+ const [errorText, setErrorText] = useState("");
+ const [infoText, setInfoText] = useState("");
+ const [txHash, setTxHash] = useState("");
+
+ useEffect(() => {
+ let cancelled = false;
+ fetch("/api/payments/config", {
+ cache: "no-store",
+ headers: { Accept: "application/json" },
+ })
+ .then(async (r) => {
+ if (!r.ok) return null;
+ return r.json();
+ })
+ .then((cfg) => {
+ if (cancelled) return;
+ setPaymentConfig(cfg || null);
+ })
+ .catch(() => {});
+ return () => { cancelled = true; };
+ }, []);
+
+ const billing: UnlockProBilling = useMemo(() => {
+ const raw = computeBilling({
+ planPriceUsd,
+ totalPoints: points,
+ usePoints,
+ redemptionCfg: (paymentConfig?.points_redemption as Record) ?? null,
+ });
+ return {
+ pointsEnabled: raw.pointsEnabled,
+ isEligible: raw.canRedeem,
+ pointsUsed: raw.pointsUsed,
+ discountAmount: raw.discountAmount,
+ finalPrice: raw.payAmount,
+ maxDiscountUsd: raw.maxDiscountUsdc,
+ pointsPerUsd: raw.pointsPerUsdc,
+ };
+ }, [planPriceUsd, points, usePoints, paymentConfig]);
+
+ const chainId = useMemo(
+ () => Number(paymentConfig?.chain_id || DEFAULT_CHAIN_ID),
+ [paymentConfig],
+ );
+
+ const tokenAddress = useMemo(() => {
+ const tokens = (paymentConfig?.tokens as Array>) || [];
+ return String(tokens[0]?.address || "");
+ }, [paymentConfig]);
+
+ const tokenSymbol = useMemo(() => {
+ const tokens = (paymentConfig?.tokens as Array>) || [];
+ return String(tokens[0]?.symbol || "USDC");
+ }, [paymentConfig]);
+
+ const tokenDecimals = useMemo(() => {
+ const tokens = (paymentConfig?.tokens as Array>) || [];
+ return Number(tokens[0]?.decimals || 6);
+ }, [paymentConfig]);
+
+ const receiverAddress = useMemo(
+ () => String(paymentConfig?.receiver_address || ""),
+ [paymentConfig],
+ );
+
+ const connectWallet = useCallback(async (): Promise => {
+ try {
+ let provider: EvmProvider | null = getEvmProvider();
+ let label = getEvmWalletLabel(provider);
+
+ if (!provider) {
+ const wcId = String(
+ process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || "",
+ ).trim();
+ if (!wcId) {
+ setErrorText(
+ isEn
+ ? "No wallet detected. Install MetaMask or configure WalletConnect."
+ : "未检测到钱包插件,请安装 MetaMask 扩展。",
+ );
+ return null;
+ }
+ provider = await getWalletConnectProvider(
+ chainId,
+ WALLETCONNECT_POLYGON_RPC_URL,
+ );
+ label = "WalletConnect";
+ const existing = (await provider
+ .request({ method: "eth_accounts" })
+ .catch(() => [])) as string[];
+ if (!Array.isArray(existing) || existing.length === 0) {
+ if (typeof provider.connect === "function") {
+ await provider.connect({ chains: [chainId] });
+ }
+ }
+ }
+
+ const accounts = (await provider.request({
+ method: "eth_requestAccounts",
+ })) as string[];
+ const address = String(accounts[0] || "").toLowerCase();
+ if (!address) throw new Error("No account returned");
+
+ setWalletAddress(address);
+ return address;
+ } catch (err) {
+ setErrorText(normalizePaymentError(err, isEn ? "Wallet connection failed" : "钱包连接失败"));
+ return null;
+ }
+ }, [chainId, isEn]);
+
+ const handlePay = useCallback(async () => {
+ if (payBusy) return;
+ setPayBusy(true);
+ setErrorText("");
+ setInfoText("");
+
+ try {
+ const host = getCurrentPaymentHost();
+ const allowed = await isPaymentHostAllowed(host);
+ if (!allowed) throw new Error(isEn ? "Payment host not allowed" : "当前域名不在支付白名单中");
+
+ // Get session token
+ const supabase = getSupabaseBrowserClient();
+ const { data: sessionData } = await supabase.auth.getSession();
+ const token = sessionData?.session?.access_token;
+ if (!token) throw new Error(isEn ? "Session expired, please refresh" : "会话过期,请刷新页面");
+ const authHeaders = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
+
+ // Connect wallet
+ let payingWallet = walletAddress;
+ if (!payingWallet) {
+ payingWallet = await connectWallet();
+ if (!payingWallet) {
+ setPayBusy(false);
+ return;
+ }
+ }
+
+ // Refresh payment config
+ const cfgRes = await fetch("/api/payments/config", {
+ headers: { Accept: "application/json" },
+ });
+ const latestCfg = cfgRes.ok ? await cfgRes.json() : paymentConfig;
+ setPaymentConfig(latestCfg || paymentConfig);
+
+ const targetChainId = Number(latestCfg?.chain_id || DEFAULT_CHAIN_ID);
+
+ // Resolve provider for payment
+ let eth = getEvmProvider();
+ if (!eth) {
+ const wcId = String(process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || "").trim();
+ if (!wcId) throw new Error("No wallet available");
+ eth = await getWalletConnectProvider(targetChainId, WALLETCONNECT_POLYGON_RPC_URL);
+ const existing = (await eth.request({ method: "eth_accounts" }).catch(() => [])) as string[];
+ if (!Array.isArray(existing) || existing.length === 0) {
+ if (typeof eth.connect === "function") {
+ await eth.connect({ chains: [targetChainId] });
+ }
+ }
+ }
+
+ // Ensure target chain
+ const currentChainHex = String(
+ (await requestWalletWithTimeout(eth, { method: "eth_chainId" }, "chainId", WALLET_TRANSACTION_REQUEST_TIMEOUT_MS)) || "",
+ );
+ const targetHex = `0x${targetChainId.toString(16)}`;
+ if (currentChainHex.toLowerCase() !== targetHex.toLowerCase()) {
+ try {
+ await requestWalletWithTimeout(eth, {
+ method: "wallet_switchEthereumChain",
+ params: [{ chainId: targetHex }],
+ }, "switchChain");
+ } catch (switchErr: unknown) {
+ const code = Number((switchErr as Record)?.code);
+ if (code === 4902 || targetChainId === 137) {
+ 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"],
+ }],
+ }, "addChain");
+ } else {
+ throw switchErr;
+ }
+ }
+ }
+
+ // Create payment intent
+ const resolvedToken = String(latestCfg?.tokens?.[0]?.address || tokenAddress);
+ const intentRes = await fetch("/api/payments/intents", {
+ method: "POST",
+ headers: authHeaders,
+ body: JSON.stringify({
+ plan_code: "pro_monthly",
+ payment_mode: "strict",
+ allowed_wallet: payingWallet,
+ token_address: resolvedToken || undefined,
+ use_points: billing.isEligible && usePoints,
+ points_to_consume: billing.isEligible && usePoints ? billing.pointsUsed : 0,
+ metadata: {
+ source: "terminal",
+ frontend_host: host || null,
+ },
+ }),
+ });
+ if (!intentRes.ok) {
+ const errBody = await intentRes.json().catch(() => ({}));
+ throw new Error(String(errBody?.detail || errBody?.message || `HTTP ${intentRes.status}`));
+ }
+ const created = (await intentRes.json()) as CreatedIntent;
+ const intentId = String(created.intent?.intent_id || "");
+ const txPayload = created.tx_payload;
+ if (!intentId || !txPayload?.to || !txPayload?.data) {
+ throw new Error(isEn ? "Invalid payment intent response" : "支付意图创建失败");
+ }
+
+ // Verify receiver
+ const expectedReceiver = String(latestCfg?.receiver_address || receiverAddress);
+ if (expectedReceiver && txPayload.to.toLowerCase() !== expectedReceiver.toLowerCase()) {
+ throw new Error(isEn ? "Payment receiver mismatch" : "收款地址不匹配");
+ }
+
+ const resolvedTokenDecimals = Number(
+ latestCfg?.tokens?.[0]?.decimals || tokenDecimals,
+ );
+ const amountUnits =
+ typeof txPayload.amount_units === "string"
+ ? BigInt(txPayload.amount_units)
+ : BigInt(String(txPayload.value || 0));
+
+ // Check balance
+ const balanceHex = await requestWalletWithTimeout(
+ eth,
+ {
+ method: "eth_call",
+ params: [
+ { to: txPayload.token_address || resolvedToken, data: buildBalanceOfCalldata(payingWallet) },
+ "latest",
+ ],
+ },
+ "balanceOf",
+ );
+ const balance = BigInt(balanceHex || "0x0");
+ if (balance < amountUnits) {
+ throw new Error(isEn ? "Insufficient USDC balance" : "USDC 余额不足");
+ }
+
+ // Check allowance and approve if needed
+ const allowanceHex = await requestWalletWithTimeout(
+ eth,
+ {
+ method: "eth_call",
+ params: [
+ { to: txPayload.token_address || resolvedToken, data: buildAllowanceCalldata(payingWallet, txPayload.to) },
+ "latest",
+ ],
+ },
+ "allowance",
+ );
+ const allowance = BigInt(allowanceHex || "0x0");
+ if (allowance < amountUnits) {
+ setInfoText(isEn ? "Approving USDC..." : "正在授权 USDC...");
+ const approveHash = await requestWalletWithTimeout(
+ eth,
+ {
+ method: "eth_sendTransaction",
+ params: [{
+ from: payingWallet,
+ to: txPayload.token_address || resolvedToken,
+ data: buildApproveCalldata(txPayload.to, amountUnits),
+ }],
+ },
+ "approve",
+ );
+ // Wait for approval receipt via simple polling
+ const approveStart = Date.now();
+ while (Date.now() - approveStart < 120_000) {
+ const receipt = await requestWalletWithTimeout | null>(
+ eth,
+ { method: "eth_getTransactionReceipt", params: [approveHash] },
+ "approveReceipt",
+ ).catch(() => null);
+ if (receipt?.status === "0x1" || receipt?.status === 1) break;
+ if (receipt?.status === "0x0" || receipt?.status === 0) {
+ throw new Error(isEn ? "USDC approval failed" : "USDC 授权失败");
+ }
+ await new Promise((r) => setTimeout(r, 3000));
+ }
+ }
+
+ // Send payment transaction
+ setInfoText(isEn ? "Confirm in wallet..." : "请在钱包中确认交易...");
+ const payHash = await requestWalletWithTimeout(
+ eth,
+ {
+ method: "eth_sendTransaction",
+ params: [{ from: payingWallet, to: txPayload.to, data: txPayload.data }],
+ },
+ "pay",
+ );
+ setTxHash(payHash);
+
+ // Submit
+ const submitRes = await fetch(`/api/payments/intents/${intentId}/submit`, {
+ method: "POST",
+ headers: authHeaders,
+ body: JSON.stringify({ tx_hash: payHash, from_address: payingWallet }),
+ });
+ if (!submitRes.ok && submitRes.status !== 409) {
+ const errBody = await submitRes.json().catch(() => ({}));
+ throw new Error(String(errBody?.detail || `HTTP ${submitRes.status}`));
+ }
+
+ // Confirm
+ const confirmStart = Date.now();
+ let confirmed = false;
+ while (Date.now() - confirmStart < INTENT_POLL_TIMEOUT_MS) {
+ const confirmRes = await fetch(`/api/payments/intents/${intentId}/confirm`, {
+ method: "POST",
+ headers: authHeaders,
+ body: JSON.stringify({ tx_hash: payHash }),
+ });
+ if (confirmRes.ok) {
+ setInfoText(isEn ? "Payment confirmed! Activating..." : "支付确认!正在激活...");
+ confirmed = true;
+ break;
+ }
+ if (confirmRes.status === 409 || confirmRes.status === 404 || confirmRes.status === 408) {
+ await new Promise((r) => setTimeout(r, INTENT_POLL_INTERVAL_MS));
+ continue;
+ }
+ const errBody = await confirmRes.json().catch(() => ({}));
+ throw new Error(String(errBody?.detail || `HTTP ${confirmRes.status}`));
+ }
+ if (!confirmed) {
+ throw new Error(isEn ? "Payment confirmation timeout" : "支付确认超时,请稍后检查订单状态");
+ }
+
+ setInfoText(isEn ? "Subscription activated!" : "订阅已激活!");
+ onPaid();
+ } catch (err) {
+ setErrorText(normalizePaymentError(err, isEn ? "Payment failed" : "支付失败"));
+ } finally {
+ setPayBusy(false);
+ }
+ }, [
+ payBusy, walletAddress, paymentConfig, billing, usePoints, chainId,
+ tokenAddress, tokenDecimals, receiverAddress, isEn, connectWallet, onPaid,
+ ]);
+
+ return {
+ billing,
+ usePoints,
+ setUsePoints,
+ payBusy,
+ errorText,
+ infoText,
+ txHash,
+ chainId,
+ tokenSymbol,
+ walletAddress,
+ connectWallet,
+ handlePay,
+ };
+}
diff --git a/frontend/lib/billing-utils.ts b/frontend/lib/billing-utils.ts
new file mode 100644
index 00000000..afc1e3a3
--- /dev/null
+++ b/frontend/lib/billing-utils.ts
@@ -0,0 +1,69 @@
+export interface TerminalBilling {
+ planAmount: number;
+ pointsEnabled: boolean;
+ pointsPerUsdc: number;
+ maxDiscountUsdc: number;
+ pointsUsed: number;
+ discountAmount: number;
+ payAmount: number;
+ canRedeem: boolean;
+}
+
+export interface PointsRedemptionConfig {
+ enabled?: boolean;
+ points_per_usdc?: number;
+ max_discount_usdc?: number;
+}
+
+export function computeBilling(params: {
+ planPriceUsd: number;
+ totalPoints: number;
+ usePoints: boolean;
+ redemptionCfg?: PointsRedemptionConfig | null;
+}): TerminalBilling {
+ const planAmount =
+ Number.isFinite(params.planPriceUsd) && params.planPriceUsd > 0
+ ? params.planPriceUsd
+ : 10;
+
+ const cfg = params.redemptionCfg || {};
+ const pointsEnabled = cfg.enabled !== false;
+ const pointsPerUsdcRaw = Number(cfg.points_per_usdc ?? 500);
+ const pointsPerUsdc =
+ Number.isFinite(pointsPerUsdcRaw) && pointsPerUsdcRaw > 0
+ ? Math.floor(pointsPerUsdcRaw)
+ : 500;
+
+ const maxDiscountRaw = Number(cfg.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(params.totalPoints, maxRedeemablePoints)
+ : 0;
+ const discountUnits = Math.floor(actualRedeem / pointsPerUsdc);
+ const pointsUsed = discountUnits * pointsPerUsdc;
+ const canRedeem =
+ pointsEnabled &&
+ maxDiscountUsdc > 0 &&
+ params.totalPoints >= pointsPerUsdc;
+ const applyDiscount =
+ params.usePoints && canRedeem && pointsUsed > 0;
+
+ return {
+ planAmount,
+ pointsEnabled,
+ pointsPerUsdc,
+ maxDiscountUsdc,
+ pointsUsed,
+ discountAmount: discountUnits,
+ payAmount: planAmount - (applyDiscount ? discountUnits : 0),
+ canRedeem,
+ };
+}