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
+117
View File
@@ -0,0 +1,117 @@
-- Usage-based pricing catalog (replaces per-cycle service_plans + scattered platform_settings)
CREATE TABLE IF NOT EXISTS pricing_rates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
runtime VARCHAR NOT NULL,
resource_type VARCHAR NOT NULL,
hourly_price DECIMAL(12, 2) NOT NULL DEFAULT 0,
monthly_price DECIMAL(12, 2) NOT NULL DEFAULT 0,
yearly_price DECIMAL(12, 2) NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (runtime, resource_type)
);
CREATE TABLE IF NOT EXISTS addon_rates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
resource_type VARCHAR NOT NULL UNIQUE,
hourly_price DECIMAL(12, 2) NOT NULL DEFAULT 0,
monthly_price DECIMAL(12, 2) NOT NULL DEFAULT 0,
yearly_price DECIMAL(12, 2) NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Seed addon rows
INSERT INTO addon_rates (resource_type, hourly_price, monthly_price, yearly_price)
VALUES
('redis_addon', 0, 0, 0),
('rabbitmq_addon', 0, 0, 0),
('elasticsearch_addon', 0, 0, 0),
('custom_domain_addon', 0, 0, 0)
ON CONFLICT (resource_type) DO NOTHING;
-- Migrate runtime rates from service_plans + pricing_rules
INSERT INTO pricing_rates (runtime, resource_type, hourly_price, monthly_price, yearly_price, is_active)
SELECT
sp.runtime,
pr.resource_type,
COALESCE(MAX(CASE WHEN sp."billingCycle" = 'hourly' THEN pr.unit_price END), 0),
COALESCE(MAX(CASE WHEN sp."billingCycle" = 'monthly' THEN pr.unit_price END), 0),
COALESCE(MAX(CASE WHEN sp."billingCycle" = 'yearly' THEN pr.unit_price END), 0),
BOOL_OR(sp."isActive")
FROM pricing_rules pr
JOIN service_plans sp ON sp.id = pr."planId"
WHERE pr.resource_type IN (
'base_fee', 'cpu_per_core', 'memory_per_gb', 'storage_per_gb', 'database_addon'
)
GROUP BY sp.runtime, pr.resource_type
ON CONFLICT (runtime, resource_type) DO UPDATE SET
hourly_price = EXCLUDED.hourly_price,
monthly_price = EXCLUDED.monthly_price,
yearly_price = EXCLUDED.yearly_price,
is_active = EXCLUDED.is_active,
"updatedAt" = NOW();
-- Optional services from platform_settings JSON
UPDATE addon_rates ar SET
hourly_price = COALESCE((s.parsed->'redis'->>'hourly')::decimal, 0),
monthly_price = COALESCE((s.parsed->'redis'->>'monthly')::decimal, 0),
yearly_price = COALESCE((s.parsed->'redis'->>'yearly')::decimal, 0)
FROM (
SELECT value::jsonb AS parsed
FROM platform_settings
WHERE key = 'optional_services_pricing_toman'
LIMIT 1
) s
WHERE ar.resource_type = 'redis_addon' AND s.parsed IS NOT NULL;
UPDATE addon_rates ar SET
hourly_price = COALESCE((s.parsed->'rabbitmq'->>'hourly')::decimal, 0),
monthly_price = COALESCE((s.parsed->'rabbitmq'->>'monthly')::decimal, 0),
yearly_price = COALESCE((s.parsed->'rabbitmq'->>'yearly')::decimal, 0)
FROM (
SELECT value::jsonb AS parsed
FROM platform_settings
WHERE key = 'optional_services_pricing_toman'
LIMIT 1
) s
WHERE ar.resource_type = 'rabbitmq_addon' AND s.parsed IS NOT NULL;
UPDATE addon_rates ar SET
hourly_price = COALESCE((s.parsed->'elasticsearch'->>'hourly')::decimal, 0),
monthly_price = COALESCE((s.parsed->'elasticsearch'->>'monthly')::decimal, 0),
yearly_price = COALESCE((s.parsed->'elasticsearch'->>'yearly')::decimal, 0)
FROM (
SELECT value::jsonb AS parsed
FROM platform_settings
WHERE key = 'optional_services_pricing_toman'
LIMIT 1
) s
WHERE ar.resource_type = 'elasticsearch_addon' AND s.parsed IS NOT NULL;
UPDATE addon_rates ar SET
monthly_price = COALESCE(s.price, 0)
FROM (
SELECT value::decimal AS price
FROM platform_settings
WHERE key = 'custom_domain_monthly_price_toman'
LIMIT 1
) s
WHERE ar.resource_type = 'custom_domain_addon' AND s.price IS NOT NULL;
-- Default runtime rows for nodejs, laravel, wordpress
INSERT INTO pricing_rates (runtime, resource_type, hourly_price, monthly_price, yearly_price)
SELECT r.runtime, t.resource_type, 0, 0, 0
FROM (VALUES ('nodejs'), ('laravel'), ('wordpress')) AS r(runtime)
CROSS JOIN (
VALUES
('base_fee'),
('cpu_per_core'),
('memory_per_gb'),
('storage_per_gb'),
('database_addon')
) AS t(resource_type)
ON CONFLICT (runtime, resource_type) DO NOTHING;
+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,
+12 -6
View File
@@ -2,11 +2,11 @@ import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingService } from './billing.service';
import { BillingController } from './billing.controller';
import { ServicePlan } from './entities/service-plan.entity';
import { PricingRule } from './entities/pricing-rule.entity';
import { PricingCatalogService } from './pricing-catalog.service';
import { PricingRate } from './entities/pricing-rate.entity';
import { AddonRate } from './entities/addon-rate.entity';
import { Wallet } from './entities/wallet.entity';
import { WalletTransaction } from './entities/wallet-transaction.entity';
import { PlatformSetting } from './entities/platform-setting.entity';
import { ResourceCredit } from './entities/resource-credit.entity';
import { LifecycleModule } from '../lifecycle/lifecycle.module';
import { ApplicationsModule } from '../applications/applications.module';
@@ -14,13 +14,19 @@ import { KubernetesModule } from '../kubernetes/kubernetes.module';
@Module({
imports: [
TypeOrmModule.forFeature([ServicePlan, PricingRule, Wallet, WalletTransaction, PlatformSetting, ResourceCredit]),
TypeOrmModule.forFeature([
PricingRate,
AddonRate,
Wallet,
WalletTransaction,
ResourceCredit,
]),
forwardRef(() => LifecycleModule),
forwardRef(() => ApplicationsModule),
forwardRef(() => KubernetesModule),
],
controllers: [BillingController],
providers: [BillingService],
exports: [BillingService],
providers: [BillingService, PricingCatalogService],
exports: [BillingService, PricingCatalogService],
})
export class BillingModule {}
+49 -238
View File
@@ -1,108 +1,41 @@
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ServicePlan } from './entities/service-plan.entity';
import { PricingRule } from './entities/pricing-rule.entity';
import { Repository, IsNull, MoreThan } from 'typeorm';
import { Wallet } from './entities/wallet.entity';
import { WalletTransaction } from './entities/wallet-transaction.entity';
import { PlatformSetting } from './entities/platform-setting.entity';
import { TransactionType, BillingCycle, PricingResourceType, DatabaseType } from '../common/enums';
import {
CreateServicePlanDto,
UpdateServicePlanDto,
CalculateCostDto,
UpgradeResourcesDto,
} from './dto/billing.dto';
import { TransactionType, BillingCycle, DatabaseType } from '../common/enums';
import { CalculateCostDto, UpgradeResourcesDto } from './dto/billing.dto';
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
import { Application } from '../applications/entities/application.entity';
import { ResourceCredit } from './entities/resource-credit.entity';
import { IsNull, MoreThan } from 'typeorm';
import { PricingCatalogService } from './pricing-catalog.service';
@Injectable()
export class BillingService {
private readonly logger = new Logger(BillingService.name);
constructor(
@InjectRepository(ServicePlan) private planRepo: Repository<ServicePlan>,
@InjectRepository(PricingRule) private ruleRepo: Repository<PricingRule>,
private readonly pricingCatalog: PricingCatalogService,
@InjectRepository(Wallet) private walletRepo: Repository<Wallet>,
@InjectRepository(WalletTransaction) private txRepo: Repository<WalletTransaction>,
@InjectRepository(PlatformSetting) private settingsRepo: Repository<PlatformSetting>,
@InjectRepository(ResourceCredit) private creditRepo: Repository<ResourceCredit>,
) {}
// ─── Service Plans ────────────────────────────────────────────────
// ─── Pricing catalog (Admin) ──────────────────────────────────────
async createPlan(dto: CreateServicePlanDto): Promise<ServicePlan> {
const plan = this.planRepo.create({
name: dto.name,
runtime: dto.runtime,
description: dto.description,
billingCycle: dto.billingCycle,
});
const saved = await this.planRepo.save(plan);
// Create pricing rules
const rules = dto.pricingRules.map((r) =>
this.ruleRepo.create({ ...r, planId: saved.id }),
);
await this.ruleRepo.save(rules);
return this.planRepo.findOne({ where: { id: saved.id }, relations: ['pricingRules'] }) as Promise<ServicePlan>;
getPricingCatalog() {
return this.pricingCatalog.getCatalog();
}
async updatePlan(id: string, dto: UpdateServicePlanDto): Promise<ServicePlan> {
const plan = await this.planRepo.findOne({ where: { id }, relations: ['pricingRules'] });
if (!plan) throw new NotFoundException('Plan not found');
if (dto.name !== undefined) plan.name = dto.name;
if (dto.runtime !== undefined) plan.runtime = dto.runtime;
if (dto.description !== undefined) plan.description = dto.description;
if (dto.billingCycle !== undefined) plan.billingCycle = dto.billingCycle;
if (dto.isActive !== undefined) plan.isActive = dto.isActive;
await this.planRepo.save(plan);
// If pricing rules are provided, replace them
if (dto.pricingRules) {
await this.ruleRepo.delete({ planId: id });
const rules = dto.pricingRules.map((r) =>
this.ruleRepo.create({ ...r, planId: id }),
);
await this.ruleRepo.save(rules);
}
return this.planRepo.findOne({ where: { id }, relations: ['pricingRules'] }) as Promise<ServicePlan>;
}
async deletePlan(id: string): Promise<void> {
const plan = await this.planRepo.findOne({ where: { id } });
if (!plan) throw new NotFoundException('Plan not found');
await this.planRepo.remove(plan);
}
async findAllPlans(): Promise<ServicePlan[]> {
return this.planRepo.find({ relations: ['pricingRules'], order: { createdAt: 'DESC' } });
}
async findActivePlans(): Promise<ServicePlan[]> {
return this.planRepo.find({
where: { isActive: true },
relations: ['pricingRules'],
order: { createdAt: 'DESC' },
});
}
async findPlan(id: string): Promise<ServicePlan> {
const plan = await this.planRepo.findOne({ where: { id }, relations: ['pricingRules'] });
if (!plan) throw new NotFoundException('Plan not found');
return plan;
updatePricingCatalog(dto: UpdatePricingCatalogDto) {
return this.pricingCatalog.updateCatalog(dto);
}
// ─── Cost Calculation ─────────────────────────────────────────────
/**
* Calculate cost for an application config against all active plans.
* Returns breakdown for each billing cycle (hourly, monthly, yearly).
* Calculate cost from the usage pricing catalog.
* Each billing cycle uses its own price column — no cross-cycle conversion.
*/
async calculateCost(dto: CalculateCostDto): Promise<{
hourly: number;
@@ -110,165 +43,29 @@ export class BillingService {
yearly: number;
breakdown: { label: string; hourly: number; monthly: number; yearly: number }[];
}> {
// Only use active plans that match the requested runtime
const plans = await this.planRepo.find({
where: { isActive: true, runtime: dto.runtime as any },
relations: ['pricingRules'],
});
if (plans.length === 0) {
return { hourly: 0, monthly: 0, yearly: 0, breakdown: [] };
}
// Gather all active pricing rules across all plans, grouped by cycle
const hourlyRules: PricingRule[] = [];
const monthlyRules: PricingRule[] = [];
const yearlyRules: PricingRule[] = [];
for (const plan of plans) {
switch (plan.billingCycle) {
case BillingCycle.HOURLY: hourlyRules.push(...plan.pricingRules); break;
case BillingCycle.MONTHLY: monthlyRules.push(...plan.pricingRules); break;
case BillingCycle.YEARLY: yearlyRules.push(...plan.pricingRules); break;
}
}
// Use the first available set of rules, or compute cross-conversions
const rules = hourlyRules.length > 0 ? hourlyRules : monthlyRules.length > 0 ? monthlyRules : yearlyRules;
const baseCycle = hourlyRules.length > 0 ? 'hourly' : monthlyRules.length > 0 ? 'monthly' : 'yearly';
// Parse resource values
const cpuCores = this.parseCpuToCores(dto.cpuLimit);
const memoryGb = this.parseMemoryToGb(dto.memoryLimit);
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 hasCustomDomain = dto.enableCustomDomain || false;
const breakdown: { label: string; hourly: number; monthly: number; yearly: number }[] = [];
let totalBase = 0;
for (const rule of rules) {
let cost = 0;
let label = '';
switch (rule.resourceType) {
case PricingResourceType.BASE_FEE:
cost = Number(rule.unitPrice);
label = 'Base fee';
break;
case PricingResourceType.CPU_PER_CORE:
cost = cpuCores * replicas * Number(rule.unitPrice);
label = `CPU (${(cpuCores * replicas).toFixed(2)} core)`;
break;
case PricingResourceType.MEMORY_PER_GB:
cost = memoryGb * replicas * Number(rule.unitPrice);
label = `Memory (${(memoryGb * replicas).toFixed(2)} GB)`;
break;
case PricingResourceType.STORAGE_PER_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;
case PricingResourceType.CUSTOM_DOMAIN_ADDON:
cost = hasCustomDomain ? Number(rule.unitPrice) : 0;
label = 'Custom domain + SSL';
break;
}
if (cost > 0) {
const hourly = baseCycle === 'hourly' ? cost : baseCycle === 'monthly' ? cost / 720 : cost / 8640;
const monthly = baseCycle === 'monthly' ? cost : baseCycle === 'hourly' ? cost * 720 : cost / 12;
const yearly = baseCycle === 'yearly' ? cost : baseCycle === 'monthly' ? cost * 12 : cost * 8640;
breakdown.push({ label, hourly: Math.round(hourly), monthly: Math.round(monthly), yearly: Math.round(yearly) });
totalBase += cost;
}
}
// If custom domain is enabled but no CUSTOM_DOMAIN_ADDON rule exists, use PlatformSetting price
if (hasCustomDomain && !breakdown.some((b) => b.label === 'Custom domain + SSL')) {
const setting = await this.settingsRepo.findOne({ where: { key: 'custom_domain_monthly_price_toman' } });
const monthlyPrice = setting ? Number(setting.value) : 0;
if (monthlyPrice > 0) {
const hourly = Math.round(monthlyPrice / 720);
const yearly = monthlyPrice * 12;
breakdown.push({ label: 'Custom domain + SSL', hourly, monthly: monthlyPrice, yearly });
totalBase += baseCycle === 'monthly' ? monthlyPrice : baseCycle === 'hourly' ? hourly : yearly;
}
}
const hourlyTotal = baseCycle === 'hourly' ? totalBase : baseCycle === 'monthly' ? totalBase / 720 : totalBase / 8640;
const monthlyTotal = baseCycle === 'monthly' ? totalBase : baseCycle === 'hourly' ? totalBase * 720 : totalBase / 12;
const yearlyTotal = baseCycle === 'yearly' ? totalBase : baseCycle === 'monthly' ? totalBase * 12 : totalBase * 8640;
return {
hourly: Math.round(hourlyTotal),
monthly: Math.round(monthlyTotal),
yearly: Math.round(yearlyTotal),
breakdown,
};
return this.pricingCatalog.computeTotalsFromDb(dto);
}
private parseCpuToCores(cpu: string): number {
if (!cpu) return 0;
if (cpu.endsWith('m')) return parseFloat(cpu) / 1000;
return parseFloat(cpu) || 0;
// ─── Optional services & custom domain (delegates to catalog) ─────
getOptionalServicesPricing() {
return this.pricingCatalog.getOptionalServicesPricing();
}
private parseMemoryToGb(memory: string): number {
if (!memory) return 0;
if (memory.endsWith('Gi')) return parseFloat(memory);
if (memory.endsWith('Mi')) return parseFloat(memory) / 1024;
if (memory.endsWith('Ki')) return parseFloat(memory) / (1024 * 1024);
return parseFloat(memory) / (1024 * 1024 * 1024);
setOptionalServicesPricing(pricing: {
redis: { hourly: number; monthly: number; yearly: number };
rabbitmq: { hourly: number; monthly: number; yearly: number };
elasticsearch: { hourly: number; monthly: number; yearly: number };
}) {
return this.pricingCatalog.setOptionalServicesPricing(pricing);
}
// ─── Custom Domain Pricing (PlatformSetting) ────────────────────
async getCustomDomainPrice(): Promise<{ monthlyPrice: number }> {
const setting = await this.settingsRepo.findOne({
where: { key: 'custom_domain_monthly_price_toman' },
});
return { monthlyPrice: setting ? Number(setting.value) : 0 };
getCustomDomainPrice() {
return this.pricingCatalog.getCustomDomainPrice();
}
async setCustomDomainPrice(monthlyPrice: number): Promise<{ monthlyPrice: number }> {
let setting = await this.settingsRepo.findOne({
where: { key: 'custom_domain_monthly_price_toman' },
});
if (setting) {
setting.value = String(monthlyPrice);
} else {
setting = this.settingsRepo.create({
key: 'custom_domain_monthly_price_toman',
value: String(monthlyPrice),
description: 'Monthly price for custom domain addon (Toman)',
});
}
await this.settingsRepo.save(setting);
this.logger.log(`Custom domain monthly price updated: ${monthlyPrice} Toman`);
return { monthlyPrice };
setCustomDomainPrice(monthlyPrice: number) {
return this.pricingCatalog.setCustomDomainPrice(monthlyPrice);
}
// ─── Wallet ───────────────────────────────────────────────────────
@@ -604,11 +401,15 @@ export class BillingService {
) {
return false;
}
if (this.parseCpuToCores(config.cpuLimit) > this.parseCpuToCores(credit.cpuLimit)) {
if (
this.pricingCatalog.parseCpuToCores(config.cpuLimit) >
this.pricingCatalog.parseCpuToCores(credit.cpuLimit)
) {
return false;
}
if (
this.parseMemoryToGb(config.memoryLimit) > this.parseMemoryToGb(credit.memoryLimit)
this.pricingCatalog.parseMemoryToGb(config.memoryLimit) >
this.pricingCatalog.parseMemoryToGb(credit.memoryLimit)
) {
return false;
}
@@ -754,8 +555,12 @@ export class BillingService {
for (const hint of labelHints) {
const afterLine = after.breakdown.find((b) => b.label === hint);
const beforeLine = before.breakdown.find((b) => b.label === hint);
const afterAmt = afterLine ? this.amountForCycle(afterLine, cycle) : 0;
const beforeAmt = beforeLine ? this.amountForCycle(beforeLine, cycle) : 0;
const afterAmt = afterLine
? this.pricingCatalog.amountForCycleFromLine(afterLine, cycle)
: 0;
const beforeAmt = beforeLine
? this.pricingCatalog.amountForCycleFromLine(beforeLine, cycle)
: 0;
sum += Math.max(0, afterAmt - beforeAmt);
}
return sum;
@@ -775,7 +580,10 @@ export class BillingService {
const items: { label: string; amount: number; fullPeriodAmount?: number }[] = [];
const baseline = this.creditBaselineDto(credit);
if (this.parseCpuToCores(config.cpuLimit) > this.parseCpuToCores(credit.cpuLimit)) {
if (
this.pricingCatalog.parseCpuToCores(config.cpuLimit) >
this.pricingCatalog.parseCpuToCores(credit.cpuLimit)
) {
await this.addExtraLineProrated(
items,
baseline,
@@ -785,7 +593,10 @@ export class BillingService {
);
}
if (this.parseMemoryToGb(config.memoryLimit) > this.parseMemoryToGb(credit.memoryLimit)) {
if (
this.pricingCatalog.parseMemoryToGb(config.memoryLimit) >
this.pricingCatalog.parseMemoryToGb(credit.memoryLimit)
) {
await this.addExtraLineProrated(
items,
baseline,
+36
View File
@@ -197,3 +197,39 @@ export class UpgradeResourcesDto {
export class CalculateUpgradeCostDto extends UpgradeResourcesDto {}
// ─── Platform optional services pricing (Admin) ─────────────────────
export class OptionalServiceCyclePricesDto {
@ApiProperty({ example: 500 })
@IsNumber()
@Min(0)
hourly: number;
@ApiProperty({ example: 50000 })
@IsNumber()
@Min(0)
monthly: number;
@ApiProperty({ example: 500000 })
@IsNumber()
@Min(0)
yearly: number;
}
export class SetOptionalServicesPricingDto {
@ApiProperty({ type: OptionalServiceCyclePricesDto })
@ValidateNested()
@Type(() => OptionalServiceCyclePricesDto)
redis: OptionalServiceCyclePricesDto;
@ApiProperty({ type: OptionalServiceCyclePricesDto })
@ValidateNested()
@Type(() => OptionalServiceCyclePricesDto)
rabbitmq: OptionalServiceCyclePricesDto;
@ApiProperty({ type: OptionalServiceCyclePricesDto })
@ValidateNested()
@Type(() => OptionalServiceCyclePricesDto)
elasticsearch: OptionalServiceCyclePricesDto;
}
@@ -0,0 +1,58 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsArray,
IsBoolean,
IsEnum,
IsNumber,
IsObject,
IsOptional,
Min,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { AppRuntime, PricingResourceType } from '../../common/enums';
export class PricingRateRowDto {
@ApiProperty({ enum: PricingResourceType })
@IsEnum(PricingResourceType)
resourceType: PricingResourceType;
@ApiProperty({ example: 500 })
@IsNumber()
@Min(0)
hourlyPrice: number;
@ApiProperty({ example: 50000 })
@IsNumber()
@Min(0)
monthlyPrice: number;
@ApiProperty({ example: 500000 })
@IsNumber()
@Min(0)
yearlyPrice: number;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UpdatePricingCatalogDto {
@ApiPropertyOptional({
description: 'Per-runtime pricing rows keyed by runtime',
example: {
nodejs: [{ resourceType: 'base_fee', hourlyPrice: 0, monthlyPrice: 10000, yearlyPrice: 100000 }],
},
})
@IsOptional()
@IsObject()
runtimes?: Partial<Record<AppRuntime, PricingRateRowDto[]>>;
@ApiPropertyOptional({ type: [PricingRateRowDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => PricingRateRowDto)
addons?: PricingRateRowDto[];
}
@@ -0,0 +1,35 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { PricingResourceType } from '../../common/enums';
@Entity('addon_rates')
export class AddonRate {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'enum', enum: PricingResourceType, unique: true })
resourceType: PricingResourceType;
@Column({ type: 'decimal', precision: 12, scale: 2, default: 0 })
hourlyPrice: number;
@Column({ type: 'decimal', precision: 12, scale: 2, default: 0 })
monthlyPrice: number;
@Column({ type: 'decimal', precision: 12, scale: 2, default: 0 })
yearlyPrice: number;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,40 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Unique,
} from 'typeorm';
import { AppRuntime, PricingResourceType } from '../../common/enums';
@Entity('pricing_rates')
@Unique(['runtime', 'resourceType'])
export class PricingRate {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'enum', enum: AppRuntime })
runtime: AppRuntime;
@Column({ type: 'enum', enum: PricingResourceType })
resourceType: PricingResourceType;
@Column({ type: 'decimal', precision: 12, scale: 2, default: 0 })
hourlyPrice: number;
@Column({ type: 'decimal', precision: 12, scale: 2, default: 0 })
monthlyPrice: number;
@Column({ type: 'decimal', precision: 12, scale: 2, default: 0 })
yearlyPrice: number;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,34 @@
import { AppRuntime, PricingResourceType } from '../common/enums';
export const BILLING_RUNTIMES: AppRuntime[] = [
AppRuntime.NODEJS,
AppRuntime.LARAVEL,
AppRuntime.WORDPRESS,
];
export const RUNTIME_PRICING_RESOURCES: PricingResourceType[] = [
PricingResourceType.BASE_FEE,
PricingResourceType.CPU_PER_CORE,
PricingResourceType.MEMORY_PER_GB,
PricingResourceType.STORAGE_PER_GB,
PricingResourceType.DATABASE_ADDON,
];
export const ADDON_PRICING_RESOURCES: PricingResourceType[] = [
PricingResourceType.REDIS_ADDON,
PricingResourceType.RABBITMQ_ADDON,
PricingResourceType.ELASTICSEARCH_ADDON,
PricingResourceType.CUSTOM_DOMAIN_ADDON,
];
export const RESOURCE_LABELS: Record<PricingResourceType, string> = {
[PricingResourceType.BASE_FEE]: 'Base fee',
[PricingResourceType.CPU_PER_CORE]: 'CPU (per core)',
[PricingResourceType.MEMORY_PER_GB]: 'Memory (per GB)',
[PricingResourceType.STORAGE_PER_GB]: 'Storage (per GB)',
[PricingResourceType.DATABASE_ADDON]: 'Database addon',
[PricingResourceType.REDIS_ADDON]: 'Redis addon',
[PricingResourceType.RABBITMQ_ADDON]: 'RabbitMQ addon',
[PricingResourceType.ELASTICSEARCH_ADDON]: 'Elasticsearch addon',
[PricingResourceType.CUSTOM_DOMAIN_ADDON]: 'Custom domain + SSL',
};
@@ -0,0 +1,124 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { PricingCatalogService } from './pricing-catalog.service';
import { PricingRate } from './entities/pricing-rate.entity';
import { AddonRate } from './entities/addon-rate.entity';
import {
AppRuntime,
BillingCycle,
DatabaseType,
PricingResourceType,
} from '../common/enums';
import { CalculateCostDto } from './dto/billing.dto';
describe('PricingCatalogService', () => {
let service: PricingCatalogService;
const rateRepo = {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockResolvedValue(null),
save: jest.fn().mockImplementation((x) => Promise.resolve(x)),
create: jest.fn().mockImplementation((x) => x),
};
const addonRepo = {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockResolvedValue(null),
save: jest.fn().mockImplementation((x) => Promise.resolve(x)),
create: jest.fn().mockImplementation((x) => x),
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
PricingCatalogService,
{ provide: getRepositoryToken(PricingRate), useValue: rateRepo },
{ provide: getRepositoryToken(AddonRate), useValue: addonRepo },
],
}).compile();
service = module.get(PricingCatalogService);
});
const baseDto = (): CalculateCostDto => ({
runtime: 'nodejs',
databaseType: DatabaseType.NONE,
cpuLimit: '500m',
memoryLimit: '512Mi',
replicas: 1,
});
it('parses CPU millicores to cores', () => {
expect(service.parseCpuToCores('500m')).toBe(0.5);
expect(service.parseCpuToCores('2')).toBe(2);
});
it('computes CPU line with cycle-native prices (no conversion)', () => {
const rates = [
{
runtime: AppRuntime.NODEJS,
resourceType: PricingResourceType.CPU_PER_CORE,
hourlyPrice: 100,
monthlyPrice: 5000,
yearlyPrice: 50000,
isActive: true,
},
] as PricingRate[];
const result = service.computeTotalsWithRates(baseDto(), rates, []);
expect(result.hourly).toBe(50);
expect(result.monthly).toBe(2500);
expect(result.yearly).toBe(25000);
expect(result.breakdown[0].label).toContain('CPU');
});
it('includes redis addon only when enabled', () => {
const addons = [
{
resourceType: PricingResourceType.REDIS_ADDON,
hourlyPrice: 10,
monthlyPrice: 100,
yearlyPrice: 1000,
isActive: true,
},
] as AddonRate[];
const without = service.computeTotalsWithRates(baseDto(), [], addons);
expect(without.monthly).toBe(0);
const withRedis = service.computeTotalsWithRates(
{ ...baseDto(), enableRedis: true },
[],
addons,
);
expect(withRedis.monthly).toBe(100);
expect(withRedis.yearly).toBe(1000);
expect(withRedis.hourly).toBe(10);
});
it('yearly deploy uses yearly column not monthly * 12', () => {
const rates = [
{
runtime: AppRuntime.NODEJS,
resourceType: PricingResourceType.BASE_FEE,
hourlyPrice: 1,
monthlyPrice: 100,
yearlyPrice: 999,
isActive: true,
},
] as PricingRate[];
const result = service.computeTotalsWithRates(baseDto(), rates, []);
expect(result.yearly).toBe(999);
expect(result.monthly).toBe(100);
expect(result.yearly).not.toBe(result.monthly * 12);
});
it('amountForCycleFromLine picks the correct column', () => {
const line = { label: 'Test', hourly: 1, monthly: 2, yearly: 3 };
expect(service.amountForCycleFromLine(line, BillingCycle.HOURLY)).toBe(1);
expect(service.amountForCycleFromLine(line, BillingCycle.MONTHLY)).toBe(2);
expect(service.amountForCycleFromLine(line, BillingCycle.YEARLY)).toBe(3);
});
});
@@ -0,0 +1,426 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PricingRate } from './entities/pricing-rate.entity';
import { AddonRate } from './entities/addon-rate.entity';
import {
AppRuntime,
BillingCycle,
DatabaseType,
PricingResourceType,
} from '../common/enums';
import { CalculateCostDto } from './dto/billing.dto';
import {
ADDON_PRICING_RESOURCES,
BILLING_RUNTIMES,
RESOURCE_LABELS,
RUNTIME_PRICING_RESOURCES,
} from './pricing-catalog.constants';
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
export interface CyclePrices {
hourly: number;
monthly: number;
yearly: number;
}
export interface PricingRateRow {
resourceType: PricingResourceType;
hourlyPrice: number;
monthlyPrice: number;
yearlyPrice: number;
isActive?: boolean;
}
export interface PricingCatalogResponse {
runtimes: Record<AppRuntime, PricingRateRow[]>;
addons: PricingRateRow[];
}
export interface CostBreakdownLine {
label: string;
hourly: number;
monthly: number;
yearly: number;
}
@Injectable()
export class PricingCatalogService implements OnModuleInit {
private readonly logger = new Logger(PricingCatalogService.name);
constructor(
@InjectRepository(PricingRate) private readonly rateRepo: Repository<PricingRate>,
@InjectRepository(AddonRate) private readonly addonRepo: Repository<AddonRate>,
) {}
async onModuleInit() {
await this.ensureDefaults();
}
async ensureDefaults() {
for (const runtime of BILLING_RUNTIMES) {
for (const resourceType of RUNTIME_PRICING_RESOURCES) {
const existing = await this.rateRepo.findOne({ where: { runtime, resourceType } });
if (!existing) {
await this.rateRepo.save(
this.rateRepo.create({
runtime,
resourceType,
hourlyPrice: 0,
monthlyPrice: 0,
yearlyPrice: 0,
}),
);
}
}
}
for (const resourceType of ADDON_PRICING_RESOURCES) {
const existing = await this.addonRepo.findOne({ where: { resourceType } });
if (!existing) {
await this.addonRepo.save(
this.addonRepo.create({
resourceType,
hourlyPrice: 0,
monthlyPrice: 0,
yearlyPrice: 0,
}),
);
}
}
}
async getCatalog(): Promise<PricingCatalogResponse> {
const rates = await this.rateRepo.find({ order: { runtime: 'ASC', resourceType: 'ASC' } });
const addons = await this.addonRepo.find({ order: { resourceType: 'ASC' } });
const runtimes = {} as Record<AppRuntime, PricingRateRow[]>;
for (const runtime of BILLING_RUNTIMES) {
runtimes[runtime] = RUNTIME_PRICING_RESOURCES.map((resourceType) => {
const row = rates.find((r) => r.runtime === runtime && r.resourceType === resourceType);
return this.toRateRow(resourceType, row);
});
}
return {
runtimes,
addons: ADDON_PRICING_RESOURCES.map((resourceType) => {
const row = addons.find((a) => a.resourceType === resourceType);
return this.toRateRow(resourceType, row);
}),
};
}
async updateCatalog(dto: UpdatePricingCatalogDto): Promise<PricingCatalogResponse> {
if (dto.runtimes) {
for (const [runtimeKey, rows] of Object.entries(dto.runtimes)) {
const runtime = runtimeKey as AppRuntime;
if (!BILLING_RUNTIMES.includes(runtime)) continue;
for (const row of rows) {
await this.upsertRuntimeRate(runtime, row);
}
}
}
if (dto.addons) {
for (const row of dto.addons) {
await this.upsertAddonRate(row);
}
}
return this.getCatalog();
}
async getRatesForRuntime(runtime: AppRuntime): Promise<PricingRate[]> {
return this.rateRepo.find({
where: { runtime, isActive: true },
});
}
async getAddonRates(): Promise<AddonRate[]> {
return this.addonRepo.find({ where: { isActive: true } });
}
computeTotals(dto: CalculateCostDto): {
hourly: number;
monthly: number;
yearly: number;
breakdown: CostBreakdownLine[];
} {
const lines = this.computeLineItemsSync(dto);
const hourly = lines.reduce((s, l) => s + l.hourly, 0);
const monthly = lines.reduce((s, l) => s + l.monthly, 0);
const yearly = lines.reduce((s, l) => s + l.yearly, 0);
return {
hourly: Math.round(hourly),
monthly: Math.round(monthly),
yearly: Math.round(yearly),
breakdown: lines,
};
}
async computeTotalsFromDb(dto: CalculateCostDto) {
const runtime = dto.runtime as AppRuntime;
const rates = await this.getRatesForRuntime(runtime);
const addons = await this.getAddonRates();
return this.computeTotalsWithRates(dto, rates, addons);
}
computeTotalsWithRates(
dto: CalculateCostDto,
rates: PricingRate[],
addons: AddonRate[],
) {
const lines = this.buildLines(dto, rates, addons);
const hourly = lines.reduce((s, l) => s + l.hourly, 0);
const monthly = lines.reduce((s, l) => s + l.monthly, 0);
const yearly = lines.reduce((s, l) => s + l.yearly, 0);
return {
hourly: Math.round(hourly),
monthly: Math.round(monthly),
yearly: Math.round(yearly),
breakdown: lines,
};
}
amountForCycleFromLine(line: CostBreakdownLine, cycle: BillingCycle): number {
switch (cycle) {
case BillingCycle.HOURLY:
return line.hourly;
case BillingCycle.MONTHLY:
return line.monthly;
case BillingCycle.YEARLY:
return line.yearly;
default:
return line.monthly;
}
}
private computeLineItemsSync(dto: CalculateCostDto): CostBreakdownLine[] {
return this.buildLines(dto, [], []);
}
private buildLines(
dto: CalculateCostDto,
rates: PricingRate[],
addons: AddonRate[],
): CostBreakdownLine[] {
const lines: CostBreakdownLine[] = [];
const quantities = this.getQuantities(dto);
for (const rate of rates) {
const qty = quantities.get(rate.resourceType) ?? 0;
if (qty <= 0) continue;
const line = this.lineFromPrices(
RESOURCE_LABELS[rate.resourceType],
qty,
Number(rate.hourlyPrice),
Number(rate.monthlyPrice),
Number(rate.yearlyPrice),
rate.resourceType,
dto,
);
if (line) lines.push(line);
}
for (const addon of addons) {
const qty = quantities.get(addon.resourceType) ?? 0;
if (qty <= 0) continue;
const line = this.lineFromPrices(
RESOURCE_LABELS[addon.resourceType],
qty,
Number(addon.hourlyPrice),
Number(addon.monthlyPrice),
Number(addon.yearlyPrice),
addon.resourceType,
dto,
);
if (line) lines.push(line);
}
return lines;
}
private lineFromPrices(
baseLabel: string,
quantity: number,
hourlyUnit: number,
monthlyUnit: number,
yearlyUnit: number,
resourceType: PricingResourceType,
dto: CalculateCostDto,
): CostBreakdownLine | null {
const hourly = Math.round(quantity * hourlyUnit);
const monthly = Math.round(quantity * monthlyUnit);
const yearly = Math.round(quantity * yearlyUnit);
if (hourly <= 0 && monthly <= 0 && yearly <= 0) return null;
const label = this.describeLine(baseLabel, resourceType, quantity, dto);
return { label, hourly, monthly, yearly };
}
private describeLine(
baseLabel: string,
resourceType: PricingResourceType,
quantity: number,
dto: CalculateCostDto,
): string {
switch (resourceType) {
case PricingResourceType.CPU_PER_CORE:
return `CPU (${quantity.toFixed(2)} core)`;
case PricingResourceType.MEMORY_PER_GB:
return `Memory (${quantity.toFixed(2)} GB)`;
case PricingResourceType.STORAGE_PER_GB:
return `Storage (${quantity} GB)`;
default:
return baseLabel;
}
}
getQuantities(dto: CalculateCostDto): Map<PricingResourceType, number> {
const cpuCores = this.parseCpuToCores(dto.cpuLimit);
const memoryGb = this.parseMemoryToGb(dto.memoryLimit);
const replicas = dto.replicas || 1;
const dbStorageGb = dto.dbStorageSize
? parseFloat(String(dto.dbStorageSize).replace(/Gi$/i, '')) || 0
: 0;
const appStorageGb = dto.appStorageSize
? parseFloat(String(dto.appStorageSize).replace(/Gi$/i, '')) || 0
: 0;
const totalStorageGb = dbStorageGb + appStorageGb;
const hasDatabase = dto.databaseType !== DatabaseType.NONE && dto.databaseType !== 'none';
const map = new Map<PricingResourceType, number>();
map.set(PricingResourceType.BASE_FEE, 1);
map.set(PricingResourceType.CPU_PER_CORE, cpuCores * replicas);
map.set(PricingResourceType.MEMORY_PER_GB, memoryGb * replicas);
map.set(PricingResourceType.STORAGE_PER_GB, totalStorageGb);
map.set(PricingResourceType.DATABASE_ADDON, hasDatabase ? 1 : 0);
map.set(PricingResourceType.REDIS_ADDON, dto.enableRedis ? 1 : 0);
map.set(PricingResourceType.RABBITMQ_ADDON, dto.enableRabbitmq ? 1 : 0);
map.set(PricingResourceType.ELASTICSEARCH_ADDON, dto.enableElasticsearch ? 1 : 0);
map.set(PricingResourceType.CUSTOM_DOMAIN_ADDON, dto.enableCustomDomain ? 1 : 0);
return map;
}
parseCpuToCores(cpu: string): number {
if (!cpu) return 0;
if (cpu.endsWith('m')) return parseFloat(cpu) / 1000;
return parseFloat(cpu) || 0;
}
parseMemoryToGb(memory: string): number {
if (!memory) return 0;
if (memory.endsWith('Gi')) return parseFloat(memory);
if (memory.endsWith('Mi')) return parseFloat(memory) / 1024;
if (memory.endsWith('Ki')) return parseFloat(memory) / (1024 * 1024);
return parseFloat(memory) / (1024 * 1024 * 1024);
}
private async upsertRuntimeRate(runtime: AppRuntime, row: PricingRateRow) {
let entity = await this.rateRepo.findOne({
where: { runtime, resourceType: row.resourceType },
});
if (!entity) {
entity = this.rateRepo.create({ runtime, resourceType: row.resourceType });
}
entity.hourlyPrice = row.hourlyPrice;
entity.monthlyPrice = row.monthlyPrice;
entity.yearlyPrice = row.yearlyPrice;
if (row.isActive !== undefined) entity.isActive = row.isActive;
await this.rateRepo.save(entity);
}
private async upsertAddonRate(row: PricingRateRow) {
let entity = await this.addonRepo.findOne({
where: { resourceType: row.resourceType },
});
if (!entity) {
entity = this.addonRepo.create({ resourceType: row.resourceType });
}
entity.hourlyPrice = row.hourlyPrice;
entity.monthlyPrice = row.monthlyPrice;
entity.yearlyPrice = row.yearlyPrice;
if (row.isActive !== undefined) entity.isActive = row.isActive;
await this.addonRepo.save(entity);
}
private toRateRow(
resourceType: PricingResourceType,
entity?: PricingRate | AddonRate | null,
): PricingRateRow {
return {
resourceType,
hourlyPrice: entity ? Number(entity.hourlyPrice) : 0,
monthlyPrice: entity ? Number(entity.monthlyPrice) : 0,
yearlyPrice: entity ? Number(entity.yearlyPrice) : 0,
isActive: entity?.isActive ?? true,
};
}
/** Legacy shape for optional-services settings API */
async getOptionalServicesPricing(): Promise<{
redis: CyclePrices;
rabbitmq: CyclePrices;
elasticsearch: CyclePrices;
}> {
const addons = await this.addonRepo.find();
const pick = (type: PricingResourceType): CyclePrices => {
const row = addons.find((a) => a.resourceType === type);
return {
hourly: row ? Number(row.hourlyPrice) : 0,
monthly: row ? Number(row.monthlyPrice) : 0,
yearly: row ? Number(row.yearlyPrice) : 0,
};
};
return {
redis: pick(PricingResourceType.REDIS_ADDON),
rabbitmq: pick(PricingResourceType.RABBITMQ_ADDON),
elasticsearch: pick(PricingResourceType.ELASTICSEARCH_ADDON),
};
}
async setOptionalServicesPricing(pricing: {
redis: CyclePrices;
rabbitmq: CyclePrices;
elasticsearch: CyclePrices;
}) {
await this.upsertAddonRate({
resourceType: PricingResourceType.REDIS_ADDON,
hourlyPrice: pricing.redis.hourly,
monthlyPrice: pricing.redis.monthly,
yearlyPrice: pricing.redis.yearly,
isActive: true,
});
await this.upsertAddonRate({
resourceType: PricingResourceType.RABBITMQ_ADDON,
hourlyPrice: pricing.rabbitmq.hourly,
monthlyPrice: pricing.rabbitmq.monthly,
yearlyPrice: pricing.rabbitmq.yearly,
isActive: true,
});
await this.upsertAddonRate({
resourceType: PricingResourceType.ELASTICSEARCH_ADDON,
hourlyPrice: pricing.elasticsearch.hourly,
monthlyPrice: pricing.elasticsearch.monthly,
yearlyPrice: pricing.elasticsearch.yearly,
isActive: true,
});
return this.getOptionalServicesPricing();
}
async getCustomDomainPrice(): Promise<{ monthlyPrice: number }> {
const row = await this.addonRepo.findOne({
where: { resourceType: PricingResourceType.CUSTOM_DOMAIN_ADDON },
});
return { monthlyPrice: row ? Number(row.monthlyPrice) : 0 };
}
async setCustomDomainPrice(monthlyPrice: number): Promise<{ monthlyPrice: number }> {
await this.upsertAddonRate({
resourceType: PricingResourceType.CUSTOM_DOMAIN_ADDON,
hourlyPrice: 0,
monthlyPrice,
yearlyPrice: 0,
isActive: true,
});
return { monthlyPrice };
}
}
@@ -57,7 +57,6 @@ export class AppLifecycleService implements OnModuleInit, OnModuleDestroy {
async activateApp(
appId: string,
billingCycle: BillingCycle,
planId: string,
planExpiresAt?: Date,
): Promise<Application> {
const app = await this.appRepo.findOne({ where: { id: appId } });
@@ -68,7 +67,6 @@ export class AppLifecycleService implements OnModuleInit, OnModuleDestroy {
? planExpiresAt
: this.calculateExpiry(now, billingCycle);
app.planId = planId;
app.billingCycle = billingCycle;
app.lifecycleStatus = AppLifecycleStatus.ACTIVE;
app.planExpiresAt = expiresAt;
@@ -225,7 +223,7 @@ export class AppLifecycleService implements OnModuleInit, OnModuleDestroy {
// ─── Auto-renew for hourly plans ──────────────────────────────────
private async tryAutoRenew(app: Application): Promise<boolean> {
if (!app.planId) return false;
if (!app.billingCycle) return false;
try {
const cost = await this.billingService.calculateCostForApp(app);