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:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user