From ed915ec539653e2f0cb7a3debb80d33a075d0e86 Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 26 May 2026 23:04:09 +0200 Subject: [PATCH] Phase7b: Manual Invoice --- .../billing/invoice-drafts/[id]/page.tsx | 59 ++ .../admin/billing/invoice-drafts/page.tsx | 58 ++ .../admin/billing/invoices/new/page.tsx | 72 +++ .../invoice-drafts/[id]/issue/route.ts | 64 +++ .../invoice-drafts/[id]/preview/route.ts | 52 ++ .../billing/invoice-drafts/[id]/route.ts | 120 ++++ .../api/admin/billing/invoice-drafts/route.ts | 94 +++ .../admin/billing/custom-invoice-editor.tsx | 537 ++++++++++++++++++ src/components/admin/billing/draft-list.tsx | 145 +++++ .../admin/billing/invoice-detail-view.tsx | 12 +- .../admin/billing/invoices-table.tsx | 23 +- .../admin/billing/new-invoice-form.tsx | 166 ++++++ .../billing/customer-invoice-detail.tsx | 16 +- .../billing/customer-invoice-list.tsx | 16 +- .../billing/running-total-widget.tsx | 13 +- src/lib/billing-i18n.ts | 16 + src/lib/billing-pdf.tsx | 21 +- src/lib/billing.ts | 306 +++++++++- src/lib/db.ts | 255 ++++++++- src/lib/email.ts | 18 +- src/lib/stripe.ts | 8 +- src/messages/de.json | 66 ++- src/messages/en.json | 68 ++- src/messages/fr.json | 64 ++- src/messages/it.json | 64 ++- src/types/index.ts | 97 +++- 26 files changed, 2365 insertions(+), 65 deletions(-) create mode 100644 src/app/[locale]/admin/billing/invoice-drafts/[id]/page.tsx create mode 100644 src/app/[locale]/admin/billing/invoice-drafts/page.tsx create mode 100644 src/app/[locale]/admin/billing/invoices/new/page.tsx create mode 100644 src/app/api/admin/billing/invoice-drafts/[id]/issue/route.ts create mode 100644 src/app/api/admin/billing/invoice-drafts/[id]/preview/route.ts create mode 100644 src/app/api/admin/billing/invoice-drafts/[id]/route.ts create mode 100644 src/app/api/admin/billing/invoice-drafts/route.ts create mode 100644 src/components/admin/billing/custom-invoice-editor.tsx create mode 100644 src/components/admin/billing/draft-list.tsx create mode 100644 src/components/admin/billing/new-invoice-form.tsx 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 ( +
+ +
+

+ {t("editorPageTitle")} +

+

+ {orgBilling?.companyName ?? draft.zitadelOrgId} +

+
+ +
+ ); +} 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")} +
+ {orgBilling ? ( + <> +

{orgBilling.companyName}

+ {orgBilling.contactName && ( +

+ {orgBilling.contactName} +

+ )} +

+ {orgBilling.streetAddress}, {orgBilling.postalCode}{" "} + {orgBilling.city}, {orgBilling.country} +

+ {orgBilling.vatNumber && ( +

+ MWST/VAT: {orgBilling.vatNumber} +

+ )} +

+ {orgBilling.billingEmail} +

+ + ) : ( +

{t("editorNoBillingSnapshot")}

+ )} +
+
+ + {/* Dates + locale + payment method */} + + {t("editorMetadataHeading")} +
+
+ + { + setIssueDate(e.target.value); + setDirty(true); + }} + className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm" + /> +
+
+ + { + setDueDate(e.target.value); + setDirty(true); + }} + className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm" + /> +
+
+ + +
+
+ + +
+
+
+ + {/* Line editor */} + + {t("editorLinesHeading")} +
+ + + + + + + + + + + + {lines.map((ln, idx) => { + const amount = + Math.round( + (Number(ln.quantity) || 0) * + (Number(ln.unitPriceChf) || 0) * + 100 + ) / 100; + return ( + + + + + + + + ); + })} + +
{t("editorLineDescription")} + {t("editorLineQty")} + + {t("editorLineUnitPrice")} + + {t("editorLineAmount")} +
+ + updateLine(idx, { description: e.target.value }) + } + placeholder={t("editorLineDescriptionPlaceholder")} + className="w-full px-2 py-1.5 rounded border border-border bg-surface-2 text-sm" + maxLength={500} + /> + + + updateLine(idx, { + quantity: parseFloat(e.target.value) || 0, + }) + } + className="w-full px-2 py-1.5 rounded border border-border bg-surface-2 text-sm font-mono text-right" + /> + + + updateLine(idx, { + unitPriceChf: parseFloat(e.target.value) || 0, + }) + } + className="w-full px-2 py-1.5 rounded border border-border bg-surface-2 text-sm font-mono text-right" + /> + + + CHF {amount.toFixed(2)} + + + +
+
+ + +
+
+
+ + {/* Admin notes */} + + {t("editorNotesHeading")} +
+