From c2c6a32ae8a8c96bb63066b4830010b54ad30d3f Mon Sep 17 00:00:00 2001 From: keyhan Date: Tue, 7 Apr 2026 02:05:58 +0330 Subject: [PATCH] =?UTF-8?q?feat:=20billing=20v2=20=E2=80=94=20runtime-base?= =?UTF-8?q?d=20plans,=20English=20UI,=20payment=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/src/billing/billing.controller.ts | 39 +++ backend/src/billing/billing.service.ts | 11 +- backend/src/billing/dto/billing.dto.ts | 15 +- .../billing/entities/service-plan.entity.ts | 7 +- .../src/app/dashboard/admin/billing/page.tsx | 113 ++++---- frontend/src/app/dashboard/deploy/page.tsx | 241 +++++++++++++++--- frontend/src/app/dashboard/wallet/page.tsx | 150 ++++++----- frontend/src/types/index.ts | 1 + frontend/tsconfig.tsbuildinfo | 2 +- 9 files changed, 441 insertions(+), 138 deletions(-) diff --git a/backend/src/billing/billing.controller.ts b/backend/src/billing/billing.controller.ts index a6fd581..b713308 100644 --- a/backend/src/billing/billing.controller.ts +++ b/backend/src/billing/billing.controller.ts @@ -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') diff --git a/backend/src/billing/billing.service.ts b/backend/src/billing/billing.service.ts index ae78f0a..dcaf5e8 100644 --- a/backend/src/billing/billing.service.ts +++ b/backend/src/billing/billing.service.ts @@ -28,6 +28,7 @@ export class BillingService { async createPlan(dto: CreateServicePlanDto): Promise { 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); diff --git a/backend/src/billing/dto/billing.dto.ts b/backend/src/billing/dto/billing.dto.ts index e7682b7..84ea51f 100644 --- a/backend/src/billing/dto/billing.dto.ts +++ b/backend/src/billing/dto/billing.dto.ts @@ -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() diff --git a/backend/src/billing/entities/service-plan.entity.ts b/backend/src/billing/entities/service-plan.entity.ts index 6040037..b1a65cc 100644 --- a/backend/src/billing/entities/service-plan.entity.ts +++ b/backend/src/billing/entities/service-plan.entity.ts @@ -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; diff --git a/frontend/src/app/dashboard/admin/billing/page.tsx b/frontend/src/app/dashboard/admin/billing/page.tsx index 27c9e83..c3ec30a 100644 --- a/frontend/src/app/dashboard/admin/billing/page.tsx +++ b/frontend/src/app/dashboard/admin/billing/page.tsx @@ -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 = { + nodejs: 'Node.js', + laravel: 'Laravel', + wordpress: 'WordPress', +}; + const cycleLabels: Record = { - hourly: 'ساعتی', - monthly: 'ماهانه', - yearly: 'سالانه', + hourly: 'Hourly', + monthly: 'Monthly', + yearly: 'Yearly', }; const resourceLabels: Record = { - 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(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()]); @@ -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 (
-

مدیریت پلن‌ها و قیمت‌گذاری

-

تعریف سرویس‌ها و هزینه‌ها برای هر نوع اپلیکیشن

+

Billing Plans

+

Define service plans and pricing for each application type

{!showForm && ( )}
@@ -143,34 +161,42 @@ export default function AdminBillingPage() { {/* Create / Edit Form */} {showForm && (
-

{editingId ? 'ویرایش پلن' : 'ایجاد پلن جدید'}

+

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

-
+
- - setFormName(e.target.value)} /> + +
- + + setFormName(e.target.value)} /> +
+
+
- - setFormDesc(e.target.value)} /> + + setFormDesc(e.target.value)} />
{/* Pricing Rules */}
- +
@@ -187,15 +213,15 @@ export default function AdminBillingPage() { ))} updateRule(i, 'unitPrice', e.target.value)} /> updateRule(i, 'description', e.target.value)} /> @@ -210,9 +236,9 @@ export default function AdminBillingPage() {
- +
@@ -220,9 +246,9 @@ export default function AdminBillingPage() { {/* Plans List */} {isLoading ? ( -
در حال بارگذاری...
+
Loading...
) : plans.length === 0 ? ( -
هنوز پلنی ایجاد نشده
+
No plans created yet
) : (
{plans.map((plan) => ( @@ -238,7 +264,8 @@ export default function AdminBillingPage() {

{plan.name}

- {cycleLabels[plan.billingCycle]} + {runtimeLabels[plan.runtime] || plan.runtime} + {cycleLabels[plan.billingCycle]} {plan.description && — {plan.description}}
@@ -247,7 +274,7 @@ export default function AdminBillingPage() { @@ -255,7 +282,7 @@ export default function AdminBillingPage() {