901a20eb01
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>
908 lines
30 KiB
TypeScript
908 lines
30 KiB
TypeScript
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 };
|
||
}
|
||
}
|