diff --git a/clients/admin/src/api/billing.ts b/clients/admin/src/api/billing.ts index c21c846ac6..5fd2e43fab 100644 --- a/clients/admin/src/api/billing.ts +++ b/clients/admin/src/api/billing.ts @@ -9,6 +9,10 @@ export type SubscriptionStatus = "Active" | "Suspended" | "Cancelled" | (string export type InvoiceLineItemKind = "BaseFee" | "Overage" | "Adjustment" | (string & {}); +export type PlanInterval = "Monthly" | "Yearly" | (string & {}); + +export type InvoicePurpose = "Usage" | "Subscription" | (string & {}); + export type QuotaResource = | "ApiCalls" | "StorageBytes" @@ -26,6 +30,8 @@ export type BillingPlanDto = { monthlyBasePrice: number; overageRates: Partial>; isActive: boolean; + interval: PlanInterval; + annualPrice?: number | null; }; export type CreatePlanInput = { @@ -34,6 +40,8 @@ export type CreatePlanInput = { currency: string; monthlyBasePrice: number; overageRates?: Partial> | null; + interval?: PlanInterval; + annualPrice?: number | null; }; export type UpdatePlanInput = { @@ -41,8 +49,17 @@ export type UpdatePlanInput = { name: string; monthlyBasePrice: number; overageRates?: Partial> | null; + interval?: PlanInterval; + annualPrice?: number | null; }; +/** Price charged for one billing term: annual price (or 12x monthly) for yearly plans, else monthly. */ +export function planTermPrice(plan: Pick): number { + return plan.interval === "Yearly" + ? plan.annualPrice ?? plan.monthlyBasePrice * 12 + : plan.monthlyBasePrice; +} + export function getPlans(includeInactive = false): Promise { const query = new URLSearchParams({ includeInactive: includeInactive ? "true" : "false" }); return apiFetch(`/api/v1/billing/plans?${query.toString()}`); @@ -57,6 +74,8 @@ export function createPlan(input: CreatePlanInput): Promise { currency: input.currency, monthlyBasePrice: input.monthlyBasePrice, overageRates: input.overageRates ?? null, + interval: input.interval ?? "Monthly", + annualPrice: input.annualPrice ?? null, }), }); } @@ -69,6 +88,8 @@ export function updatePlan(input: UpdatePlanInput): Promise { name: input.name, monthlyBasePrice: input.monthlyBasePrice, overageRates: input.overageRates ?? null, + interval: input.interval ?? "Monthly", + annualPrice: input.annualPrice ?? null, }), }); } @@ -132,6 +153,9 @@ export type InvoiceDto = { voidedAtUtc?: string | null; notes?: string | null; lineItems: InvoiceLineItemDto[]; + purpose: InvoicePurpose; + periodStartUtc?: string | null; + periodEndUtc?: string | null; }; export type ListInvoicesParams = { diff --git a/clients/admin/src/api/tenants.ts b/clients/admin/src/api/tenants.ts index a9a82d28dc..2337aeb96d 100644 --- a/clients/admin/src/api/tenants.ts +++ b/clients/admin/src/api/tenants.ts @@ -3,6 +3,8 @@ import type { PagedResponse } from "@/lib/api-types"; export type { PagedResponse } from "@/lib/api-types"; +export type TenantExpiryState = "Active" | "InGrace" | "Expired" | (string & {}); + export type TenantDto = { id: string; name: string; @@ -10,6 +12,10 @@ export type TenantDto = { isActive: boolean; validUpto: string; issuer?: string; + /** Present on the status endpoint (TenantStatusDto); absent on the list projection. */ + plan?: string | null; + expiryState?: TenantExpiryState; + graceEndsUtc?: string; }; export type ListTenantsParams = { @@ -25,6 +31,15 @@ export type CreateTenantInput = { adminPassword: string; issuer: string; connectionString?: string | null; + /** Plan key to subscribe the tenant to. Omitted → server falls back to the default/trial plan. */ + planKey?: string | null; +}; + +export type RenewTenantResponse = { + tenantId: string; + validUpto: string; + planKey: string; + planChanged: boolean; }; export type CreateTenantResponse = { @@ -84,10 +99,19 @@ export async function createTenant(input: CreateTenantInput): Promise { + return apiFetch(`/api/v1/tenants/${encodeURIComponent(id)}/renew`, { + method: "POST", + body: JSON.stringify({ tenantId: id, planKey: planKey ?? null }), + }); +} + export async function changeTenantActivation(id: string, isActive: boolean): Promise { return apiFetch(`/api/v1/tenants/${encodeURIComponent(id)}/activation`, { method: "POST", diff --git a/clients/admin/src/components/billing/plan-form-dialog.tsx b/clients/admin/src/components/billing/plan-form-dialog.tsx new file mode 100644 index 0000000000..8ae2d7b8fe --- /dev/null +++ b/clients/admin/src/components/billing/plan-form-dialog.tsx @@ -0,0 +1,328 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { CreditCard, Gauge } from "lucide-react"; +import { toast } from "sonner"; +import { + createPlan, + updatePlan, + type BillingPlanDto, + type PlanInterval, + type QuotaResource, +} from "@/api/billing"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Field, Select, type SelectOption } from "@/components/list"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ApiRequestError } from "@/lib/api-client"; + +const PLAN_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/; + +const INTERVAL_OPTIONS: SelectOption[] = [ + { value: "Monthly", label: "Monthly", hint: "billed every month" }, + { value: "Yearly", label: "Yearly", hint: "billed every 12 months" }, +]; + +const OVERAGE_RESOURCES: { key: QuotaResource; label: string; placeholder: string }[] = [ + { key: "ApiCalls", label: "API calls", placeholder: "0.0010" }, + { key: "StorageBytes", label: "Storage bytes", placeholder: "0.00000001" }, + { key: "Users", label: "Users", placeholder: "5.00" }, + { key: "ActiveFeatureFlags", label: "Feature flags", placeholder: "1.00" }, +]; + +type OverageState = Record; + +function toOverageNumbers(state: OverageState): Record | null { + const out: Record = {}; + let any = false; + for (const { key } of OVERAGE_RESOURCES) { + const raw = state[key]; + if (raw === undefined || raw.trim() === "") continue; + const n = Number(raw); + if (!Number.isFinite(n) || n < 0) continue; + out[key] = n; + any = true; + } + return any ? out : null; +} + +function describe(err: unknown, fallback: string): string { + if (err instanceof ApiRequestError) return err.problem?.detail ?? err.problem?.title ?? err.message; + if (err instanceof Error) return err.message; + return fallback; +} + +function SectionLabel({ + icon: Icon, + title, + description, +}: { + icon: React.ComponentType<{ className?: string }>; + title: string; + description: string; +}) { + return ( +
+ + + +
+

{title}

+

{description}

+
+
+ ); +} + +/** + * Create or edit a billing plan in a dialog. Pass `plan` to edit (key + currency are immutable then), + * omit it to create. On success it invalidates the plans cache and closes. + */ +export function PlanFormDialog({ + open, + onOpenChange, + plan, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + plan?: BillingPlanDto; +}) { + const queryClient = useQueryClient(); + const isEdit = !!plan; + + const [key, setKey] = useState(""); + const [name, setName] = useState(""); + const [currency, setCurrency] = useState("USD"); + const [monthlyBasePrice, setMonthlyBasePrice] = useState(""); + const [interval, setInterval] = useState("Monthly"); + const [annualPrice, setAnnualPrice] = useState(""); + const [overage, setOverage] = useState({}); + + // Reset/populate whenever the dialog opens (or the target plan changes). + useEffect(() => { + if (!open) return; + setKey(plan?.key ?? ""); + setName(plan?.name ?? ""); + setCurrency(plan?.currency ?? "USD"); + setMonthlyBasePrice(plan ? String(plan.monthlyBasePrice) : ""); + setInterval(plan?.interval === "Yearly" ? "Yearly" : "Monthly"); + setAnnualPrice(plan?.annualPrice != null ? String(plan.annualPrice) : ""); + const next: OverageState = {}; + for (const [resource, rate] of Object.entries(plan?.overageRates ?? {})) { + if (rate !== undefined && rate !== null) next[resource] = String(rate); + } + setOverage(next); + }, [open, plan]); + + const keyInvalid = !isEdit && key.length > 0 && !PLAN_KEY_PATTERN.test(key); + const priceNum = Number(monthlyBasePrice); + const priceInvalid = monthlyBasePrice.length > 0 && (!Number.isFinite(priceNum) || priceNum < 0); + const annualNum = Number(annualPrice); + const annualInvalid = annualPrice.trim().length > 0 && (!Number.isFinite(annualNum) || annualNum < 0); + const annualPricePayload = interval === "Yearly" && annualPrice.trim().length > 0 ? annualNum : null; + + const onClose = () => onOpenChange(false); + + const createMutation = useMutation({ + mutationFn: createPlan, + onSuccess: () => { + toast.success(`Plan "${name}" created`); + queryClient.invalidateQueries({ queryKey: ["billing", "plans"] }); + onClose(); + }, + onError: (err) => toast.error("Create failed", { description: describe(err, "Could not create plan.") }), + }); + + const updateMutation = useMutation({ + mutationFn: updatePlan, + onSuccess: () => { + toast.success(`Plan "${name}" updated`); + queryClient.invalidateQueries({ queryKey: ["billing", "plans"] }); + onClose(); + }, + onError: (err) => toast.error("Update failed", { description: describe(err, "Could not update plan.") }), + }); + + const pending = createMutation.isPending || updateMutation.isPending; + + const onSubmit = (e: FormEvent) => { + e.preventDefault(); + if (priceInvalid || annualInvalid) return; + const overageRates = toOverageNumbers(overage); + + if (isEdit && plan) { + updateMutation.mutate({ + planId: plan.id, + name: name.trim(), + monthlyBasePrice: priceNum, + overageRates, + interval, + annualPrice: annualPricePayload, + }); + return; + } + if (keyInvalid) return; + createMutation.mutate({ + key: key.trim(), + name: name.trim(), + currency: currency.trim().toUpperCase(), + monthlyBasePrice: priceNum, + overageRates, + interval, + annualPrice: annualPricePayload, + }); + }; + + return ( + + + +
+ + + + {isEdit ? "Edit plan" : "New plan"} +
+ + {isEdit + ? "Update name, pricing, interval, or overage rates. Key and currency are immutable." + : "Plan keys are canonical slugs used by tenant subscriptions and quota configuration."} + +
+ +
+ + {/* ── Details ── */} +
+ +
+
+ + setKey(e.target.value)} + placeholder="pro" + className="font-mono" + disabled={isEdit} + autoComplete="off" + /> + + + setName(e.target.value)} placeholder="Pro" /> + + + setCurrency(e.target.value.toUpperCase())} + placeholder="USD" + className="font-mono" + disabled={isEdit} + autoComplete="off" + /> + + + setMonthlyBasePrice(e.target.value)} + inputMode="decimal" + placeholder="29.00" + /> + + + + id="pf-interval" + value={interval} + onValueChange={(v) => setInterval(v === "Yearly" ? "Yearly" : "Monthly")} + options={INTERVAL_OPTIONS} + /> + + {interval === "Yearly" && ( + + setAnnualPrice(e.target.value)} + inputMode="decimal" + placeholder={monthlyBasePrice ? String(Number(monthlyBasePrice) * 12) : "290.00"} + /> + + )} +
+
+ + {/* ── Overage rates ── */} +
+ +
+
+ {OVERAGE_RESOURCES.map((res) => ( + + setOverage((s) => ({ ...s, [res.key]: e.target.value }))} + inputMode="decimal" + placeholder={res.placeholder} + /> + + ))} +
+
+ + + + + + + + +
+ ); +} diff --git a/clients/admin/src/components/list/select.tsx b/clients/admin/src/components/list/select.tsx index ceb56e7c98..82f0bcffd8 100644 --- a/clients/admin/src/components/list/select.tsx +++ b/clients/admin/src/components/list/select.tsx @@ -1,5 +1,10 @@ -import * as React from "react"; -import { ChevronDown } from "lucide-react"; +import { Check, ChevronDown } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/cn"; export type SelectOption = { @@ -8,62 +13,125 @@ export type SelectOption = { hint?: string; }; -type SelectProps = Omit< - React.SelectHTMLAttributes, - "onChange" | "value" | "children" -> & { +type SelectProps = { value: T | ""; onValueChange: (value: T | "") => void; options: SelectOption[]; /** Adds a leading "All" / "Any" / etc. option that maps to "". */ emptyLabel?: string; + id?: string; + disabled?: boolean; + className?: string; + "aria-invalid"?: boolean | "true" | "false"; + "aria-describedby"?: string; }; /** - * Select — native ignores ::after). + * Select — form-field single-select built on the Radix DropdownMenu + * primitive (shares the filter Select's vocabulary in components/ui). + * Full-width trigger sized to its container with a brand-tinted focus + * ring matching ; the panel matches the trigger width and + * scrolls for long lists. Keeps the former native- onValueChange(e.target.value as T | "")} - disabled={disabled} - className={cn( - "h-9 w-full appearance-none rounded-md border border-[var(--color-input)] bg-transparent", - "pl-3 pr-8 text-sm font-sans", - "transition-[border-color,background-color,box-shadow] duration-[var(--duration-fast)] ease-[var(--ease-out-cubic)]", - "hover:border-[var(--color-border-strong)]", - "focus-visible:outline-none focus-visible:border-[var(--color-accent-signal)] focus-visible:ring-2 focus-visible:ring-[oklch(from_var(--color-accent-signal)_l_c_h_/_0.25)] focus-visible:bg-[var(--color-surface-2)]", - "disabled:cursor-not-allowed disabled:opacity-50", - "aria-[invalid=true]:border-[var(--color-destructive)]", - )} - {...rest} - > - {emptyLabel !== undefined && } - {options.map((opt) => ( - - ))} - - + + + {displayLabel} + + + + + {allOptions.map((opt) => { + const selected = opt.value === value; + return ( + onValueChange(opt.value as T | "")} + className={cn( + selected && [ + "bg-[oklch(from_var(--color-primary)_l_c_h_/_0.08)]", + "text-[var(--color-primary)]", + "data-[highlighted]:bg-[oklch(from_var(--color-primary)_l_c_h_/_0.12)]", + "data-[highlighted]:text-[var(--color-primary)]", + ], + )} + > + + {opt.label} + {opt.hint && ( + + — {opt.hint} + + )} + + {selected && ( + + )} + + ); + })} + + ); } diff --git a/clients/admin/src/components/tenants/create-tenant-dialog.tsx b/clients/admin/src/components/tenants/create-tenant-dialog.tsx index 44032cc637..cda09142fa 100644 --- a/clients/admin/src/components/tenants/create-tenant-dialog.tsx +++ b/clients/admin/src/components/tenants/create-tenant-dialog.tsx @@ -1,10 +1,12 @@ +import { useEffect } from "react"; import { useNavigate } from "react-router-dom"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useForm } from "react-hook-form"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Building2, + CreditCard, Database, KeyRound, UserRound, @@ -12,9 +14,10 @@ import { } from "lucide-react"; import { toast } from "sonner"; import { createTenant } from "@/api/tenants"; +import { getPlans, planTermPrice } from "@/api/billing"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Field } from "@/components/list"; +import { Field, Select, type SelectOption } from "@/components/list"; import { Dialog, DialogContent, @@ -46,8 +49,19 @@ const schema = z.object({ .max(128, "Maximum 128 characters."), issuer: z.string().trim().min(2, "Required.").max(256), connectionString: z.string().trim().max(2048).optional(), + // Optional: preselected to the default plan when plans load; if left empty (e.g. plans + // unavailable) the server falls back to the configured trial plan. + planKey: z.string().trim().optional(), }); +function formatMoney(amount: number, currency: string): string { + try { + return new Intl.NumberFormat(undefined, { style: "currency", currency }).format(amount); + } catch { + return `${amount.toFixed(2)} ${currency}`; + } +} + type FormValues = z.infer; // ─── Section header (inline, no card — we're inside the dialog already) ───── @@ -91,10 +105,28 @@ export function CreateTenantDialog({ const navigate = useNavigate(); const queryClient = useQueryClient(); + const plansQuery = useQuery({ + queryKey: ["billing", "plans", "active"], + queryFn: () => getPlans(false), + enabled: open, + }); + + const planOptions: SelectOption[] = (plansQuery.data ?? []).map((p) => ({ + value: p.key, + label: p.name, + hint: `${p.interval} · ${formatMoney(planTermPrice(p), p.currency)}`, + })); + // Prefer the conventional trial plan, else the first active plan. + const defaultPlanKey = + plansQuery.data?.find((p) => p.key === "free")?.key ?? plansQuery.data?.[0]?.key ?? ""; + const { register, handleSubmit, + control, reset, + setValue, + getValues, formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(schema), @@ -105,9 +137,17 @@ export function CreateTenantDialog({ adminPassword: "", issuer: "", connectionString: "", + planKey: "", }, }); + // Preselect the default/trial plan once plans load (without clobbering an explicit choice). + useEffect(() => { + if (defaultPlanKey && !getValues("planKey")) { + setValue("planKey", defaultPlanKey); + } + }, [defaultPlanKey, getValues, setValue]); + const mutation = useMutation({ // Pass values via mutate(arg) — no closed-over state captured at submit time. mutationFn: (values: FormValues) => @@ -118,6 +158,7 @@ export function CreateTenantDialog({ adminPassword: values.adminPassword, issuer: values.issuer, connectionString: values.connectionString?.trim() ? values.connectionString : null, + planKey: values.planKey?.trim() ? values.planKey : null, }), onSuccess: (result) => { toast.success(`Tenant ${result.id} created`, { @@ -256,6 +297,47 @@ export function CreateTenantDialog({ + {/* ── Plan section ── */} +
+ +
+ + ( + + + + + + + + + + + ); +} diff --git a/clients/admin/src/components/ui/dialog.tsx b/clients/admin/src/components/ui/dialog.tsx index 1818cc2221..158e6f5513 100644 --- a/clients/admin/src/components/ui/dialog.tsx +++ b/clients/admin/src/components/ui/dialog.tsx @@ -68,6 +68,9 @@ export const DialogContent = React.forwardRef< data-slot="dialog-content" className={cn( "fixed left-1/2 top-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2", + // Cap height to the viewport and scroll when content overflows so the footer + // (submit/cancel) stays reachable on short viewports and tall forms. + "max-h-[calc(100dvh-2rem)] overflow-y-auto", sizeClass[size], "rounded-xl border border-[var(--color-border)] bg-[var(--color-card)]", "shadow-xl outline-none", diff --git a/clients/admin/src/pages/billing/invoice-detail.tsx b/clients/admin/src/pages/billing/invoice-detail.tsx index d75034a8cb..32b46c3ffc 100644 --- a/clients/admin/src/pages/billing/invoice-detail.tsx +++ b/clients/admin/src/pages/billing/invoice-detail.tsx @@ -147,8 +147,16 @@ export function InvoiceDetailPage() { {invoice.invoiceNumber} {invoice.status} + {invoice.purpose && ( + + {invoice.purpose === "Subscription" ? "Subscription" : "Usage"} + + )} tenant {invoice.tenantId} · period {formatPeriod(invoice.periodYear, invoice.periodMonth)} · created {formatDate(invoice.createdAtUtc)} + {invoice.periodStartUtc && invoice.periodEndUtc && ( + ` · term ${formatDate(invoice.periodStartUtc)} – ${formatDate(invoice.periodEndUtc)}` + )} {invoice.issuedAtUtc && ` · issued ${formatDate(invoice.issuedAtUtc)}`} {invoice.dueAtUtc && invoice.status === "Issued" && ( · due {formatDate(invoice.dueAtUtc)} diff --git a/clients/admin/src/pages/billing/invoices-list.tsx b/clients/admin/src/pages/billing/invoices-list.tsx index 1ef9110f9e..3fae58095f 100644 --- a/clients/admin/src/pages/billing/invoices-list.tsx +++ b/clients/admin/src/pages/billing/invoices-list.tsx @@ -21,6 +21,7 @@ import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Select } from "@/components/list"; import { KpiTile } from "@/components/kpi-tile"; import { ApiRequestError } from "@/lib/api-client"; import { cn } from "@/lib/cn"; @@ -215,22 +216,16 @@ export function InvoicesListPage() {
- + options={STATUSES.map((s) => ({ value: s, label: s }))} + emptyLabel="All" + />
@@ -328,6 +323,11 @@ export function InvoicesListPage() { {inv.invoiceNumber} {inv.status} + {inv.purpose && ( + + {inv.purpose === "Subscription" ? "Subscription" : "Usage"} + + )}
tenant {inv.tenantId} · diff --git a/clients/admin/src/pages/billing/plan-form.tsx b/clients/admin/src/pages/billing/plan-form.tsx deleted file mode 100644 index 184a9cc97e..0000000000 --- a/clients/admin/src/pages/billing/plan-form.tsx +++ /dev/null @@ -1,274 +0,0 @@ -import { useEffect, useMemo, useState, type FormEvent } from "react"; -import { useNavigate, useParams } from "react-router-dom"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowLeft, CreditCard } from "lucide-react"; -import { toast } from "sonner"; -import { - createPlan, - getPlans, - updatePlan, - type BillingPlanDto, - type QuotaResource, -} from "@/api/billing"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { EntityPageHeader, SettingsSection, Field } from "@/components/list"; -import { ApiRequestError } from "@/lib/api-client"; - -// Plan keys are canonical lowercase slugs (a-z 0-9 -), 2-64 chars. -const PLAN_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/; - -const OVERAGE_RESOURCES: { key: QuotaResource; label: string; placeholder: string }[] = [ - { key: "ApiCalls", label: "API calls", placeholder: "0.0010" }, - { key: "StorageBytes", label: "Storage bytes", placeholder: "0.00000001" }, - { key: "Users", label: "Users", placeholder: "5.00" }, - { key: "ActiveFeatureFlags", label: "Feature flags", placeholder: "1.00" }, -]; - -type OverageState = Record; - -function toOverageNumbers(state: OverageState): Record | null { - const out: Record = {}; - let any = false; - for (const { key } of OVERAGE_RESOURCES) { - const raw = state[key]; - if (raw === undefined || raw.trim() === "") continue; - const n = Number(raw); - if (!Number.isFinite(n) || n < 0) continue; - out[key] = n; - any = true; - } - return any ? out : null; -} - -function describe(err: unknown, fallback: string): string { - if (err instanceof ApiRequestError) return err.problem?.detail ?? err.problem?.title ?? err.message; - if (err instanceof Error) return err.message; - return fallback; -} - -export function PlanFormPage() { - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const { planId } = useParams<{ planId?: string }>(); - const isEdit = !!planId; - - // ── form state ───────────────────────────────────────────────────── - const [key, setKey] = useState(""); - const [name, setName] = useState(""); - const [currency, setCurrency] = useState("USD"); - const [monthlyBasePrice, setMonthlyBasePrice] = useState(""); - const [overage, setOverage] = useState({}); - - // ── load existing plan when editing ──────────────────────────────── - const plansQuery = useQuery({ - queryKey: ["billing", "plans", { includeInactive: true }], - queryFn: () => getPlans(true), - enabled: isEdit, - }); - const existing = useMemo(() => { - if (!isEdit || !plansQuery.data) return undefined; - return plansQuery.data.find((p) => p.id === planId); - }, [isEdit, plansQuery.data, planId]); - - useEffect(() => { - if (!existing) return; - setKey(existing.key); - setName(existing.name); - setCurrency(existing.currency); - setMonthlyBasePrice(String(existing.monthlyBasePrice)); - const next: OverageState = {}; - for (const [resource, rate] of Object.entries(existing.overageRates)) { - if (rate !== undefined && rate !== null) next[resource] = String(rate); - } - setOverage(next); - }, [existing]); - - // ── validation ───────────────────────────────────────────────────── - const keyInvalid = !isEdit && key.length > 0 && !PLAN_KEY_PATTERN.test(key); - const priceNum = Number(monthlyBasePrice); - const priceInvalid = - monthlyBasePrice.length > 0 && (!Number.isFinite(priceNum) || priceNum < 0); - - // ── submit ───────────────────────────────────────────────────────── - const createMutation = useMutation({ - mutationFn: createPlan, - onSuccess: () => { - toast.success(`Plan "${name}" created`); - queryClient.invalidateQueries({ queryKey: ["billing", "plans"] }); - navigate("/billing/plans"); - }, - onError: (err) => toast.error("Create failed", { description: describe(err, "Could not create plan.") }), - }); - - const updateMutation = useMutation({ - mutationFn: updatePlan, - onSuccess: () => { - toast.success(`Plan "${name}" updated`); - queryClient.invalidateQueries({ queryKey: ["billing", "plans"] }); - navigate("/billing/plans"); - }, - onError: (err) => toast.error("Update failed", { description: describe(err, "Could not update plan.") }), - }); - - const onSubmit = (e: FormEvent) => { - e.preventDefault(); - if (priceInvalid) return; - - const overageRates = toOverageNumbers(overage); - - if (isEdit && planId) { - updateMutation.mutate({ - planId, - name: name.trim(), - monthlyBasePrice: priceNum, - overageRates, - }); - return; - } - if (keyInvalid) return; - createMutation.mutate({ - key: key.trim(), - name: name.trim(), - currency: currency.trim().toUpperCase(), - monthlyBasePrice: priceNum, - overageRates, - }); - }; - - const pending = createMutation.isPending || updateMutation.isPending; - const loadingExisting = isEdit && plansQuery.isLoading; - - return ( -
-
- - -
- -
- - - -
- } - > -
- - setKey(e.target.value)} - placeholder="pro" - required={!isEdit} - disabled={isEdit} - autoComplete="off" - /> - - - setName(e.target.value)} - required - placeholder="Pro" - /> - -
- - setCurrency(e.target.value.toUpperCase())} - required={!isEdit} - disabled={isEdit} - placeholder="USD" - autoComplete="off" - /> - - - setMonthlyBasePrice(e.target.value)} - inputMode="decimal" - required - placeholder="29.00" - /> - -
- - -
- {OVERAGE_RESOURCES.map((res) => ( - - setOverage((s) => ({ ...s, [res.key]: e.target.value }))} - inputMode="decimal" - placeholder={res.placeholder} - /> - - ))} -
-
-
- - -
- ); -} diff --git a/clients/admin/src/pages/billing/plans-list.tsx b/clients/admin/src/pages/billing/plans-list.tsx index 647980d430..55668c3be8 100644 --- a/clients/admin/src/pages/billing/plans-list.tsx +++ b/clients/admin/src/pages/billing/plans-list.tsx @@ -1,12 +1,12 @@ -import { useMemo } from "react"; -import { useNavigate } from "react-router-dom"; +import { useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Pencil, Plus, Tag } from "lucide-react"; -import { getPlans, type BillingPlanDto } from "@/api/billing"; +import { getPlans, planTermPrice, type BillingPlanDto } from "@/api/billing"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { StatStrip, Stat, SettingsSection } from "@/components/list"; +import { PlanFormDialog } from "@/components/billing/plan-form-dialog"; import { ApiRequestError } from "@/lib/api-client"; // ─── helpers ────────────────────────────────────────────────────────── @@ -36,7 +36,18 @@ function describe(err: unknown): string { // ─── component ──────────────────────────────────────────────────────── export function PlansListPage() { - const navigate = useNavigate(); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingPlan, setEditingPlan] = useState(undefined); + + const openCreate = () => { + setEditingPlan(undefined); + setDialogOpen(true); + }; + const openEdit = (plan: BillingPlanDto) => { + setEditingPlan(plan); + setDialogOpen(true); + }; + const query = useQuery({ queryKey: ["billing", "plans", { includeInactive: true }], queryFn: () => getPlans(true), @@ -91,7 +102,7 @@ export function PlansListPage() { title="All plans" description="Pricing schedule used by tenant subscriptions and invoice generation." footer={ - } @@ -130,6 +141,7 @@ export function PlansListPage() { {plan.key} {plan.name} + {plan.interval === "Yearly" ? "Yearly" : "Monthly"} {plan.isActive ? ( Active ) : ( @@ -146,17 +158,17 @@ export function PlansListPage() {
- {formatMoney(plan.monthlyBasePrice, plan.currency)} + {formatMoney(planTermPrice(plan), plan.currency)}
- per month + {plan.interval === "Yearly" ? "per year" : "per month"}
@@ -166,6 +178,8 @@ export function PlansListPage() { )} + +
); } diff --git a/clients/admin/src/pages/tenants/detail.tsx b/clients/admin/src/pages/tenants/detail.tsx index 9c205e2dc1..fdccd60670 100644 --- a/clients/admin/src/pages/tenants/detail.tsx +++ b/clients/admin/src/pages/tenants/detail.tsx @@ -8,6 +8,7 @@ import { CheckCircle2, CircleDashed, ClipboardList, + CreditCard, Info, KeyRound, Loader2, @@ -22,6 +23,7 @@ import { useAuth } from "@/auth/use-auth"; import { ImpersonateDialog } from "@/components/impersonation/impersonate-dialog"; import { ActiveGrantsCard } from "@/components/impersonation/active-grants-card"; import { TenantBrandingCard } from "@/components/tenants/tenant-branding-card"; +import { RenewTenantDialog } from "@/components/tenants/renew-tenant-dialog"; import { IdentityPermissions } from "@/lib/permissions"; import { changeTenantActivation, @@ -48,6 +50,7 @@ export function TenantDetailPage() { const queryClient = useQueryClient(); const { user: currentUser } = useAuth(); const [impersonateOpen, setImpersonateOpen] = useState(false); + const [renewOpen, setRenewOpen] = useState(false); const canImpersonate = (currentUser?.permissions ?? []).includes( IdentityPermissions.Users.Impersonate, ); @@ -148,6 +151,17 @@ export function TenantDetailPage() { {tenant.isActive ? "Active" : "Inactive"} + {tenant.expiryState && tenant.expiryState !== "Active" && ( + + {tenant.expiryState === "InGrace" ? "In grace" : "Expired"} + + )} + {tenant.plan && ( + + + {tenant.plan} + + )} Valid until {formatDate(tenant.validUpto)} @@ -174,6 +188,15 @@ export function TenantDetailPage() { Impersonate user )} +