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.

This commit is contained in:
IGNY8 VPS (Salman)
2025-12-07 10:07:28 +00:00
parent 46fc6dcf04
commit 508b6b4220
26 changed files with 518 additions and 69 deletions

View File

@@ -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();
});

View File

@@ -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<string>('');
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 (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="text-center max-w-md px-4">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-brand-500 mb-4"></div>
<p className="text-lg font-medium text-gray-800 dark:text-white mb-2">Checking billing status...</p>
</div>
</div>
);
}
return <Navigate to="/account/plans" state={{ from: location }} replace />;
}

View File

@@ -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.");
}

View File

@@ -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() {
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400">Subscription Plan</h3>
</div>
<div className="text-3xl font-bold text-gray-900 dark:text-white">
{(balance as any)?.subscription_plan || 'None'}
(balance as any)?.subscription_plan || user?.account?.plan?.name || 'None'
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
{(balance?.plan_credits_per_month ?? 0) ? `${(balance?.plan_credits_per_month ?? 0).toLocaleString()} credits/month` : 'No subscription'}

View File

@@ -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;

View File

@@ -21,7 +21,9 @@ export default function CreditBalanceWidget() {
if (error && !balance) {
return (
<ComponentCard title="Credit Balance" desc="Balance unavailable">
<div className="text-sm text-red-600 dark:text-red-400 mb-3">{error}</div>
<div className="text-sm text-red-600 dark:text-red-400 mb-3">
Balance unavailable. Please retry.
</div>
<Button variant="outline" size="sm" onClick={loadBalance}>
Retry
</Button>

View File

@@ -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

View File

@@ -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}`);
}
}
};

View File

@@ -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<Payment[]>([]);
const [creditPackages, setCreditPackages] = useState<CreditPackage[]>([]);
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
const [plans, setPlans] = useState<Plan[]>([]);
const [subscriptions, setSubscriptions] = useState<Subscription[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>('');
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() {
<PricingTable
variant="2"
className="w-full"
plans={planCatalog.filter((plan) => 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={() => {}}
/>
</Card>

View File

@@ -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: <Package className="w-4 h-4" /> },
@@ -270,6 +353,18 @@ export default function PlansAndBillingPage() {
</p>
</div>
{/* Activation / pending payment notice */}
{!hasActivePlan && (
<div className="mb-4 p-4 rounded-lg border border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-200">
No active plan. Choose a plan below to activate your account.
</div>
)}
{hasPendingManualPayment && (
<div className="mb-4 p-4 rounded-lg border border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-100">
We received your manual payment. Its pending admin approval; activation will complete once approved.
</div>
)}
{error && (
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-red-600" />

View File

@@ -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');

View File

@@ -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<string>('');
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()}
</td>
<td className="px-6 py-4 text-right">
<button className="text-blue-600 hover:text-blue-700 text-sm font-medium">
<button
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
onClick={() => navigate(`/admin/subscriptions?account_id=${account.id}`)}
>
Manage
</button>
</td>

View File

@@ -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<string>('');
const [statusFilter, setStatusFilter] = useState('all');
const [actionLoadingId, setActionLoadingId] = useState<number | null>(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 (
<div className="flex items-center justify-center min-h-screen">
@@ -114,8 +142,38 @@ export default function AdminSubscriptionsPage() {
<td className="px-6 py-4 text-sm text-gray-600">
{new Date(sub.current_period_end).toLocaleDateString()}
</td>
<td className="px-6 py-4 text-right">
<button className="text-blue-600 hover:text-blue-700 text-sm">Manage</button>
<td className="px-6 py-4 text-right space-x-2">
{sub.status !== 'active' && (
<Button
variant="outline"
size="sm"
onClick={() => changeStatus(sub.id, 'activate')}
disabled={actionLoadingId === sub.id}
startIcon={actionLoadingId === sub.id ? <Loader2 className="w-4 h-4 animate-spin" /> : undefined}
>
Activate
</Button>
)}
{sub.status === 'active' && (
<Button
variant="outline"
tone="neutral"
size="sm"
onClick={() => changeStatus(sub.id, 'cancel')}
disabled={actionLoadingId === sub.id}
startIcon={actionLoadingId === sub.id ? <Loader2 className="w-4 h-4 animate-spin" /> : undefined}
>
Cancel
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={refreshPlans}
startIcon={<RefreshCw className="w-4 h-4" />}
>
Refresh
</Button>
</td>
</tr>
))

View File

@@ -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<ModuleEnableSettings> | null = null;
export async function fetchModuleSettings(moduleName: string): Promise<ModuleSetting[]> {
// 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<ModuleEnableSettings> {
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<ModuleEnableSettings>): Promise<ModuleEnableSettings> {

View File

@@ -5,6 +5,9 @@
import { fetchAPI } from './api';
// Coalesce concurrent credit balance requests to avoid hitting throttle for normal users
let creditBalanceInFlight: Promise<CreditBalance> | null = null;
// ============================================================================
// TYPES
// ============================================================================
@@ -193,8 +196,17 @@ export interface PendingPayment extends Payment {
// ============================================================================
export async function getCreditBalance(): Promise<CreditBalance> {
// 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: {

View File

@@ -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<AuthState>()(
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
});

View File

@@ -40,7 +40,7 @@ export const useBillingStore = create<BillingState>((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 });
}
},

View File

@@ -59,6 +59,8 @@ export const useSettingsStore = create<SettingsState>()(
moduleEnableSettings: null,
loading: false,
error: null,
_moduleEnableLastFetched: 0 as number | undefined,
_moduleEnableInFlight: null as Promise<ModuleEnableSettings> | null,
loadAccountSettings: async () => {
set({ loading: true, error: null });
@@ -179,12 +181,32 @@ export const useSettingsStore = create<SettingsState>()(
},
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;
}
},

View File

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