import { Controller, Get, Post, Patch, Delete, Body, Param, Query, UseGuards, Request, Res, BadRequestException, Inject, forwardRef, ForbiddenException, } from '@nestjs/common'; import { Response } from 'express'; import { AuthGuard } from '@nestjs/passport'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { BillingService } from './billing.service'; import { AppLifecycleService } from '../lifecycle/app-lifecycle.service'; import { ApplicationsService } from '../applications/applications.service'; import { KubernetesService } from '../kubernetes/kubernetes.service'; import { ChargeWalletDto, CalculateCostDto, CalculateDeployCostDto, SetOptionalServicesPricingDto, RenewApplicationDto, UpgradeResourcesDto, CalculateUpgradeCostDto, InitiateInvoicePaymentDto, VerifyInvoiceGatewayDto, UpdateInvoiceStatusDto, } from './dto/billing.dto'; import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto'; import { RolesGuard } from '../common/guards/roles.guard'; import { Roles } from '../common/decorators/roles.decorator'; import { UserRole, BillingCycle, AppLifecycleStatus, InvoiceReason, InvoiceStatus, PaymentMethod, ProductType, DatabaseType, } from '../common/enums'; import { Application } from '../applications/entities/application.entity'; @ApiTags('Billing') @ApiBearerAuth() @Controller('billing') @UseGuards(AuthGuard('jwt'), RolesGuard) export class BillingController { constructor( private readonly billingService: BillingService, @Inject(forwardRef(() => AppLifecycleService)) private readonly lifecycleService: AppLifecycleService, @Inject(forwardRef(() => ApplicationsService)) private readonly applicationsService: ApplicationsService, @Inject(forwardRef(() => KubernetesService)) private readonly kubernetesService: KubernetesService, ) {} // ─── Pricing catalog (Admin) ────────────────────────────────────── @Get('pricing-catalog') @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Get usage-based pricing catalog (Admin)' }) async getPricingCatalog() { return this.billingService.getPricingCatalog(); } @Patch('pricing-catalog') @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Update usage-based pricing catalog (Admin)' }) async updatePricingCatalog(@Body() dto: UpdatePricingCatalogDto) { return this.billingService.updatePricingCatalog(dto); } // ─── Cost Calculation ───────────────────────────────────────────── @Post('calculate') @ApiOperation({ summary: 'Calculate cost for an application configuration' }) async calculateCost(@Body() dto: CalculateCostDto) { return this.billingService.calculateCost(dto); } @Post('calculate-deploy') @ApiOperation({ summary: 'Calculate deploy cost with prepaid resource credits applied', }) async calculateDeployCost(@Request() req: any, @Body() dto: CalculateDeployCostDto) { if (!Object.values(BillingCycle).includes(dto.cycle)) { throw new BadRequestException(`Invalid billing cycle: ${dto.cycle}`); } return this.billingService.calculateDeployPayment(req.user.id, dto, dto.cycle); } // ─── Custom Domain Pricing ───────────────────────────────────── @Get('settings/custom-domain-price') @ApiOperation({ summary: 'Get custom domain monthly price' }) async getCustomDomainPrice() { return this.billingService.getCustomDomainPrice(); } @Patch('settings/custom-domain-price') @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Set custom domain monthly price (Admin)' }) async setCustomDomainPrice(@Body() body: { monthlyPrice: number }) { if (body.monthlyPrice === undefined || body.monthlyPrice < 0) { throw new BadRequestException('monthlyPrice must be a non-negative number'); } return this.billingService.setCustomDomainPrice(body.monthlyPrice); } @Get('settings/optional-services') @ApiOperation({ summary: 'Get global optional service addon prices (Redis, RabbitMQ, Elasticsearch)' }) async getOptionalServicesPricing() { return this.billingService.getOptionalServicesPricing(); } @Patch('settings/optional-services') @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Set global optional service addon prices (Admin)' }) async setOptionalServicesPricing(@Body() dto: SetOptionalServicesPricingDto) { return this.billingService.setOptionalServicesPricing(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); } @Get('resource-credits') @ApiOperation({ summary: 'List active prepaid resource credits (from deleted apps)' }) async getResourceCredits(@Request() req: any) { const credits = await this.billingService.getActiveCredits(req.user.id); return credits.map((c) => this.billingService.formatCreditForApi(c)); } // ─── Invoices ───────────────────────────────────────────────────── @Get('invoices') @ApiOperation({ summary: 'List my invoices' }) async listMyInvoices( @Request() req: any, @Query('status') status?: InvoiceStatus, @Query('applicationId') applicationId?: string, @Query('limit') limit?: string, ) { return this.billingService.listInvoices(req.user, { status, applicationId, limit: limit ? parseInt(limit, 10) : undefined, }); } @Get('invoices/:id/pdf') @ApiOperation({ summary: 'Download invoice as PDF' }) async downloadInvoicePdf( @Request() req: any, @Param('id') id: string, @Res() res: Response, ) { const invoice = await this.billingService.getInvoiceForUser(id, req.user); const pdf = this.billingService.generateInvoicePdf(invoice); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `attachment; filename="${invoice.invoiceNumber}.pdf"`); res.send(pdf); } @Get('invoices/:id') @ApiOperation({ summary: 'Get one invoice with line items and transactions' }) async getInvoice(@Request() req: any, @Param('id') id: string) { return this.billingService.getInvoiceForUser(id, req.user); } @Post('invoices/:id/pay/mixed') @ApiOperation({ summary: 'Pay invoice with wallet first, then gateway for the remaining amount' }) async initiateInvoiceMixed( @Request() req: any, @Param('id') id: string, @Body() dto: InitiateInvoicePaymentDto, ) { const result = await this.billingService.initiateInvoiceMixedPayment(id, req.user, dto.callbackUrl); const effect = await this.completePaidInvoiceEffect(result.invoice); return { ...result, effect }; } @Post('invoices/:id/gateway/verify') @ApiOperation({ summary: 'Verify invoice gateway payment' }) async verifyInvoiceGateway( @Request() req: any, @Param('id') id: string, @Body() dto: VerifyInvoiceGatewayDto, ) { const result = await this.billingService.verifyInvoiceGatewayPayment( id, req.user, dto.trackingCode, dto.amount, ); const effect = await this.completePaidInvoiceEffect(result.invoice); return { ...result, effect }; } @Post('wallet/pay/:applicationId') @ApiOperation({ summary: 'Pay for an application plan from wallet — activates/renews the app' }) async payForApplication( @Request() req: any, @Param('applicationId') applicationId: string, @Body() body: { cycle: string }, ) { const cycle = body.cycle as BillingCycle; if (!Object.values(BillingCycle).includes(cycle)) { throw new BadRequestException(`Invalid billing cycle: ${body.cycle}`); } const app = await this.applicationsService.findOne(applicationId, req.user.id); const payment = await this.billingService.resolveAppPayment( req.user.id, app, cycle, ); let invoice = null; if (payment.amountDue > 0) { invoice = await this.billingService.createInvoice({ userId: req.user.id, applicationId: app.id, reason: InvoiceReason.DEPLOY, lines: [ { label: `Application payment: ${app.name}`, description: `Billing cycle: ${cycle}`, amount: payment.amountDue, metadata: { cycle, waivedAmount: payment.waivedAmount, creditApplied: payment.creditId || null, }, }, ], metadata: { action: 'activate', cycle, }, }); } let tx = null; if (invoice) { const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user); tx = paid.transaction; invoice = paid.invoice; } const activated = await this.lifecycleService.activateApp( applicationId, cycle, ); return { transaction: tx, invoice, creditApplied: payment.creditId || null, waivedAmount: payment.waivedAmount, paidAmount: payment.amountDue, application: { id: activated.id, name: activated.name, lifecycleStatus: activated.lifecycleStatus, planExpiresAt: activated.planExpiresAt, }, message: payment.waivedAmount > 0 ? payment.amountDue > 0 ? `Application "${activated.name}" activated — prepaid credit applied; you paid ${payment.amountDue} Toman for additional services.` : `Application "${activated.name}" activated using your prepaid resource credit (no charge).` : payment.amountDue > 0 ? `Application "${activated.name}" activated until ${activated.planExpiresAt?.toISOString()}` : `Application "${activated.name}" activated`, }; } // ─── 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, }; } // ─── Invoice Admin ──────────────────────────────────────────────── @Get('admin/invoices') @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'List all invoices (Admin)' }) async listAdminInvoices( @Request() req: any, @Query('status') status?: InvoiceStatus, @Query('userId') userId?: string, @Query('applicationId') applicationId?: string, @Query('paymentMethod') paymentMethod?: PaymentMethod, @Query('search') search?: string, @Query('limit') limit?: string, ) { return this.billingService.listInvoices(req.user, { status, userId, applicationId, paymentMethod, search, limit: limit ? parseInt(limit, 10) : undefined, }); } @Get('admin/invoices/:id') @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Get invoice details (Admin)' }) async getAdminInvoice(@Request() req: any, @Param('id') id: string) { return this.billingService.getInvoiceForUser(id, req.user); } @Get('admin/invoices/:id/pdf') @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Download invoice PDF (Admin)' }) async downloadAdminInvoicePdf( @Request() req: any, @Param('id') id: string, @Res() res: Response, ) { const invoice = await this.billingService.getInvoiceForUser(id, req.user); const pdf = this.billingService.generateInvoicePdf(invoice); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `attachment; filename="${invoice.invoiceNumber}.pdf"`); res.send(pdf); } @Patch('admin/invoices/:id/status') @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Update invoice status with reason (Admin)' }) async updateAdminInvoiceStatus( @Param('id') id: string, @Body() dto: UpdateInvoiceStatusDto, ) { return this.billingService.updateInvoiceStatus(id, dto.status, dto.reason); } // ─── 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); } // ─── Application Renewal ────────────────────────────────────────── @Get('applications/:applicationId/renewal-cost') @ApiOperation({ summary: 'Get renewal cost for an application' }) async getRenewalCost( @Request() req: any, @Param('applicationId') applicationId: string, ) { // User can only view their own app, admin/sales can view any const app = await this.getAppWithAccess(req.user, applicationId); const costs = await this.billingService.calculateRenewalCost(app); return { applicationId: app.id, applicationName: app.name, lifecycleStatus: app.lifecycleStatus, planExpiresAt: app.planExpiresAt, currentCycle: app.billingCycle, costs, }; } @Post('applications/:applicationId/renew/invoice') @ApiOperation({ summary: 'Create an unpaid renewal invoice for choosing wallet/gateway/mixed payment' }) async createRenewalInvoice( @Request() req: any, @Param('applicationId') applicationId: string, @Body() dto: RenewApplicationDto, ) { const app = await this.getAppWithAccess(req.user, applicationId); const costs = await this.billingService.calculateRenewalCost(app); const amount = dto.cycle === BillingCycle.HOURLY ? costs.hourly : dto.cycle === BillingCycle.MONTHLY ? costs.monthly : costs.yearly; if (amount <= 0) { throw new BadRequestException('Invalid cost calculation — no pricing rules found'); } return this.billingService.createInvoice({ userId: app.userId, applicationId: app.id, reason: InvoiceReason.RENEWAL, lines: [ { label: `Renewal for ${app.name}`, description: `Billing cycle: ${dto.cycle}`, amount, metadata: { cycle: dto.cycle }, }, ], metadata: { action: 'renew', cycle: dto.cycle }, }); } @Post('applications/:applicationId/renew') @ApiOperation({ summary: 'Renew an application (user pays from wallet)' }) async renewApplication( @Request() req: any, @Param('applicationId') applicationId: string, @Body() dto: RenewApplicationDto, ) { // User can only renew their own app, admin/sales can renew any const app = await this.getAppWithAccess(req.user, applicationId); // Calculate cost for the selected cycle const costs = await this.billingService.calculateRenewalCost(app); const amount = dto.cycle === BillingCycle.HOURLY ? costs.hourly : dto.cycle === BillingCycle.MONTHLY ? costs.monthly : costs.yearly; if (amount <= 0) { throw new BadRequestException('Invalid cost calculation — no pricing rules found'); } const walletUserId = req.user.role === UserRole.ADMIN || req.user.role === UserRole.SALES ? app.userId : req.user.id; const invoice = await this.billingService.createInvoice({ userId: walletUserId, applicationId: app.id, reason: InvoiceReason.RENEWAL, lines: [ { label: `Renewal for ${app.name}`, description: `Billing cycle: ${dto.cycle}`, amount, metadata: { cycle: dto.cycle }, }, ], metadata: { action: 'renew', cycle: dto.cycle }, }); const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user); // Activate the application const renewedApp = await this.lifecycleService.activateApp(app.id, dto.cycle); return { success: true, transaction: paid.transaction, invoice: paid.invoice, application: { id: renewedApp.id, name: renewedApp.name, lifecycleStatus: renewedApp.lifecycleStatus, planExpiresAt: renewedApp.planExpiresAt, billingCycle: renewedApp.billingCycle, }, message: `Application "${renewedApp.name}" renewed until ${renewedApp.planExpiresAt?.toISOString()}`, }; } @Post('admin/applications/:applicationId/renew') @Roles(UserRole.ADMIN, UserRole.SALES) @ApiOperation({ summary: 'Admin/Sales: Renew an application (can bypass wallet if needed)' }) async adminRenewApplication( @Request() req: any, @Param('applicationId') applicationId: string, @Body() body: { cycle: string; bypassPayment?: boolean; reason?: string }, ) { const app = await this.applicationsService.findOne(applicationId); const cycle = body.cycle as BillingCycle; if (!Object.values(BillingCycle).includes(cycle)) { throw new BadRequestException(`Invalid billing cycle: ${body.cycle}`); } if (body.bypassPayment) { // Direct activation without payment (for special cases, support, etc.) const renewedApp = await this.lifecycleService.activateApp(app.id, cycle); return { success: true, bypassedPayment: true, reason: body.reason || 'Admin action', application: { id: renewedApp.id, name: renewedApp.name, lifecycleStatus: renewedApp.lifecycleStatus, planExpiresAt: renewedApp.planExpiresAt, }, message: `Application "${renewedApp.name}" renewed by admin (payment bypassed)`, }; } // Normal renewal - deduct from app owner's wallet const costs = await this.billingService.calculateRenewalCost(app); const amount = cycle === BillingCycle.HOURLY ? costs.hourly : cycle === BillingCycle.MONTHLY ? costs.monthly : costs.yearly; const invoice = await this.billingService.createInvoice({ userId: app.userId, applicationId: app.id, reason: InvoiceReason.RENEWAL, lines: [ { label: `Renewal for ${app.name}`, description: `Billing cycle: ${cycle}; initiated by ${req.user.role}`, amount, metadata: { cycle, initiatedBy: req.user.role }, }, ], metadata: { action: 'renew', cycle, initiatedBy: req.user.role }, }); const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user); const renewedApp = await this.lifecycleService.activateApp(app.id, cycle); return { success: true, transaction: paid.transaction, invoice: paid.invoice, application: { id: renewedApp.id, name: renewedApp.name, lifecycleStatus: renewedApp.lifecycleStatus, planExpiresAt: renewedApp.planExpiresAt, }, message: `Application "${renewedApp.name}" renewed by ${req.user.role}`, }; } // ─── Resource Upgrade ───────────────────────────────────────────── @Post('applications/:applicationId/upgrade/calculate') @ApiOperation({ summary: 'Calculate cost for resource upgrade' }) async calculateUpgradeCost( @Request() req: any, @Param('applicationId') applicationId: string, @Body() dto: CalculateUpgradeCostDto, ) { const app = await this.getAppWithAccess(req.user, applicationId); const result = await this.billingService.calculateUpgradeCost(app, dto); return { applicationId: app.id, applicationName: app.name, ...result, currentResources: { cpuRequest: app.cpuRequest, cpuLimit: app.cpuLimit, memoryRequest: app.memoryRequest, memoryLimit: app.memoryLimit, replicas: app.replicas, dbStorageSize: app.dbStorageSize, appStorageSize: app.appStorageSize, }, newResources: { cpuRequest: dto.cpuRequest || app.cpuRequest, cpuLimit: dto.cpuLimit || app.cpuLimit, memoryRequest: dto.memoryRequest || app.memoryRequest, memoryLimit: dto.memoryLimit || app.memoryLimit, replicas: dto.replicas ?? app.replicas, dbStorageSize: dto.dbStorageSize || app.dbStorageSize, appStorageSize: dto.appStorageSize || app.appStorageSize, }, }; } @Post('applications/:applicationId/upgrade/invoice') @ApiOperation({ summary: 'Create an unpaid upgrade invoice for choosing wallet/gateway/mixed payment' }) async createUpgradeInvoice( @Request() req: any, @Param('applicationId') applicationId: string, @Body() dto: UpgradeResourcesDto, ) { const app = await this.getAppWithAccess(req.user, applicationId); if (app.lifecycleStatus !== AppLifecycleStatus.ACTIVE) { throw new BadRequestException( `Cannot upgrade resources for ${app.lifecycleStatus} application. Please renew first.`, ); } const costResult = await this.billingService.calculateUpgradeCost(app, dto); if (costResult.proratedAmount <= 0) { throw new BadRequestException('This change does not require a paid invoice'); } return this.billingService.createInvoice({ userId: app.userId, applicationId: app.id, reason: InvoiceReason.UPGRADE, lines: [ { label: `Resource upgrade for ${app.name}`, description: `Prorated for ${costResult.remainingHours} hours`, amount: costResult.proratedAmount, metadata: { remainingHours: costResult.remainingHours, currentCost: costResult.currentCost, newCost: costResult.newCost, }, }, ], metadata: { action: 'upgrade', resources: dto, remainingHours: costResult.remainingHours, }, }); } @Post('applications/:applicationId/upgrade') @ApiOperation({ summary: 'Upgrade application resources (with payment)' }) async upgradeResources( @Request() req: any, @Param('applicationId') applicationId: string, @Body() dto: UpgradeResourcesDto, ) { const app = await this.getAppWithAccess(req.user, applicationId); // Application must be active to upgrade if (app.lifecycleStatus !== AppLifecycleStatus.ACTIVE) { throw new BadRequestException( `Cannot upgrade resources for ${app.lifecycleStatus} application. Please renew first.` ); } // Calculate upgrade cost const costResult = await this.billingService.calculateUpgradeCost(app, dto); let paidInvoice = null; // If upgrading (positive difference), require payment if (costResult.proratedAmount > 0) { const walletUserId = req.user.role === UserRole.ADMIN || req.user.role === UserRole.SALES ? app.userId : req.user.id; const invoice = await this.billingService.createInvoice({ userId: walletUserId, applicationId: app.id, reason: InvoiceReason.UPGRADE, lines: [ { label: `Resource upgrade for ${app.name}`, description: `Prorated for ${costResult.remainingHours} hours`, amount: costResult.proratedAmount, metadata: { remainingHours: costResult.remainingHours, currentCost: costResult.currentCost, newCost: costResult.newCost, }, }, ], metadata: { action: 'upgrade', resources: dto, remainingHours: costResult.remainingHours, }, }); const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user); paidInvoice = paid.invoice; } const updatedApp = await this.applicationsService.update( app.id, app.userId, this.buildUpgradeEntityPatch(app, dto), ); try { await this.applyUpgradeToKubernetes(updatedApp, dto, app); } catch (e: any) { console.warn(`K8s resource update failed for ${app.name}: ${e.message}`); } return { success: true, paidAmount: costResult.proratedAmount, invoice: paidInvoice, application: { id: updatedApp.id, name: updatedApp.name, cpuRequest: updatedApp.cpuRequest, cpuLimit: updatedApp.cpuLimit, memoryRequest: updatedApp.memoryRequest, memoryLimit: updatedApp.memoryLimit, replicas: updatedApp.replicas, dbStorageSize: updatedApp.dbStorageSize, appStorageSize: updatedApp.appStorageSize, }, message: costResult.proratedAmount > 0 ? `Resources upgraded. Paid ${costResult.proratedAmount} Toman for remaining ${costResult.remainingHours} hours.` : 'Resources updated (downgrade or no cost change).', }; } // ─── Helper Methods ─────────────────────────────────────────────── private async completePaidInvoiceEffect(invoice: any) { if (invoice.status !== InvoiceStatus.PAID) return null; if (invoice.metadata?.completedAt) return invoice.metadata.completionResult || null; const action = invoice.metadata?.action; if (!action || !invoice.applicationId) return null; if (action === 'renew' || action === 'activate') { const cycle = invoice.metadata?.cycle as BillingCycle; if (!Object.values(BillingCycle).includes(cycle)) return null; const activated = await this.lifecycleService.activateApp(invoice.applicationId, cycle); const result = { action, application: { id: activated.id, name: activated.name, lifecycleStatus: activated.lifecycleStatus, planExpiresAt: activated.planExpiresAt, billingCycle: activated.billingCycle, }, }; await this.billingService.markInvoiceEffectCompleted(invoice.id, result); return result; } if (action === 'upgrade') { const app = await this.applicationsService.findOne(invoice.applicationId); const resources = (invoice.metadata?.resources || {}) as UpgradeResourcesDto; const updatedApp = await this.applicationsService.update( app.id, app.userId, this.buildUpgradeEntityPatch(app, resources), ); try { await this.applyUpgradeToKubernetes(updatedApp, resources, app); } catch (e: any) { console.warn(`K8s resource update failed for ${app.name}: ${e.message}`); } const result = { action, application: { id: updatedApp.id, name: updatedApp.name, cpuRequest: updatedApp.cpuRequest, cpuLimit: updatedApp.cpuLimit, memoryRequest: updatedApp.memoryRequest, memoryLimit: updatedApp.memoryLimit, replicas: updatedApp.replicas, }, }; await this.billingService.markInvoiceEffectCompleted(invoice.id, result); return result; } return null; } private buildUpgradeEntityPatch(app: Application, dto: UpgradeResourcesDto): Partial { const pt = app.productType ?? ProductType.APPLICATION; if (dto.redisResources) { return { optionalServiceResources: { ...app.optionalServiceResources, redis: { ...app.optionalServiceResources?.redis, ...dto.redisResources, storageGi: dto.redisResources.storageGi ?? app.optionalServiceResources?.redis?.storageGi ?? 1, }, }, }; } if (dto.rabbitmqResources) { return { optionalServiceResources: { ...app.optionalServiceResources, rabbitmq: { ...app.optionalServiceResources?.rabbitmq, ...dto.rabbitmqResources, storageGi: dto.rabbitmqResources.storageGi ?? app.optionalServiceResources?.rabbitmq?.storageGi ?? 2, }, }, }; } if (pt === ProductType.MANAGED_REDIS || pt === ProductType.MANAGED_RABBITMQ) { return {}; } return { cpuRequest: dto.cpuRequest || app.cpuRequest, cpuLimit: dto.cpuLimit || app.cpuLimit, memoryRequest: dto.memoryRequest || app.memoryRequest, memoryLimit: dto.memoryLimit || app.memoryLimit, replicas: dto.replicas ?? app.replicas, dbStorageSize: dto.dbStorageSize || app.dbStorageSize, appStorageSize: dto.appStorageSize || app.appStorageSize, }; } private async applyUpgradeToKubernetes( app: Application, dto: UpgradeResourcesDto, previous: Application, ): Promise { const pt = app.productType ?? ProductType.APPLICATION; if (pt === ProductType.MANAGED_DATABASE) { await this.kubernetesService.updateResources( app, { cpuRequest: dto.cpuRequest, cpuLimit: dto.cpuLimit, memoryRequest: dto.memoryRequest, memoryLimit: dto.memoryLimit, }, 'database', ); if (dto.dbStorageSize && dto.dbStorageSize !== previous.dbStorageSize) { const resize = await this.kubernetesService.resizeDatabasePvc(app, dto.dbStorageSize); if (!resize.success) { throw new BadRequestException(resize.message); } } return; } if (pt === ProductType.MANAGED_REDIS) { await this.applyOptionalServiceUpgrade(app, dto, previous, 'redis'); return; } if (pt === ProductType.MANAGED_RABBITMQ) { await this.applyOptionalServiceUpgrade(app, dto, previous, 'rabbitmq'); return; } if (dto.redisResources && app.enableRedis) { await this.applyOptionalServiceUpgrade(app, dto, previous, 'redis'); } if (dto.rabbitmqResources && app.enableRabbitmq) { await this.applyOptionalServiceUpgrade(app, dto, previous, 'rabbitmq'); } const touchesAppWorkload = dto.cpuRequest !== undefined || dto.cpuLimit !== undefined || dto.memoryRequest !== undefined || dto.memoryLimit !== undefined || dto.replicas !== undefined; if (touchesAppWorkload) { await this.kubernetesService.updateResources(app, { cpuRequest: dto.cpuRequest, cpuLimit: dto.cpuLimit, memoryRequest: dto.memoryRequest, memoryLimit: dto.memoryLimit, replicas: dto.replicas, }); } if (dto.appStorageSize && dto.appStorageSize !== previous.appStorageSize) { await this.kubernetesService.resizeAppStoragePvc(app, dto.appStorageSize); } if ( dto.dbStorageSize && dto.dbStorageSize !== previous.dbStorageSize && previous.databaseType && previous.databaseType !== DatabaseType.NONE ) { const resize = await this.kubernetesService.resizeDatabasePvc(app, dto.dbStorageSize); if (!resize.success) { throw new BadRequestException(resize.message); } } } private async applyOptionalServiceUpgrade( app: Application, dto: UpgradeResourcesDto, previous: Application, service: 'redis' | 'rabbitmq', ): Promise { const res = app.optionalServiceResources?.[service]; const dtoRes = service === 'redis' ? dto.redisResources : dto.rabbitmqResources; if (res) { await this.kubernetesService.updateResources( app, { cpuRequest: res.cpuRequest, cpuLimit: res.cpuLimit, memoryRequest: res.memoryRequest, memoryLimit: res.memoryLimit, }, service, ); } const prevGi = previous.optionalServiceResources?.[service]?.storageGi ?? (service === 'redis' ? 1 : 2); const nextGi = dtoRes?.storageGi; if (nextGi != null && nextGi > prevGi) { const resize = service === 'redis' ? await this.kubernetesService.resizeRedisStoragePvc(app, `${nextGi}Gi`) : await this.kubernetesService.resizeRabbitmqStoragePvc(app, `${nextGi}Gi`); if (!resize.success) { throw new BadRequestException(resize.message); } } } private async getAppWithAccess(user: any, applicationId: string) { const isAdminOrSales = user.role === UserRole.ADMIN || user.role === UserRole.SALES; if (isAdminOrSales) { return this.applicationsService.findOne(applicationId); } // Regular user - must own the app return this.applicationsService.findOne(applicationId, user.id); } }