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 <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-15 18:22:35 +03:30
parent 35235fe0fc
commit bb27c90ae4
6 changed files with 292 additions and 109 deletions
@@ -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;
@@ -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, string> = {
[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,
/** Maps optional service → flat addon row in addon_rates. */
export const OPTIONAL_SERVICE_PRICING_TYPE: Record<OptionalService, PricingResourceType> = {
[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, string> = {
[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, string> = {
[PricingResourceType.BASE_FEE]: 'Base fee',
@@ -27,8 +63,14 @@ export const RESOURCE_LABELS: Record<PricingResourceType, string> = {
[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();
@@ -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);
+132 -60
View File
@@ -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<AppRuntime, PricingRateRow[]>;
runtimes: Record<string, PricingRateRow[]>;
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<AppRuntime, PricingRateRow[]>;
for (const runtime of BILLING_RUNTIMES) {
const billingRuntimes = getAllBillingRuntimes();
const runtimes: Record<string, PricingRateRow[]> = {};
for (const runtime of billingRuntimes) {
runtimes[runtime] = RUNTIME_PRICING_RESOURCES.map((resourceType) => {
const row = rates.find((r) => r.runtime === runtime && r.resourceType === resourceType);
return this.toRateRow(resourceType, row);
});
}
const 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<PricingResourceType, number> {
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<PricingResourceType, number>();
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<Record<OptionalService, CyclePrices>> {
const addons = await this.addonRepo.find();
const pick = (type: PricingResourceType): CyclePrices => {
const result = {} as Record<OptionalService, CyclePrices>;
for (const service of Object.values(OptionalService)) {
const type = OPTIONAL_SERVICE_PRICING_TYPE[service];
const row = addons.find((a) => a.resourceType === type);
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;
}) {
async setOptionalServicesPricing(
pricing: Partial<Record<OptionalService, CyclePrices>>,
): Promise<Record<OptionalService, CyclePrices>> {
for (const service of Object.values(OptionalService)) {
const prices = pricing[service];
if (!prices) continue;
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,
resourceType: OPTIONAL_SERVICE_PRICING_TYPE[service],
hourlyPrice: prices.hourly,
monthlyPrice: prices.monthly,
yearlyPrice: prices.yearly,
isActive: true,
});
}
return this.getOptionalServicesPricing();
}
@@ -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<PricingResourceType, string> = {
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<AppRuntime>('nodejs');
const [activeRuntime, setActiveRuntime] = useState<string>('nodejs');
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<PricingCatalog | null>(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 (
<div className="max-w-5xl mx-auto space-y-6 animate-fade-in">
@@ -234,7 +232,7 @@ export default function AdminBillingPage() {
<DollarSign className="w-6 h-6" /> Billing & Pricing
</h1>
<p className="page-subtitle">
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.
</p>
</div>
{!editing ? (
@@ -307,7 +305,7 @@ export default function AdminBillingPage() {
</div>
<PricingMatrixTable
rows={display.runtimes[activeRuntime]}
rows={runtimeRows}
readOnly={!editing}
onChange={updateRuntimePrice}
/>
@@ -329,7 +327,7 @@ export default function AdminBillingPage() {
)}
</div>
<p className="text-sm text-gray-500">
Redis, RabbitMQ, Elasticsearch, and custom domain same prices for all application types.
Flat addon fees (optional). Deploy cost also includes each service&apos;s CPU, RAM, and disk at the app runtime unit rates above.
</p>
<PricingMatrixTable
rows={addonRows}
+14 -1
View File
@@ -383,9 +383,22 @@ export interface PricingRateRow {
isActive?: boolean;
}
export interface CatalogRuntimeOption {
value: string;
label: string;
}
export interface CatalogOptionalServiceOption {
value: string;
label: string;
resourceType: PricingResourceType;
}
export interface PricingCatalog {
runtimes: Record<'nodejs' | 'laravel' | 'wordpress', PricingRateRow[]>;
runtimes: Record<string, PricingRateRow[]>;
addons: PricingRateRow[];
runtimeOptions: CatalogRuntimeOption[];
optionalServiceOptions: CatalogOptionalServiceOption[];
}
export interface WalletBalance {