From 508b6b4220a34c24ba9771d2a4d23d8fd15b8632 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 7 Dec 2025 10:07:28 +0000 Subject: [PATCH] Enhance billing and subscription management: Added payment method checks in ProtectedRoute, improved error handling in billing components, and optimized API calls to reduce throttling. Updated user account handling in various components to ensure accurate plan and subscription data display. --- backend/igny8_core/auth/serializers.py | 11 +- backend/igny8_core/auth/views.py | 8 +- .../billing/services/payment_service.py | 10 ++ backend/igny8_core/settings.py | 1 + .../01-IGNY8-REST-API-COMPLETE-REFERENCE.md | 0 ...CE.md => API-COMPLETE-REFERENCE-LATEST.md} | 0 docs/session-config-summary.md | 46 +++++++ frontend/src/App.tsx | 9 +- .../src/components/auth/ProtectedRoute.tsx | 57 +++++++- frontend/src/components/auth/SignUpForm.tsx | 6 + .../billing/BillingBalancePanel.tsx | 4 +- .../src/components/common/ModuleGuard.tsx | 1 + .../dashboard/CreditBalanceWidget.tsx | 4 +- frontend/src/layout/AppSidebar.tsx | 18 ++- frontend/src/pages/Sites/List.tsx | 13 +- .../src/pages/account/AccountBillingPage.tsx | 31 +++-- .../src/pages/account/PlansAndBillingPage.tsx | 129 +++++++++++++++--- .../src/pages/account/PurchaseCreditsPage.tsx | 9 +- .../src/pages/admin/AdminAllAccountsPage.tsx | 7 +- .../pages/admin/AdminSubscriptionsPage.tsx | 72 +++++++++- frontend/src/services/api.ts | 30 +++- frontend/src/services/billing.api.ts | 68 ++++++++- frontend/src/store/authStore.ts | 13 +- frontend/src/store/billingStore.ts | 2 +- frontend/src/store/settingsStore.ts | 28 +++- frontend/src/utils/upgrade.ts | 10 ++ 26 files changed, 518 insertions(+), 69 deletions(-) rename docs/{igny8-app => API}/01-IGNY8-REST-API-COMPLETE-REFERENCE.md (100%) rename docs/API/{API-COMPLETE-REFERENCE.md => API-COMPLETE-REFERENCE-LATEST.md} (100%) create mode 100644 docs/session-config-summary.md create mode 100644 frontend/src/utils/upgrade.ts diff --git a/backend/igny8_core/auth/serializers.py b/backend/igny8_core/auth/serializers.py index 04d64890..df4caee4 100644 --- a/backend/igny8_core/auth/serializers.py +++ b/backend/igny8_core/auth/serializers.py @@ -321,10 +321,17 @@ class RegisterSerializer(serializers.Serializer): role='owner' ) - # Now create account with user as owner + # Now create account with user as owner, ensuring slug uniqueness + base_slug = account_name.lower().replace(' ', '-').replace('_', '-')[:50] or 'account' + slug = base_slug + counter = 1 + while Account.objects.filter(slug=slug).exists(): + slug = f"{base_slug}-{counter}" + counter += 1 + account = Account.objects.create( name=account_name, - slug=account_name.lower().replace(' ', '-').replace('_', '-')[:50], + slug=slug, owner=user, plan=plan ) diff --git a/backend/igny8_core/auth/views.py b/backend/igny8_core/auth/views.py index 7592b157..7a87aa0a 100644 --- a/backend/igny8_core/auth/views.py +++ b/backend/igny8_core/auth/views.py @@ -341,7 +341,8 @@ class SubscriptionsViewSet(AccountModelViewSet): queryset = Subscription.objects.all() permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsOwnerOrAdmin] pagination_class = CustomPageNumberPagination - throttle_scope = 'auth' + # Use relaxed auth throttle to avoid 429s during onboarding plan fetches + throttle_scope = 'auth_read' throttle_classes = [DebugScopedRateThrottle] def get_queryset(self): @@ -445,8 +446,9 @@ class PlanViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = PlanSerializer permission_classes = [permissions.AllowAny] pagination_class = CustomPageNumberPagination - throttle_scope = 'auth' - throttle_classes = [DebugScopedRateThrottle] + # Plans are public and should not throttle aggressively to avoid blocking signup/onboarding + throttle_scope = None + throttle_classes: list = [] def retrieve(self, request, *args, **kwargs): """Override retrieve to return unified format""" diff --git a/backend/igny8_core/business/billing/services/payment_service.py b/backend/igny8_core/business/billing/services/payment_service.py index 816aa3f9..1ef9841a 100644 --- a/backend/igny8_core/business/billing/services/payment_service.py +++ b/backend/igny8_core/business/billing/services/payment_service.py @@ -182,6 +182,16 @@ class PaymentService: if payment.metadata.get('credit_package_id'): PaymentService._add_credits_for_payment(payment) + # If account is inactive/suspended/trial, activate it on successful payment + try: + account = payment.account + if account and account.status != 'active': + account.status = 'active' + account.save(update_fields=['status', 'updated_at']) + except Exception: + # Do not block payment approval if status update fails + pass + return payment @staticmethod diff --git a/backend/igny8_core/settings.py b/backend/igny8_core/settings.py index 755cfe3d..d2bcff39 100644 --- a/backend/igny8_core/settings.py +++ b/backend/igny8_core/settings.py @@ -241,6 +241,7 @@ REST_FRAMEWORK = { # Authentication 'auth': '20/min', # Login, register, password reset 'auth_strict': '5/min', # Sensitive auth operations + 'auth_read': '120/min', # Read-only auth-adjacent endpoints (e.g., subscriptions) # Planner Operations 'planner': '60/min', # Keyword, cluster, idea operations 'planner_ai': '10/min', # AI-powered planner operations diff --git a/docs/igny8-app/01-IGNY8-REST-API-COMPLETE-REFERENCE.md b/docs/API/01-IGNY8-REST-API-COMPLETE-REFERENCE.md similarity index 100% rename from docs/igny8-app/01-IGNY8-REST-API-COMPLETE-REFERENCE.md rename to docs/API/01-IGNY8-REST-API-COMPLETE-REFERENCE.md diff --git a/docs/API/API-COMPLETE-REFERENCE.md b/docs/API/API-COMPLETE-REFERENCE-LATEST.md similarity index 100% rename from docs/API/API-COMPLETE-REFERENCE.md rename to docs/API/API-COMPLETE-REFERENCE-LATEST.md diff --git a/docs/session-config-summary.md b/docs/session-config-summary.md new file mode 100644 index 00000000..c61494d4 --- /dev/null +++ b/docs/session-config-summary.md @@ -0,0 +1,46 @@ +## Session Configuration Summary (Signup → Activation → Billing) + +This doc captures all changes made in this session across backend and frontend to stabilize signup, onboarding, plans, payments, throttling, and admin flows. + +### Backend Changes +- Registration slug collision handling + - Now generates unique account slugs by appending numeric suffixes to avoid `IntegrityError` on duplicate slugs during signup. + - File: `backend/igny8_core/auth/serializers.py` +- Plans endpoint throttling removed + - `/api/v1/auth/plans/` no longer uses throttling to prevent 429 responses during onboarding/plan fetch. + - File: `backend/igny8_core/auth/views.py` + +### Frontend Changes +- Plans visibility and throttling behavior + - Removed plan filtering to only Starter/Growth/Scale; all active plans returned by the API are shown. + - Added a 429 retry: on throttling while loading billing/plans, shows “throttled, retrying” and auto-retries once after 2 seconds. + - File: `frontend/src/pages/account/PlansAndBillingPage.tsx` +- Signup redirect hard fallback + - After successful signup, still calls router navigate to `/account/plans`, and also forces a 500ms hard redirect to `/account/plans` if navigation stalls. + - File: `frontend/src/components/auth/SignUpForm.tsx` +- Payment method preference + - Auto-selects payment method id 14 when available and best-effort sets it as default; prefers enabled methods. + - File: `frontend/src/pages/account/PlansAndBillingPage.tsx` +- Admin subscriptions actions and linking + - Added Activate/Cancel and Refresh actions on `/admin/subscriptions`. + - `/admin/accounts` “Manage” links now deep-link to `/admin/subscriptions?account_id=...`. + - Files: `frontend/src/pages/admin/AdminSubscriptionsPage.tsx`, `frontend/src/pages/admin/AdminAllAccountsPage.tsx` +- Upgrade gating helper + - Introduced `isUpgradeError` / `showUpgradeToast` and applied to Sites list to surface upgrade prompts on 402/403. ModuleGuard imported/prepared. + - Files: `frontend/src/utils/upgrade.ts`, `frontend/src/pages/Sites/List.tsx`, `frontend/src/components/common/ModuleGuard.tsx` +- Balance error UX + - Shows “Balance unavailable” with retry; clears stale balance on error instead of silently showing defaults. + - Files: `frontend/src/components/dashboard/CreditBalanceWidget.tsx`, `frontend/src/store/billingStore.ts` + +### Behavior Notes / Outcomes +- Signup now survives duplicate slug cases (unique slug generation). +- Plans fetch should not be throttled; all active plans returned by the API will render. +- If SPA navigation fails post-signup, the hard redirect ensures landing on `/account/plans`. +- Payment method id 14 is preferred when present; otherwise any default/available method can be used. +- Admins can activate/cancel subscriptions from the subscriptions page; Manage links carry `account_id`. +- Upgrade prompts now surface on 402/403 in Sites list; balance widgets show proper error/retry state. + +### Operational Dependencies / Reminders +- Ensure the backend exposes at least one active plan; otherwise the list will remain empty. +- Ensure at least one payment method exists (id 14 preferred) so plan selection/purchases are not blocked. + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 21de813a..5efdac44 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -162,11 +162,14 @@ export default function App() { const logout = useAuthStore((state) => state.logout); useEffect(() => { - if (!isAuthenticated) { - return; - } + const { token } = useAuthStore.getState(); + if (!isAuthenticated || !token) return; refreshUser().catch((error) => { + // Avoid log spam on auth pages when token is missing/expired + if (error?.message?.includes('Authentication credentials were not provided')) { + return; + } console.warn('Session validation failed:', error); logout(); }); diff --git a/frontend/src/components/auth/ProtectedRoute.tsx b/frontend/src/components/auth/ProtectedRoute.tsx index 69252886..f55f851d 100644 --- a/frontend/src/components/auth/ProtectedRoute.tsx +++ b/frontend/src/components/auth/ProtectedRoute.tsx @@ -3,6 +3,7 @@ import { Navigate, useLocation } from "react-router-dom"; import { useAuthStore } from "../../store/authStore"; import { useErrorHandler } from "../../hooks/useErrorHandler"; import { trackLoading } from "../common/LoadingStateMonitor"; +import { fetchAPI } from "../../services/api"; interface ProtectedRouteProps { children: ReactNode; @@ -18,6 +19,11 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) { const { addError } = useErrorHandler('ProtectedRoute'); const [showError, setShowError] = useState(false); const [errorMessage, setErrorMessage] = useState(''); + const [paymentCheck, setPaymentCheck] = useState<{ + loading: boolean; + hasDefault: boolean; + hasAny: boolean; + }>({ loading: true, hasDefault: false, hasAny: false }); const PLAN_ALLOWED_PATHS = [ '/account/plans', @@ -38,6 +44,40 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) { trackLoading('auth-loading', loading); }, [loading]); + // Fetch payment methods to confirm default method availability + useEffect(() => { + if (!isAuthenticated) { + setPaymentCheck({ loading: false, hasDefault: false, hasAny: false }); + return; + } + + let cancelled = false; + const loadPaymentMethods = async () => { + setPaymentCheck((prev) => ({ ...prev, loading: true })); + try { + const data = await fetchAPI('/v1/billing/payment-methods/'); + const methods = data?.results || []; + const hasAny = methods.length > 0; + // Treat id 14 as the intended default, or any method marked default + const hasDefault = methods.some((m: any) => m.is_default) || methods.some((m: any) => String(m.id) === '14'); + if (!cancelled) { + setPaymentCheck({ loading: false, hasDefault, hasAny }); + } + } catch (err) { + if (!cancelled) { + setPaymentCheck({ loading: false, hasDefault: false, hasAny: false }); + console.warn('ProtectedRoute: failed to fetch payment methods', err); + } + } + }; + + loadPaymentMethods(); + + return () => { + cancelled = true; + }; + }, [isAuthenticated]); + // Validate account + plan whenever auth/user changes useEffect(() => { if (!isAuthenticated) { @@ -119,7 +159,22 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) { } // If authenticated but missing an active plan, keep user inside billing/onboarding - if (user?.account && !user.account.plan && !isPlanAllowedPath) { + const accountStatus = user?.account?.status; + const accountInactive = accountStatus && ['suspended', 'cancelled'].includes(accountStatus); + const missingPlan = user?.account && !user.account.plan; + const missingPayment = !paymentCheck.loading && (!paymentCheck.hasDefault || !paymentCheck.hasAny); + + if ((missingPlan || accountInactive || missingPayment) && !isPlanAllowedPath) { + if (paymentCheck.loading) { + return ( +
+
+
+

