Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion clients/admin/src/api/billing.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { apiFetch } from "@/lib/api-client";
import { apiFetch, ApiRequestError } from "@/lib/api-client";
import type { PagedResponse } from "@/lib/api-types";
import { env } from "@/env";
import { tokenStore } from "@/auth/token-store";

// ─── shared enums ────────────────────────────────────────────────────

Expand Down Expand Up @@ -182,6 +184,48 @@ export function getInvoice(invoiceId: string): Promise<InvoiceDto> {
return apiFetch<InvoiceDto>(`/api/v1/billing/invoices/${encodeURIComponent(invoiceId)}`);
}

/**
* Fetch the invoice PDF as a blob and trigger a browser download named
* `{invoiceNumber}.pdf`. The endpoint streams `application/pdf`, so it can't
* go through `apiFetch` (which only parses JSON). We replicate apiFetch's
* auth + tenant headers by hand so cross-tenant viewing works identically to
* how the detail page loads the invoice via `getInvoice`.
*/
export async function downloadInvoicePdf(invoiceId: string, invoiceNumber: string): Promise<void> {
const headers = new Headers({ Accept: "application/pdf" });

const accessToken = tokenStore.getAccessToken();
if (accessToken) {
headers.set("Authorization", `Bearer ${accessToken}`);
}
const tenant = tokenStore.getTenant() ?? env.defaultTenant;
if (tenant) {
headers.set("tenant", tenant);
}

const response = await fetch(
`${env.apiBase}/api/v1/billing/invoices/${encodeURIComponent(invoiceId)}/pdf`,
{ headers },
);

if (!response.ok) {
throw new ApiRequestError(response.status, `Failed to download invoice PDF (${response.status})`);
}

const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
try {
const link = document.createElement("a");
link.href = objectUrl;
link.download = `${invoiceNumber}.pdf`;
document.body.appendChild(link);
link.click();
link.remove();
} finally {
URL.revokeObjectURL(objectUrl);
}
}

