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.'); +} +