35235fe0fc
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>
511 lines
18 KiB
TypeScript
511 lines
18 KiB
TypeScript
'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>
|
||
);
|
||
}
|