Files
cloud-host/backend/src/billing/billing.controller.ts
T
keyhan dc9830383b Improve logging recovery, resource scaling, and app deploy logging.
Auto-reconnect Elasticsearch port-forward after cluster or API restarts, poll log status in the UI, and apply storage changes through billing upgrade for all workloads. Add Redis/RabbitMQ PVC resize, Helm ES credentials for Fluent Bit, and fix deploy progress overlay behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 21:41:00 +03:30

1002 lines
34 KiB
TypeScript

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<Application> {
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<void> {
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<void> {
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);
}
}