Add optional service pricing matrix and fix admin catalog save.

Users pick per-service CPU/memory/storage at deploy; admins manage unit rates and deploy defaults. PATCH sends only fields accepted by the pricing-catalog DTO.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-15 19:16:20 +03:30
parent bb27c90ae4
commit 055e7a7c8d
21 changed files with 1746 additions and 257 deletions
@@ -3,6 +3,8 @@ 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 { OptionalServiceProfile } from './entities/optional-service-profile.entity';
import { OptionalServiceRate } from './entities/optional-service-rate.entity';
import {
AppRuntime,
BillingCycle,
@@ -30,6 +32,20 @@ describe('PricingCatalogService', () => {
create: jest.fn().mockImplementation((x) => x),
};
const optionalProfileRepo = {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockResolvedValue(null),
save: jest.fn().mockImplementation((x) => Promise.resolve(x)),
create: jest.fn().mockImplementation((x) => x),
};
const optionalRateRepo = {
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({
@@ -37,6 +53,8 @@ describe('PricingCatalogService', () => {
PricingCatalogService,
{ provide: getRepositoryToken(PricingRate), useValue: rateRepo },
{ provide: getRepositoryToken(AddonRate), useValue: addonRepo },
{ provide: getRepositoryToken(OptionalServiceProfile), useValue: optionalProfileRepo },
{ provide: getRepositoryToken(OptionalServiceRate), useValue: optionalRateRepo },
],
}).compile();
@@ -51,6 +69,8 @@ describe('PricingCatalogService', () => {
replicas: 1,
});
const emptyOptional = () => ({ profiles: [], rates: [], customDomain: null });
it('parses CPU millicores to cores', () => {
expect(service.parseCpuToCores('500m')).toBe(0.5);
expect(service.parseCpuToCores('2')).toBe(2);
@@ -68,31 +88,41 @@ describe('PricingCatalogService', () => {
},
] as PricingRate[];
const result = service.computeTotalsWithRates(baseDto(), rates, []);
const result = service.computeTotalsWithRates(baseDto(), rates, emptyOptional());
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 = [
it('includes redis optional service only when enabled', () => {
const profile = {
service: OptionalService.REDIS,
cpuLimit: OPTIONAL_SERVICE_DEPLOY_SPECS[OptionalService.REDIS].cpuLimit,
memoryLimit: '256Mi',
storageGi: 0,
} as OptionalServiceProfile;
const rates = [
{
resourceType: PricingResourceType.REDIS_ADDON,
service: OptionalService.REDIS,
resourceType: PricingResourceType.BASE_FEE,
hourlyPrice: 10,
monthlyPrice: 100,
yearlyPrice: 1000,
isActive: true,
},
] as AddonRate[];
] as OptionalServiceRate[];
const without = service.computeTotalsWithRates(baseDto(), [], addons);
const optional = { profiles: [profile], rates, customDomain: null };
const without = service.computeTotalsWithRates(baseDto(), [], optional);
expect(without.monthly).toBe(0);
const withRedis = service.computeTotalsWithRates(
{ ...baseDto(), enableRedis: true },
[],
addons,
optional,
);
expect(withRedis.monthly).toBe(100);
expect(withRedis.yearly).toBe(1000);
@@ -111,42 +141,41 @@ describe('PricingCatalogService', () => {
},
] as PricingRate[];
const result = service.computeTotalsWithRates(baseDto(), rates, []);
const result = service.computeTotalsWithRates(baseDto(), rates, emptyOptional());
expect(result.yearly).toBe(999);
expect(result.monthly).toBe(100);
expect(result.yearly).not.toBe(result.monthly * 12);
});
it('bills optional service CPU/RAM/storage with same runtime unit rates', () => {
it('bills optional service CPU from profile and service rate matrix', () => {
const profile = {
service: OptionalService.REDIS,
cpuLimit: OPTIONAL_SERVICE_DEPLOY_SPECS[OptionalService.REDIS].cpuLimit,
memoryLimit: '256Mi',
storageGi: 0,
} as OptionalServiceProfile;
const rates = [
{
runtime: AppRuntime.NODEJS,
service: OptionalService.REDIS,
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[];
] as OptionalServiceRate[];
const redisCpu = parseFloat(OPTIONAL_SERVICE_DEPLOY_SPECS[OptionalService.REDIS].cpuLimit) / 1000;
const optional = { profiles: [profile], rates, customDomain: null };
const without = service.computeTotalsWithRates(baseDto(), rates, []);
const without = service.computeTotalsWithRates(baseDto(), [], optional);
const withRedis = service.computeTotalsWithRates(
{ ...baseDto(), enableRedis: true },
rates,
[],
optional,
);
expect(withRedis.hourly - without.hourly).toBe(Math.round(redisCpu * 1000));
expect(withRedis.hourly - without.hourly).toBe(200);
});
it('amountForCycleFromLine picks the correct column', () => {