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 { OptionalServiceProfile } from './entities/optional-service-profile.entity'; import { OptionalServiceRate } from './entities/optional-service-rate.entity'; import { AppRuntime, BillingCycle, DatabaseType, OptionalService, PricingResourceType, ProductType, } from '../common/enums'; import { CalculateCostDto } from './dto/billing.dto'; import { FLUENT_BIT_SIDECAR, getAllBillingRuntimes, getAllOptionalServices, getBillableAddonResourceTypes, OPTIONAL_SERVICE_BILLING_RESOURCES, OPTIONAL_SERVICE_DEPLOY_SPECS, OPTIONAL_SERVICE_LABELS, RESOURCE_LABELS, RUNTIME_DISPLAY_LABELS, RUNTIME_PRICING_RESOURCES, } from './pricing-catalog.constants'; import { CustomDomainCatalogDto, OptionalServiceCatalogEntryDto, UpdatePricingCatalogDto, } from './dto/pricing-catalog.dto'; import { OptionalServiceResourcesDto } from './dto/optional-service-resources.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 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; } export interface CatalogOptionalServiceOption { value: OptionalService; label: string; } export interface PricingCatalogResponse { runtimes: Record; optionalServices: Record; customDomain: CustomDomainCatalogRow; runtimeOptions: CatalogRuntimeOption[]; optionalServiceOptions: CatalogOptionalServiceOption[]; } export interface CostBreakdownLine { label: string; hourly: number; monthly: number; 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); constructor( @InjectRepository(PricingRate) private readonly rateRepo: Repository, @InjectRepository(AddonRate) private readonly addonRepo: Repository, @InjectRepository(OptionalServiceProfile) private readonly optionalProfileRepo: Repository, @InjectRepository(OptionalServiceRate) private readonly optionalRateRepo: Repository, ) {} async onModuleInit() { await this.ensureDefaults(); } async ensureDefaults() { for (const runtime of getAllBillingRuntimes()) { 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 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) { await this.addonRepo.save( this.addonRepo.create({ resourceType, hourlyPrice: 0, monthlyPrice: 0, yearlyPrice: 0, }), ); } } } async getCatalog(): Promise { const rates = await this.rateRepo.find({ order: { runtime: 'ASC', 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 = {}; 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 optionalServices: Record = {}; for (const service of getAllOptionalServices()) { optionalServices[service] = this.toOptionalServiceEntry( service, profiles.find((p) => p.service === service), osRates.filter((r) => r.service === service), ); } return { runtimes, optionalServices, customDomain: this.toCustomDomainRow(customDomainEntity), runtimeOptions: billingRuntimes.map((value) => ({ value, label: RUNTIME_DISPLAY_LABELS[value] ?? value, })), optionalServiceOptions: getAllOptionalServices().map((value) => ({ value, label: OPTIONAL_SERVICE_LABELS[value] ?? value, })), }; } async updateCatalog(dto: UpdatePricingCatalogDto): Promise { if (dto.runtimes) { for (const [runtimeKey, rows] of Object.entries(dto.runtimes)) { const runtime = runtimeKey as AppRuntime; if (!getAllBillingRuntimes().includes(runtime)) continue; for (const row of rows) { await this.upsertRuntimeRate(runtime, 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 { return this.rateRepo.find({ where: { runtime, isActive: true } }); } async getOptionalBillingContext(): Promise { 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): { hourly: number; monthly: number; yearly: number; breakdown: CostBreakdownLine[]; } { 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); 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 optional = await this.getOptionalBillingContext(); return this.computeTotalsWithRates(dto, rates, optional); } computeTotalsWithRates( dto: CalculateCostDto, rates: PricingRate[], optional: OptionalBillingContext, ) { 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); 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 buildLines( dto: CalculateCostDto, rates: PricingRate[], optional: OptionalBillingContext, ): CostBreakdownLine[] { const productType = dto.productType ?? ProductType.APPLICATION; if ( productType === ProductType.MANAGED_REDIS || productType === ProductType.MANAGED_RABBITMQ ) { return this.buildOptionalServiceLines(dto, optional); } if (productType === ProductType.MANAGED_DATABASE) { return this.buildManagedDatabaseLines(dto, rates); } 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); } lines.push(...this.buildOptionalServiceLines(dto, optional)); return lines; } private buildManagedDatabaseLines( dto: CalculateCostDto, rates: PricingRate[], ): CostBreakdownLine[] { const lines: CostBreakdownLine[] = []; const quantities = this.getManagedDatabaseQuantities(dto); const allowed = new Set([ PricingResourceType.DATABASE_ADDON, PricingResourceType.CPU_PER_CORE, PricingResourceType.MEMORY_PER_GB, PricingResourceType.STORAGE_PER_GB, ]); for (const rate of rates) { if (!allowed.has(rate.resourceType)) continue; 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); } return lines; } private getManagedDatabaseQuantities(dto: CalculateCostDto): Map { const cpuQty = this.parseCpuToCores(dto.cpuLimit || '500m'); const memoryQty = this.parseMemoryToGb(dto.memoryLimit || '512Mi'); const storageQty = dto.dbStorageSize ? parseFloat(String(dto.dbStorageSize).replace(/Gi$/i, '')) || 0 : 1; const map = new Map(); map.set(PricingResourceType.DATABASE_ADDON, 1); map.set(PricingResourceType.CPU_PER_CORE, cpuQty); map.set(PricingResourceType.MEMORY_PER_GB, memoryQty); map.set(PricingResourceType.STORAGE_PER_GB, storageQty); return map; } 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); } 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, ): 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 { const map = new Map(); 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 { const map = new Map(); 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, hourlyUnit: number, monthlyUnit: number, 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 = useFixedLabel ? baseLabel : 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(3)} GB billable)`; case PricingResourceType.STORAGE_PER_GB: return `Storage (${quantity} GB)`; default: return baseLabel; } } getQuantities(dto: CalculateCostDto): Map { if ((dto.productType ?? ProductType.APPLICATION) === ProductType.MANAGED_DATABASE) { return this.getManagedDatabaseQuantities(dto); } 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; // The database runs as its own single-replica workload; bill its CPU/RAM on // top of the app's, using the user-selected per-database resources. if (hasDatabase && dto.databaseResources) { cpuQty += this.parseCpuToCores(dto.databaseResources.cpuLimit); memoryQty += this.parseMemoryToGb(dto.databaseResources.memoryLimit); } const storageQty = (dto.dbStorageSize ? parseFloat(String(dto.dbStorageSize).replace(/Gi$/i, '')) || 0 : 0) + (dto.appStorageSize ? parseFloat(String(dto.appStorageSize).replace(/Gi$/i, '')) || 0 : 0); const map = new Map(); 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); return map; } parseCpuToCores(cpu: string): number { if (!cpu) return 0; if (cpu.endsWith('m')) return parseFloat(cpu) / 1000; return parseFloat(cpu) || 0; } /** * Billable memory quantity for rates labeled "per GB". * Uses decimal GB so fractional Mi scales linearly (e.g. 500Mi → 0.5 × per-GB price). * Gi values are treated as GB-sized billing units (1Gi → 1 unit). */ parseMemoryToGb(memory: string): number { if (!memory) return 0; const m = memory.trim(); if (m.endsWith('Gi')) return parseFloat(m) || 0; if (m.endsWith('Mi')) return (parseFloat(m) || 0) / 1000; if (m.endsWith('Ki')) return (parseFloat(m) || 0) / 1_000_000; const n = parseFloat(m); return Number.isFinite(n) ? n / (1024 * 1024 * 1024) : 0; } 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 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: PricingResourceType.CUSTOM_DOMAIN_ADDON }, }); if (!entity) { entity = this.addonRepo.create({ resourceType: PricingResourceType.CUSTOM_DOMAIN_ADDON, }); } 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 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 | OptionalServiceRate | 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 API: estimated monthly/hourly/yearly from catalog matrix (base_fee row if set, else full slice). */ async getOptionalServicesPricing(): Promise> { const catalog = await this.getCatalog(); const result = {} as Record; 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; } async setOptionalServicesPricing( pricing: Partial>, ): Promise> { for (const service of getAllOptionalServices()) { const prices = pricing[service]; if (!prices) continue; 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 }, }); return { monthlyPrice: row ? Number(row.monthlyPrice) : 0 }; } async setCustomDomainPrice(monthlyPrice: number): Promise<{ monthlyPrice: number }> { await this.upsertCustomDomain({ hourlyPrice: 0, monthlyPrice, yearlyPrice: 0, }); return { monthlyPrice }; } }