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);
}
}
+4
View File
@@ -8,11 +8,15 @@ import { Wallet } from './entities/wallet.entity';
import { WalletTransaction } from './entities/wallet-transaction.entity';
import { PlatformSetting } from './entities/platform-setting.entity';
import { LifecycleModule } from '../lifecycle/lifecycle.module';
import { ApplicationsModule } from '../applications/applications.module';
import { KubernetesModule } from '../kubernetes/kubernetes.module';
@Module({
imports: [
TypeOrmModule.forFeature([ServicePlan, PricingRule, Wallet, WalletTransaction, PlatformSetting]),
forwardRef(() => LifecycleModule),
forwardRef(() => ApplicationsModule),
forwardRef(() => KubernetesModule),
],
controllers: [BillingController],
providers: [BillingService],
+116 -3
View File
@@ -10,7 +10,9 @@ import {
CreateServicePlanDto,
UpdateServicePlanDto,
CalculateCostDto,
UpgradeResourcesDto,
} from './dto/billing.dto';
import { Application } from '../applications/entities/application.entity';
@Injectable()
export class BillingService {
@@ -133,9 +135,14 @@ export class BillingService {
// Parse resource values
const cpuCores = this.parseCpuToCores(dto.cpuLimit);
const memoryGb = this.parseMemoryToGb(dto.memoryLimit);
const storageGb = dto.dbStorageSize ? parseFloat(dto.dbStorageSize.replace('Gi', '')) || 0 : 0;
const dbStorageGb = dto.dbStorageSize ? parseFloat(dto.dbStorageSize.replace('Gi', '')) || 0 : 0;
const appStorageGb = dto.appStorageSize ? parseFloat(dto.appStorageSize.replace('Gi', '')) || 0 : 0;
const totalStorageGb = dbStorageGb + appStorageGb;
const hasDatabase = dto.databaseType !== 'none';
const replicas = dto.replicas || 1;
const hasRedis = dto.enableRedis || false;
const hasRabbitmq = dto.enableRabbitmq || false;
const hasElasticsearch = dto.enableElasticsearch || false;
const breakdown: { label: string; hourly: number; monthly: number; yearly: number }[] = [];
let totalBase = 0;
@@ -158,13 +165,25 @@ export class BillingService {
label = `Memory (${(memoryGb * replicas).toFixed(2)} GB)`;
break;
case PricingResourceType.STORAGE_PER_GB:
cost = storageGb * Number(rule.unitPrice);
label = `Storage (${storageGb} GB)`;
cost = totalStorageGb * Number(rule.unitPrice);
label = `Storage (${totalStorageGb} GB)`;
break;
case PricingResourceType.DATABASE_ADDON:
cost = hasDatabase ? Number(rule.unitPrice) : 0;
label = 'Database addon';
break;
case PricingResourceType.REDIS_ADDON:
cost = hasRedis ? Number(rule.unitPrice) : 0;
label = 'Redis addon';
break;
case PricingResourceType.RABBITMQ_ADDON:
cost = hasRabbitmq ? Number(rule.unitPrice) : 0;
label = 'RabbitMQ addon';
break;
case PricingResourceType.ELASTICSEARCH_ADDON:
cost = hasElasticsearch ? Number(rule.unitPrice) : 0;
label = 'Elasticsearch addon';
break;
}
if (cost > 0) {
@@ -300,6 +319,7 @@ export class BillingService {
memoryLimit: string;
replicas: number;
dbStorageSize?: string;
appStorageSize?: string;
}): Promise<{ hourly: number; monthly: number; yearly: number }> {
const result = await this.calculateCost({
runtime: app.runtime,
@@ -308,7 +328,100 @@ export class BillingService {
memoryLimit: app.memoryLimit,
replicas: app.replicas,
dbStorageSize: app.dbStorageSize,
appStorageSize: app.appStorageSize,
});
return { hourly: result.hourly, monthly: result.monthly, yearly: result.yearly };
}
// ─── Renewal Cost Calculation ─────────────────────────────────────
/**
* Calculate renewal cost for an application.
* Returns cost for each billing cycle based on current app config.
*/
async calculateRenewalCost(app: Application): Promise<{
hourly: number;
monthly: number;
yearly: number;
currentCycle?: BillingCycle;
currentCycleCost?: number;
}> {
const costs = await this.calculateCostForApp(app);
let currentCycleCost: number | undefined;
if (app.billingCycle) {
currentCycleCost = app.billingCycle === BillingCycle.HOURLY ? costs.hourly
: app.billingCycle === BillingCycle.MONTHLY ? costs.monthly
: costs.yearly;
}
return {
...costs,
currentCycle: app.billingCycle,
currentCycleCost,
};
}
// ─── Resource Upgrade Cost Calculation ────────────────────────────
/**
* Calculate the cost difference for a resource upgrade.
* Returns the additional cost per billing cycle.
*/
async calculateUpgradeCost(
app: Application,
newResources: UpgradeResourcesDto,
): Promise<{
currentCost: { hourly: number; monthly: number; yearly: number };
newCost: { hourly: number; monthly: number; yearly: number };
difference: { hourly: number; monthly: number; yearly: number };
proratedAmount: number;
remainingHours: number;
billingCycle: BillingCycle | null;
}> {
// Current cost
const currentCost = await this.calculateCostForApp(app);
// New cost with upgraded resources
const newCost = await this.calculateCost({
runtime: app.runtime,
databaseType: app.databaseType,
cpuLimit: newResources.cpuLimit || app.cpuLimit,
memoryLimit: newResources.memoryLimit || app.memoryLimit,
replicas: newResources.replicas ?? app.replicas,
dbStorageSize: newResources.dbStorageSize || app.dbStorageSize,
appStorageSize: newResources.appStorageSize || app.appStorageSize,
});
// Difference
const difference = {
hourly: newCost.hourly - currentCost.hourly,
monthly: newCost.monthly - currentCost.monthly,
yearly: newCost.yearly - currentCost.yearly,
};
// Calculate prorated amount based on remaining time in billing period
let proratedAmount = 0;
let remainingHours = 0;
if (app.planExpiresAt && app.billingCycle) {
const now = new Date();
const expiresAt = new Date(app.planExpiresAt);
remainingHours = Math.max(0, (expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60));
// Only charge difference if upgrading (not downgrading)
if (difference.hourly > 0) {
proratedAmount = Math.ceil(difference.hourly * remainingHours);
}
}
return {
currentCost,
newCost,
difference,
proratedAmount,
remainingHours: Math.round(remainingHours),
billingCycle: app.billingCycle,
};
}
}
+69
View File
@@ -116,4 +116,73 @@ export class CalculateCostDto {
@IsOptional()
@IsString()
dbStorageSize?: string;
@ApiProperty({ example: '2Gi', description: 'App storage size for uploads/wp-content' })
@IsOptional()
@IsString()
appStorageSize?: string;
@ApiPropertyOptional({ example: false, description: 'Enable Redis caching' })
@IsOptional()
@IsBoolean()
enableRedis?: boolean;
@ApiPropertyOptional({ example: false, description: 'Enable RabbitMQ message broker' })
@IsOptional()
@IsBoolean()
enableRabbitmq?: boolean;
@ApiPropertyOptional({ example: false, description: 'Enable Elasticsearch logging' })
@IsOptional()
@IsBoolean()
enableElasticsearch?: boolean;
}
// ─── Renewal & Upgrade DTOs ─────────────────────────────────────────
export class RenewApplicationDto {
@ApiProperty({ enum: BillingCycle, example: 'monthly' })
@IsEnum(BillingCycle)
cycle: BillingCycle;
}
export class UpgradeResourcesDto {
@ApiPropertyOptional({ example: '500m', description: 'New CPU request' })
@IsOptional()
@IsString()
cpuRequest?: string;
@ApiPropertyOptional({ example: '1000m', description: 'New CPU limit' })
@IsOptional()
@IsString()
cpuLimit?: string;
@ApiPropertyOptional({ example: '256Mi', description: 'New memory request' })
@IsOptional()
@IsString()
memoryRequest?: string;
@ApiPropertyOptional({ example: '1Gi', description: 'New memory limit' })
@IsOptional()
@IsString()
memoryLimit?: string;
@ApiPropertyOptional({ example: 2, description: 'New replica count' })
@IsOptional()
@IsNumber()
@Min(1)
replicas?: number;
@ApiPropertyOptional({ example: '5Gi', description: 'New database storage size' })
@IsOptional()
@IsString()
dbStorageSize?: string;
@ApiPropertyOptional({ example: '5Gi', description: 'New app storage size for uploads/wp-content' })
@IsOptional()
@IsString()
appStorageSize?: string;
}
export class CalculateUpgradeCostDto extends UpgradeResourcesDto {}