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:
keyhan
2026-04-07 02:05:58 +03:30
parent 4974f88e8c
commit c2c6a32ae8
9 changed files with 441 additions and 138 deletions
+39
View File
@@ -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')
+9 -2
View File
@@ -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);
+12 -3
View File
@@ -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;