Files
cloud-host/backend/src/billing/pricing-catalog.service.spec.ts
T
keyhan f7974dd382 feat(landing,billing): public pricing, global discount, services bar & estimator
Backend
- Add platform-wide global discount (platform_settings: global_discount_percent),
  applied centrally in PricingCatalogService.computeTotalsFromDb so it reaches
  every real charge (previews, deploys, renewals, upgrades, invoices). Admin
  GET/PATCH /billing/settings/global-discount.
- Add unauthenticated PublicPricingController (catalog + calculate) for the
  public landing page, returning gross/net and the discount percentage.
- Bill application replicas by the user-selected footprint: app CPU/RAM/storage
  now all scale by replica count; the single-replica database stays unscaled.

Frontend
- Landing: Services bar (PaaS active, DBaaS, KaaS/LaaS "coming soon" with
  expandable runtime/database menus), transparent Pricing section (per-resource
  rate cards with cycle toggle + discount strikethrough), and a cost Estimator
  ("estimate your package").
- Optional services and the database are priced like runtimes: the estimator
  lets users pick their CPU/RAM/storage (and DB type) so the cost scales by need.
- Admin billing: global-discount editor.
- i18n: fa/en strings for services, pricing, estimator and global discount.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:44:30 +03:30

309 lines
9.5 KiB
TypeScript

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 { OptionalServiceProfile } from './entities/optional-service-profile.entity';
import { OptionalServiceRate } from './entities/optional-service-rate.entity';
import { PlatformSetting } from './entities/platform-setting.entity';
import {
AppRuntime,
BillingCycle,
DatabaseType,
OptionalService,
PricingResourceType,
ProductType,
} from '../common/enums';
import { OPTIONAL_SERVICE_DEPLOY_SPECS } from './pricing-catalog.constants';
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),
};
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),
};
const settingsRepo = {
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 },
{ provide: getRepositoryToken(OptionalServiceProfile), useValue: optionalProfileRepo },
{ provide: getRepositoryToken(OptionalServiceRate), useValue: optionalRateRepo },
{ provide: getRepositoryToken(PlatformSetting), useValue: settingsRepo },
],
}).compile();
service = module.get(PricingCatalogService);
});
const baseDto = (): CalculateCostDto => ({
runtime: 'nodejs',
databaseType: DatabaseType.NONE,
cpuLimit: '500m',
memoryLimit: '512Mi',
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);
});
it('parses memory Mi to decimal GB for proportional billing (500Mi = 0.5 GB)', () => {
expect(service.parseMemoryToGb('500Mi')).toBe(0.5);
expect(service.parseMemoryToGb('1000Mi')).toBe(1);
expect(service.parseMemoryToGb('1Gi')).toBe(1);
});
it('bills memory proportional to decimal GB (500Mi at half the per-GB monthly rate)', () => {
const rates = [
{
runtime: AppRuntime.NODEJS,
resourceType: PricingResourceType.MEMORY_PER_GB,
hourlyPrice: 0,
monthlyPrice: 10_000,
yearlyPrice: 0,
isActive: true,
},
] as PricingRate[];
const dto = { ...baseDto(), memoryLimit: '500Mi' };
const result = service.computeTotalsWithRates(dto, rates, emptyOptional());
expect(result.monthly).toBe(5000);
});
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, 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 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 = [
{
service: OptionalService.REDIS,
resourceType: PricingResourceType.BASE_FEE,
hourlyPrice: 10,
monthlyPrice: 100,
yearlyPrice: 1000,
isActive: true,
},
] as OptionalServiceRate[];
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 },
[],
optional,
);
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, emptyOptional());
expect(result.yearly).toBe(999);
expect(result.monthly).toBe(100);
expect(result.yearly).not.toBe(result.monthly * 12);
});
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 = [
{
service: OptionalService.REDIS,
resourceType: PricingResourceType.CPU_PER_CORE,
hourlyPrice: 1000,
monthlyPrice: 0,
yearlyPrice: 0,
isActive: true,
},
] as OptionalServiceRate[];
const optional = { profiles: [profile], rates, customDomain: null };
const without = service.computeTotalsWithRates(baseDto(), [], optional);
const withRedis = service.computeTotalsWithRates(
{ ...baseDto(), enableRedis: true },
[],
optional,
);
expect(withRedis.hourly - without.hourly).toBe(200);
});
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);
});
it('managed_database bills database addon and resources without app base fee', () => {
const rates = [
{
runtime: AppRuntime.NODEJS,
resourceType: PricingResourceType.BASE_FEE,
hourlyPrice: 999,
monthlyPrice: 999,
yearlyPrice: 999,
isActive: true,
},
{
runtime: AppRuntime.NODEJS,
resourceType: PricingResourceType.DATABASE_ADDON,
hourlyPrice: 0,
monthlyPrice: 500,
yearlyPrice: 0,
isActive: true,
},
{
runtime: AppRuntime.NODEJS,
resourceType: PricingResourceType.STORAGE_PER_GB,
hourlyPrice: 0,
monthlyPrice: 100,
yearlyPrice: 0,
isActive: true,
},
] as PricingRate[];
const result = service.computeTotalsWithRates(
{
...baseDto(),
productType: ProductType.MANAGED_DATABASE,
databaseType: DatabaseType.POSTGRESQL,
replicas: 0,
dbStorageSize: '2Gi',
cpuLimit: '500m',
memoryLimit: '512Mi',
},
rates,
emptyOptional(),
);
expect(result.monthly).toBe(700);
expect(result.breakdown.some((l) => l.label.includes('Base fee'))).toBe(false);
});
it('managed_redis bills only optional redis lines', () => {
const profile = {
service: OptionalService.REDIS,
cpuLimit: OPTIONAL_SERVICE_DEPLOY_SPECS[OptionalService.REDIS].cpuLimit,
memoryLimit: '256Mi',
storageGi: 0,
} as OptionalServiceProfile;
const rates = [
{
service: OptionalService.REDIS,
resourceType: PricingResourceType.BASE_FEE,
hourlyPrice: 0,
monthlyPrice: 250,
yearlyPrice: 0,
isActive: true,
},
] as OptionalServiceRate[];
const appRates = [
{
runtime: AppRuntime.NODEJS,
resourceType: PricingResourceType.BASE_FEE,
hourlyPrice: 999,
monthlyPrice: 999,
yearlyPrice: 999,
isActive: true,
},
] as PricingRate[];
const result = service.computeTotalsWithRates(
{
...baseDto(),
productType: ProductType.MANAGED_REDIS,
replicas: 0,
enableRedis: true,
},
appRates,
{ profiles: [profile], rates, customDomain: null },
);
expect(result.monthly).toBe(250);
});
});