Phase7b: Manual Invoice
Some checks failed
Build and Push / build (push) Failing after 59s

This commit is contained in:
2026-05-26 23:04:09 +02:00
parent 667617296b
commit ed915ec539
26 changed files with 2365 additions and 65 deletions

View File

@@ -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 (
<main className="max-w-5xl mx-auto px-6 py-8">
<BackLink
href="/admin/billing/invoice-drafts"
label={t("backToDrafts")}
/>
<div className="mb-6">
<h1 className="font-display text-2xl font-semibold accent-rule">
{t("editorPageTitle")}
</h1>
<p className="text-sm text-text-secondary mt-3">
{orgBilling?.companyName ?? draft.zitadelOrgId}
</p>
</div>
<CustomInvoiceEditor
draft={draft}
orgBilling={orgBilling}
/>
</main>
);
}

View File

@@ -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<string, string> = {};
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 (
<main className="max-w-5xl mx-auto px-6 py-8">
<BackLink href="/admin/billing" label={t("backToBilling")} />
<div className="mb-6">
<h1 className="font-display text-2xl font-semibold accent-rule">
{t("draftsPageTitle")}
</h1>
<p className="text-sm text-text-secondary mt-3">
{t("draftsPageSubtitle")}
</p>
</div>
<DraftList drafts={drafts} orgNameMap={orgNameMap} />
</main>
);
}

View File

@@ -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/<new-id>.
*
* 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<string>();
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 (
<main className="max-w-2xl mx-auto px-6 py-8">
<BackLink
href="/admin/billing/invoices"
label={t("backToInvoices")}
/>
<div className="mb-6">
<h1 className="font-display text-2xl font-semibold accent-rule">
{t("newInvoicePageTitle")}
</h1>
<p className="text-sm text-text-secondary mt-3">
{t("newInvoicePageSubtitle")}
</p>
</div>
<NewInvoiceForm orgs={orgs} />
</main>
);
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}