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:
@@ -1,108 +1,41 @@
|
||||
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ServicePlan } from './entities/service-plan.entity';
|
||||
import { PricingRule } from './entities/pricing-rule.entity';
|
||||
import { Repository, IsNull, MoreThan } from 'typeorm';
|
||||
import { Wallet } from './entities/wallet.entity';
|
||||
import { WalletTransaction } from './entities/wallet-transaction.entity';
|
||||
import { PlatformSetting } from './entities/platform-setting.entity';
|
||||
import { TransactionType, BillingCycle, PricingResourceType, DatabaseType } from '../common/enums';
|
||||
import {
|
||||
CreateServicePlanDto,
|
||||
UpdateServicePlanDto,
|
||||
CalculateCostDto,
|
||||
UpgradeResourcesDto,
|
||||
} from './dto/billing.dto';
|
||||
import { TransactionType, BillingCycle, DatabaseType } from '../common/enums';
|
||||
import { CalculateCostDto, UpgradeResourcesDto } from './dto/billing.dto';
|
||||
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
|
||||
import { Application } from '../applications/entities/application.entity';
|
||||
import { ResourceCredit } from './entities/resource-credit.entity';
|
||||
import { IsNull, MoreThan } from 'typeorm';
|
||||
import { PricingCatalogService } from './pricing-catalog.service';
|
||||
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
private readonly logger = new Logger(BillingService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ServicePlan) private planRepo: Repository<ServicePlan>,
|
||||
@InjectRepository(PricingRule) private ruleRepo: Repository<PricingRule>,
|
||||
private readonly pricingCatalog: PricingCatalogService,
|
||||
@InjectRepository(Wallet) private walletRepo: Repository<Wallet>,
|
||||
@InjectRepository(WalletTransaction) private txRepo: Repository<WalletTransaction>,
|
||||
@InjectRepository(PlatformSetting) private settingsRepo: Repository<PlatformSetting>,
|
||||
@InjectRepository(ResourceCredit) private creditRepo: Repository<ResourceCredit>,
|
||||
) {}
|
||||
|
||||
// ─── Service Plans ────────────────────────────────────────────────
|
||||
// ─── Pricing catalog (Admin) ──────────────────────────────────────
|
||||
|
||||
async createPlan(dto: CreateServicePlanDto): Promise<ServicePlan> {
|
||||
const plan = this.planRepo.create({
|
||||
name: dto.name,
|
||||
runtime: dto.runtime,
|
||||
description: dto.description,
|
||||
billingCycle: dto.billingCycle,
|
||||
});
|
||||
const saved = await this.planRepo.save(plan);
|
||||
|
||||
// Create pricing rules
|
||||
const rules = dto.pricingRules.map((r) =>
|
||||
this.ruleRepo.create({ ...r, planId: saved.id }),
|
||||
);
|
||||
await this.ruleRepo.save(rules);
|
||||
|
||||
return this.planRepo.findOne({ where: { id: saved.id }, relations: ['pricingRules'] }) as Promise<ServicePlan>;
|
||||
getPricingCatalog() {
|
||||
return this.pricingCatalog.getCatalog();
|
||||
}
|
||||
|
||||
async updatePlan(id: string, dto: UpdateServicePlanDto): Promise<ServicePlan> {
|
||||
const plan = await this.planRepo.findOne({ where: { id }, relations: ['pricingRules'] });
|
||||
if (!plan) throw new NotFoundException('Plan not found');
|
||||
|
||||
if (dto.name !== undefined) plan.name = dto.name;
|
||||
if (dto.runtime !== undefined) plan.runtime = dto.runtime;
|
||||
if (dto.description !== undefined) plan.description = dto.description;
|
||||
if (dto.billingCycle !== undefined) plan.billingCycle = dto.billingCycle;
|
||||
if (dto.isActive !== undefined) plan.isActive = dto.isActive;
|
||||
|
||||
await this.planRepo.save(plan);
|
||||
|
||||
// If pricing rules are provided, replace them
|
||||
if (dto.pricingRules) {
|
||||
await this.ruleRepo.delete({ planId: id });
|
||||
const rules = dto.pricingRules.map((r) =>
|
||||
this.ruleRepo.create({ ...r, planId: id }),
|
||||
);
|
||||
await this.ruleRepo.save(rules);
|
||||
}
|
||||
|
||||
return this.planRepo.findOne({ where: { id }, relations: ['pricingRules'] }) as Promise<ServicePlan>;
|
||||
}
|
||||
|
||||
async deletePlan(id: string): Promise<void> {
|
||||
const plan = await this.planRepo.findOne({ where: { id } });
|
||||
if (!plan) throw new NotFoundException('Plan not found');
|
||||
await this.planRepo.remove(plan);
|
||||
}
|
||||
|
||||
async findAllPlans(): Promise<ServicePlan[]> {
|
||||
return this.planRepo.find({ relations: ['pricingRules'], order: { createdAt: 'DESC' } });
|
||||
}
|
||||
|
||||
async findActivePlans(): Promise<ServicePlan[]> {
|
||||
return this.planRepo.find({
|
||||
where: { isActive: true },
|
||||
relations: ['pricingRules'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findPlan(id: string): Promise<ServicePlan> {
|
||||
const plan = await this.planRepo.findOne({ where: { id }, relations: ['pricingRules'] });
|
||||
if (!plan) throw new NotFoundException('Plan not found');
|
||||
return plan;
|
||||
updatePricingCatalog(dto: UpdatePricingCatalogDto) {
|
||||
return this.pricingCatalog.updateCatalog(dto);
|
||||
}
|
||||
|
||||
// ─── Cost Calculation ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Calculate cost for an application config against all active plans.
|
||||
* Returns breakdown for each billing cycle (hourly, monthly, yearly).
|
||||
* Calculate cost from the usage pricing catalog.
|
||||
* Each billing cycle uses its own price column — no cross-cycle conversion.
|
||||
*/
|
||||
async calculateCost(dto: CalculateCostDto): Promise<{
|
||||
hourly: number;
|
||||
@@ -110,165 +43,29 @@ export class BillingService {
|
||||
yearly: number;
|
||||
breakdown: { label: string; hourly: number; monthly: number; yearly: number }[];
|
||||
}> {
|
||||
// Only use active plans that match the requested runtime
|
||||
const plans = await this.planRepo.find({
|
||||
where: { isActive: true, runtime: dto.runtime as any },
|
||||
relations: ['pricingRules'],
|
||||
});
|
||||
|
||||
if (plans.length === 0) {
|
||||
return { hourly: 0, monthly: 0, yearly: 0, breakdown: [] };
|
||||
}
|
||||
|
||||
// Gather all active pricing rules across all plans, grouped by cycle
|
||||
const hourlyRules: PricingRule[] = [];
|
||||
const monthlyRules: PricingRule[] = [];
|
||||
const yearlyRules: PricingRule[] = [];
|
||||
|
||||
for (const plan of plans) {
|
||||
switch (plan.billingCycle) {
|
||||
case BillingCycle.HOURLY: hourlyRules.push(...plan.pricingRules); break;
|
||||
case BillingCycle.MONTHLY: monthlyRules.push(...plan.pricingRules); break;
|
||||
case BillingCycle.YEARLY: yearlyRules.push(...plan.pricingRules); break;
|
||||
}
|
||||
}
|
||||
|
||||
// Use the first available set of rules, or compute cross-conversions
|
||||
const rules = hourlyRules.length > 0 ? hourlyRules : monthlyRules.length > 0 ? monthlyRules : yearlyRules;
|
||||
const baseCycle = hourlyRules.length > 0 ? 'hourly' : monthlyRules.length > 0 ? 'monthly' : 'yearly';
|
||||
|
||||
// Parse resource values
|
||||
const cpuCores = this.parseCpuToCores(dto.cpuLimit);
|
||||
const memoryGb = this.parseMemoryToGb(dto.memoryLimit);
|
||||
const dbStorageGb = dto.dbStorageSize ? parseFloat(dto.dbStorageSize.replace('Gi', '')) || 0 : 0;
|
||||
const appStorageGb = dto.appStorageSize ? parseFloat(dto.appStorageSize.replace('Gi', '')) || 0 : 0;
|
||||
const totalStorageGb = dbStorageGb + appStorageGb;
|
||||
const hasDatabase = dto.databaseType !== 'none';
|
||||
const replicas = dto.replicas || 1;
|
||||
const hasRedis = dto.enableRedis || false;
|
||||
const hasRabbitmq = dto.enableRabbitmq || false;
|
||||
const hasElasticsearch = dto.enableElasticsearch || false;
|
||||
const hasCustomDomain = dto.enableCustomDomain || false;
|
||||
|
||||
const breakdown: { label: string; hourly: number; monthly: number; yearly: number }[] = [];
|
||||
let totalBase = 0;
|
||||
|
||||
for (const rule of rules) {
|
||||
let cost = 0;
|
||||
let label = '';
|
||||
|
||||
switch (rule.resourceType) {
|
||||
case PricingResourceType.BASE_FEE:
|
||||
cost = Number(rule.unitPrice);
|
||||
label = 'Base fee';
|
||||
break;
|
||||
case PricingResourceType.CPU_PER_CORE:
|
||||
cost = cpuCores * replicas * Number(rule.unitPrice);
|
||||
label = `CPU (${(cpuCores * replicas).toFixed(2)} core)`;
|
||||
break;
|
||||
case PricingResourceType.MEMORY_PER_GB:
|
||||
cost = memoryGb * replicas * Number(rule.unitPrice);
|
||||
label = `Memory (${(memoryGb * replicas).toFixed(2)} GB)`;
|
||||
break;
|
||||
case PricingResourceType.STORAGE_PER_GB:
|
||||
cost = totalStorageGb * Number(rule.unitPrice);
|
||||
label = `Storage (${totalStorageGb} GB)`;
|
||||
break;
|
||||
case PricingResourceType.DATABASE_ADDON:
|
||||
cost = hasDatabase ? Number(rule.unitPrice) : 0;
|
||||
label = 'Database addon';
|
||||
break;
|
||||
case PricingResourceType.REDIS_ADDON:
|
||||
cost = hasRedis ? Number(rule.unitPrice) : 0;
|
||||
label = 'Redis addon';
|
||||
break;
|
||||
case PricingResourceType.RABBITMQ_ADDON:
|
||||
cost = hasRabbitmq ? Number(rule.unitPrice) : 0;
|
||||
label = 'RabbitMQ addon';
|
||||
break;
|
||||
case PricingResourceType.ELASTICSEARCH_ADDON:
|
||||
cost = hasElasticsearch ? Number(rule.unitPrice) : 0;
|
||||
label = 'Elasticsearch addon';
|
||||
break;
|
||||
case PricingResourceType.CUSTOM_DOMAIN_ADDON:
|
||||
cost = hasCustomDomain ? Number(rule.unitPrice) : 0;
|
||||
label = 'Custom domain + SSL';
|
||||
break;
|
||||
}
|
||||
|
||||
if (cost > 0) {
|
||||
const hourly = baseCycle === 'hourly' ? cost : baseCycle === 'monthly' ? cost / 720 : cost / 8640;
|
||||
const monthly = baseCycle === 'monthly' ? cost : baseCycle === 'hourly' ? cost * 720 : cost / 12;
|
||||
const yearly = baseCycle === 'yearly' ? cost : baseCycle === 'monthly' ? cost * 12 : cost * 8640;
|
||||
|
||||
breakdown.push({ label, hourly: Math.round(hourly), monthly: Math.round(monthly), yearly: Math.round(yearly) });
|
||||
totalBase += cost;
|
||||
}
|
||||
}
|
||||
|
||||
// If custom domain is enabled but no CUSTOM_DOMAIN_ADDON rule exists, use PlatformSetting price
|
||||
if (hasCustomDomain && !breakdown.some((b) => b.label === 'Custom domain + SSL')) {
|
||||
const setting = await this.settingsRepo.findOne({ where: { key: 'custom_domain_monthly_price_toman' } });
|
||||
const monthlyPrice = setting ? Number(setting.value) : 0;
|
||||
if (monthlyPrice > 0) {
|
||||
const hourly = Math.round(monthlyPrice / 720);
|
||||
const yearly = monthlyPrice * 12;
|
||||
breakdown.push({ label: 'Custom domain + SSL', hourly, monthly: monthlyPrice, yearly });
|
||||
totalBase += baseCycle === 'monthly' ? monthlyPrice : baseCycle === 'hourly' ? hourly : yearly;
|
||||
}
|
||||
}
|
||||
|
||||
const hourlyTotal = baseCycle === 'hourly' ? totalBase : baseCycle === 'monthly' ? totalBase / 720 : totalBase / 8640;
|
||||
const monthlyTotal = baseCycle === 'monthly' ? totalBase : baseCycle === 'hourly' ? totalBase * 720 : totalBase / 12;
|
||||
const yearlyTotal = baseCycle === 'yearly' ? totalBase : baseCycle === 'monthly' ? totalBase * 12 : totalBase * 8640;
|
||||
|
||||
return {
|
||||
hourly: Math.round(hourlyTotal),
|
||||
monthly: Math.round(monthlyTotal),
|
||||
yearly: Math.round(yearlyTotal),
|
||||
breakdown,
|
||||
};
|
||||
return this.pricingCatalog.computeTotalsFromDb(dto);
|
||||
}
|
||||
|
||||
private parseCpuToCores(cpu: string): number {
|
||||
if (!cpu) return 0;
|
||||
if (cpu.endsWith('m')) return parseFloat(cpu) / 1000;
|
||||
return parseFloat(cpu) || 0;
|
||||
// ─── Optional services & custom domain (delegates to catalog) ─────
|
||||
|
||||
getOptionalServicesPricing() {
|
||||
return this.pricingCatalog.getOptionalServicesPricing();
|
||||
}
|
||||
|
||||
private 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);
|
||||
setOptionalServicesPricing(pricing: {
|
||||
redis: { hourly: number; monthly: number; yearly: number };
|
||||
rabbitmq: { hourly: number; monthly: number; yearly: number };
|
||||
elasticsearch: { hourly: number; monthly: number; yearly: number };
|
||||
}) {
|
||||
return this.pricingCatalog.setOptionalServicesPricing(pricing);
|
||||
}
|
||||
|
||||
// ─── Custom Domain Pricing (PlatformSetting) ────────────────────
|
||||
|
||||
async getCustomDomainPrice(): Promise<{ monthlyPrice: number }> {
|
||||
const setting = await this.settingsRepo.findOne({
|
||||
where: { key: 'custom_domain_monthly_price_toman' },
|
||||
});
|
||||
return { monthlyPrice: setting ? Number(setting.value) : 0 };
|
||||
getCustomDomainPrice() {
|
||||
return this.pricingCatalog.getCustomDomainPrice();
|
||||
}
|
||||
|
||||
async setCustomDomainPrice(monthlyPrice: number): Promise<{ monthlyPrice: number }> {
|
||||
let setting = await this.settingsRepo.findOne({
|
||||
where: { key: 'custom_domain_monthly_price_toman' },
|
||||
});
|
||||
if (setting) {
|
||||
setting.value = String(monthlyPrice);
|
||||
} else {
|
||||
setting = this.settingsRepo.create({
|
||||
key: 'custom_domain_monthly_price_toman',
|
||||
value: String(monthlyPrice),
|
||||
description: 'Monthly price for custom domain addon (Toman)',
|
||||
});
|
||||
}
|
||||
await this.settingsRepo.save(setting);
|
||||
this.logger.log(`Custom domain monthly price updated: ${monthlyPrice} Toman`);
|
||||
return { monthlyPrice };
|
||||
setCustomDomainPrice(monthlyPrice: number) {
|
||||
return this.pricingCatalog.setCustomDomainPrice(monthlyPrice);
|
||||
}
|
||||
|
||||
// ─── Wallet ───────────────────────────────────────────────────────
|
||||
@@ -604,11 +401,15 @@ export class BillingService {
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (this.parseCpuToCores(config.cpuLimit) > this.parseCpuToCores(credit.cpuLimit)) {
|
||||
if (
|
||||
this.pricingCatalog.parseCpuToCores(config.cpuLimit) >
|
||||
this.pricingCatalog.parseCpuToCores(credit.cpuLimit)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
this.parseMemoryToGb(config.memoryLimit) > this.parseMemoryToGb(credit.memoryLimit)
|
||||
this.pricingCatalog.parseMemoryToGb(config.memoryLimit) >
|
||||
this.pricingCatalog.parseMemoryToGb(credit.memoryLimit)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -754,8 +555,12 @@ export class BillingService {
|
||||
for (const hint of labelHints) {
|
||||
const afterLine = after.breakdown.find((b) => b.label === hint);
|
||||
const beforeLine = before.breakdown.find((b) => b.label === hint);
|
||||
const afterAmt = afterLine ? this.amountForCycle(afterLine, cycle) : 0;
|
||||
const beforeAmt = beforeLine ? this.amountForCycle(beforeLine, cycle) : 0;
|
||||
const afterAmt = afterLine
|
||||
? this.pricingCatalog.amountForCycleFromLine(afterLine, cycle)
|
||||
: 0;
|
||||
const beforeAmt = beforeLine
|
||||
? this.pricingCatalog.amountForCycleFromLine(beforeLine, cycle)
|
||||
: 0;
|
||||
sum += Math.max(0, afterAmt - beforeAmt);
|
||||
}
|
||||
return sum;
|
||||
@@ -775,7 +580,10 @@ export class BillingService {
|
||||
const items: { label: string; amount: number; fullPeriodAmount?: number }[] = [];
|
||||
const baseline = this.creditBaselineDto(credit);
|
||||
|
||||
if (this.parseCpuToCores(config.cpuLimit) > this.parseCpuToCores(credit.cpuLimit)) {
|
||||
if (
|
||||
this.pricingCatalog.parseCpuToCores(config.cpuLimit) >
|
||||
this.pricingCatalog.parseCpuToCores(credit.cpuLimit)
|
||||
) {
|
||||
await this.addExtraLineProrated(
|
||||
items,
|
||||
baseline,
|
||||
@@ -785,7 +593,10 @@ export class BillingService {
|
||||
);
|
||||
}
|
||||
|
||||
if (this.parseMemoryToGb(config.memoryLimit) > this.parseMemoryToGb(credit.memoryLimit)) {
|
||||
if (
|
||||
this.pricingCatalog.parseMemoryToGb(config.memoryLimit) >
|
||||
this.pricingCatalog.parseMemoryToGb(credit.memoryLimit)
|
||||
) {
|
||||
await this.addExtraLineProrated(
|
||||
items,
|
||||
baseline,
|
||||
|
||||
Reference in New Issue
Block a user