Add optional service pricing matrix and fix admin catalog save.

Users pick per-service CPU/memory/storage at deploy; admins manage unit rates and deploy defaults. PATCH sends only fields accepted by the pricing-catalog DTO.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-15 19:16:20 +03:30
parent bb27c90ae4
commit 055e7a7c8d
21 changed files with 1746 additions and 257 deletions
+465 -132
View File
@@ -3,6 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PricingRate } from './entities/pricing-rate.entity';
import { AddonRate } from './entities/addon-rate.entity';
import { OptionalServiceProfile } from './entities/optional-service-profile.entity';
import { OptionalServiceRate } from './entities/optional-service-rate.entity';
import {
AppRuntime,
BillingCycle,
@@ -14,15 +16,21 @@ import { CalculateCostDto } from './dto/billing.dto';
import {
FLUENT_BIT_SIDECAR,
getAllBillingRuntimes,
getAllOptionalServices,
getBillableAddonResourceTypes,
OPTIONAL_SERVICE_BILLING_RESOURCES,
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';
import {
CustomDomainCatalogDto,
OptionalServiceCatalogEntryDto,
UpdatePricingCatalogDto,
} from './dto/pricing-catalog.dto';
import { OptionalServiceResourcesDto } from './dto/optional-service-resources.dto';
export interface CyclePrices {
hourly: number;
@@ -38,6 +46,30 @@ export interface PricingRateRow {
isActive?: boolean;
}
export interface OptionalServiceProfileRow {
cpuRequest?: string;
memoryRequest?: string;
cpuLimit: string;
memoryLimit: string;
storageGi: number;
logShipperCpuLimit?: string;
logShipperMemoryLimit?: string;
}
export interface OptionalServiceCatalogEntry {
service: OptionalService;
label: string;
profile: OptionalServiceProfileRow;
rates: PricingRateRow[];
}
export interface CustomDomainCatalogRow {
hourlyPrice: number;
monthlyPrice: number;
yearlyPrice: number;
isActive?: boolean;
}
export interface CatalogRuntimeOption {
value: AppRuntime;
label: string;
@@ -46,12 +78,12 @@ export interface CatalogRuntimeOption {
export interface CatalogOptionalServiceOption {
value: OptionalService;
label: string;
resourceType: PricingResourceType;
}
export interface PricingCatalogResponse {
runtimes: Record<string, PricingRateRow[]>;
addons: PricingRateRow[];
optionalServices: Record<string, OptionalServiceCatalogEntry>;
customDomain: CustomDomainCatalogRow;
runtimeOptions: CatalogRuntimeOption[];
optionalServiceOptions: CatalogOptionalServiceOption[];
}
@@ -63,6 +95,12 @@ export interface CostBreakdownLine {
yearly: number;
}
export interface OptionalBillingContext {
profiles: OptionalServiceProfile[];
rates: OptionalServiceRate[];
customDomain: AddonRate | null;
}
@Injectable()
export class PricingCatalogService implements OnModuleInit {
private readonly logger = new Logger(PricingCatalogService.name);
@@ -70,6 +108,10 @@ export class PricingCatalogService implements OnModuleInit {
constructor(
@InjectRepository(PricingRate) private readonly rateRepo: Repository<PricingRate>,
@InjectRepository(AddonRate) private readonly addonRepo: Repository<AddonRate>,
@InjectRepository(OptionalServiceProfile)
private readonly optionalProfileRepo: Repository<OptionalServiceProfile>,
@InjectRepository(OptionalServiceRate)
private readonly optionalRateRepo: Repository<OptionalServiceRate>,
) {}
async onModuleInit() {
@@ -93,6 +135,45 @@ export class PricingCatalogService implements OnModuleInit {
}
}
}
for (const service of getAllOptionalServices()) {
let profile = await this.optionalProfileRepo.findOne({ where: { service } });
if (!profile) {
const spec = OPTIONAL_SERVICE_DEPLOY_SPECS[service];
profile = this.optionalProfileRepo.create({
service,
cpuRequest: spec.cpuRequest,
memoryRequest: spec.memoryRequest,
cpuLimit: spec.cpuLimit,
memoryLimit: spec.memoryLimit,
storageGi: spec.storageGi,
...(service === OptionalService.ELASTICSEARCH
? {
logShipperCpuLimit: FLUENT_BIT_SIDECAR.cpuLimit,
logShipperMemoryLimit: FLUENT_BIT_SIDECAR.memoryLimit,
}
: {}),
});
await this.optionalProfileRepo.save(profile);
}
for (const resourceType of OPTIONAL_SERVICE_BILLING_RESOURCES) {
const existing = await this.optionalRateRepo.findOne({
where: { service, resourceType },
});
if (!existing) {
await this.optionalRateRepo.save(
this.optionalRateRepo.create({
service,
resourceType,
hourlyPrice: 0,
monthlyPrice: 0,
yearlyPrice: 0,
}),
);
}
}
}
for (const resourceType of getBillableAddonResourceTypes()) {
const existing = await this.addonRepo.findOne({ where: { resourceType } });
if (!existing) {
@@ -110,7 +191,11 @@ export class PricingCatalogService implements OnModuleInit {
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 profiles = await this.optionalProfileRepo.find();
const osRates = await this.optionalRateRepo.find();
const customDomainEntity = await this.addonRepo.findOne({
where: { resourceType: PricingResourceType.CUSTOM_DOMAIN_ADDON },
});
const billingRuntimes = getAllBillingRuntimes();
const runtimes: Record<string, PricingRateRow[]> = {};
@@ -121,21 +206,26 @@ export class PricingCatalogService implements OnModuleInit {
});
}
const addonTypes = getBillableAddonResourceTypes();
const optionalServices: Record<string, OptionalServiceCatalogEntry> = {};
for (const service of getAllOptionalServices()) {
optionalServices[service] = this.toOptionalServiceEntry(
service,
profiles.find((p) => p.service === service),
osRates.filter((r) => r.service === service),
);
}
return {
runtimes,
addons: addonTypes.map((resourceType) => {
const row = addons.find((a) => a.resourceType === resourceType);
return this.toRateRow(resourceType, row);
}),
optionalServices,
customDomain: this.toCustomDomainRow(customDomainEntity),
runtimeOptions: billingRuntimes.map((value) => ({
value,
label: RUNTIME_DISPLAY_LABELS[value] ?? value,
})),
optionalServiceOptions: Object.values(OptionalService).map((value) => ({
optionalServiceOptions: getAllOptionalServices().map((value) => ({
value,
label: OPTIONAL_SERVICE_LABELS[value] ?? value,
resourceType: OPTIONAL_SERVICE_PRICING_TYPE[value],
})),
};
}
@@ -150,22 +240,33 @@ export class PricingCatalogService implements OnModuleInit {
}
}
}
if (dto.addons) {
for (const row of dto.addons) {
await this.upsertAddonRate(row);
if (dto.optionalServices) {
for (const [serviceKey, entry] of Object.entries(dto.optionalServices)) {
if (!entry) continue;
const service = serviceKey as OptionalService;
if (!getAllOptionalServices().includes(service)) continue;
await this.upsertOptionalService(service, entry);
}
}
if (dto.customDomain) {
await this.upsertCustomDomain(dto.customDomain);
}
return this.getCatalog();
}
async getRatesForRuntime(runtime: AppRuntime): Promise<PricingRate[]> {
return this.rateRepo.find({
where: { runtime, isActive: true },
});
return this.rateRepo.find({ where: { runtime, isActive: true } });
}
async getAddonRates(): Promise<AddonRate[]> {
return this.addonRepo.find({ where: { isActive: true } });
async getOptionalBillingContext(): Promise<OptionalBillingContext> {
const [profiles, rates, customDomain] = await Promise.all([
this.optionalProfileRepo.find(),
this.optionalRateRepo.find({ where: { isActive: true } }),
this.addonRepo.findOne({
where: { resourceType: PricingResourceType.CUSTOM_DOMAIN_ADDON, isActive: true },
}),
]);
return { profiles, rates, customDomain };
}
computeTotals(dto: CalculateCostDto): {
@@ -174,7 +275,7 @@ export class PricingCatalogService implements OnModuleInit {
yearly: number;
breakdown: CostBreakdownLine[];
} {
const lines = this.computeLineItemsSync(dto);
const lines = this.buildLines(dto, [], { profiles: [], rates: [], customDomain: null });
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);
@@ -189,16 +290,16 @@ export class PricingCatalogService implements OnModuleInit {
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);
const optional = await this.getOptionalBillingContext();
return this.computeTotalsWithRates(dto, rates, optional);
}
computeTotalsWithRates(
dto: CalculateCostDto,
rates: PricingRate[],
addons: AddonRate[],
optional: OptionalBillingContext,
) {
const lines = this.buildLines(dto, rates, addons);
const lines = this.buildLines(dto, rates, optional);
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);
@@ -223,14 +324,10 @@ export class PricingCatalogService implements OnModuleInit {
}
}
private computeLineItemsSync(dto: CalculateCostDto): CostBreakdownLine[] {
return this.buildLines(dto, [], []);
}
private buildLines(
dto: CalculateCostDto,
rates: PricingRate[],
addons: AddonRate[],
optional: OptionalBillingContext,
): CostBreakdownLine[] {
const lines: CostBreakdownLine[] = [];
const quantities = this.getQuantities(dto);
@@ -250,17 +347,93 @@ export class PricingCatalogService implements OnModuleInit {
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,
lines.push(...this.buildOptionalServiceLines(dto, optional));
return lines;
}
private buildOptionalServiceLines(
dto: CalculateCostDto,
optional: OptionalBillingContext,
): CostBreakdownLine[] {
const lines: CostBreakdownLine[] = [];
const hasDatabase =
dto.databaseType !== DatabaseType.NONE && dto.databaseType !== 'none';
const logging = !!dto.enableElasticsearch;
const profileFor = (service: OptionalService) =>
optional.profiles.find((p) => p.service === service);
const ratesFor = (service: OptionalService) =>
optional.rates.filter((r) => r.service === service);
const esProfile = profileFor(OptionalService.ELASTICSEARCH);
const esRates = ratesFor(OptionalService.ELASTICSEARCH);
const addLogShipper = (workloadLabel: string) => {
if (!logging || !esProfile) return;
const shipCpu =
esProfile.logShipperCpuLimit || FLUENT_BIT_SIDECAR.cpuLimit;
const shipMem =
esProfile.logShipperMemoryLimit || FLUENT_BIT_SIDECAR.memoryLimit;
lines.push(
...this.linesForResourceSlice(
`${workloadLabel} log shipper`,
esRates,
this.getLogShipperQuantities(shipCpu, shipMem),
),
);
};
if (dto.enableRedis) {
const profile = this.resolveOptionalServiceProfile(
OptionalService.REDIS,
dto,
profileFor(OptionalService.REDIS),
);
if (profile) {
lines.push(
...this.linesForOptionalService(
OPTIONAL_SERVICE_LABELS[OptionalService.REDIS],
profile,
ratesFor(OptionalService.REDIS),
),
);
if (logging) addLogShipper('Redis');
}
}
if (dto.enableRabbitmq) {
const profile = this.resolveOptionalServiceProfile(
OptionalService.RABBITMQ,
dto,
profileFor(OptionalService.RABBITMQ),
);
if (profile) {
lines.push(
...this.linesForOptionalService(
OPTIONAL_SERVICE_LABELS[OptionalService.RABBITMQ],
profile,
ratesFor(OptionalService.RABBITMQ),
),
);
if (logging) addLogShipper('RabbitMQ');
}
}
if (logging && esProfile) {
addLogShipper('Application');
if (hasDatabase) addLogShipper('Database');
}
if (dto.enableCustomDomain && optional.customDomain) {
const line = this.lineFromPrices(
'Custom domain + SSL',
1,
Number(optional.customDomain.hourlyPrice),
Number(optional.customDomain.monthlyPrice),
Number(optional.customDomain.yearlyPrice),
PricingResourceType.CUSTOM_DOMAIN_ADDON,
dto,
true,
);
if (line) lines.push(line);
}
@@ -268,6 +441,114 @@ export class PricingCatalogService implements OnModuleInit {
return lines;
}
private linesForOptionalService(
serviceLabel: string,
profile: OptionalServiceProfile,
rates: OptionalServiceRate[],
): CostBreakdownLine[] {
return this.linesForResourceSlice(
serviceLabel,
rates,
this.getOptionalServiceQuantities(profile),
);
}
private linesForResourceSlice(
prefix: string,
rates: OptionalServiceRate[],
quantities: Map<PricingResourceType, number>,
): CostBreakdownLine[] {
const lines: CostBreakdownLine[] = [];
for (const rate of rates) {
if (!rate.isActive) continue;
const qty = quantities.get(rate.resourceType) ?? 0;
if (qty <= 0) continue;
const resourceLabel = RESOURCE_LABELS[rate.resourceType] ?? rate.resourceType;
const line = this.lineFromPrices(
`${prefix}${resourceLabel}`,
qty,
Number(rate.hourlyPrice),
Number(rate.monthlyPrice),
Number(rate.yearlyPrice),
rate.resourceType,
{} as CalculateCostDto,
true,
);
if (line) lines.push(line);
}
return lines;
}
private resolveOptionalServiceProfile(
service: OptionalService,
dto: CalculateCostDto,
adminProfile?: OptionalServiceProfile | null,
): OptionalServiceProfile | null {
const spec = OPTIONAL_SERVICE_DEPLOY_SPECS[service];
const override = this.getDtoOptionalResources(dto, service);
if (!adminProfile && !override) return null;
const cpuLimit = override?.cpuLimit ?? adminProfile?.cpuLimit ?? spec.cpuLimit;
const memoryLimit =
override?.memoryLimit ?? adminProfile?.memoryLimit ?? spec.memoryLimit;
const storageGi =
override?.storageGi ??
(adminProfile ? Number(adminProfile.storageGi) : spec.storageGi);
const resolved: OptionalServiceProfile = {
id: adminProfile?.id ?? '',
service,
cpuLimit,
memoryLimit,
storageGi,
createdAt: adminProfile?.createdAt ?? new Date(),
updatedAt: adminProfile?.updatedAt ?? new Date(),
};
if (service === OptionalService.ELASTICSEARCH) {
resolved.logShipperCpuLimit = adminProfile?.logShipperCpuLimit;
resolved.logShipperMemoryLimit = adminProfile?.logShipperMemoryLimit;
}
return resolved;
}
private getDtoOptionalResources(
dto: CalculateCostDto,
service: OptionalService,
): OptionalServiceResourcesDto | undefined {
if (service === OptionalService.REDIS) return dto.redisResources;
if (service === OptionalService.RABBITMQ) return dto.rabbitmqResources;
return undefined;
}
private getOptionalServiceQuantities(
profile: OptionalServiceProfile,
): Map<PricingResourceType, number> {
const map = new Map<PricingResourceType, number>();
map.set(PricingResourceType.BASE_FEE, 1);
map.set(
PricingResourceType.CPU_PER_CORE,
this.parseCpuToCores(profile.cpuLimit),
);
map.set(
PricingResourceType.MEMORY_PER_GB,
this.parseMemoryToGb(profile.memoryLimit),
);
map.set(PricingResourceType.STORAGE_PER_GB, Number(profile.storageGi) || 0);
return map;
}
private getLogShipperQuantities(
cpuLimit: string,
memoryLimit: string,
): Map<PricingResourceType, number> {
const map = new Map<PricingResourceType, number>();
map.set(PricingResourceType.BASE_FEE, 0);
map.set(PricingResourceType.CPU_PER_CORE, this.parseCpuToCores(cpuLimit));
map.set(PricingResourceType.MEMORY_PER_GB, this.parseMemoryToGb(memoryLimit));
map.set(PricingResourceType.STORAGE_PER_GB, 0);
return map;
}
private lineFromPrices(
baseLabel: string,
quantity: number,
@@ -276,13 +557,16 @@ export class PricingCatalogService implements OnModuleInit {
yearlyUnit: number,
resourceType: PricingResourceType,
dto: CalculateCostDto,
useFixedLabel = false,
): 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);
const label = useFixedLabel
? baseLabel
: this.describeLine(baseLabel, resourceType, quantity, dto);
return { label, hourly, monthly, yearly };
}
@@ -307,10 +591,9 @@ export class PricingCatalogService implements OnModuleInit {
getQuantities(dto: CalculateCostDto): Map<PricingResourceType, number> {
const replicas = dto.replicas || 1;
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 =
const cpuQty = this.parseCpuToCores(dto.cpuLimit) * replicas;
const memoryQty = this.parseMemoryToGb(dto.memoryLimit) * replicas;
const storageQty =
(dto.dbStorageSize
? parseFloat(String(dto.dbStorageSize).replace(/Gi$/i, '')) || 0
: 0) +
@@ -318,78 +601,15 @@ export class PricingCatalogService implements OnModuleInit {
? 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, 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.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;
@@ -418,12 +638,52 @@ export class PricingCatalogService implements OnModuleInit {
await this.rateRepo.save(entity);
}
private async upsertAddonRate(row: PricingRateRow) {
private async upsertOptionalService(
service: OptionalService,
entry: OptionalServiceCatalogEntryDto,
) {
let profile = await this.optionalProfileRepo.findOne({ where: { service } });
if (!profile) {
profile = this.optionalProfileRepo.create({ service });
}
if (entry.profile.cpuRequest !== undefined) profile.cpuRequest = entry.profile.cpuRequest;
if (entry.profile.memoryRequest !== undefined) {
profile.memoryRequest = entry.profile.memoryRequest;
}
profile.cpuLimit = entry.profile.cpuLimit;
profile.memoryLimit = entry.profile.memoryLimit;
profile.storageGi = entry.profile.storageGi;
if (entry.profile.logShipperCpuLimit !== undefined) {
profile.logShipperCpuLimit = entry.profile.logShipperCpuLimit;
}
if (entry.profile.logShipperMemoryLimit !== undefined) {
profile.logShipperMemoryLimit = entry.profile.logShipperMemoryLimit;
}
await this.optionalProfileRepo.save(profile);
for (const row of entry.rates) {
let rate = await this.optionalRateRepo.findOne({
where: { service, resourceType: row.resourceType },
});
if (!rate) {
rate = this.optionalRateRepo.create({ service, resourceType: row.resourceType });
}
rate.hourlyPrice = row.hourlyPrice;
rate.monthlyPrice = row.monthlyPrice;
rate.yearlyPrice = row.yearlyPrice;
if (row.isActive !== undefined) rate.isActive = row.isActive;
await this.optionalRateRepo.save(rate);
}
}
private async upsertCustomDomain(row: CustomDomainCatalogDto) {
let entity = await this.addonRepo.findOne({
where: { resourceType: row.resourceType },
where: { resourceType: PricingResourceType.CUSTOM_DOMAIN_ADDON },
});
if (!entity) {
entity = this.addonRepo.create({ resourceType: row.resourceType });
entity = this.addonRepo.create({
resourceType: PricingResourceType.CUSTOM_DOMAIN_ADDON,
});
}
entity.hourlyPrice = row.hourlyPrice;
entity.monthlyPrice = row.monthlyPrice;
@@ -432,9 +692,49 @@ export class PricingCatalogService implements OnModuleInit {
await this.addonRepo.save(entity);
}
private toOptionalServiceEntry(
service: OptionalService,
profile?: OptionalServiceProfile | null,
rateEntities: OptionalServiceRate[] = [],
): OptionalServiceCatalogEntry {
const spec = OPTIONAL_SERVICE_DEPLOY_SPECS[service];
return {
service,
label: OPTIONAL_SERVICE_LABELS[service] ?? service,
profile: {
cpuRequest: profile?.cpuRequest ?? spec.cpuRequest,
memoryRequest: profile?.memoryRequest ?? spec.memoryRequest,
cpuLimit: profile?.cpuLimit ?? spec.cpuLimit,
memoryLimit: profile?.memoryLimit ?? spec.memoryLimit,
storageGi: profile ? Number(profile.storageGi) : spec.storageGi,
logShipperCpuLimit:
service === OptionalService.ELASTICSEARCH
? profile?.logShipperCpuLimit ?? FLUENT_BIT_SIDECAR.cpuLimit
: undefined,
logShipperMemoryLimit:
service === OptionalService.ELASTICSEARCH
? profile?.logShipperMemoryLimit ?? FLUENT_BIT_SIDECAR.memoryLimit
: undefined,
},
rates: OPTIONAL_SERVICE_BILLING_RESOURCES.map((resourceType) => {
const row = rateEntities.find((r) => r.resourceType === resourceType);
return this.toRateRow(resourceType, row);
}),
};
}
private toCustomDomainRow(entity?: AddonRate | null): CustomDomainCatalogRow {
return {
hourlyPrice: entity ? Number(entity.hourlyPrice) : 0,
monthlyPrice: entity ? Number(entity.monthlyPrice) : 0,
yearlyPrice: entity ? Number(entity.yearlyPrice) : 0,
isActive: entity?.isActive ?? true,
};
}
private toRateRow(
resourceType: PricingResourceType,
entity?: PricingRate | AddonRate | null,
entity?: PricingRate | OptionalServiceRate | AddonRate | null,
): PricingRateRow {
return {
resourceType,
@@ -445,18 +745,33 @@ export class PricingCatalogService implements OnModuleInit {
};
}
/** Legacy shape for optional-services settings API */
/** Legacy API: estimated monthly/hourly/yearly from catalog matrix (base_fee row if set, else full slice). */
async getOptionalServicesPricing(): Promise<Record<OptionalService, CyclePrices>> {
const addons = await this.addonRepo.find();
const catalog = await this.getCatalog();
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);
result[service] = {
hourly: row ? Number(row.hourlyPrice) : 0,
monthly: row ? Number(row.monthlyPrice) : 0,
yearly: row ? Number(row.yearlyPrice) : 0,
};
for (const service of getAllOptionalServices()) {
const entry = catalog.optionalServices[service];
const profile = await this.optionalProfileRepo.findOne({ where: { service } });
const rates = await this.optionalRateRepo.find({ where: { service, isActive: true } });
if (profile && rates.length) {
const totals = this.computeTotalsWithRates(
{ ...this.legacyDtoForService(service), runtime: AppRuntime.NODEJS },
[],
{ profiles: [profile], rates, customDomain: null },
);
result[service] = {
hourly: totals.hourly,
monthly: totals.monthly,
yearly: totals.yearly,
};
} else {
const base = entry.rates.find((r) => r.resourceType === PricingResourceType.BASE_FEE);
result[service] = {
hourly: base?.hourlyPrice ?? 0,
monthly: base?.monthlyPrice ?? 0,
yearly: base?.yearlyPrice ?? 0,
};
}
}
return result;
}
@@ -464,20 +779,40 @@ export class PricingCatalogService implements OnModuleInit {
async setOptionalServicesPricing(
pricing: Partial<Record<OptionalService, CyclePrices>>,
): Promise<Record<OptionalService, CyclePrices>> {
for (const service of Object.values(OptionalService)) {
for (const service of getAllOptionalServices()) {
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,
let rate = await this.optionalRateRepo.findOne({
where: { service, resourceType: PricingResourceType.BASE_FEE },
});
if (!rate) {
rate = this.optionalRateRepo.create({
service,
resourceType: PricingResourceType.BASE_FEE,
});
}
rate.hourlyPrice = prices.hourly;
rate.monthlyPrice = prices.monthly;
rate.yearlyPrice = prices.yearly;
await this.optionalRateRepo.save(rate);
}
return this.getOptionalServicesPricing();
}
private legacyDtoForService(service: OptionalService): CalculateCostDto {
const dto: CalculateCostDto = {
runtime: AppRuntime.NODEJS,
databaseType: DatabaseType.NONE,
cpuLimit: '0',
memoryLimit: '0',
replicas: 1,
};
if (service === OptionalService.REDIS) dto.enableRedis = true;
if (service === OptionalService.RABBITMQ) dto.enableRabbitmq = true;
if (service === OptionalService.ELASTICSEARCH) dto.enableElasticsearch = true;
return dto;
}
async getCustomDomainPrice(): Promise<{ monthlyPrice: number }> {
const row = await this.addonRepo.findOne({
where: { resourceType: PricingResourceType.CUSTOM_DOMAIN_ADDON },
@@ -486,12 +821,10 @@ export class PricingCatalogService implements OnModuleInit {
}
async setCustomDomainPrice(monthlyPrice: number): Promise<{ monthlyPrice: number }> {
await this.upsertAddonRate({
resourceType: PricingResourceType.CUSTOM_DOMAIN_ADDON,
await this.upsertCustomDomain({
hourlyPrice: 0,
monthlyPrice,
yearlyPrice: 0,
isActive: true,
});
return { monthlyPrice };
}