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:
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { BillingService } from './billing.service';
|
||||
import {
|
||||
CreateServicePlanDto,
|
||||
UpdateServicePlanDto,
|
||||
ChargeWalletDto,
|
||||
CalculateCostDto,
|
||||
} from './dto/billing.dto';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { UserRole } from '../common/enums';
|
||||
|
||||
@ApiTags('Billing')
|
||||
@ApiBearerAuth()
|
||||
@Controller('billing')
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
export class BillingController {
|
||||
constructor(private readonly billingService: BillingService) {}
|
||||
|
||||
// ─── Service Plans (Admin) ────────────────────────────────────────
|
||||
|
||||
@Post('plans')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Create a new service plan (Admin)' })
|
||||
async createPlan(@Body() dto: CreateServicePlanDto) {
|
||||
return this.billingService.createPlan(dto);
|
||||
}
|
||||
|
||||
@Get('plans')
|
||||
@ApiOperation({ summary: 'List all service plans' })
|
||||
async findAllPlans(@Request() req: any) {
|
||||
if (req.user.role === UserRole.ADMIN) {
|
||||
return this.billingService.findAllPlans();
|
||||
}
|
||||
return this.billingService.findActivePlans();
|
||||
}
|
||||
|
||||
@Get('plans/:id')
|
||||
@ApiOperation({ summary: 'Get service plan details' })
|
||||
async findPlan(@Param('id') id: string) {
|
||||
return this.billingService.findPlan(id);
|
||||
}
|
||||
|
||||
@Patch('plans/:id')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Update a service plan (Admin)' })
|
||||
async updatePlan(@Param('id') id: string, @Body() dto: UpdateServicePlanDto) {
|
||||
return this.billingService.updatePlan(id, dto);
|
||||
}
|
||||
|
||||
@Delete('plans/:id')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Delete a service plan (Admin)' })
|
||||
async deletePlan(@Param('id') id: string) {
|
||||
await this.billingService.deletePlan(id);
|
||||
return { message: 'Plan deleted' };
|
||||
}
|
||||
|
||||
// ─── Cost Calculation ─────────────────────────────────────────────
|
||||
|
||||
@Post('calculate')
|
||||
@ApiOperation({ summary: 'Calculate cost for an application configuration' })
|
||||
async calculateCost(@Body() dto: CalculateCostDto) {
|
||||
return this.billingService.calculateCost(dto);
|
||||
}
|
||||
|
||||
// ─── Wallet (User) ───────────────────────────────────────────────
|
||||
|
||||
@Get('wallet')
|
||||
@ApiOperation({ summary: 'Get my wallet balance' })
|
||||
async getBalance(@Request() req: any) {
|
||||
return this.billingService.getBalance(req.user.id);
|
||||
}
|
||||
|
||||
@Post('wallet/charge')
|
||||
@ApiOperation({ summary: 'Charge my wallet (self top-up)' })
|
||||
async chargeMyWallet(@Request() req: any, @Body() dto: ChargeWalletDto) {
|
||||
return this.billingService.chargeWallet(req.user.id, dto.amount, dto.description || 'Self top-up');
|
||||
}
|
||||
|
||||
@Get('wallet/transactions')
|
||||
@ApiOperation({ summary: 'Get my wallet transactions' })
|
||||
async getTransactions(@Request() req: any, @Query('limit') limit?: string) {
|
||||
return this.billingService.getTransactions(req.user.id, limit ? parseInt(limit, 10) : 50);
|
||||
}
|
||||
|
||||
@Post('wallet/pay/:applicationId')
|
||||
@ApiOperation({ summary: 'Pay for an application from wallet' })
|
||||
async payForApplication(
|
||||
@Request() req: any,
|
||||
@Param('applicationId') applicationId: string,
|
||||
@Body() body: { amount: number; cycle: string },
|
||||
) {
|
||||
return this.billingService.deductWallet(
|
||||
req.user.id,
|
||||
body.amount,
|
||||
`Payment for application (${body.cycle})`,
|
||||
applicationId,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Wallet Admin ─────────────────────────────────────────────────
|
||||
|
||||
@Get('admin/wallets')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'List all wallets (Admin)' })
|
||||
async getAllWallets() {
|
||||
return this.billingService.getAllWallets();
|
||||
}
|
||||
|
||||
@Post('admin/wallets/:userId/charge')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Charge a user\'s wallet (Admin)' })
|
||||
async adminChargeWallet(
|
||||
@Param('userId') userId: string,
|
||||
@Body() dto: ChargeWalletDto,
|
||||
) {
|
||||
return this.billingService.adminChargeWallet(userId, dto.amount, dto.description);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BillingService } from './billing.service';
|
||||
import { BillingController } from './billing.controller';
|
||||
import { ServicePlan } from './entities/service-plan.entity';
|
||||
import { PricingRule } from './entities/pricing-rule.entity';
|
||||
import { Wallet } from './entities/wallet.entity';
|
||||
import { WalletTransaction } from './entities/wallet-transaction.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ServicePlan, PricingRule, Wallet, WalletTransaction]),
|
||||
],
|
||||
controllers: [BillingController],
|
||||
providers: [BillingService],
|
||||
exports: [BillingService],
|
||||
})
|
||||
export class BillingModule {}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ServicePlan } from './entities/service-plan.entity';
|
||||
import { PricingRule } from './entities/pricing-rule.entity';
|
||||
import { Wallet } from './entities/wallet.entity';
|
||||
import { WalletTransaction } from './entities/wallet-transaction.entity';
|
||||
import { TransactionType, BillingCycle, PricingResourceType } from '../common/enums';
|
||||
import {
|
||||
CreateServicePlanDto,
|
||||
UpdateServicePlanDto,
|
||||
CalculateCostDto,
|
||||
} from './dto/billing.dto';
|
||||
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
private readonly logger = new Logger(BillingService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ServicePlan) private planRepo: Repository<ServicePlan>,
|
||||
@InjectRepository(PricingRule) private ruleRepo: Repository<PricingRule>,
|
||||
@InjectRepository(Wallet) private walletRepo: Repository<Wallet>,
|
||||
@InjectRepository(WalletTransaction) private txRepo: Repository<WalletTransaction>,
|
||||
) {}
|
||||
|
||||
// ─── Service Plans ────────────────────────────────────────────────
|
||||
|
||||
async createPlan(dto: CreateServicePlanDto): Promise<ServicePlan> {
|
||||
const plan = this.planRepo.create({
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
billingCycle: dto.billingCycle,
|
||||
});
|
||||
const saved = await this.planRepo.save(plan);
|
||||
|
||||
// Create pricing rules
|
||||
const rules = dto.pricingRules.map((r) =>
|
||||
this.ruleRepo.create({ ...r, planId: saved.id }),
|
||||
);
|
||||
await this.ruleRepo.save(rules);
|
||||
|
||||
return this.planRepo.findOne({ where: { id: saved.id }, relations: ['pricingRules'] }) as Promise<ServicePlan>;
|
||||
}
|
||||
|
||||
async updatePlan(id: string, dto: UpdateServicePlanDto): Promise<ServicePlan> {
|
||||
const plan = await this.planRepo.findOne({ where: { id }, relations: ['pricingRules'] });
|
||||
if (!plan) throw new NotFoundException('Plan not found');
|
||||
|
||||
if (dto.name !== undefined) plan.name = dto.name;
|
||||
if (dto.description !== undefined) plan.description = dto.description;
|
||||
if (dto.billingCycle !== undefined) plan.billingCycle = dto.billingCycle;
|
||||
if (dto.isActive !== undefined) plan.isActive = dto.isActive;
|
||||
|
||||
await this.planRepo.save(plan);
|
||||
|
||||
// If pricing rules are provided, replace them
|
||||
if (dto.pricingRules) {
|
||||
await this.ruleRepo.delete({ planId: id });
|
||||
const rules = dto.pricingRules.map((r) =>
|
||||
this.ruleRepo.create({ ...r, planId: id }),
|
||||
);
|
||||
await this.ruleRepo.save(rules);
|
||||
}
|
||||
|
||||
return this.planRepo.findOne({ where: { id }, relations: ['pricingRules'] }) as Promise<ServicePlan>;
|
||||
}
|
||||
|
||||
async deletePlan(id: string): Promise<void> {
|
||||
const plan = await this.planRepo.findOne({ where: { id } });
|
||||
if (!plan) throw new NotFoundException('Plan not found');
|
||||
await this.planRepo.remove(plan);
|
||||
}
|
||||
|
||||
async findAllPlans(): Promise<ServicePlan[]> {
|
||||
return this.planRepo.find({ relations: ['pricingRules'], order: { createdAt: 'DESC' } });
|
||||
}
|
||||
|
||||
async findActivePlans(): Promise<ServicePlan[]> {
|
||||
return this.planRepo.find({
|
||||
where: { isActive: true },
|
||||
relations: ['pricingRules'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findPlan(id: string): Promise<ServicePlan> {
|
||||
const plan = await this.planRepo.findOne({ where: { id }, relations: ['pricingRules'] });
|
||||
if (!plan) throw new NotFoundException('Plan not found');
|
||||
return plan;
|
||||
}
|
||||
|
||||
// ─── Cost Calculation ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Calculate cost for an application config against all active plans.
|
||||
* Returns breakdown for each billing cycle (hourly, monthly, yearly).
|
||||
*/
|
||||
async calculateCost(dto: CalculateCostDto): Promise<{
|
||||
hourly: number;
|
||||
monthly: number;
|
||||
yearly: number;
|
||||
breakdown: { label: string; hourly: number; monthly: number; yearly: number }[];
|
||||
}> {
|
||||
const plans = await this.findActivePlans();
|
||||
if (plans.length === 0) {
|
||||
return { hourly: 0, monthly: 0, yearly: 0, breakdown: [] };
|
||||
}
|
||||
|
||||
// Gather all active pricing rules across all plans, grouped by cycle
|
||||
const hourlyRules: PricingRule[] = [];
|
||||
const monthlyRules: PricingRule[] = [];
|
||||
const yearlyRules: PricingRule[] = [];
|
||||
|
||||
for (const plan of plans) {
|
||||
switch (plan.billingCycle) {
|
||||
case BillingCycle.HOURLY: hourlyRules.push(...plan.pricingRules); break;
|
||||
case BillingCycle.MONTHLY: monthlyRules.push(...plan.pricingRules); break;
|
||||
case BillingCycle.YEARLY: yearlyRules.push(...plan.pricingRules); break;
|
||||
}
|
||||
}
|
||||
|
||||
// Use the first available set of rules, or compute cross-conversions
|
||||
const rules = hourlyRules.length > 0 ? hourlyRules : monthlyRules.length > 0 ? monthlyRules : yearlyRules;
|
||||
const baseCycle = hourlyRules.length > 0 ? 'hourly' : monthlyRules.length > 0 ? 'monthly' : 'yearly';
|
||||
|
||||
// Parse resource values
|
||||
const cpuCores = this.parseCpuToCores(dto.cpuLimit);
|
||||
const memoryGb = this.parseMemoryToGb(dto.memoryLimit);
|
||||
const storageGb = dto.dbStorageSize ? parseFloat(dto.dbStorageSize.replace('Gi', '')) || 0 : 0;
|
||||
const hasDatabase = dto.databaseType !== 'none';
|
||||
const replicas = dto.replicas || 1;
|
||||
|
||||
const breakdown: { label: string; hourly: number; monthly: number; yearly: number }[] = [];
|
||||
let totalBase = 0;
|
||||
|
||||
for (const rule of rules) {
|
||||
let cost = 0;
|
||||
let label = '';
|
||||
|
||||
switch (rule.resourceType) {
|
||||
case PricingResourceType.BASE_FEE:
|
||||
cost = Number(rule.unitPrice);
|
||||
label = 'هزینه پایه';
|
||||
break;
|
||||
case PricingResourceType.CPU_PER_CORE:
|
||||
cost = cpuCores * replicas * Number(rule.unitPrice);
|
||||
label = `CPU (${(cpuCores * replicas).toFixed(2)} core)`;
|
||||
break;
|
||||
case PricingResourceType.MEMORY_PER_GB:
|
||||
cost = memoryGb * replicas * Number(rule.unitPrice);
|
||||
label = `Memory (${(memoryGb * replicas).toFixed(2)} GB)`;
|
||||
break;
|
||||
case PricingResourceType.STORAGE_PER_GB:
|
||||
cost = storageGb * Number(rule.unitPrice);
|
||||
label = `Storage (${storageGb} GB)`;
|
||||
break;
|
||||
case PricingResourceType.DATABASE_ADDON:
|
||||
cost = hasDatabase ? Number(rule.unitPrice) : 0;
|
||||
label = 'Database addon';
|
||||
break;
|
||||
}
|
||||
|
||||
if (cost > 0) {
|
||||
const hourly = baseCycle === 'hourly' ? cost : baseCycle === 'monthly' ? cost / 720 : cost / 8640;
|
||||
const monthly = baseCycle === 'monthly' ? cost : baseCycle === 'hourly' ? cost * 720 : cost / 12;
|
||||
const yearly = baseCycle === 'yearly' ? cost : baseCycle === 'monthly' ? cost * 12 : cost * 8640;
|
||||
|
||||
breakdown.push({ label, hourly: Math.round(hourly), monthly: Math.round(monthly), yearly: Math.round(yearly) });
|
||||
totalBase += cost;
|
||||
}
|
||||
}
|
||||
|
||||
const hourlyTotal = baseCycle === 'hourly' ? totalBase : baseCycle === 'monthly' ? totalBase / 720 : totalBase / 8640;
|
||||
const monthlyTotal = baseCycle === 'monthly' ? totalBase : baseCycle === 'hourly' ? totalBase * 720 : totalBase / 12;
|
||||
const yearlyTotal = baseCycle === 'yearly' ? totalBase : baseCycle === 'monthly' ? totalBase * 12 : totalBase * 8640;
|
||||
|
||||
return {
|
||||
hourly: Math.round(hourlyTotal),
|
||||
monthly: Math.round(monthlyTotal),
|
||||
yearly: Math.round(yearlyTotal),
|
||||
breakdown,
|
||||
};
|
||||
}
|
||||
|
||||
private parseCpuToCores(cpu: string): number {
|
||||
if (!cpu) return 0;
|
||||
if (cpu.endsWith('m')) return parseFloat(cpu) / 1000;
|
||||
return parseFloat(cpu) || 0;
|
||||
}
|
||||
|
||||
private parseMemoryToGb(memory: string): number {
|
||||
if (!memory) return 0;
|
||||
if (memory.endsWith('Gi')) return parseFloat(memory);
|
||||
if (memory.endsWith('Mi')) return parseFloat(memory) / 1024;
|
||||
if (memory.endsWith('Ki')) return parseFloat(memory) / (1024 * 1024);
|
||||
return parseFloat(memory) / (1024 * 1024 * 1024);
|
||||
}
|
||||
|
||||
// ─── Wallet ───────────────────────────────────────────────────────
|
||||
|
||||
async getOrCreateWallet(userId: string): Promise<Wallet> {
|
||||
let wallet = await this.walletRepo.findOne({ where: { userId } });
|
||||
if (!wallet) {
|
||||
wallet = this.walletRepo.create({ userId, balance: 0 });
|
||||
wallet = await this.walletRepo.save(wallet);
|
||||
this.logger.log(`Created wallet for user ${userId}`);
|
||||
}
|
||||
return wallet;
|
||||
}
|
||||
|
||||
async getBalance(userId: string): Promise<{ balance: number }> {
|
||||
const wallet = await this.getOrCreateWallet(userId);
|
||||
return { balance: Number(wallet.balance) };
|
||||
}
|
||||
|
||||
async chargeWallet(userId: string, amount: number, description?: string): Promise<WalletTransaction> {
|
||||
if (amount <= 0) throw new BadRequestException('Amount must be positive');
|
||||
|
||||
const wallet = await this.getOrCreateWallet(userId);
|
||||
wallet.balance = Number(wallet.balance) + amount;
|
||||
await this.walletRepo.save(wallet);
|
||||
|
||||
const tx = this.txRepo.create({
|
||||
walletId: wallet.id,
|
||||
type: TransactionType.CHARGE,
|
||||
amount,
|
||||
balanceAfter: wallet.balance,
|
||||
description: description || 'Wallet charge',
|
||||
});
|
||||
const saved = await this.txRepo.save(tx);
|
||||
|
||||
this.logger.log(`Charged wallet of user ${userId}: +${amount} Toman → balance: ${wallet.balance}`);
|
||||
return saved;
|
||||
}
|
||||
|
||||
async deductWallet(
|
||||
userId: string,
|
||||
amount: number,
|
||||
description?: string,
|
||||
applicationId?: string,
|
||||
): Promise<WalletTransaction> {
|
||||
if (amount <= 0) throw new BadRequestException('Amount must be positive');
|
||||
|
||||
const wallet = await this.getOrCreateWallet(userId);
|
||||
if (Number(wallet.balance) < amount) {
|
||||
throw new BadRequestException('Insufficient wallet balance');
|
||||
}
|
||||
|
||||
wallet.balance = Number(wallet.balance) - amount;
|
||||
await this.walletRepo.save(wallet);
|
||||
|
||||
const tx = this.txRepo.create({
|
||||
walletId: wallet.id,
|
||||
type: TransactionType.DEDUCTION,
|
||||
amount,
|
||||
balanceAfter: wallet.balance,
|
||||
description: description || 'Service payment',
|
||||
applicationId,
|
||||
});
|
||||
const saved = await this.txRepo.save(tx);
|
||||
|
||||
this.logger.log(`Deducted from wallet of user ${userId}: -${amount} Toman → balance: ${wallet.balance}`);
|
||||
return saved;
|
||||
}
|
||||
|
||||
async getTransactions(userId: string, limit = 50): Promise<WalletTransaction[]> {
|
||||
const wallet = await this.getOrCreateWallet(userId);
|
||||
return this.txRepo.find({
|
||||
where: { walletId: wallet.id },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
// Admin: charge any user's wallet
|
||||
async adminChargeWallet(userId: string, amount: number, description?: string): Promise<WalletTransaction> {
|
||||
return this.chargeWallet(userId, amount, description || 'Admin charge');
|
||||
}
|
||||
|
||||
// Admin: get all wallets
|
||||
async getAllWallets(): Promise<Wallet[]> {
|
||||
return this.walletRepo.find({ relations: ['user'], order: { balance: 'DESC' } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
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';
|
||||
|
||||
export class CreatePricingRuleDto {
|
||||
@ApiProperty({ enum: PricingResourceType })
|
||||
@IsEnum(PricingResourceType)
|
||||
resourceType: PricingResourceType;
|
||||
|
||||
@ApiProperty({ example: 5000, description: 'Unit price in Toman' })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
unitPrice: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 'CPU per core per hour' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class CreateServicePlanDto {
|
||||
@ApiProperty({ example: 'Node.js Basic' })
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Basic plan for Node.js applications' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ enum: BillingCycle })
|
||||
@IsEnum(BillingCycle)
|
||||
billingCycle: BillingCycle;
|
||||
|
||||
@ApiProperty({ type: [CreatePricingRuleDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreatePricingRuleDto)
|
||||
pricingRules: CreatePricingRuleDto[];
|
||||
}
|
||||
|
||||
export class UpdateServicePlanDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: BillingCycle })
|
||||
@IsOptional()
|
||||
@IsEnum(BillingCycle)
|
||||
billingCycle?: BillingCycle;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ type: [CreatePricingRuleDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreatePricingRuleDto)
|
||||
pricingRules?: CreatePricingRuleDto[];
|
||||
}
|
||||
|
||||
export class ChargeWalletDto {
|
||||
@ApiProperty({ example: 50000, description: 'Amount to charge in Toman' })
|
||||
@IsNumber()
|
||||
@Min(1000)
|
||||
amount: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Manual top-up' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class CalculateCostDto {
|
||||
@ApiProperty({ example: 'nodejs' })
|
||||
@IsString()
|
||||
runtime: string;
|
||||
|
||||
@ApiProperty({ example: 'postgresql' })
|
||||
@IsString()
|
||||
databaseType: string;
|
||||
|
||||
@ApiProperty({ example: '500m', description: 'CPU limit' })
|
||||
@IsString()
|
||||
cpuLimit: string;
|
||||
|
||||
@ApiProperty({ example: '512Mi', description: 'Memory limit' })
|
||||
@IsString()
|
||||
memoryLimit: string;
|
||||
|
||||
@ApiProperty({ example: 1, description: 'Number of replicas' })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
replicas: number;
|
||||
|
||||
@ApiProperty({ example: '1Gi', description: 'Database storage size' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dbStorageSize?: string;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
CreateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { PricingResourceType } from '../../common/enums';
|
||||
import { ServicePlan } from './service-plan.entity';
|
||||
|
||||
@Entity('pricing_rules')
|
||||
export class PricingRule {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'enum', enum: PricingResourceType })
|
||||
resourceType: PricingResourceType;
|
||||
|
||||
@Column({ type: 'decimal', precision: 12, scale: 2 })
|
||||
unitPrice: number; // Price per unit (Toman)
|
||||
|
||||
@Column({ nullable: true })
|
||||
description: string; // e.g. "CPU per core per hour"
|
||||
|
||||
@ManyToOne(() => ServicePlan, (plan: ServicePlan) => plan.pricingRules, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'planId' })
|
||||
plan: ServicePlan;
|
||||
|
||||
@Column()
|
||||
planId: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { BillingCycle } from '../../common/enums';
|
||||
import { PricingRule } from './pricing-rule.entity';
|
||||
|
||||
@Entity('service_plans')
|
||||
export class ServicePlan {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
name: string; // e.g. "Node.js Basic", "WordPress Pro"
|
||||
|
||||
@Column({ nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({ type: 'enum', enum: BillingCycle })
|
||||
billingCycle: BillingCycle;
|
||||
|
||||
@Column({ default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@OneToMany(() => PricingRule, (rule) => rule.plan, { cascade: true, eager: true })
|
||||
pricingRules: PricingRule[];
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
CreateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { TransactionType } from '../../common/enums';
|
||||
import { Wallet } from './wallet.entity';
|
||||
|
||||
@Entity('wallet_transactions')
|
||||
export class WalletTransaction {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'enum', enum: TransactionType })
|
||||
type: TransactionType;
|
||||
|
||||
@Column({ type: 'decimal', precision: 14, scale: 2 })
|
||||
amount: number; // Amount in Toman (positive)
|
||||
|
||||
@Column({ type: 'decimal', precision: 14, scale: 2 })
|
||||
balanceAfter: number; // Balance after this transaction
|
||||
|
||||
@Column({ nullable: true })
|
||||
description: string; // e.g. "Charge via admin", "Payment for app: my-app (monthly)"
|
||||
|
||||
@Column({ nullable: true })
|
||||
applicationId: string; // Linked application (for deductions)
|
||||
|
||||
@ManyToOne(() => Wallet, (wallet: Wallet) => wallet.transactions, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'walletId' })
|
||||
wallet: Wallet;
|
||||
|
||||
@Column()
|
||||
walletId: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToOne,
|
||||
JoinColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { WalletTransaction } from './wallet-transaction.entity';
|
||||
|
||||
@Entity('wallets')
|
||||
export class Wallet {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'decimal', precision: 14, scale: 2, default: 0 })
|
||||
balance: number; // Balance in Toman
|
||||
|
||||
@OneToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@Column({ unique: true })
|
||||
userId: string;
|
||||
|
||||
@OneToMany(() => WalletTransaction, (tx: WalletTransaction) => tx.wallet)
|
||||
transactions: WalletTransaction[];
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
Reference in New Issue
Block a user