Make billing catalog dynamic for all runtimes and bill optional service resources.
Derive admin tabs and addon rows from enums, and add Redis/RabbitMQ/ES CPU/RAM/disk to deploy cost using the app runtime unit rates. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -13,41 +13,27 @@ import type {
|
||||
} from '@/types';
|
||||
import { DollarSign, Edit2, Shield, Clock, Layers } from 'lucide-react';
|
||||
|
||||
type AppRuntime = 'nodejs' | 'laravel' | 'wordpress';
|
||||
|
||||
const runtimeTabs: { value: AppRuntime; label: string }[] = [
|
||||
{ value: 'nodejs', label: 'Node.js' },
|
||||
{ value: 'laravel', label: 'Laravel' },
|
||||
{ value: 'wordpress', label: 'WordPress' },
|
||||
];
|
||||
|
||||
const addonResourceTypes: PricingResourceType[] = [
|
||||
'redis_addon',
|
||||
'rabbitmq_addon',
|
||||
'elasticsearch_addon',
|
||||
'custom_domain_addon',
|
||||
];
|
||||
|
||||
const resourceLabels: Record<PricingResourceType, string> = {
|
||||
base_fee: 'Base fee',
|
||||
cpu_per_core: 'CPU (per core)',
|
||||
memory_per_gb: 'Memory (per GB)',
|
||||
storage_per_gb: 'Storage (per GB)',
|
||||
database_addon: 'Database addon',
|
||||
redis_addon: 'Redis',
|
||||
rabbitmq_addon: 'RabbitMQ',
|
||||
elasticsearch_addon: 'Elasticsearch',
|
||||
redis_addon: 'Redis (flat addon)',
|
||||
rabbitmq_addon: 'RabbitMQ (flat addon)',
|
||||
elasticsearch_addon: 'Elasticsearch (flat addon)',
|
||||
custom_domain_addon: 'Custom domain + SSL',
|
||||
};
|
||||
|
||||
const cycles: BillingCycle[] = ['hourly', 'monthly', 'yearly'];
|
||||
|
||||
function cloneCatalog(catalog: PricingCatalog): PricingCatalog {
|
||||
const runtimes = {} as PricingCatalog['runtimes'];
|
||||
for (const rt of runtimeTabs) {
|
||||
runtimes[rt.value] = catalog.runtimes[rt.value].map((r) => ({ ...r }));
|
||||
const runtimes: PricingCatalog['runtimes'] = {};
|
||||
for (const key of Object.keys(catalog.runtimes)) {
|
||||
runtimes[key] = catalog.runtimes[key].map((r) => ({ ...r }));
|
||||
}
|
||||
return {
|
||||
...catalog,
|
||||
runtimes,
|
||||
addons: catalog.addons.map((a) => ({ ...a })),
|
||||
};
|
||||
@@ -123,7 +109,7 @@ function PricingMatrixTable({
|
||||
|
||||
export default function AdminBillingPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [activeRuntime, setActiveRuntime] = useState<AppRuntime>('nodejs');
|
||||
const [activeRuntime, setActiveRuntime] = useState<string>('nodejs');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<PricingCatalog | null>(null);
|
||||
|
||||
@@ -132,6 +118,14 @@ export default function AdminBillingPage() {
|
||||
queryFn: () => api.get('/billing/pricing-catalog').then((r) => r.data),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const options = catalog?.runtimeOptions ?? [];
|
||||
if (options.length === 0) return;
|
||||
if (!options.some((o) => o.value === activeRuntime)) {
|
||||
setActiveRuntime(options[0].value);
|
||||
}
|
||||
}, [catalog, activeRuntime]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (body: PricingCatalog) => api.patch('/billing/pricing-catalog', body),
|
||||
onSuccess: () => {
|
||||
@@ -152,10 +146,14 @@ export default function AdminBillingPage() {
|
||||
});
|
||||
|
||||
const display = editing && draft ? draft : catalog;
|
||||
const runtimeTabs = display?.runtimeOptions ?? catalog?.runtimeOptions ?? [];
|
||||
|
||||
const startEdit = () => {
|
||||
if (!catalog) return;
|
||||
setDraft(cloneCatalog(catalog));
|
||||
if (!activeRuntime && catalog.runtimeOptions[0]) {
|
||||
setActiveRuntime(catalog.runtimeOptions[0].value);
|
||||
}
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
@@ -171,7 +169,7 @@ export default function AdminBillingPage() {
|
||||
...draft,
|
||||
runtimes: {
|
||||
...draft.runtimes,
|
||||
[activeRuntime]: draft.runtimes[activeRuntime].map((row) =>
|
||||
[activeRuntime]: (draft.runtimes[activeRuntime] ?? []).map((row) =>
|
||||
row.resourceType === resourceType ? { ...row, [field]: value } : row,
|
||||
),
|
||||
},
|
||||
@@ -201,7 +199,7 @@ export default function AdminBillingPage() {
|
||||
...draft,
|
||||
runtimes: {
|
||||
...draft.runtimes,
|
||||
[activeRuntime]: draft.runtimes[activeRuntime].map((row) => ({
|
||||
[activeRuntime]: (draft.runtimes[activeRuntime] ?? []).map((row) => ({
|
||||
...row,
|
||||
yearlyPrice: Math.round(Number(row.monthlyPrice) * 12),
|
||||
})),
|
||||
@@ -223,8 +221,8 @@ export default function AdminBillingPage() {
|
||||
saveMutation.mutate(draft);
|
||||
};
|
||||
|
||||
const addonRows =
|
||||
display?.addons.filter((a) => addonResourceTypes.includes(a.resourceType)) ?? [];
|
||||
const runtimeRows = display?.runtimes[activeRuntime] ?? [];
|
||||
const addonRows = display?.addons ?? [];
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto space-y-6 animate-fade-in">
|
||||
@@ -234,7 +232,7 @@ export default function AdminBillingPage() {
|
||||
<DollarSign className="w-6 h-6" /> Billing & Pricing
|
||||
</h1>
|
||||
<p className="page-subtitle">
|
||||
Usage-based prices per application type. Each resource has explicit hourly, monthly, and yearly rates — deploy cost uses the column for the cycle the user selects.
|
||||
Usage-based prices per application type (all runtimes from the platform). Optional services also bill CPU, RAM, and disk at the same unit rates as the app, plus any flat addon fee below.
|
||||
</p>
|
||||
</div>
|
||||
{!editing ? (
|
||||
@@ -307,7 +305,7 @@ export default function AdminBillingPage() {
|
||||
</div>
|
||||
|
||||
<PricingMatrixTable
|
||||
rows={display.runtimes[activeRuntime]}
|
||||
rows={runtimeRows}
|
||||
readOnly={!editing}
|
||||
onChange={updateRuntimePrice}
|
||||
/>
|
||||
@@ -329,7 +327,7 @@ export default function AdminBillingPage() {
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
Redis, RabbitMQ, Elasticsearch, and custom domain — same prices for all application types.
|
||||
Flat addon fees (optional). Deploy cost also includes each service's CPU, RAM, and disk at the app runtime unit rates above.
|
||||
</p>
|
||||
<PricingMatrixTable
|
||||
rows={addonRows}
|
||||
|
||||
@@ -383,9 +383,22 @@ export interface PricingRateRow {
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface CatalogRuntimeOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface CatalogOptionalServiceOption {
|
||||
value: string;
|
||||
label: string;
|
||||
resourceType: PricingResourceType;
|
||||
}
|
||||
|
||||
export interface PricingCatalog {
|
||||
runtimes: Record<'nodejs' | 'laravel' | 'wordpress', PricingRateRow[]>;
|
||||
runtimes: Record<string, PricingRateRow[]>;
|
||||
addons: PricingRateRow[];
|
||||
runtimeOptions: CatalogRuntimeOption[];
|
||||
optionalServiceOptions: CatalogOptionalServiceOption[];
|
||||
}
|
||||
|
||||
export interface WalletBalance {
|
||||
|
||||
Reference in New Issue
Block a user