From bb27c90ae4f725e58aff8d9f50888580b087bb3d Mon Sep 17 00:00:00 2001 From: keyhan Date: Fri, 15 May 2026 18:22:35 +0330 Subject: [PATCH] Make billing catalog dynamic for all runtimes and bill optional service resources. Derive admin tabs and addon rows from enums, and add Redis/RabbitMQ/ES CPU/RAM/disk to deploy cost using the app runtime unit rates. Co-authored-by: Cursor --- .../005_pricing_catalog_all_runtimes.sql | 24 +++ .../src/billing/pricing-catalog.constants.ts | 72 +++++-- .../billing/pricing-catalog.service.spec.ts | 34 +++ .../src/billing/pricing-catalog.service.ts | 198 ++++++++++++------ .../src/app/dashboard/admin/billing/page.tsx | 58 +++-- frontend/src/types/index.ts | 15 +- 6 files changed, 292 insertions(+), 109 deletions(-) create mode 100644 backend/migrations/005_pricing_catalog_all_runtimes.sql diff --git a/backend/migrations/005_pricing_catalog_all_runtimes.sql b/backend/migrations/005_pricing_catalog_all_runtimes.sql new file mode 100644 index 0000000..1af68ab --- /dev/null +++ b/backend/migrations/005_pricing_catalog_all_runtimes.sql @@ -0,0 +1,24 @@ +-- Ensure pricing_rates rows exist for every AppRuntime enum value + +INSERT INTO pricing_rates (runtime, resource_type, hourly_price, monthly_price, yearly_price) +SELECT r.runtime, t.resource_type, 0, 0, 0 +FROM ( + VALUES + ('nodejs'), + ('laravel'), + ('wordpress'), + ('go'), + ('php'), + ('python'), + ('django'), + ('dotnet') +) AS r(runtime) +CROSS JOIN ( + VALUES + ('base_fee'), + ('cpu_per_core'), + ('memory_per_gb'), + ('storage_per_gb'), + ('database_addon') +) AS t(resource_type) +ON CONFLICT (runtime, resource_type) DO NOTHING; diff --git a/backend/src/billing/pricing-catalog.constants.ts b/backend/src/billing/pricing-catalog.constants.ts index e947151..f19cac2 100644 --- a/backend/src/billing/pricing-catalog.constants.ts +++ b/backend/src/billing/pricing-catalog.constants.ts @@ -1,10 +1,20 @@ -import { AppRuntime, PricingResourceType } from '../common/enums'; +import { AppRuntime, OptionalService, PricingResourceType } from '../common/enums'; -export const BILLING_RUNTIMES: AppRuntime[] = [ - AppRuntime.NODEJS, - AppRuntime.LARAVEL, - AppRuntime.WORDPRESS, -]; +/** All application runtimes — new enum values appear in billing automatically. */ +export function getAllBillingRuntimes(): AppRuntime[] { + return Object.values(AppRuntime); +} + +export const RUNTIME_DISPLAY_LABELS: Record = { + [AppRuntime.NODEJS]: 'Node.js', + [AppRuntime.LARAVEL]: 'Laravel', + [AppRuntime.WORDPRESS]: 'WordPress', + [AppRuntime.GO]: 'Go', + [AppRuntime.PHP]: 'PHP', + [AppRuntime.PYTHON]: 'Python', + [AppRuntime.DJANGO]: 'Django', + [AppRuntime.DOTNET]: '.NET', +}; export const RUNTIME_PRICING_RESOURCES: PricingResourceType[] = [ PricingResourceType.BASE_FEE, @@ -14,12 +24,38 @@ export const RUNTIME_PRICING_RESOURCES: PricingResourceType[] = [ PricingResourceType.DATABASE_ADDON, ]; -export const ADDON_PRICING_RESOURCES: PricingResourceType[] = [ - PricingResourceType.REDIS_ADDON, - PricingResourceType.RABBITMQ_ADDON, - PricingResourceType.ELASTICSEARCH_ADDON, - PricingResourceType.CUSTOM_DOMAIN_ADDON, -]; +/** Maps optional service → flat addon row in addon_rates. */ +export const OPTIONAL_SERVICE_PRICING_TYPE: Record = { + [OptionalService.REDIS]: PricingResourceType.REDIS_ADDON, + [OptionalService.RABBITMQ]: PricingResourceType.RABBITMQ_ADDON, + [OptionalService.ELASTICSEARCH]: PricingResourceType.ELASTICSEARCH_ADDON, +}; + +export function getBillableAddonResourceTypes(): PricingResourceType[] { + return [ + ...Object.values(OPTIONAL_SERVICE_PRICING_TYPE), + PricingResourceType.CUSTOM_DOMAIN_ADDON, + ]; +} + +export const OPTIONAL_SERVICE_LABELS: Record = { + [OptionalService.REDIS]: 'Redis', + [OptionalService.RABBITMQ]: 'RabbitMQ', + [OptionalService.ELASTICSEARCH]: 'Elasticsearch (logging)', +}; + +/** 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 } +> = { + [OptionalService.REDIS]: { cpuLimit: '200m', memoryLimit: '256Mi', storageGi: 1 }, + [OptionalService.RABBITMQ]: { cpuLimit: '500m', memoryLimit: '512Mi', storageGi: 2 }, + [OptionalService.ELASTICSEARCH]: { cpuLimit: '50m', memoryLimit: '64Mi', storageGi: 0 }, +}; + +/** Fluent Bit sidecar per workload when Elasticsearch logging is enabled. */ +export const FLUENT_BIT_SIDECAR = { cpuLimit: '50m', memoryLimit: '64Mi' }; export const RESOURCE_LABELS: Record = { [PricingResourceType.BASE_FEE]: 'Base fee', @@ -27,8 +63,14 @@ export const RESOURCE_LABELS: Record = { [PricingResourceType.MEMORY_PER_GB]: 'Memory (per GB)', [PricingResourceType.STORAGE_PER_GB]: 'Storage (per GB)', [PricingResourceType.DATABASE_ADDON]: 'Database addon', - [PricingResourceType.REDIS_ADDON]: 'Redis addon', - [PricingResourceType.RABBITMQ_ADDON]: 'RabbitMQ addon', - [PricingResourceType.ELASTICSEARCH_ADDON]: 'Elasticsearch addon', + [PricingResourceType.REDIS_ADDON]: 'Redis (flat addon)', + [PricingResourceType.RABBITMQ_ADDON]: 'RabbitMQ (flat addon)', + [PricingResourceType.ELASTICSEARCH_ADDON]: 'Elasticsearch (flat addon)', [PricingResourceType.CUSTOM_DOMAIN_ADDON]: 'Custom domain + SSL', }; + +/** @deprecated Use getAllBillingRuntimes() */ +export const BILLING_RUNTIMES = getAllBillingRuntimes(); + +/** @deprecated Use getBillableAddonResourceTypes() */ +export const ADDON_PRICING_RESOURCES = getBillableAddonResourceTypes(); diff --git a/backend/src/billing/pricing-catalog.service.spec.ts b/backend/src/billing/pricing-catalog.service.spec.ts index f199ada..d1401de 100644 --- a/backend/src/billing/pricing-catalog.service.spec.ts +++ b/backend/src/billing/pricing-catalog.service.spec.ts @@ -7,8 +7,10 @@ import { AppRuntime, BillingCycle, DatabaseType, + OptionalService, PricingResourceType, } from '../common/enums'; +import { OPTIONAL_SERVICE_DEPLOY_SPECS } from './pricing-catalog.constants'; import { CalculateCostDto } from './dto/billing.dto'; describe('PricingCatalogService', () => { @@ -115,6 +117,38 @@ describe('PricingCatalogService', () => { expect(result.yearly).not.toBe(result.monthly * 12); }); + it('bills optional service CPU/RAM/storage with same runtime unit rates', () => { + const rates = [ + { + runtime: AppRuntime.NODEJS, + 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[]; + + const redisCpu = parseFloat(OPTIONAL_SERVICE_DEPLOY_SPECS[OptionalService.REDIS].cpuLimit) / 1000; + + const without = service.computeTotalsWithRates(baseDto(), rates, []); + const withRedis = service.computeTotalsWithRates( + { ...baseDto(), enableRedis: true }, + rates, + [], + ); + + expect(withRedis.hourly - without.hourly).toBe(Math.round(redisCpu * 1000)); + }); + it('amountForCycleFromLine picks the correct column', () => { const line = { label: 'Test', hourly: 1, monthly: 2, yearly: 3 }; expect(service.amountForCycleFromLine(line, BillingCycle.HOURLY)).toBe(1); diff --git a/backend/src/billing/pricing-catalog.service.ts b/backend/src/billing/pricing-catalog.service.ts index d10c67d..d2de0b0 100644 --- a/backend/src/billing/pricing-catalog.service.ts +++ b/backend/src/billing/pricing-catalog.service.ts @@ -7,13 +7,19 @@ import { AppRuntime, BillingCycle, DatabaseType, + OptionalService, PricingResourceType, } from '../common/enums'; import { CalculateCostDto } from './dto/billing.dto'; import { - ADDON_PRICING_RESOURCES, - BILLING_RUNTIMES, + FLUENT_BIT_SIDECAR, + getAllBillingRuntimes, + getBillableAddonResourceTypes, + 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'; @@ -32,9 +38,22 @@ export interface PricingRateRow { isActive?: boolean; } +export interface CatalogRuntimeOption { + value: AppRuntime; + label: string; +} + +export interface CatalogOptionalServiceOption { + value: OptionalService; + label: string; + resourceType: PricingResourceType; +} + export interface PricingCatalogResponse { - runtimes: Record; + runtimes: Record; addons: PricingRateRow[]; + runtimeOptions: CatalogRuntimeOption[]; + optionalServiceOptions: CatalogOptionalServiceOption[]; } export interface CostBreakdownLine { @@ -58,7 +77,7 @@ export class PricingCatalogService implements OnModuleInit { } async ensureDefaults() { - for (const runtime of BILLING_RUNTIMES) { + for (const runtime of getAllBillingRuntimes()) { for (const resourceType of RUNTIME_PRICING_RESOURCES) { const existing = await this.rateRepo.findOne({ where: { runtime, resourceType } }); if (!existing) { @@ -74,7 +93,7 @@ export class PricingCatalogService implements OnModuleInit { } } } - for (const resourceType of ADDON_PRICING_RESOURCES) { + for (const resourceType of getBillableAddonResourceTypes()) { const existing = await this.addonRepo.findOne({ where: { resourceType } }); if (!existing) { await this.addonRepo.save( @@ -93,20 +112,31 @@ export class PricingCatalogService implements OnModuleInit { const rates = await this.rateRepo.find({ order: { runtime: 'ASC', resourceType: 'ASC' } }); const addons = await this.addonRepo.find({ order: { resourceType: 'ASC' } }); - const runtimes = {} as Record; - for (const runtime of BILLING_RUNTIMES) { + const billingRuntimes = getAllBillingRuntimes(); + const runtimes: Record = {}; + for (const runtime of billingRuntimes) { runtimes[runtime] = RUNTIME_PRICING_RESOURCES.map((resourceType) => { const row = rates.find((r) => r.runtime === runtime && r.resourceType === resourceType); return this.toRateRow(resourceType, row); }); } + const addonTypes = getBillableAddonResourceTypes(); return { runtimes, - addons: ADDON_PRICING_RESOURCES.map((resourceType) => { + addons: addonTypes.map((resourceType) => { const row = addons.find((a) => a.resourceType === resourceType); return this.toRateRow(resourceType, row); }), + runtimeOptions: billingRuntimes.map((value) => ({ + value, + label: RUNTIME_DISPLAY_LABELS[value] ?? value, + })), + optionalServiceOptions: Object.values(OptionalService).map((value) => ({ + value, + label: OPTIONAL_SERVICE_LABELS[value] ?? value, + resourceType: OPTIONAL_SERVICE_PRICING_TYPE[value], + })), }; } @@ -114,7 +144,7 @@ export class PricingCatalogService implements OnModuleInit { if (dto.runtimes) { for (const [runtimeKey, rows] of Object.entries(dto.runtimes)) { const runtime = runtimeKey as AppRuntime; - if (!BILLING_RUNTIMES.includes(runtime)) continue; + if (!getAllBillingRuntimes().includes(runtime)) continue; for (const row of rows) { await this.upsertRuntimeRate(runtime, row); } @@ -275,31 +305,91 @@ export class PricingCatalogService implements OnModuleInit { } getQuantities(dto: CalculateCostDto): Map { - const cpuCores = this.parseCpuToCores(dto.cpuLimit); - const memoryGb = this.parseMemoryToGb(dto.memoryLimit); const replicas = dto.replicas || 1; - const dbStorageGb = dto.dbStorageSize - ? parseFloat(String(dto.dbStorageSize).replace(/Gi$/i, '')) || 0 - : 0; - const appStorageGb = dto.appStorageSize - ? parseFloat(String(dto.appStorageSize).replace(/Gi$/i, '')) || 0 - : 0; - const totalStorageGb = dbStorageGb + appStorageGb; 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 = + (dto.dbStorageSize + ? parseFloat(String(dto.dbStorageSize).replace(/Gi$/i, '')) || 0 + : 0) + + (dto.appStorageSize + ? 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, cpuCores * replicas); - map.set(PricingResourceType.MEMORY_PER_GB, memoryGb * replicas); - map.set(PricingResourceType.STORAGE_PER_GB, totalStorageGb); + 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.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; @@ -356,53 +446,35 @@ export class PricingCatalogService implements OnModuleInit { } /** Legacy shape for optional-services settings API */ - async getOptionalServicesPricing(): Promise<{ - redis: CyclePrices; - rabbitmq: CyclePrices; - elasticsearch: CyclePrices; - }> { + async getOptionalServicesPricing(): Promise> { const addons = await this.addonRepo.find(); - const pick = (type: PricingResourceType): CyclePrices => { + 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); - return { + result[service] = { hourly: row ? Number(row.hourlyPrice) : 0, monthly: row ? Number(row.monthlyPrice) : 0, yearly: row ? Number(row.yearlyPrice) : 0, }; - }; - return { - redis: pick(PricingResourceType.REDIS_ADDON), - rabbitmq: pick(PricingResourceType.RABBITMQ_ADDON), - elasticsearch: pick(PricingResourceType.ELASTICSEARCH_ADDON), - }; + } + return result; } - async setOptionalServicesPricing(pricing: { - redis: CyclePrices; - rabbitmq: CyclePrices; - elasticsearch: CyclePrices; - }) { - await this.upsertAddonRate({ - resourceType: PricingResourceType.REDIS_ADDON, - hourlyPrice: pricing.redis.hourly, - monthlyPrice: pricing.redis.monthly, - yearlyPrice: pricing.redis.yearly, - isActive: true, - }); - await this.upsertAddonRate({ - resourceType: PricingResourceType.RABBITMQ_ADDON, - hourlyPrice: pricing.rabbitmq.hourly, - monthlyPrice: pricing.rabbitmq.monthly, - yearlyPrice: pricing.rabbitmq.yearly, - isActive: true, - }); - await this.upsertAddonRate({ - resourceType: PricingResourceType.ELASTICSEARCH_ADDON, - hourlyPrice: pricing.elasticsearch.hourly, - monthlyPrice: pricing.elasticsearch.monthly, - yearlyPrice: pricing.elasticsearch.yearly, - isActive: true, - }); + async setOptionalServicesPricing( + pricing: Partial>, + ): Promise> { + for (const service of Object.values(OptionalService)) { + 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, + }); + } return this.getOptionalServicesPricing(); } diff --git a/frontend/src/app/dashboard/admin/billing/page.tsx b/frontend/src/app/dashboard/admin/billing/page.tsx index 4ba916d..da2b245 100644 --- a/frontend/src/app/dashboard/admin/billing/page.tsx +++ b/frontend/src/app/dashboard/admin/billing/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '@/lib/api'; import { toast } from 'react-toastify'; @@ -13,41 +13,27 @@ import type { } from '@/types'; import { DollarSign, Edit2, Shield, Clock, Layers } from 'lucide-react'; -type AppRuntime = 'nodejs' | 'laravel' | 'wordpress'; - -const runtimeTabs: { value: AppRuntime; label: string }[] = [ - { value: 'nodejs', label: 'Node.js' }, - { value: 'laravel', label: 'Laravel' }, - { value: 'wordpress', label: 'WordPress' }, -]; - -const addonResourceTypes: PricingResourceType[] = [ - 'redis_addon', - 'rabbitmq_addon', - 'elasticsearch_addon', - 'custom_domain_addon', -]; - const resourceLabels: Record = { base_fee: 'Base fee', cpu_per_core: 'CPU (per core)', memory_per_gb: 'Memory (per GB)', storage_per_gb: 'Storage (per GB)', database_addon: 'Database addon', - redis_addon: 'Redis', - rabbitmq_addon: 'RabbitMQ', - elasticsearch_addon: 'Elasticsearch', + redis_addon: 'Redis (flat addon)', + rabbitmq_addon: 'RabbitMQ (flat addon)', + elasticsearch_addon: 'Elasticsearch (flat addon)', custom_domain_addon: 'Custom domain + SSL', }; const cycles: BillingCycle[] = ['hourly', 'monthly', 'yearly']; function cloneCatalog(catalog: PricingCatalog): PricingCatalog { - const runtimes = {} as PricingCatalog['runtimes']; - for (const rt of runtimeTabs) { - runtimes[rt.value] = catalog.runtimes[rt.value].map((r) => ({ ...r })); + const runtimes: PricingCatalog['runtimes'] = {}; + for (const key of Object.keys(catalog.runtimes)) { + runtimes[key] = catalog.runtimes[key].map((r) => ({ ...r })); } return { + ...catalog, runtimes, addons: catalog.addons.map((a) => ({ ...a })), }; @@ -123,7 +109,7 @@ function PricingMatrixTable({ export default function AdminBillingPage() { const queryClient = useQueryClient(); - const [activeRuntime, setActiveRuntime] = useState('nodejs'); + const [activeRuntime, setActiveRuntime] = useState('nodejs'); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(null); @@ -132,6 +118,14 @@ export default function AdminBillingPage() { queryFn: () => api.get('/billing/pricing-catalog').then((r) => r.data), }); + useEffect(() => { + const options = catalog?.runtimeOptions ?? []; + if (options.length === 0) return; + if (!options.some((o) => o.value === activeRuntime)) { + setActiveRuntime(options[0].value); + } + }, [catalog, activeRuntime]); + const saveMutation = useMutation({ mutationFn: (body: PricingCatalog) => api.patch('/billing/pricing-catalog', body), onSuccess: () => { @@ -152,10 +146,14 @@ export default function AdminBillingPage() { }); const display = editing && draft ? draft : catalog; + const runtimeTabs = display?.runtimeOptions ?? catalog?.runtimeOptions ?? []; const startEdit = () => { if (!catalog) return; setDraft(cloneCatalog(catalog)); + if (!activeRuntime && catalog.runtimeOptions[0]) { + setActiveRuntime(catalog.runtimeOptions[0].value); + } setEditing(true); }; @@ -171,7 +169,7 @@ export default function AdminBillingPage() { ...draft, runtimes: { ...draft.runtimes, - [activeRuntime]: draft.runtimes[activeRuntime].map((row) => + [activeRuntime]: (draft.runtimes[activeRuntime] ?? []).map((row) => row.resourceType === resourceType ? { ...row, [field]: value } : row, ), }, @@ -201,7 +199,7 @@ export default function AdminBillingPage() { ...draft, runtimes: { ...draft.runtimes, - [activeRuntime]: draft.runtimes[activeRuntime].map((row) => ({ + [activeRuntime]: (draft.runtimes[activeRuntime] ?? []).map((row) => ({ ...row, yearlyPrice: Math.round(Number(row.monthlyPrice) * 12), })), @@ -223,8 +221,8 @@ export default function AdminBillingPage() { saveMutation.mutate(draft); }; - const addonRows = - display?.addons.filter((a) => addonResourceTypes.includes(a.resourceType)) ?? []; + const runtimeRows = display?.runtimes[activeRuntime] ?? []; + const addonRows = display?.addons ?? []; return (
@@ -234,7 +232,7 @@ export default function AdminBillingPage() { Billing & Pricing

- Usage-based prices per application type. Each resource has explicit hourly, monthly, and yearly rates — deploy cost uses the column for the cycle the user selects. + 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.

{!editing ? ( @@ -307,7 +305,7 @@ export default function AdminBillingPage() { @@ -329,7 +327,7 @@ export default function AdminBillingPage() { )}

- Redis, RabbitMQ, Elasticsearch, and custom domain — same prices for all application types. + Flat addon fees (optional). Deploy cost also includes each service's CPU, RAM, and disk at the app runtime unit rates above.

; + runtimes: Record; addons: PricingRateRow[]; + runtimeOptions: CatalogRuntimeOption[]; + optionalServiceOptions: CatalogOptionalServiceOption[]; } export interface WalletBalance {