Replace billing plans with per-runtime usage pricing catalog.

Store explicit hourly/monthly/yearly rates in pricing_rates and addon_rates, compute deploy costs without cycle conversion, and simplify admin UI and wallet payment to cycle-only.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-15 18:10:54 +03:30
parent 5239e8aa94
commit 35235fe0fc
15 changed files with 1293 additions and 716 deletions
+28 -65
View File
@@ -21,15 +21,15 @@ import { AppLifecycleService } from '../lifecycle/app-lifecycle.service';
import { ApplicationsService } from '../applications/applications.service';
import { KubernetesService } from '../kubernetes/kubernetes.service';
import {
CreateServicePlanDto,
UpdateServicePlanDto,
ChargeWalletDto,
CalculateCostDto,
CalculateDeployCostDto,
SetOptionalServicesPricingDto,
RenewApplicationDto,
UpgradeResourcesDto,
CalculateUpgradeCostDto,
} 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 } from '../common/enums';
@@ -49,43 +49,20 @@ export class BillingController {
private readonly kubernetesService: KubernetesService,
) {}
// ─── Service Plans (Admin) ────────────────────────────────────────
// ─── Pricing catalog (Admin) ──────────────────────────────────────
@Post('plans')
@Get('pricing-catalog')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Create a new service plan (Admin)' })
async createPlan(@Body() dto: CreateServicePlanDto) {
return this.billingService.createPlan(dto);
@ApiOperation({ summary: 'Get usage-based pricing catalog (Admin)' })
async getPricingCatalog() {
return this.billingService.getPricingCatalog();
}
@Get('plans')
@ApiOperation({ summary: 'List all service plans' })
async findAllPlans(@Request() req: any) {
if (req.user.role === UserRole.ADMIN) {
return this.billingService.findAllPlans();
}
return this.billingService.findActivePlans();
}
@Get('plans/:id')
@ApiOperation({ summary: 'Get service plan details' })
async findPlan(@Param('id') id: string) {
return this.billingService.findPlan(id);
}
@Patch('plans/:id')
@Patch('pricing-catalog')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Update a service plan (Admin)' })
async updatePlan(@Param('id') id: string, @Body() dto: UpdateServicePlanDto) {
return this.billingService.updatePlan(id, dto);
}
@Delete('plans/:id')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Delete a service plan (Admin)' })
async deletePlan(@Param('id') id: string) {
await this.billingService.deletePlan(id);
return { message: 'Plan deleted' };
@ApiOperation({ summary: 'Update usage-based pricing catalog (Admin)' })
async updatePricingCatalog(@Body() dto: UpdatePricingCatalogDto) {
return this.billingService.updatePricingCatalog(dto);
}
// ─── Cost Calculation ─────────────────────────────────────────────
@@ -125,6 +102,19 @@ export class BillingController {
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')
@@ -157,7 +147,7 @@ export class BillingController {
async payForApplication(
@Request() req: any,
@Param('applicationId') applicationId: string,
@Body() body: { planId?: string; cycle: string; amount?: number },
@Body() body: { cycle: string },
) {
const cycle = body.cycle as BillingCycle;
if (!Object.values(BillingCycle).includes(cycle)) {
@@ -166,32 +156,6 @@ export class BillingController {
const app = await this.applicationsService.findOne(applicationId, req.user.id);
let amount = body.amount;
let planId = body.planId || app.planId || '';
if (amount === undefined || amount === null) {
if (!body.planId) {
throw new BadRequestException('planId or amount is required');
}
const plan = await this.billingService.findPlan(body.planId);
const costs = await this.billingService.calculateCostForApp({
runtime: app.runtime,
databaseType: app.databaseType,
cpuLimit: app.cpuLimit,
memoryLimit: app.memoryLimit,
replicas: app.replicas,
dbStorageSize: app.dbStorageSize,
appStorageSize: app.appStorageSize,
enableRedis: app.enableRedis,
enableRabbitmq: app.enableRabbitmq,
enableElasticsearch: app.enableElasticsearch,
});
amount = cycle === BillingCycle.HOURLY ? costs.hourly
: cycle === BillingCycle.MONTHLY ? costs.monthly
: costs.yearly;
planId = body.planId;
}
const payment = await this.billingService.resolveAppPayment(
req.user.id,
app,
@@ -211,7 +175,6 @@ export class BillingController {
const activated = await this.lifecycleService.activateApp(
applicationId,
cycle,
planId,
payment.planExpiresAt,
);
@@ -348,7 +311,7 @@ export class BillingController {
);
// Activate the application
const renewedApp = await this.lifecycleService.activateApp(app.id, dto.cycle, app.planId || '');
const renewedApp = await this.lifecycleService.activateApp(app.id, dto.cycle);
return {
success: true,
@@ -381,7 +344,7 @@ export class BillingController {
if (body.bypassPayment) {
// Direct activation without payment (for special cases, support, etc.)
const renewedApp = await this.lifecycleService.activateApp(app.id, cycle, app.planId || '');
const renewedApp = await this.lifecycleService.activateApp(app.id, cycle);
return {
success: true,
bypassedPayment: true,
@@ -409,7 +372,7 @@ export class BillingController {
app.id,
);
const renewedApp = await this.lifecycleService.activateApp(app.id, cycle, app.planId || '');
const renewedApp = await this.lifecycleService.activateApp(app.id, cycle);
return {
success: true,