diff --git a/frontend/src/desktop/components/quickAccessBar/QuickAccessBarFooterExtensions.tsx b/frontend/src/desktop/components/quickAccessBar/QuickAccessBarFooterExtensions.tsx index 7c10b97fb..f0f776751 100644 --- a/frontend/src/desktop/components/quickAccessBar/QuickAccessBarFooterExtensions.tsx +++ b/frontend/src/desktop/components/quickAccessBar/QuickAccessBarFooterExtensions.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { Box, Text, Stack } from '@mantine/core'; import { useSaaSBilling } from '@app/contexts/SaasBillingContext'; +import { BILLING_CONFIG } from '@app/config/billing'; import { connectionModeService } from '@app/services/connectionModeService'; import { authService } from '@app/services/authService'; import { CREDIT_EVENTS } from '@app/constants/creditEvents'; @@ -49,7 +50,7 @@ export function QuickAccessBarFooterExtensions({ className }: QuickAccessBarFoot // - Still loading billing data // - User is a managed team member (unlimited credits) // - Credits >= 20 (only show when low) - if (!isSaasMode || !isAuthenticated || loading || isManagedTeamMember || creditBalance >= 20) { + if (!isSaasMode || !isAuthenticated || loading || isManagedTeamMember || creditBalance >= BILLING_CONFIG.PLAN_PRICING_PRELOAD_THRESHOLD) { return null; } diff --git a/frontend/src/desktop/components/shared/config/configSections/SaasPlanSection.tsx b/frontend/src/desktop/components/shared/config/configSections/SaasPlanSection.tsx index 778f030d3..9a0ae3335 100644 --- a/frontend/src/desktop/components/shared/config/configSections/SaasPlanSection.tsx +++ b/frontend/src/desktop/components/shared/config/configSections/SaasPlanSection.tsx @@ -42,7 +42,7 @@ export function SaasPlanSection() { const { currentTeam, isTeamLeader, isPersonalTeam } = useSaaSTeam(); // Plans data - const { plans, loading: plansLoading, error: plansError } = useSaaSPlans('usd'); + const { plans, loading: plansLoading, error: plansError } = useSaaSPlans(); // Check connection mode on mount useEffect(() => { diff --git a/frontend/src/desktop/components/shared/modals/CreditModalBootstrap.tsx b/frontend/src/desktop/components/shared/modals/CreditModalBootstrap.tsx index 994a2b916..0c2f3a7f6 100644 --- a/frontend/src/desktop/components/shared/modals/CreditModalBootstrap.tsx +++ b/frontend/src/desktop/components/shared/modals/CreditModalBootstrap.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { useSaaSBilling } from '@app/contexts/SaasBillingContext'; +import { BILLING_CONFIG } from '@app/config/billing'; import { CreditExhaustedModal } from '@app/components/shared/modals/CreditExhaustedModal'; import { InsufficientCreditsModal } from '@app/components/shared/modals/InsufficientCreditsModal'; import { useCreditEvents } from '@app/hooks/useCreditEvents'; @@ -18,7 +19,16 @@ export function CreditModalBootstrap() { requiredCredits?: number; }>({}); - const { creditBalance, isManagedTeamMember } = useSaaSBilling(); + const { creditBalance, isManagedTeamMember, lastFetchTime, plansLastFetchTime, refreshPlans } = useSaaSBilling(); + + // Preload plan pricing when billing confirms credits are low. + // Fires once: only when billing has loaded (lastFetchTime set) and plans haven't been + // fetched yet (plansLastFetchTime null). This way the modal shows real prices instantly. + useEffect(() => { + if (lastFetchTime !== null && plansLastFetchTime === null && creditBalance < BILLING_CONFIG.PLAN_PRICING_PRELOAD_THRESHOLD && !isManagedTeamMember) { + refreshPlans(); + } + }, [lastFetchTime, plansLastFetchTime, creditBalance, isManagedTeamMember, refreshPlans]); // Monitor credit balance and dispatch events useCreditEvents(); diff --git a/frontend/src/desktop/config/billing.ts b/frontend/src/desktop/config/billing.ts index f6768a11a..050db913f 100644 --- a/frontend/src/desktop/config/billing.ts +++ b/frontend/src/desktop/config/billing.ts @@ -13,9 +13,13 @@ export const BILLING_CONFIG = { // Overage pricing (per credit) - also fetched dynamically from Stripe OVERAGE_PRICE_PER_CREDIT: 0.05, - // Credit warning threshold + // Credit warning threshold (shows low-balance indicator) LOW_CREDIT_THRESHOLD: 10, + // Threshold at which plan pricing is preloaded (higher than LOW_CREDIT_THRESHOLD + // so prices are ready before the warning UI appears) + PLAN_PRICING_PRELOAD_THRESHOLD: 20, + // Stripe lookup keys PRO_PLAN_LOOKUP_KEY: 'plan:pro', METER_LOOKUP_KEY: 'meter:overage', diff --git a/frontend/src/desktop/contexts/SaasBillingContext.tsx b/frontend/src/desktop/contexts/SaasBillingContext.tsx index 890ff2040..50a15e272 100644 --- a/frontend/src/desktop/contexts/SaasBillingContext.tsx +++ b/frontend/src/desktop/contexts/SaasBillingContext.tsx @@ -1,10 +1,14 @@ -import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react'; +import { createContext, useContext, useEffect, useState, ReactNode, useCallback, useRef, useMemo } from 'react'; import { saasBillingService, BillingStatus, PlanPrice } from '@app/services/saasBillingService'; import { authService } from '@app/services/authService'; import { connectionModeService } from '@app/services/connectionModeService'; import { useSaaSTeam } from '@app/contexts/SaaSTeamContext'; import type { TierLevel } from '@app/types/billing'; +// How long plan pricing is considered fresh. Lives at module level so both the +// provider and usePlanPricing (the consumer hook) can reference it. +const PLANS_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour + /** * Desktop implementation of SaaS Billing Context * Provides billing and plan management for users connected to SaaS backend @@ -34,6 +38,7 @@ interface SaasBillingContextType { plans: Map; plansLoading: boolean; plansError: string | null; + plansLastFetchTime: number | null; // Derived State isManagedTeamMember: boolean; @@ -63,6 +68,7 @@ const SaasBillingContext = createContext({ plans: new Map(), plansLoading: false, plansError: null, + plansLastFetchTime: null, isManagedTeamMember: false, loading: false, error: null, @@ -80,7 +86,19 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) { const [plansError, setPlansError] = useState(null); const [loading, setLoading] = useState(false); // Start false (lazy load) const [error, setError] = useState(null); - const [lastFetchTime, setLastFetchTime] = useState(null); + // lastFetchTimeRef is the source of truth for cache logic (always current, no stale closure). + // lastFetchTimeValue mirrors it as state purely to drive re-renders and expose through context. + const lastFetchTimeRef = useRef(null); + const [lastFetchTimeValue, setLastFetchTimeValue] = useState(null); + // billingStatusRef mirrors billingStatus so fetchBillingData can read the current value + // without needing billingStatus in its useCallback dep array. + const billingStatusRef = useRef(null); + // plansLastFetchTimeRef is the source of truth for timing; plansLastFetchTimeValue + // is the state mirror exposed via context so consumers can react to it. + const plansLastFetchTimeRef = useRef(null); + const [plansLastFetchTimeValue, setPlansLastFetchTimeValue] = useState(null); + // In-flight deduplication — prevents concurrent duplicate network requests. + const plansFetchInProgressRef = useRef(false); const [isSaasMode, setIsSaasMode] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false); @@ -133,19 +151,21 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) { // Cache check: Skip if fresh data exists (<5 min old) const now = Date.now(); - if (billingStatus && lastFetchTime && (now - lastFetchTime) < 300000) { + if (billingStatusRef.current && lastFetchTimeRef.current && (now - lastFetchTimeRef.current) < 300000) { return; } // Only set loading if no existing data (prevents flicker on refresh) - if (!billingStatus) { + if (!billingStatusRef.current) { setLoading(true); } try { const status = await saasBillingService.getBillingStatus(); - setBillingStatus(status); // Atomic update - setLastFetchTime(now); + billingStatusRef.current = status; + setBillingStatus(status); + lastFetchTimeRef.current = now; + setLastFetchTimeValue(now); setError(null); } catch (err) { console.error('[SaasBillingContext] Failed to fetch billing:', err); @@ -154,26 +174,34 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) { } finally { setLoading(false); } - }, [isSaasMode, isAuthenticated, isManagedTeamMember, billingStatus, lastFetchTime, teamLoading]); + }, [isSaasMode, isAuthenticated, isManagedTeamMember, teamLoading]); - // Fetch available plans + // Raw plan fetch — auth guard + in-flight deduplication only. + // TTL cache logic lives in usePlanPricing so the consumer hook controls staleness policy. const fetchPlansData = useCallback(async () => { - // Guard: Skip if not in SaaS mode or not authenticated if (!isSaasMode || !isAuthenticated) { return; } + if (plansFetchInProgressRef.current) { + return; + } + + plansFetchInProgressRef.current = true; setPlansLoading(true); setPlansError(null); try { const priceMap = await saasBillingService.getAvailablePlans('usd'); setPlans(priceMap); + const now = Date.now(); + plansLastFetchTimeRef.current = now; + setPlansLastFetchTimeValue(now); } catch (err) { console.error('[SaasBillingContext] Failed to fetch plans:', err); setPlansError(err instanceof Error ? err.message : 'Failed to fetch plans'); - // Non-blocking: continue with empty plans } finally { + plansFetchInProgressRef.current = false; setPlansLoading(false); } }, [isSaasMode, isAuthenticated]); @@ -182,9 +210,14 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) { useEffect(() => { if (!isSaasMode || !isAuthenticated) { // Clear state when not in SaaS mode or not authenticated + billingStatusRef.current = null; setBillingStatus(null); setPlans(new Map()); - setLastFetchTime(null); + lastFetchTimeRef.current = null; + setLastFetchTimeValue(null); + plansLastFetchTimeRef.current = null; + setPlansLastFetchTimeValue(null); + plansFetchInProgressRef.current = false; setLoading(false); setError(null); setPlansError(null); @@ -199,12 +232,12 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) { isAuthenticated && !teamLoading && !isManagedTeamMember && - billingStatus === null && - lastFetchTime === null + billingStatusRef.current === null && + lastFetchTimeRef.current === null ) { fetchBillingData(); } - }, [isSaasMode, isAuthenticated, teamLoading, isManagedTeamMember, billingStatus, lastFetchTime, fetchBillingData]); + }, [isSaasMode, isAuthenticated, teamLoading, isManagedTeamMember, fetchBillingData]); // Public refresh methods const refreshBilling = useCallback(async () => { @@ -212,11 +245,12 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) { return; } - // Force cache invalidation - setLastFetchTime(null); + // Force cache invalidation — write to ref synchronously so fetchBillingData + // reads null immediately (not a stale closure value from the previous render). + lastFetchTimeRef.current = null; + setLastFetchTimeValue(null); await fetchBillingData(); - await fetchPlansData(); - }, [isSaasMode, isAuthenticated, fetchBillingData, fetchPlansData]); + }, [isSaasMode, isAuthenticated, fetchBillingData]); const refreshPlans = useCallback(async () => { await fetchPlansData(); @@ -240,10 +274,9 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) { if (typeof newBalance === 'number' && billingStatus) { // Update credit balance in billing status without full refresh - setBillingStatus({ - ...billingStatus, - creditBalance: newBalance, - }); + const updated = { ...billingStatus, creditBalance: newBalance }; + billingStatusRef.current = updated; + setBillingStatus(updated); // Dispatch exhausted event if credits hit 0 if (newBalance <= 0 && (billingStatus.creditBalance ?? 0) > 0) { @@ -260,29 +293,36 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) { }; }, [billingStatus]); + const contextValue = useMemo(() => ({ + billingStatus, + tier: isManagedTeamMember ? 'team' : (billingStatus?.tier ?? 'free'), + subscription: billingStatus?.subscription ?? null, + usage: billingStatus?.meterUsage ?? null, + isTrialing: billingStatus?.isTrialing ?? false, + trialDaysRemaining: billingStatus?.trialDaysRemaining, + price: plans.get('team')?.price, + currency: plans.get('team')?.currency, + creditBalance: billingStatus?.creditBalance ?? 0, + plans, + plansLoading, + plansError, + plansLastFetchTime: plansLastFetchTimeValue, + isManagedTeamMember, + loading: loading || teamLoading, + error, + lastFetchTime: lastFetchTimeValue, + refreshBilling, + refreshCredits: refreshBilling, + refreshPlans, + openBillingPortal, + }), [ + billingStatus, isManagedTeamMember, plans, plansLoading, plansError, + plansLastFetchTimeValue, loading, teamLoading, error, lastFetchTimeValue, + refreshBilling, refreshPlans, openBillingPortal, + ]); + return ( - + {children} ); @@ -311,5 +351,26 @@ export function useSaaSBilling() { return context; } +/** + * Hook for components that display plan pricing data. + * Fetches on first use; re-fetches only after PLANS_CACHE_TTL_MS (1 hour). + * Safe to call from multiple components — in-flight deduplication is handled by fetchPlansData. + */ +export function usePlanPricing() { + const { plans, plansLoading, plansError, plansLastFetchTime, refreshPlans } = useContext(SaasBillingContext); + + useEffect(() => { + const isFresh = + plansLastFetchTime !== null && + Date.now() - plansLastFetchTime < PLANS_CACHE_TTL_MS; + + if (!isFresh) { + refreshPlans(); + } + }, [plansLastFetchTime, refreshPlans]); + + return { plans, plansLoading, plansError }; +} + export { SaasBillingContext }; export type { SaasBillingContextType }; diff --git a/frontend/src/desktop/hooks/useSaaSPlans.ts b/frontend/src/desktop/hooks/useSaaSPlans.ts index 17ac2fc07..718b7d072 100644 --- a/frontend/src/desktop/hooks/useSaaSPlans.ts +++ b/frontend/src/desktop/hooks/useSaaSPlans.ts @@ -1,6 +1,6 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { saasBillingService } from '@app/services/saasBillingService'; +import { useSaaSBilling, usePlanPricing } from '@app/contexts/SaasBillingContext'; import { FREE_PLAN_FEATURES, TEAM_PLAN_FEATURES, ENTERPRISE_PLAN_FEATURES } from '@app/config/planFeatures'; import type { TierLevel } from '@app/types/billing'; @@ -22,34 +22,13 @@ export interface PlanTier { overagePrice?: number; } -export const useSaaSPlans = (currency: string = 'usd') => { +export const useSaaSPlans = () => { const { t } = useTranslation(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [dynamicPrices, setDynamicPrices] = useState>(new Map()); + const { refreshPlans } = useSaaSBilling(); + const { plans, plansLoading, plansError } = usePlanPricing(); - const fetchPlans = async () => { - try { - setLoading(true); - setError(null); - - const priceMap = await saasBillingService.getAvailablePlans(currency); - setDynamicPrices(priceMap); - } catch (err) { - console.error('Error fetching plans:', err); - setError(err instanceof Error ? err.message : 'Failed to fetch plans'); - // Continue with default prices if fetch fails - } finally { - setLoading(false); - } - }; - - useEffect(() => { - fetchPlans(); - }, [currency]); - - const plans = useMemo(() => { - const teamPlan = dynamicPrices.get('team'); + const computedPlans = useMemo(() => { + const teamPlan = plans.get('team'); return [ { @@ -104,12 +83,12 @@ export const useSaaSPlans = (currency: string = 'usd') => { ] } ]; - }, [t, dynamicPrices]); + }, [t, plans]); return { - plans, - loading, - error, - refetch: fetchPlans, + plans: computedPlans, + loading: plansLoading, + error: plansError, + refetch: refreshPlans, }; }; diff --git a/frontend/src/desktop/services/saasBillingService.ts b/frontend/src/desktop/services/saasBillingService.ts index 2c8b4622f..a607a620f 100644 --- a/frontend/src/desktop/services/saasBillingService.ts +++ b/frontend/src/desktop/services/saasBillingService.ts @@ -24,8 +24,6 @@ export interface BillingStatus { tier: TierLevel; isTrialing: boolean; trialDaysRemaining?: number; - price?: number; // Monthly price (in dollars) - currency?: string; // Currency symbol (e.g., '$', '£') creditBalance?: number; // Real-time remaining credits } @@ -73,51 +71,6 @@ export class SaasBillingService { } } - /** - * Fetch Pro plan price from Stripe - * Calls stripe-price-lookup edge function to get current pricing - */ - private async fetchPlanPrice( - token: string, - currencyCode: string = 'usd' - ): Promise<{ price: number; currency: string }> { - try { - const { data, error } = await supabase.functions.invoke<{ - prices: Record; - missing: string[]; - }>('stripe-price-lookup', { - headers: { - Authorization: `Bearer ${token}`, - }, - body: { - lookup_keys: ['plan:pro'], - currency: currencyCode, - }, - }); - - if (error) { - throw new Error(error.message || 'Failed to fetch plan price'); - } - - if (!data || !data.prices) { - throw new Error('No pricing data returned'); - } - - const proPrice = data.prices['plan:pro']; - if (proPrice) { - const price = proPrice.unit_amount / 100; // Convert cents to dollars - const currency = getCurrencySymbol(proPrice.currency); - return { price, currency }; - } - - // Fallback if price not found - throw new Error('Pro plan price not found'); - } catch (error) { - console.error('[Desktop Billing] Error fetching plan price:', error); - throw error; - } - } - /** * Fetch billing status from Supabase edge function * Calls get-usage-billing which returns subscription + meter usage data @@ -173,8 +126,6 @@ export class SaasBillingService { let meterUsage: BillingStatus['meterUsage'] = null; let isTrialing = false; let trialDaysRemaining: number | undefined; - let price: number | undefined; - let currency: string | undefined; if (isPro) { // Fetch usage details @@ -204,17 +155,6 @@ export class SaasBillingService { console.warn('[Desktop Billing] Failed to fetch usage data:', usageError); } - // Fetch the current Pro plan price from Stripe - try { - const priceData = await this.fetchPlanPrice(token, 'usd'); - price = priceData.price; - currency = priceData.currency; - } catch (error) { - console.warn('[Desktop Billing] Failed to fetch plan price, using default:', error); - // Fallback to default pricing - price = 10; - currency = '$'; - } } // Fetch credit balance for all authenticated users (both Pro and Free) @@ -250,8 +190,6 @@ export class SaasBillingService { tier, isTrialing, trialDaysRemaining, - price, - currency, creditBalance, };