feat: billing v2 — runtime-based plans, English UI, payment flow
- ServicePlan now has 'runtime' field (nodejs/laravel/wordpress) - Admin billing page: Application Type dropdown, English UI, Toman prices - calculateCost filters active plans by matching runtime - Wallet page: English UI, payment gateway integration (Pay Now button) - Deploy page Review step: billing cycle selector (hourly/monthly/yearly), payment method choice (wallet or payment gateway), Pay & Deploy button - Payment gateway endpoints: POST /billing/gateway/initiate + /verify (simulated — ready for Zarinpal/IDPay integration) - Deploy requires payment: wallet deduction or gateway charge before deploy
This commit is contained in:
@@ -112,6 +112,45 @@ export class BillingController {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Payment Gateway ─────────────────────────────────────────────
|
||||
|
||||
@Post('gateway/initiate')
|
||||
@ApiOperation({ summary: 'Initiate a payment gateway transaction' })
|
||||
async initiateGateway(
|
||||
@Request() req: any,
|
||||
@Body() body: { amount: number; description?: string; callbackUrl: string },
|
||||
) {
|
||||
// In production, integrate with Zarinpal/IDPay/etc.
|
||||
// For now, simulate a gateway redirect URL.
|
||||
const trackingCode = `PAY-${Date.now()}-${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
|
||||
return {
|
||||
success: true,
|
||||
trackingCode,
|
||||
gatewayUrl: `${body.callbackUrl}?trackingCode=${trackingCode}&amount=${body.amount}&status=success`,
|
||||
message: 'Redirect user to gatewayUrl to complete payment',
|
||||
};
|
||||
}
|
||||
|
||||
@Post('gateway/verify')
|
||||
@ApiOperation({ summary: 'Verify a payment gateway transaction and charge wallet' })
|
||||
async verifyGateway(
|
||||
@Request() req: any,
|
||||
@Body() body: { trackingCode: string; amount: number },
|
||||
) {
|
||||
// In production, verify with the gateway provider.
|
||||
// For now, auto-approve and charge the wallet.
|
||||
await this.billingService.chargeWallet(
|
||||
req.user.id,
|
||||
body.amount,
|
||||
`Payment gateway: ${body.trackingCode}`,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
message: 'Payment verified and wallet charged',
|
||||
trackingCode: body.trackingCode,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Wallet Admin ─────────────────────────────────────────────────
|
||||
|
||||
@Get('admin/wallets')
|
||||
|
||||
@@ -28,6 +28,7 @@ export class BillingService {
|
||||
async createPlan(dto: CreateServicePlanDto): Promise<ServicePlan> {
|
||||
const plan = this.planRepo.create({
|
||||
name: dto.name,
|
||||
runtime: dto.runtime,
|
||||
description: dto.description,
|
||||
billingCycle: dto.billingCycle,
|
||||
});
|
||||
@@ -47,6 +48,7 @@ export class BillingService {
|
||||
if (!plan) throw new NotFoundException('Plan not found');
|
||||
|
||||
if (dto.name !== undefined) plan.name = dto.name;
|
||||
if (dto.runtime !== undefined) plan.runtime = dto.runtime;
|
||||
if (dto.description !== undefined) plan.description = dto.description;
|
||||
if (dto.billingCycle !== undefined) plan.billingCycle = dto.billingCycle;
|
||||
if (dto.isActive !== undefined) plan.isActive = dto.isActive;
|
||||
@@ -101,7 +103,12 @@ export class BillingService {
|
||||
yearly: number;
|
||||
breakdown: { label: string; hourly: number; monthly: number; yearly: number }[];
|
||||
}> {
|
||||
const plans = await this.findActivePlans();
|
||||
// Only use active plans that match the requested runtime
|
||||
const plans = await this.planRepo.find({
|
||||
where: { isActive: true, runtime: dto.runtime as any },
|
||||
relations: ['pricingRules'],
|
||||
});
|
||||
|
||||
if (plans.length === 0) {
|
||||
return { hourly: 0, monthly: 0, yearly: 0, breakdown: [] };
|
||||
}
|
||||
@@ -140,7 +147,7 @@ export class BillingService {
|
||||
switch (rule.resourceType) {
|
||||
case PricingResourceType.BASE_FEE:
|
||||
cost = Number(rule.unitPrice);
|
||||
label = 'هزینه پایه';
|
||||
label = 'Base fee';
|
||||
break;
|
||||
case PricingResourceType.CPU_PER_CORE:
|
||||
cost = cpuCores * replicas * Number(rule.unitPrice);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { IsString, IsEnum, IsOptional, IsBoolean, IsNumber, IsArray, ValidateNested, Min } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { BillingCycle, PricingResourceType } from '../../common/enums';
|
||||
import { BillingCycle, PricingResourceType, AppRuntime } from '../../common/enums';
|
||||
|
||||
export class CreatePricingRuleDto {
|
||||
@ApiProperty({ enum: PricingResourceType })
|
||||
@@ -20,11 +20,15 @@ export class CreatePricingRuleDto {
|
||||
}
|
||||
|
||||
export class CreateServicePlanDto {
|
||||
@ApiProperty({ example: 'Node.js Basic' })
|
||||
@ApiProperty({ example: 'Node.js Standard' })
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Basic plan for Node.js applications' })
|
||||
@ApiProperty({ enum: AppRuntime, example: 'nodejs' })
|
||||
@IsEnum(AppRuntime)
|
||||
runtime: AppRuntime;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Standard plan for Node.js applications' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
@@ -46,6 +50,11 @@ export class UpdateServicePlanDto {
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: AppRuntime })
|
||||
@IsOptional()
|
||||
@IsEnum(AppRuntime)
|
||||
runtime?: AppRuntime;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { BillingCycle } from '../../common/enums';
|
||||
import { BillingCycle, AppRuntime } from '../../common/enums';
|
||||
import { PricingRule } from './pricing-rule.entity';
|
||||
|
||||
@Entity('service_plans')
|
||||
@@ -15,7 +15,10 @@ export class ServicePlan {
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
name: string; // e.g. "Node.js Basic", "WordPress Pro"
|
||||
name: string; // Display label, e.g. "Node.js Standard"
|
||||
|
||||
@Column({ type: 'enum', enum: AppRuntime })
|
||||
runtime: AppRuntime; // Which application type this plan targets
|
||||
|
||||
@Column({ nullable: true })
|
||||
description: string;
|
||||
|
||||
@@ -7,18 +7,32 @@ 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 runtimeOptions = [
|
||||
{ value: 'nodejs', label: 'Node.js' },
|
||||
{ value: 'laravel', label: 'Laravel' },
|
||||
{ value: 'wordpress', label: 'WordPress' },
|
||||
] as const;
|
||||
|
||||
type AppRuntime = 'nodejs' | 'laravel' | 'wordpress';
|
||||
|
||||
const runtimeLabels: Record<AppRuntime, string> = {
|
||||
nodejs: 'Node.js',
|
||||
laravel: 'Laravel',
|
||||
wordpress: 'WordPress',
|
||||
};
|
||||
|
||||
const cycleLabels: Record<BillingCycle, string> = {
|
||||
hourly: 'ساعتی',
|
||||
monthly: 'ماهانه',
|
||||
yearly: 'سالانه',
|
||||
hourly: 'Hourly',
|
||||
monthly: 'Monthly',
|
||||
yearly: 'Yearly',
|
||||
};
|
||||
|
||||
const resourceLabels: Record<PricingResourceType, string> = {
|
||||
base_fee: 'هزینه پایه',
|
||||
cpu_per_core: 'CPU (هر هسته)',
|
||||
memory_per_gb: 'حافظه (هر GB)',
|
||||
storage_per_gb: 'دیسک (هر GB)',
|
||||
database_addon: 'افزونه دیتابیس',
|
||||
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'];
|
||||
@@ -37,6 +51,7 @@ export default function AdminBillingPage() {
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [expandedPlan, setExpandedPlan] = useState<string | null>(null);
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formRuntime, setFormRuntime] = useState<AppRuntime>('nodejs');
|
||||
const [formDesc, setFormDesc] = useState('');
|
||||
const [formCycle, setFormCycle] = useState<BillingCycle>('monthly');
|
||||
const [rules, setRules] = useState<RuleForm[]>([emptyRule()]);
|
||||
@@ -52,19 +67,19 @@ export default function AdminBillingPage() {
|
||||
: api.post('/billing/plans', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['billing-plans'] });
|
||||
toast.success(editingId ? 'پلن بروزرسانی شد' : 'پلن ایجاد شد');
|
||||
toast.success(editingId ? 'Plan updated' : 'Plan created');
|
||||
resetForm();
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'خطا'),
|
||||
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('پلن حذف شد');
|
||||
toast.success('Plan deleted');
|
||||
},
|
||||
onError: () => toast.error('خطا در حذف پلن'),
|
||||
onError: () => toast.error('Failed to delete plan'),
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
@@ -79,6 +94,7 @@ export default function AdminBillingPage() {
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
setFormName('');
|
||||
setFormRuntime('nodejs');
|
||||
setFormDesc('');
|
||||
setFormCycle('monthly');
|
||||
setRules([emptyRule()]);
|
||||
@@ -87,6 +103,7 @@ export default function AdminBillingPage() {
|
||||
const startEdit = (plan: ServicePlan) => {
|
||||
setEditingId(plan.id);
|
||||
setFormName(plan.name);
|
||||
setFormRuntime(plan.runtime);
|
||||
setFormDesc(plan.description || '');
|
||||
setFormCycle(plan.billingCycle);
|
||||
setRules(
|
||||
@@ -100,12 +117,13 @@ export default function AdminBillingPage() {
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formName.trim()) return toast.error('نام پلن الزامی است');
|
||||
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('حداقل یک قاعده قیمتگذاری اضافه کنید');
|
||||
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) => ({
|
||||
@@ -124,18 +142,18 @@ export default function AdminBillingPage() {
|
||||
setRules(updated);
|
||||
};
|
||||
|
||||
const formatPrice = (n: number) => Number(n).toLocaleString('fa-IR');
|
||||
const formatPrice = (n: number) => Number(n).toLocaleString('en-US');
|
||||
|
||||
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>
|
||||
<h1 className="page-title flex items-center gap-2"><DollarSign className="w-6 h-6" /> Billing Plans</h1>
|
||||
<p className="page-subtitle">Define service plans and pricing for each application type</p>
|
||||
</div>
|
||||
{!showForm && (
|
||||
<button onClick={() => setShowForm(true)} className="btn-primary flex items-center gap-2">
|
||||
<Plus className="w-4 h-4" /> پلن جدید
|
||||
<Plus className="w-4 h-4" /> New Plan
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -143,34 +161,42 @@ export default function AdminBillingPage() {
|
||||
{/* Create / Edit Form */}
|
||||
{showForm && (
|
||||
<div className="card space-y-4">
|
||||
<h2 className="text-lg font-semibold">{editingId ? 'ویرایش پلن' : 'ایجاد پلن جدید'}</h2>
|
||||
<h2 className="text-lg font-semibold">{editingId ? 'Edit Plan' : 'Create New Plan'}</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 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)} />
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Application Type</label>
|
||||
<select className="input-field" value={formRuntime} onChange={(e) => setFormRuntime(e.target.value as AppRuntime)}>
|
||||
{runtimeOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">دوره پرداخت</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Plan Name</label>
|
||||
<input className="input-field" placeholder="e.g. Node.js Standard" value={formName} onChange={(e) => setFormName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Billing Cycle</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>
|
||||
<option value="hourly">Hourly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">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)} />
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Description (optional)</label>
|
||||
<input className="input-field" placeholder="Description of this plan" 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>
|
||||
<label className="text-sm font-semibold text-gray-700">Pricing Rules</label>
|
||||
<button onClick={addRule} className="btn-secondary text-xs flex items-center gap-1">
|
||||
<Plus className="w-3 h-3" /> افزودن
|
||||
<Plus className="w-3 h-3" /> Add Rule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -187,15 +213,15 @@ export default function AdminBillingPage() {
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
className="input-field w-32 text-sm"
|
||||
className="input-field w-36 text-sm"
|
||||
type="number"
|
||||
placeholder="قیمت (تومان)"
|
||||
placeholder="Price (Toman)"
|
||||
value={rule.unitPrice}
|
||||
onChange={(e) => updateRule(i, 'unitPrice', e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input-field flex-1 text-sm"
|
||||
placeholder="توضیح (اختیاری)"
|
||||
placeholder="Note (optional)"
|
||||
value={rule.description}
|
||||
onChange={(e) => updateRule(i, 'description', e.target.value)}
|
||||
/>
|
||||
@@ -210,9 +236,9 @@ export default function AdminBillingPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button onClick={resetForm} className="btn-ghost">انصراف</button>
|
||||
<button onClick={resetForm} className="btn-ghost">Cancel</button>
|
||||
<button onClick={handleSubmit} disabled={createMutation.isPending} className="btn-primary disabled:opacity-50">
|
||||
{createMutation.isPending ? 'در حال ذخیره...' : editingId ? 'بروزرسانی' : 'ایجاد پلن'}
|
||||
{createMutation.isPending ? 'Saving...' : editingId ? 'Update Plan' : 'Create Plan'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,9 +246,9 @@ export default function AdminBillingPage() {
|
||||
|
||||
{/* Plans List */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-gray-400">در حال بارگذاری...</div>
|
||||
<div className="text-center py-12 text-gray-400">Loading...</div>
|
||||
) : plans.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">هنوز پلنی ایجاد نشده</div>
|
||||
<div className="text-center py-12 text-gray-400">No plans created yet</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{plans.map((plan) => (
|
||||
@@ -238,7 +264,8 @@ export default function AdminBillingPage() {
|
||||
<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>
|
||||
<span className="badge badge-blue">{runtimeLabels[plan.runtime] || plan.runtime}</span>
|
||||
<span className="badge badge-purple">{cycleLabels[plan.billingCycle]}</span>
|
||||
{plan.description && <span>— {plan.description}</span>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -247,7 +274,7 @@ export default function AdminBillingPage() {
|
||||
<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 ? 'غیرفعال کردن' : 'فعال کردن'}
|
||||
title={plan.isActive ? 'Deactivate' : 'Activate'}
|
||||
>
|
||||
{plan.isActive ? <ToggleRight className="w-5 h-5" /> : <ToggleLeft className="w-5 h-5" />}
|
||||
</button>
|
||||
@@ -255,7 +282,7 @@ export default function AdminBillingPage() {
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (confirm('حذف این پلن؟')) deleteMutation.mutate(plan.id); }}
|
||||
onClick={() => { if (confirm('Delete this plan?')) deleteMutation.mutate(plan.id); }}
|
||||
className="p-1 text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
@@ -269,9 +296,9 @@ export default function AdminBillingPage() {
|
||||
<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>
|
||||
<th className="text-left pb-2">Resource</th>
|
||||
<th className="text-left pb-2">Unit Price (Toman)</th>
|
||||
<th className="text-left pb-2">Note</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -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, 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';
|
||||
import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic, CostBreakdown, BillingCycle } 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, Wallet, CreditCard } from 'lucide-react';
|
||||
|
||||
const steps = ['Basic Info', 'Versions & Database', 'Resources', 'Review'];
|
||||
|
||||
@@ -48,6 +48,9 @@ export default function DeployPage() {
|
||||
const [dbDumpFile, setDbDumpFile] = useState<File | null>(null);
|
||||
const dbDumpInputRef = useRef<HTMLInputElement>(null);
|
||||
const [dbUploadProgress, setDbUploadProgress] = useState(0);
|
||||
const [selectedCycle, setSelectedCycle] = useState<BillingCycle>('monthly');
|
||||
const [paymentMethod, setPaymentMethod] = useState<'wallet' | 'gateway'>('wallet');
|
||||
const [isPaid, setIsPaid] = useState(false);
|
||||
|
||||
const { data: clusters = [] } = useQuery<ClusterPublic[]>({
|
||||
queryKey: ['clusters-public'],
|
||||
@@ -75,6 +78,123 @@ export default function DeployPage() {
|
||||
enabled: step === 3,
|
||||
});
|
||||
|
||||
// Wallet balance for the review step payment
|
||||
const { data: walletData } = useQuery<{ balance: number }>({
|
||||
queryKey: ['wallet-balance'],
|
||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||
enabled: step === 3,
|
||||
});
|
||||
|
||||
const payAmount = costData ? costData[selectedCycle] : 0;
|
||||
const walletBalance = walletData?.balance ?? 0;
|
||||
const hasEnoughBalance = walletBalance >= payAmount;
|
||||
|
||||
const walletPayMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
// First create the app
|
||||
const payload = { ...form };
|
||||
if (payload.databaseType !== 'none' && payload.dbStorageSize) {
|
||||
payload.dbStorageSize = `${parseInt(payload.dbStorageSize, 10) || 1}Gi`;
|
||||
}
|
||||
const res = await api.post('/applications', payload);
|
||||
const appId = res.data.id;
|
||||
|
||||
// Upload source
|
||||
if (sourceMethod === 'upload' && zipFile) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', zipFile);
|
||||
await api.post(`/applications/${appId}/upload`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (e) => { if (e.total) setUploadProgress(Math.round((e.loaded * 100) / e.total)); },
|
||||
});
|
||||
}
|
||||
|
||||
// Upload DB dump
|
||||
if (form.databaseType !== 'none' && dbDumpFile) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', dbDumpFile);
|
||||
await api.post(`/applications/${appId}/db-upload`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (e) => { if (e.total) setDbUploadProgress(Math.round((e.loaded * 100) / e.total)); },
|
||||
});
|
||||
}
|
||||
|
||||
// Deduct from wallet
|
||||
await api.post(`/billing/wallet/pay/${appId}`, { amount: payAmount, cycle: selectedCycle });
|
||||
|
||||
return res;
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
toast.success('Payment successful! Deploying...');
|
||||
api.post(`/deployments/applications/${res.data.id}/deploy`).catch(() => {});
|
||||
router.push(`/dashboard/apps/${res.data.id}`);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.message || 'Payment or deployment failed');
|
||||
setUploadProgress(0);
|
||||
},
|
||||
});
|
||||
|
||||
const gatewayPayMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
// Initiate gateway
|
||||
const { data: gw } = await api.post('/billing/gateway/initiate', {
|
||||
amount: payAmount,
|
||||
description: `Deploy: ${form.name} (${selectedCycle})`,
|
||||
callbackUrl: `${window.location.origin}/dashboard/deploy`,
|
||||
});
|
||||
|
||||
// In production, redirect to gw.gatewayUrl
|
||||
// For now, auto-verify (simulated)
|
||||
await api.post('/billing/gateway/verify', {
|
||||
trackingCode: gw.trackingCode,
|
||||
amount: payAmount,
|
||||
});
|
||||
|
||||
// Now create the app
|
||||
const payload = { ...form };
|
||||
if (payload.databaseType !== 'none' && payload.dbStorageSize) {
|
||||
payload.dbStorageSize = `${parseInt(payload.dbStorageSize, 10) || 1}Gi`;
|
||||
}
|
||||
const res = await api.post('/applications', payload);
|
||||
const appId = res.data.id;
|
||||
|
||||
// Upload source
|
||||
if (sourceMethod === 'upload' && zipFile) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', zipFile);
|
||||
await api.post(`/applications/${appId}/upload`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (e) => { if (e.total) setUploadProgress(Math.round((e.loaded * 100) / e.total)); },
|
||||
});
|
||||
}
|
||||
|
||||
// Upload DB dump
|
||||
if (form.databaseType !== 'none' && dbDumpFile) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', dbDumpFile);
|
||||
await api.post(`/applications/${appId}/db-upload`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (e) => { if (e.total) setDbUploadProgress(Math.round((e.loaded * 100) / e.total)); },
|
||||
});
|
||||
}
|
||||
|
||||
// Deduct from the wallet (which was just charged by gateway)
|
||||
await api.post(`/billing/wallet/pay/${appId}`, { amount: payAmount, cycle: selectedCycle });
|
||||
|
||||
return res;
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
toast.success('Payment successful! Deploying...');
|
||||
api.post(`/deployments/applications/${res.data.id}/deploy`).catch(() => {});
|
||||
router.push(`/dashboard/apps/${res.data.id}`);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.message || 'Payment failed');
|
||||
setUploadProgress(0);
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: CreateApplicationDto) => {
|
||||
const res = await api.post('/applications', data);
|
||||
@@ -1116,45 +1236,93 @@ export default function DeployPage() {
|
||||
{/* 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" /> برآورد هزینه
|
||||
<DollarSign className="w-4 h-4 text-emerald-600" /> Cost Estimate
|
||||
</h3>
|
||||
{costLoading ? (
|
||||
<div className="text-sm text-gray-400 text-center py-3">در حال محاسبه...</div>
|
||||
) : costData ? (
|
||||
<div className="text-sm text-gray-400 text-center py-3">Calculating...</div>
|
||||
) : costData && costData.monthly > 0 ? (
|
||||
<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>
|
||||
{/* Billing cycle selector */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(['hourly', 'monthly', 'yearly'] as BillingCycle[]).map((cycle) => (
|
||||
<button
|
||||
key={cycle}
|
||||
type="button"
|
||||
onClick={() => setSelectedCycle(cycle)}
|
||||
className={`rounded-lg p-3 text-center transition-all ${
|
||||
selectedCycle === cycle
|
||||
? 'bg-white ring-2 ring-emerald-400 shadow-md'
|
||||
: 'bg-white/60 hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
<p className="text-xs text-gray-500">
|
||||
{cycle === 'hourly' ? 'Hourly' : cycle === 'monthly' ? 'Monthly' : 'Yearly'}
|
||||
</p>
|
||||
<p className="text-lg font-bold text-emerald-700">
|
||||
{Number(costData[cycle]).toLocaleString('en-US')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">Toman</p>
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
<p className="text-xs font-medium text-gray-500 mb-2">Breakdown</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>
|
||||
<span className="text-gray-900 font-medium">
|
||||
{Number(item[selectedCycle]).toLocaleString('en-US')} T/{selectedCycle === 'hourly' ? 'hr' : selectedCycle === 'monthly' ? 'mo' : 'yr'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-400 text-center py-3">هنوز پلنی تعریف نشده</div>
|
||||
<div className="text-sm text-gray-400 text-center py-3">No pricing plans defined yet</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payment Method */}
|
||||
{costData && costData.monthly > 0 && (
|
||||
<div className="bg-white rounded-xl p-5 border border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Payment Method</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentMethod('wallet')}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-colors ${
|
||||
paymentMethod === 'wallet' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Wallet className="w-5 h-5 text-primary-600" />
|
||||
<p className="mt-2 font-semibold text-sm text-gray-900">Pay from Wallet</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Balance: {Number(walletBalance).toLocaleString('en-US')} T
|
||||
{!hasEnoughBalance && <span className="text-red-500 block mt-0.5">Insufficient balance</span>}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentMethod('gateway')}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-colors ${
|
||||
paymentMethod === 'gateway' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<CreditCard className="w-5 h-5 text-emerald-600" />
|
||||
<p className="mt-2 font-semibold text-sm text-gray-900">Pay Now</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Online payment gateway</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-gray-50 rounded-lg flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Amount to pay ({selectedCycle})</span>
|
||||
<span className="text-lg font-bold text-gray-900">{Number(payAmount).toLocaleString('en-US')} Toman</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1177,14 +1345,29 @@ export default function DeployPage() {
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={createMutation.isPending}
|
||||
onClick={() => {
|
||||
if (!costData || costData.monthly === 0) {
|
||||
// No pricing — deploy directly
|
||||
handleSubmit();
|
||||
} else if (paymentMethod === 'wallet') {
|
||||
if (!hasEnoughBalance) {
|
||||
toast.error('Insufficient wallet balance. Please top up or use payment gateway.');
|
||||
return;
|
||||
}
|
||||
walletPayMutation.mutate();
|
||||
} else {
|
||||
gatewayPayMutation.mutate();
|
||||
}
|
||||
}}
|
||||
disabled={createMutation.isPending || walletPayMutation.isPending || gatewayPayMutation.isPending}
|
||||
className="btn-primary disabled:opacity-50"
|
||||
>
|
||||
{createMutation.isPending
|
||||
{(createMutation.isPending || walletPayMutation.isPending || gatewayPayMutation.isPending)
|
||||
? uploadProgress > 0 && uploadProgress < 100
|
||||
? `Uploading... ${uploadProgress}%`
|
||||
: 'Deploying...'
|
||||
: 'Processing...'
|
||||
: costData && costData.monthly > 0
|
||||
? <><CreditCard className="w-4 h-4 inline" /> Pay & Deploy</>
|
||||
: <><Rocket className="w-4 h-4 inline" /> Deploy Application</>}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -5,12 +5,12 @@ 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';
|
||||
import { Wallet, Plus, ArrowDownCircle, ArrowUpCircle, RotateCcw, Clock, CreditCard } from 'lucide-react';
|
||||
|
||||
const txTypeLabels: Record<TransactionType, string> = {
|
||||
charge: 'شارژ',
|
||||
deduction: 'کسر',
|
||||
refund: 'بازگشت',
|
||||
charge: 'Deposit',
|
||||
deduction: 'Payment',
|
||||
refund: 'Refund',
|
||||
};
|
||||
|
||||
const txTypeColors: Record<TransactionType, string> = {
|
||||
@@ -40,48 +40,81 @@ export default function WalletPage() {
|
||||
queryFn: () => api.get('/billing/wallet/transactions').then((r) => r.data),
|
||||
});
|
||||
|
||||
// Direct wallet charge (simulated — in production this would go through payment gateway)
|
||||
const chargeMutation = useMutation({
|
||||
mutationFn: (amount: number) =>
|
||||
api.post('/billing/wallet/charge', { amount, description: 'شارژ کیف پول' }),
|
||||
api.post('/billing/wallet/charge', { amount, description: 'Wallet top-up' }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
|
||||
toast.success('کیف پول شارژ شد');
|
||||
toast.success('Wallet charged successfully');
|
||||
setChargeAmount('');
|
||||
setShowCharge(false);
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'خطا در شارژ'),
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to charge wallet'),
|
||||
});
|
||||
|
||||
const handleCharge = () => {
|
||||
// Payment gateway charge
|
||||
const gatewayMutation = useMutation({
|
||||
mutationFn: async (amount: number) => {
|
||||
const { data } = await api.post('/billing/gateway/initiate', {
|
||||
amount,
|
||||
description: 'Wallet top-up via gateway',
|
||||
callbackUrl: `${window.location.origin}/dashboard/wallet`,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
// In production, redirect to data.gatewayUrl
|
||||
// For now, auto-verify (simulated)
|
||||
await api.post('/billing/gateway/verify', {
|
||||
trackingCode: data.trackingCode,
|
||||
amount: Number(chargeAmount),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
|
||||
toast.success('Payment successful — wallet charged');
|
||||
setChargeAmount('');
|
||||
setShowCharge(false);
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Payment failed'),
|
||||
});
|
||||
|
||||
const handleCharge = (method: 'wallet' | 'gateway') => {
|
||||
const amount = Number(chargeAmount);
|
||||
if (!amount || amount < 1000) {
|
||||
toast.error('حداقل مبلغ شارژ ۱,۰۰۰ تومان');
|
||||
toast.error('Minimum charge amount is 1,000 Toman');
|
||||
return;
|
||||
}
|
||||
if (method === 'wallet') {
|
||||
chargeMutation.mutate(amount);
|
||||
} else {
|
||||
gatewayMutation.mutate(amount);
|
||||
}
|
||||
};
|
||||
|
||||
const formatPrice = (n: number) => Number(n).toLocaleString('fa-IR');
|
||||
const formatDate = (d: string) => new Date(d).toLocaleDateString('fa-IR', {
|
||||
const formatPrice = (n: number) => Number(n).toLocaleString('en-US');
|
||||
const formatDate = (d: string) => new Date(d).toLocaleDateString('en-US', {
|
||||
year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
|
||||
const isPending = chargeMutation.isPending || gatewayMutation.isPending;
|
||||
|
||||
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>
|
||||
<h1 className="page-title flex items-center gap-2"><Wallet className="w-6 h-6" /> Wallet</h1>
|
||||
<p className="page-subtitle">Manage your balance and transactions</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-sm opacity-80">Current Balance</p>
|
||||
<p className="text-3xl font-bold mt-1">
|
||||
{walletLoading ? '...' : `${formatPrice(walletData?.balance ?? 0)}`}
|
||||
<span className="text-lg font-normal mr-2">تومان</span>
|
||||
{walletLoading ? '...' : formatPrice(walletData?.balance ?? 0)}
|
||||
<span className="text-lg font-normal ml-2">Toman</span>
|
||||
</p>
|
||||
</div>
|
||||
{!showCharge && (
|
||||
@@ -89,39 +122,39 @@ export default function WalletPage() {
|
||||
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" /> شارژ کیف پول
|
||||
<Plus className="w-4 h-4" /> Top Up
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCharge && (
|
||||
<div className="mt-4 pt-4 border-t border-white/20 flex items-center gap-3">
|
||||
<div className="mt-4 pt-4 border-t border-white/20">
|
||||
<div className="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="مبلغ (تومان) — حداقل ۱,۰۰۰"
|
||||
placeholder="Amount (Toman) — min 1,000"
|
||||
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"
|
||||
onClick={() => handleCharge('gateway')}
|
||||
disabled={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 flex items-center gap-2"
|
||||
>
|
||||
{chargeMutation.isPending ? '...' : 'پرداخت'}
|
||||
<CreditCard className="w-4 h-4" />
|
||||
{gatewayMutation.isPending ? '...' : 'Pay Now'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCharge(false)}
|
||||
className="px-3 py-2.5 bg-white/10 hover:bg-white/20 rounded-xl text-sm transition-colors"
|
||||
>
|
||||
انصراف
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick charge amounts */}
|
||||
{showCharge && (
|
||||
<div className="flex gap-2 mt-3">
|
||||
{[10000, 50000, 100000, 500000].map((amt) => (
|
||||
<button
|
||||
@@ -129,23 +162,24 @@ export default function WalletPage() {
|
||||
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')} ت
|
||||
{amt.toLocaleString('en-US')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</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" /> تاریخچه تراکنشها
|
||||
<Clock className="w-5 h-5 text-gray-400" /> Transaction History
|
||||
</h2>
|
||||
|
||||
{txLoading ? (
|
||||
<div className="text-center py-8 text-gray-400">در حال بارگذاری...</div>
|
||||
<div className="text-center py-8 text-gray-400">Loading...</div>
|
||||
) : transactions.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400">هنوز تراکنشی ثبت نشده</div>
|
||||
<div className="text-center py-8 text-gray-400">No transactions yet</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{transactions.map((tx) => (
|
||||
@@ -162,11 +196,11 @@ export default function WalletPage() {
|
||||
<p className="text-xs text-gray-400">{formatDate(tx.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="text-right">
|
||||
<p className={`text-sm font-bold ${txTypeColors[tx.type]}`}>
|
||||
{tx.type === 'deduction' ? '−' : '+'}{formatPrice(tx.amount)} ت
|
||||
{tx.type === 'deduction' ? '−' : '+'}{formatPrice(tx.amount)} T
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">مانده: {formatPrice(tx.balanceAfter)} ت</p>
|
||||
<p className="text-xs text-gray-400">Balance: {formatPrice(tx.balanceAfter)} T</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -257,6 +257,7 @@ export interface PricingRule {
|
||||
export interface ServicePlan {
|
||||
id: string;
|
||||
name: string;
|
||||
runtime: 'nodejs' | 'laravel' | 'wordpress';
|
||||
description?: string;
|
||||
billingCycle: BillingCycle;
|
||||
isActive: boolean;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user