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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user