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;
|
||||
|
||||
Reference in New Issue
Block a user