diff --git a/backend/src/billing/pricing-catalog.service.spec.ts b/backend/src/billing/pricing-catalog.service.spec.ts index 194e4b4..5cefd47 100644 --- a/backend/src/billing/pricing-catalog.service.spec.ts +++ b/backend/src/billing/pricing-catalog.service.spec.ts @@ -76,6 +76,29 @@ describe('PricingCatalogService', () => { 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 = [ { diff --git a/backend/src/billing/pricing-catalog.service.ts b/backend/src/billing/pricing-catalog.service.ts index 6d5f867..688a221 100644 --- a/backend/src/billing/pricing-catalog.service.ts +++ b/backend/src/billing/pricing-catalog.service.ts @@ -580,7 +580,7 @@ export class PricingCatalogService implements OnModuleInit { case PricingResourceType.CPU_PER_CORE: return `CPU (${quantity.toFixed(2)} core)`; case PricingResourceType.MEMORY_PER_GB: - return `Memory (${quantity.toFixed(2)} GB)`; + return `Memory (${quantity.toFixed(3)} GB billable)`; case PricingResourceType.STORAGE_PER_GB: return `Storage (${quantity} GB)`; default: @@ -616,12 +616,19 @@ export class PricingCatalogService implements OnModuleInit { return parseFloat(cpu) || 0; } + /** + * Billable memory quantity for rates labeled "per GB". + * Uses decimal GB so fractional Mi scales linearly (e.g. 500Mi → 0.5 × per-GB price). + * Gi values are treated as GB-sized billing units (1Gi → 1 unit). + */ 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); + const m = memory.trim(); + if (m.endsWith('Gi')) return parseFloat(m) || 0; + if (m.endsWith('Mi')) return (parseFloat(m) || 0) / 1000; + if (m.endsWith('Ki')) return (parseFloat(m) || 0) / 1_000_000; + const n = parseFloat(m); + return Number.isFinite(n) ? n / (1024 * 1024 * 1024) : 0; } private async upsertRuntimeRate(runtime: AppRuntime, row: PricingRateRow) { diff --git a/frontend/src/app/dashboard/admin/billing/page.tsx b/frontend/src/app/dashboard/admin/billing/page.tsx index 453c45c..e7d9868 100644 --- a/frontend/src/app/dashboard/admin/billing/page.tsx +++ b/frontend/src/app/dashboard/admin/billing/page.tsx @@ -13,7 +13,7 @@ import type { PricingResourceType, LifecycleSettings, } from '@/types'; -import { DollarSign, Edit2, Shield, Clock, Layers, Info, Box, Server } from 'lucide-react'; +import { DollarSign, Edit2, Shield, Clock, Layers, Info, Box, Server, Globe } from 'lucide-react'; const resourceLabels: Record = { base_fee: 'Base fee', @@ -555,11 +555,13 @@ export default function AdminBillingPage() {