mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-19 02:22:11 +01:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user