export function generateInvoices(periodYear: number, periodMonth: number): Promise<{ generated: number }> {
return apiFetch<{ generated: number }>(`/api/v1/billing/invoices/generate`, {
method: "POST",
Expand Down
17 changes: 17 additions & 0 deletions clients/admin/src/api/tenants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ export type RenewTenantResponse = {
planChanged: boolean;
};

export type AdjustTenantValidityResponse = {
tenantId: string;
validUpto: string;
};

export type CreateTenantResponse = {
id: string;
provisioningCorrelationId?: string;
Expand Down Expand Up @@ -112,6 +117,18 @@ export async function renewTenant(id: string, planKey?: string | null): Promise<
});
}

/**
* Operator override: set a tenant's ValidUpto directly with NO invoice
* (comp/correction). Backdating is allowed server-side. Root-operator only —
* gated by MultitenancyPermissions.Tenants.UpgradeSubscription, same as renew.
*/
export async function adjustTenantValidity(id: string, validUpto: string): Promise<AdjustTenantValidityResponse> {
return apiFetch<AdjustTenantValidityResponse>(`/api/v1/tenants/${encodeURIComponent(id)}/adjust-validity`, {
method: "POST",
body: JSON.stringify({ tenantId: id, validUpto }),
});
}

export async function changeTenantActivation(id: string, isActive: boolean): Promise<TenantLifecycleResult> {
return apiFetch<TenantLifecycleResult>(`/api/v1/tenants/${encodeURIComponent(id)}/activation`, {
method: "POST",
Expand Down
65 changes: 57 additions & 8 deletions clients/admin/src/components/billing/plan-form-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useState, type FormEvent } from "react";
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
import { CreditCard, Gauge } from "lucide-react";
import { toast } from "sonner";
import {
Expand All @@ -25,6 +26,24 @@ import { ApiRequestError } from "@/lib/api-client";

const PLAN_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/;

// A money/rate field is a free-text decimal string. These refinements run
// client-side so a negative price is rejected before any network call (the
// server also rejects it, but we don't rely on that).
const NON_NEGATIVE_MSG = "Must be a non-negative number.";

/** Required non-negative decimal (e.g. monthly base price). */
const requiredNonNegative = z
.string()
.trim()
.min(1, "Required.")
.refine((v) => Number.isFinite(Number(v)) && Number(v) >= 0, NON_NEGATIVE_MSG);

/** Optional non-negative decimal (blank allowed → omitted). */
const optionalNonNegative = z
.string()
.trim()
.refine((v) => v === "" || (Number.isFinite(Number(v)) && Number(v) >= 0), NON_NEGATIVE_MSG);

const INTERVAL_OPTIONS: SelectOption<PlanInterval>[] = [
{ value: "Monthly", label: "Monthly", hint: "billed every month" },
{ value: "Yearly", label: "Yearly", hint: "billed every 12 months" },
Expand All @@ -46,13 +65,21 @@ function toOverageNumbers(state: OverageState): Record<string, number> | null {
const raw = state[key];
if (raw === undefined || raw.trim() === "") continue;
const n = Number(raw);
// Submission is blocked upstream when a value is invalid, so anything that
// reaches here is a non-negative finite number.
if (!Number.isFinite(n) || n < 0) continue;
out[key] = n;
any = true;
}
return any ? out : null;
}

/** First validation message for a value against a schema, or undefined when valid. */
function fieldError(schema: z.ZodTypeAny, value: string): string | undefined {
const result = schema.safeParse(value);
return result.success ? undefined : result.error.issues[0]?.message;
}

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;
Expand Down Expand Up @@ -126,11 +153,28 @@ export function PlanFormDialog({

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);
// Only surface the price error once something's been typed; submit-time
// validation (onSubmit) still blocks an empty required field.
const priceError =
monthlyBasePrice.length > 0 ? fieldError(requiredNonNegative, monthlyBasePrice) : undefined;
const annualNum = Number(annualPrice);
const annualInvalid = annualPrice.trim().length > 0 && (!Number.isFinite(annualNum) || annualNum < 0);
const annualError = fieldError(optionalNonNegative, annualPrice);
const annualPricePayload = interval === "Yearly" && annualPrice.trim().length > 0 ? annualNum : null;

// Per-resource overage validation — a negative or non-numeric rate blocks submit.
const overageErrors = useMemo(() => {
const out: Partial<Record<string, string>> = {};
for (const { key: resKey } of OVERAGE_RESOURCES) {
const err = fieldError(optionalNonNegative, overage[resKey] ?? "");
if (err) out[resKey] = err;
}
return out;
}, [overage]);
const hasOverageError = Object.keys(overageErrors).length > 0;
// Aggregate validity for disabling submit. Monthly price is required + non-negative.
const pricingInvalid =
!!fieldError(requiredNonNegative, monthlyBasePrice) || !!annualError || hasOverageError;

const onClose = () => onOpenChange(false);

const createMutation = useMutation({
Expand All @@ -157,7 +201,7 @@ export function PlanFormDialog({

const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (priceInvalid || annualInvalid) return;
if (pricingInvalid) return;
const overageRates = toOverageNumbers(overage);

if (isEdit && plan) {
Expand Down Expand Up @@ -252,7 +296,7 @@ export function PlanFormDialog({
label="Monthly base price"
hint="Canonical monthly rate; the term price for monthly plans."
required
error={priceInvalid ? "Must be a non-negative number." : undefined}
error={priceError}
>
<Input
id="pf-monthlyBasePrice"
Expand All @@ -275,7 +319,7 @@ export function PlanFormDialog({
id="pf-annualPrice"
label="Annual price"
hint="Per yearly term. Blank → 12× monthly."
error={annualInvalid ? "Must be a non-negative number." : undefined}
error={annualError}
>
<Input
id="pf-annualPrice"
Expand All @@ -299,7 +343,12 @@ export function PlanFormDialog({
<div className="h-px bg-[var(--color-border)] opacity-60" />
<div className="grid gap-4 sm:grid-cols-2">
{OVERAGE_RESOURCES.map((res) => (
<Field key={res.key} id={`pf-overage-${res.key}`} label={res.label}>
<Field
key={res.key}
id={`pf-overage-${res.key}`}
label={res.label}
error={overageErrors[res.key]}
>
<Input
id={`pf-overage-${res.key}`}
value={overage[res.key] ?? ""}
Expand All @@ -317,7 +366,7 @@ export function PlanFormDialog({
<Button type="button" variant="outline" onClick={onClose} disabled={pending}>
Cancel
</Button>
<Button type="submit" disabled={pending || keyInvalid || priceInvalid || annualInvalid}>
<Button type="submit" disabled={pending || keyInvalid || pricingInvalid}>
{pending ? "Saving…" : isEdit ? "Save changes" : "Create plan"}
</Button>
</DialogFooter>
Expand Down
28 changes: 0 additions & 28 deletions clients/admin/src/components/layout/topbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,31 +101,6 @@ function SimpleMenuItem({
);
}

// ─────────────────────────────────────────────────────────────────────────────
// TenantChip — tenant indicator in the topbar right zone.
// ─────────────────────────────────────────────────────────────────────────────

function TenantChip({ tenant }: { tenant?: string }) {
return (
<div
className="hidden items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-muted)] px-2.5 py-1 md:inline-flex"
title="Active tenant"
>
<span
aria-hidden
className="inline-flex h-1.5 w-1.5 rounded-full bg-[var(--color-success)]"
style={{ color: "var(--color-success)" }}
/>
<span className="font-mono text-[10px] font-semibold uppercase tracking-wider text-[var(--color-muted-foreground)]">
tenant
</span>
<span className="font-mono text-[12px] font-medium tracking-tight text-[var(--color-foreground)]">
{tenant ?? "—"}
</span>
</div>
);
}

// ─────────────────────────────────────────────────────────────────────────────
// Topbar
// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -155,9 +130,6 @@ export function Topbar() {
{/* Spacer pushes right-side actions to the trailing edge */}
<div className="flex-1" />

{/* Tenant chip */}
<TenantChip tenant={user?.tenant} />

{/* Notification bell */}
<NotificationBell />

Expand Down
Loading
Loading