feat: billing system — service plans, wallet, cost calculation

- Backend: billing module with ServicePlan, PricingRule, Wallet, WalletTransaction entities
- Admin can CRUD service plans with hourly/monthly/yearly billing cycles
- Each plan has flexible pricing rules (base_fee, cpu, memory, storage, db addon)
- Wallet system: auto-created per user, charge, deduct, refund, transaction history
- Cost calculation endpoint: cross-cycle conversion (hourly*720=monthly, monthly*12=yearly)
- Frontend: admin billing management page (/dashboard/admin/billing)
- Frontend: user wallet page with balance, quick-charge, transaction history
- Deploy page: cost breakdown shown in Review step (hourly/monthly/yearly)
- Navigation: Wallet link for users, Billing Plans link for admins
This commit is contained in:
keyhan
2026-04-07 01:45:45 +03:30
parent ac489c88d8
commit 4974f88e8c
16 changed files with 1302 additions and 3 deletions
@@ -0,0 +1,295 @@
'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 } from '@/types';
import { DollarSign, Plus, Trash2, Edit2, ToggleLeft, ToggleRight, ChevronDown, ChevronUp } from 'lucide-react';
const cycleLabels: Record<BillingCycle, string> = {
hourly: 'ساعتی',
monthly: 'ماهانه',
yearly: 'سالانه',
};
const resourceLabels: Record<PricingResourceType, string> = {
base_fee: 'هزینه پایه',
cpu_per_core: 'CPU (هر هسته)',
memory_per_gb: 'حافظه (هر GB)',
storage_per_gb: 'دیسک (هر GB)',
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 [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [expandedPlan, setExpandedPlan] = useState<string | null>(null);
const [formName, setFormName] = useState('');
const [formDesc, setFormDesc] = useState('');
const [formCycle, setFormCycle] = useState<BillingCycle>('monthly');
const [rules, setRules] = useState<RuleForm[]>([emptyRule()]);
const { data: plans = [], isLoading } = useQuery<ServicePlan[]>({
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 ? 'پلن بروزرسانی شد' : 'پلن ایجاد شد');
resetForm();
},
onError: (err: any) => toast.error(err.response?.data?.message || 'خطا'),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/billing/plans/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['billing-plans'] });
toast.success('پلن حذف شد');
},
onError: () => toast.error('خطا در حذف پلن'),
});
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('');
setFormDesc('');
setFormCycle('monthly');
setRules([emptyRule()]);
};
const startEdit = (plan: ServicePlan) => {
setEditingId(plan.id);
setFormName(plan.name);
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('نام پلن الزامی است');
const validRules = rules.filter((r) => r.unitPrice && Number(r.unitPrice) > 0);
if (validRules.length === 0) return toast.error('حداقل یک قاعده قیمت‌گذاری اضافه کنید');
createMutation.mutate({
name: formName,
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('fa-IR');
return (
<div className="max-w-4xl mx-auto space-y-6 animate-fade-in">
<div className="flex items-center justify-between">
<div>
<h1 className="page-title flex items-center gap-2"><DollarSign className="w-6 h-6" /> مدیریت پلنها و قیمتگذاری</h1>
<p className="page-subtitle">تعریف سرویسها و هزینهها برای هر نوع اپلیکیشن</p>
</div>
{!showForm && (
<button onClick={() => setShowForm(true)} className="btn-primary flex items-center gap-2">
<Plus className="w-4 h-4" /> پلن جدید
</button>
)}
</div>
{/* Create / Edit Form */}
{showForm && (
<div className="card space-y-4">
<h2 className="text-lg font-semibold">{editingId ? 'ویرایش پلن' : 'ایجاد پلن جدید'}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">نام پلن</label>
<input className="input-field" placeholder="مثال: Node.js پایه" value={formName} onChange={(e) => setFormName(e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">دوره پرداخت</label>
<select className="input-field" value={formCycle} onChange={(e) => setFormCycle(e.target.value as BillingCycle)}>
<option value="hourly">ساعتی</option>
<option value="monthly">ماهانه</option>
<option value="yearly">سالانه</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">توضیحات (اختیاری)</label>
<input className="input-field" placeholder="توضیحات درباره پلن" value={formDesc} onChange={(e) => setFormDesc(e.target.value)} />
</div>
{/* Pricing Rules */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-semibold text-gray-700">قواعد قیمتگذاری</label>
<button onClick={addRule} className="btn-secondary text-xs flex items-center gap-1">
<Plus className="w-3 h-3" /> افزودن
</button>
</div>
<div className="space-y-3">
{rules.map((rule, i) => (
<div key={i} className="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
<select
className="input-field flex-1 text-sm"
value={rule.resourceType}
onChange={(e) => updateRule(i, 'resourceType', e.target.value)}
>
{allResourceTypes.map((rt) => (
<option key={rt} value={rt}>{resourceLabels[rt]}</option>
))}
</select>
<input
className="input-field w-32 text-sm"
type="number"
placeholder="قیمت (تومان)"
value={rule.unitPrice}
onChange={(e) => updateRule(i, 'unitPrice', e.target.value)}
/>
<input
className="input-field flex-1 text-sm"
placeholder="توضیح (اختیاری)"
value={rule.description}
onChange={(e) => updateRule(i, 'description', e.target.value)}
/>
{rules.length > 1 && (
<button onClick={() => removeRule(i)} className="text-red-500 hover:text-red-700 p-1">
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={resetForm} className="btn-ghost">انصراف</button>
<button onClick={handleSubmit} disabled={createMutation.isPending} className="btn-primary disabled:opacity-50">
{createMutation.isPending ? 'در حال ذخیره...' : editingId ? 'بروزرسانی' : 'ایجاد پلن'}
</button>
</div>
</div>
)}
{/* Plans List */}
{isLoading ? (
<div className="text-center py-12 text-gray-400">در حال بارگذاری...</div>
) : plans.length === 0 ? (
<div className="text-center py-12 text-gray-400">هنوز پلنی ایجاد نشده</div>
) : (
<div className="space-y-3">
{plans.map((plan) => (
<div key={plan.id} className="card">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => setExpandedPlan(expandedPlan === plan.id ? null : plan.id)}
className="p-1 text-gray-400 hover:text-gray-600"
>
{expandedPlan === plan.id ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
<div>
<h3 className="font-semibold text-gray-900">{plan.name}</h3>
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className="badge badge-blue">{cycleLabels[plan.billingCycle]}</span>
{plan.description && <span> {plan.description}</span>}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => toggleMutation.mutate({ id: plan.id, isActive: !plan.isActive })}
className={`p-1 transition-colors ${plan.isActive ? 'text-green-500' : 'text-gray-400'}`}
title={plan.isActive ? 'غیرفعال کردن' : 'فعال کردن'}
>
{plan.isActive ? <ToggleRight className="w-5 h-5" /> : <ToggleLeft className="w-5 h-5" />}
</button>
<button onClick={() => startEdit(plan)} className="p-1 text-blue-500 hover:text-blue-700">
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => { if (confirm('حذف این پلن؟')) deleteMutation.mutate(plan.id); }}
className="p-1 text-red-500 hover:text-red-700"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{/* Expanded pricing rules */}
{expandedPlan === plan.id && (
<div className="mt-4 pt-4 border-t border-gray-100">
<table className="w-full text-sm">
<thead>
<tr className="text-gray-500 text-xs">
<th className="text-right pb-2">نوع منبع</th>
<th className="text-right pb-2">قیمت واحد (تومان)</th>
<th className="text-right pb-2">توضیحات</th>
</tr>
</thead>
<tbody>
{plan.pricingRules.map((rule) => (
<tr key={rule.id} className="border-t border-gray-50">
<td className="py-2 font-medium">{resourceLabels[rule.resourceType]}</td>
<td className="py-2 text-green-700 font-mono">{formatPrice(rule.unitPrice)}</td>
<td className="py-2 text-gray-500">{rule.description || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
+59 -2
View File
@@ -6,8 +6,8 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import { useAuthStore } from '@/lib/store';
import { toast } from 'react-toastify';
import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic } from '@/types';
import { Hexagon, FolderUp, Link as LinkIcon, CheckCircle, Package, Server, Scale, Home, Target, BarChart3, RotateCw, KeyRound, Rocket, Clock, Upload, XCircle, Database, Eye, EyeOff, RefreshCw } from 'lucide-react';
import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic, CostBreakdown } from '@/types';
import { Hexagon, FolderUp, Link as LinkIcon, CheckCircle, Package, Server, Scale, Home, Target, BarChart3, RotateCw, KeyRound, Rocket, Clock, Upload, XCircle, Database, Eye, EyeOff, RefreshCw, DollarSign } from 'lucide-react';
const steps = ['Basic Info', 'Versions & Database', 'Resources', 'Review'];
@@ -61,6 +61,20 @@ export default function DeployPage() {
enabled: isAdmin,
});
// Cost calculation for the review step
const { data: costData, isLoading: costLoading } = useQuery<CostBreakdown>({
queryKey: ['deploy-cost', form.runtime, form.databaseType, form.cpuLimit, form.memoryLimit, form.replicas, form.dbStorageSize],
queryFn: () => api.post('/billing/calculate', {
runtime: form.runtime,
databaseType: form.databaseType,
cpuLimit: form.cpuLimit,
memoryLimit: form.memoryLimit,
replicas: form.replicas,
dbStorageSize: form.databaseType !== 'none' ? `${parseInt(form.dbStorageSize || '1', 10) || 1}` : undefined,
}).then((r) => r.data),
enabled: step === 3,
});
const createMutation = useMutation({
mutationFn: async (data: CreateApplicationDto) => {
const res = await api.post('/applications', data);
@@ -1098,6 +1112,49 @@ export default function DeployPage() {
</div>
)}
</div>
{/* Cost Breakdown */}
<div className="bg-gradient-to-br from-emerald-50 to-teal-50 rounded-xl p-5 sm:p-6 border border-emerald-200">
<h3 className="text-sm font-semibold text-gray-700 flex items-center gap-2 mb-3">
<DollarSign className="w-4 h-4 text-emerald-600" /> برآورد هزینه
</h3>
{costLoading ? (
<div className="text-sm text-gray-400 text-center py-3">در حال محاسبه...</div>
) : costData ? (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3 text-center">
<div className="bg-white rounded-lg p-3 shadow-sm">
<p className="text-xs text-gray-500">ساعتی</p>
<p className="text-lg font-bold text-emerald-700">{Number(costData.hourly).toLocaleString('fa-IR')}</p>
<p className="text-xs text-gray-400">تومان</p>
</div>
<div className="bg-white rounded-lg p-3 shadow-sm ring-2 ring-emerald-200">
<p className="text-xs text-gray-500">ماهانه</p>
<p className="text-lg font-bold text-emerald-700">{Number(costData.monthly).toLocaleString('fa-IR')}</p>
<p className="text-xs text-gray-400">تومان</p>
</div>
<div className="bg-white rounded-lg p-3 shadow-sm">
<p className="text-xs text-gray-500">سالانه</p>
<p className="text-lg font-bold text-emerald-700">{Number(costData.yearly).toLocaleString('fa-IR')}</p>
<p className="text-xs text-gray-400">تومان</p>
</div>
</div>
{costData.breakdown && costData.breakdown.length > 0 && (
<div className="mt-2 pt-3 border-t border-emerald-200/50">
<p className="text-xs font-medium text-gray-500 mb-2">جزئیات</p>
{costData.breakdown.map((item, i) => (
<div key={i} className="flex justify-between text-xs py-1">
<span className="text-gray-600">{item.label}</span>
<span className="text-gray-900 font-medium">{Number(item.monthly).toLocaleString('fa-IR')} ت/ماه</span>
</div>
))}
</div>
)}
</div>
) : (
<div className="text-sm text-gray-400 text-center py-3">هنوز پلنی تعریف نشده</div>
)}
</div>
</div>
)}
+4
View File
@@ -22,6 +22,8 @@ import {
X,
LogOut,
Boxes,
Wallet,
CreditCard,
} from 'lucide-react';
type NavItem = { href: string; label: string; icon: ReactNode };
@@ -30,12 +32,14 @@ const userNavItems: NavItem[] = [
{ href: '/dashboard', label: 'Dashboard', icon: <LayoutDashboard className="w-4 h-4" /> },
{ href: '/dashboard/apps', label: 'Applications', icon: <Package className="w-4 h-4" /> },
{ href: '/dashboard/deploy', label: 'New Deploy', icon: <Rocket className="w-4 h-4" /> },
{ href: '/dashboard/wallet', label: 'Wallet', icon: <Wallet className="w-4 h-4" /> },
{ href: '/dashboard/tickets', label: 'Tickets', icon: <Ticket className="w-4 h-4" /> },
];
const adminNavItems: NavItem[] = [
{ href: '/dashboard/admin/users', label: 'Users', icon: <Users className="w-4 h-4" /> },
{ href: '/dashboard/admin/apps', label: 'All Applications', icon: <Boxes className="w-4 h-4" /> },
{ href: '/dashboard/admin/billing', label: 'Billing Plans', icon: <CreditCard className="w-4 h-4" /> },
{ href: '/dashboard/admin/clusters', label: 'Clusters', icon: <Server className="w-4 h-4" /> },
{ href: '/dashboard/admin/pools', label: 'Cluster Pools', icon: <Scale className="w-4 h-4" /> },
{ href: '/dashboard/admin/tickets', label: 'All Tickets', icon: <ClipboardList className="w-4 h-4" /> },
+178
View File
@@ -0,0 +1,178 @@
'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 { WalletTransaction, TransactionType } from '@/types';
import { Wallet, Plus, ArrowDownCircle, ArrowUpCircle, RotateCcw, Clock } from 'lucide-react';
const txTypeLabels: Record<TransactionType, string> = {
charge: 'شارژ',
deduction: 'کسر',
refund: 'بازگشت',
};
const txTypeColors: Record<TransactionType, string> = {
charge: 'text-green-600',
deduction: 'text-red-600',
refund: 'text-blue-600',
};
const txTypeIcons: Record<TransactionType, React.ReactNode> = {
charge: <ArrowDownCircle className="w-4 h-4 text-green-500" />,
deduction: <ArrowUpCircle className="w-4 h-4 text-red-500" />,
refund: <RotateCcw className="w-4 h-4 text-blue-500" />,
};
export default function WalletPage() {
const queryClient = useQueryClient();
const [chargeAmount, setChargeAmount] = useState('');
const [showCharge, setShowCharge] = useState(false);
const { data: walletData, isLoading: walletLoading } = useQuery<{ balance: number }>({
queryKey: ['wallet-balance'],
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
});
const { data: transactions = [], isLoading: txLoading } = useQuery<WalletTransaction[]>({
queryKey: ['wallet-transactions'],
queryFn: () => api.get('/billing/wallet/transactions').then((r) => r.data),
});
const chargeMutation = useMutation({
mutationFn: (amount: number) =>
api.post('/billing/wallet/charge', { amount, description: 'شارژ کیف پول' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
toast.success('کیف پول شارژ شد');
setChargeAmount('');
setShowCharge(false);
},
onError: (err: any) => toast.error(err.response?.data?.message || 'خطا در شارژ'),
});
const handleCharge = () => {
const amount = Number(chargeAmount);
if (!amount || amount < 1000) {
toast.error('حداقل مبلغ شارژ ۱,۰۰۰ تومان');
return;
}
chargeMutation.mutate(amount);
};
const formatPrice = (n: number) => Number(n).toLocaleString('fa-IR');
const formatDate = (d: string) => new Date(d).toLocaleDateString('fa-IR', {
year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
});
return (
<div className="max-w-3xl mx-auto space-y-6 animate-fade-in">
<div>
<h1 className="page-title flex items-center gap-2"><Wallet className="w-6 h-6" /> کیف پول</h1>
<p className="page-subtitle">مدیریت موجودی و تراکنشها</p>
</div>
{/* Balance Card */}
<div className="card bg-gradient-to-br from-primary-600 to-primary-800 text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-sm opacity-80">موجودی فعلی</p>
<p className="text-3xl font-bold mt-1">
{walletLoading ? '...' : `${formatPrice(walletData?.balance ?? 0)}`}
<span className="text-lg font-normal mr-2">تومان</span>
</p>
</div>
{!showCharge && (
<button
onClick={() => setShowCharge(true)}
className="flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 rounded-xl text-sm font-semibold transition-colors"
>
<Plus className="w-4 h-4" /> شارژ کیف پول
</button>
)}
</div>
{showCharge && (
<div className="mt-4 pt-4 border-t border-white/20 flex items-center gap-3">
<input
type="number"
className="flex-1 px-4 py-2.5 rounded-xl bg-white/20 text-white placeholder-white/60 border border-white/30 focus:outline-none focus:border-white/60 text-sm"
placeholder="مبلغ (تومان) — حداقل ۱,۰۰۰"
value={chargeAmount}
onChange={(e) => setChargeAmount(e.target.value)}
min={1000}
/>
<button
onClick={handleCharge}
disabled={chargeMutation.isPending}
className="px-5 py-2.5 bg-white text-primary-700 rounded-xl font-semibold text-sm hover:bg-gray-100 transition-colors disabled:opacity-50"
>
{chargeMutation.isPending ? '...' : 'پرداخت'}
</button>
<button
onClick={() => setShowCharge(false)}
className="px-3 py-2.5 bg-white/10 hover:bg-white/20 rounded-xl text-sm transition-colors"
>
انصراف
</button>
</div>
)}
{/* Quick charge amounts */}
{showCharge && (
<div className="flex gap-2 mt-3">
{[10000, 50000, 100000, 500000].map((amt) => (
<button
key={amt}
onClick={() => setChargeAmount(String(amt))}
className="px-3 py-1 bg-white/10 hover:bg-white/20 rounded-lg text-xs font-medium transition-colors"
>
{amt.toLocaleString('fa-IR')} ت
</button>
))}
</div>
)}
</div>
{/* Transactions */}
<div className="card">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Clock className="w-5 h-5 text-gray-400" /> تاریخچه تراکنشها
</h2>
{txLoading ? (
<div className="text-center py-8 text-gray-400">در حال بارگذاری...</div>
) : transactions.length === 0 ? (
<div className="text-center py-8 text-gray-400">هنوز تراکنشی ثبت نشده</div>
) : (
<div className="divide-y divide-gray-100">
{transactions.map((tx) => (
<div key={tx.id} className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gray-50 flex items-center justify-center">
{txTypeIcons[tx.type]}
</div>
<div>
<p className="text-sm font-medium text-gray-900">
{txTypeLabels[tx.type]}
{tx.description && <span className="text-gray-500 font-normal"> {tx.description}</span>}
</p>
<p className="text-xs text-gray-400">{formatDate(tx.createdAt)}</p>
</div>
</div>
<div className="text-left">
<p className={`text-sm font-bold ${txTypeColors[tx.type]}`}>
{tx.type === 'deduction' ? '' : '+'}{formatPrice(tx.amount)} ت
</p>
<p className="text-xs text-gray-400">مانده: {formatPrice(tx.balanceAfter)} ت</p>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}