f7974dd382
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>
309 lines
9.5 KiB
TypeScript
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);
|
|
});
|
|
});
|