Files
cloud-host/backend/src/billing/pricing-catalog.service.ts
T
keyhan 901a20eb01 feat(deploy): configurable CPU/RAM/disk for app databases
Let users size the database workload from the deploy wizard the same way
Redis/RabbitMQ are configured: a resource card (CPU request/limit, memory
request/limit, storage) on the Resources step, stored in
optionalServiceResources.database.

- entity/DTO: add `database` to optionalServiceResources
- k8s: resolveDatabaseResources() applies user-selected resources on both
  the Helm and K8s-API deploy paths (was hardcoded 100m/256Mi→500m/512Mi)
- billing: bill database CPU/RAM as a separate line on top of the app's
  resources; merge it through the upgrade path too
- wizard: db resource card on the Resources step, disk moved into the card,
  cost preview + review summary include the database resources

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 01:20:33 +03:30

908 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, PricingRateRow[]>;
optionalServices: Record<string, OptionalServiceCatalogEntry>;
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<PricingRate>,
@InjectRepository(AddonRate) private readonly addonRepo: Repository<AddonRate>,
@InjectRepository(OptionalServiceProfile)
private readonly optionalProfileRepo: Repository<OptionalServiceProfile>,
@InjectRepository(OptionalServiceRate)
private readonly optionalRateRepo: Repository<OptionalServiceRate>,
) {}
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<PricingCatalogResponse> {
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<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 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,
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<PricingCatalogResponse> {
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<PricingRate[]> {
return this.rateRepo.find({ where: { runtime, 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): {
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<PricingResourceType, number> {
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<PricingResourceType, number>();
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<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,
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<PricingResourceType, number> {
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<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);
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<Record<OptionalService, CyclePrices>> {
const catalog = await this.getCatalog();
const result = {} as Record<OptionalService, CyclePrices>;
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<Record<OptionalService, CyclePrices>>,
): Promise<Record<OptionalService, CyclePrices>> {
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 };
}
}