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:
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user