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
@@ -0,0 +1,24 @@
-- Ensure pricing_rates rows exist for every AppRuntime enum value
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'),
('go'),
('php'),
('python'),
('django'),
('dotnet')
) 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;
@@ -1,10 +1,20 @@
import { AppRuntime, PricingResourceType } from '../common/enums';
import { AppRuntime, OptionalService, PricingResourceType } from '../common/enums';
export const BILLING_RUNTIMES: AppRuntime[] = [
AppRuntime.NODEJS,
AppRuntime.LARAVEL,
AppRuntime.WORDPRESS,
];
/** All application runtimes — new enum values appear in billing automatically. */
export function getAllBillingRuntimes(): AppRuntime[] {
return Object.values(AppRuntime);
}
export const RUNTIME_DISPLAY_LABELS: Record<AppRuntime, string> = {
[AppRuntime.NODEJS]: 'Node.js',
[AppRuntime.LARAVEL]: 'Laravel',
[AppRuntime.WORDPRESS]: 'WordPress',
[AppRuntime.GO]: 'Go',
[AppRuntime.PHP]: 'PHP',
[AppRuntime.PYTHON]: 'Python',
[AppRuntime.DJANGO]: 'Django',
[AppRuntime.DOTNET]: '.NET',
};
export const RUNTIME_PRICING_RESOURCES: PricingResourceType[] = [
PricingResourceType.BASE_FEE,
@@ -14,12 +24,38 @@ export const RUNTIME_PRICING_RESOURCES: PricingResourceType[] = [
PricingResourceType.DATABASE_ADDON,
];
export const ADDON_PRICING_RESOURCES: PricingResourceType[] = [
PricingResourceType.REDIS_ADDON,
PricingResourceType.RABBITMQ_ADDON,
PricingResourceType.ELASTICSEARCH_ADDON,
PricingResourceType.CUSTOM_DOMAIN_ADDON,
];
/** Maps optional service → flat addon row in addon_rates. */
export const OPTIONAL_SERVICE_PRICING_TYPE: Record<OptionalService, PricingResourceType> = {
[OptionalService.REDIS]: PricingResourceType.REDIS_ADDON,
[OptionalService.RABBITMQ]: PricingResourceType.RABBITMQ_ADDON,
[OptionalService.ELASTICSEARCH]: PricingResourceType.ELASTICSEARCH_ADDON,
};
export function getBillableAddonResourceTypes(): PricingResourceType[] {
return [
...Object.values(OPTIONAL_SERVICE_PRICING_TYPE),
PricingResourceType.CUSTOM_DOMAIN_ADDON,
];
}
export const OPTIONAL_SERVICE_LABELS: Record<OptionalService, string> = {
[OptionalService.REDIS]: 'Redis',
[OptionalService.RABBITMQ]: 'RabbitMQ',
[OptionalService.ELASTICSEARCH]: 'Elasticsearch (logging)',
};
/** Deploy footprint aligned with helm/cloudhost-app defaults (limits used for billing). */
export const OPTIONAL_SERVICE_DEPLOY_SPECS: Record<
OptionalService,
{ cpuLimit: string; memoryLimit: string; storageGi: number }
> = {
[OptionalService.REDIS]: { cpuLimit: '200m', memoryLimit: '256Mi', storageGi: 1 },
[OptionalService.RABBITMQ]: { cpuLimit: '500m', memoryLimit: '512Mi', storageGi: 2 },
[OptionalService.ELASTICSEARCH]: { cpuLimit: '50m', memoryLimit: '64Mi', storageGi: 0 },
};
/** Fluent Bit sidecar per workload when Elasticsearch logging is enabled. */
export const FLUENT_BIT_SIDECAR = { cpuLimit: '50m', memoryLimit: '64Mi' };
export const RESOURCE_LABELS: Record<PricingResourceType, string> = {
[PricingResourceType.BASE_FEE]: 'Base fee',
@@ -27,8 +63,14 @@ export const RESOURCE_LABELS: Record<PricingResourceType, string> = {
[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.REDIS_ADDON]: 'Redis (flat addon)',
[PricingResourceType.RABBITMQ_ADDON]: 'RabbitMQ (flat addon)',
[PricingResourceType.ELASTICSEARCH_ADDON]: 'Elasticsearch (flat addon)',
[PricingResourceType.CUSTOM_DOMAIN_ADDON]: 'Custom domain + SSL',
};
/** @deprecated Use getAllBillingRuntimes() */
export const BILLING_RUNTIMES = getAllBillingRuntimes();
/** @deprecated Use getBillableAddonResourceTypes() */
export const ADDON_PRICING_RESOURCES = getBillableAddonResourceTypes();
@@ -7,8 +7,10 @@ import {
AppRuntime,
BillingCycle,
DatabaseType,
OptionalService,
PricingResourceType,
} from '../common/enums';
import { OPTIONAL_SERVICE_DEPLOY_SPECS } from './pricing-catalog.constants';
import { CalculateCostDto } from './dto/billing.dto';
describe('PricingCatalogService', () => {
@@ -115,6 +117,38 @@ describe('PricingCatalogService', () => {
expect(result.yearly).not.toBe(result.monthly * 12);
});
it('bills optional service CPU/RAM/storage with same runtime unit rates', () => {
const rates = [
{
runtime: AppRuntime.NODEJS,
resourceType: PricingResourceType.CPU_PER_CORE,
hourlyPrice: 1000,
monthlyPrice: 0,
yearlyPrice: 0,
isActive: true,
},
{
runtime: AppRuntime.NODEJS,
resourceType: PricingResourceType.MEMORY_PER_GB,
hourlyPrice: 0,
monthlyPrice: 0,
yearlyPrice: 0,
isActive: true,
},
] as PricingRate[];
const redisCpu = parseFloat(OPTIONAL_SERVICE_DEPLOY_SPECS[OptionalService.REDIS].cpuLimit) / 1000;
const without = service.computeTotalsWithRates(baseDto(), rates, []);
const withRedis = service.computeTotalsWithRates(
{ ...baseDto(), enableRedis: true },
rates,
[],
);
expect(withRedis.hourly - without.hourly).toBe(Math.round(redisCpu * 1000));
});
it('amountForCycleFromLine picks the correct column', () => {
const line = { label: 'Test', hourly: 1, monthly: 2, yearly: 3 };
expect(service.amountForCycleFromLine(line, BillingCycle.HOURLY)).toBe(1);
+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();
}