'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 { ServicePlan, BillingCycle, PricingResourceType, LifecycleSettings } from '@/types'; import { DollarSign, Plus, Trash2, Edit2, ToggleLeft, ToggleRight, ChevronDown, ChevronUp, Shield, Clock } from 'lucide-react'; import { useConfirm } from '@/components/confirm-modal'; const runtimeOptions = [ { value: 'nodejs', label: 'Node.js' }, { value: 'laravel', label: 'Laravel' }, { value: 'wordpress', label: 'WordPress' }, ] as const; type AppRuntime = 'nodejs' | 'laravel' | 'wordpress'; const runtimeLabels: Record = { nodejs: 'Node.js', laravel: 'Laravel', wordpress: 'WordPress', }; const cycleLabels: Record = { hourly: 'Hourly', monthly: 'Monthly', yearly: 'Yearly', }; const resourceLabels: Record = { 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', }; const allResourceTypes: PricingResourceType[] = ['base_fee', 'cpu_per_core', 'memory_per_gb', 'storage_per_gb', 'database_addon']; interface RuleForm { resourceType: PricingResourceType; unitPrice: string; description: string; } const emptyRule = (): RuleForm => ({ resourceType: 'base_fee', unitPrice: '', description: '' }); export default function AdminBillingPage() { const queryClient = useQueryClient(); const confirm = useConfirm(); const [showForm, setShowForm] = useState(false); const [editingId, setEditingId] = useState(null); const [expandedPlan, setExpandedPlan] = useState(null); const [formName, setFormName] = useState(''); const [formRuntime, setFormRuntime] = useState('nodejs'); const [formDesc, setFormDesc] = useState(''); const [formCycle, setFormCycle] = useState('monthly'); const [rules, setRules] = useState([emptyRule()]); const { data: plans = [], isLoading } = useQuery({ queryKey: ['billing-plans'], queryFn: () => api.get('/billing/plans').then((r) => r.data), }); const createMutation = useMutation({ mutationFn: (data: any) => editingId ? api.patch(`/billing/plans/${editingId}`, data) : api.post('/billing/plans', data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['billing-plans'] }); toast.success(editingId ? 'Plan updated' : 'Plan created'); resetForm(); }, onError: (err: any) => toast.error(err.response?.data?.message || 'Error'), }); const deleteMutation = useMutation({ mutationFn: (id: string) => api.delete(`/billing/plans/${id}`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['billing-plans'] }); toast.success('Plan deleted'); }, onError: () => toast.error('Failed to delete plan'), }); const toggleMutation = useMutation({ mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) => api.patch(`/billing/plans/${id}`, { isActive }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['billing-plans'] }); }, }); const resetForm = () => { setShowForm(false); setEditingId(null); setFormName(''); setFormRuntime('nodejs'); setFormDesc(''); setFormCycle('monthly'); setRules([emptyRule()]); }; const startEdit = (plan: ServicePlan) => { setEditingId(plan.id); setFormName(plan.name); setFormRuntime(plan.runtime); setFormDesc(plan.description || ''); setFormCycle(plan.billingCycle); setRules( plan.pricingRules.map((r) => ({ resourceType: r.resourceType, unitPrice: String(r.unitPrice), description: r.description || '', })), ); setShowForm(true); }; const handleSubmit = () => { if (!formName.trim()) return toast.error('Plan name is required'); const validRules = rules.filter((r) => r.unitPrice && Number(r.unitPrice) > 0); if (validRules.length === 0) return toast.error('Add at least one pricing rule'); createMutation.mutate({ name: formName, runtime: formRuntime, description: formDesc || undefined, billingCycle: formCycle, pricingRules: validRules.map((r) => ({ resourceType: r.resourceType, unitPrice: Number(r.unitPrice), description: r.description || undefined, })), }); }; const addRule = () => setRules([...rules, emptyRule()]); const removeRule = (i: number) => setRules(rules.filter((_, idx) => idx !== i)); const updateRule = (i: number, field: keyof RuleForm, value: string) => { const updated = [...rules]; updated[i] = { ...updated[i], [field]: value }; setRules(updated); }; const formatPrice = (n: number) => Number(n).toLocaleString('en-US'); return (

Billing Plans

Define service plans and pricing for each application type

{!showForm && ( )}
{/* Create / Edit Form */} {showForm && (

{editingId ? 'Edit Plan' : 'Create New Plan'}

setFormName(e.target.value)} />
setFormDesc(e.target.value)} />
{/* Pricing Rules */}
{rules.map((rule, i) => (
updateRule(i, 'unitPrice', e.target.value)} /> updateRule(i, 'description', e.target.value)} /> {rules.length > 1 && ( )}
))}
)} {/* Plans List */} {isLoading ? (
Loading...
) : plans.length === 0 ? (
No plans created yet
) : (
{plans.map((plan) => (

{plan.name}

{runtimeLabels[plan.runtime] || plan.runtime} {cycleLabels[plan.billingCycle]} {plan.description && — {plan.description}}
{/* Expanded pricing rules */} {expandedPlan === plan.id && (
{plan.pricingRules.map((rule) => ( ))}
Resource Unit Price (Toman) Note
{resourceLabels[rule.resourceType]} {formatPrice(rule.unitPrice)} {rule.description || '—'}
)}
))}
)} {/* ─── Lifecycle Retention Settings ───────────────────── */}
); } // ─── Lifecycle Settings Sub-component ───────────────────────────── 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({ queryKey: ['lifecycle-settings'], queryFn: () => api.get('/lifecycle/settings').then((r) => r.data), }); const saveMutation = useMutation({ mutationFn: (body: any) => api.patch('/lifecycle/settings', body), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['lifecycle-settings'] }); toast.success('Lifecycle settings updated'); setEditing(false); }, onError: (err: any) => toast.error(err.response?.data?.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: any = {}; 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 (

Data Retention & Deletion Policy

{!editing && ( )}

Configure how long user data is retained after plan expiration before permanent deletion. After a plan expires, the application is suspended (scaled to 0). If no payment is received within the grace period, the application and all its data are permanently deleted.

{isLoading ? (
Loading...
) : editing ? (

Hourly Plans

setHourlyHours(e.target.value)} min={1} placeholder="24" />

Monthly Plans

setMonthlyDays(e.target.value)} min={1} placeholder="3" />

Yearly Plans

setYearlyDays(e.target.value)} min={1} placeholder="7" />
) : (

Hourly Plans

{settings?.hourly.deleteAfterHours ?? Math.round((settings?.hourly.deleteAfterMs || 0) / 3600000)} hours

after suspension → delete

Monthly Plans

{settings?.monthly.deleteAfterDays ?? Math.round((settings?.monthly.deleteAfterMs || 0) / 86400000)} days

after suspension → delete

Yearly Plans

{settings?.yearly.deleteAfterDays ?? Math.round((settings?.yearly.deleteAfterMs || 0) / 86400000)} days

after suspension → delete

)}
); }