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
@@ -10,9 +10,26 @@ import {
Max,
Matches,
IsIn,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { AppRuntime, DatabaseType } from '../../common/enums';
import { OptionalServiceResourcesDto } from '../../billing/dto/optional-service-resources.dto';
export class OptionalServiceResourcesMapDto {
@ApiPropertyOptional({ type: OptionalServiceResourcesDto })
@IsOptional()
@ValidateNested()
@Type(() => OptionalServiceResourcesDto)
redis?: OptionalServiceResourcesDto;
@ApiPropertyOptional({ type: OptionalServiceResourcesDto })
@IsOptional()
@ValidateNested()
@Type(() => OptionalServiceResourcesDto)
rabbitmq?: OptionalServiceResourcesDto;
}
export class CreateApplicationDto {
@ApiProperty({ example: 'my-app' })
@@ -107,6 +124,12 @@ export class CreateApplicationDto {
@IsString({ each: true })
logPaths?: string[];
@ApiPropertyOptional({ type: OptionalServiceResourcesMapDto })
@IsOptional()
@ValidateNested()
@Type(() => OptionalServiceResourcesMapDto)
optionalServiceResources?: OptionalServiceResourcesMapDto;
@ApiPropertyOptional({ example: 'https://github.com/user/repo.git' })
@IsOptional()
@IsString()
@@ -72,6 +72,25 @@ export class Application {
@Column({ type: 'jsonb', nullable: true })
logPaths: string[]; // Custom log paths to collect (e.g., ['/app/logs/*.log'])
/** User-selected CPU/RAM/storage per optional service (redis, rabbitmq). */
@Column({ type: 'jsonb', nullable: true })
optionalServiceResources?: {
redis?: {
cpuRequest?: string;
cpuLimit: string;
memoryRequest?: string;
memoryLimit: string;
storageGi: number;
};
rabbitmq?: {
cpuRequest?: string;
cpuLimit: string;
memoryRequest?: string;
memoryLimit: string;
storageGi: number;
};
};
@Column({ nullable: true })
gitUrl: string;
+4
View File
@@ -5,6 +5,8 @@ import { BillingController } from './billing.controller';
import { PricingCatalogService } from './pricing-catalog.service';
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 { Wallet } from './entities/wallet.entity';
import { WalletTransaction } from './entities/wallet-transaction.entity';
import { ResourceCredit } from './entities/resource-credit.entity';
@@ -17,6 +19,8 @@ import { KubernetesModule } from '../kubernetes/kubernetes.module';
TypeOrmModule.forFeature([
PricingRate,
AddonRate,
OptionalServiceProfile,
OptionalServiceRate,
Wallet,
WalletTransaction,
ResourceCredit,
+10
View File
@@ -312,6 +312,12 @@ export class BillingService {
('enableCustomDomain' in app
? !!(app as CalculateCostDto).enableCustomDomain
: !!(app as Application).customDomain);
const optionalRes =
'optionalServiceResources' in app
? (app as Application).optionalServiceResources
: undefined;
const dtoExtras =
'redisResources' in app ? (app as CalculateCostDto) : undefined;
return {
runtime: app.runtime,
databaseType: app.databaseType,
@@ -324,6 +330,8 @@ export class BillingService {
enableRabbitmq: !!app.enableRabbitmq,
enableElasticsearch: !!app.enableElasticsearch,
enableCustomDomain,
redisResources: optionalRes?.redis ?? dtoExtras?.redisResources,
rabbitmqResources: optionalRes?.rabbitmq ?? dtoExtras?.rabbitmqResources,
};
}
@@ -467,6 +475,8 @@ export class BillingService {
enableRabbitmq: config.enableRabbitmq,
enableElasticsearch: config.enableElasticsearch,
enableCustomDomain: config.enableCustomDomain,
redisResources: config.redisResources,
rabbitmqResources: config.rabbitmqResources,
};
}
+13
View File
@@ -2,6 +2,7 @@ import { IsString, IsEnum, IsOptional, IsBoolean, IsNumber, IsArray, ValidateNes
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { BillingCycle, PricingResourceType, AppRuntime } from '../../common/enums';
import { OptionalServiceResourcesDto } from './optional-service-resources.dto';
export class CreatePricingRuleDto {
@ApiProperty({ enum: PricingResourceType })
@@ -137,6 +138,18 @@ export class CalculateCostDto {
@IsBoolean()
enableElasticsearch?: boolean;
@ApiPropertyOptional({ type: OptionalServiceResourcesDto })
@IsOptional()
@ValidateNested()
@Type(() => OptionalServiceResourcesDto)
redisResources?: OptionalServiceResourcesDto;
@ApiPropertyOptional({ type: OptionalServiceResourcesDto })
@IsOptional()
@ValidateNested()
@Type(() => OptionalServiceResourcesDto)
rabbitmqResources?: OptionalServiceResourcesDto;
@ApiPropertyOptional({ example: false, description: 'Enable custom domain with SSL' })
@IsOptional()
@IsBoolean()
@@ -0,0 +1,28 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNumber, IsOptional, IsString, Min } from 'class-validator';
/** User-selected limits for an optional service at deploy time. */
export class OptionalServiceResourcesDto {
@ApiPropertyOptional({ example: '50m' })
@IsOptional()
@IsString()
cpuRequest?: string;
@ApiProperty({ example: '200m' })
@IsString()
cpuLimit: string;
@ApiPropertyOptional({ example: '64Mi' })
@IsOptional()
@IsString()
memoryRequest?: string;
@ApiProperty({ example: '256Mi' })
@IsString()
memoryLimit: string;
@ApiProperty({ example: 1 })
@IsNumber()
@Min(0)
storageGi: number;
}
+86 -12
View File
@@ -6,11 +6,12 @@ import {
IsNumber,
IsObject,
IsOptional,
IsString,
Min,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { AppRuntime, PricingResourceType } from '../../common/enums';
import { AppRuntime, OptionalService, PricingResourceType } from '../../common/enums';
export class PricingRateRowDto {
@ApiProperty({ enum: PricingResourceType })
@@ -38,21 +39,94 @@ export class PricingRateRowDto {
isActive?: boolean;
}
export class OptionalServiceProfileDto {
@ApiPropertyOptional({ example: '50m' })
@IsOptional()
@IsString()
cpuRequest?: string;
@ApiPropertyOptional({ example: '64Mi' })
@IsOptional()
@IsString()
memoryRequest?: string;
@ApiProperty({ example: '200m' })
@IsString()
cpuLimit: string;
@ApiProperty({ example: '256Mi' })
@IsString()
memoryLimit: string;
@ApiProperty({ example: 1 })
@IsNumber()
@Min(0)
storageGi: number;
@ApiPropertyOptional({ example: '50m' })
@IsOptional()
@IsString()
logShipperCpuLimit?: string;
@ApiPropertyOptional({ example: '64Mi' })
@IsOptional()
@IsString()
logShipperMemoryLimit?: string;
}
export class OptionalServiceCatalogEntryDto {
@ApiProperty({ enum: OptionalService })
@IsEnum(OptionalService)
service: OptionalService;
@ApiProperty({ type: OptionalServiceProfileDto })
@ValidateNested()
@Type(() => OptionalServiceProfileDto)
profile: OptionalServiceProfileDto;
@ApiProperty({ type: [PricingRateRowDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => PricingRateRowDto)
rates: PricingRateRowDto[];
}
export class CustomDomainCatalogDto {
@ApiProperty({ example: 500 })
@IsNumber()
@Min(0)
hourlyPrice: number;
@ApiProperty({ example: 50000 })
@IsNumber()
@Min(0)
monthlyPrice: number;
@ApiProperty({ example: 500000 })
@IsNumber()
@Min(0)
yearlyPrice: number;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UpdatePricingCatalogDto {
@ApiPropertyOptional({
description: 'Per-runtime pricing rows keyed by runtime',
example: {
nodejs: [{ resourceType: 'base_fee', hourlyPrice: 0, monthlyPrice: 10000, yearlyPrice: 100000 }],
},
})
@ApiPropertyOptional()
@IsOptional()
@IsObject()
runtimes?: Partial<Record<AppRuntime, PricingRateRowDto[]>>;
@ApiPropertyOptional({ type: [PricingRateRowDto] })
@ApiPropertyOptional()
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => PricingRateRowDto)
addons?: PricingRateRowDto[];
@IsObject()
optionalServices?: Partial<Record<OptionalService, OptionalServiceCatalogEntryDto>>;
@ApiPropertyOptional({ type: CustomDomainCatalogDto })
@IsOptional()
@ValidateNested()
@Type(() => CustomDomainCatalogDto)
customDomain?: CustomDomainCatalogDto;
}
@@ -7,6 +7,7 @@ import {
} from 'typeorm';
import { PricingResourceType } from '../../common/enums';
/** Flat-fee addons only (e.g. custom domain). Optional services use optional_service_* tables. */
@Entity('addon_rates')
export class AddonRate {
@PrimaryGeneratedColumn('uuid')
@@ -0,0 +1,44 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { OptionalService } from '../../common/enums';
@Entity('optional_service_profiles')
export class OptionalServiceProfile {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'enum', enum: OptionalService, unique: true })
service: OptionalService;
@Column({ type: 'varchar', nullable: true })
cpuRequest?: string;
@Column({ type: 'varchar', nullable: true })
memoryRequest?: string;
@Column({ type: 'varchar', default: '200m' })
cpuLimit: string;
@Column({ type: 'varchar', default: '256Mi' })
memoryLimit: string;
@Column({ type: 'decimal', precision: 10, scale: 2, default: 1 })
storageGi: number;
@Column({ type: 'varchar', nullable: true })
logShipperCpuLimit?: string;
@Column({ type: 'varchar', nullable: true })
logShipperMemoryLimit?: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,40 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Unique,
} from 'typeorm';
import { OptionalService, PricingResourceType } from '../../common/enums';
@Entity('optional_service_rates')
@Unique(['service', 'resourceType'])
export class OptionalServiceRate {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'enum', enum: OptionalService })
service: OptionalService;
@Column({ type: 'enum', enum: PricingResourceType })
resourceType: PricingResourceType;
@Column({ type: 'decimal', precision: 12, scale: 2, default: 0 })
hourlyPrice: number;
@Column({ type: 'decimal', precision: 12, scale: 2, default: 0 })
monthlyPrice: number;
@Column({ type: 'decimal', precision: 12, scale: 2, default: 0 })
yearlyPrice: number;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -24,6 +24,14 @@ export const RUNTIME_PRICING_RESOURCES: PricingResourceType[] = [
PricingResourceType.DATABASE_ADDON,
];
/** Same billing rows as apps, without database (not applicable to Redis/RabbitMQ/ES). */
export const OPTIONAL_SERVICE_BILLING_RESOURCES: PricingResourceType[] = [
PricingResourceType.BASE_FEE,
PricingResourceType.CPU_PER_CORE,
PricingResourceType.MEMORY_PER_GB,
PricingResourceType.STORAGE_PER_GB,
];
/** Maps optional service → flat addon row in addon_rates. */
export const OPTIONAL_SERVICE_PRICING_TYPE: Record<OptionalService, PricingResourceType> = {
[OptionalService.REDIS]: PricingResourceType.REDIS_ADDON,
@@ -32,10 +40,11 @@ export const OPTIONAL_SERVICE_PRICING_TYPE: Record<OptionalService, PricingResou
};
export function getBillableAddonResourceTypes(): PricingResourceType[] {
return [
...Object.values(OPTIONAL_SERVICE_PRICING_TYPE),
PricingResourceType.CUSTOM_DOMAIN_ADDON,
];
return [PricingResourceType.CUSTOM_DOMAIN_ADDON];
}
export function getAllOptionalServices(): OptionalService[] {
return Object.values(OptionalService);
}
export const OPTIONAL_SERVICE_LABELS: Record<OptionalService, string> = {
@@ -47,11 +56,35 @@ export const OPTIONAL_SERVICE_LABELS: Record<OptionalService, string> = {
/** Deploy footprint aligned with helm/cloudhost-app defaults (limits used for billing). */
export const OPTIONAL_SERVICE_DEPLOY_SPECS: Record<
OptionalService,
{ cpuLimit: string; memoryLimit: string; storageGi: number }
{
cpuRequest: string;
memoryRequest: string;
cpuLimit: string;
memoryLimit: string;
storageGi: number;
}
> = {
[OptionalService.REDIS]: { cpuLimit: '200m', memoryLimit: '256Mi', storageGi: 1 },
[OptionalService.RABBITMQ]: { cpuLimit: '500m', memoryLimit: '512Mi', storageGi: 2 },
[OptionalService.ELASTICSEARCH]: { cpuLimit: '50m', memoryLimit: '64Mi', storageGi: 0 },
[OptionalService.REDIS]: {
cpuRequest: '50m',
memoryRequest: '64Mi',
cpuLimit: '200m',
memoryLimit: '256Mi',
storageGi: 1,
},
[OptionalService.RABBITMQ]: {
cpuRequest: '100m',
memoryRequest: '256Mi',
cpuLimit: '500m',
memoryLimit: '512Mi',
storageGi: 2,
},
[OptionalService.ELASTICSEARCH]: {
cpuRequest: '50m',
memoryRequest: '64Mi',
cpuLimit: '50m',
memoryLimit: '64Mi',
storageGi: 0,
},
};
/** Fluent Bit sidecar per workload when Elasticsearch logging is enabled. */
@@ -3,6 +3,8 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { PricingCatalogService } from './pricing-catalog.service';
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,
@@ -30,6 +32,20 @@ describe('PricingCatalogService', () => {
create: jest.fn().mockImplementation((x) => x),
};
const optionalProfileRepo = {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockResolvedValue(null),
save: jest.fn().mockImplementation((x) => Promise.resolve(x)),
create: jest.fn().mockImplementation((x) => x),
};
const optionalRateRepo = {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockResolvedValue(null),
save: jest.fn().mockImplementation((x) => Promise.resolve(x)),
create: jest.fn().mockImplementation((x) => x),
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
@@ -37,6 +53,8 @@ describe('PricingCatalogService', () => {
PricingCatalogService,
{ provide: getRepositoryToken(PricingRate), useValue: rateRepo },
{ provide: getRepositoryToken(AddonRate), useValue: addonRepo },
{ provide: getRepositoryToken(OptionalServiceProfile), useValue: optionalProfileRepo },
{ provide: getRepositoryToken(OptionalServiceRate), useValue: optionalRateRepo },
],
}).compile();
@@ -51,6 +69,8 @@ describe('PricingCatalogService', () => {
replicas: 1,
});
const emptyOptional = () => ({ profiles: [], rates: [], customDomain: null });
it('parses CPU millicores to cores', () => {
expect(service.parseCpuToCores('500m')).toBe(0.5);
expect(service.parseCpuToCores('2')).toBe(2);
@@ -68,31 +88,41 @@ describe('PricingCatalogService', () => {
},
] as PricingRate[];
const result = service.computeTotalsWithRates(baseDto(), rates, []);
const result = service.computeTotalsWithRates(baseDto(), rates, emptyOptional());
expect(result.hourly).toBe(50);
expect(result.monthly).toBe(2500);
expect(result.yearly).toBe(25000);
expect(result.breakdown[0].label).toContain('CPU');
});
it('includes redis addon only when enabled', () => {
const addons = [
it('includes redis optional service only when enabled', () => {
const profile = {
service: OptionalService.REDIS,
cpuLimit: OPTIONAL_SERVICE_DEPLOY_SPECS[OptionalService.REDIS].cpuLimit,
memoryLimit: '256Mi',
storageGi: 0,
} as OptionalServiceProfile;
const rates = [
{
resourceType: PricingResourceType.REDIS_ADDON,
service: OptionalService.REDIS,
resourceType: PricingResourceType.BASE_FEE,
hourlyPrice: 10,
monthlyPrice: 100,
yearlyPrice: 1000,
isActive: true,
},
] as AddonRate[];
] as OptionalServiceRate[];
const without = service.computeTotalsWithRates(baseDto(), [], addons);
const optional = { profiles: [profile], rates, customDomain: null };
const without = service.computeTotalsWithRates(baseDto(), [], optional);
expect(without.monthly).toBe(0);
const withRedis = service.computeTotalsWithRates(
{ ...baseDto(), enableRedis: true },
[],
addons,
optional,
);
expect(withRedis.monthly).toBe(100);
expect(withRedis.yearly).toBe(1000);
@@ -111,42 +141,41 @@ describe('PricingCatalogService', () => {
},
] as PricingRate[];
const result = service.computeTotalsWithRates(baseDto(), rates, []);
const result = service.computeTotalsWithRates(baseDto(), rates, emptyOptional());
expect(result.yearly).toBe(999);
expect(result.monthly).toBe(100);
expect(result.yearly).not.toBe(result.monthly * 12);
});
it('bills optional service CPU/RAM/storage with same runtime unit rates', () => {
it('bills optional service CPU from profile and service rate matrix', () => {
const profile = {
service: OptionalService.REDIS,
cpuLimit: OPTIONAL_SERVICE_DEPLOY_SPECS[OptionalService.REDIS].cpuLimit,
memoryLimit: '256Mi',
storageGi: 0,
} as OptionalServiceProfile;
const rates = [
{
runtime: AppRuntime.NODEJS,
service: OptionalService.REDIS,
resourceType: PricingResourceType.CPU_PER_CORE,
hourlyPrice: 1000,
monthlyPrice: 0,
yearlyPrice: 0,
isActive: true,
},
{
runtime: AppRuntime.NODEJS,
resourceType: PricingResourceType.MEMORY_PER_GB,
hourlyPrice: 0,
monthlyPrice: 0,
yearlyPrice: 0,
isActive: true,
},
] as PricingRate[];
] as OptionalServiceRate[];
const redisCpu = parseFloat(OPTIONAL_SERVICE_DEPLOY_SPECS[OptionalService.REDIS].cpuLimit) / 1000;
const optional = { profiles: [profile], rates, customDomain: null };
const without = service.computeTotalsWithRates(baseDto(), rates, []);
const without = service.computeTotalsWithRates(baseDto(), [], optional);
const withRedis = service.computeTotalsWithRates(
{ ...baseDto(), enableRedis: true },
rates,
[],
optional,
);
expect(withRedis.hourly - without.hourly).toBe(Math.round(redisCpu * 1000));
expect(withRedis.hourly - without.hourly).toBe(200);
});
it('amountForCycleFromLine picks the correct column', () => {
+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 };
}
+32 -20
View File
@@ -104,6 +104,36 @@ export class KubernetesService implements OnModuleInit {
/**
* Build Helm values object from an Application entity and image URI.
*/
private buildRedisHelmBlock(app: Application) {
const res = app.optionalServiceResources?.redis;
const storageGi = res?.storageGi ?? 1;
return {
enabled: app.enableRedis || false,
storageSize: `${storageGi}Gi`,
resources: {
cpuRequest: res?.cpuRequest || '50m',
cpuLimit: res?.cpuLimit || '200m',
memoryRequest: res?.memoryRequest || '64Mi',
memoryLimit: res?.memoryLimit || '256Mi',
},
};
}
private buildRabbitmqHelmBlock(app: Application) {
const res = app.optionalServiceResources?.rabbitmq;
const storageGi = res?.storageGi ?? 2;
return {
enabled: app.enableRabbitmq || false,
storageSize: `${storageGi}Gi`,
resources: {
cpuRequest: res?.cpuRequest || '100m',
cpuLimit: res?.cpuLimit || '500m',
memoryRequest: res?.memoryRequest || '256Mi',
memoryLimit: res?.memoryLimit || '512Mi',
},
};
}
private resolveEnvVars(app: Application): Record<string, string> {
const platformDomain = this.configService.get('platform.domain') || 'apps.cloudhost.ir';
return ensureAppUrlEnv(app, platformDomain);
@@ -162,26 +192,8 @@ export class KubernetesService implements OnModuleInit {
wordpress: {
enabled: isWordPress,
},
redis: {
enabled: app.enableRedis || false,
storageSize: '1Gi',
resources: {
cpuRequest: '50m',
cpuLimit: '200m',
memoryRequest: '64Mi',
memoryLimit: '256Mi',
},
},
rabbitmq: {
enabled: app.enableRabbitmq || false,
storageSize: '2Gi',
resources: {
cpuRequest: '100m',
cpuLimit: '500m',
memoryRequest: '256Mi',
memoryLimit: '512Mi',
},
},
redis: this.buildRedisHelmBlock(app),
rabbitmq: this.buildRabbitmqHelmBlock(app),
elasticsearch: {
enabled: app.enableElasticsearch || false,
logPaths: app.logPaths || [],