Files
cloud-host/frontend/src/app/dashboard/admin/billing/page.tsx
T
keyhan 35235fe0fc Replace billing plans with per-runtime usage pricing catalog.
Store explicit hourly/monthly/yearly rates in pricing_rates and addon_rates, compute deploy costs without cycle conversion, and simplify admin UI and wallet payment to cycle-only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 18:10:54 +03:30

511 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import type {
PricingCatalog,
PricingRateRow,
BillingCycle,
PricingResourceType,
LifecycleSettings,
} from '@/types';
import { DollarSign, Edit2, Shield, Clock, Layers } from 'lucide-react';
type AppRuntime = 'nodejs' | 'laravel' | 'wordpress';
const runtimeTabs: { value: AppRuntime; label: string }[] = [
{ value: 'nodejs', label: 'Node.js' },
{ value: 'laravel', label: 'Laravel' },
{ value: 'wordpress', label: 'WordPress' },
];
const addonResourceTypes: PricingResourceType[] = [
'redis_addon',
'rabbitmq_addon',
'elasticsearch_addon',
'custom_domain_addon',
];
const resourceLabels: Record<PricingResourceType, string> = {
base_fee: 'Base fee',
cpu_per_core: 'CPU (per core)',
memory_per_gb: 'Memory (per GB)',
storage_per_gb: 'Storage (per GB)',
database_addon: 'Database addon',
redis_addon: 'Redis',
rabbitmq_addon: 'RabbitMQ',
elasticsearch_addon: 'Elasticsearch',
custom_domain_addon: 'Custom domain + SSL',
};
const cycles: BillingCycle[] = ['hourly', 'monthly', 'yearly'];
function cloneCatalog(catalog: PricingCatalog): PricingCatalog {
const runtimes = {} as PricingCatalog['runtimes'];
for (const rt of runtimeTabs) {
runtimes[rt.value] = catalog.runtimes[rt.value].map((r) => ({ ...r }));
}
return {
runtimes,
addons: catalog.addons.map((a) => ({ ...a })),
};
}
function PricingMatrixTable({
rows,
onChange,
readOnly,
}: {
rows: PricingRateRow[];
onChange: (resourceType: PricingResourceType, cycle: BillingCycle, value: number) => void;
readOnly: boolean;
}) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm border border-gray-200 rounded-lg overflow-hidden">
<thead className="bg-gray-50">
<tr>
<th className="text-left p-3 font-medium text-gray-600">Resource</th>
{cycles.map((cycle) => (
<th key={cycle} className="text-left p-3 font-medium text-gray-600 capitalize">
{cycle} (T)
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.resourceType} className="border-t border-gray-100">
<td className="p-3 font-medium text-gray-900">
{resourceLabels[row.resourceType]}
</td>
{cycles.map((cycle) => {
const field =
cycle === 'hourly'
? 'hourlyPrice'
: cycle === 'monthly'
? 'monthlyPrice'
: 'yearlyPrice';
const val = row[field];
return (
<td key={cycle} className="p-3">
{readOnly ? (
<span className="font-mono text-gray-700">
{Number(val).toLocaleString('en-US')}
</span>
) : (
<input
type="number"
min={0}
className="input-field w-full max-w-[120px]"
value={val}
onChange={(e) =>
onChange(
row.resourceType,
cycle,
e.target.value === '' ? 0 : Number(e.target.value),
)
}
/>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}
export default function AdminBillingPage() {
const queryClient = useQueryClient();
const [activeRuntime, setActiveRuntime] = useState<AppRuntime>('nodejs');
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<PricingCatalog | null>(null);
const { data: catalog, isLoading } = useQuery<PricingCatalog>({
queryKey: ['pricing-catalog'],
queryFn: () => api.get('/billing/pricing-catalog').then((r) => r.data),
});
const saveMutation = useMutation({
mutationFn: (body: PricingCatalog) => api.patch('/billing/pricing-catalog', body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pricing-catalog'] });
queryClient.invalidateQueries({ queryKey: ['custom-domain-price'] });
queryClient.invalidateQueries({ queryKey: ['optional-services-pricing'] });
toast.success('Pricing catalog saved');
setEditing(false);
setDraft(null);
},
onError: (err: unknown) => {
const message =
err && typeof err === 'object' && 'response' in err
? (err as { response?: { data?: { message?: string } } }).response?.data?.message
: undefined;
toast.error(message || 'Failed to save pricing');
},
});
const display = editing && draft ? draft : catalog;
const startEdit = () => {
if (!catalog) return;
setDraft(cloneCatalog(catalog));
setEditing(true);
};
const updateRuntimePrice = (
resourceType: PricingResourceType,
cycle: BillingCycle,
value: number,
) => {
if (!draft) return;
const field =
cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice';
setDraft({
...draft,
runtimes: {
...draft.runtimes,
[activeRuntime]: draft.runtimes[activeRuntime].map((row) =>
row.resourceType === resourceType ? { ...row, [field]: value } : row,
),
},
});
};
const updateAddonPrice = (
resourceType: PricingResourceType,
cycle: BillingCycle,
value: number,
) => {
if (!draft) return;
const field =
cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice';
setDraft({
...draft,
addons: draft.addons.map((row) =>
row.resourceType === resourceType ? { ...row, [field]: value } : row,
),
});
};
const fillYearlyFromMonthly = (scope: 'runtime' | 'addons') => {
if (!draft) return;
if (scope === 'runtime') {
setDraft({
...draft,
runtimes: {
...draft.runtimes,
[activeRuntime]: draft.runtimes[activeRuntime].map((row) => ({
...row,
yearlyPrice: Math.round(Number(row.monthlyPrice) * 12),
})),
},
});
} else {
setDraft({
...draft,
addons: draft.addons.map((row) => ({
...row,
yearlyPrice: Math.round(Number(row.monthlyPrice) * 12),
})),
});
}
};
const handleSave = () => {
if (!draft) return;
saveMutation.mutate(draft);
};
const addonRows =
display?.addons.filter((a) => addonResourceTypes.includes(a.resourceType)) ?? [];
return (
<div className="max-w-5xl mx-auto space-y-6 animate-fade-in">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h1 className="page-title flex items-center gap-2">
<DollarSign className="w-6 h-6" /> Billing & Pricing
</h1>
<p className="page-subtitle">
Usage-based prices per application type. Each resource has explicit hourly, monthly, and yearly rates deploy cost uses the column for the cycle the user selects.
</p>
</div>
{!editing ? (
<button
onClick={startEdit}
disabled={!catalog}
className="btn-primary flex items-center gap-2 shrink-0"
>
<Edit2 className="w-4 h-4" /> Edit pricing
</button>
) : (
<div className="flex gap-2 shrink-0">
<button
onClick={handleSave}
disabled={saveMutation.isPending}
className="btn-primary text-sm disabled:opacity-50"
>
{saveMutation.isPending ? 'Saving...' : 'Save'}
</button>
<button
onClick={() => {
setEditing(false);
setDraft(null);
}}
className="btn-secondary text-sm"
>
Cancel
</button>
</div>
)}
</div>
{isLoading ? (
<div className="text-center py-12 text-gray-400">Loading...</div>
) : !display ? (
<div className="text-center py-12 text-gray-400">No pricing data</div>
) : (
<>
<div className="card space-y-4">
<div className="flex flex-wrap gap-2 border-b border-gray-100 pb-3">
{runtimeTabs.map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => setActiveRuntime(tab.value)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeRuntime === tab.value
? 'bg-primary-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{tab.label}
</button>
))}
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold text-gray-900">
{runtimeTabs.find((t) => t.value === activeRuntime)?.label} resources
</h2>
{editing && (
<button
type="button"
onClick={() => fillYearlyFromMonthly('runtime')}
className="btn-secondary text-xs"
>
Fill yearly from monthly ×12
</button>
)}
</div>
<PricingMatrixTable
rows={display.runtimes[activeRuntime]}
readOnly={!editing}
onChange={updateRuntimePrice}
/>
</div>
<div className="card space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<Layers className="w-5 h-5" /> Platform add-ons
</h2>
{editing && (
<button
type="button"
onClick={() => fillYearlyFromMonthly('addons')}
className="btn-secondary text-xs"
>
Fill yearly from monthly ×12
</button>
)}
</div>
<p className="text-sm text-gray-500">
Redis, RabbitMQ, Elasticsearch, and custom domain same prices for all application types.
</p>
<PricingMatrixTable
rows={addonRows}
readOnly={!editing}
onChange={updateAddonPrice}
/>
</div>
</>
)}
<LifecycleSettingsSection />
</div>
);
}
function LifecycleSettingsSection() {
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const [hourlyHours, setHourlyHours] = useState('');
const [monthlyDays, setMonthlyDays] = useState('');
const [yearlyDays, setYearlyDays] = useState('');
const { data: settings, isLoading } = useQuery<LifecycleSettings>({
queryKey: ['lifecycle-settings'],
queryFn: () => api.get('/lifecycle/settings').then((r) => r.data),
});
const saveMutation = useMutation({
mutationFn: (body: Record<string, number>) => api.patch('/lifecycle/settings', body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['lifecycle-settings'] });
toast.success('Lifecycle settings updated');
setEditing(false);
},
onError: (err: unknown) => {
const message =
err && typeof err === 'object' && 'response' in err
? (err as { response?: { data?: { message?: string } } }).response?.data?.message
: undefined;
toast.error(message || 'Failed to save');
},
});
const startEditing = () => {
if (settings) {
setHourlyHours(String((settings.hourly.deleteAfterMs || 0) / 3600000));
setMonthlyDays(String((settings.monthly.deleteAfterMs || 0) / 86400000));
setYearlyDays(String((settings.yearly.deleteAfterMs || 0) / 86400000));
}
setEditing(true);
};
const handleSave = () => {
const body: Record<string, number> = {};
if (hourlyHours) body.hourlyDeleteAfterMs = Number(hourlyHours) * 3600000;
if (monthlyDays) body.monthlyDeleteAfterMs = Number(monthlyDays) * 86400000;
if (yearlyDays) body.yearlyDeleteAfterMs = Number(yearlyDays) * 86400000;
saveMutation.mutate(body);
};
return (
<div className="card mt-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-red-500" />
<h2 className="text-lg font-semibold text-gray-900">Data Retention & Deletion Policy</h2>
</div>
{!editing && (
<button onClick={startEditing} className="btn-secondary text-xs flex items-center gap-1">
<Edit2 className="w-3 h-3" /> Edit
</button>
)}
</div>
<p className="text-sm text-gray-500 mb-4">
Configure how long user data is retained after plan expiration before permanent deletion.
</p>
{isLoading ? (
<div className="text-center py-6 text-gray-400">Loading...</div>
) : editing ? (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="p-4 rounded-xl bg-blue-50 border border-blue-100">
<div className="flex items-center gap-2 mb-2">
<Clock className="w-4 h-4 text-blue-600" />
<h3 className="font-semibold text-blue-900">Hourly Plans</h3>
</div>
<label className="text-xs text-blue-700 font-medium">Delete after (hours):</label>
<input
type="number"
className="input-field mt-1 text-sm"
value={hourlyHours}
onChange={(e) => setHourlyHours(e.target.value)}
min={1}
placeholder="24"
/>
</div>
<div className="p-4 rounded-xl bg-purple-50 border border-purple-100">
<div className="flex items-center gap-2 mb-2">
<Clock className="w-4 h-4 text-purple-600" />
<h3 className="font-semibold text-purple-900">Monthly Plans</h3>
</div>
<label className="text-xs text-purple-700 font-medium">Delete after (days):</label>
<input
type="number"
className="input-field mt-1 text-sm"
value={monthlyDays}
onChange={(e) => setMonthlyDays(e.target.value)}
min={1}
placeholder="3"
/>
</div>
<div className="p-4 rounded-xl bg-green-50 border border-green-100">
<div className="flex items-center gap-2 mb-2">
<Clock className="w-4 h-4 text-green-600" />
<h3 className="font-semibold text-green-900">Yearly Plans</h3>
</div>
<label className="text-xs text-green-700 font-medium">Delete after (days):</label>
<input
type="number"
className="input-field mt-1 text-sm"
value={yearlyDays}
onChange={(e) => setYearlyDays(e.target.value)}
min={1}
placeholder="7"
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => setEditing(false)} className="btn-ghost">
Cancel
</button>
<button
onClick={handleSave}
disabled={saveMutation.isPending}
className="btn-primary disabled:opacity-50"
>
{saveMutation.isPending ? 'Saving...' : 'Save Settings'}
</button>
</div>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="p-4 rounded-xl bg-blue-50/50 border border-blue-100">
<h3 className="font-semibold text-blue-900 flex items-center gap-1 text-sm">
<Clock className="w-4 h-4" /> Hourly Plans
</h3>
<p className="text-2xl font-bold text-blue-700 mt-2">
{settings?.hourly.deleteAfterHours ??
Math.round((settings?.hourly.deleteAfterMs || 0) / 3600000)}
<span className="text-sm font-normal ml-1">hours</span>
</p>
</div>
<div className="p-4 rounded-xl bg-purple-50/50 border border-purple-100">
<h3 className="font-semibold text-purple-900 flex items-center gap-1 text-sm">
<Clock className="w-4 h-4" /> Monthly Plans
</h3>
<p className="text-2xl font-bold text-purple-700 mt-2">
{settings?.monthly.deleteAfterDays ??
Math.round((settings?.monthly.deleteAfterMs || 0) / 86400000)}
<span className="text-sm font-normal ml-1">days</span>
</p>
</div>
<div className="p-4 rounded-xl bg-green-50/50 border border-green-100">
<h3 className="font-semibold text-green-900 flex items-center gap-1 text-sm">
<Clock className="w-4 h-4" /> Yearly Plans
</h3>
<p className="text-2xl font-bold text-green-700 mt-2">
{settings?.yearly.deleteAfterDays ??
Math.round((settings?.yearly.deleteAfterMs || 0) / 86400000)}
<span className="text-sm font-normal ml-1">days</span>
</p>
</div>
</div>
)}
</div>
);
}