import axios, { AxiosResponse } from 'axios'; import { emitApiEvent } from '../utils/apiEvents'; import { getApiUrl } from '../api/client'; import { DashboardData, UsageStats, UsageTrends, SubscriptionPlan, APIPricing, UsageAlert, UsageLog, UsageLogsResponse, DashboardAPIResponse, UsageAPIResponse, PlansAPIResponse, PricingAPIResponse, AlertsAPIResponse, DashboardDataSchema, UsageStatsSchema, ProviderBreakdown, UsagePercentages, ProviderUsage, ProviderBreakdownSchema, SubscriptionRenewal, RenewalHistoryResponse, RenewalHistoryAPIResponse, } from '../types/billing'; // API base configuration - consistent with client.ts pattern const API_BASE_URL = getApiUrl(); const BILLING_BASE_URL = API_BASE_URL ? `${API_BASE_URL.replace(/\/+$/, '')}/api/subscription` : '/api/subscription'; // Create axios instance with default config const billingAPI = axios.create({ baseURL: BILLING_BASE_URL, timeout: 60000, // Increased to 60s to prevent timeouts in dev/slow networks headers: { 'Content-Type': 'application/json', }, }); // Optional token getter - will be set by App.tsx when Clerk is available let authTokenGetter: (() => Promise) | null = null; // Export function to set auth token getter (called from App.tsx) export const setBillingAuthTokenGetter = (getter: (() => Promise)) => { authTokenGetter = getter; }; // Request interceptor for authentication - uses Clerk token getter billingAPI.interceptors.request.use( async (config) => { // Use Clerk token getter if available (same pattern as apiClient) if (authTokenGetter) { try { const token = await authTokenGetter(); if (token) { config.headers = config.headers || {}; config.headers.Authorization = `Bearer ${token}`; } } catch (tokenError) { console.error('Error getting auth token for billing API:', tokenError); } } else { console.warn('Billing API: authTokenGetter not set - request may fail authentication'); } return config; }, (error) => { return Promise.reject(error); } ); // Response interceptor for error handling - similar to apiClient pattern billingAPI.interceptors.response.use( (response: AxiosResponse) => { return response; }, async (error) => { const originalRequest = error.config; // Handle network errors if (!error.response) { console.error('Billing API Network Error:', error.message); return Promise.reject(error); } // Handle 401 errors - try to refresh token if possible if (error?.response?.status === 401 && !originalRequest._retry && authTokenGetter) { originalRequest._retry = true; try { const newToken = await authTokenGetter(); if (newToken) { originalRequest.headers['Authorization'] = `Bearer ${newToken}`; return billingAPI(originalRequest); } } catch (retryError) { console.error('Billing API: Token refresh failed:', retryError); } // If retry failed, don't redirect here - let ProtectedRoute handle it // The 401 will propagate and ProtectedRoute will check authentication } // Handle rate limiting if (error.response?.status === 429) { console.warn('Billing API: Rate limited'); } console.error('Billing API Error:', error.response?.status, error.response?.data); return Promise.reject(error); } ); // ------------------------------------------------------------ // Response coercion helpers to ensure required fields exist // ------------------------------------------------------------ const defaultProviderUsage = { calls: 0, tokens: 0, cost: 0 }; const defaultProviderBreakdown = { gemini: { ...defaultProviderUsage }, huggingface: { ...defaultProviderUsage }, }; const defaultLimits = { plan_name: 'Unknown Plan', tier: 'free' as const, limits: { gemini_calls: 0, openai_calls: 0, anthropic_calls: 0, mistral_calls: 0, tavily_calls: 0, serper_calls: 0, metaphor_calls: 0, firecrawl_calls: 0, stability_calls: 0, gemini_tokens: 0, openai_tokens: 0, anthropic_tokens: 0, mistral_tokens: 0, monthly_cost: 0, }, features: [], }; // Helper to coerce alerts into fully-typed objects expected by Zod function coerceAlerts(rawAlerts: any[]): UsageAlert[] { if (!Array.isArray(rawAlerts)) return []; const nowIso = new Date().toISOString(); return rawAlerts.map((a: any, idx: number) => ({ id: typeof a?.id === 'number' ? a.id : idx, type: typeof a?.type === 'string' ? a.type : 'usage', threshold_percentage: typeof a?.threshold_percentage === 'number' ? a.threshold_percentage : 0, provider: typeof a?.provider === 'string' ? a.provider : undefined, title: typeof a?.title === 'string' ? a.title : 'Usage alert', message: typeof a?.message === 'string' ? a.message : '', severity: a?.severity === 'warning' || a?.severity === 'error' || a?.severity === 'info' ? a.severity : 'info', is_sent: typeof a?.is_sent === 'boolean' ? a.is_sent : true, sent_at: typeof a?.sent_at === 'string' ? a.sent_at : nowIso, is_read: typeof a?.is_read === 'boolean' ? a.is_read : false, read_at: typeof a?.read_at === 'string' ? a.read_at : undefined, billing_period: typeof a?.billing_period === 'string' ? a.billing_period : (a?.period || ''), created_at: typeof a?.created_at === 'string' ? a.created_at : nowIso, })); } function coerceUsageStats(raw: any): UsageStats { const providerBreakdown = raw?.provider_breakdown || {}; const defaultLimits = { plan_name: raw?.limits?.plan_name ?? 'free', tier: raw?.limits?.tier ?? 'free', limits: { gemini_calls: raw?.limits?.limits?.gemini_calls ?? 0, openai_calls: raw?.limits?.limits?.openai_calls ?? 0, anthropic_calls: raw?.limits?.limits?.anthropic_calls ?? 0, mistral_calls: raw?.limits?.limits?.mistral_calls ?? 0, tavily_calls: raw?.limits?.limits?.tavily_calls ?? 0, serper_calls: raw?.limits?.limits?.serper_calls ?? 0, metaphor_calls: raw?.limits?.limits?.metaphor_calls ?? 0, firecrawl_calls: raw?.limits?.limits?.firecrawl_calls ?? 0, stability_calls: raw?.limits?.limits?.stability_calls ?? 0, video_calls: raw?.limits?.limits?.video_calls ?? 0, image_edit_calls: raw?.limits?.limits?.image_edit_calls ?? 0, gemini_tokens: raw?.limits?.limits?.gemini_tokens ?? 0, openai_tokens: raw?.limits?.limits?.openai_tokens ?? 0, anthropic_tokens: raw?.limits?.limits?.anthropic_tokens ?? 0, mistral_tokens: raw?.limits?.limits?.mistral_tokens ?? 0, monthly_cost: raw?.limits?.limits?.monthly_cost ?? 0, }, features: raw?.limits?.features ?? [], }; // Extract provider breakdown - only include gemini and huggingface // Backend sends mistral data for HuggingFace, so we map it to huggingface // Explicitly extract and type the provider usage data const geminiData = providerBreakdown.gemini; const mistralData = providerBreakdown.mistral; // Backend sends 'mistral' for HuggingFace const huggingfaceData = providerBreakdown.huggingface; const wavespeedData = providerBreakdown.wavespeed; // Create properly typed ProviderUsage objects const geminiUsage: ProviderUsage = geminiData && typeof geminiData === 'object' && 'calls' in geminiData ? { calls: Number(geminiData.calls) || 0, tokens: Number(geminiData.tokens) || 0, cost: Number(geminiData.cost) || 0 } : { calls: 0, tokens: 0, cost: 0 }; // Map mistral data to huggingface (HuggingFace is stored as MISTRAL in DB) const huggingfaceUsage: ProviderUsage = (huggingfaceData && typeof huggingfaceData === 'object' && 'calls' in huggingfaceData) ? { calls: Number(huggingfaceData.calls) || 0, tokens: Number(huggingfaceData.tokens) || 0, cost: Number(huggingfaceData.cost) || 0 } : (mistralData && typeof mistralData === 'object' && 'calls' in mistralData) ? { calls: Number(mistralData.calls) || 0, tokens: Number(mistralData.tokens) || 0, cost: Number(mistralData.cost) || 0 } : { calls: 0, tokens: 0, cost: 0 }; const wavespeedUsage: ProviderUsage = wavespeedData && typeof wavespeedData === 'object' && 'calls' in wavespeedData ? { calls: Number(wavespeedData.calls) || 0, tokens: Number(wavespeedData.tokens) || 0, cost: Number(wavespeedData.cost) || 0 } : { calls: 0, tokens: 0, cost: 0 }; // Create ProviderBreakdown with only gemini and huggingface const providerBreakdownCoerced: ProviderBreakdown = { gemini: geminiUsage, huggingface: huggingfaceUsage, wavespeed: wavespeedUsage, }; // Extract usage percentages - only include gemini and huggingface // Backend sends mistral_calls for HuggingFace, map it to huggingface_calls const usagePercentagesCoerced: UsagePercentages = { gemini_calls: typeof raw?.usage_percentages?.gemini_calls === 'number' ? raw.usage_percentages.gemini_calls : 0, huggingface_calls: typeof raw?.usage_percentages?.mistral_calls === 'number' ? raw.usage_percentages.mistral_calls : (typeof raw?.usage_percentages?.huggingface_calls === 'number' ? raw.usage_percentages.huggingface_calls : 0), cost: typeof raw?.usage_percentages?.cost === 'number' ? raw.usage_percentages.cost : 0, }; // Calculate total_cost from provider breakdown // Always calculate from provider breakdown to ensure accuracy, but prefer backend total if it's more accurate const backendTotalCost = typeof raw?.total_cost === 'number' ? raw.total_cost : 0; const calculatedTotalCost = geminiUsage.cost + huggingfaceUsage.cost + wavespeedUsage.cost; // Use the maximum of backend cost and calculated cost to ensure we show the actual cost // If backend cost is 0 but we have provider costs, use calculated cost // If both are 0, the cost is genuinely 0 (no API calls with costs yet) const totalCost = Math.max(backendTotalCost, calculatedTotalCost); // Debug logging for cost calculation if (calculatedTotalCost > 0 || backendTotalCost > 0) { console.log('💰 [BILLING DEBUG] Cost calculation in coerceUsageStats:', { backendTotalCost, calculatedTotalCost, finalTotalCost: totalCost, geminiCost: geminiUsage.cost, huggingfaceCost: huggingfaceUsage.cost, wavespeedCost: wavespeedUsage.cost, geminiCalls: geminiUsage.calls, huggingfaceCalls: huggingfaceUsage.calls, wavespeedCalls: wavespeedUsage.calls, }); } // Calculate total_calls and total_tokens from provider breakdown if needed const backendTotalCalls = typeof raw?.total_calls === 'number' ? raw.total_calls : 0; const calculatedTotalCalls = geminiUsage.calls + huggingfaceUsage.calls + wavespeedUsage.calls; const totalCalls = backendTotalCalls > 0 ? backendTotalCalls : calculatedTotalCalls; const backendTotalTokens = typeof raw?.total_tokens === 'number' ? raw.total_tokens : 0; const calculatedTotalTokens = geminiUsage.tokens + huggingfaceUsage.tokens + wavespeedUsage.tokens; const totalTokens = backendTotalTokens > 0 ? backendTotalTokens : calculatedTotalTokens; const coerced: UsageStats = { billing_period: raw?.billing_period ?? new Date().toISOString().slice(0,7), usage_status: raw?.usage_status ?? 'active', total_calls: totalCalls, total_tokens: totalTokens, total_cost: totalCost, avg_response_time: raw?.avg_response_time ?? 0, error_rate: raw?.error_rate ?? 0, limits: defaultLimits, provider_breakdown: providerBreakdownCoerced, alerts: coerceAlerts(raw?.alerts), usage_percentages: usagePercentagesCoerced, last_updated: raw?.last_updated ?? new Date().toISOString(), }; return coerced; } // Core billing service functions export const billingService = { /** * Get comprehensive dashboard data for a user */ getDashboardData: async (userId: string): Promise => { try { const actualUserId = userId; // Debug logs removed to reduce console noise const response = await billingAPI.get(`/dashboard/${actualUserId}`); // Debug logs removed to reduce console noise if (!response.data.success) { console.error('❌ [BILLING DEBUG] API response not successful:', response.data); throw new Error(response.data.error || 'Failed to fetch dashboard data'); } // Coerce missing fields to satisfy the contract before validation const raw = response.data.data as any; // Coerce usage stats first to ensure proper typing const currentUsage = coerceUsageStats(raw?.current_usage ?? raw); const coerced: DashboardData = { current_usage: currentUsage, trends: raw?.trends ?? { periods: [], total_calls: [], total_cost: [], total_tokens: [], provider_trends: {}, }, limits: raw?.limits ?? defaultLimits, alerts: coerceAlerts(raw?.alerts), projections: raw?.projections ?? { projected_monthly_cost: 0, cost_limit: 0, projected_usage_percentage: 0, }, summary: raw?.summary ?? { total_api_calls_this_month: 0, total_cost_this_month: 0, usage_status: 'active', unread_alerts: 0, }, }; // Debug: Log cost calculation details console.log('💰 [BILLING DEBUG] Cost calculation:', { backendTotalCost: coerced.current_usage.total_cost, geminiCost: coerced.current_usage.provider_breakdown.gemini?.cost ?? 0, huggingfaceCost: coerced.current_usage.provider_breakdown.huggingface?.cost ?? 0, calculatedTotal: (coerced.current_usage.provider_breakdown.gemini?.cost ?? 0) + (coerced.current_usage.provider_breakdown.huggingface?.cost ?? 0), providerBreakdown: coerced.current_usage.provider_breakdown, }); // Validate response data after coercion // Note: If validation fails due to cached schema, we'll handle it gracefully try { const validatedData = DashboardDataSchema.parse(coerced); // Notify app that fresh billing data is available emitApiEvent({ url: `/dashboard/${actualUserId}`, method: 'GET', source: 'billing' }); return validatedData; } catch (validationError: any) { // Check if error is due to old schema expecting other providers const isOldSchemaError = validationError.errors?.some((err: any) => err.path?.includes('provider_breakdown') && err.path[err.path.length - 1] !== 'gemini' && err.path[err.path.length - 1] !== 'huggingface' ); if (isOldSchemaError) { console.error('❌ [BILLING DEBUG] Validation failed due to cached old schema. Browser cache needs to be cleared.'); console.error('❌ [BILLING DEBUG] Error details:', validationError.errors); // Still return the coerced data - it's correct, just schema validation is cached // The data structure is correct with only gemini and huggingface emitApiEvent({ url: `/dashboard/${actualUserId}`, method: 'GET', source: 'billing' }); return coerced; } // For other validation errors, throw them console.error('❌ [BILLING DEBUG] Validation error:', validationError); throw validationError; } } catch (error) { console.error('❌ [BILLING DEBUG] Error fetching dashboard data:', error); throw error; } }, /** * Get current usage statistics for a user */ getUsageStats: async (userId: string, period?: string): Promise => { try { const actualUserId = userId; const params = period ? { billing_period: period } : {}; const response = await billingAPI.get(`/usage/${actualUserId}`, { params }); if (!response.data.success) { throw new Error(response.data.error || 'Failed to fetch usage stats'); } // Coerce then validate const raw = response.data.data as any; const coerced = coerceUsageStats(raw); const validatedData = UsageStatsSchema.parse(coerced); emitApiEvent({ url: `/usage/${actualUserId}`, method: 'GET', source: 'billing' }); return validatedData; } catch (error) { console.error('Error fetching usage stats:', error); throw error; } }, /** * Get usage trends over time */ getUsageTrends: async (userId: string, months: number = 6): Promise => { try { const actualUserId = userId; const response = await billingAPI.get(`/usage/${actualUserId}/trends`, { params: { months } }); if (!response.data.success) { throw new Error(response.data.error || 'Failed to fetch usage trends'); } emitApiEvent({ url: `/usage/${actualUserId}/trends`, method: 'GET', source: 'billing' }); return response.data.data; } catch (error) { console.error('Error fetching usage trends:', error); throw error; } }, /** * Get all available subscription plans */ getSubscriptionPlans: async (): Promise => { try { const response = await billingAPI.get('/plans'); if (!response.data.success) { throw new Error(response.data.error || 'Failed to fetch subscription plans'); } return response.data.data.plans; } catch (error) { console.error('Error fetching subscription plans:', error); throw error; } }, /** * Get API pricing information */ getAPIPricing: async (provider?: string): Promise => { try { const params = provider ? { provider } : {}; const response = await billingAPI.get('/pricing', { params }); if (!response.data.success) { throw new Error(response.data.error || 'Failed to fetch API pricing'); } emitApiEvent({ url: '/pricing', method: 'GET', source: 'billing' }); return response.data.data.pricing; } catch (error) { console.error('Error fetching API pricing:', error); throw error; } }, /** * Get usage alerts for a user */ getUsageAlerts: async (userId: string, unreadOnly: boolean = false): Promise => { try { const actualUserId = userId; const response = await billingAPI.get(`/alerts/${actualUserId}`, { params: { unread_only: unreadOnly } }); if (!response.data.success) { throw new Error(response.data.error || 'Failed to fetch usage alerts'); } emitApiEvent({ url: `/alerts/${actualUserId}`, method: 'GET', source: 'billing' }); return response.data.data.alerts; } catch (error) { console.error('Error fetching usage alerts:', error); throw error; } }, /** * Mark an alert as read */ markAlertRead: async (alertId: number): Promise => { try { const response = await billingAPI.post(`/alerts/${alertId}/mark-read`); if (!response.data.success) { throw new Error(response.data.error || 'Failed to mark alert as read'); } } catch (error) { console.error('Error marking alert as read:', error); throw error; } }, /** * Get user's current subscription information */ getUserSubscription: async (userId: string) => { try { const actualUserId = userId; const response = await billingAPI.get(`/user/${actualUserId}/subscription`); if (!response.data.success) { throw new Error(response.data.error || 'Failed to fetch user subscription'); } return response.data.data; } catch (error) { console.error('Error fetching user subscription:', error); throw error; } }, /** * Get API usage logs for the current user */ getUsageLogs: async ( limit: number = 50, offset: number = 0, provider?: string, statusCode?: number, billingPeriod?: string ): Promise => { try { const params: any = { limit, offset }; if (provider) params.provider = provider; if (statusCode !== undefined) params.status_code = statusCode; if (billingPeriod) params.billing_period = billingPeriod; const response = await billingAPI.get('/usage-logs', { params }); return response.data; } catch (error: any) { console.error('Error fetching usage logs:', error); throw new Error( error.response?.data?.detail || error.message || 'Failed to fetch usage logs' ); } }, /** * Get subscription renewal history for the current user */ getRenewalHistory: async ( userId: string, limit: number = 50, offset: number = 0 ): Promise => { try { const actualUserId = userId; const params: any = { limit, offset }; const response = await billingAPI.get( `/renewal-history/${actualUserId}`, { params } ); if (!response.data.success) { throw new Error(response.data.error || 'Failed to fetch renewal history'); } emitApiEvent({ url: `/renewal-history/${actualUserId}`, method: 'GET', source: 'billing' }); return response.data.data; } catch (error: any) { console.error('Error fetching renewal history:', error); throw new Error( error.response?.data?.detail || error.message || 'Failed to fetch renewal history' ); } }, }; // Utility functions export const formatCurrency = (amount: number): string => { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 4, }).format(amount); }; // Pre-flight check interfaces export interface PreflightOperation { provider: string; model?: string; tokens_requested?: number; operation_type: string; actual_provider_name?: string; } export interface PreflightLimitInfo { current_usage: number; limit: number; remaining: number; } export interface PreflightOperationResult { provider: string; operation_type: string; cost: number; allowed: boolean; limit_info: PreflightLimitInfo | null; message: string | null; } export interface PreflightCheckResponse { can_proceed: boolean; estimated_cost: number; operations: PreflightOperationResult[]; total_cost: number; usage_summary: { current_calls: number; limit: number; remaining: number; } | null; cached: boolean; } /** * Check pre-flight validation for a single operation. * Returns cost estimation, limits check, and usage information. */ export const checkPreflight = async ( operation: PreflightOperation ): Promise => { try { const response = await billingAPI.post<{ success: boolean; data: PreflightCheckResponse }>( '/preflight-check', { operations: [operation] } ); if (!response.data.success) { throw new Error('Pre-flight check failed'); } return response.data.data; } catch (error: any) { console.error('[BillingService] Pre-flight check error:', error); // Return a safe default response on error return { can_proceed: false, estimated_cost: 0, operations: [{ provider: operation.provider, operation_type: operation.operation_type, cost: 0, allowed: false, limit_info: null, message: error?.response?.data?.detail || 'Pre-flight check failed' }], total_cost: 0, usage_summary: null, cached: false }; } }; /** * Check pre-flight validation for multiple operations in a single request. * Useful for pages with many buttons to reduce API calls. */ export const checkPreflightBatch = async ( operations: PreflightOperation[] ): Promise => { try { const response = await billingAPI.post<{ success: boolean; data: PreflightCheckResponse }>( '/preflight-check', { operations } ); if (!response.data.success) { throw new Error('Pre-flight check failed'); } return response.data.data; } catch (error: any) { console.error('[BillingService] Pre-flight batch check error:', error); // Return a safe default response on error return { can_proceed: false, estimated_cost: 0, operations: operations.map(op => ({ provider: op.provider, operation_type: op.operation_type, cost: 0, allowed: false, limit_info: null, message: error?.response?.data?.detail || 'Pre-flight check failed' })), total_cost: 0, usage_summary: null, cached: false }; } }; export const formatNumber = (num: number): string => { return new Intl.NumberFormat('en-US').format(num); }; export const formatPercentage = (value: number): string => { return `${value.toFixed(1)}%`; }; export const getUsageStatusColor = (status: string): string => { switch (status) { case 'active': return '#22c55e'; // Green case 'warning': return '#f59e0b'; // Orange case 'limit_reached': return '#ef4444'; // Red default: return '#6b7280'; // Gray } }; export const getUsageStatusIcon = (status: string): string => { switch (status) { case 'active': return '✅'; case 'warning': return '⚠️'; case 'limit_reached': return '🚨'; default: return '❓'; } }; export const calculateUsagePercentage = (current: number, limit: number): number => { if (limit === 0) return 0; return Math.min((current / limit) * 100, 100); }; export const getProviderIcon = (provider: string): string => { const icons: { [key: string]: string } = { gemini: '🤖', huggingface: '🤗', // HuggingFace icon }; return icons[provider.toLowerCase()] || '🔧'; }; export const getProviderColor = (provider: string): string => { const colors: { [key: string]: string } = { gemini: '#4285f4', huggingface: '#ffd21e', // HuggingFace yellow color }; return colors[provider.toLowerCase()] || '#6b7280'; }; export default billingService;