From a3f25f23c90a31259427513538fa2b4c5a7261eb Mon Sep 17 00:00:00 2001 From: ajaysi Date: Thu, 23 Oct 2025 21:47:52 +0530 Subject: [PATCH] Subscription implementation complete, Renewal system implemented --- backend/alwrity_utils/rate_limiter.py | 9 +- .../onboarding_completion_service.py | 34 ++- .../onboarding_utils/step4_persona_routes.py | 10 +- backend/api/subscription_api.py | 7 + backend/middleware/monitoring_middleware.py | 36 ++- .../seo/blog_seo_metadata_generator.py | 10 +- .../services/llm_providers/gemini_provider.py | 22 +- backend/services/usage_tracking_service.py | 25 +- frontend/public/index.html | 2 +- frontend/src/App.tsx | 2 +- frontend/src/api/client.ts | 55 ++++ .../OnboardingWizard/FinalStep/FinalStep.tsx | 268 ++++++++++++++++-- .../FinalStep/components/SetupSummary.tsx | 26 +- .../OnboardingWizard/FinalStep/types.ts | 1 + .../components/OnboardingWizard/Wizard.tsx | 29 +- .../common/WizardNavigation.tsx | 68 ++--- .../components/SubscriptionExpiredModal.tsx | 221 +++++++++++++++ .../src/components/shared/ProtectedRoute.tsx | 33 ++- frontend/src/contexts/SubscriptionContext.tsx | 171 ++++++++++- .../src/hooks/useSubscriptionErrorHandler.ts | 39 +++ frontend/src/services/billingService.ts | 98 +++++-- 21 files changed, 1016 insertions(+), 150 deletions(-) create mode 100644 frontend/src/components/SubscriptionExpiredModal.tsx create mode 100644 frontend/src/hooks/useSubscriptionErrorHandler.ts diff --git a/backend/alwrity_utils/rate_limiter.py b/backend/alwrity_utils/rate_limiter.py index 7082991e..4fcc92ee 100644 --- a/backend/alwrity_utils/rate_limiter.py +++ b/backend/alwrity_utils/rate_limiter.py @@ -30,12 +30,16 @@ class RateLimiter: "/calendar-events", "/calendar-generation/progress", "/health", - "/health/database" + "/health/database", ] + # Prefixes to exempt entire route families (keep empty; rely on specific exemptions only) + self.exempt_prefixes = [] def is_exempt_path(self, path: str) -> bool: """Check if a path is exempt from rate limiting.""" - return any(exempt_path in path for exempt_path in self.exempt_paths) + return any(exempt_path == path or exempt_path in path for exempt_path in self.exempt_paths) or any( + path.startswith(prefix) for prefix in self.exempt_prefixes + ) def clean_old_requests(self, client_ip: str, current_time: float) -> None: """Clean old requests from the tracking dictionary.""" @@ -77,7 +81,6 @@ class RateLimiter: # Check if path is exempt from rate limiting if self.is_exempt_path(path): - # Allow streaming endpoints without rate limiting response = await call_next(request) return response diff --git a/backend/api/onboarding_utils/onboarding_completion_service.py b/backend/api/onboarding_utils/onboarding_completion_service.py index 52c577e9..4f1ee427 100644 --- a/backend/api/onboarding_utils/onboarding_completion_service.py +++ b/backend/api/onboarding_utils/onboarding_completion_service.py @@ -16,8 +16,8 @@ class OnboardingCompletionService: """Service for handling onboarding completion logic.""" def __init__(self): - # Only pre-requisite steps; step 6 is the finalization itself - self.required_steps = [1, 2, 3] + # Pre-requisite steps; step 6 is the finalization itself + self.required_steps = [1, 2, 3, 4, 5] async def complete_onboarding(self, current_user: Dict[str, Any]) -> Dict[str, Any]: """Complete the onboarding process with full validation.""" @@ -73,9 +73,15 @@ class OnboardingCompletionService: db = None db_service = None + logger.info(f"OnboardingCompletionService: Validating steps for user {user_id}") + logger.info(f"OnboardingCompletionService: Current step: {progress.current_step}") + logger.info(f"OnboardingCompletionService: Required steps: {self.required_steps}") + for step_num in self.required_steps: step = progress.get_step_data(step_num) + logger.info(f"OnboardingCompletionService: Step {step_num} - status: {step.status if step else 'None'}") if step and step.status in [StepStatus.COMPLETED, StepStatus.SKIPPED]: + logger.info(f"OnboardingCompletionService: Step {step_num} already completed/skipped") continue # DB-aware fallbacks for migration period @@ -129,6 +135,30 @@ class OnboardingCompletionService: except Exception: pass continue + if step_num == 4: + # Treat as completed if persona data exists in DB + persona = None + try: + persona = db_service.get_persona_data(user_id, db) + except Exception: + persona = None + if persona and persona.get('corePersona'): + try: + progress.mark_step_completed(4, {'source': 'db-fallback'}) + except Exception: + pass + continue + if step_num == 5: + # Treat as completed if integrations data exists in DB + # For now, we'll consider step 5 completed if the user has reached the final step + # This is a simplified approach - in the future, we could check for specific integration data + try: + # Check if user has completed previous steps and is on final step + if progress.current_step >= 6: # FinalStep is step 6 + progress.mark_step_completed(5, {'source': 'final-step-fallback'}) + continue + except Exception: + pass except Exception: # If DB check fails, fall back to progress status only pass diff --git a/backend/api/onboarding_utils/step4_persona_routes.py b/backend/api/onboarding_utils/step4_persona_routes.py index dec2871d..3e5c6a72 100644 --- a/backend/api/onboarding_utils/step4_persona_routes.py +++ b/backend/api/onboarding_utils/step4_persona_routes.py @@ -134,11 +134,11 @@ async def generate_writing_personas_async( "request_data": (PersonaGenerationRequest(**(request if isinstance(request, dict) else request.dict())).dict()) if request else {} } logger.info(f"Cache hit for user {user_id} - returning completed task without regeneration: {task_id}") - return { - "task_id": task_id, - "status": "completed", - "message": "Persona loaded from cache" - } + return { + "task_id": task_id, + "status": "completed", + "message": "Persona loaded from cache" + } # Generate unique task ID task_id = str(uuid.uuid4()) diff --git a/backend/api/subscription_api.py b/backend/api/subscription_api.py index ac6a88c2..b296d5d9 100644 --- a/backend/api/subscription_api.py +++ b/backend/api/subscription_api.py @@ -380,6 +380,13 @@ async def subscribe_to_plan( db.commit() + # Reset usage status for current billing period so new plan takes effect immediately + try: + usage_service = UsageTrackingService(db) + await usage_service.reset_current_billing_period(user_id) + except Exception as reset_err: + logger.error(f"Failed to reset usage after subscribe: {reset_err}") + return { "success": True, "message": f"Successfully subscribed to {plan.name}", diff --git a/backend/middleware/monitoring_middleware.py b/backend/middleware/monitoring_middleware.py index 717ad36c..e84851c9 100644 --- a/backend/middleware/monitoring_middleware.py +++ b/backend/middleware/monitoring_middleware.py @@ -35,25 +35,25 @@ class DatabaseAPIMonitor: # API provider detection patterns - Updated to match actual endpoints self.provider_patterns = { APIProvider.GEMINI: [ - r'/api/blog-writer', r'/api/content-planning', r'/api/strategy-copilot', - r'/api/brainstorm', r'/api/writing-assistant', r'/api/seo-dashboard', - r'/api/onboarding', r'/api/user-data', r'/api/component-logic', - r'gemini', r'google.*ai', r'blog.*writer', r'content.*planning' + r'gemini', r'google.*ai' ], - APIProvider.OPENAI: [r'/openai', r'openai', r'gpt', r'chatgpt'], - APIProvider.ANTHROPIC: [r'/anthropic', r'claude', r'anthropic'], - APIProvider.MISTRAL: [r'/mistral', r'mistral'], - APIProvider.TAVILY: [r'/tavily', r'tavily', r'research', r'search'], - APIProvider.SERPER: [r'/serper', r'serper', r'google.*search', r'seo'], - APIProvider.METAPHOR: [r'/metaphor', r'/exa', r'metaphor', r'exa'], - APIProvider.FIRECRAWL: [r'/firecrawl', r'firecrawl', r'crawl'], - APIProvider.STABILITY: [r'/stability', r'stable.*diffusion', r'stability', r'image.*generation'] + APIProvider.OPENAI: [r'openai', r'gpt', r'chatgpt'], + APIProvider.ANTHROPIC: [r'anthropic', r'claude'], + APIProvider.MISTRAL: [r'mistral'], + APIProvider.TAVILY: [r'tavily'], + APIProvider.SERPER: [r'serper'], + APIProvider.METAPHOR: [r'metaphor', r'/exa'], + APIProvider.FIRECRAWL: [r'firecrawl'] } def detect_api_provider(self, path: str, user_agent: str = None) -> Optional[APIProvider]: """Detect which API provider is being used based on request details.""" path_lower = path.lower() user_agent_lower = (user_agent or '').lower() + + # Permanently ignore internal route families that must not accrue or check provider usage + if path_lower.startswith('/api/onboarding/') or path_lower.startswith('/api/subscription/'): + return None for provider, patterns in self.provider_patterns.items(): for pattern in patterns: @@ -384,16 +384,26 @@ EXCLUDED_ENDPOINTS = [ "/api/content-planning/monitoring/cache-stats", "/api/content-planning/monitoring/health" ] +# Also exclude whole route families by prefix (e.g., subscription/billing must never be blocked) +EXCLUDED_PREFIXES = [ +] + def should_monitor_endpoint(path: str) -> bool: """Check if an endpoint should be monitored.""" - return not any(path.endswith(excluded) for excluded in EXCLUDED_ENDPOINTS) + return not any(path.endswith(excluded) for excluded in EXCLUDED_ENDPOINTS) and not any(path.startswith(prefix) for prefix in EXCLUDED_PREFIXES) async def check_usage_limits_middleware(request: Request, user_id: str, request_body: str = None) -> Optional[JSONResponse]: """Check usage limits before processing request.""" if not user_id: return None + # No special whitelist; onboarding/subscription are ignored by provider detection + try: + path = request.url.path + except Exception: + pass + try: db = next(get_db()) api_monitor = DatabaseAPIMonitor() diff --git a/backend/services/blog_writer/seo/blog_seo_metadata_generator.py b/backend/services/blog_writer/seo/blog_seo_metadata_generator.py index c180a1aa..cbbc2a06 100644 --- a/backend/services/blog_writer/seo/blog_seo_metadata_generator.py +++ b/backend/services/blog_writer/seo/blog_seo_metadata_generator.py @@ -157,8 +157,8 @@ class BlogSEOMetadataGenerator: # Get structured response from Gemini ai_response = self.gemini_provider( - prompt=prompt, - schema=schema, + prompt, + schema, temperature=0.3, max_tokens=2048 ) @@ -167,6 +167,8 @@ class BlogSEOMetadataGenerator: if not ai_response or not isinstance(ai_response, dict): logger.error("Core metadata generation failed: Invalid response from Gemini") # Return fallback response + primary_keywords = ', '.join(keywords_data.get('primary_keywords', ['content'])) + word_count = len(blog_content.split()) return { 'seo_title': blog_title, 'meta_description': f'Learn about {primary_keywords.split(", ")[0] if primary_keywords else "this topic"}.', @@ -246,8 +248,8 @@ class BlogSEOMetadataGenerator: # Get structured response from Gemini ai_response = self.gemini_provider( - prompt=prompt, - schema=schema, + prompt, + schema, temperature=0.3, max_tokens=2048 ) diff --git a/backend/services/llm_providers/gemini_provider.py b/backend/services/llm_providers/gemini_provider.py index b20cb695..16bcc98b 100644 --- a/backend/services/llm_providers/gemini_provider.py +++ b/backend/services/llm_providers/gemini_provider.py @@ -348,6 +348,11 @@ def gemini_structured_json_response(prompt, schema, temperature=0.7, top_p=0.9, try: # Get API key with proper error handling api_key = get_gemini_api_key() + logger.info(f"🔑 Gemini API key loaded: {bool(api_key)} (length: {len(api_key) if api_key else 0})") + + if not api_key: + raise Exception("GEMINI_API_KEY not found in environment variables") + client = genai.Client(api_key=api_key) logger.info("✅ Gemini client initialized for structured JSON response") @@ -383,11 +388,18 @@ def gemini_structured_json_response(prompt, schema, temperature=0.7, top_p=0.9, system_instruction=system_prompt, ) - response = client.models.generate_content( - model="gemini-2.5-flash", - contents=prompt, - config=generation_config, - ) + logger.info("🚀 Making Gemini API call...") + try: + response = client.models.generate_content( + model="gemini-2.5-flash", + contents=prompt, + config=generation_config, + ) + logger.info("✅ Gemini API call completed successfully") + except Exception as api_error: + logger.error(f"❌ Gemini API call failed: {api_error}") + logger.error(f"❌ API Error type: {type(api_error).__name__}") + raise api_error # Check for parsed content first (primary method for structured output) if hasattr(response, 'parsed'): diff --git a/backend/services/usage_tracking_service.py b/backend/services/usage_tracking_service.py index 867dde88..503cd395 100644 --- a/backend/services/usage_tracking_service.py +++ b/backend/services/usage_tracking_service.py @@ -485,4 +485,27 @@ class UsageTrackingService: user_id=user_id, provider=provider, tokens_requested=tokens_requested - ) \ No newline at end of file + ) + + async def reset_current_billing_period(self, user_id: str) -> Dict[str, Any]: + """Reset usage status for the current billing period (after plan change).""" + try: + billing_period = datetime.now().strftime("%Y-%m") + summary = self.db.query(UsageSummary).filter( + UsageSummary.user_id == user_id, + UsageSummary.billing_period == billing_period + ).first() + + if not summary: + # Nothing to reset + return {"reset": False, "reason": "no_summary"} + + # Clear LIMIT_REACHED so the user can resume; keep counters intact + summary.usage_status = UsageStatus.ACTIVE + summary.updated_at = datetime.utcnow() + self.db.commit() + return {"reset": True} + except Exception as e: + self.db.rollback() + logger.error(f"Error resetting usage status: {e}") + return {"reset": False, "error": str(e)} \ No newline at end of file diff --git a/frontend/public/index.html b/frontend/public/index.html index f7f6092b..1c67ccb0 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -9,7 +9,7 @@ name="description" content="Alwrity - AI Content Creation Platform" /> - + Alwrity - AI Content Creation Platform diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d14dfa08..45db7751 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -71,7 +71,7 @@ const InitialRouteHandler: React.FC = () => { }); } }); - }, [checkSubscription]); + }, []); // Remove checkSubscription dependency to prevent loop // Initialize onboarding only after subscription is confirmed useEffect(() => { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ee446695..bd130ce8 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,5 +1,12 @@ import axios from 'axios'; +// Global subscription error handler - will be set by the app +let globalSubscriptionErrorHandler: ((error: any) => boolean) | null = null; + +export const setGlobalSubscriptionErrorHandler = (handler: (error: any) => boolean) => { + globalSubscriptionErrorHandler = handler; +}; + // Optional token getter installed from within the app after Clerk is available let authTokenGetter: (() => Promise) | null = null; @@ -141,6 +148,18 @@ apiClient.interceptors.response.use( } } + // Check if it's a subscription-related error and handle it globally + if (error.response?.status === 429 || error.response?.status === 402) { + console.log('API Client: Detected subscription error, triggering global handler'); + if (globalSubscriptionErrorHandler) { + const wasHandled = globalSubscriptionErrorHandler(error); + if (wasHandled) { + console.log('API Client: Subscription error handled by global handler'); + return Promise.reject(error); + } + } + } + console.error('API Error:', error.response?.status, error.response?.data); return Promise.reject(error); } @@ -194,6 +213,18 @@ aiApiClient.interceptors.response.use( } } + // Check if it's a subscription-related error and handle it globally + if (error.response?.status === 429 || error.response?.status === 402) { + console.log('AI API Client: Detected subscription error, triggering global handler'); + if (globalSubscriptionErrorHandler) { + const wasHandled = globalSubscriptionErrorHandler(error); + if (wasHandled) { + console.log('AI API Client: Subscription error handled by global handler'); + return Promise.reject(error); + } + } + } + console.error('AI API Error:', error.response?.status, error.response?.data); return Promise.reject(error); } @@ -232,6 +263,18 @@ longRunningApiClient.interceptors.response.use( console.warn('401 Unauthorized during onboarding - token may need refresh'); } } + // Check if it's a subscription-related error and handle it globally + if (error.response?.status === 429 || error.response?.status === 402) { + console.log('Long-running API Client: Detected subscription error, triggering global handler'); + if (globalSubscriptionErrorHandler) { + const wasHandled = globalSubscriptionErrorHandler(error); + if (wasHandled) { + console.log('Long-running API Client: Subscription error handled by global handler'); + return Promise.reject(error); + } + } + } + console.error('Long-running API Error:', error.response?.status, error.response?.data); return Promise.reject(error); } @@ -270,6 +313,18 @@ pollingApiClient.interceptors.response.use( console.warn('401 Unauthorized during onboarding - token may need refresh'); } } + // Check if it's a subscription-related error and handle it globally + if (error.response?.status === 429 || error.response?.status === 402) { + console.log('Polling API Client: Detected subscription error, triggering global handler'); + if (globalSubscriptionErrorHandler) { + const wasHandled = globalSubscriptionErrorHandler(error); + if (wasHandled) { + console.log('Polling API Client: Subscription error handled by global handler'); + return Promise.reject(error); + } + } + } + console.error('Polling API Error:', error.response?.status, error.response?.data); return Promise.reject(error); } diff --git a/frontend/src/components/OnboardingWizard/FinalStep/FinalStep.tsx b/frontend/src/components/OnboardingWizard/FinalStep/FinalStep.tsx index 78fa8e1f..3071f711 100644 --- a/frontend/src/components/OnboardingWizard/FinalStep/FinalStep.tsx +++ b/frontend/src/components/OnboardingWizard/FinalStep/FinalStep.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { Box, Button, @@ -12,7 +12,9 @@ import { import { Rocket, Star, - CheckCircle + CheckCircle, + CreditCard, + Warning } from '@mui/icons-material'; import OnboardingButton from '../common/OnboardingButton'; import { getApiKeys, completeOnboarding, getOnboardingSummary, getWebsiteAnalysisData, getResearchPreferencesData, setCurrentStep } from '../../../api/onboarding'; @@ -21,23 +23,40 @@ import { FinalStepProps, OnboardingData, Capability } from './types'; const FinalStep: React.FC = ({ onContinue, updateHeaderContent }) => { const [loading, setLoading] = useState(false); - const [dataLoading, setDataLoading] = useState(true); + const [dataLoading, setDataLoading] = useState(false); const [error, setError] = useState(null); const [onboardingData, setOnboardingData] = useState({ apiKeys: {} }); const [expandedSection, setExpandedSection] = useState('summary'); + const [validationStatus, setValidationStatus] = useState<{isValid: boolean, missingSteps: string[]} | null>(null); + const buttonRef = useRef(null); useEffect(() => { updateHeaderContent({ title: 'Review & Launch Alwrity 🚀', description: 'Review your configuration and confirm all settings before launching your AI-powered content creation workspace.' }); + // Always attempt to load data once on mount loadOnboardingData(); }, [updateHeaderContent]); + // Remove the DOM manipulation approach - we'll use React's built-in event handling + const loadOnboardingData = async () => { + // Prevent multiple simultaneous data loading calls + if (dataLoading) { + return; + } + setDataLoading(true); + + // Set a timeout to prevent infinite loading + const loadingTimeout = setTimeout(() => { + console.log('FinalStep: Data loading timeout reached, proceeding with available data'); + setDataLoading(false); + }, 4000); // 4s timeout + try { // Load comprehensive onboarding summary const summary = await getOnboardingSummary(); @@ -50,16 +69,26 @@ const FinalStep: React.FC = ({ onContinue, updateHeaderContent } const cachedAnalysisRaw = typeof window !== 'undefined' ? localStorage.getItem('website_analysis_data') : null; const cachedAnalysis = cachedAnalysisRaw ? safeParseJSON(cachedAnalysisRaw) : undefined; - setOnboardingData({ + const newOnboardingData = { apiKeys: summary.api_keys || {}, websiteUrl: websiteAnalysis?.website_url || summary.website_url || cachedUrl || undefined, researchPreferences: researchPreferences || summary.research_preferences, personalizationSettings: summary.personalization_settings, integrations: summary.integrations || {}, styleAnalysis: websiteAnalysis?.style_analysis || summary.style_analysis || cachedAnalysis || undefined - }); + }; + + setOnboardingData(newOnboardingData); + + // Validate completion status after data is loaded + console.log('FinalStep: Data loaded, running validation...'); + const validation = await validateOnboardingCompletionWithData(newOnboardingData); + setValidationStatus(validation); } catch (error) { console.error('Error loading onboarding data:', error); + + // Error handling is managed by global API client interceptors + // Fallback to just API keys if other endpoints fail try { const apiKeys = await getApiKeys(); @@ -73,9 +102,11 @@ const FinalStep: React.FC = ({ onContinue, updateHeaderContent } }); } catch (fallbackError) { console.error('Error loading API keys as fallback:', fallbackError); + // Error handling is managed by global API client interceptors } } finally { setDataLoading(false); + clearTimeout(loadingTimeout); } }; @@ -85,34 +116,168 @@ const FinalStep: React.FC = ({ onContinue, updateHeaderContent } try { return JSON.parse(raw); } catch { return undefined; } }; + const validateOnboardingCompletionWithData = async (data: OnboardingData): Promise<{isValid: boolean, missingSteps: string[]}> => { + console.log('FinalStep: Validating onboarding completion with data...'); + console.log('FinalStep: Data to validate:', data); + const missingSteps: string[] = []; + + try { + // Check API Keys (Step 1) - Since the user is on step 5 (FinalStep), + // they must have completed step 1 (API Keys) to get here + // The backend has EXA_API_KEY and GEMINI_API_KEY in .env and user completed step 1 + const hasApiKeys = true; // User is on final step, so step 1 must be completed + + console.log('FinalStep: API Keys check:', { + hasApiKeys, + reason: 'User is on final step, so step 1 (API Keys) must be completed', + note: 'Backend has EXA_API_KEY and GEMINI_API_KEY in .env' + }); + if (!hasApiKeys) { + missingSteps.push('API Keys'); + } + + // Check Website Analysis (Step 2) - Check for website URL or analysis data + const hasWebsiteAnalysis = (data.websiteUrl && data.websiteUrl.trim() !== '') || + (data.styleAnalysis && Object.keys(data.styleAnalysis).length > 0); + console.log('FinalStep: Website Analysis check:', { + websiteUrl: data.websiteUrl, + styleAnalysis: data.styleAnalysis, + hasWebsiteAnalysis + }); + if (!hasWebsiteAnalysis) { + missingSteps.push('Website Analysis'); + } + + // Check Research Preferences (Step 3) - Check for research preferences data + const hasResearchPreferences = data.researchPreferences && + (data.researchPreferences.research_depth || + data.researchPreferences.content_characteristics || + Object.keys(data.researchPreferences).length > 0); + console.log('FinalStep: Research Preferences check:', { + researchPreferences: data.researchPreferences, + hasResearchPreferences + }); + if (!hasResearchPreferences) { + missingSteps.push('Research Preferences'); + } + + // Check Persona Generation (Step 4) - Check for persona readiness or data + const hasPersonaData = (data.personaReadiness && data.personaReadiness.isReady) || + (data.personalizationSettings && Object.keys(data.personalizationSettings).length > 0); + console.log('FinalStep: Persona Generation check:', { + personaReadiness: data.personaReadiness, + personalizationSettings: data.personalizationSettings, + hasPersonaData + }); + if (!hasPersonaData) { + missingSteps.push('Persona Generation'); + } + + // Check Integrations (Step 5) - For now, we'll consider this optional + // In the future, this could check for specific integration data + + const isValid = missingSteps.length === 0; + console.log('FinalStep: Validation result:', {isValid, missingSteps}); + + return {isValid, missingSteps}; + } catch (error) { + console.error('FinalStep: Error validating completion:', error); + return {isValid: false, missingSteps: ['Validation Error']}; + } + }; + + const validateOnboardingCompletion = async (): Promise<{isValid: boolean, missingSteps: string[]}> => { + return validateOnboardingCompletionWithData(onboardingData); + }; + const handleLaunch = async () => { + console.log('FinalStep: handleLaunch called - button clicked'); + console.log('FinalStep: handleLaunch - starting execution'); + console.log('FinalStep: handleLaunch - current state:', {loading, error, validationStatus, dataLoading}); + + if (loading) { + console.log('FinalStep: Already processing, ignoring click'); + return; + } + + // Wait for data to be fully loaded before proceeding + if (dataLoading) { + console.log('FinalStep: Data still loading, waiting...'); + // Wait a bit and try again + setTimeout(() => { + if (!dataLoading) { + handleLaunch(); + } + }, 100); + return; + } + setLoading(true); setError(null); try { console.log('FinalStep: Starting onboarding completion...'); - // First, complete step 6 (Final Step) to mark it as completed - console.log('FinalStep: Completing step 6...'); - await setCurrentStep(6); - console.log('FinalStep: Step 6 completed successfully'); + // First, validate that all required steps are completed + console.log('FinalStep: Validating all required steps...'); + const validationResult = await validateOnboardingCompletion(); + if (!validationResult.isValid) { + throw new Error(`Cannot complete onboarding. Missing steps: ${validationResult.missingSteps.join(', ')}`); + } + console.log('FinalStep: All required steps validated successfully'); - // Then complete the entire onboarding process + // Complete step 6 (Final Step) to mark it as completed + console.log('FinalStep: Completing step 6...'); + console.log('FinalStep: Calling setCurrentStep(6)...'); + const step6Result = await setCurrentStep(6); + console.log('FinalStep: Step 6 completed successfully:', step6Result); + + // Complete the entire onboarding process console.log('FinalStep: Completing onboarding...'); - await completeOnboarding(); - console.log('FinalStep: Onboarding completed successfully'); + console.log('FinalStep: Calling completeOnboarding()...'); + const completionResult = await completeOnboarding(); + console.log('FinalStep: Onboarding completed successfully:', completionResult); + + // Mark onboarding as complete locally to unblock immediate navigation + try { + localStorage.setItem('onboarding_complete', 'true'); + localStorage.setItem('onboarding_active_step', String(stepsLengthFallback())); + } catch {} // Navigate directly to dashboard without calling onContinue // This bypasses the wizard flow and goes straight to the dashboard console.log('FinalStep: Navigating to dashboard...'); - window.location.href = '/dashboard'; + console.log('FinalStep: Setting window.location.href to /dashboard'); + + // Try multiple navigation methods to ensure redirect works + try { + window.location.href = '/dashboard'; + console.log('FinalStep: window.location.href set successfully'); + } catch (navError) { + console.error('FinalStep: window.location.href failed:', navError); + console.log('FinalStep: Trying alternative navigation method...'); + window.location.assign('/dashboard'); + } + + console.log('FinalStep: Navigation initiated'); } catch (e: any) { console.error('FinalStep: Error completing onboarding:', e); + console.error('FinalStep: Error details:', { + message: e.message, + status: e.response?.status, + statusText: e.response?.statusText, + data: e.response?.data, + stack: e.stack + }); + + // Error handling is managed by global API client interceptors // Provide more specific error messages let errorMessage = 'Failed to complete onboarding. Please try again.'; if (e.response?.data?.detail) { errorMessage = e.response.data.detail; + } else if (e.response?.data?.message) { + errorMessage = e.response.data.message; } else if (e.message) { errorMessage = e.message; } @@ -122,6 +287,9 @@ const FinalStep: React.FC = ({ onContinue, updateHeaderContent } setLoading(false); }; + // Helper to compute steps length for storing active step (fallback value) + const stepsLengthFallback = () => 6; + const capabilities: Capability[] = [ { id: 'ai-content', @@ -232,6 +400,7 @@ const FinalStep: React.FC = ({ onContinue, updateHeaderContent } )} + {/* Alerts */} {error && ( @@ -240,13 +409,15 @@ const FinalStep: React.FC = ({ onContinue, updateHeaderContent } severity="error" sx={{ mb: 2, borderRadius: 2 }} action={ - + + + } > @@ -260,18 +431,59 @@ const FinalStep: React.FC = ({ onContinue, updateHeaderContent } )} - {/* Action Button */} + {/* Validation Status */} + {validationStatus && !validationStatus.isValid && ( + + + + Setup Incomplete + + + The following steps need to be completed before launching: + + + {validationStatus.missingSteps.map((step, index) => ( +
  • + {step} +
  • + ))} +
    +
    +
    + )} + + {/* Launch Button */} - } - disabled={Object.keys(onboardingData.apiKeys).length === 0} + disabled={loading || dataLoading} + onClick={handleLaunch} + startIcon={} + sx={{ + background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + fontSize: '1.125rem', + fontWeight: 600, + px: 4, + py: 2, + borderRadius: 2, + textTransform: 'none', + boxShadow: '0 4px 12px rgba(102, 126, 234, 0.3)', + '&:hover': { + background: 'linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%)', + transform: 'translateY(-1px)', + boxShadow: '0 6px 16px rgba(102, 126, 234, 0.4)', + }, + '&:disabled': { + background: 'rgba(0,0,0,0.1)', + color: 'rgba(0,0,0,0.4)', + boxShadow: 'none', + transform: 'none', + } + }} > Launch Alwrity & Complete Setup - + {/* Help Text */} diff --git a/frontend/src/components/OnboardingWizard/FinalStep/components/SetupSummary.tsx b/frontend/src/components/OnboardingWizard/FinalStep/components/SetupSummary.tsx index cd563b47..d977c59a 100644 --- a/frontend/src/components/OnboardingWizard/FinalStep/components/SetupSummary.tsx +++ b/frontend/src/components/OnboardingWizard/FinalStep/components/SetupSummary.tsx @@ -74,12 +74,26 @@ export const SetupSummary: React.FC = ({ size="small" icon={} /> - + {/* Only show missing chip if there are actually missing items */} + {(() => { + const missingCount = capabilities.length - unlockedCapabilities.length; + return missingCount > 0 ? ( + + ) : ( + } + /> + ); + })()} diff --git a/frontend/src/components/OnboardingWizard/FinalStep/types.ts b/frontend/src/components/OnboardingWizard/FinalStep/types.ts index de7ea2dd..0fa84dd2 100644 --- a/frontend/src/components/OnboardingWizard/FinalStep/types.ts +++ b/frontend/src/components/OnboardingWizard/FinalStep/types.ts @@ -5,6 +5,7 @@ export interface OnboardingData { personalizationSettings?: any; integrations?: any; styleAnalysis?: any; + personaReadiness?: any; } export interface Capability { diff --git a/frontend/src/components/OnboardingWizard/Wizard.tsx b/frontend/src/components/OnboardingWizard/Wizard.tsx index 6fc302af..b887c046 100644 --- a/frontend/src/components/OnboardingWizard/Wizard.tsx +++ b/frontend/src/components/OnboardingWizard/Wizard.tsx @@ -324,6 +324,7 @@ const Wizard: React.FC = ({ onComplete }) => { }); } catch (error) { console.error('Error initializing onboarding:', error); + // Error handling is managed by global API client interceptors } finally { setLoading(false); } @@ -335,6 +336,7 @@ const Wizard: React.FC = ({ onComplete }) => { const handleNext = useCallback(async (rawStepData?: any) => { console.log('Wizard: handleNext called'); console.log('Wizard: Current step:', activeStep); + console.log('Wizard: Raw step data:', rawStepData); console.log('Wizard: Step data:', stepDataRef.current); console.log('Wizard: competitorDataCollector:', competitorDataCollectorRef.current); console.log('Wizard: competitorDataCollector type:', typeof competitorDataCollectorRef.current); @@ -351,6 +353,8 @@ const Wizard: React.FC = ({ onComplete }) => { let currentStepData = rawStepData && typeof rawStepData === 'object' && 'nativeEvent' in rawStepData ? undefined : rawStepData; + + console.log('Wizard: Processed currentStepData:', currentStepData); // Special handling for CompetitorAnalysisStep (step 2) if (activeStep === 2) { @@ -416,6 +420,9 @@ const Wizard: React.FC = ({ onComplete }) => { // Special handling for PersonaStep (step 3) if (activeStep === 3) { console.log('Wizard: Handling PersonaStep data...'); + console.log('Wizard: currentStepData for PersonaStep:', currentStepData); + console.log('Wizard: currentStepData has corePersona:', !!currentStepData?.corePersona); + console.log('Wizard: currentStepData has qualityMetrics:', !!currentStepData?.qualityMetrics); // If we have data from onContinue, use it if (currentStepData && currentStepData.corePersona && currentStepData.qualityMetrics) { @@ -442,6 +449,8 @@ const Wizard: React.FC = ({ onComplete }) => { currentStepData = currentData; } else { console.warn('Wizard: No valid persona data available for PersonaStep - cannot complete step'); + console.log('Wizard: Current step data:', currentStepData); + console.log('Wizard: Step data ref:', currentData); // Don't try to complete the step if we don't have valid persona data console.log('Wizard: Aborting step completion - missing valid persona data'); setLoading(false); @@ -694,15 +703,17 @@ const Wizard: React.FC = ({ onComplete }) => { - {/* Navigation */} - + {/* Navigation - Hide on final step */} + {activeStep !== steps.length - 1 && ( + + )} ); diff --git a/frontend/src/components/OnboardingWizard/common/WizardNavigation.tsx b/frontend/src/components/OnboardingWizard/common/WizardNavigation.tsx index 88abcc4d..2f2f3c39 100644 --- a/frontend/src/components/OnboardingWizard/common/WizardNavigation.tsx +++ b/frontend/src/components/OnboardingWizard/common/WizardNavigation.tsx @@ -73,39 +73,41 @@ export const WizardNavigation: React.FC = ({ )} - - - - - + {!isLastStep && ( + + + + + + )} ); }; diff --git a/frontend/src/components/SubscriptionExpiredModal.tsx b/frontend/src/components/SubscriptionExpiredModal.tsx new file mode 100644 index 00000000..baf49031 --- /dev/null +++ b/frontend/src/components/SubscriptionExpiredModal.tsx @@ -0,0 +1,221 @@ +import React from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, + Box, + Alert, + Paper +} from '@mui/material'; +import { + CreditCard, + Warning, + ArrowForward +} from '@mui/icons-material'; + +interface SubscriptionExpiredModalProps { + open: boolean; + onClose: () => void; + onRenewSubscription: () => void; + subscriptionData?: { + plan?: string; + tier?: string; + limits?: any; + } | null; + errorData?: { + provider?: string; + usage_info?: any; + message?: string; + } | null; +} + +const SubscriptionExpiredModal: React.FC = ({ + open, + onClose, + onRenewSubscription, + subscriptionData, + errorData +}) => { + const handleRenewClick = () => { + onRenewSubscription(); + onClose(); + }; + + return ( + + + + + + {errorData?.usage_info ? 'Usage Limit Reached' : 'Subscription Expired'} + + + + + + } + > + + {errorData?.usage_info ? 'You\'ve reached your API usage limit' : 'Your subscription has expired'} + + + + + + {errorData?.message || (errorData?.usage_info + ? 'You\'ve reached your monthly usage limit for this plan. Upgrade your plan to get higher limits.' + : 'To continue using Alwrity and access all features, you need to renew your subscription.' + )} + + + {errorData?.usage_info && ( + + + Usage Information: + + {errorData.usage_info.call_usage_percentage && ( + + You've used {errorData.usage_info.call_usage_percentage.toFixed(1)}% of your monthly limit + + )} + {errorData.provider && ( + + Provider: {errorData.provider} + + )} + + )} + + {subscriptionData && ( + + {subscriptionData.plan && ( + + + Current Plan: {subscriptionData.plan} + + + )} + {subscriptionData.tier && subscriptionData.tier !== subscriptionData.plan && ( + + + Tier: {subscriptionData.tier} + + + )} + + )} + + + + Renewing your subscription will restore access to: + + + + + ✓ AI Content Generation + + + ✓ Website Analysis + + + ✓ Research Tools + + + ✓ All Premium Features + + + + + + + + + + + ); +}; + +export default SubscriptionExpiredModal; diff --git a/frontend/src/components/shared/ProtectedRoute.tsx b/frontend/src/components/shared/ProtectedRoute.tsx index 3992467b..f560f1a3 100644 --- a/frontend/src/components/shared/ProtectedRoute.tsx +++ b/frontend/src/components/shared/ProtectedRoute.tsx @@ -10,7 +10,7 @@ interface ProtectedRouteProps { } const ProtectedRoute: React.FC = ({ children }) => { - const { isSignedIn } = useAuth(); + const { isLoaded, isSignedIn } = useAuth(); // Use onboarding context instead of making API calls const { @@ -21,8 +21,23 @@ const ProtectedRoute: React.FC = ({ children }) => { clearError } = useOnboarding(); - // Loading state - show spinner - if (loading) { + // Local fallback (in case context hasn't refreshed yet right after completion) + const localComplete = (() => { + try { return localStorage.getItem('onboarding_complete') === 'true'; } catch { return false; } + })(); + const allowAccess = isOnboardingComplete || localComplete; + + // Wait for Clerk to load before any redirect decisions + if (!isLoaded) { + return ( + + + + ); + } + + // Loading state from context - show spinner unless local flag says complete + if (loading && !localComplete) { console.log('ProtectedRoute: Loading onboarding state from context...'); return ( = ({ children }) => { ); } - // Error state - show error with retry - if (error) { + // Error state - show error with retry (unless local flag allows pass-through) + if (error && !localComplete) { console.error('ProtectedRoute: Error from context:', error); return ( = ({ children }) => { } // Not signed in - redirect to landing - if (!isSignedIn) { + if (isLoaded && !isSignedIn) { console.log('ProtectedRoute: Not signed in, redirecting to landing'); return ; } // Onboarding not complete - redirect to onboarding - if (!isOnboardingComplete) { - console.log('ProtectedRoute: Onboarding not complete (from context), redirecting'); + if (!allowAccess) { + console.log('ProtectedRoute: Onboarding not complete (context/local), redirecting'); return ; } // All checks passed - render protected component - console.log('ProtectedRoute: Access granted (from context), rendering component'); + console.log('ProtectedRoute: Access granted (context/local), rendering component'); return <>{children}; }; diff --git a/frontend/src/contexts/SubscriptionContext.tsx b/frontend/src/contexts/SubscriptionContext.tsx index c77637c9..e376d63e 100644 --- a/frontend/src/contexts/SubscriptionContext.tsx +++ b/frontend/src/contexts/SubscriptionContext.tsx @@ -1,5 +1,6 @@ -import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; -import { apiClient } from '../api/client'; +import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react'; +import { apiClient, setGlobalSubscriptionErrorHandler } from '../api/client'; +import SubscriptionExpiredModal from '../components/SubscriptionExpiredModal'; export interface SubscriptionLimits { gemini_calls: number; @@ -29,6 +30,8 @@ interface SubscriptionContextType { error: string | null; checkSubscription: () => Promise; refreshSubscription: () => Promise; + showExpiredModal: () => void; + hideExpiredModal: () => void; } const SubscriptionContext = createContext(undefined); @@ -49,8 +52,26 @@ export const SubscriptionProvider: React.FC = ({ chil const [subscription, setSubscription] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [showModal, setShowModal] = useState(false); + const [modalErrorData, setModalErrorData] = useState(null); + const [lastModalShowTime, setLastModalShowTime] = useState(0); + const [deferredError, setDeferredError] = useState(null); + const [lastCheckTime, setLastCheckTime] = useState(0); + // New: Grace window after plan changes to avoid noisy UX + const [graceUntil, setGraceUntil] = useState(0); + const [planSignature, setPlanSignature] = useState(""); - const checkSubscription = async () => { + const checkSubscription = useCallback(async () => { + // Throttle subscription checks to prevent excessive API calls + const now = Date.now(); + const THROTTLE_MS = 5000; // 5 seconds minimum between checks + + if (now - lastCheckTime < THROTTLE_MS) { + console.log('SubscriptionContext: Check throttled (5s)'); + return; + } + + setLastCheckTime(now); setLoading(true); setError(null); @@ -71,6 +92,70 @@ export const SubscriptionProvider: React.FC = ({ chil console.log('SubscriptionContext: Received subscription data from backend:', subscriptionData); setSubscription(subscriptionData); + + // Detect plan/tier change and start a grace window (5 minutes) + try { + const newSignature = `${subscriptionData?.plan || ''}:${subscriptionData?.tier || ''}`; + if (newSignature && newSignature !== planSignature) { + console.log('SubscriptionContext: Plan change detected, starting grace window'); + setPlanSignature(newSignature); + setGraceUntil(Date.now() + 5 * 60 * 1000); + // Close any existing modal as plan just changed + if (showModal) { + setShowModal(false); + setModalErrorData(null); + } + } + } catch (_e) {} + + // If we have a valid subscription and the modal is open, close it + if (subscriptionData && subscriptionData.active && showModal) { + console.log('SubscriptionContext: Valid subscription detected, closing modal'); + setShowModal(false); + setModalErrorData(null); + setLastModalShowTime(0); // Reset the cooldown timer + } + + // Also check if this is a usage limit error that should be suppressed + if (subscriptionData && subscriptionData.active && modalErrorData) { + const now = Date.now(); + const timeSinceLastModal = now - lastModalShowTime; + + // If it's been less than 10 minutes since modal was shown for usage limits, keep it closed + if (timeSinceLastModal < 600000 && modalErrorData.usage_info) { + console.log('SubscriptionContext: Recent usage limit modal, keeping it closed'); + } + } + + // Check if we have a deferred error to process now that we have subscription data + if (subscriptionData && deferredError) { + console.log('SubscriptionContext: Processing deferred error now that subscription data is available'); + const error = deferredError; + setDeferredError(null); // Clear the deferred error + + // Re-run the error handling logic now that we have subscription data + const status = error.response?.status; + if (status === 429 || status === 402) { + const now = Date.now(); + + // If active, suppress modal for usage limits + if (subscriptionData.active) { + console.log('SubscriptionContext: Active subscription (deferred); suppressing usage-limit modal'); + return; + } + + // For inactive subscriptions, show modal immediately + console.log('SubscriptionContext: Showing deferred modal for inactive subscription'); + const errorData = error.response?.data || {}; + setModalErrorData({ + provider: errorData.provider, + usage_info: errorData.usage_info, + message: errorData.message || errorData.error + }); + setShowModal(true); + setLastModalShowTime(now); + } + } } catch (err) { console.error('Error checking subscription:', err); @@ -84,15 +169,76 @@ export const SubscriptionProvider: React.FC = ({ chil // Don't default to free tier on error - preserve existing subscription or leave null // This prevents overriding correct subscription data with 'free' on temporary errors - console.warn('Subscription check failed, preserving existing data:', subscription); + console.warn('Subscription check failed, preserving existing data'); } finally { setLoading(false); } - }; + }, [lastCheckTime, planSignature, showModal, modalErrorData, lastModalShowTime, graceUntil]); - const refreshSubscription = async () => { + const refreshSubscription = useCallback(async () => { await checkSubscription(); - }; + }, [checkSubscription]); + + const showExpiredModal = useCallback(() => { + setShowModal(true); + }, []); + + const hideExpiredModal = useCallback(() => { + setShowModal(false); + }, []); + + const handleRenewSubscription = useCallback(() => { + window.location.href = '/pricing'; + }, []); + + // Global subscription error handler for API client + const globalSubscriptionErrorHandler = useCallback((error: any) => { + console.log('SubscriptionContext: Global error handler triggered', error); + + // Check if it's a subscription-related error + const status = error.response?.status; + + if (status === 429 || status === 402) { + console.log('SubscriptionContext: Subscription error detected'); + + const now = Date.now(); + + // If we have subscription data and it's active, always suppress modal for usage limits + if (subscription && subscription.active) { + console.log('SubscriptionContext: Active subscription; suppressing usage-limit modal'); + return true; // Do not show modal for active plan usage limits + } + + // If we don't have subscription data yet, defer the decision + if (!subscription) { + console.log('SubscriptionContext: No subscription data yet, deferring modal decision'); + setDeferredError(error); + return true; // Handle the error but don't show modal yet + } + + // If subscription is not active, show modal immediately + if (!subscription.active) { + console.log('SubscriptionContext: Inactive subscription, showing modal immediately'); + const errorData = error.response?.data || {}; + setModalErrorData({ + provider: errorData.provider, + usage_info: errorData.usage_info, + message: errorData.message || errorData.error + }); + setShowModal(true); + setLastModalShowTime(now); + return true; + } + } + + return false; // Not a subscription error + }, [subscription]); + + // Register the global error handler with the API client + useEffect(() => { + console.log('SubscriptionContext: Registering global subscription error handler'); + setGlobalSubscriptionErrorHandler(globalSubscriptionErrorHandler); + }, [globalSubscriptionErrorHandler]); useEffect(() => { // Check subscription on mount @@ -121,7 +267,7 @@ export const SubscriptionProvider: React.FC = ({ chil window.removeEventListener('subscription-updated', handleSubscriptionUpdate); window.removeEventListener('user-authenticated', handleUserAuth); }; - }, []); + }, []); // Remove checkSubscription dependency to prevent loop const value: SubscriptionContextType = { subscription, @@ -129,11 +275,20 @@ export const SubscriptionProvider: React.FC = ({ chil error, checkSubscription, refreshSubscription, + showExpiredModal, + hideExpiredModal, }; return ( {children} + ); }; diff --git a/frontend/src/hooks/useSubscriptionErrorHandler.ts b/frontend/src/hooks/useSubscriptionErrorHandler.ts new file mode 100644 index 00000000..6c8e276a --- /dev/null +++ b/frontend/src/hooks/useSubscriptionErrorHandler.ts @@ -0,0 +1,39 @@ +import { useCallback } from 'react'; +import { useSubscription } from '../contexts/SubscriptionContext'; + +interface ApiError { + response?: { + status?: number; + data?: any; + }; + message?: string; +} + +export const useSubscriptionErrorHandler = () => { + const { subscription, showExpiredModal } = useSubscription(); + + const handleApiError = useCallback((error: ApiError) => { + // Check if it's a subscription-related error + const status = error.response?.status; + + if (status === 429 || status === 402) { + console.log('Subscription error detected, letting global handler manage modal'); + + // Don't show modal directly - let the global API client handler manage it + // This ensures proper subscription state checking and modal spam prevention + return true; // Indicates subscription error was detected + } + + return false; // Not a subscription error + }, [subscription]); + + const handleSubscriptionExpired = useCallback(() => { + console.log('Manually triggering subscription expired modal'); + showExpiredModal(); + }, [showExpiredModal]); + + return { + handleApiError, + handleSubscriptionExpired + }; +}; diff --git a/frontend/src/services/billingService.ts b/frontend/src/services/billingService.ts index c3ee5737..7d305bd6 100644 --- a/frontend/src/services/billingService.ts +++ b/frontend/src/services/billingService.ts @@ -124,29 +124,83 @@ const defaultLimits = { 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, + 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 ?? [], + }; + const coerced: UsageStats = { - billing_period: raw?.billing_period ?? 'unknown', - usage_status: (raw?.usage_status ?? 'active') as UsageStats['usage_status'], - total_calls: Number(raw?.total_calls ?? 0), - total_tokens: Number(raw?.total_tokens ?? 0), - total_cost: Number(raw?.total_cost ?? 0), - avg_response_time: Number(raw?.avg_response_time ?? 0), - error_rate: Number(raw?.error_rate ?? 0), - limits: raw?.limits ?? defaultLimits, - provider_breakdown: raw?.provider_breakdown ?? defaultProviderBreakdown, - alerts: Array.isArray(raw?.alerts) ? raw.alerts : [], - usage_percentages: raw?.usage_percentages ?? { - 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, - cost: 0, + billing_period: raw?.billing_period ?? new Date().toISOString().slice(0,7), + usage_status: raw?.usage_status ?? 'active', + total_calls: raw?.total_calls ?? 0, + total_tokens: raw?.total_tokens ?? 0, + total_cost: raw?.total_cost ?? 0, + avg_response_time: raw?.avg_response_time ?? 0, + error_rate: raw?.error_rate ?? 0, + limits: defaultLimits, + provider_breakdown: { + gemini: providerBreakdown.gemini ?? { calls: 0, tokens: 0, cost: 0 }, + openai: providerBreakdown.openai ?? { calls: 0, tokens: 0, cost: 0 }, + anthropic: providerBreakdown.anthropic ?? { calls: 0, tokens: 0, cost: 0 }, + mistral: providerBreakdown.mistral ?? { calls: 0, tokens: 0, cost: 0 }, + tavily: providerBreakdown.tavily ?? { calls: 0, tokens: 0, cost: 0 }, + serper: providerBreakdown.serper ?? { calls: 0, tokens: 0, cost: 0 }, + metaphor: providerBreakdown.metaphor ?? { calls: 0, tokens: 0, cost: 0 }, + firecrawl: providerBreakdown.firecrawl ?? { calls: 0, tokens: 0, cost: 0 }, + stability: providerBreakdown.stability ?? { calls: 0, tokens: 0, cost: 0 }, + }, + alerts: coerceAlerts(raw?.alerts), + usage_percentages: { + gemini_calls: raw?.usage_percentages?.gemini_calls ?? 0, + openai_calls: raw?.usage_percentages?.openai_calls ?? 0, + anthropic_calls: raw?.usage_percentages?.anthropic_calls ?? 0, + mistral_calls: raw?.usage_percentages?.mistral_calls ?? 0, + tavily_calls: raw?.usage_percentages?.tavily_calls ?? 0, + serper_calls: raw?.usage_percentages?.serper_calls ?? 0, + metaphor_calls: raw?.usage_percentages?.metaphor_calls ?? 0, + firecrawl_calls: raw?.usage_percentages?.firecrawl_calls ?? 0, + stability_calls: raw?.usage_percentages?.stability_calls ?? 0, + cost: raw?.usage_percentages?.cost ?? 0, }, last_updated: raw?.last_updated ?? new Date().toISOString(), }; @@ -185,7 +239,7 @@ export const billingService = { provider_trends: {}, }, limits: raw?.limits ?? defaultLimits, - alerts: Array.isArray(raw?.alerts) ? raw.alerts : [], + alerts: coerceAlerts(raw?.alerts), projections: raw?.projections ?? { projected_monthly_cost: 0, cost_limit: 0,