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,124 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { PricingCatalogService } from './pricing-catalog.service';
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';
describe('PricingCatalogService', () => {
let service: PricingCatalogService;
const rateRepo = {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockResolvedValue(null),
save: jest.fn().mockImplementation((x) => Promise.resolve(x)),
create: jest.fn().mockImplementation((x) => x),
};
const addonRepo = {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockResolvedValue(null),
save: jest.fn().mockImplementation((x) => Promise.resolve(x)),
create: jest.fn().mockImplementation((x) => x),
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
PricingCatalogService,
{ provide: getRepositoryToken(PricingRate), useValue: rateRepo },
{ provide: getRepositoryToken(AddonRate), useValue: addonRepo },
],
}).compile();
service = module.get(PricingCatalogService);
});
const baseDto = (): CalculateCostDto => ({
runtime: 'nodejs',
databaseType: DatabaseType.NONE,
cpuLimit: '500m',
memoryLimit: '512Mi',
replicas: 1,
});
it('parses CPU millicores to cores', () => {
expect(service.parseCpuToCores('500m')).toBe(0.5);
expect(service.parseCpuToCores('2')).toBe(2);
});
it('computes CPU line with cycle-native prices (no conversion)', () => {
const rates = [
{
runtime: AppRuntime.NODEJS,
resourceType: PricingResourceType.CPU_PER_CORE,
hourlyPrice: 100,
monthlyPrice: 5000,
yearlyPrice: 50000,
isActive: true,
},
] as PricingRate[];
const result = service.computeTotalsWithRates(baseDto(), rates, []);
expect(result.hourly).toBe(50);
expect(result.monthly).toBe(2500);
expect(result.yearly).toBe(25000);
expect(result.breakdown[0].label).toContain('CPU');
});
it('includes redis addon only when enabled', () => {
const addons = [
{
resourceType: PricingResourceType.REDIS_ADDON,
hourlyPrice: 10,
monthlyPrice: 100,
yearlyPrice: 1000,
isActive: true,
},
] as AddonRate[];
const without = service.computeTotalsWithRates(baseDto(), [], addons);
expect(without.monthly).toBe(0);
const withRedis = service.computeTotalsWithRates(
{ ...baseDto(), enableRedis: true },
[],
addons,
);
expect(withRedis.monthly).toBe(100);
expect(withRedis.yearly).toBe(1000);
expect(withRedis.hourly).toBe(10);
});
it('yearly deploy uses yearly column not monthly * 12', () => {
const rates = [
{
runtime: AppRuntime.NODEJS,
resourceType: PricingResourceType.BASE_FEE,
hourlyPrice: 1,
monthlyPrice: 100,
yearlyPrice: 999,
isActive: true,
},
] as PricingRate[];
const result = service.computeTotalsWithRates(baseDto(), rates, []);
expect(result.yearly).toBe(999);
expect(result.monthly).toBe(100);
expect(result.yearly).not.toBe(result.monthly * 12);
});
it('amountForCycleFromLine picks the correct column', () => {
const line = { label: 'Test', hourly: 1, monthly: 2, yearly: 3 };
expect(service.amountForCycleFromLine(line, BillingCycle.HOURLY)).toBe(1);
expect(service.amountForCycleFromLine(line, BillingCycle.MONTHLY)).toBe(2);
expect(service.amountForCycleFromLine(line, BillingCycle.YEARLY)).toBe(3);
});
});