add optinal apps

This commit is contained in:
keyhan
2026-04-23 15:26:49 +03:30
parent f481a57d8f
commit 38748b0827
16 changed files with 3091 additions and 144 deletions
+273
View File
@@ -12,16 +12,22 @@ import {
BadRequestException,
Inject,
forwardRef,
ForbiddenException,
} from '@nestjs/common';
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 {
CreateServicePlanDto,
UpdateServicePlanDto,
ChargeWalletDto,
CalculateCostDto,
RenewApplicationDto,
UpgradeResourcesDto,
CalculateUpgradeCostDto,
} from './dto/billing.dto';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
@@ -36,6 +42,10 @@ export class BillingController {
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,
) {}
// ─── Service Plans (Admin) ────────────────────────────────────────
@@ -208,4 +218,267 @@ export class BillingController {
) {
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')
@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');
}
// Deduct from wallet (user's wallet for user, app owner's wallet for admin action)
const walletUserId = req.user.role === UserRole.ADMIN || req.user.role === UserRole.SALES
? app.userId
: req.user.id;
const tx = await this.billingService.deductWallet(
walletUserId,
amount,
`Renewal for ${app.name} (${dto.cycle})`,
app.id,
);
// Activate the application
const renewedApp = await this.lifecycleService.activateApp(app.id, dto.cycle, app.planId || '');
return {
success: true,
transaction: tx,
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, app.planId || '');
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 tx = await this.billingService.deductWallet(
app.userId,
amount,
`Renewal by ${req.user.role} for ${app.name} (${cycle})`,
app.id,
);
const renewedApp = await this.lifecycleService.activateApp(app.id, cycle, app.planId || '');
return {
success: true,
transaction: tx,
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')
@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);
// 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;
await this.billingService.deductWallet(
walletUserId,
costResult.proratedAmount,
`Resource upgrade for ${app.name}: prorated ${costResult.remainingHours}h`,
app.id,
);
}
// Apply the resource changes
const updatedApp = await this.applicationsService.update(app.id, app.userId, {
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,
});
// Update Kubernetes resources
try {
await this.kubernetesService.updateResources(updatedApp, {
cpuRequest: dto.cpuRequest,
cpuLimit: dto.cpuLimit,
memoryRequest: dto.memoryRequest,
memoryLimit: dto.memoryLimit,
replicas: dto.replicas,
});
// Resize app storage PVC if changed
if (dto.appStorageSize && dto.appStorageSize !== app.appStorageSize) {
await this.kubernetesService.resizeAppStoragePvc(updatedApp, dto.appStorageSize);
}
} catch (e: any) {
// Log error but don't fail - DB is updated, K8s will sync on next deploy
console.warn(`K8s resource update failed for ${app.name}: ${e.message}`);
}
return {
success: true,
paidAmount: costResult.proratedAmount,
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 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);
}
}