From 055e7a7c8d1eabb7cb1d58fd3e63a7301749e57a Mon Sep 17 00:00:00 2001 From: keyhan Date: Fri, 15 May 2026 19:16:20 +0330 Subject: [PATCH] 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 --- .../migrations/006_addon_rate_resources.sql | 27 + .../007_optional_service_pricing_matrix.sql | 98 +++ ...application_optional_service_resources.sql | 4 + .../009_optional_service_deploy_defaults.sql | 14 + .../src/applications/dto/application.dto.ts | 23 + .../entities/application.entity.ts | 19 + backend/src/billing/billing.module.ts | 4 + backend/src/billing/billing.service.ts | 10 + backend/src/billing/dto/billing.dto.ts | 13 + .../dto/optional-service-resources.dto.ts | 28 + .../src/billing/dto/pricing-catalog.dto.ts | 98 ++- .../src/billing/entities/addon-rate.entity.ts | 1 + .../optional-service-profile.entity.ts | 44 ++ .../entities/optional-service-rate.entity.ts | 40 ++ .../src/billing/pricing-catalog.constants.ts | 49 +- .../billing/pricing-catalog.service.spec.ts | 75 ++- .../src/billing/pricing-catalog.service.ts | 597 ++++++++++++++---- backend/src/kubernetes/kubernetes.service.ts | 52 +- .../src/app/dashboard/admin/billing/page.tsx | 471 ++++++++++++-- frontend/src/app/dashboard/deploy/page.tsx | 288 ++++++++- frontend/src/types/index.ts | 48 +- 21 files changed, 1746 insertions(+), 257 deletions(-) create mode 100644 backend/migrations/006_addon_rate_resources.sql create mode 100644 backend/migrations/007_optional_service_pricing_matrix.sql create mode 100644 backend/migrations/008_application_optional_service_resources.sql create mode 100644 backend/migrations/009_optional_service_deploy_defaults.sql create mode 100644 backend/src/billing/dto/optional-service-resources.dto.ts create mode 100644 backend/src/billing/entities/optional-service-profile.entity.ts create mode 100644 backend/src/billing/entities/optional-service-rate.entity.ts diff --git a/backend/migrations/006_addon_rate_resources.sql b/backend/migrations/006_addon_rate_resources.sql new file mode 100644 index 0000000..c86c060 --- /dev/null +++ b/backend/migrations/006_addon_rate_resources.sql @@ -0,0 +1,27 @@ +-- Per-addon consumption footprint and optional resource unit prices (0 = use app runtime rates) + +ALTER TABLE addon_rates + ADD COLUMN IF NOT EXISTS cpu_limit VARCHAR, + ADD COLUMN IF NOT EXISTS memory_limit VARCHAR, + ADD COLUMN IF NOT EXISTS storage_gi DECIMAL(10, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS log_shipper_cpu_limit VARCHAR, + ADD COLUMN IF NOT EXISTS log_shipper_memory_limit VARCHAR, + ADD COLUMN IF NOT EXISTS cpu_hourly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS cpu_monthly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS cpu_yearly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS memory_hourly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS memory_monthly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS memory_yearly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS storage_hourly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS storage_monthly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS storage_yearly_price DECIMAL(12, 2) NOT NULL DEFAULT 0; + +UPDATE addon_rates SET cpu_limit = '200m', memory_limit = '256Mi', storage_gi = 1 +WHERE resource_type = 'redis_addon' AND cpu_limit IS NULL; + +UPDATE addon_rates SET cpu_limit = '500m', memory_limit = '512Mi', storage_gi = 2 +WHERE resource_type = 'rabbitmq_addon' AND cpu_limit IS NULL; + +UPDATE addon_rates SET cpu_limit = '50m', memory_limit = '64Mi', storage_gi = 0, + log_shipper_cpu_limit = '50m', log_shipper_memory_limit = '64Mi' +WHERE resource_type = 'elasticsearch_addon' AND cpu_limit IS NULL; diff --git a/backend/migrations/007_optional_service_pricing_matrix.sql b/backend/migrations/007_optional_service_pricing_matrix.sql new file mode 100644 index 0000000..f9ddc33 --- /dev/null +++ b/backend/migrations/007_optional_service_pricing_matrix.sql @@ -0,0 +1,98 @@ +-- Optional services: resource profile + pricing matrix (same model as application runtimes) + +CREATE TABLE IF NOT EXISTS optional_service_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + service VARCHAR NOT NULL UNIQUE, + cpu_limit VARCHAR NOT NULL DEFAULT '200m', + memory_limit VARCHAR NOT NULL DEFAULT '256Mi', + storage_gi DECIMAL(10, 2) NOT NULL DEFAULT 0, + log_shipper_cpu_limit VARCHAR, + log_shipper_memory_limit VARCHAR, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS optional_service_rates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + service VARCHAR NOT NULL, + resource_type VARCHAR NOT NULL, + hourly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + monthly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + yearly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (service, resource_type) +); + +INSERT INTO optional_service_profiles (service, cpu_limit, memory_limit, storage_gi, log_shipper_cpu_limit, log_shipper_memory_limit) +VALUES + ('redis', '200m', '256Mi', 1, NULL, NULL), + ('rabbitmq', '500m', '512Mi', 2, NULL, NULL), + ('elasticsearch', '50m', '64Mi', 0, '50m', '64Mi') +ON CONFLICT (service) DO NOTHING; + +UPDATE optional_service_profiles SET + log_shipper_cpu_limit = '50m', + log_shipper_memory_limit = '64Mi' +WHERE service = 'elasticsearch' AND log_shipper_cpu_limit IS NULL; + +INSERT INTO optional_service_rates (service, resource_type, hourly_price, monthly_price, yearly_price) +SELECT s.service, t.resource_type, 0, 0, 0 +FROM (VALUES ('redis'), ('rabbitmq'), ('elasticsearch')) AS s(service) +CROSS JOIN ( + VALUES ('base_fee'), ('cpu_per_core'), ('memory_per_gb'), ('storage_per_gb') +) AS t(resource_type) +ON CONFLICT (service, resource_type) DO NOTHING; + +-- Migrate legacy per-resource addon prices into optional_service_rates (if columns exist) +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'addon_rates' AND column_name = 'cpu_hourly_price' + ) THEN + UPDATE optional_service_rates osr SET + hourly_price = COALESCE(ar.cpu_hourly_price, 0), + monthly_price = COALESCE(ar.cpu_monthly_price, 0), + yearly_price = COALESCE(ar.cpu_yearly_price, 0) + FROM addon_rates ar + WHERE ar.resource_type = 'redis_addon' AND osr.service = 'redis' AND osr.resource_type = 'cpu_per_core' + AND (ar.cpu_hourly_price > 0 OR ar.cpu_monthly_price > 0); + + UPDATE optional_service_rates osr SET + hourly_price = COALESCE(ar.memory_hourly_price, 0), + monthly_price = COALESCE(ar.memory_monthly_price, 0), + yearly_price = COALESCE(ar.memory_yearly_price, 0) + FROM addon_rates ar + WHERE ar.resource_type = 'redis_addon' AND osr.service = 'redis' AND osr.resource_type = 'memory_per_gb' + AND (ar.memory_hourly_price > 0 OR ar.memory_monthly_price > 0); + + UPDATE optional_service_rates osr SET + hourly_price = COALESCE(ar.storage_hourly_price, 0), + monthly_price = COALESCE(ar.storage_monthly_price, 0), + yearly_price = COALESCE(ar.storage_yearly_price, 0) + FROM addon_rates ar + WHERE ar.resource_type = 'redis_addon' AND osr.service = 'redis' AND osr.resource_type = 'storage_per_gb' + AND (ar.storage_hourly_price > 0 OR ar.storage_monthly_price > 0); + + UPDATE optional_service_profiles osp SET + cpu_limit = COALESCE(ar.cpu_limit, osp.cpu_limit), + memory_limit = COALESCE(ar.memory_limit, osp.memory_limit), + storage_gi = COALESCE(ar.storage_gi, osp.storage_gi) + FROM addon_rates ar WHERE ar.resource_type = 'redis_addon' AND osp.service = 'redis'; + + UPDATE optional_service_profiles osp SET + cpu_limit = COALESCE(ar.cpu_limit, osp.cpu_limit), + memory_limit = COALESCE(ar.memory_limit, osp.memory_limit), + storage_gi = COALESCE(ar.storage_gi, osp.storage_gi) + FROM addon_rates ar WHERE ar.resource_type = 'rabbitmq_addon' AND osp.service = 'rabbitmq'; + + UPDATE optional_service_profiles osp SET + cpu_limit = COALESCE(ar.cpu_limit, osp.cpu_limit), + memory_limit = COALESCE(ar.memory_limit, osp.memory_limit), + log_shipper_cpu_limit = COALESCE(ar.log_shipper_cpu_limit, osp.log_shipper_cpu_limit), + log_shipper_memory_limit = COALESCE(ar.log_shipper_memory_limit, osp.log_shipper_memory_limit) + FROM addon_rates ar WHERE ar.resource_type = 'elasticsearch_addon' AND osp.service = 'elasticsearch'; + END IF; +END $$; diff --git a/backend/migrations/008_application_optional_service_resources.sql b/backend/migrations/008_application_optional_service_resources.sql new file mode 100644 index 0000000..742a1ca --- /dev/null +++ b/backend/migrations/008_application_optional_service_resources.sql @@ -0,0 +1,4 @@ +-- Per-deploy optional service resource limits (user-chosen, like application CPU/RAM/storage) + +ALTER TABLE applications + ADD COLUMN IF NOT EXISTS optional_service_resources JSONB; diff --git a/backend/migrations/009_optional_service_deploy_defaults.sql b/backend/migrations/009_optional_service_deploy_defaults.sql new file mode 100644 index 0000000..7d652d1 --- /dev/null +++ b/backend/migrations/009_optional_service_deploy_defaults.sql @@ -0,0 +1,14 @@ +-- Deploy-wizard default requests (TypeORM camelCase column names) + +ALTER TABLE optional_service_profiles + ADD COLUMN IF NOT EXISTS "cpuRequest" VARCHAR, + ADD COLUMN IF NOT EXISTS "memoryRequest" VARCHAR; + +UPDATE optional_service_profiles SET "cpuRequest" = '50m', "memoryRequest" = '64Mi' +WHERE service = 'redis' AND "cpuRequest" IS NULL; + +UPDATE optional_service_profiles SET "cpuRequest" = '100m', "memoryRequest" = '256Mi' +WHERE service = 'rabbitmq' AND "cpuRequest" IS NULL; + +UPDATE optional_service_profiles SET "cpuRequest" = '50m', "memoryRequest" = '64Mi' +WHERE service = 'elasticsearch' AND "cpuRequest" IS NULL; diff --git a/backend/src/applications/dto/application.dto.ts b/backend/src/applications/dto/application.dto.ts index 1b85cfd..54f9820 100644 --- a/backend/src/applications/dto/application.dto.ts +++ b/backend/src/applications/dto/application.dto.ts @@ -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() diff --git a/backend/src/applications/entities/application.entity.ts b/backend/src/applications/entities/application.entity.ts index 1517230..1170dcc 100644 --- a/backend/src/applications/entities/application.entity.ts +++ b/backend/src/applications/entities/application.entity.ts @@ -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; diff --git a/backend/src/billing/billing.module.ts b/backend/src/billing/billing.module.ts index e3e2739..a9da3ba 100644 --- a/backend/src/billing/billing.module.ts +++ b/backend/src/billing/billing.module.ts @@ -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, diff --git a/backend/src/billing/billing.service.ts b/backend/src/billing/billing.service.ts index f1264dc..2d9aaa7 100644 --- a/backend/src/billing/billing.service.ts +++ b/backend/src/billing/billing.service.ts @@ -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, }; } diff --git a/backend/src/billing/dto/billing.dto.ts b/backend/src/billing/dto/billing.dto.ts index c627aaa..21a82ed 100644 --- a/backend/src/billing/dto/billing.dto.ts +++ b/backend/src/billing/dto/billing.dto.ts @@ -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() diff --git a/backend/src/billing/dto/optional-service-resources.dto.ts b/backend/src/billing/dto/optional-service-resources.dto.ts new file mode 100644 index 0000000..a5b3df0 --- /dev/null +++ b/backend/src/billing/dto/optional-service-resources.dto.ts @@ -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; +} diff --git a/backend/src/billing/dto/pricing-catalog.dto.ts b/backend/src/billing/dto/pricing-catalog.dto.ts index 3a667ad..fa031a2 100644 --- a/backend/src/billing/dto/pricing-catalog.dto.ts +++ b/backend/src/billing/dto/pricing-catalog.dto.ts @@ -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>; - @ApiPropertyOptional({ type: [PricingRateRowDto] }) + @ApiPropertyOptional() @IsOptional() - @IsArray() - @ValidateNested({ each: true }) - @Type(() => PricingRateRowDto) - addons?: PricingRateRowDto[]; + @IsObject() + optionalServices?: Partial>; + + @ApiPropertyOptional({ type: CustomDomainCatalogDto }) + @IsOptional() + @ValidateNested() + @Type(() => CustomDomainCatalogDto) + customDomain?: CustomDomainCatalogDto; } diff --git a/backend/src/billing/entities/addon-rate.entity.ts b/backend/src/billing/entities/addon-rate.entity.ts index ba3562b..c59249b 100644 --- a/backend/src/billing/entities/addon-rate.entity.ts +++ b/backend/src/billing/entities/addon-rate.entity.ts @@ -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') diff --git a/backend/src/billing/entities/optional-service-profile.entity.ts b/backend/src/billing/entities/optional-service-profile.entity.ts new file mode 100644 index 0000000..b2cee83 --- /dev/null +++ b/backend/src/billing/entities/optional-service-profile.entity.ts @@ -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; +} diff --git a/backend/src/billing/entities/optional-service-rate.entity.ts b/backend/src/billing/entities/optional-service-rate.entity.ts new file mode 100644 index 0000000..013d86a --- /dev/null +++ b/backend/src/billing/entities/optional-service-rate.entity.ts @@ -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; +} diff --git a/backend/src/billing/pricing-catalog.constants.ts b/backend/src/billing/pricing-catalog.constants.ts index f19cac2..276e143 100644 --- a/backend/src/billing/pricing-catalog.constants.ts +++ b/backend/src/billing/pricing-catalog.constants.ts @@ -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.REDIS]: PricingResourceType.REDIS_ADDON, @@ -32,10 +40,11 @@ export const OPTIONAL_SERVICE_PRICING_TYPE: Record = { @@ -47,11 +56,35 @@ export const OPTIONAL_SERVICE_LABELS: Record = { /** 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. */ diff --git a/backend/src/billing/pricing-catalog.service.spec.ts b/backend/src/billing/pricing-catalog.service.spec.ts index d1401de..194e4b4 100644 --- a/backend/src/billing/pricing-catalog.service.spec.ts +++ b/backend/src/billing/pricing-catalog.service.spec.ts @@ -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', () => { diff --git a/backend/src/billing/pricing-catalog.service.ts b/backend/src/billing/pricing-catalog.service.ts index d2de0b0..6d5f867 100644 --- a/backend/src/billing/pricing-catalog.service.ts +++ b/backend/src/billing/pricing-catalog.service.ts @@ -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; - addons: PricingRateRow[]; + optionalServices: Record; + 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, @InjectRepository(AddonRate) private readonly addonRepo: Repository, + @InjectRepository(OptionalServiceProfile) + private readonly optionalProfileRepo: Repository, + @InjectRepository(OptionalServiceRate) + private readonly optionalRateRepo: Repository, ) {} 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 { 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 = {}; @@ -121,21 +206,26 @@ export class PricingCatalogService implements OnModuleInit { }); } - const addonTypes = getBillableAddonResourceTypes(); + const optionalServices: Record = {}; + for (const service of getAllOptionalServices()) { + optionalServices[service] = this.toOptionalServiceEntry( + service, + profiles.find((p) => p.service === service), + osRates.filter((r) => r.service === service), + ); + } + return { runtimes, - 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 { - return this.rateRepo.find({ - where: { runtime, isActive: true }, - }); + return this.rateRepo.find({ where: { runtime, isActive: true } }); } - async getAddonRates(): Promise { - return this.addonRepo.find({ where: { isActive: true } }); + async getOptionalBillingContext(): Promise { + const [profiles, rates, customDomain] = await Promise.all([ + this.optionalProfileRepo.find(), + this.optionalRateRepo.find({ where: { isActive: true } }), + this.addonRepo.findOne({ + where: { resourceType: PricingResourceType.CUSTOM_DOMAIN_ADDON, isActive: true }, + }), + ]); + return { profiles, rates, customDomain }; } computeTotals(dto: CalculateCostDto): { @@ -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, + ): CostBreakdownLine[] { + const lines: CostBreakdownLine[] = []; + for (const rate of rates) { + if (!rate.isActive) continue; + const qty = quantities.get(rate.resourceType) ?? 0; + if (qty <= 0) continue; + const resourceLabel = RESOURCE_LABELS[rate.resourceType] ?? rate.resourceType; + const line = this.lineFromPrices( + `${prefix} — ${resourceLabel}`, + qty, + Number(rate.hourlyPrice), + Number(rate.monthlyPrice), + Number(rate.yearlyPrice), + rate.resourceType, + {} as CalculateCostDto, + true, + ); + if (line) lines.push(line); + } + return lines; + } + + private resolveOptionalServiceProfile( + service: OptionalService, + dto: CalculateCostDto, + adminProfile?: OptionalServiceProfile | null, + ): OptionalServiceProfile | null { + const spec = OPTIONAL_SERVICE_DEPLOY_SPECS[service]; + const override = this.getDtoOptionalResources(dto, service); + if (!adminProfile && !override) return null; + + const cpuLimit = override?.cpuLimit ?? adminProfile?.cpuLimit ?? spec.cpuLimit; + const memoryLimit = + override?.memoryLimit ?? adminProfile?.memoryLimit ?? spec.memoryLimit; + const storageGi = + override?.storageGi ?? + (adminProfile ? Number(adminProfile.storageGi) : spec.storageGi); + + const resolved: OptionalServiceProfile = { + id: adminProfile?.id ?? '', + service, + cpuLimit, + memoryLimit, + storageGi, + createdAt: adminProfile?.createdAt ?? new Date(), + updatedAt: adminProfile?.updatedAt ?? new Date(), + }; + if (service === OptionalService.ELASTICSEARCH) { + resolved.logShipperCpuLimit = adminProfile?.logShipperCpuLimit; + resolved.logShipperMemoryLimit = adminProfile?.logShipperMemoryLimit; + } + return resolved; + } + + private getDtoOptionalResources( + dto: CalculateCostDto, + service: OptionalService, + ): OptionalServiceResourcesDto | undefined { + if (service === OptionalService.REDIS) return dto.redisResources; + if (service === OptionalService.RABBITMQ) return dto.rabbitmqResources; + return undefined; + } + + private getOptionalServiceQuantities( + profile: OptionalServiceProfile, + ): Map { + const map = new Map(); + map.set(PricingResourceType.BASE_FEE, 1); + map.set( + PricingResourceType.CPU_PER_CORE, + this.parseCpuToCores(profile.cpuLimit), + ); + map.set( + PricingResourceType.MEMORY_PER_GB, + this.parseMemoryToGb(profile.memoryLimit), + ); + map.set(PricingResourceType.STORAGE_PER_GB, Number(profile.storageGi) || 0); + return map; + } + + private getLogShipperQuantities( + cpuLimit: string, + memoryLimit: string, + ): Map { + const map = new Map(); + map.set(PricingResourceType.BASE_FEE, 0); + map.set(PricingResourceType.CPU_PER_CORE, this.parseCpuToCores(cpuLimit)); + map.set(PricingResourceType.MEMORY_PER_GB, this.parseMemoryToGb(memoryLimit)); + map.set(PricingResourceType.STORAGE_PER_GB, 0); + return map; + } + private lineFromPrices( baseLabel: string, quantity: number, @@ -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 { 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(); 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> { - const addons = await this.addonRepo.find(); + const catalog = await this.getCatalog(); const result = {} as Record; - 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>, ): Promise> { - 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 }; } diff --git a/backend/src/kubernetes/kubernetes.service.ts b/backend/src/kubernetes/kubernetes.service.ts index bdd45e1..728125b 100644 --- a/backend/src/kubernetes/kubernetes.service.ts +++ b/backend/src/kubernetes/kubernetes.service.ts @@ -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 { 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 || [], diff --git a/frontend/src/app/dashboard/admin/billing/page.tsx b/frontend/src/app/dashboard/admin/billing/page.tsx index da2b245..453c45c 100644 --- a/frontend/src/app/dashboard/admin/billing/page.tsx +++ b/frontend/src/app/dashboard/admin/billing/page.tsx @@ -7,11 +7,13 @@ import { toast } from 'react-toastify'; import type { PricingCatalog, PricingRateRow, + OptionalServiceProfileRow, + CustomDomainCatalogRow, BillingCycle, PricingResourceType, LifecycleSettings, } from '@/types'; -import { DollarSign, Edit2, Shield, Clock, Layers } from 'lucide-react'; +import { DollarSign, Edit2, Shield, Clock, Layers, Info, Box, Server } from 'lucide-react'; const resourceLabels: Record = { base_fee: 'Base fee', @@ -19,23 +21,62 @@ const resourceLabels: Record = { memory_per_gb: 'Memory (per GB)', storage_per_gb: 'Storage (per GB)', database_addon: 'Database addon', - redis_addon: 'Redis (flat addon)', - rabbitmq_addon: 'RabbitMQ (flat addon)', - elasticsearch_addon: 'Elasticsearch (flat addon)', + redis_addon: 'Redis', + rabbitmq_addon: 'RabbitMQ', + elasticsearch_addon: 'Elasticsearch', custom_domain_addon: 'Custom domain + SSL', }; const cycles: BillingCycle[] = ['hourly', 'monthly', 'yearly']; +/** PATCH body must match UpdatePricingCatalogDto (no runtimeOptions / label). */ +function toPricingCatalogPatch(catalog: PricingCatalog) { + const optionalServices: Record< + string, + { service: string; profile: OptionalServiceProfileRow; rates: PricingRateRow[] } + > = {}; + for (const [key, entry] of Object.entries(catalog.optionalServices)) { + optionalServices[key] = { + service: entry.service ?? key, + profile: entry.profile, + rates: entry.rates, + }; + } + return { + runtimes: catalog.runtimes, + optionalServices, + customDomain: catalog.customDomain, + }; +} + +function formatApiError(err: unknown, fallback: string): string { + if (!err || typeof err !== 'object' || !('response' in err)) return fallback; + const message = (err as { response?: { data?: { message?: string | string[] } } }).response + ?.data?.message; + if (Array.isArray(message)) return message.join(', '); + if (typeof message === 'string') return message; + return fallback; +} + function cloneCatalog(catalog: PricingCatalog): PricingCatalog { const runtimes: PricingCatalog['runtimes'] = {}; for (const key of Object.keys(catalog.runtimes)) { runtimes[key] = catalog.runtimes[key].map((r) => ({ ...r })); } + const optionalServices: PricingCatalog['optionalServices'] = {}; + for (const key of Object.keys(catalog.optionalServices)) { + const entry = catalog.optionalServices[key]; + optionalServices[key] = { + ...entry, + profile: { ...entry.profile }, + rates: entry.rates.map((r) => ({ ...r })), + }; + } return { ...catalog, runtimes, - addons: catalog.addons.map((a) => ({ ...a })), + optionalServices, + customDomain: { ...catalog.customDomain }, }; } @@ -107,9 +148,200 @@ function PricingMatrixTable({ ); } +function DeployDefaultsFields({ + service, + profile, + readOnly, + onUpdate, +}: { + service: string; + profile: OptionalServiceProfileRow; + readOnly: boolean; + onUpdate: (patch: Partial) => void; +}) { + const isLogging = service === 'elasticsearch'; + + if (isLogging) { + return ( +
+

+ Prefilled limits for Fluent Bit log shippers (billed per enabled workload when logging is on). +

+
+
+ + {readOnly ? ( +

{profile.logShipperCpuLimit || '—'}

+ ) : ( + onUpdate({ logShipperCpuLimit: e.target.value })} + /> + )} +
+
+ + {readOnly ? ( +

{profile.logShipperMemoryLimit || '—'}

+ ) : ( + onUpdate({ logShipperMemoryLimit: e.target.value })} + /> + )} +
+
+
+ ); + } + + return ( +
+

+ Shown when a user enables this service in deploy. They can change CPU, memory, and storage in + Resources & Configuration; actual billing uses their choices × unit prices below. +

+
+
+ + {readOnly ? ( +

{profile.cpuRequest || '—'}

+ ) : ( + + )} +
+
+ + {readOnly ? ( +

{profile.cpuLimit}

+ ) : ( + + )} +
+
+ + {readOnly ? ( +

{profile.memoryRequest || '—'}

+ ) : ( + + )} +
+
+ + {readOnly ? ( +

{profile.memoryLimit}

+ ) : ( + + )} +
+
+ + {readOnly ? ( +

{profile.storageGi}

+ ) : ( + + onUpdate({ storageGi: e.target.value === '' ? 0 : Number(e.target.value) }) + } + /> + )} +
+
+
+ ); +} + +function CustomDomainPricing({ + customDomain, + readOnly, + onChange, +}: { + customDomain: CustomDomainCatalogRow; + readOnly: boolean; + onChange: (cycle: BillingCycle, value: number) => void; +}) { + return ( +
+

Custom domain + SSL

+

Flat fee per billing cycle (not resource-based)

+
+ {cycles.map((cycle) => { + const field = + cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice'; + return ( +
+ + {readOnly ? ( +

{Number(customDomain[field]).toLocaleString('en-US')}

+ ) : ( + + onChange(cycle, e.target.value === '' ? 0 : Number(e.target.value)) + } + /> + )} +
+ ); + })} +
+
+ ); +} + export default function AdminBillingPage() { const queryClient = useQueryClient(); const [activeRuntime, setActiveRuntime] = useState('nodejs'); + const [activeOptionalService, setActiveOptionalService] = useState('redis'); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(null); @@ -126,22 +358,27 @@ export default function AdminBillingPage() { } }, [catalog, activeRuntime]); + useEffect(() => { + const options = catalog?.optionalServiceOptions ?? []; + if (options.length === 0) return; + if (!options.some((o) => o.value === activeOptionalService)) { + setActiveOptionalService(options[0].value); + } + }, [catalog, activeOptionalService]); + const saveMutation = useMutation({ - mutationFn: (body: PricingCatalog) => api.patch('/billing/pricing-catalog', body), + mutationFn: (catalog: PricingCatalog) => + api.patch('/billing/pricing-catalog', toPricingCatalogPatch(catalog)), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['pricing-catalog'] }); queryClient.invalidateQueries({ queryKey: ['custom-domain-price'] }); queryClient.invalidateQueries({ queryKey: ['optional-services-pricing'] }); - toast.success('Pricing catalog saved'); + toast.success('Billing plans saved'); setEditing(false); setDraft(null); }, onError: (err: unknown) => { - const message = - err && typeof err === 'object' && 'response' in err - ? (err as { response?: { data?: { message?: string } } }).response?.data?.message - : undefined; - toast.error(message || 'Failed to save pricing'); + toast.error(formatApiError(err, 'Failed to save billing plans')); }, }); @@ -176,7 +413,23 @@ export default function AdminBillingPage() { }); }; - const updateAddonPrice = ( + const updateOptionalProfile = (patch: Partial) => { + if (!draft) return; + const entry = draft.optionalServices[activeOptionalService]; + if (!entry) return; + setDraft({ + ...draft, + optionalServices: { + ...draft.optionalServices, + [activeOptionalService]: { + ...entry, + profile: { ...entry.profile, ...patch }, + }, + }, + }); + }; + + const updateOptionalRate = ( resourceType: PricingResourceType, cycle: BillingCycle, value: number, @@ -184,15 +437,33 @@ export default function AdminBillingPage() { if (!draft) return; const field = cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice'; + const entry = draft.optionalServices[activeOptionalService]; + if (!entry) return; setDraft({ ...draft, - addons: draft.addons.map((row) => - row.resourceType === resourceType ? { ...row, [field]: value } : row, - ), + optionalServices: { + ...draft.optionalServices, + [activeOptionalService]: { + ...entry, + rates: entry.rates.map((row) => + row.resourceType === resourceType ? { ...row, [field]: value } : row, + ), + }, + }, }); }; - const fillYearlyFromMonthly = (scope: 'runtime' | 'addons') => { + const updateCustomDomain = (cycle: BillingCycle, value: number) => { + if (!draft) return; + const field = + cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice'; + setDraft({ + ...draft, + customDomain: { ...draft.customDomain, [field]: value }, + }); + }; + + const fillYearlyFromMonthly = (scope: 'runtime' | 'optional') => { if (!draft) return; if (scope === 'runtime') { setDraft({ @@ -206,12 +477,20 @@ export default function AdminBillingPage() { }, }); } else { + const entry = draft.optionalServices[activeOptionalService]; + if (!entry) return; setDraft({ ...draft, - addons: draft.addons.map((row) => ({ - ...row, - yearlyPrice: Math.round(Number(row.monthlyPrice) * 12), - })), + optionalServices: { + ...draft.optionalServices, + [activeOptionalService]: { + ...entry, + rates: entry.rates.map((row) => ({ + ...row, + yearlyPrice: Math.round(Number(row.monthlyPrice) * 12), + })), + }, + }, }); } }; @@ -222,17 +501,21 @@ export default function AdminBillingPage() { }; const runtimeRows = display?.runtimes[activeRuntime] ?? []; - const addonRows = display?.addons ?? []; + const optionalServiceTabs = display?.optionalServiceOptions ?? []; + const activeOptionalEntry = display?.optionalServices[activeOptionalService]; + const optionalRateRows = activeOptionalEntry?.rates ?? []; + const customDomain = display?.customDomain; return (

- Billing & Pricing + Billing Plans

- Usage-based prices per application type (all runtimes from the platform). Optional services also bill CPU, RAM, and disk at the same unit rates as the app, plus any flat addon fee below. + Set unit prices per resource. Users choose CPU, memory, and storage when deploying; cost = + their usage × these rates.

{!editing ? ( @@ -241,7 +524,7 @@ export default function AdminBillingPage() { disabled={!catalog} className="btn-primary flex items-center gap-2 shrink-0" > - Edit pricing + Edit plans ) : (
@@ -265,6 +548,27 @@ export default function AdminBillingPage() { )}
+
+ +
+

How billing works

+
    +
  • + Applications — user picks runtime resources in deploy; you set price per + core, GB, base fee, and database addon. +
  • +
  • + Optional services (Redis, RabbitMQ) — user enables the service, then sets + resources in a separate block; you set the same unit-price rows for that service. +
  • +
  • + Deploy defaults — optional prefill only; changing them does not change + what existing apps pay unless the user chose those values at deploy. +
  • +
+
+
+ {isLoading ? (
Loading...
) : !display ? ( @@ -272,6 +576,14 @@ export default function AdminBillingPage() { ) : ( <>
+
+ +
+

Application runtimes

+

Unit pricing per runtime (hourly / monthly / yearly)

+
+
+
{runtimeTabs.map((tab) => ( - )} +
+ +
+
+

Optional services

+

+ Unit pricing + deploy wizard defaults per service +

+
+ {editing && activeOptionalEntry && ( + + )} +
-

- Flat addon fees (optional). Deploy cost also includes each service's CPU, RAM, and disk at the app runtime unit rates above. -

- + +
+ {optionalServiceTabs.map((tab) => ( + + ))} +
+ + {activeOptionalEntry && ( +
+
+

+ + Deploy wizard defaults +

+ +
+ +
+

Unit pricing

+

+ Billed from the resources the user selects for this service at deploy (CPU cores × + rate, memory GB × rate, storage GB × rate, plus base fee if used). +

+ +
+
+ )} + + {customDomain && ( +
+ +
+ )}
)} @@ -363,11 +730,7 @@ function LifecycleSettingsSection() { setEditing(false); }, onError: (err: unknown) => { - const message = - err && typeof err === 'object' && 'response' in err - ? (err as { response?: { data?: { message?: string } } }).response?.data?.message - : undefined; - toast.error(message || 'Failed to save'); + toast.error(formatApiError(err, 'Failed to save')); }, }); diff --git a/frontend/src/app/dashboard/deploy/page.tsx b/frontend/src/app/dashboard/deploy/page.tsx index fc56a0a..8bc2eb3 100644 --- a/frontend/src/app/dashboard/deploy/page.tsx +++ b/frontend/src/app/dashboard/deploy/page.tsx @@ -7,10 +7,177 @@ import api from '@/lib/api'; import { parseDotenv } from '@/lib/parseDotenv'; import { useAuthStore } from '@/lib/store'; import { toast } from 'react-toastify'; -import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic, DeployCostPreview, BillingCycle } from '@/types'; +import type { + CreateApplicationDto, + ClusterPublic, + ClusterPoolPublic, + DeployCostPreview, + BillingCycle, + PricingCatalog, + OptionalServiceResourceConfig, + OptionalServiceResourcesMap, +} from '@/types'; import { Hexagon, FolderUp, Link as LinkIcon, CheckCircle, Package, Server, Scale, Home, Target, BarChart3, RotateCw, KeyRound, Rocket, Clock, Upload, XCircle, Database, Eye, EyeOff, RefreshCw, DollarSign, Wallet, CreditCard, Loader2, Globe, Copy, AlertCircle, ShieldCheck } from 'lucide-react'; const steps = ['Basic Info', 'Versions & Database', 'Resources', 'Review']; +type OptionalServiceKey = 'redis' | 'rabbitmq'; + +const FALLBACK_OPTIONAL_RESOURCES: Record = { + redis: { + cpuRequest: '50m', + cpuLimit: '200m', + memoryRequest: '64Mi', + memoryLimit: '256Mi', + storageGi: 1, + }, + rabbitmq: { + cpuRequest: '100m', + cpuLimit: '500m', + memoryRequest: '256Mi', + memoryLimit: '512Mi', + storageGi: 2, + }, +}; + +function optionalDefaultsFromCatalog( + catalog: PricingCatalog | undefined, + service: OptionalServiceKey, +): OptionalServiceResourceConfig { + const profile = catalog?.optionalServices?.[service]?.profile; + const fallback = FALLBACK_OPTIONAL_RESOURCES[service]; + if (!profile) return { ...fallback }; + return { + cpuRequest: profile.cpuRequest || fallback.cpuRequest, + cpuLimit: profile.cpuLimit || fallback.cpuLimit, + memoryRequest: profile.memoryRequest || fallback.memoryRequest, + memoryLimit: profile.memoryLimit || fallback.memoryLimit, + storageGi: profile.storageGi ?? fallback.storageGi, + }; +} + +function WorkloadResourceFields({ + title, + accentClass, + borderClass, + bgClass, + config, + readOnly, + onChange, +}: { + title: string; + accentClass: string; + borderClass: string; + bgClass: string; + config: OptionalServiceResourceConfig; + readOnly?: boolean; + onChange: (patch: Partial) => void; +}) { + const storageStr = String(config.storageGi); + const setStorage = (gb: number) => onChange({ storageGi: Math.max(0, gb) }); + + return ( +
+

+ + {title} +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ + setStorage(parseInt(e.target.value, 10) || 0)} + className="w-16 text-center py-2 border-x border-gray-300 text-sm font-semibold focus:outline-none" + /> + +
+ GB +
+
+
+ ); +} + + type DeployStage = 'idle' | 'creating' | 'uploading-source' | 'uploading-db' | 'paying' | 'deploying' | 'done' | 'error'; @@ -56,6 +223,7 @@ export default function DeployPage() { rabbitmqVersion: '3.13', enableElasticsearch: false, elasticsearchVersion: '8.12', + optionalServiceResources: {}, }); const [envKey, setEnvKey] = useState(''); const [envVal, setEnvVal] = useState(''); @@ -92,6 +260,12 @@ export default function DeployPage() { enabled: isAdmin, }); + const { data: pricingCatalog } = useQuery({ + queryKey: ['pricing-catalog'], + queryFn: () => api.get('/billing/pricing-catalog').then((r) => r.data), + enabled: step >= 1, + }); + // ── Custom Domain ────────────────────────────── const [enableCustomDomain, setEnableCustomDomain] = useState(false); const [customDomainInput, setCustomDomainInput] = useState(''); @@ -134,6 +308,8 @@ export default function DeployPage() { enableRedis: form.enableRedis, enableRabbitmq: form.enableRabbitmq, enableElasticsearch: form.enableElasticsearch, + redisResources: form.enableRedis ? form.optionalServiceResources?.redis : undefined, + rabbitmqResources: form.enableRabbitmq ? form.optionalServiceResources?.rabbitmq : undefined, enableCustomDomain, cycle: selectedCycle, }; @@ -142,14 +318,14 @@ export default function DeployPage() { const { data: costData, isLoading: costLoading } = useQuery({ queryKey: ['deploy-cost', deployCostPayload], queryFn: () => api.post('/billing/calculate-deploy', deployCostPayload).then((r) => r.data), - enabled: step === 3, + enabled: step >= 2, }); // Wallet balance for the review step payment const { data: walletData } = useQuery<{ balance: number }>({ queryKey: ['wallet-balance'], queryFn: () => api.get('/billing/wallet').then((r) => r.data), - enabled: step === 3, + enabled: step >= 2, }); const payAmount = costData?.amountDue ?? 0; @@ -1380,7 +1556,21 @@ export default function DeployPage() { }`}>