From 67b41ae311efdb018b44a12400938eb9ad3f3cf9 Mon Sep 17 00:00:00 2001 From: keyhan Date: Sat, 16 May 2026 00:39:37 +0330 Subject: [PATCH] Align admin optional-services billing UI with runtimes and bill RAM proportionally. Split optional pricing vs deploy defaults and move custom domain to its own card; parse Mi as decimal GB so fractional memory scales linearly with per-GB rates. Deploy wizard hides env and optional services for WordPress and sanitizes create payloads. Co-authored-by: Cursor --- .../billing/pricing-catalog.service.spec.ts | 23 ++ .../src/billing/pricing-catalog.service.ts | 17 +- .../src/app/dashboard/admin/billing/page.tsx | 145 ++++++----- frontend/src/app/dashboard/deploy/page.tsx | 225 ++++++++++-------- 4 files changed, 253 insertions(+), 157 deletions(-) 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() {
  • Applications — user picks runtime resources in deploy; you set price per - core, GB, base fee, and database addon. + core, GB, base fee, and database addon. CPU and RAM in estimates bill proportionally (for + example half the per-GB rate at half a gigabyte of memory).
  • - Optional services (Redis, RabbitMQ) — user enables the service, then sets - resources in a separate block; you set the same unit-price rows for that service. + Optional services (Redis, RabbitMQ) — same unit matrix per service as + runtimes; deploy wizard defaults are edited separately. CPU/RAM in the cost calculator bill in + proportion to actual limits (e.g. 500Mi counts as 0.5× the per-GB memory rate).
  • Deploy defaults — optional prefill only; changing them does not change @@ -625,23 +627,12 @@ export default function AdminBillingPage() {
    - -
    -
    -

    Optional services

    -

    - Unit pricing + deploy wizard defaults per service -

    -
    - {editing && activeOptionalEntry && ( - - )} + +
    +

    Optional services

    +

    + Unit pricing per service (hourly / monthly / yearly) +

    @@ -662,46 +653,84 @@ export default function AdminBillingPage() { ))}
    +
    +

    + {optionalServiceTabs.find((t) => t.value === activeOptionalService)?.label} — unit prices +

    + {editing && activeOptionalEntry && ( + + )} +
    + {activeOptionalEntry && ( -
    -
    -

    - - Deploy wizard defaults -

    - -
    - -
    -

    Unit pricing

    -

    - Billed from the resources the user selects for this service at deploy (CPU cores × - rate, memory GB × rate, storage GB × rate, plus base fee if used). -

    - -
    -
    - )} - - {customDomain && ( -
    - -
    + )}
    + +
    +
    + +
    +

    Optional services — deploy defaults

    +

    + Prefill CPU, memory, and storage when a user enables each service in the deploy wizard +

    +
    +
    + +
    + {optionalServiceTabs.map((tab) => ( + + ))} +
    + + {activeOptionalEntry && ( + + )} +
    + + {customDomain && ( +
    +
    + +
    +

    Add-ons

    +

    Flat fees not tied to a runtime

    +
    +
    + +
    + )} )} diff --git a/frontend/src/app/dashboard/deploy/page.tsx b/frontend/src/app/dashboard/deploy/page.tsx index 4d86285..14746ec 100644 --- a/frontend/src/app/dashboard/deploy/page.tsx +++ b/frontend/src/app/dashboard/deploy/page.tsx @@ -20,6 +20,20 @@ import type { import { Hexagon, FolderUp, Link as LinkIcon, CheckCircle, Package, Server, Scale, Home, Target, BarChart3, RotateCw, KeyRound, Rocket, Clock, Upload, XCircle, Database, Eye, EyeOff, RefreshCw, DollarSign, Wallet, CreditCard, Loader2, Globe, Copy, AlertCircle, ShieldCheck } from 'lucide-react'; const steps = ['Basic Info', 'Versions & Database', 'Resources', 'Review']; + +/** WordPress uses the managed image stack — wizard hides env & optional services; strip if ever sent. */ +function sanitizePayloadForWordPressRuntime(payload: CreateApplicationDto): CreateApplicationDto { + if (payload.runtime !== 'wordpress') return payload; + return { + ...payload, + enableRedis: false, + enableRabbitmq: false, + enableElasticsearch: false, + optionalServiceResources: {}, + envVars: {}, + }; +} + type OptionalServiceKey = 'redis' | 'rabbitmq'; const FALLBACK_OPTIONAL_RESOURCES: Record = { @@ -305,11 +319,13 @@ export default function DeployPage() { replicas: form.replicas, dbStorageSize: form.databaseType !== 'none' ? `${parseInt(form.dbStorageSize || '1', 10) || 1}Gi` : undefined, appStorageSize: `${parseInt(form.appStorageSize || '2', 10) || 2}Gi`, - enableRedis: form.enableRedis, - enableRabbitmq: form.enableRabbitmq, - enableElasticsearch: form.enableElasticsearch, - redisResources: form.enableRedis ? form.optionalServiceResources?.redis : undefined, - rabbitmqResources: form.enableRabbitmq ? form.optionalServiceResources?.rabbitmq : undefined, + enableRedis: form.runtime === 'wordpress' ? false : form.enableRedis, + enableRabbitmq: form.runtime === 'wordpress' ? false : form.enableRabbitmq, + enableElasticsearch: form.runtime === 'wordpress' ? false : form.enableElasticsearch, + redisResources: + form.runtime !== 'wordpress' && form.enableRedis ? form.optionalServiceResources?.redis : undefined, + rabbitmqResources: + form.runtime !== 'wordpress' && form.enableRabbitmq ? form.optionalServiceResources?.rabbitmq : undefined, enableCustomDomain, cycle: selectedCycle, }; @@ -341,13 +357,13 @@ export default function DeployPage() { mutationFn: async () => { // First create the app setDeployStage('creating'); - const payload = { ...form }; - if (payload.databaseType !== 'none' && payload.dbStorageSize) { - payload.dbStorageSize = `${parseInt(payload.dbStorageSize, 10) || 1}Gi`; - } - if (payload.appStorageSize) { - payload.appStorageSize = `${parseInt(payload.appStorageSize, 10) || 2}Gi`; - } + const payload = sanitizePayloadForWordPressRuntime({ + ...form, + ...(form.databaseType !== 'none' && form.dbStorageSize + ? { dbStorageSize: `${parseInt(form.dbStorageSize, 10) || 1}Gi` } + : {}), + ...(form.appStorageSize ? { appStorageSize: `${parseInt(form.appStorageSize, 10) || 2}Gi` } : {}), + }); const res = await api.post('/applications', payload); const appId = res.data.id; @@ -419,13 +435,13 @@ export default function DeployPage() { // Now create the app setDeployStage('creating'); - const payload = { ...form }; - if (payload.databaseType !== 'none' && payload.dbStorageSize) { - payload.dbStorageSize = `${parseInt(payload.dbStorageSize, 10) || 1}Gi`; - } - if (payload.appStorageSize) { - payload.appStorageSize = `${parseInt(payload.appStorageSize, 10) || 2}Gi`; - } + const payload = sanitizePayloadForWordPressRuntime({ + ...form, + ...(form.databaseType !== 'none' && form.dbStorageSize + ? { dbStorageSize: `${parseInt(form.dbStorageSize, 10) || 1}Gi` } + : {}), + ...(form.appStorageSize ? { appStorageSize: `${parseInt(form.appStorageSize, 10) || 2}Gi` } : {}), + }); const res = await api.post('/applications', payload); const appId = res.data.id; @@ -478,13 +494,14 @@ export default function DeployPage() { const createMutation = useMutation({ mutationFn: async (data: CreateApplicationDto) => { + const sanitized = sanitizePayloadForWordPressRuntime(data); setDeployStage('creating'); - const res = await api.post('/applications', data); + const res = await api.post('/applications', sanitized); const appId = res.data.id; // Upload zip file if selected // Upload source (regular apps or WordPress migrate/public_html) - const fileToUpload = data.runtime === 'wordpress' ? ((wpMode === 'migrate' || wpMode === 'public_html') ? wpContentFile : null) : (sourceMethod === 'upload' ? zipFile : null); + const fileToUpload = sanitized.runtime === 'wordpress' ? ((wpMode === 'migrate' || wpMode === 'public_html') ? wpContentFile : null) : (sourceMethod === 'upload' ? zipFile : null); if (fileToUpload) { setDeployStage('uploading-source'); setUploadProgress(0); @@ -499,7 +516,7 @@ export default function DeployPage() { } // Upload DB dump if provided and a database was requested - if (data.databaseType && data.databaseType !== 'none' && dbDumpFile) { + if (sanitized.databaseType && sanitized.databaseType !== 'none' && dbDumpFile) { setDeployStage('uploading-db'); setDbUploadProgress(0); const formData = new FormData(); @@ -580,7 +597,7 @@ export default function DeployPage() { if (enableCustomDomain && customDomainInput.trim()) { payload.customDomain = customDomainInput.trim(); } - createMutation.mutate(payload); + createMutation.mutate(sanitizePayloadForWordPressRuntime(payload)); }; const handleFileSelect = useCallback((file: File) => { @@ -783,13 +800,29 @@ export default function DeployPage() { const updates: any = { runtime: opt.value as any, phpVersion: '', runtimeVersion: '' }; if (opt.value === 'nodejs') { updates.port = 3000; updates.runtimeVersion = '20'; } else if (opt.value === 'laravel') { updates.port = 80; updates.phpVersion = '8.3'; } - else if (opt.value === 'wordpress') { updates.port = 80; updates.databaseType = 'mysql'; updates.runtimeVersion = '6.7'; updates.phpVersion = '8.3'; } + else if (opt.value === 'wordpress') { + updates.port = 80; + updates.databaseType = 'mysql'; + updates.runtimeVersion = '6.7'; + updates.phpVersion = '8.3'; + updates.enableRedis = false; + updates.enableRabbitmq = false; + updates.enableElasticsearch = false; + updates.optionalServiceResources = {}; + updates.envVars = {}; + } else if (opt.value === 'go') { updates.port = 8080; updates.runtimeVersion = '1.22'; } else if (opt.value === 'python') { updates.port = 8000; updates.runtimeVersion = '3.12'; } else if (opt.value === 'django') { updates.port = 8000; updates.runtimeVersion = '3.12'; } else if (opt.value === 'php') { updates.port = 80; updates.phpVersion = '8.3'; } else if (opt.value === 'dotnet') { updates.port = 5000; updates.runtimeVersion = '8.0'; } setForm({ ...form, ...updates }); + if (opt.value === 'wordpress') { + setEnvFile(null); + setEnvKey(''); + setEnvVal(''); + if (envFileInputRef.current) envFileInputRef.current.value = ''; + } // Reset WordPress-specific state when switching types if (opt.value !== 'wordpress') { setWpMode('fresh'); setWpContentFile(null); } }} @@ -1154,8 +1187,9 @@ export default function DeployPage() { )} - {/* Environment Variables */} + {form.runtime !== 'wordpress' && (
    + {/* Environment Variables */}
    ))}
    + )}
    )} @@ -1610,6 +1645,8 @@ export default function DeployPage() { {/* Optional Services Section */}
    + {form.runtime !== 'wordpress' && ( + <>

    Optional Services

    @@ -1844,8 +1881,11 @@ export default function DeployPage() {

    )} + + )} + {/* Custom Domain */} -
    +
    +
    + +

    + {form.runtime === 'wordpress' + ? 'Space for uploads, plugins, themes, and other WordPress files.' + : form.runtime === 'laravel' + ? 'Space for uploads, logs, cache, and other Laravel storage files.' + : 'Persistent space for application data, uploads, and files.'} +

    +
    +
    + + { + const val = Math.max(1, Math.min(100, parseInt(e.target.value, 10) || 2)); + setForm({ ...form, appStorageSize: String(val) }); + }} + className="w-16 text-center py-2 border-x border-gray-300 text-sm font-semibold focus:outline-none" + /> + +
    + GB + {wpContentFile && form.runtime === 'wordpress' && ( + + Suggested from wp-content ({(wpContentFile.size / (1024 * 1024 * 1024)).toFixed(2)} GB) + + )} +
    +

    Minimum 1 GB • Recommended: 2 GB or more

    +
    - {(form.enableRedis || form.enableRabbitmq) && ( + {form.runtime !== 'wordpress' && (form.enableRedis || form.enableRabbitmq) && (

    Optional services

    @@ -2293,69 +2393,6 @@ export default function DeployPage() {

    )} - {/* App Storage Size (all app types) */} -
    -
    - -

    - {form.runtime === 'wordpress' ? 'Upload Storage (wp-content)' - : form.runtime === 'laravel' ? 'Storage Directory' - : 'Application Data Storage'} -

    -
    -

    - {form.runtime === 'wordpress' - ? 'This space is used for uploads, plugins, themes, and other WordPress files.' - : form.runtime === 'laravel' - ? 'This space is used for uploads, logs, cache, and other Laravel storage files.' - : 'This space is used for persistent application data, uploads, and files.'} -

    -
    -
    - - { - const val = Math.max(1, Math.min(100, parseInt(e.target.value, 10) || 2)); - setForm({ ...form, appStorageSize: String(val) }); - }} - className="w-16 text-center py-2 border-x border-gray-300 text-sm font-semibold focus:outline-none" - /> - -
    - GB - {wpContentFile && form.runtime === 'wordpress' && ( - - Suggested based on wp-content ({(wpContentFile.size / (1024 * 1024 * 1024)).toFixed(2)} GB) - - )} -
    -

    Minimum 1GB • Recommended: 2GB or more

    -
    -
    )} @@ -2458,7 +2495,7 @@ export default function DeployPage() { Port {form.port}
    - {(form.enableRedis || form.enableRabbitmq || form.enableElasticsearch) && ( + {form.runtime !== 'wordpress' && (form.enableRedis || form.enableRabbitmq || form.enableElasticsearch) && (
    Optional Services @@ -2470,7 +2507,7 @@ export default function DeployPage() {
    )} - {form.enableRedis && form.optionalServiceResources?.redis && ( + {form.runtime !== 'wordpress' && form.enableRedis && form.optionalServiceResources?.redis && (
    Redis resources @@ -2481,7 +2518,7 @@ export default function DeployPage() {
    )} - {form.enableRabbitmq && form.optionalServiceResources?.rabbitmq && ( + {form.runtime !== 'wordpress' && form.enableRabbitmq && form.optionalServiceResources?.rabbitmq && (
    RabbitMQ resources @@ -2492,7 +2529,7 @@ export default function DeployPage() {
    )} - {Object.keys(form.envVars || {}).length > 0 && ( + {form.runtime !== 'wordpress' && Object.keys(form.envVars || {}).length > 0 && (
    Env Vars {Object.keys(form.envVars!).length} defined