Replace billing plans with per-runtime usage pricing catalog.

Store explicit hourly/monthly/yearly rates in pricing_rates and addon_rates, compute deploy costs without cycle conversion, and simplify admin UI and wallet payment to cycle-only.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-15 18:10:54 +03:30
parent 5239e8aa94
commit 35235fe0fc
15 changed files with 1293 additions and 716 deletions
@@ -0,0 +1,426 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PricingRate } from './entities/pricing-rate.entity';
import { AddonRate } from './entities/addon-rate.entity';
import {
AppRuntime,
BillingCycle,
DatabaseType,
PricingResourceType,
} from '../common/enums';
import { CalculateCostDto } from './dto/billing.dto';
import {
ADDON_PRICING_RESOURCES,
BILLING_RUNTIMES,
RESOURCE_LABELS,
RUNTIME_PRICING_RESOURCES,
} from './pricing-catalog.constants';
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
export interface CyclePrices {
hourly: number;
monthly: number;
yearly: number;
}
export interface PricingRateRow {
resourceType: PricingResourceType;
hourlyPrice: number;
monthlyPrice: number;
yearlyPrice: number;
isActive?: boolean;
}
export interface PricingCatalogResponse {
runtimes: Record<AppRuntime, PricingRateRow[]>;
addons: PricingRateRow[];
}
export interface CostBreakdownLine {
label: string;
hourly: number;
monthly: number;
yearly: number;
}
@Injectable()
export class PricingCatalogService implements OnModuleInit {
private readonly logger = new Logger(PricingCatalogService.name);
constructor(
@InjectRepository(PricingRate) private readonly rateRepo: Repository<PricingRate>,
@InjectRepository(AddonRate) private readonly addonRepo: Repository<AddonRate>,
) {}
async onModuleInit() {
await this.ensureDefaults();
}
async ensureDefaults() {
for (const runtime of BILLING_RUNTIMES) {
for (const resourceType of RUNTIME_PRICING_RESOURCES) {
const existing = await this.rateRepo.findOne({ where: { runtime, resourceType } });
if (!existing) {
await this.rateRepo.save(
this.rateRepo.create({
runtime,
resourceType,
hourlyPrice: 0,
monthlyPrice: 0,
yearlyPrice: 0,
}),
);
}
}
}
for (const resourceType of ADDON_PRICING_RESOURCES) {
const existing = await this.addonRepo.findOne({ where: { resourceType } });
if (!existing) {
await this.addonRepo.save(
this.addonRepo.create({
resourceType,
hourlyPrice: 0,
monthlyPrice: 0,
yearlyPrice: 0,
}),
);
}
}
}
async getCatalog(): Promise<PricingCatalogResponse> {
const rates = await this.rateRepo.find({ order: { runtime: 'ASC', resourceType: 'ASC' } });
const addons = await this.addonRepo.find({ order: { resourceType: 'ASC' } });
const runtimes = {} as Record<AppRuntime, PricingRateRow[]>;
for (const runtime of BILLING_RUNTIMES) {
runtimes[runtime] = RUNTIME_PRICING_RESOURCES.map((resourceType) => {
const row = rates.find((r) => r.runtime === runtime && r.resourceType === resourceType);
return this.toRateRow(resourceType, row);
});
}
return {
runtimes,
addons: ADDON_PRICING_RESOURCES.map((resourceType) => {
const row = addons.find((a) => a.resourceType === resourceType);
return this.toRateRow(resourceType, row);
}),
};
}
async updateCatalog(dto: UpdatePricingCatalogDto): Promise<PricingCatalogResponse> {
if (dto.runtimes) {
for (const [runtimeKey, rows] of Object.entries(dto.runtimes)) {
const runtime = runtimeKey as AppRuntime;
if (!BILLING_RUNTIMES.includes(runtime)) continue;
for (const row of rows) {
await this.upsertRuntimeRate(runtime, row);
}
}
}
if (dto.addons) {
for (const row of dto.addons) {
await this.upsertAddonRate(row);
}
}
return this.getCatalog();
}
async getRatesForRuntime(runtime: AppRuntime): Promise<PricingRate[]> {
return this.rateRepo.find({
where: { runtime, isActive: true },
});
}
async getAddonRates(): Promise<AddonRate[]> {
return this.addonRepo.find({ where: { isActive: true } });
}
computeTotals(dto: CalculateCostDto): {
hourly: number;
monthly: number;
yearly: number;
breakdown: CostBreakdownLine[];
} {
const lines = this.computeLineItemsSync(dto);
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);
return {
hourly: Math.round(hourly),
monthly: Math.round(monthly),
yearly: Math.round(yearly),
breakdown: lines,
};
}
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);
}
computeTotalsWithRates(
dto: CalculateCostDto,
rates: PricingRate[],
addons: AddonRate[],
) {
const lines = this.buildLines(dto, rates, addons);
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);
return {
hourly: Math.round(hourly),
monthly: Math.round(monthly),
yearly: Math.round(yearly),
breakdown: lines,
};
}
amountForCycleFromLine(line: CostBreakdownLine, cycle: BillingCycle): number {
switch (cycle) {
case BillingCycle.HOURLY:
return line.hourly;
case BillingCycle.MONTHLY:
return line.monthly;
case BillingCycle.YEARLY:
return line.yearly;
default:
return line.monthly;
}
}
private computeLineItemsSync(dto: CalculateCostDto): CostBreakdownLine[] {
return this.buildLines(dto, [], []);
}
private buildLines(
dto: CalculateCostDto,
rates: PricingRate[],
addons: AddonRate[],
): CostBreakdownLine[] {
const lines: CostBreakdownLine[] = [];
const quantities = this.getQuantities(dto);
for (const rate of rates) {
const qty = quantities.get(rate.resourceType) ?? 0;
if (qty <= 0) continue;
const line = this.lineFromPrices(
RESOURCE_LABELS[rate.resourceType],
qty,
Number(rate.hourlyPrice),
Number(rate.monthlyPrice),
Number(rate.yearlyPrice),
rate.resourceType,
dto,
);
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,
dto,
);
if (line) lines.push(line);
}
return lines;
}
private lineFromPrices(
baseLabel: string,
quantity: number,
hourlyUnit: number,
monthlyUnit: number,
yearlyUnit: number,
resourceType: PricingResourceType,
dto: CalculateCostDto,
): 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);
return { label, hourly, monthly, yearly };
}
private describeLine(
baseLabel: string,
resourceType: PricingResourceType,
quantity: number,
dto: CalculateCostDto,
): string {
switch (resourceType) {
case PricingResourceType.CPU_PER_CORE:
return `CPU (${quantity.toFixed(2)} core)`;
case PricingResourceType.MEMORY_PER_GB:
return `Memory (${quantity.toFixed(2)} GB)`;
case PricingResourceType.STORAGE_PER_GB:
return `Storage (${quantity} GB)`;
default:
return baseLabel;
}
}
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';
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.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;
}
parseCpuToCores(cpu: string): number {
if (!cpu) return 0;
if (cpu.endsWith('m')) return parseFloat(cpu) / 1000;
return parseFloat(cpu) || 0;
}
parseMemoryToGb(memory: string): number {
if (!memory) return 0;
if (memory.endsWith('Gi')) return parseFloat(memory);
if (memory.endsWith('Mi')) return parseFloat(memory) / 1024;
if (memory.endsWith('Ki')) return parseFloat(memory) / (1024 * 1024);
return parseFloat(memory) / (1024 * 1024 * 1024);
}
private async upsertRuntimeRate(runtime: AppRuntime, row: PricingRateRow) {
let entity = await this.rateRepo.findOne({
where: { runtime, resourceType: row.resourceType },
});
if (!entity) {
entity = this.rateRepo.create({ runtime, resourceType: row.resourceType });
}
entity.hourlyPrice = row.hourlyPrice;
entity.monthlyPrice = row.monthlyPrice;
entity.yearlyPrice = row.yearlyPrice;
if (row.isActive !== undefined) entity.isActive = row.isActive;
await this.rateRepo.save(entity);
}
private async upsertAddonRate(row: PricingRateRow) {
let entity = await this.addonRepo.findOne({
where: { resourceType: row.resourceType },
});
if (!entity) {
entity = this.addonRepo.create({ resourceType: row.resourceType });
}
entity.hourlyPrice = row.hourlyPrice;
entity.monthlyPrice = row.monthlyPrice;
entity.yearlyPrice = row.yearlyPrice;
if (row.isActive !== undefined) entity.isActive = row.isActive;
await this.addonRepo.save(entity);
}
private toRateRow(
resourceType: PricingResourceType,
entity?: PricingRate | AddonRate | null,
): PricingRateRow {
return {
resourceType,
hourlyPrice: entity ? Number(entity.hourlyPrice) : 0,
monthlyPrice: entity ? Number(entity.monthlyPrice) : 0,
yearlyPrice: entity ? Number(entity.yearlyPrice) : 0,
isActive: entity?.isActive ?? true,
};
}
/** Legacy shape for optional-services settings API */
async getOptionalServicesPricing(): Promise<{
redis: CyclePrices;
rabbitmq: CyclePrices;
elasticsearch: CyclePrices;
}> {
const addons = await this.addonRepo.find();
const pick = (type: PricingResourceType): CyclePrices => {
const row = addons.find((a) => a.resourceType === type);
return {
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),
};
}
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,
});
return this.getOptionalServicesPricing();
}
async getCustomDomainPrice(): Promise<{ monthlyPrice: number }> {
const row = await this.addonRepo.findOne({
where: { resourceType: PricingResourceType.CUSTOM_DOMAIN_ADDON },
});
return { monthlyPrice: row ? Number(row.monthlyPrice) : 0 };
}
async setCustomDomainPrice(monthlyPrice: number): Promise<{ monthlyPrice: number }> {
await this.upsertAddonRate({
resourceType: PricingResourceType.CUSTOM_DOMAIN_ADDON,
hourlyPrice: 0,
monthlyPrice,
yearlyPrice: 0,
isActive: true,
});
return { monthlyPrice };
}
}