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
+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>
)}