dc9830383b
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>
1002 lines
34 KiB
TypeScript
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);
|
|
}
|
|
}
|