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, @InjectRepository(PricingRule) private ruleRepo: Repository, @InjectRepository(Wallet) private walletRepo: Repository, @InjectRepository(WalletTransaction) private txRepo: Repository, ) {} // ─── Service Plans ──────────────────────────────────────────────── async createPlan(dto: CreateServicePlanDto): Promise { const plan = this.planRepo.create({ name: dto.name, runtime: dto.runtime, 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; } async updatePlan(id: string, dto: UpdateServicePlanDto): Promise { 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.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; 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; } async deletePlan(id: string): Promise { const plan = await this.planRepo.findOne({ where: { id } }); if (!plan) throw new NotFoundException('Plan not found'); await this.planRepo.remove(plan); } async findAllPlans(): Promise { return this.planRepo.find({ relations: ['pricingRules'], order: { createdAt: 'DESC' } }); } async findActivePlans(): Promise { return this.planRepo.find({ where: { isActive: true }, relations: ['pricingRules'], order: { createdAt: 'DESC' }, }); } async findPlan(id: string): Promise { 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 }[]; }> { // 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: [] }; } // 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 = 'Base fee'; 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 { 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 { 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 { 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 { 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 { return this.chargeWallet(userId, amount, description || 'Admin charge'); } // Admin: get all wallets async getAllWallets(): Promise { return this.walletRepo.find({ relations: ['user'], order: { balance: 'DESC' } }); } }