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:
@@ -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[] = [
|
/** All application runtimes — new enum values appear in billing automatically. */
|
||||||
AppRuntime.NODEJS,
|
export function getAllBillingRuntimes(): AppRuntime[] {
|
||||||
AppRuntime.LARAVEL,
|
return Object.values(AppRuntime);
|
||||||
AppRuntime.WORDPRESS,
|
}
|
||||||
];
|
|
||||||
|
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[] = [
|
export const RUNTIME_PRICING_RESOURCES: PricingResourceType[] = [
|
||||||
PricingResourceType.BASE_FEE,
|
PricingResourceType.BASE_FEE,
|
||||||
@@ -14,12 +24,38 @@ export const RUNTIME_PRICING_RESOURCES: PricingResourceType[] = [
|
|||||||
PricingResourceType.DATABASE_ADDON,
|
PricingResourceType.DATABASE_ADDON,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ADDON_PRICING_RESOURCES: PricingResourceType[] = [
|
/** Maps optional service → flat addon row in addon_rates. */
|
||||||
PricingResourceType.REDIS_ADDON,
|
export const OPTIONAL_SERVICE_PRICING_TYPE: Record<OptionalService, PricingResourceType> = {
|
||||||
PricingResourceType.RABBITMQ_ADDON,
|
[OptionalService.REDIS]: PricingResourceType.REDIS_ADDON,
|
||||||
PricingResourceType.ELASTICSEARCH_ADDON,
|
[OptionalService.RABBITMQ]: PricingResourceType.RABBITMQ_ADDON,
|
||||||
PricingResourceType.CUSTOM_DOMAIN_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> = {
|
export const RESOURCE_LABELS: Record<PricingResourceType, string> = {
|
||||||
[PricingResourceType.BASE_FEE]: 'Base fee',
|
[PricingResourceType.BASE_FEE]: 'Base fee',
|
||||||
@@ -27,8 +63,14 @@ export const RESOURCE_LABELS: Record<PricingResourceType, string> = {
|
|||||||
[PricingResourceType.MEMORY_PER_GB]: 'Memory (per GB)',
|
[PricingResourceType.MEMORY_PER_GB]: 'Memory (per GB)',
|
||||||
[PricingResourceType.STORAGE_PER_GB]: 'Storage (per GB)',
|
[PricingResourceType.STORAGE_PER_GB]: 'Storage (per GB)',
|
||||||
[PricingResourceType.DATABASE_ADDON]: 'Database addon',
|
[PricingResourceType.DATABASE_ADDON]: 'Database addon',
|
||||||
[PricingResourceType.REDIS_ADDON]: 'Redis addon',
|
[PricingResourceType.REDIS_ADDON]: 'Redis (flat addon)',
|
||||||
[PricingResourceType.RABBITMQ_ADDON]: 'RabbitMQ addon',
|
[PricingResourceType.RABBITMQ_ADDON]: 'RabbitMQ (flat addon)',
|
||||||
[PricingResourceType.ELASTICSEARCH_ADDON]: 'Elasticsearch addon',
|
[PricingResourceType.ELASTICSEARCH_ADDON]: 'Elasticsearch (flat addon)',
|
||||||
[PricingResourceType.CUSTOM_DOMAIN_ADDON]: 'Custom domain + SSL',
|
[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,
|
AppRuntime,
|
||||||
BillingCycle,
|
BillingCycle,
|
||||||
DatabaseType,
|
DatabaseType,
|
||||||
|
OptionalService,
|
||||||
PricingResourceType,
|
PricingResourceType,
|
||||||
} from '../common/enums';
|
} from '../common/enums';
|
||||||
|
import { OPTIONAL_SERVICE_DEPLOY_SPECS } from './pricing-catalog.constants';
|
||||||
import { CalculateCostDto } from './dto/billing.dto';
|
import { CalculateCostDto } from './dto/billing.dto';
|
||||||
|
|
||||||
describe('PricingCatalogService', () => {
|
describe('PricingCatalogService', () => {
|
||||||
@@ -115,6 +117,38 @@ describe('PricingCatalogService', () => {
|
|||||||
expect(result.yearly).not.toBe(result.monthly * 12);
|
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', () => {
|
it('amountForCycleFromLine picks the correct column', () => {
|
||||||
const line = { label: 'Test', hourly: 1, monthly: 2, yearly: 3 };
|
const line = { label: 'Test', hourly: 1, monthly: 2, yearly: 3 };
|
||||||
expect(service.amountForCycleFromLine(line, BillingCycle.HOURLY)).toBe(1);
|
expect(service.amountForCycleFromLine(line, BillingCycle.HOURLY)).toBe(1);
|
||||||
|
|||||||
@@ -7,13 +7,19 @@ import {
|
|||||||
AppRuntime,
|
AppRuntime,
|
||||||
BillingCycle,
|
BillingCycle,
|
||||||
DatabaseType,
|
DatabaseType,
|
||||||
|
OptionalService,
|
||||||
PricingResourceType,
|
PricingResourceType,
|
||||||
} from '../common/enums';
|
} from '../common/enums';
|
||||||
import { CalculateCostDto } from './dto/billing.dto';
|
import { CalculateCostDto } from './dto/billing.dto';
|
||||||
import {
|
import {
|
||||||
ADDON_PRICING_RESOURCES,
|
FLUENT_BIT_SIDECAR,
|
||||||
BILLING_RUNTIMES,
|
getAllBillingRuntimes,
|
||||||
|
getBillableAddonResourceTypes,
|
||||||
|
OPTIONAL_SERVICE_DEPLOY_SPECS,
|
||||||
|
OPTIONAL_SERVICE_LABELS,
|
||||||
|
OPTIONAL_SERVICE_PRICING_TYPE,
|
||||||
RESOURCE_LABELS,
|
RESOURCE_LABELS,
|
||||||
|
RUNTIME_DISPLAY_LABELS,
|
||||||
RUNTIME_PRICING_RESOURCES,
|
RUNTIME_PRICING_RESOURCES,
|
||||||
} from './pricing-catalog.constants';
|
} from './pricing-catalog.constants';
|
||||||
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
|
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
|
||||||
@@ -32,9 +38,22 @@ export interface PricingRateRow {
|
|||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CatalogRuntimeOption {
|
||||||
|
value: AppRuntime;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogOptionalServiceOption {
|
||||||
|
value: OptionalService;
|
||||||
|
label: string;
|
||||||
|
resourceType: PricingResourceType;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PricingCatalogResponse {
|
export interface PricingCatalogResponse {
|
||||||
runtimes: Record<AppRuntime, PricingRateRow[]>;
|
runtimes: Record<string, PricingRateRow[]>;
|
||||||
addons: PricingRateRow[];
|
addons: PricingRateRow[];
|
||||||
|
runtimeOptions: CatalogRuntimeOption[];
|
||||||
|
optionalServiceOptions: CatalogOptionalServiceOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CostBreakdownLine {
|
export interface CostBreakdownLine {
|
||||||
@@ -58,7 +77,7 @@ export class PricingCatalogService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async ensureDefaults() {
|
async ensureDefaults() {
|
||||||
for (const runtime of BILLING_RUNTIMES) {
|
for (const runtime of getAllBillingRuntimes()) {
|
||||||
for (const resourceType of RUNTIME_PRICING_RESOURCES) {
|
for (const resourceType of RUNTIME_PRICING_RESOURCES) {
|
||||||
const existing = await this.rateRepo.findOne({ where: { runtime, resourceType } });
|
const existing = await this.rateRepo.findOne({ where: { runtime, resourceType } });
|
||||||
if (!existing) {
|
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 } });
|
const existing = await this.addonRepo.findOne({ where: { resourceType } });
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
await this.addonRepo.save(
|
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 rates = await this.rateRepo.find({ order: { runtime: 'ASC', resourceType: 'ASC' } });
|
||||||
const addons = await this.addonRepo.find({ order: { resourceType: 'ASC' } });
|
const addons = await this.addonRepo.find({ order: { resourceType: 'ASC' } });
|
||||||
|
|
||||||
const runtimes = {} as Record<AppRuntime, PricingRateRow[]>;
|
const billingRuntimes = getAllBillingRuntimes();
|
||||||
for (const runtime of BILLING_RUNTIMES) {
|
const runtimes: Record<string, PricingRateRow[]> = {};
|
||||||
|
for (const runtime of billingRuntimes) {
|
||||||
runtimes[runtime] = RUNTIME_PRICING_RESOURCES.map((resourceType) => {
|
runtimes[runtime] = RUNTIME_PRICING_RESOURCES.map((resourceType) => {
|
||||||
const row = rates.find((r) => r.runtime === runtime && r.resourceType === resourceType);
|
const row = rates.find((r) => r.runtime === runtime && r.resourceType === resourceType);
|
||||||
return this.toRateRow(resourceType, row);
|
return this.toRateRow(resourceType, row);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addonTypes = getBillableAddonResourceTypes();
|
||||||
return {
|
return {
|
||||||
runtimes,
|
runtimes,
|
||||||
addons: ADDON_PRICING_RESOURCES.map((resourceType) => {
|
addons: addonTypes.map((resourceType) => {
|
||||||
const row = addons.find((a) => a.resourceType === resourceType);
|
const row = addons.find((a) => a.resourceType === resourceType);
|
||||||
return this.toRateRow(resourceType, row);
|
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) {
|
if (dto.runtimes) {
|
||||||
for (const [runtimeKey, rows] of Object.entries(dto.runtimes)) {
|
for (const [runtimeKey, rows] of Object.entries(dto.runtimes)) {
|
||||||
const runtime = runtimeKey as AppRuntime;
|
const runtime = runtimeKey as AppRuntime;
|
||||||
if (!BILLING_RUNTIMES.includes(runtime)) continue;
|
if (!getAllBillingRuntimes().includes(runtime)) continue;
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
await this.upsertRuntimeRate(runtime, row);
|
await this.upsertRuntimeRate(runtime, row);
|
||||||
}
|
}
|
||||||
@@ -275,31 +305,91 @@ export class PricingCatalogService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getQuantities(dto: CalculateCostDto): Map<PricingResourceType, number> {
|
getQuantities(dto: CalculateCostDto): Map<PricingResourceType, number> {
|
||||||
const cpuCores = this.parseCpuToCores(dto.cpuLimit);
|
|
||||||
const memoryGb = this.parseMemoryToGb(dto.memoryLimit);
|
|
||||||
const replicas = dto.replicas || 1;
|
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';
|
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>();
|
const map = new Map<PricingResourceType, number>();
|
||||||
map.set(PricingResourceType.BASE_FEE, 1);
|
map.set(PricingResourceType.BASE_FEE, 1);
|
||||||
map.set(PricingResourceType.CPU_PER_CORE, cpuCores * replicas);
|
map.set(PricingResourceType.CPU_PER_CORE, cpuQty);
|
||||||
map.set(PricingResourceType.MEMORY_PER_GB, memoryGb * replicas);
|
map.set(PricingResourceType.MEMORY_PER_GB, memoryQty);
|
||||||
map.set(PricingResourceType.STORAGE_PER_GB, totalStorageGb);
|
map.set(PricingResourceType.STORAGE_PER_GB, storageQty);
|
||||||
map.set(PricingResourceType.DATABASE_ADDON, hasDatabase ? 1 : 0);
|
map.set(PricingResourceType.DATABASE_ADDON, hasDatabase ? 1 : 0);
|
||||||
map.set(PricingResourceType.REDIS_ADDON, dto.enableRedis ? 1 : 0);
|
map.set(
|
||||||
map.set(PricingResourceType.RABBITMQ_ADDON, dto.enableRabbitmq ? 1 : 0);
|
PricingResourceType.REDIS_ADDON,
|
||||||
map.set(PricingResourceType.ELASTICSEARCH_ADDON, dto.enableElasticsearch ? 1 : 0);
|
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);
|
map.set(PricingResourceType.CUSTOM_DOMAIN_ADDON, dto.enableCustomDomain ? 1 : 0);
|
||||||
return map;
|
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 {
|
parseCpuToCores(cpu: string): number {
|
||||||
if (!cpu) return 0;
|
if (!cpu) return 0;
|
||||||
if (cpu.endsWith('m')) return parseFloat(cpu) / 1000;
|
if (cpu.endsWith('m')) return parseFloat(cpu) / 1000;
|
||||||
@@ -356,53 +446,35 @@ export class PricingCatalogService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Legacy shape for optional-services settings API */
|
/** Legacy shape for optional-services settings API */
|
||||||
async getOptionalServicesPricing(): Promise<{
|
async getOptionalServicesPricing(): Promise<Record<OptionalService, CyclePrices>> {
|
||||||
redis: CyclePrices;
|
|
||||||
rabbitmq: CyclePrices;
|
|
||||||
elasticsearch: CyclePrices;
|
|
||||||
}> {
|
|
||||||
const addons = await this.addonRepo.find();
|
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);
|
const row = addons.find((a) => a.resourceType === type);
|
||||||
return {
|
result[service] = {
|
||||||
hourly: row ? Number(row.hourlyPrice) : 0,
|
hourly: row ? Number(row.hourlyPrice) : 0,
|
||||||
monthly: row ? Number(row.monthlyPrice) : 0,
|
monthly: row ? Number(row.monthlyPrice) : 0,
|
||||||
yearly: row ? Number(row.yearlyPrice) : 0,
|
yearly: row ? Number(row.yearlyPrice) : 0,
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
return {
|
return result;
|
||||||
redis: pick(PricingResourceType.REDIS_ADDON),
|
|
||||||
rabbitmq: pick(PricingResourceType.RABBITMQ_ADDON),
|
|
||||||
elasticsearch: pick(PricingResourceType.ELASTICSEARCH_ADDON),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async setOptionalServicesPricing(pricing: {
|
async setOptionalServicesPricing(
|
||||||
redis: CyclePrices;
|
pricing: Partial<Record<OptionalService, CyclePrices>>,
|
||||||
rabbitmq: CyclePrices;
|
): Promise<Record<OptionalService, CyclePrices>> {
|
||||||
elasticsearch: CyclePrices;
|
for (const service of Object.values(OptionalService)) {
|
||||||
}) {
|
const prices = pricing[service];
|
||||||
await this.upsertAddonRate({
|
if (!prices) continue;
|
||||||
resourceType: PricingResourceType.REDIS_ADDON,
|
await this.upsertAddonRate({
|
||||||
hourlyPrice: pricing.redis.hourly,
|
resourceType: OPTIONAL_SERVICE_PRICING_TYPE[service],
|
||||||
monthlyPrice: pricing.redis.monthly,
|
hourlyPrice: prices.hourly,
|
||||||
yearlyPrice: pricing.redis.yearly,
|
monthlyPrice: prices.monthly,
|
||||||
isActive: true,
|
yearlyPrice: prices.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,
|
|
||||||
});
|
|
||||||
return this.getOptionalServicesPricing();
|
return this.getOptionalServicesPricing();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
@@ -13,41 +13,27 @@ import type {
|
|||||||
} from '@/types';
|
} from '@/types';
|
||||||
import { DollarSign, Edit2, Shield, Clock, Layers } from 'lucide-react';
|
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> = {
|
const resourceLabels: Record<PricingResourceType, string> = {
|
||||||
base_fee: 'Base fee',
|
base_fee: 'Base fee',
|
||||||
cpu_per_core: 'CPU (per core)',
|
cpu_per_core: 'CPU (per core)',
|
||||||
memory_per_gb: 'Memory (per GB)',
|
memory_per_gb: 'Memory (per GB)',
|
||||||
storage_per_gb: 'Storage (per GB)',
|
storage_per_gb: 'Storage (per GB)',
|
||||||
database_addon: 'Database addon',
|
database_addon: 'Database addon',
|
||||||
redis_addon: 'Redis',
|
redis_addon: 'Redis (flat addon)',
|
||||||
rabbitmq_addon: 'RabbitMQ',
|
rabbitmq_addon: 'RabbitMQ (flat addon)',
|
||||||
elasticsearch_addon: 'Elasticsearch',
|
elasticsearch_addon: 'Elasticsearch (flat addon)',
|
||||||
custom_domain_addon: 'Custom domain + SSL',
|
custom_domain_addon: 'Custom domain + SSL',
|
||||||
};
|
};
|
||||||
|
|
||||||
const cycles: BillingCycle[] = ['hourly', 'monthly', 'yearly'];
|
const cycles: BillingCycle[] = ['hourly', 'monthly', 'yearly'];
|
||||||
|
|
||||||
function cloneCatalog(catalog: PricingCatalog): PricingCatalog {
|
function cloneCatalog(catalog: PricingCatalog): PricingCatalog {
|
||||||
const runtimes = {} as PricingCatalog['runtimes'];
|
const runtimes: PricingCatalog['runtimes'] = {};
|
||||||
for (const rt of runtimeTabs) {
|
for (const key of Object.keys(catalog.runtimes)) {
|
||||||
runtimes[rt.value] = catalog.runtimes[rt.value].map((r) => ({ ...r }));
|
runtimes[key] = catalog.runtimes[key].map((r) => ({ ...r }));
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
...catalog,
|
||||||
runtimes,
|
runtimes,
|
||||||
addons: catalog.addons.map((a) => ({ ...a })),
|
addons: catalog.addons.map((a) => ({ ...a })),
|
||||||
};
|
};
|
||||||
@@ -123,7 +109,7 @@ function PricingMatrixTable({
|
|||||||
|
|
||||||
export default function AdminBillingPage() {
|
export default function AdminBillingPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [activeRuntime, setActiveRuntime] = useState<AppRuntime>('nodejs');
|
const [activeRuntime, setActiveRuntime] = useState<string>('nodejs');
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState<PricingCatalog | null>(null);
|
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),
|
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({
|
const saveMutation = useMutation({
|
||||||
mutationFn: (body: PricingCatalog) => api.patch('/billing/pricing-catalog', body),
|
mutationFn: (body: PricingCatalog) => api.patch('/billing/pricing-catalog', body),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -152,10 +146,14 @@ export default function AdminBillingPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const display = editing && draft ? draft : catalog;
|
const display = editing && draft ? draft : catalog;
|
||||||
|
const runtimeTabs = display?.runtimeOptions ?? catalog?.runtimeOptions ?? [];
|
||||||
|
|
||||||
const startEdit = () => {
|
const startEdit = () => {
|
||||||
if (!catalog) return;
|
if (!catalog) return;
|
||||||
setDraft(cloneCatalog(catalog));
|
setDraft(cloneCatalog(catalog));
|
||||||
|
if (!activeRuntime && catalog.runtimeOptions[0]) {
|
||||||
|
setActiveRuntime(catalog.runtimeOptions[0].value);
|
||||||
|
}
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -171,7 +169,7 @@ export default function AdminBillingPage() {
|
|||||||
...draft,
|
...draft,
|
||||||
runtimes: {
|
runtimes: {
|
||||||
...draft.runtimes,
|
...draft.runtimes,
|
||||||
[activeRuntime]: draft.runtimes[activeRuntime].map((row) =>
|
[activeRuntime]: (draft.runtimes[activeRuntime] ?? []).map((row) =>
|
||||||
row.resourceType === resourceType ? { ...row, [field]: value } : row,
|
row.resourceType === resourceType ? { ...row, [field]: value } : row,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -201,7 +199,7 @@ export default function AdminBillingPage() {
|
|||||||
...draft,
|
...draft,
|
||||||
runtimes: {
|
runtimes: {
|
||||||
...draft.runtimes,
|
...draft.runtimes,
|
||||||
[activeRuntime]: draft.runtimes[activeRuntime].map((row) => ({
|
[activeRuntime]: (draft.runtimes[activeRuntime] ?? []).map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
yearlyPrice: Math.round(Number(row.monthlyPrice) * 12),
|
yearlyPrice: Math.round(Number(row.monthlyPrice) * 12),
|
||||||
})),
|
})),
|
||||||
@@ -223,8 +221,8 @@ export default function AdminBillingPage() {
|
|||||||
saveMutation.mutate(draft);
|
saveMutation.mutate(draft);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addonRows =
|
const runtimeRows = display?.runtimes[activeRuntime] ?? [];
|
||||||
display?.addons.filter((a) => addonResourceTypes.includes(a.resourceType)) ?? [];
|
const addonRows = display?.addons ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto space-y-6 animate-fade-in">
|
<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
|
<DollarSign className="w-6 h-6" /> Billing & Pricing
|
||||||
</h1>
|
</h1>
|
||||||
<p className="page-subtitle">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{!editing ? (
|
{!editing ? (
|
||||||
@@ -307,7 +305,7 @@ export default function AdminBillingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PricingMatrixTable
|
<PricingMatrixTable
|
||||||
rows={display.runtimes[activeRuntime]}
|
rows={runtimeRows}
|
||||||
readOnly={!editing}
|
readOnly={!editing}
|
||||||
onChange={updateRuntimePrice}
|
onChange={updateRuntimePrice}
|
||||||
/>
|
/>
|
||||||
@@ -329,7 +327,7 @@ export default function AdminBillingPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500">
|
<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's CPU, RAM, and disk at the app runtime unit rates above.
|
||||||
</p>
|
</p>
|
||||||
<PricingMatrixTable
|
<PricingMatrixTable
|
||||||
rows={addonRows}
|
rows={addonRows}
|
||||||
|
|||||||
@@ -383,9 +383,22 @@ export interface PricingRateRow {
|
|||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CatalogRuntimeOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogOptionalServiceOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
resourceType: PricingResourceType;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PricingCatalog {
|
export interface PricingCatalog {
|
||||||
runtimes: Record<'nodejs' | 'laravel' | 'wordpress', PricingRateRow[]>;
|
runtimes: Record<string, PricingRateRow[]>;
|
||||||
addons: PricingRateRow[];
|
addons: PricingRateRow[];
|
||||||
|
runtimeOptions: CatalogRuntimeOption[];
|
||||||
|
optionalServiceOptions: CatalogOptionalServiceOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WalletBalance {
|
export interface WalletBalance {
|
||||||
|
|||||||
Reference in New Issue
Block a user