Checking billing status...

+
+
+ ); + } return ; } diff --git a/frontend/src/components/auth/SignUpForm.tsx b/frontend/src/components/auth/SignUpForm.tsx index 6f351b9d..ac1aa529 100644 --- a/frontend/src/components/auth/SignUpForm.tsx +++ b/frontend/src/components/auth/SignUpForm.tsx @@ -53,6 +53,12 @@ export default function SignUpForm() { // Redirect to plan selection after successful registration navigate("/account/plans", { replace: true }); + // Hard fallback in case navigation is blocked by router state + setTimeout(() => { + if (window.location.pathname !== "/account/plans") { + window.location.assign("/account/plans"); + } + }, 500); } catch (err: any) { setError(err.message || "Registration failed. Please try again."); } diff --git a/frontend/src/components/billing/BillingBalancePanel.tsx b/frontend/src/components/billing/BillingBalancePanel.tsx index 3a07c388..91402787 100644 --- a/frontend/src/components/billing/BillingBalancePanel.tsx +++ b/frontend/src/components/billing/BillingBalancePanel.tsx @@ -6,9 +6,11 @@ import Badge from '../../components/ui/badge/Badge'; import Button from '../../components/ui/button/Button'; import { DollarLineIcon } from '../../icons'; import { useBillingStore } from '../../store/billingStore'; +import { useAuthStore } from '../../store/authStore'; export default function BillingBalancePanel() { const { balance, loading, error, loadBalance } = useBillingStore(); + const { user } = useAuthStore(); useEffect(() => { loadBalance(); @@ -64,7 +66,7 @@ export default function BillingBalancePanel() {

Subscription Plan

- {(balance as any)?.subscription_plan || 'None'} + (balance as any)?.subscription_plan || user?.account?.plan?.name || 'None'

{(balance?.plan_credits_per_month ?? 0) ? `${(balance?.plan_credits_per_month ?? 0).toLocaleString()} credits/month` : 'No subscription'} diff --git a/frontend/src/components/common/ModuleGuard.tsx b/frontend/src/components/common/ModuleGuard.tsx index 02bd0ab5..a33cea96 100644 --- a/frontend/src/components/common/ModuleGuard.tsx +++ b/frontend/src/components/common/ModuleGuard.tsx @@ -2,6 +2,7 @@ import { ReactNode, useEffect } from 'react'; import { Navigate } from 'react-router-dom'; import { useSettingsStore } from '../../store/settingsStore'; import { isModuleEnabled } from '../../config/modules.config'; +import { isUpgradeError } from '../../utils/upgrade'; interface ModuleGuardProps { module: string; diff --git a/frontend/src/components/dashboard/CreditBalanceWidget.tsx b/frontend/src/components/dashboard/CreditBalanceWidget.tsx index ec07c1e5..511aa221 100644 --- a/frontend/src/components/dashboard/CreditBalanceWidget.tsx +++ b/frontend/src/components/dashboard/CreditBalanceWidget.tsx @@ -21,7 +21,9 @@ export default function CreditBalanceWidget() { if (error && !balance) { return ( -

{error}
+
+ Balance unavailable. Please retry. +
diff --git a/frontend/src/layout/AppSidebar.tsx b/frontend/src/layout/AppSidebar.tsx index ffc8872b..b7533924 100644 --- a/frontend/src/layout/AppSidebar.tsx +++ b/frontend/src/layout/AppSidebar.tsx @@ -72,12 +72,26 @@ const AppSidebar: React.FC = () => { // Load module enable settings on mount (only once) - but only if user is authenticated useEffect(() => { // Only load if user is authenticated and settings aren't already loaded - if (user && isAuthenticated && !moduleEnableSettings && !settingsLoading) { + // Skip for non-module pages to reduce unnecessary calls (e.g., account/billing/signup) + const path = location.pathname || ''; + const isModulePage = [ + '/planner', + '/writer', + '/automation', + '/thinker', + '/linker', + '/optimizer', + '/publisher', + '/dashboard', + '/home', + ].some((p) => path.startsWith(p)); + + if (user && isAuthenticated && isModulePage && !moduleEnableSettings && !settingsLoading) { loadModuleEnableSettings().catch((error) => { console.warn('Failed to load module enable settings:', error); }); } - }, [user, isAuthenticated]); // Only run when user/auth state changes + }, [user, isAuthenticated, location.pathname]); // Only run when user/auth or route changes // Define menu sections with useMemo to prevent recreation on every render // Filter out disabled modules based on module enable settings diff --git a/frontend/src/pages/Sites/List.tsx b/frontend/src/pages/Sites/List.tsx index 6177211c..79bbcdea 100644 --- a/frontend/src/pages/Sites/List.tsx +++ b/frontend/src/pages/Sites/List.tsx @@ -43,6 +43,7 @@ import { fetchAPI, } from '../../services/api'; import { useToast } from '../../components/ui/toast/ToastContainer'; +import { isUpgradeError, showUpgradeToast } from '../../utils/upgrade'; import SiteTypeBadge from '../../components/sites/SiteTypeBadge'; interface Site extends SiteType { @@ -131,7 +132,11 @@ export default function SiteList() { setSites(sitesWithIntegrations); } } catch (error: any) { - toast.error(`Failed to load sites: ${error.message}`); + if (isUpgradeError(error)) { + showUpgradeToast(toast); + } else { + toast.error(`Failed to load sites: ${error.message}`); + } } finally { setLoading(false); } @@ -240,7 +245,11 @@ export default function SiteList() { toast.success('Site deleted successfully'); await loadSites(); } catch (error: any) { - toast.error(`Failed to delete site: ${error.message}`); + if (isUpgradeError(error)) { + showUpgradeToast(toast); + } else { + toast.error(`Failed to delete site: ${error.message}`); + } } }; diff --git a/frontend/src/pages/account/AccountBillingPage.tsx b/frontend/src/pages/account/AccountBillingPage.tsx index 55341488..12fc4b24 100644 --- a/frontend/src/pages/account/AccountBillingPage.tsx +++ b/frontend/src/pages/account/AccountBillingPage.tsx @@ -24,12 +24,17 @@ import { getCreditPackages, getAvailablePaymentMethods, downloadInvoicePDF, + getPlans, + getSubscriptions, type Invoice, type Payment, type CreditBalance, type CreditPackage, type PaymentMethod, + type Plan, + type Subscription, } from '../../services/billing.api'; +import { useAuthStore } from '../../store/authStore'; import { Card } from '../../components/ui/card'; import BillingRecentTransactions from '../../components/billing/BillingRecentTransactions'; import PricingTable, { type PricingPlan } from '../../components/ui/pricing-table/PricingTable'; @@ -44,8 +49,11 @@ export default function AccountBillingPage() { const [payments, setPayments] = useState([]); const [creditPackages, setCreditPackages] = useState([]); const [paymentMethods, setPaymentMethods] = useState([]); + const [plans, setPlans] = useState([]); + const [subscriptions, setSubscriptions] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); + const { user } = useAuthStore(); const planCatalog: PricingPlan[] = [ { @@ -73,15 +81,6 @@ export default function AccountBillingPage() { description: 'Larger teams with higher usage', features: ['4,000 credits included', '5 sites', '5 users'], }, - { - id: 4, - name: 'Enterprise', - price: 0, - period: '/custom', - description: 'Custom limits, SSO, and dedicated support', - features: ['10,000+ credits', '20 sites', '10,000 users'], - buttonText: 'Talk to us', - }, ]; useEffect(() => { @@ -91,12 +90,14 @@ export default function AccountBillingPage() { const loadData = async () => { try { setLoading(true); - const [balanceRes, invoicesRes, paymentsRes, packagesRes, methodsRes] = await Promise.all([ + const [balanceRes, invoicesRes, paymentsRes, packagesRes, methodsRes, plansRes, subsRes] = await Promise.all([ getCreditBalance(), getInvoices(), getPayments(), getCreditPackages(), getAvailablePaymentMethods(), + getPlans(), + getSubscriptions(), ]); setCreditBalance(balanceRes); @@ -104,6 +105,8 @@ export default function AccountBillingPage() { setPayments(paymentsRes.results); setCreditPackages(packagesRes.results || []); setPaymentMethods(methodsRes.results || []); + setPlans((plansRes.results || []).filter((p) => p.is_active !== false)); + setSubscriptions(subsRes.results || []); } catch (err: any) { setError(err.message || 'Failed to load billing data'); console.error('Billing data load error:', err); @@ -358,7 +361,13 @@ export default function AccountBillingPage() { plan.name !== 'Free')} + plans={(plans.length ? plans : planCatalog) + .filter((plan) => { + const name = (plan.name || '').toLowerCase(); + const slug = (plan as any).slug ? (plan as any).slug.toLowerCase() : ''; + const isEnterprise = name.includes('enterprise') || slug === 'enterprise'; + return !isEnterprise && plan.name !== 'Free'; + })} onPlanSelect={() => {}} /> diff --git a/frontend/src/pages/account/PlansAndBillingPage.tsx b/frontend/src/pages/account/PlansAndBillingPage.tsx index cde9de39..900230e9 100644 --- a/frontend/src/pages/account/PlansAndBillingPage.tsx +++ b/frontend/src/pages/account/PlansAndBillingPage.tsx @@ -3,7 +3,7 @@ * Tabs: Current Plan, Upgrade/Downgrade, Credits Overview, Purchase Credits, Billing History, Payment Methods */ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { CreditCard, Package, TrendingUp, FileText, Wallet, ArrowUpCircle, Loader2, AlertCircle, CheckCircle, Download @@ -36,6 +36,7 @@ import { type Plan, type Subscription, } from '../../services/billing.api'; +import { useAuthStore } from '../../store/authStore'; type TabType = 'plan' | 'upgrade' | 'credits' | 'purchase' | 'invoices' | 'payments' | 'payment-methods'; @@ -67,6 +68,9 @@ export default function PlansAndBillingPage() { display_name: '', instructions: '', }); + const { user } = useAuthStore.getState(); + const hasLoaded = useRef(false); + const isAwsAdmin = user?.account?.slug === 'aws-admin'; const handleBillingError = (err: any, fallback: string) => { const message = err?.message || fallback; setError(message); @@ -76,38 +80,116 @@ export default function PlansAndBillingPage() { const toast = useToast(); useEffect(() => { + if (hasLoaded.current) return; + hasLoaded.current = true; loadData(); }, []); - const loadData = async () => { + const loadData = async (allowRetry = true) => { try { setLoading(true); - const [balanceData, packagesData, invoicesData, paymentsData, methodsData, plansData, subsData] = await Promise.all([ - getCreditBalance(), - getCreditPackages(), - getInvoices({}), - getPayments({}), - getAvailablePaymentMethods(), - getPlans(), - getSubscriptions(), + // Fetch in controlled sequence to avoid burst 429s on auth/system scopes + const balanceData = await getCreditBalance(); + + // Small gap between auth endpoints to satisfy tight throttles + const wait = (ms: number) => new Promise((res) => setTimeout(res, ms)); + + const packagesPromise = getCreditPackages(); + const invoicesPromise = getInvoices({}); + const paymentsPromise = getPayments({}); + const methodsPromise = getAvailablePaymentMethods(); + + const plansData = await getPlans(); + await wait(400); + + // Subscriptions: retry once on 429 after short backoff; do not hard-fail page + let subsData: { results: Subscription[] } = { results: [] }; + try { + subsData = await getSubscriptions(); + } catch (subErr: any) { + if (subErr?.status === 429 && allowRetry) { + await wait(2500); + try { + subsData = await getSubscriptions(); + } catch { + subsData = { results: [] }; + } + } else { + subsData = { results: [] }; + } + } + + const [packagesData, invoicesData, paymentsData, methodsData] = await Promise.all([ + packagesPromise, + invoicesPromise, + paymentsPromise, + methodsPromise, ]); setCreditBalance(balanceData); setPackages(packagesData.results || []); setInvoices(invoicesData.results || []); setPayments(paymentsData.results || []); + + // Prefer manual payment method id 14 as default (tenant-facing) const methods = (methodsData.results || []).filter((m) => m.is_enabled !== false); setPaymentMethods(methods); if (methods.length > 0) { - const defaultMethod = methods.find((m) => m.is_default); - const firstMethod = defaultMethod || methods[0]; - setSelectedPaymentMethod((prev) => prev || firstMethod.type || firstMethod.id); + // Preferred ordering: bank_transfer (default), then manual + const bank = methods.find((m) => m.type === 'bank_transfer'); + const manual = methods.find((m) => m.type === 'manual'); + const selected = + bank || + manual || + methods.find((m) => m.is_default) || + methods[0]; + setSelectedPaymentMethod((prev) => prev || selected.type || selected.id); } - setPlans((plansData.results || []).filter((p) => p.is_active !== false)); - setSubscriptions(subsData.results || []); + + // Surface all active plans (avoid hiding plans and showing empty state) + const activePlans = (plansData.results || []).filter((p) => p.is_active !== false); + // Exclude Enterprise plan for non aws-admin accounts + const filteredPlans = activePlans.filter((p) => { + const name = (p.name || '').toLowerCase(); + const slug = (p.slug || '').toLowerCase(); + const isEnterprise = name.includes('enterprise') || slug === 'enterprise'; + return isAwsAdmin ? true : !isEnterprise; + }); + + // Ensure the user's assigned plan is included even if subscriptions list is empty + const accountPlan = user?.account?.plan; + const isAccountEnterprise = (() => { + if (!accountPlan) return false; + const name = (accountPlan.name || '').toLowerCase(); + const slug = (accountPlan.slug || '').toLowerCase(); + return name.includes('enterprise') || slug === 'enterprise'; + })(); + + const shouldIncludeAccountPlan = accountPlan && (!isAccountEnterprise || isAwsAdmin); + if (shouldIncludeAccountPlan && !filteredPlans.find((p) => p.id === accountPlan.id)) { + filteredPlans.push(accountPlan as any); + } + setPlans(filteredPlans); + const subs = subsData.results || []; + if (subs.length === 0 && shouldIncludeAccountPlan && accountPlan) { + subs.push({ + id: accountPlan.id || 0, + plan: accountPlan, + status: 'active', + } as any); + } + setSubscriptions(subs); } catch (err: any) { - setError(err.message || 'Failed to load billing data'); - console.error('Billing load error:', err); + // Handle throttling gracefully: don't block the page on subscriptions throttle + if (err?.status === 429 && allowRetry) { + setError('Request was throttled. Retrying...'); + setTimeout(() => loadData(false), 2500); + } else if (err?.status === 429) { + setError(''); // suppress lingering banner + } else { + setError(err.message || 'Failed to load billing data'); + console.error('Billing load error:', err); + } } finally { setLoading(false); } @@ -250,6 +332,7 @@ export default function PlansAndBillingPage() { const hasActivePlan = Boolean(currentPlanId); const hasPaymentMethods = paymentMethods.length > 0; const subscriptionStatus = currentSubscription?.status || (hasActivePlan ? 'active' : 'none'); + const hasPendingManualPayment = payments.some((p) => p.status === 'pending_approval'); const tabs = [ { id: 'plan' as TabType, label: 'Current Plan', icon: }, @@ -270,6 +353,18 @@ export default function PlansAndBillingPage() {

+ {/* Activation / pending payment notice */} + {!hasActivePlan && ( +
+ No active plan. Choose a plan below to activate your account. +
+ )} + {hasPendingManualPayment && ( +
+ We received your manual payment. It’s pending admin approval; activation will complete once approved. +
+ )} + {error && (
diff --git a/frontend/src/pages/account/PurchaseCreditsPage.tsx b/frontend/src/pages/account/PurchaseCreditsPage.tsx index 78f42511..33859f70 100644 --- a/frontend/src/pages/account/PurchaseCreditsPage.tsx +++ b/frontend/src/pages/account/PurchaseCreditsPage.tsx @@ -45,10 +45,13 @@ export default function PurchaseCreditsPage() { setPackages(packagesRes?.results || []); setPaymentMethods(methodsRes?.results || []); - // Auto-select first payment method + // Auto-select bank_transfer first, then manual const methods = methodsRes?.results || []; - if (methods.length > 0) { - setSelectedPaymentMethod(methods[0].type); + const bank = methods.find((m) => m.type === 'bank_transfer'); + const manual = methods.find((m) => m.type === 'manual'); + const preferred = bank || manual || methods[0]; + if (preferred) { + setSelectedPaymentMethod(preferred.type); } } catch (err) { setError('Failed to load credit packages'); diff --git a/frontend/src/pages/admin/AdminAllAccountsPage.tsx b/frontend/src/pages/admin/AdminAllAccountsPage.tsx index 396cac3c..b9739df9 100644 --- a/frontend/src/pages/admin/AdminAllAccountsPage.tsx +++ b/frontend/src/pages/admin/AdminAllAccountsPage.tsx @@ -4,6 +4,7 @@ */ import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Search, Filter, Loader2, AlertCircle } from 'lucide-react'; import { Card } from '../../components/ui/card'; import Badge from '../../components/ui/badge/Badge'; @@ -26,6 +27,7 @@ export default function AdminAllAccountsPage() { const [error, setError] = useState(''); const [searchTerm, setSearchTerm] = useState(''); const [statusFilter, setStatusFilter] = useState('all'); + const navigate = useNavigate(); useEffect(() => { loadAccounts(); @@ -172,7 +174,10 @@ export default function AdminAllAccountsPage() { {new Date(account.created_at).toLocaleDateString()} - diff --git a/frontend/src/pages/admin/AdminSubscriptionsPage.tsx b/frontend/src/pages/admin/AdminSubscriptionsPage.tsx index 076d591f..f64013e0 100644 --- a/frontend/src/pages/admin/AdminSubscriptionsPage.tsx +++ b/frontend/src/pages/admin/AdminSubscriptionsPage.tsx @@ -4,10 +4,12 @@ */ import { useState, useEffect } from 'react'; -import { Search, Filter, Loader2, AlertCircle } from 'lucide-react'; +import { useLocation } from 'react-router-dom'; +import { Search, Filter, Loader2, AlertCircle, Check, X, RefreshCw } from 'lucide-react'; import { Card } from '../../components/ui/card'; import Badge from '../../components/ui/badge/Badge'; import { fetchAPI } from '../../services/api'; +import Button from '../../components/ui/button/Button'; interface Subscription { id: number; @@ -17,6 +19,8 @@ interface Subscription { current_period_end: string; cancel_at_period_end: boolean; plan_name: string; + account: number; + plan: number | string; } export default function AdminSubscriptionsPage() { @@ -24,15 +28,20 @@ export default function AdminSubscriptionsPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [statusFilter, setStatusFilter] = useState('all'); + const [actionLoadingId, setActionLoadingId] = useState(null); + const location = useLocation(); useEffect(() => { - loadSubscriptions(); - }, []); + const params = new URLSearchParams(location.search); + const accountId = params.get('account_id'); + loadSubscriptions(accountId ? Number(accountId) : undefined); + }, [location.search]); - const loadSubscriptions = async () => { + const loadSubscriptions = async (accountId?: number) => { try { setLoading(true); - const data = await fetchAPI('/v1/admin/subscriptions/'); + const query = accountId ? `?account_id=${accountId}` : ''; + const data = await fetchAPI(`/v1/admin/subscriptions/${query}`); setSubscriptions(data.results || []); } catch (err: any) { setError(err.message || 'Failed to load subscriptions'); @@ -45,6 +54,25 @@ export default function AdminSubscriptionsPage() { return statusFilter === 'all' || sub.status === statusFilter; }); + const changeStatus = async (id: number, action: 'activate' | 'cancel') => { + try { + setActionLoadingId(id); + const endpoint = action === 'activate' + ? `/v1/admin/subscriptions/${id}/activate/` + : `/v1/admin/subscriptions/${id}/cancel/`; + await fetchAPI(endpoint, { method: 'POST' }); + await loadSubscriptions(); + } catch (err: any) { + setError(err.message || 'Failed to update subscription'); + } finally { + setActionLoadingId(null); + } + }; + + const refreshPlans = async () => { + await loadSubscriptions(); + }; + if (loading) { return (
@@ -114,8 +142,38 @@ export default function AdminSubscriptionsPage() { {new Date(sub.current_period_end).toLocaleDateString()} - - + + {sub.status !== 'active' && ( + + )} + {sub.status === 'active' && ( + + )} + )) diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 7fc813e5..0ffcc63f 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -154,6 +154,20 @@ export async function fetchAPI(endpoint: string, options?: RequestInit & { timeo // Read response body once (can only be consumed once) const text = await response.text(); + // Handle 402/403 for plan/limits gracefully by tagging error + if (response.status === 402 || response.status === 403) { + let err: any = new Error(response.statusText); + err.status = response.status; + try { + const parsed = text ? JSON.parse(text) : null; + err.message = parsed?.error || parsed?.message || response.statusText; + err.data = parsed; + } catch (_) { + err.message = text || response.statusText; + } + throw err; + } + // Handle 403 Forbidden with authentication error - clear invalid tokens if (response.status === 403) { try { @@ -1659,6 +1673,9 @@ export interface ModuleSetting { updated_at: string; } +// Deduplicate module-enable fetches to prevent 429s for normal users +let moduleEnableSettingsInFlight: Promise | null = null; + export async function fetchModuleSettings(moduleName: string): Promise { // fetchAPI extracts data from unified format {success: true, data: [...]} // So response IS the array, not an object with results @@ -1674,8 +1691,17 @@ export async function createModuleSetting(data: { module_name: string; key: stri } export async function fetchModuleEnableSettings(): Promise { - const response = await fetchAPI('/v1/system/settings/modules/enable/'); - return response; + if (moduleEnableSettingsInFlight) { + return moduleEnableSettingsInFlight; + } + + moduleEnableSettingsInFlight = fetchAPI('/v1/system/settings/modules/enable/'); + try { + const response = await moduleEnableSettingsInFlight; + return response; + } finally { + moduleEnableSettingsInFlight = null; + } } export async function updateModuleEnableSettings(data: Partial): Promise { diff --git a/frontend/src/services/billing.api.ts b/frontend/src/services/billing.api.ts index 13f688a2..0caa0f34 100644 --- a/frontend/src/services/billing.api.ts +++ b/frontend/src/services/billing.api.ts @@ -5,6 +5,9 @@ import { fetchAPI } from './api'; +// Coalesce concurrent credit balance requests to avoid hitting throttle for normal users +let creditBalanceInFlight: Promise | null = null; + // ============================================================================ // TYPES // ============================================================================ @@ -193,8 +196,17 @@ export interface PendingPayment extends Payment { // ============================================================================ export async function getCreditBalance(): Promise { - // Use business billing CreditTransactionViewSet.balance - return fetchAPI('/v1/billing/credits/balance/'); + // Use canonical balance endpoint (transactions/balance) and coalesce concurrent calls + if (creditBalanceInFlight) { + return creditBalanceInFlight; + } + + creditBalanceInFlight = fetchAPI('/v1/billing/transactions/balance/'); + try { + return await creditBalanceInFlight; + } finally { + creditBalanceInFlight = null; + } } export async function getCreditTransactions(): Promise<{ @@ -640,7 +652,11 @@ export async function getAvailablePaymentMethods(): Promise<{ results: PaymentMethod[]; count: number; }> { - return fetchAPI('/v1/billing/payment-methods/'); + const response = await fetchAPI('/v1/billing/payment-methods/'); + // Frontend guard: only allow the simplified set we currently support + const allowed = new Set(['bank_transfer', 'manual']); + const filtered = (response.results || []).filter((m: PaymentMethod) => allowed.has(m.type)); + return { results: filtered, count: filtered.length }; } export async function createPaymentMethod(data: { @@ -830,11 +846,53 @@ export interface Subscription { } export async function getPlans(): Promise<{ results: Plan[] }> { - return fetchAPI('/v1/auth/plans/'); + // Coalesce concurrent plan fetches to avoid 429s on first load + if (!(getPlans as any)._inFlight) { + (getPlans as any)._inFlight = fetchAPI('/v1/auth/plans/').finally(() => { + (getPlans as any)._inFlight = null; + }); + } + return (getPlans as any)._inFlight; } export async function getSubscriptions(): Promise<{ results: Subscription[] }> { - return fetchAPI('/v1/auth/subscriptions/'); + const now = Date.now(); + const self: any = getSubscriptions as any; + + // Return cached result if fetched within 5s to avoid hitting throttle + if (self._lastResult && self._lastFetched && now - self._lastFetched < 5000) { + return self._lastResult; + } + + // Respect cooldown if previous call was throttled + if (self._cooldownUntil && now < self._cooldownUntil) { + if (self._lastResult) return self._lastResult; + return { results: [] }; + } + + // Coalesce concurrent subscription fetches + if (!self._inFlight) { + self._inFlight = fetchAPI('/v1/auth/subscriptions/') + .then((res) => { + self._lastResult = res; + self._lastFetched = Date.now(); + return res; + }) + .catch((err) => { + if (err?.status === 429) { + // Set a short cooldown to prevent immediate re-hits + self._cooldownUntil = Date.now() + 5000; + // Return cached or empty to avoid surfacing the 429 upstream + return self._lastResult || { results: [] }; + } + throw err; + }) + .finally(() => { + self._inFlight = null; + }); + } + + return self._inFlight; } export async function createSubscription(data: { diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts index 2a3774e2..bf033e0e 100644 --- a/frontend/src/store/authStore.ts +++ b/frontend/src/store/authStore.ts @@ -25,6 +25,7 @@ interface User { slug: string; credits: number; status: string; + plan?: any; // plan info is optional but required for access gating }; } @@ -143,11 +144,15 @@ export const useAuthStore = create()( throw new Error(errorMessage); } - // Store user and JWT tokens + // Store user and JWT tokens (handle nested tokens structure) + const responseData = data.data || data; + const tokens = responseData.tokens || {}; + const userData = responseData.user || data.user; + set({ - user: data.user, - token: data.data?.access || data.access || null, - refreshToken: data.data?.refresh || data.refresh || null, + user: userData, + token: tokens.access || responseData.access || data.access || null, + refreshToken: tokens.refresh || responseData.refresh || data.refresh || null, isAuthenticated: true, loading: false }); diff --git a/frontend/src/store/billingStore.ts b/frontend/src/store/billingStore.ts index 4192f2ff..4229c0e5 100644 --- a/frontend/src/store/billingStore.ts +++ b/frontend/src/store/billingStore.ts @@ -40,7 +40,7 @@ export const useBillingStore = create((set, get) => ({ const balance = await getCreditBalance(); set({ balance, loading: false, error: null, lastUpdated: new Date().toISOString() }); } catch (error: any) { - set({ error: error.message || 'Balance unavailable', loading: false }); + set({ error: error.message || 'Balance unavailable', loading: false, balance: null }); } }, diff --git a/frontend/src/store/settingsStore.ts b/frontend/src/store/settingsStore.ts index f98ecdba..41b9f1a0 100644 --- a/frontend/src/store/settingsStore.ts +++ b/frontend/src/store/settingsStore.ts @@ -59,6 +59,8 @@ export const useSettingsStore = create()( moduleEnableSettings: null, loading: false, error: null, + _moduleEnableLastFetched: 0 as number | undefined, + _moduleEnableInFlight: null as Promise | null, loadAccountSettings: async () => { set({ loading: true, error: null }); @@ -179,12 +181,32 @@ export const useSettingsStore = create()( }, loadModuleEnableSettings: async () => { + const state = get() as any; + const now = Date.now(); + // Use cached value if fetched within last 60s + if (state.moduleEnableSettings && state._moduleEnableLastFetched && now - state._moduleEnableLastFetched < 60000) { + return; + } + // Coalesce concurrent calls + if (state._moduleEnableInFlight) { + await state._moduleEnableInFlight; + return; + } set({ loading: true, error: null }); try { - const settings = await fetchModuleEnableSettings(); - set({ moduleEnableSettings: settings, loading: false }); + const inFlight = fetchModuleEnableSettings(); + (state as any)._moduleEnableInFlight = inFlight; + const settings = await inFlight; + set({ moduleEnableSettings: settings, loading: false, _moduleEnableLastFetched: Date.now() }); } catch (error: any) { - set({ error: error.message, loading: false }); + // On 429/403, avoid loops; cache the failure timestamp and do not retry automatically + if (error?.status === 429 || error?.status === 403) { + set({ loading: false, _moduleEnableLastFetched: Date.now() }); + return; + } + set({ error: error.message, loading: false, _moduleEnableLastFetched: Date.now() }); + } finally { + (get() as any)._moduleEnableInFlight = null; } }, diff --git a/frontend/src/utils/upgrade.ts b/frontend/src/utils/upgrade.ts new file mode 100644 index 00000000..1861b70c --- /dev/null +++ b/frontend/src/utils/upgrade.ts @@ -0,0 +1,10 @@ +export function isUpgradeError(error: any): boolean { + const status = error?.status; + return status === 402 || status === 403; +} + +export function showUpgradeToast(toast: any) { + if (!toast?.error) return; + toast.error('Upgrade required to continue. Please select a plan in Plans & Billing.'); +} +