Manage state of price-lookup calls (#5915)

Now calls stripe-price-lookup once when prices are required rather then
bombarding on every rerender
This commit is contained in:
ConnorYoh
2026-03-11 13:53:49 +00:00
committed by GitHub
parent 32cf6866f3
commit d5d03b9ada
7 changed files with 136 additions and 143 deletions

View File

@@ -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;
}

View File

@@ -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(() => {

View File

@@ -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();

View File

@@ -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',

View File

@@ -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<string, PlanPrice>;
plansLoading: boolean;
plansError: string | null;
plansLastFetchTime: number | null;
// Derived State
isManagedTeamMember: boolean;
@@ -63,6 +68,7 @@ const SaasBillingContext = createContext<SaasBillingContextType>({
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<string | null>(null);
const [loading, setLoading] = useState(false); // Start false (lazy load)
const [error, setError] = useState<string | null>(null);
const [lastFetchTime, setLastFetchTime] = useState<number | null>(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<number | null>(null);
const [lastFetchTimeValue, setLastFetchTimeValue] = useState<number | null>(null);
// billingStatusRef mirrors billingStatus so fetchBillingData can read the current value
// without needing billingStatus in its useCallback dep array.
const billingStatusRef = useRef<BillingStatus | null>(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<number | null>(null);
const [plansLastFetchTimeValue, setPlansLastFetchTimeValue] = useState<number | null>(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 (
<SaasBillingContext.Provider value={{
billingStatus,
tier: isManagedTeamMember ? 'team' : (billingStatus?.tier ?? 'free'),
subscription: billingStatus?.subscription ?? null,
usage: billingStatus?.meterUsage ?? null,
isTrialing: billingStatus?.isTrialing ?? false,
trialDaysRemaining: billingStatus?.trialDaysRemaining,
price: billingStatus?.price,
currency: billingStatus?.currency,
creditBalance: billingStatus?.creditBalance ?? 0,
plans,
plansLoading,
plansError,
isManagedTeamMember,
loading: loading || teamLoading,
error,
lastFetchTime,
refreshBilling,
refreshCredits: refreshBilling, // Alias for clarity
refreshPlans,
openBillingPortal,
}}>
<SaasBillingContext.Provider value={contextValue}>
{children}
</SaasBillingContext.Provider>
);
@@ -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 };

View File

@@ -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<string | null>(null);
const [dynamicPrices, setDynamicPrices] = useState<Map<string, { price: number; currency: string; overagePrice?: number }>>(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<PlanTier[]>(() => {
const teamPlan = dynamicPrices.get('team');
const computedPlans = useMemo<PlanTier[]>(() => {
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,
};
};

View File

@@ -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<string, { unit_amount: number; currency: string }>;
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,
};