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:
@@ -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}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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. It’s 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" />
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user