Make billing catalog dynamic for all runtimes and bill optional service resources.

Derive admin tabs and addon rows from enums, and add Redis/RabbitMQ/ES CPU/RAM/disk to deploy cost using the app runtime unit rates.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-15 18:22:35 +03:30
parent 35235fe0fc
commit bb27c90ae4
6 changed files with 292 additions and 109 deletions
+135 -63
View File
@@ -7,13 +7,19 @@ import {
AppRuntime,
BillingCycle,
DatabaseType,
OptionalService,
PricingResourceType,
} from '../common/enums';
import { CalculateCostDto } from './dto/billing.dto';
import {
ADDON_PRICING_RESOURCES,
BILLING_RUNTIMES,
FLUENT_BIT_SIDECAR,
getAllBillingRuntimes,
getBillableAddonResourceTypes,
OPTIONAL_SERVICE_DEPLOY_SPECS,
OPTIONAL_SERVICE_LABELS,
OPTIONAL_SERVICE_PRICING_TYPE,
RESOURCE_LABELS,
RUNTIME_DISPLAY_LABELS,
RUNTIME_PRICING_RESOURCES,
} from './pricing-catalog.constants';
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
@@ -32,9 +38,22 @@ export interface PricingRateRow {
isActive?: boolean;
}
export interface CatalogRuntimeOption {
value: AppRuntime;
label: string;
}
export interface CatalogOptionalServiceOption {
value: OptionalService;
label: string;
resourceType: PricingResourceType;
}
export interface PricingCatalogResponse {
runtimes: Record<AppRuntime, PricingRateRow[]>;
runtimes: Record<string, PricingRateRow[]>;
addons: PricingRateRow[];
runtimeOptions: CatalogRuntimeOption[];
optionalServiceOptions: CatalogOptionalServiceOption[];
}
export interface CostBreakdownLine {
@@ -58,7 +77,7 @@ export class PricingCatalogService implements OnModuleInit {
}
async ensureDefaults() {
for (const runtime of BILLING_RUNTIMES) {
for (const runtime of getAllBillingRuntimes()) {
for (const resourceType of RUNTIME_PRICING_RESOURCES) {
const existing = await this.rateRepo.findOne({ where: { runtime, resourceType } });
if (!existing) {
@@ -74,7 +93,7 @@ export class PricingCatalogService implements OnModuleInit {
}
}
}
for (const resourceType of ADDON_PRICING_RESOURCES) {
for (const resourceType of getBillableAddonResourceTypes()) {
const existing = await this.addonRepo.findOne({ where: { resourceType } });
if (!existing) {
await this.addonRepo.save(
@@ -93,20 +112,31 @@ export class PricingCatalogService implements OnModuleInit {
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) {
const billingRuntimes = getAllBillingRuntimes();
const runtimes: Record<string, PricingRateRow[]> = {};
for (const runtime of billingRuntimes) {
runtimes[runtime] = RUNTIME_PRICING_RESOURCES.map((resourceType) => {
const row = rates.find((r) => r.runtime === runtime && r.resourceType === resourceType);
return this.toRateRow(resourceType, row);
});
}
const addonTypes = getBillableAddonResourceTypes();
return {
runtimes,
addons: ADDON_PRICING_RESOURCES.map((resourceType) => {
addons: addonTypes.map((resourceType) => {
const row = addons.find((a) => a.resourceType === resourceType);
return this.toRateRow(resourceType, row);
}),
runtimeOptions: billingRuntimes.map((value) => ({
value,
label: RUNTIME_DISPLAY_LABELS[value] ?? value,
})),
optionalServiceOptions: Object.values(OptionalService).map((value) => ({
value,
label: OPTIONAL_SERVICE_LABELS[value] ?? value,
resourceType: OPTIONAL_SERVICE_PRICING_TYPE[value],
})),
};
}
@@ -114,7 +144,7 @@ export class PricingCatalogService implements OnModuleInit {
if (dto.runtimes) {
for (const [runtimeKey, rows] of Object.entries(dto.runtimes)) {
const runtime = runtimeKey as AppRuntime;
if (!BILLING_RUNTIMES.includes(runtime)) continue;
if (!getAllBillingRuntimes().includes(runtime)) continue;
for (const row of rows) {
await this.upsertRuntimeRate(runtime, row);
}
@@ -275,31 +305,91 @@ export class PricingCatalogService implements OnModuleInit {
}
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';
let cpuQty = this.parseCpuToCores(dto.cpuLimit) * replicas;
let memoryQty = this.parseMemoryToGb(dto.memoryLimit) * replicas;
let storageQty =
(dto.dbStorageSize
? parseFloat(String(dto.dbStorageSize).replace(/Gi$/i, '')) || 0
: 0) +
(dto.appStorageSize
? parseFloat(String(dto.appStorageSize).replace(/Gi$/i, '')) || 0
: 0);
const optionalResources = this.optionalServiceResourceTotals(dto, hasDatabase);
cpuQty += optionalResources.cpuCores;
memoryQty += optionalResources.memoryGb;
storageQty += optionalResources.storageGb;
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.CPU_PER_CORE, cpuQty);
map.set(PricingResourceType.MEMORY_PER_GB, memoryQty);
map.set(PricingResourceType.STORAGE_PER_GB, storageQty);
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.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;
}
/**
* Optional-service pods (and Fluent Bit sidecars when logging is on) bill CPU/RAM/storage
* using the same per-runtime unit rates as the main application.
*/
optionalServiceResourceTotals(
dto: CalculateCostDto,
hasDatabase: boolean,
): { cpuCores: number; memoryGb: number; storageGb: number } {
let cpuCores = 0;
let memoryGb = 0;
let storageGb = 0;
const logging = !!dto.enableElasticsearch;
const fbCpu = this.parseCpuToCores(FLUENT_BIT_SIDECAR.cpuLimit);
const fbMem = this.parseMemoryToGb(FLUENT_BIT_SIDECAR.memoryLimit);
const addWorkload = (service: OptionalService) => {
const spec = OPTIONAL_SERVICE_DEPLOY_SPECS[service];
cpuCores += this.parseCpuToCores(spec.cpuLimit);
memoryGb += this.parseMemoryToGb(spec.memoryLimit);
storageGb += spec.storageGi;
};
if (dto.enableRedis) addWorkload(OptionalService.REDIS);
if (dto.enableRabbitmq) addWorkload(OptionalService.RABBITMQ);
if (logging) {
cpuCores += fbCpu;
memoryGb += fbMem;
if (dto.enableRedis) {
cpuCores += fbCpu;
memoryGb += fbMem;
}
if (dto.enableRabbitmq) {
cpuCores += fbCpu;
memoryGb += fbMem;
}
if (hasDatabase) {
cpuCores += fbCpu;
memoryGb += fbMem;
}
}
return { cpuCores, memoryGb, storageGb };
}
parseCpuToCores(cpu: string): number {
if (!cpu) return 0;
if (cpu.endsWith('m')) return parseFloat(cpu) / 1000;
@@ -356,53 +446,35 @@ export class PricingCatalogService implements OnModuleInit {
}
/** Legacy shape for optional-services settings API */
async getOptionalServicesPricing(): Promise<{
redis: CyclePrices;
rabbitmq: CyclePrices;
elasticsearch: CyclePrices;
}> {
async getOptionalServicesPricing(): Promise<Record<OptionalService, CyclePrices>> {
const addons = await this.addonRepo.find();
const pick = (type: PricingResourceType): CyclePrices => {
const result = {} as Record<OptionalService, CyclePrices>;
for (const service of Object.values(OptionalService)) {
const type = OPTIONAL_SERVICE_PRICING_TYPE[service];
const row = addons.find((a) => a.resourceType === type);
return {
result[service] = {
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),
};
}
return result;
}
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,
});
async setOptionalServicesPricing(
pricing: Partial<Record<OptionalService, CyclePrices>>,
): Promise<Record<OptionalService, CyclePrices>> {
for (const service of Object.values(OptionalService)) {
const prices = pricing[service];
if (!prices) continue;
await this.upsertAddonRate({
resourceType: OPTIONAL_SERVICE_PRICING_TYPE[service],
hourlyPrice: prices.hourly,
monthlyPrice: prices.monthly,
yearlyPrice: prices.yearly,
isActive: true,
});
}
return this.getOptionalServicesPricing();
}