diff --git a/src/app/[locale]/admin/billing/invoice-drafts/[id]/page.tsx b/src/app/[locale]/admin/billing/invoice-drafts/[id]/page.tsx
new file mode 100644
index 0000000..861eb20
--- /dev/null
+++ b/src/app/[locale]/admin/billing/invoice-drafts/[id]/page.tsx
@@ -0,0 +1,59 @@
+import { notFound, redirect } from "next/navigation";
+import { getTranslations } from "next-intl/server";
+import { getSessionUser } from "@/lib/session";
+import { getInvoiceDraftById, getOrgBilling } from "@/lib/db";
+import { BackLink } from "@/components/ui/back-link";
+import { CustomInvoiceEditor } from "@/components/admin/billing/custom-invoice-editor";
+
+/**
+ * /admin/billing/invoice-drafts/[id] — full editor for an
+ * in-progress custom invoice.
+ *
+ * Phase 8. Server-loads the draft + the org's billing snapshot
+ * (used to display the bill-to block preview), then hands off to
+ * the client editor for the interactive line-management UI.
+ *
+ * The snapshot is loaded read-only for display. The actual VAT
+ * computation happens server-side at issue time via
+ * computeCustomInvoiceTotals, which re-reads the same snapshot.
+ * That two-time read is intentional: the editor's preview math
+ * is a hint, the issue-time read is authoritative — if the
+ * customer updates their billing address between Draft and Issue,
+ * the invoice reflects the new address.
+ */
+export default async function InvoiceDraftEditorPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const user = await getSessionUser();
+ if (!user) redirect("/login");
+ if (!user.isPlatform) redirect("/dashboard");
+ const t = await getTranslations("adminBilling");
+
+ const { id } = await params;
+ const draft = await getInvoiceDraftById(id);
+ if (!draft) notFound();
+ const orgBilling = await getOrgBilling(draft.zitadelOrgId).catch(() => null);
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/app/[locale]/admin/billing/invoice-drafts/page.tsx b/src/app/[locale]/admin/billing/invoice-drafts/page.tsx
new file mode 100644
index 0000000..f15c66d
--- /dev/null
+++ b/src/app/[locale]/admin/billing/invoice-drafts/page.tsx
@@ -0,0 +1,58 @@
+import { redirect } from "next/navigation";
+import { getTranslations } from "next-intl/server";
+import { getSessionUser } from "@/lib/session";
+import { listAllInvoiceDrafts } from "@/lib/db";
+import { listTenants } from "@/lib/k8s";
+import { BackLink } from "@/components/ui/back-link";
+import { DraftList } from "@/components/admin/billing/draft-list";
+
+/**
+ * /admin/billing/invoice-drafts — list of all open custom-invoice
+ * drafts across orgs.
+ *
+ * Phase 8. Each draft is a JSONB blob the admin is composing into
+ * an invoice; visible only to platform admins. From here the admin
+ * can resume editing or discard.
+ *
+ * Building an org-name map by reading tenant labels (same approach
+ * as the existing /admin/billing/orgs endpoint) so the table can
+ * show "Customer X" instead of a raw ZITADEL org id.
+ */
+export default async function AdminInvoiceDraftsPage() {
+ const user = await getSessionUser();
+ if (!user) redirect("/login");
+ if (!user.isPlatform) redirect("/dashboard");
+ const t = await getTranslations("adminBilling");
+
+ const [drafts, tenants] = await Promise.all([
+ listAllInvoiceDrafts(),
+ listTenants().catch(() => []),
+ ]);
+
+ // Build org-id → company-name map from tenant labels. Same shape
+ // the existing /api/admin/billing/orgs uses. Falls back to the
+ // raw org id when we don't have a tenant label match.
+ const orgNameMap: Record = {};
+ for (const t of tenants) {
+ const oid = t.metadata.labels?.["pieced.ch/zitadel-org-id"];
+ const company = t.spec?.billing?.companyName;
+ if (oid && company && !orgNameMap[oid]) {
+ orgNameMap[oid] = company;
+ }
+ }
+
+ return (
+
+
+
+
+ {t("draftsPageTitle")}
+
+
+ {t("draftsPageSubtitle")}
+
+
+
+
+ );
+}
diff --git a/src/app/[locale]/admin/billing/invoices/new/page.tsx b/src/app/[locale]/admin/billing/invoices/new/page.tsx
new file mode 100644
index 0000000..8b46c54
--- /dev/null
+++ b/src/app/[locale]/admin/billing/invoices/new/page.tsx
@@ -0,0 +1,72 @@
+import { redirect } from "next/navigation";
+import { getTranslations } from "next-intl/server";
+import { getSessionUser } from "@/lib/session";
+import { listTenants } from "@/lib/k8s";
+import { getOrgBilling } from "@/lib/db";
+import { BackLink } from "@/components/ui/back-link";
+import { NewInvoiceForm } from "@/components/admin/billing/new-invoice-form";
+
+/**
+ * /admin/billing/invoices/new — entry point for the custom-invoice
+ * flow. The admin picks an org, clicks Continue, and lands on the
+ * editor at /admin/billing/invoice-drafts/.
+ *
+ * Phase 8. Org list is built from tenant labels + each org's
+ * billing config (we need the company name and the
+ * has-billing-snapshot flag to gate the picker — orgs without a
+ * snapshot can't be invoiced until they complete onboarding or
+ * admin sets the billing info manually).
+ */
+export default async function NewInvoicePage() {
+ const user = await getSessionUser();
+ if (!user) redirect("/login");
+ if (!user.isPlatform) redirect("/dashboard");
+ const t = await getTranslations("adminBilling");
+
+ // Tenants give us org membership; getOrgBilling per org gives us
+ // the snapshot status. We dedupe by org id since one org can own
+ // many tenants.
+ const tenants = await listTenants();
+ const orgIds = new Set();
+ for (const tnt of tenants) {
+ const oid = tnt.metadata.labels?.["pieced.ch/zitadel-org-id"];
+ if (oid) orgIds.add(oid);
+ }
+ const orgs = await Promise.all(
+ Array.from(orgIds).map(async (oid) => {
+ const billing = await getOrgBilling(oid).catch(() => null);
+ return {
+ zitadelOrgId: oid,
+ companyName: billing?.companyName ?? null,
+ country: billing?.country ?? null,
+ hasBillingAddress: !!billing && !!billing.companyName,
+ };
+ })
+ );
+ // Sort: orgs with billing first (admin's most likely target),
+ // then alphabetically by company name.
+ orgs.sort((a, b) => {
+ if (a.hasBillingAddress !== b.hasBillingAddress) {
+ return a.hasBillingAddress ? -1 : 1;
+ }
+ return (a.companyName ?? "").localeCompare(b.companyName ?? "");
+ });
+
+ return (
+
+
+
+
+ {t("newInvoicePageTitle")}
+
+
+ {t("newInvoicePageSubtitle")}
+
+
+
+
+ );
+}
diff --git a/src/app/api/admin/billing/invoice-drafts/[id]/issue/route.ts b/src/app/api/admin/billing/invoice-drafts/[id]/issue/route.ts
new file mode 100644
index 0000000..dbc9bd1
--- /dev/null
+++ b/src/app/api/admin/billing/invoice-drafts/[id]/issue/route.ts
@@ -0,0 +1,64 @@
+import { NextResponse } from "next/server";
+import { getSessionUser, requirePlatformRole } from "@/lib/session";
+import {
+ CustomInvoiceValidationError,
+ issueCustomInvoiceDraft,
+} from "@/lib/billing";
+import { safeError } from "@/lib/errors";
+
+/**
+ * POST /api/admin/billing/invoice-drafts/[id]/issue
+ *
+ * Phase 8. Convert a draft into a real invoice:
+ * - Validate payload (must have lines, valid dates, billing snapshot)
+ * - Allocate invoice number from the shared year-scoped counter
+ * - Persist invoice with source='custom'
+ * - Render PDF
+ * - Email customer
+ * - Delete the draft
+ *
+ * Returns the issued Invoice on success. Errors map cleanly to
+ * HTTP codes:
+ * 400 — validation failure (CustomInvoiceValidationError)
+ * 404 — draft id doesn't exist (also CustomInvoiceValidationError
+ * since the orchestrator can't tell apart "draft missing"
+ * from "invalid input" — the message string discriminates)
+ * 500 — anything else (DB error, Stripe error not applicable here)
+ *
+ * Idempotency: this endpoint is NOT idempotent. Issuing twice
+ * allocates two invoice numbers. The admin UI disables the submit
+ * button while in-flight, but for safety the backend handles
+ * double-submit by failing on the second call (the draft was
+ * deleted by the first).
+ */
+export async function POST(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ let user;
+ try {
+ await requirePlatformRole();
+ user = await getSessionUser();
+ } catch {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+ if (!user) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ const { id } = await params;
+ try {
+ const invoice = await issueCustomInvoiceDraft({
+ draftId: id,
+ issuedBy: user.id,
+ });
+ return NextResponse.json({ invoice });
+ } catch (e) {
+ if (e instanceof CustomInvoiceValidationError) {
+ return NextResponse.json({ error: e.message }, { status: 400 });
+ }
+ return NextResponse.json(
+ { error: safeError(e, "Failed to issue custom invoice") },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/admin/billing/invoice-drafts/[id]/preview/route.ts b/src/app/api/admin/billing/invoice-drafts/[id]/preview/route.ts
new file mode 100644
index 0000000..8c1d592
--- /dev/null
+++ b/src/app/api/admin/billing/invoice-drafts/[id]/preview/route.ts
@@ -0,0 +1,52 @@
+import { NextResponse } from "next/server";
+import { requirePlatformRole } from "@/lib/session";
+import {
+ CustomInvoiceValidationError,
+ renderCustomDraftPreview,
+} from "@/lib/billing";
+import { safeError } from "@/lib/errors";
+
+/**
+ * GET /api/admin/billing/invoice-drafts/[id]/preview
+ *
+ * Phase 8. Render the current draft as a PDF without persisting an
+ * invoice. The bytes are returned inline so the browser displays
+ * the document in a new tab. The invoice number on the rendered
+ * PDF is the placeholder "DRAFT" — no real number is allocated.
+ *
+ * Useful for the admin's "Review" step in the draft → review →
+ * issue flow.
+ */
+export async function GET(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ await requirePlatformRole();
+ } catch {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+ const { id } = await params;
+ try {
+ const pdf = await renderCustomDraftPreview(id);
+ return new NextResponse(new Uint8Array(pdf), {
+ status: 200,
+ headers: {
+ "Content-Type": "application/pdf",
+ // Inline so the browser displays the PDF immediately. The
+ // filename is a guide — most browsers ignore it for inline
+ // disposition but it shows on the "Save as" dialog.
+ "Content-Disposition": `inline; filename="invoice-draft-${id}.pdf"`,
+ "Cache-Control": "no-store",
+ },
+ });
+ } catch (e) {
+ if (e instanceof CustomInvoiceValidationError) {
+ return NextResponse.json({ error: e.message }, { status: 400 });
+ }
+ return NextResponse.json(
+ { error: safeError(e, "Failed to render preview") },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/admin/billing/invoice-drafts/[id]/route.ts b/src/app/api/admin/billing/invoice-drafts/[id]/route.ts
new file mode 100644
index 0000000..d99a7e0
--- /dev/null
+++ b/src/app/api/admin/billing/invoice-drafts/[id]/route.ts
@@ -0,0 +1,120 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { requirePlatformRole } from "@/lib/session";
+import {
+ deleteInvoiceDraft,
+ getInvoiceDraftById,
+ updateInvoiceDraft,
+} from "@/lib/db";
+import { safeError } from "@/lib/errors";
+import type { CustomInvoiceDraftPayload } from "@/types";
+
+/**
+ * /api/admin/billing/invoice-drafts/[id]
+ *
+ * Phase 8.
+ *
+ * GET — fetch one draft
+ * PUT — overwrite the payload (full replace, not patch)
+ * DELETE — discard the draft
+ *
+ * All require platform admin. The org boundary is *not* enforced
+ * here: a platform admin can edit any draft regardless of which
+ * org it targets. If we ever introduce a per-org admin role,
+ * scope filtering would go in this file.
+ */
+
+const lineSchema = z.object({
+ description: z.string().trim().min(1).max(500),
+ quantity: z.number().finite(),
+ unitPriceChf: z.number().finite(),
+});
+
+const payloadSchema = z.object({
+ issueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
+ dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
+ locale: z.enum(["de", "en", "fr", "it"]),
+ paymentMethod: z.enum(["invoice", "card"]),
+ adminNotes: z.string().max(2000).optional(),
+ lines: z.array(lineSchema).max(100),
+});
+
+export async function GET(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ await requirePlatformRole();
+ } catch {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+ const { id } = await params;
+ try {
+ const draft = await getInvoiceDraftById(id);
+ if (!draft) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+ return NextResponse.json({ draft });
+ } catch (e) {
+ return NextResponse.json(
+ { error: safeError(e, "Failed to load draft") },
+ { status: 500 }
+ );
+ }
+}
+
+export async function PUT(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ await requirePlatformRole();
+ } catch {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+ const { id } = await params;
+ const body = await request.json().catch(() => ({}));
+ const parsed = payloadSchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: "Invalid request", details: parsed.error.flatten() },
+ { status: 400 }
+ );
+ }
+ try {
+ const updated = await updateInvoiceDraft(
+ id,
+ parsed.data as CustomInvoiceDraftPayload
+ );
+ if (!updated) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+ return NextResponse.json({ draft: updated });
+ } catch (e) {
+ return NextResponse.json(
+ { error: safeError(e, "Failed to update draft") },
+ { status: 500 }
+ );
+ }
+}
+
+export async function DELETE(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ await requirePlatformRole();
+ } catch {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+ const { id } = await params;
+ try {
+ const deleted = await deleteInvoiceDraft(id);
+ return NextResponse.json({ deleted });
+ } catch (e) {
+ return NextResponse.json(
+ { error: safeError(e, "Failed to delete draft") },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/admin/billing/invoice-drafts/route.ts b/src/app/api/admin/billing/invoice-drafts/route.ts
new file mode 100644
index 0000000..c4c96af
--- /dev/null
+++ b/src/app/api/admin/billing/invoice-drafts/route.ts
@@ -0,0 +1,94 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { requirePlatformRole, getSessionUser } from "@/lib/session";
+import {
+ createInvoiceDraft,
+ listAllInvoiceDrafts,
+} from "@/lib/db";
+import { safeError } from "@/lib/errors";
+import type { CustomInvoiceDraftPayload } from "@/types";
+
+/**
+ * /api/admin/billing/invoice-drafts
+ *
+ * Phase 8. Drafts for the admin "New invoice" flow.
+ *
+ * GET — list all open drafts across all orgs, newest-touched first.
+ * POST — create a new draft for an org with an initial (possibly
+ * empty) payload. Returns the inserted draft.
+ *
+ * Both require platform admin. Drafts have no customer-facing
+ * surface: they aren't reachable from /billing or any non-admin
+ * route.
+ */
+
+const lineSchema = z.object({
+ description: z.string().trim().min(1).max(500),
+ quantity: z.number().finite(),
+ unitPriceChf: z.number().finite(),
+});
+
+const payloadSchema = z.object({
+ issueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
+ dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
+ locale: z.enum(["de", "en", "fr", "it"]),
+ paymentMethod: z.enum(["invoice", "card"]),
+ adminNotes: z.string().max(2000).optional(),
+ lines: z.array(lineSchema).max(100),
+});
+
+const createSchema = z.object({
+ zitadelOrgId: z.string().trim().min(1),
+ payload: payloadSchema,
+});
+
+export async function GET() {
+ try {
+ await requirePlatformRole();
+ } catch {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+ try {
+ const drafts = await listAllInvoiceDrafts();
+ return NextResponse.json({ drafts });
+ } catch (e) {
+ return NextResponse.json(
+ { error: safeError(e, "Failed to list drafts") },
+ { status: 500 }
+ );
+ }
+}
+
+export async function POST(request: Request) {
+ let user;
+ try {
+ await requirePlatformRole();
+ user = await getSessionUser();
+ } catch {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+ if (!user) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ const body = await request.json().catch(() => ({}));
+ const parsed = createSchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: "Invalid request", details: parsed.error.flatten() },
+ { status: 400 }
+ );
+ }
+ try {
+ const draft = await createInvoiceDraft({
+ zitadelOrgId: parsed.data.zitadelOrgId,
+ createdBy: user.id,
+ payload: parsed.data.payload as CustomInvoiceDraftPayload,
+ });
+ return NextResponse.json({ draft });
+ } catch (e) {
+ return NextResponse.json(
+ { error: safeError(e, "Failed to create draft") },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/components/admin/billing/custom-invoice-editor.tsx b/src/components/admin/billing/custom-invoice-editor.tsx
new file mode 100644
index 0000000..d6f47c5
--- /dev/null
+++ b/src/components/admin/billing/custom-invoice-editor.tsx
@@ -0,0 +1,537 @@
+"use client";
+
+import { useState, useMemo, useCallback } from "react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { Card, CardHeader } from "@/components/ui/card";
+import type {
+ CustomInvoiceDraftLine,
+ CustomInvoiceDraftPayload,
+ InvoiceDraftRecord,
+ OrgBilling,
+} from "@/types";
+
+interface Props {
+ draft: InvoiceDraftRecord;
+ orgBilling: OrgBilling | null;
+}
+
+const LOCALE_OPTIONS = [
+ { value: "de", label: "Deutsch" },
+ { value: "en", label: "English" },
+ { value: "fr", label: "Français" },
+ { value: "it", label: "Italiano" },
+];
+
+/**
+ * Custom invoice editor — Phase 8.
+ *
+ * Local state mirrors the persisted payload. Save persists the
+ * current state via PUT. Preview re-renders the PDF in-memory (no
+ * persistence). Issue allocates the invoice number and emails the
+ * customer.
+ *
+ * VAT preview is computed client-side from the country in the org
+ * billing snapshot — it's an estimate for the admin's eye, not
+ * authoritative. The server recomputes at issue time using the
+ * same vatRateForAddress() helper to ensure consistency.
+ *
+ * Discount/Rabatt is supported via a row with a negative
+ * unitPriceChf. The "Add discount" button seeds a new row with
+ * quantity 1 and a -50 placeholder to nudge the admin toward the
+ * intended sign.
+ */
+export function CustomInvoiceEditor({ draft, orgBilling }: Props) {
+ const t = useTranslations("adminBilling");
+ const router = useRouter();
+
+ // Editable state — initialized from the draft payload.
+ const [issueDate, setIssueDate] = useState(draft.payload.issueDate);
+ const [dueDate, setDueDate] = useState(draft.payload.dueDate);
+ const [locale, setLocale] = useState<"de" | "en" | "fr" | "it">(
+ draft.payload.locale
+ );
+ const [paymentMethod, setPaymentMethod] = useState<"invoice" | "card">(
+ draft.payload.paymentMethod
+ );
+ const [adminNotes, setAdminNotes] = useState(draft.payload.adminNotes ?? "");
+ const [lines, setLines] = useState(
+ draft.payload.lines.length > 0
+ ? draft.payload.lines
+ : [{ description: "", quantity: 1, unitPriceChf: 0 }]
+ );
+
+ const [busy, setBusy] = useState(
+ null
+ );
+ const [error, setError] = useState("");
+ const [dirty, setDirty] = useState(false);
+
+ // Build current payload — used by every action.
+ const buildPayload = useCallback((): CustomInvoiceDraftPayload => {
+ return {
+ issueDate,
+ dueDate,
+ locale,
+ paymentMethod,
+ adminNotes: adminNotes.trim() ? adminNotes.trim() : undefined,
+ lines: lines.map((ln) => ({
+ description: ln.description,
+ quantity: Number(ln.quantity) || 0,
+ unitPriceChf: Number(ln.unitPriceChf) || 0,
+ })),
+ };
+ }, [issueDate, dueDate, locale, paymentMethod, adminNotes, lines]);
+
+ // Client-side VAT estimate. The auth-of-truth math runs server-side
+ // at issue time; this is just to show the admin what they're about
+ // to commit to.
+ const totals = useMemo(() => {
+ const subtotal = Math.round(
+ lines.reduce(
+ (s, ln) => s + (Number(ln.quantity) || 0) * (Number(ln.unitPriceChf) || 0),
+ 0
+ ) * 100
+ ) / 100;
+ // Country-based VAT estimate. Mirrors vatRateForAddress() —
+ // simplified because the editor doesn't know the platform
+ // pricing config. Defaults to 8.1 for CH/LI; 0 otherwise.
+ const country = (orgBilling?.country ?? "").toUpperCase();
+ let vatRate = 0;
+ if (country === "CH" || country === "LI") {
+ vatRate = 8.1;
+ } else if (orgBilling?.vatNumber) {
+ vatRate = 0; // reverse charge
+ } else {
+ vatRate = 0; // out of scope OR consumer (server will fix)
+ }
+ const vatAmount = Math.round(subtotal * (vatRate / 100) * 100) / 100;
+ const total = Math.round((subtotal + vatAmount) * 100) / 100;
+ return { subtotal, vatRate, vatAmount, total };
+ }, [lines, orgBilling]);
+
+ // Line management
+ const updateLine = (idx: number, patch: Partial) => {
+ setLines((prev) =>
+ prev.map((ln, i) => (i === idx ? { ...ln, ...patch } : ln))
+ );
+ setDirty(true);
+ };
+ const addLine = () => {
+ setLines((prev) => [
+ ...prev,
+ { description: "", quantity: 1, unitPriceChf: 0 },
+ ]);
+ setDirty(true);
+ };
+ const addDiscountLine = () => {
+ setLines((prev) => [
+ ...prev,
+ { description: t("editorRabattDefaultDescription"), quantity: 1, unitPriceChf: -50 },
+ ]);
+ setDirty(true);
+ };
+ const removeLine = (idx: number) => {
+ setLines((prev) => prev.filter((_, i) => i !== idx));
+ setDirty(true);
+ };
+
+ // Actions
+ const save = async (): Promise => {
+ setError("");
+ setBusy("save");
+ try {
+ const res = await fetch(
+ `/api/admin/billing/invoice-drafts/${draft.id}`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(buildPayload()),
+ }
+ );
+ const j = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
+ setDirty(false);
+ return true;
+ } catch (e: any) {
+ setError(e.message);
+ return false;
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ const preview = async () => {
+ // Save first if there are unsaved changes — otherwise the
+ // preview reflects stale data.
+ if (dirty) {
+ const ok = await save();
+ if (!ok) return;
+ }
+ // Open the preview in a new tab. The browser handles the PDF
+ // download/render natively; we don't need to fetch the bytes
+ // ourselves.
+ window.open(
+ `/api/admin/billing/invoice-drafts/${draft.id}/preview`,
+ "_blank",
+ "noopener"
+ );
+ };
+
+ const issue = async () => {
+ if (!confirm(t("editorIssueConfirm"))) return;
+ if (dirty) {
+ const ok = await save();
+ if (!ok) return;
+ }
+ setError("");
+ setBusy("issue");
+ try {
+ const res = await fetch(
+ `/api/admin/billing/invoice-drafts/${draft.id}/issue`,
+ { method: "POST" }
+ );
+ const j = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
+ // The draft was deleted server-side; go look at the new invoice.
+ router.push(`/admin/billing/invoices/${j.invoice.id}`);
+ } catch (e: any) {
+ setError(e.message);
+ setBusy(null);
+ }
+ };
+
+ const deleteDraft = async () => {
+ if (!confirm(t("editorDeleteConfirm"))) return;
+ setError("");
+ setBusy("delete");
+ try {
+ const res = await fetch(
+ `/api/admin/billing/invoice-drafts/${draft.id}`,
+ { method: "DELETE" }
+ );
+ if (!res.ok) {
+ const j = await res.json().catch(() => ({}));
+ throw new Error(j.error || `HTTP ${res.status}`);
+ }
+ router.push("/admin/billing/invoice-drafts");
+ } catch (e: any) {
+ setError(e.message);
+ setBusy(null);
+ }
+ };
+
+ // No billing snapshot = can't issue. Save still works so admin
+ // can come back once the customer has completed onboarding.
+ const canIssue =
+ !!orgBilling &&
+ lines.length > 0 &&
+ lines.every((ln) => ln.description.trim().length > 0);
+
+ return (
+
+ {/* Bill-to preview — read-only, sourced from the org's billing
+ snapshot. Issued at issue time. */}
+
+ {t("editorBillToHeading")}
+