Compare commits

...

7 Commits

Author SHA1 Message Date
e69b68b73c Phase7b: Manual Invoice
All checks were successful
Build and Push / build (push) Successful in 1m46s
2026-05-26 23:14:53 +02:00
41c1553b1f Phase7b: Manual Invoice
Some checks failed
Build and Push / build (push) Failing after 57s
2026-05-26 23:12:03 +02:00
38f4c3243e Phase7b: Manual Invoice
Some checks failed
Build and Push / build (push) Failing after 54s
2026-05-26 23:08:07 +02:00
ed915ec539 Phase7b: Manual Invoice
Some checks failed
Build and Push / build (push) Failing after 59s
2026-05-26 23:04:09 +02:00
667617296b Phase7: Void/Refund logic
All checks were successful
Build and Push / build (push) Successful in 1m43s
2026-05-25 22:59:18 +02:00
1c61111da3 Phase7: Void/Refund logic
All checks were successful
Build and Push / build (push) Successful in 1m46s
2026-05-25 22:52:54 +02:00
6fed5b083b Phase7: Void/Refund logic
All checks were successful
Build and Push / build (push) Successful in 1m42s
2026-05-25 22:39:27 +02:00
29 changed files with 2710 additions and 346 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,72 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { getOrgBilling, 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 (for the set of
* known orgs) + getOrgBilling per org (for the actual company name)
* 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 the set of distinct ZITADEL org ids from tenant labels,
// PLUS the set referenced by any current draft. Drafts may target
// orgs that don't have tenants yet (rare but possible), so we
// union both sources before fetching billing rows.
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);
}
for (const d of drafts) {
orgIds.add(d.zitadelOrgId);
}
// Look up billing in parallel — same pattern as
// /api/admin/billing/orgs uses. Failure for any single org is
// non-fatal (falls back to the raw id in the table).
const orgNamePairs = await Promise.all(
Array.from(orgIds).map(async (oid) => {
const billing = await getOrgBilling(oid).catch(() => null);
return [oid, billing?.companyName ?? null] as const;
})
);
const orgNameMap: Record<string, string> = {};
for (const [oid, name] of orgNamePairs) {
if (name) orgNameMap[oid] = name;
}
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 }
);
}
}

View File

@@ -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<CustomInvoiceDraftLine[]>(
draft.payload.lines.length > 0
? draft.payload.lines
: [{ description: "", quantity: 1, unitPriceChf: 0 }]
);
const [busy, setBusy] = useState<null | "save" | "preview" | "issue" | "delete">(
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<CustomInvoiceDraftLine>) => {
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<boolean> => {
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 (
<div className="flex flex-col gap-6">
{/* Bill-to preview — read-only, sourced from the org's billing
snapshot. Issued at issue time. */}
<Card>
<CardHeader>{t("editorBillToHeading")}</CardHeader>
<div className="p-4 text-sm">
{orgBilling ? (
<>
<p className="font-medium">{orgBilling.companyName}</p>
{orgBilling.contactName && (
<p className="text-text-secondary text-xs">
{orgBilling.contactName}
</p>
)}
<p className="text-text-secondary text-xs">
{orgBilling.streetAddress}, {orgBilling.postalCode}{" "}
{orgBilling.city}, {orgBilling.country}
</p>
{orgBilling.vatNumber && (
<p className="text-text-muted text-xs mt-1">
MWST/VAT: {orgBilling.vatNumber}
</p>
)}
<p className="text-text-muted text-xs">
{orgBilling.billingEmail}
</p>
</>
) : (
<p className="text-error">{t("editorNoBillingSnapshot")}</p>
)}
</div>
</Card>
{/* Dates + locale + payment method */}
<Card>
<CardHeader>{t("editorMetadataHeading")}</CardHeader>
<div className="p-4 grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="flex flex-col gap-1">
<label className="text-xs uppercase tracking-wider text-text-muted">
{t("editorIssueDateLabel")}
</label>
<input
type="date"
value={issueDate}
onChange={(e) => {
setIssueDate(e.target.value);
setDirty(true);
}}
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs uppercase tracking-wider text-text-muted">
{t("editorDueDateLabel")}
</label>
<input
type="date"
value={dueDate}
onChange={(e) => {
setDueDate(e.target.value);
setDirty(true);
}}
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs uppercase tracking-wider text-text-muted">
{t("editorLocaleLabel")}
</label>
<select
value={locale}
onChange={(e) => {
setLocale(e.target.value as "de" | "en" | "fr" | "it");
setDirty(true);
}}
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
>
{LOCALE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs uppercase tracking-wider text-text-muted">
{t("editorPaymentMethodLabel")}
</label>
<select
value={paymentMethod}
onChange={(e) => {
setPaymentMethod(e.target.value as "invoice" | "card");
setDirty(true);
}}
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
>
<option value="invoice">{t("editorPaymentInvoice")}</option>
<option value="card">{t("editorPaymentCard")}</option>
</select>
</div>
</div>
</Card>
{/* Line editor */}
<Card>
<CardHeader>{t("editorLinesHeading")}</CardHeader>
<div className="p-4">
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2 pr-3">{t("editorLineDescription")}</th>
<th className="pb-2 pr-3 w-20 text-right">
{t("editorLineQty")}
</th>
<th className="pb-2 pr-3 w-32 text-right">
{t("editorLineUnitPrice")}
</th>
<th className="pb-2 pr-3 w-32 text-right">
{t("editorLineAmount")}
</th>
<th className="pb-2 w-12"></th>
</tr>
</thead>
<tbody>
{lines.map((ln, idx) => {
const amount =
Math.round(
(Number(ln.quantity) || 0) *
(Number(ln.unitPriceChf) || 0) *
100
) / 100;
return (
<tr key={idx} className="border-t border-border">
<td className="py-2 pr-3">
<input
type="text"
value={ln.description}
onChange={(e) =>
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}
/>
</td>
<td className="py-2 pr-3">
<input
type="number"
step="0.01"
value={ln.quantity}
onChange={(e) =>
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"
/>
</td>
<td className="py-2 pr-3">
<input
type="number"
step="0.01"
value={ln.unitPriceChf}
onChange={(e) =>
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"
/>
</td>
<td className="py-2 pr-3 text-right font-mono text-sm whitespace-nowrap">
<span className={amount < 0 ? "text-error" : ""}>
CHF {amount.toFixed(2)}
</span>
</td>
<td className="py-2 text-right">
<button
onClick={() => removeLine(idx)}
className="text-text-muted hover:text-error text-lg leading-none"
title={t("editorLineRemove")}
type="button"
>
×
</button>
</td>
</tr>
);
})}
</tbody>
</table>
<div className="flex gap-2 mt-3">
<button
onClick={addLine}
type="button"
className="px-3 py-1.5 rounded-md border border-border text-sm hover:bg-surface-3"
>
+ {t("editorAddLine")}
</button>
<button
onClick={addDiscountLine}
type="button"
className="px-3 py-1.5 rounded-md border border-border text-sm hover:bg-surface-3 text-text-secondary"
title={t("editorAddDiscountHint")}
>
{t("editorAddDiscount")}
</button>
</div>
</div>
</Card>
{/* Admin notes */}
<Card>
<CardHeader>{t("editorNotesHeading")}</CardHeader>
<div className="p-4">
<textarea
value={adminNotes}
onChange={(e) => {
setAdminNotes(e.target.value);
setDirty(true);
}}
placeholder={t("editorNotesPlaceholder")}
rows={2}
maxLength={2000}
className="w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
/>
<p className="text-xs text-text-muted mt-1">
{t("editorNotesHint")}
</p>
</div>
</Card>
{/* Totals preview */}
<Card>
<CardHeader>{t("editorTotalsHeading")}</CardHeader>
<div className="p-4 max-w-sm ml-auto text-sm">
<div className="flex justify-between py-1">
<span className="text-text-muted">{t("editorSubtotal")}</span>
<span className="font-mono">CHF {totals.subtotal.toFixed(2)}</span>
</div>
<div className="flex justify-between py-1">
<span className="text-text-muted">
{t("editorVat")} ({totals.vatRate.toFixed(1)}%)
</span>
<span className="font-mono">CHF {totals.vatAmount.toFixed(2)}</span>
</div>
<div className="flex justify-between py-2 border-t border-border mt-1 font-medium">
<span>{t("editorTotal")}</span>
<span className="font-mono">CHF {totals.total.toFixed(2)}</span>
</div>
<p className="text-xs text-text-muted mt-2 italic">
{t("editorTotalsEstimateNote")}
</p>
</div>
</Card>
{/* Error + actions */}
{error && (
<div className="text-sm text-error border border-error/30 bg-error/10 rounded-md px-4 py-2">
{error}
</div>
)}
<div className="flex flex-wrap gap-2 justify-between items-center">
<button
onClick={deleteDraft}
disabled={busy !== null}
className="px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
type="button"
>
{busy === "delete" ? t("deleting") : t("editorDeleteBtn")}
</button>
<div className="flex gap-2 ml-auto">
<button
onClick={save}
disabled={busy !== null || !dirty}
className="px-4 py-2 rounded-md border border-border text-sm disabled:opacity-50"
type="button"
>
{busy === "save"
? t("saving")
: dirty
? t("editorSaveBtn")
: t("editorSavedBtn")}
</button>
<button
onClick={preview}
disabled={busy !== null || lines.length === 0}
className="px-4 py-2 rounded-md border border-border text-sm disabled:opacity-50"
type="button"
>
{busy === "preview" ? t("previewing") : t("editorPreviewBtn")}
</button>
<button
onClick={issue}
disabled={busy !== null || !canIssue}
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
type="button"
>
{busy === "issue" ? t("issuing") : t("editorIssueBtn")}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,145 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslations, useFormatter } from "next-intl";
import { Card } from "@/components/ui/card";
import type { InvoiceDraftRecord } from "@/types";
interface Props {
drafts: InvoiceDraftRecord[];
/** Map ZITADEL org id → company name for friendlier display. */
orgNameMap: Record<string, string>;
}
/**
* Renders the drafts table with per-row Edit / Delete actions.
*
* The total preview is the algebraic sum of line amounts (the same
* formula billing.computeCustomInvoiceTotals uses for the subtotal,
* minus VAT — which we don't know without the org's billing
* snapshot). It's a hint, not authoritative; the real total
* appears when the draft is issued.
*
* Empty state shows a clear CTA so a fresh admin knows where to
* start.
*/
export function DraftList({ drafts, orgNameMap }: Props) {
const t = useTranslations("adminBilling");
const fmt = useFormatter();
const router = useRouter();
const [busyId, setBusyId] = useState<string | null>(null);
const onDelete = async (id: string) => {
if (!confirm(t("draftDeleteConfirm"))) return;
setBusyId(id);
try {
const res = await fetch(`/api/admin/billing/invoice-drafts/${id}`, {
method: "DELETE",
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.error || `HTTP ${res.status}`);
}
router.refresh();
} catch (e: any) {
alert(e.message);
} finally {
setBusyId(null);
}
};
if (drafts.length === 0) {
return (
<Card>
<div className="p-6 text-center">
<p className="text-text-secondary mb-4">{t("draftsEmpty")}</p>
<Link
href="/admin/billing/invoices/new"
className="inline-block px-4 py-2 rounded-md bg-accent text-white text-sm"
>
{t("newInvoiceBtn")}
</Link>
</div>
</Card>
);
}
return (
<Card>
<div className="flex justify-end p-3 border-b border-border">
<Link
href="/admin/billing/invoices/new"
className="inline-block px-3 py-1.5 rounded-md bg-accent text-white text-sm"
>
{t("newInvoiceBtn")}
</Link>
</div>
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2 pl-3 pr-4">{t("draftOrgCol")}</th>
<th className="pb-2 pr-4">{t("draftIssueDateCol")}</th>
<th className="pb-2 pr-4 text-center">{t("draftLinesCol")}</th>
<th className="pb-2 pr-4 text-right">{t("draftSubtotalCol")}</th>
<th className="pb-2 pr-4">{t("draftUpdatedCol")}</th>
<th className="pb-2 pr-3 text-right">{t("draftActionsCol")}</th>
</tr>
</thead>
<tbody>
{drafts.map((d) => {
const subtotal = d.payload.lines.reduce(
(s, ln) =>
s +
Math.round(ln.quantity * ln.unitPriceChf * 100) / 100,
0
);
return (
<tr key={d.id} className="border-t border-border">
<td className="py-2 pl-3 pr-4">
<Link
href={`/admin/billing/invoice-drafts/${d.id}`}
className="hover:underline"
>
{orgNameMap[d.zitadelOrgId] ?? d.zitadelOrgId}
</Link>
</td>
<td className="py-2 pr-4 text-xs font-mono text-text-secondary whitespace-nowrap">
{d.payload.issueDate}
</td>
<td className="py-2 pr-4 text-center text-xs">
{d.payload.lines.length}
</td>
<td className="py-2 pr-4 text-right font-mono text-xs whitespace-nowrap">
CHF {subtotal.toFixed(2)}
</td>
<td className="py-2 pr-4 text-xs text-text-muted whitespace-nowrap">
{fmt.dateTime(new Date(d.updatedAt), {
dateStyle: "medium",
timeStyle: "short",
})}
</td>
<td className="py-2 pr-3 text-right">
<Link
href={`/admin/billing/invoice-drafts/${d.id}`}
className="text-accent hover:underline text-xs mr-3"
>
{t("editBtn")}
</Link>
<button
onClick={() => onDelete(d.id)}
disabled={busyId === d.id}
className="text-error hover:underline text-xs disabled:opacity-50"
>
{busyId === d.id ? t("deleting") : t("deleteBtn")}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</Card>
);
}

View File

@@ -203,10 +203,14 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
</h1>
<div className="flex items-center gap-3 mt-3 text-sm">
<StatusPill status={invoice.status} />
<span className="text-text-muted">
{invoice.periodStart} {invoice.periodEnd}
</span>
<span className="text-text-muted">·</span>
{invoice.periodStart && invoice.periodEnd && (
<>
<span className="text-text-muted">
{invoice.periodStart} {invoice.periodEnd}
</span>
<span className="text-text-muted">·</span>
</>
)}
<span className="text-text-muted">
{t("dueOnLabel")}: {invoice.dueAt}
</span>
@@ -356,45 +360,60 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
max: remainingRefundable.toFixed(2),
})}
</div>
<div className="flex items-center gap-2">
<input
type="number"
step="0.01"
min="0.01"
max={remainingRefundable}
placeholder="CHF"
value={refundAmount}
onChange={(e) => setRefundAmount(e.target.value)}
className="w-28 px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm font-mono"
autoFocus
/>
<input
type="text"
placeholder={t("refundReasonPlaceholder")}
value={refundReason}
onChange={(e) => setRefundReason(e.target.value)}
maxLength={500}
className="flex-grow px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
/>
<button
onClick={refundInvoice}
disabled={busyAction !== null}
className="px-3 py-1.5 rounded-md bg-error text-white text-sm disabled:opacity-50"
>
{busyAction === "refund"
? t("saving")
: t("confirmRefund")}
</button>
<button
onClick={() => {
setRefundOpen(false);
setRefundAmount("");
setRefundReason("");
}}
className="px-3 py-1.5 rounded-md border border-border text-sm"
>
{t("cancel")}
</button>
<div className="flex items-center gap-4 flex-wrap">
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-text-muted">
{t("refundAmountLabel")}
</label>
<input
type="number"
step="0.01"
min="0.01"
max={remainingRefundable}
placeholder="CHF"
value={refundAmount}
onChange={(e) => setRefundAmount(e.target.value)}
className="w-32 px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm font-mono"
autoFocus
/>
<span className="text-[10px] text-text-muted italic">
{t("refundAmountInclVatHint")}
</span>
</div>
<div className="flex flex-col gap-1 flex-grow min-w-[200px]">
<label className="text-[10px] uppercase tracking-wider text-text-muted">
{t("refundReasonLabel")}
</label>
<input
type="text"
placeholder={t("refundReasonPlaceholder")}
value={refundReason}
onChange={(e) => setRefundReason(e.target.value)}
maxLength={500}
className="w-full px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
/>
</div>
<div className="flex items-center gap-2 self-end">
<button
onClick={refundInvoice}
disabled={busyAction !== null}
className="px-3 py-1.5 rounded-md bg-error text-white text-sm disabled:opacity-50"
>
{busyAction === "refund"
? t("saving")
: t("confirmRefund")}
</button>
<button
onClick={() => {
setRefundOpen(false);
setRefundAmount("");
setRefundReason("");
}}
className="px-3 py-1.5 rounded-md border border-border text-sm"
>
{t("cancel")}
</button>
</div>
</div>
</div>
)}
@@ -447,34 +466,34 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2">{t("creditNoteNumberHeader")}</th>
<th className="pb-2">{t("creditNoteKindHeader")}</th>
<th className="pb-2 text-right">
<th className="pb-2 pr-4">{t("creditNoteNumberHeader")}</th>
<th className="pb-2 pr-4">{t("creditNoteKindHeader")}</th>
<th className="pb-2 pr-4 text-right">
{t("creditNoteAmountHeader")}
</th>
<th className="pb-2">{t("creditNoteReasonHeader")}</th>
<th className="pb-2">{t("creditNoteIssuedHeader")}</th>
<th className="pb-2 pr-4">{t("creditNoteReasonHeader")}</th>
<th className="pb-2 pr-4">{t("creditNoteIssuedHeader")}</th>
<th className="pb-2 text-right">{t("creditNotePdfHeader")}</th>
</tr>
</thead>
<tbody>
{creditNotes.map((cn) => (
<tr key={cn.id} className="border-t border-border">
<td className="py-2 font-mono text-xs">
<td className="py-2 pr-4 font-mono text-xs">
{cn.creditNoteNumber}
</td>
<td className="py-2">
<td className="py-2 pr-4">
<span className="px-2 py-0.5 rounded text-xs text-error bg-error/10">
{t(`creditNoteKind_${cn.kind}` as any)}
</span>
</td>
<td className="py-2 text-right font-mono">
<td className="py-2 pr-4 text-right font-mono whitespace-nowrap">
CHF {cn.amountChf.toFixed(2)}
</td>
<td className="py-2 text-text-secondary text-xs">
<td className="py-2 pr-4 text-text-secondary text-xs">
{cn.reason ?? "—"}
</td>
<td className="py-2 text-xs text-text-muted">
<td className="py-2 pr-4 text-xs text-text-muted whitespace-nowrap">
{cn.issuedAt.slice(0, 10)}
</td>
<td className="py-2 text-right">

View File

@@ -100,6 +100,23 @@ export function InvoicesTable({ initialInvoices }: Props) {
{t("loading")}
</span>
)}
{/* Phase 8: shortcuts to the custom-invoice flow. The
Drafts link is muted because most of the time it's
empty; New invoice is the prominent CTA. */}
<div className={`flex items-center gap-3 ${busy ? "" : "ml-auto"}`}>
<Link
href="/admin/billing/invoice-drafts"
className="text-xs text-text-muted hover:underline"
>
{t("draftsLink")}
</Link>
<Link
href="/admin/billing/invoices/new"
className="px-3 py-1.5 rounded-md bg-accent text-white text-sm"
>
+ {t("newInvoiceBtn")}
</Link>
</div>
</div>
</Card>
@@ -142,7 +159,11 @@ export function InvoicesTable({ initialInvoices }: Props) {
</div>
</td>
<td className="py-2 text-xs font-mono">
{inv.periodStart.slice(0, 7)}
{inv.periodStart
? inv.periodStart.slice(0, 7)
: inv.source === "custom"
? "—"
: ""}
</td>
<td className="py-2">
<StatusPill status={inv.status} />

View File

@@ -0,0 +1,166 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card";
interface OrgEntry {
zitadelOrgId: string;
companyName: string | null;
country: string | null;
hasBillingAddress: boolean;
}
interface Props {
orgs: OrgEntry[];
}
const LOCALE_OPTIONS = [
{ value: "de", label: "Deutsch" },
{ value: "en", label: "English" },
{ value: "fr", label: "Français" },
{ value: "it", label: "Italiano" },
];
/**
* Step 1 of the custom-invoice flow: pick an org. Creating the
* draft on the backend allocates an id we redirect to; the editor
* page then loads the draft and lets the admin add lines.
*
* The dropdown shows the company name when known, falling back to
* the raw org id. Orgs without a billing snapshot are visually
* marked and warn the admin — they can still create the draft but
* won't be able to issue until billing info is set.
*
* Default issue date = today; due date = today + 30 days. These
* are sensible defaults the editor can override.
*/
export function NewInvoiceForm({ orgs }: Props) {
const t = useTranslations("adminBilling");
const router = useRouter();
const [orgId, setOrgId] = useState(
orgs.find((o) => o.hasBillingAddress)?.zitadelOrgId ??
orgs[0]?.zitadelOrgId ??
""
);
const [locale, setLocale] = useState<"de" | "en" | "fr" | "it">("de");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const selected = orgs.find((o) => o.zitadelOrgId === orgId);
// Pick a locale default from the org's country if admin hasn't
// overridden — same heuristic the auto cron uses.
const onOrgChange = (newOrgId: string) => {
setOrgId(newOrgId);
const o = orgs.find((x) => x.zitadelOrgId === newOrgId);
const c = (o?.country ?? "").toUpperCase();
if (["CH", "LI", "AT", "DE"].includes(c)) setLocale("de");
else if (["FR", "BE", "LU"].includes(c)) setLocale("fr");
else if (c === "IT") setLocale("it");
else setLocale("en");
};
const onSubmit = async () => {
if (!orgId) {
setError(t("newInvoiceOrgRequired"));
return;
}
setError("");
setBusy(true);
try {
const today = new Date().toISOString().slice(0, 10);
const due = new Date();
due.setDate(due.getDate() + 30);
const dueIso = due.toISOString().slice(0, 10);
const res = await fetch("/api/admin/billing/invoice-drafts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
zitadelOrgId: orgId,
payload: {
issueDate: today,
dueDate: dueIso,
locale,
paymentMethod: "invoice",
lines: [],
},
}),
});
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
router.push(`/admin/billing/invoice-drafts/${j.draft.id}`);
} catch (e: any) {
setError(e.message);
setBusy(false);
}
};
return (
<Card>
<div className="p-5 flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label className="text-xs uppercase tracking-wider text-text-muted">
{t("newInvoiceOrgLabel")}
</label>
<select
value={orgId}
onChange={(e) => onOrgChange(e.target.value)}
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
>
<option value="">{t("newInvoiceOrgPlaceholder")}</option>
{orgs.map((o) => (
<option
key={o.zitadelOrgId}
value={o.zitadelOrgId}
disabled={!o.hasBillingAddress}
>
{o.companyName ?? o.zitadelOrgId}
{!o.hasBillingAddress
? ` (${t("newInvoiceOrgNoBilling")})`
: ""}
</option>
))}
</select>
{selected && !selected.hasBillingAddress && (
<p className="text-xs text-error mt-1">
{t("newInvoiceOrgBillingMissing")}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<label className="text-xs uppercase tracking-wider text-text-muted">
{t("newInvoiceLocaleLabel")}
</label>
<select
value={locale}
onChange={(e) =>
setLocale(e.target.value as "de" | "en" | "fr" | "it")
}
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
>
{LOCALE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
{error && <div className="text-sm text-error">{error}</div>}
<div className="flex justify-end gap-2 pt-2">
<button
onClick={onSubmit}
disabled={busy || !orgId || !selected?.hasBillingAddress}
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
>
{busy ? t("creating") : t("newInvoiceContinueBtn")}
</button>
</div>
</div>
</Card>
);
}

View File

@@ -39,11 +39,11 @@ export function CustomerCreditNoteList({ creditNotes }: Props) {
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2">{t("creditNoteNumberCol")}</th>
<th className="pb-2">{t("creditNoteInvoiceCol")}</th>
<th className="pb-2">{t("creditNoteIssuedCol")}</th>
<th className="pb-2 text-right">{t("creditNoteAmountCol")}</th>
<th className="pb-2 text-right">{t("creditNoteKindCol")}</th>
<th className="pb-2 pr-4">{t("creditNoteNumberCol")}</th>
<th className="pb-2 pr-4">{t("creditNoteInvoiceCol")}</th>
<th className="pb-2 pr-4">{t("creditNoteIssuedCol")}</th>
<th className="pb-2 pr-4 text-right">{t("creditNoteAmountCol")}</th>
<th className="pb-2 pr-4 text-right">{t("creditNoteKindCol")}</th>
<th className="pb-2 text-right">{t("creditNotePdfCol")}</th>
</tr>
</thead>
@@ -53,19 +53,19 @@ export function CustomerCreditNoteList({ creditNotes }: Props) {
key={cn.id}
className="border-t border-border align-middle"
>
<td className="py-2 font-mono text-xs">
<td className="py-2 pr-4 font-mono text-xs">
{cn.creditNoteNumber}
</td>
<td className="py-2 font-mono text-xs text-text-secondary">
<td className="py-2 pr-4 font-mono text-xs text-text-secondary">
{cn.invoiceNumber}
</td>
<td className="py-2 text-text-secondary">
<td className="py-2 pr-4 text-text-secondary whitespace-nowrap">
{fmt.dateTime(new Date(cn.issuedAt), { dateStyle: "medium" })}
</td>
<td className="py-2 text-right font-mono">
<td className="py-2 pr-4 text-right font-mono whitespace-nowrap">
CHF {cn.amountChf.toFixed(2)}
</td>
<td className="py-2 text-right">
<td className="py-2 pr-4 text-right">
<span
className={`px-2 py-0.5 rounded text-xs ${
kindColors[cn.kind] ?? ""

View File

@@ -46,11 +46,17 @@ export function CustomerInvoiceDetail({ invoice, lines }: Props) {
{t(`status.${invoice.status}` as any)}
</span>
</div>
<p className="text-sm text-text-secondary">
{fmt.dateTime(new Date(invoice.periodStart), { dateStyle: "long" })}
<span className="text-text-muted mx-1"></span>
{fmt.dateTime(new Date(invoice.periodEnd), { dateStyle: "long" })}
</p>
{invoice.periodStart && invoice.periodEnd && (
<p className="text-sm text-text-secondary">
{fmt.dateTime(new Date(invoice.periodStart), {
dateStyle: "long",
})}
<span className="text-text-muted mx-1"></span>
{fmt.dateTime(new Date(invoice.periodEnd), {
dateStyle: "long",
})}
</p>
)}
</div>
<div className="flex items-start gap-2 flex-wrap">
{/* Phase 4: Pay-with-card available for open + overdue.

View File

@@ -71,9 +71,19 @@ export function CustomerInvoiceList({ invoices }: Props) {
</Link>
</td>
<td className="py-2 text-xs text-text-secondary">
{fmt.dateTime(new Date(inv.periodStart), { dateStyle: "medium" })}
<span className="text-text-muted mx-1"></span>
{fmt.dateTime(new Date(inv.periodEnd), { dateStyle: "medium" })}
{inv.periodStart && inv.periodEnd ? (
<>
{fmt.dateTime(new Date(inv.periodStart), {
dateStyle: "medium",
})}
<span className="text-text-muted mx-1"></span>
{fmt.dateTime(new Date(inv.periodEnd), {
dateStyle: "medium",
})}
</>
) : (
<span className="text-text-muted"></span>
)}
</td>
<td className="py-2 text-xs text-text-secondary">
{fmt.dateTime(new Date(inv.dueAt), { dateStyle: "medium" })}

View File

@@ -125,9 +125,16 @@ export function RunningTotalWidget({ isOwner }: Props) {
}
// draft
const draft = data.draft;
const periodLabel = `${fmt.dateTime(new Date(draft.periodStart), {
dateStyle: "long",
})} → ${fmt.dateTime(new Date(draft.periodEnd), { dateStyle: "long" })}`;
// Phase 8: InvoiceDraft.periodStart/End became nullable for the
// custom-invoice flow. The running-total widget only renders the
// auto-cron draft (always has a period), so the null branch is
// defensive — if we ever did hit it the label just collapses.
const periodLabel =
draft.periodStart && draft.periodEnd
? `${fmt.dateTime(new Date(draft.periodStart), {
dateStyle: "long",
})} → ${fmt.dateTime(new Date(draft.periodEnd), { dateStyle: "long" })}`
: "";
return (
<Card>
<div className="flex items-start justify-between gap-4 flex-wrap mb-3">

View File

@@ -153,5 +153,21 @@ export function formatLineDescription(
}[L];
return reason ? `${base}: ${reason}` : base;
}
// Phase 8: custom invoice lines. The description is what the
// admin typed in the editor — return it verbatim (no template,
// no locale-specific formatting). billing.ts persists the
// already-trimmed admin input into invoice_lines.description.
case "custom_line": {
const dRaw = (m as Record<string, unknown>)["description"];
if (typeof dRaw === "string" && dRaw.trim().length > 0) return dRaw;
// Fallback: the description column on the row itself. The
// PDF renderer hands us the line so it can read it directly
// — see how billing-pdf invokes formatLineDescription.
const onRow = (line as unknown as { description?: string }).description;
return onRow && onRow.trim().length > 0
? onRow
: { de: "Leistung", en: "Service", fr: "Service", it: "Servizio" }[L];
}
}
}

View File

@@ -31,44 +31,18 @@ import {
Text,
View,
StyleSheet,
Svg,
Polygon,
Polyline,
renderToBuffer,
} from "@react-pdf/renderer";
import type { Invoice, InvoiceLine, InvoiceLineKind } from "@/types";
import { BRAND, Logo } from "./pdf-brand";
// ---------------------------------------------------------------------------
// Brand constants — edit here to tweak look without touching layout
// Brand: imported from lib/pdf-brand. Edit there to change issuer
// info, colours, or the logo. Both billing-pdf.tsx and credit-note-pdf.tsx
// share the same source of truth so a brand change applies to every
// PDF the portal produces.
// ---------------------------------------------------------------------------
const BRAND = {
name: "PieCed IT",
// Primary emerald — matches the logo SVG fill (#10B981).
primary: "#10B981",
// Slightly darker emerald for headings.
primaryDark: "#0a8060",
textColor: "#1a1a1a",
mutedColor: "#666",
borderColor: "#d4d4d4",
// Issuer block — change these to your real legal info.
issuer: {
legalName: "PieCed IT",
addressLine1: "Cedric Mosimann",
addressLine2: "[Strasse Nr.]",
postalCity: "[PLZ] Basel",
country: "Switzerland",
email: "billing@pieced.ch",
web: "pieced.ch",
// Show "MWST-Nr. ..." on PDF when set.
vatNumber: null as string | null,
// Bank instructions — Phase 7 replaces with QR-bill.
bankName: "[Bank name]",
bankIban: "[CHxx xxxx xxxx xxxx xxxx x]",
bankBic: "[BIC]",
},
};
// ---------------------------------------------------------------------------
// Localized strings
// ---------------------------------------------------------------------------
@@ -133,6 +107,7 @@ const MESSAGES: Record<string, PdfStrings> = {
skill_usage: "Skill-Nutzung",
skill_setup: "Einrichtungsgebühr Skill",
adjustment: "Anpassung",
custom_line: "Leistungen",
},
reverseCharge:
"Steuerschuldnerschaft des Leistungsempfängers (Reverse Charge).",
@@ -166,6 +141,7 @@ const MESSAGES: Record<string, PdfStrings> = {
skill_usage: "Skill usage",
skill_setup: "Skill setup fee",
adjustment: "Adjustment",
custom_line: "Services",
},
reverseCharge:
"Reverse charge — VAT to be accounted for by the recipient.",
@@ -199,6 +175,7 @@ const MESSAGES: Record<string, PdfStrings> = {
skill_usage: "Utilisation Skill",
skill_setup: "Frais de configuration skill",
adjustment: "Ajustement",
custom_line: "Services",
},
reverseCharge:
"Autoliquidation — TVA à acquitter par le destinataire.",
@@ -232,6 +209,7 @@ const MESSAGES: Record<string, PdfStrings> = {
skill_usage: "Utilizzo Skill",
skill_setup: "Spese di attivazione skill",
adjustment: "Rettifica",
custom_line: "Servizi",
},
reverseCharge:
"Inversione contabile — IVA a carico del destinatario.",
@@ -358,62 +336,6 @@ const styles = StyleSheet.create({
});
// ---------------------------------------------------------------------------
// Logo — inlined SVG primitives
// ---------------------------------------------------------------------------
/**
* PieCed honeycomb logo. Re-renders the same 6-hex glyph as the
* portal's `public/pieced-logo.svg` using React-PDF's SVG support.
* Width/height are independent of the original viewBox so we can
* scale it without losing stroke quality.
*/
const Logo = ({ size = 60 }: { size?: number }) => (
<Svg width={size} height={size * (106 / 70)} viewBox="0 0 70 106">
{/* H1 solid */}
<Polygon
points="38.5,22.69 31.5,10.566 17.5,10.566 10.5,22.69 17.5,34.814 31.5,34.814"
fill="#10B981"
stroke="#10B981"
strokeWidth={1.6}
/>
{/* H2 outline */}
<Polygon
points="59.5,34.814 52.5,22.69 38.5,22.69 31.5,34.814 38.5,46.938 52.5,46.938"
fill="none"
stroke="#10B981"
strokeWidth={1.8}
/>
{/* H3 outline */}
<Polygon
points="38.5,46.938 31.5,34.814 17.5,34.814 10.5,46.938 17.5,59.062 31.5,59.062"
fill="none"
stroke="#10B981"
strokeWidth={1.8}
/>
{/* H4 solid */}
<Polygon
points="59.5,59.062 52.5,46.938 38.5,46.938 31.5,59.062 38.5,71.186 52.5,71.186"
fill="#10B981"
stroke="#10B981"
strokeWidth={1.6}
/>
{/* H5 partial */}
<Polyline
points="31.5,83.31 38.5,71.186 31.5,59.062 17.5,59.062 10.5,71.186"
fill="none"
stroke="#10B981"
strokeWidth={1.8}
/>
{/* H6 partial */}
<Polyline
points="59.5,83.31 52.5,71.186 38.5,71.186 31.5,83.31 38.5,95.434"
fill="none"
stroke="#10B981"
strokeWidth={1.8}
/>
</Svg>
);
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -517,11 +439,18 @@ const InvoicePdf: React.FC<InvoicePdfProps> = ({ invoice, lines }) => {
</Text>
</View>
<View style={styles.metaCol}>
<Text style={styles.metaLabel}>{s.period}</Text>
<Text style={styles.metaValue}>
{fmtDate(invoice.periodStart, invoice.locale)} {" "}
{fmtDate(invoice.periodEnd, invoice.locale)}
</Text>
{/* Phase 8: skip the billing-period block on custom
invoices (which aren't tied to a period). Due date
still renders. */}
{invoice.periodStart && invoice.periodEnd && (
<>
<Text style={styles.metaLabel}>{s.period}</Text>
<Text style={styles.metaValue}>
{fmtDate(invoice.periodStart, invoice.locale)} {" "}
{fmtDate(invoice.periodEnd, invoice.locale)}
</Text>
</>
)}
<Text style={styles.metaLabel}>{s.dueDate}</Text>
<Text style={styles.metaValue}>
{fmtDate(invoice.dueAt, invoice.locale)}

View File

@@ -31,9 +31,11 @@
import type {
CreditNote,
CustomInvoiceDraftPayload,
Invoice,
InvoiceBillingSnapshot,
InvoiceDraft,
InvoiceDraftRecord,
InvoiceLine,
InvoiceLineKind,
InvoicePaymentMethod,
@@ -48,7 +50,9 @@ import {
attachCreditNotePdf,
createCreditNote,
createInvoice,
deleteInvoiceDraft,
getInvoiceById,
getInvoiceDraftById,
getOrgBilling,
getOrgBillingConfig,
getPlatformPricing,
@@ -247,6 +251,9 @@ const EU_COUNTRIES = new Set([
/**
* Determine VAT rate from billing address and the platform default.
* Exported for reuse by the Phase 8 custom-invoice flow so both
* pipelines (cron and custom) compute VAT identically.
*
* See README for the legal interpretation; this implements the
* defaults you confirmed:
*
@@ -255,7 +262,7 @@ const EU_COUNTRIES = new Set([
* - EU without VAT: CH MWST (B2C consumer, we charge our rate)
* - other: 0% (export of services)
*/
function vatRateForAddress(
export function vatRateForAddress(
snapshot: InvoiceBillingSnapshot,
platformPricing: PlatformPricing
): { rate: number; note: string | null } {
@@ -1202,3 +1209,333 @@ export async function refundInvoice(params: {
return creditNote;
}
// ---------------------------------------------------------------------------
// Phase 8 — custom invoices (admin-entered, ad-hoc)
// ---------------------------------------------------------------------------
export class CustomInvoiceValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "CustomInvoiceValidationError";
}
}
/**
* Compute the totals for a custom-invoice draft payload, applying
* the same VAT logic the auto cron uses (vatRateForAddress against
* the org's billing snapshot).
*
* Returns the InvoiceDraft the createInvoice helper expects.
* Throws CustomInvoiceValidationError on:
* - no lines
* - any line with empty description or zero quantity
* - invalid date (issue or due)
* - issue date in past beyond 1 year (probably a typo)
* - due before issue
*
* Negative line amounts are intentionally allowed — they're the
* Rabatt / discount mechanism (one row with a negative unitPriceChf).
* The algebraic sum becomes the subtotal.
*/
export async function computeCustomInvoiceTotals(params: {
zitadelOrgId: string;
payload: CustomInvoiceDraftPayload;
}): Promise<InvoiceDraft> {
const { zitadelOrgId, payload } = params;
// Validation
if (!payload.lines || payload.lines.length === 0) {
throw new CustomInvoiceValidationError(
"Custom invoice must have at least one line."
);
}
for (let i = 0; i < payload.lines.length; i++) {
const ln = payload.lines[i];
if (!ln.description || !ln.description.trim()) {
throw new CustomInvoiceValidationError(
`Line ${i + 1}: description is required.`
);
}
if (
typeof ln.quantity !== "number" ||
!isFinite(ln.quantity) ||
ln.quantity === 0
) {
throw new CustomInvoiceValidationError(
`Line ${i + 1}: quantity must be a non-zero number.`
);
}
if (typeof ln.unitPriceChf !== "number" || !isFinite(ln.unitPriceChf)) {
throw new CustomInvoiceValidationError(
`Line ${i + 1}: unit price must be a number (use negative for discounts).`
);
}
}
const issueDate = payload.issueDate;
const dueDate = payload.dueDate;
if (!/^\d{4}-\d{2}-\d{2}$/.test(issueDate)) {
throw new CustomInvoiceValidationError(
"Issue date must be a valid YYYY-MM-DD."
);
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(dueDate)) {
throw new CustomInvoiceValidationError(
"Due date must be a valid YYYY-MM-DD."
);
}
if (dueDate < issueDate) {
throw new CustomInvoiceValidationError(
"Due date cannot be before issue date."
);
}
// Billing snapshot — required for any invoice to render.
const orgBilling = await getOrgBilling(zitadelOrgId);
if (!orgBilling) {
throw new CustomInvoiceValidationError(
"Org has no billing configuration. Ask the customer to complete onboarding first, or set the billing info from the admin panel."
);
}
// Build the same snapshot shape the auto-cron freezes. Mirroring
// the auto flow keeps the PDF renderer happy with one code path.
const snapshot: InvoiceBillingSnapshot = {
companyName: orgBilling.companyName,
contactName: orgBilling.contactName ?? null,
streetAddress: orgBilling.streetAddress,
city: orgBilling.city,
postalCode: orgBilling.postalCode,
country: orgBilling.country,
vatNumber: orgBilling.vatNumber ?? null,
billingEmail: orgBilling.billingEmail,
notes: orgBilling.notes ?? null,
};
// VAT — same logic as auto.
const platformPricing = await getPlatformPricing();
const vat = vatRateForAddress(snapshot, platformPricing);
// Build invoice lines. quantity * unitPrice rounded to 2 decimals
// (rappen precision). We carry the per-line amount on the row so
// the PDF doesn't need to recompute and any rounding remains
// identical between rendering passes.
//
// tenantName=null because custom invoices aren't bound to a
// specific tenant. unitLabel=null because admin-entered lines are
// free-form (the auto-cron lines use "day" / "request" /
// "message" — for custom lines the quantity is just a number).
// metadata.description preserves the admin's input so
// formatLineDescription can read it via the metadata channel
// (the row's description column also has it, redundantly, for
// safety). displayOrder reflects the order the admin added the
// rows so the PDF renders them top-to-bottom unchanged.
const lines: Omit<InvoiceLine, "id" | "invoiceId">[] = payload.lines.map(
(ln, idx) => {
const amount = Math.round(ln.quantity * ln.unitPriceChf * 100) / 100;
return {
tenantName: null,
kind: "custom_line" as InvoiceLineKind,
description: ln.description.trim(),
quantity: ln.quantity,
unitLabel: null,
unitPriceChf: ln.unitPriceChf,
amountChf: amount,
metadata: { description: ln.description.trim() },
displayOrder: idx,
};
}
);
// Subtotal is the algebraic sum (negative lines reduce it).
const subtotalChf = Math.round(
lines.reduce((s, l) => s + l.amountChf, 0) * 100
) / 100;
// VAT applies to the subtotal AFTER discounts (which is the
// legal default in CH — discounts reduce the taxable base).
const vatAmountChf = Math.round(subtotalChf * (vat.rate / 100) * 100) / 100;
const totalChf = Math.round((subtotalChf + vatAmountChf) * 100) / 100;
return {
zitadelOrgId,
source: "custom",
periodStart: null,
periodEnd: null,
issuedAt: `${issueDate}T00:00:00Z`,
dueAt: dueDate,
locale: payload.locale,
paymentMethod: payload.paymentMethod,
billingSnapshot: snapshot,
lines,
subtotalChf,
vatRate: vat.rate,
vatAmountChf,
totalChf,
warnings: [],
};
}
/**
* Issue a custom invoice from a draft. Three-step flow:
*
* 1. Compute totals + validate the payload (computeCustomInvoiceTotals)
* 2. Persist via createInvoice (allocates the number, inserts the
* row + lines, source='custom', issued_at honours the override)
* 3. Render PDF, send email — best-effort each. PDF render failure
* leaves the row in place with no PDF; admin can re-render. Email
* failure is logged.
*
* After successful persistence, the draft row is deleted (its job
* is done). If persistence fails, the draft stays so the admin can
* fix the issue and try again.
*/
export async function issueCustomInvoiceDraft(params: {
draftId: string;
issuedBy: string;
}): Promise<Invoice> {
const draft = await getInvoiceDraftById(params.draftId);
if (!draft) {
throw new CustomInvoiceValidationError(
`Draft not found: ${params.draftId}`
);
}
const invoiceDraft = await computeCustomInvoiceTotals({
zitadelOrgId: draft.zitadelOrgId,
payload: draft.payload,
});
// Two-pass: persist without PDF first, render against the canonical
// row (now has a number), then attach. Same pattern as the auto
// flow — keeps the PDF self-referential without juggling temporary
// numbers.
const placeholder = await createInvoice(invoiceDraft, null, null);
let pdfBuffer: Buffer | null = null;
try {
pdfBuffer = await renderInvoicePdf(
placeholder,
// Same pattern as the auto-cron generateInvoice: synthesize
// temporary ids for the PDF renderer. The real DB rows have
// these populated post-insert, but the renderer only reads
// them for React keys (display) and id-comparison-free
// operations, so synthetic values are fine.
invoiceDraft.lines.map((l, i) => ({
...l,
id: `tmp-${i}`,
invoiceId: placeholder.id,
}))
);
const filename = `${placeholder.invoiceNumber}.pdf`;
await updateInvoicePdf(placeholder.id, pdfBuffer, filename);
} catch (e) {
console.error(
`Custom invoice ${placeholder.invoiceNumber} persisted but PDF render failed:`,
e
);
// Don't throw — the row exists. Admin can re-render via a
// future tool (Phase 8.5 or just by deleting+reissuing).
}
// Best-effort email.
try {
const snap = invoiceDraft.billingSnapshot;
if (snap.billingEmail) {
await sendInvoiceIssuedEmail({
to: snap.billingEmail,
contactName: snap.contactName || snap.companyName,
companyName: snap.companyName,
invoiceNumber: placeholder.invoiceNumber,
totalChf: placeholder.totalChf,
currency: "CHF",
dueAt: placeholder.dueAt,
lineCount: invoiceDraft.lines.length,
periodStart: null,
periodEnd: null,
locale: invoiceDraft.locale as "de" | "en" | "fr" | "it",
});
}
} catch (e) {
console.error(
`Custom invoice ${placeholder.invoiceNumber} issued; email send failed.`,
e
);
}
// Draft did its job — remove it. If this fails the issuance
// still stands (we already have a real invoice). Log and move on.
try {
await deleteInvoiceDraft(draft.id);
} catch (e) {
console.error(
`Custom invoice ${placeholder.invoiceNumber} issued but draft ${draft.id} could not be deleted:`,
e
);
}
return placeholder;
}
/**
* Preview a draft as a PDF without persisting an invoice. The PDF
* is rendered with a placeholder number ("DRAFT") and not stored
* anywhere — the caller streams the bytes back to the admin's
* browser for review.
*
* Throws CustomInvoiceValidationError if the draft isn't ready to
* issue (no lines, missing billing snapshot, etc.) so the editor
* can surface the problem before any rendering work.
*/
export async function renderCustomDraftPreview(
draftId: string
): Promise<Buffer> {
const draft = await getInvoiceDraftById(draftId);
if (!draft) {
throw new CustomInvoiceValidationError(`Draft not found: ${draftId}`);
}
const invoiceDraft = await computeCustomInvoiceTotals({
zitadelOrgId: draft.zitadelOrgId,
payload: draft.payload,
});
// Render against a synthetic Invoice — same shape the persisted
// row would have, but with a DRAFT placeholder number. No DB
// writes. The PDF renderer doesn't care; it just consumes the
// Invoice + lines.
const fakeInvoice: Invoice = {
id: "preview",
invoiceNumber: "DRAFT",
zitadelOrgId: draft.zitadelOrgId,
source: "custom",
periodStart: null,
periodEnd: null,
issuedAt: invoiceDraft.issuedAt ?? new Date().toISOString(),
dueAt: invoiceDraft.dueAt,
subtotalChf: invoiceDraft.subtotalChf,
vatRate: invoiceDraft.vatRate,
vatAmountChf: invoiceDraft.vatAmountChf,
totalChf: invoiceDraft.totalChf,
status: "draft",
locale: invoiceDraft.locale,
paymentMethod: invoiceDraft.paymentMethod,
billingSnapshot: invoiceDraft.billingSnapshot,
stripePaymentIntentId: null,
pdfFilename: null,
hasPdf: false,
adminNotes: null,
paidAt: null,
paidBy: null,
paidMethodDetail: null,
voidReason: null,
voidedAt: null,
voidedBy: null,
refundedTotalChf: 0,
createdAt: new Date().toISOString(),
};
return renderInvoicePdf(
fakeInvoice,
invoiceDraft.lines.map((l, i) => ({
...l,
id: `tmp-${i}`,
invoiceId: fakeInvoice.id,
}))
);
}

View File

@@ -1,22 +1,17 @@
/**
* Credit-note PDF rendering via @react-pdf/renderer.
*
* Phase 7. Mirrors billing-pdf.tsx in layout but with:
* - Title "Gutschrift" / "Credit note" / "Note de crédit" / "Nota di credito"
* - Red accent (vs invoice's emerald) so the document is visually
* unmistakable from an invoice
* - References the original invoice number prominently
* - One amount line ("Refund for invoice 2026-00042" or
* "Voided invoice 2026-00042") with VAT broken out
* - Optional reason text below the amount
* - No bank-transfer instructions (refunds flow the other way:
* either Stripe → customer's card, or PieCed → customer's bank
* for invoice-paid cases — neither requires the customer to
* do anything)
* Phase 7. Renders the same brand identity as the invoice PDF
* (hexagon logo, issuer block, layout) with one accent override:
* red instead of emerald. That difference is enough to make voids
* and refunds visually unmistakable from an invoice at a glance,
* while keeping every other element (logo shape, fonts, structure,
* issuer info, page footer) identical so the document family reads
* as one brand.
*
* Issuer block and brand constants are intentionally duplicated
* from billing-pdf.tsx for now. A future refactor can hoist them
* into lib/pdf-brand.ts; doing so today is out of scope.
* Brand + Logo come from lib/pdf-brand. Edit there to change
* issuer info, colours, or the logo glyph — both invoice and
* credit-note PDFs pick the changes up.
*/
import React from "react";
@@ -26,71 +21,31 @@ import {
Text,
View,
StyleSheet,
Svg,
Polygon,
Polyline,
renderToBuffer,
} from "@react-pdf/renderer";
import type { CreditNote, Invoice } from "@/types";
import { BRAND, Logo } from "./pdf-brand";
// ---------------------------------------------------------------------------
// Brand constants — keep in sync with billing-pdf.tsx until the
// shared brand module lands.
// ---------------------------------------------------------------------------
const BRAND = {
name: "PieCed IT",
// Red accent for credit notes — visually distinct from the invoice
// emerald so customers can't confuse the two at a glance.
primary: "#DC2626",
primaryDark: "#991B1B",
textColor: "#1a1a1a",
mutedColor: "#666",
borderColor: "#d4d4d4",
issuer: {
legalName: "PieCed IT",
addressLine1: "Cedric Mosimann",
addressLine2: "[Strasse Nr.]",
postalCity: "[PLZ] Basel",
country: "Switzerland",
email: "billing@pieced.ch",
web: "pieced.ch",
vatNumber: null as string | null,
},
};
// ---------------------------------------------------------------------------
// Localized strings (mirrors PdfStrings in billing-pdf.tsx, adapted
// for the credit-note context)
// Localized strings
// ---------------------------------------------------------------------------
interface CreditNoteStrings {
/** Document title at the top — "Gutschrift" etc. */
creditNote: string;
/** "Credit note no." label */
creditNoteNumber: string;
/** "Issue date" label */
issueDate: string;
/** "Bill to" label, same as invoice */
billTo: string;
/** "Attn:" / "z.Hd." prefix */
attentionPrefix: string;
/** "Reference invoice" — links the credit note back to the original */
referenceInvoice: string;
/** "Reason" label for the free-text reason block */
reason: string;
/** Body text describing what this credit note is for. Takes the
* invoice number as a `{number}` placeholder. */
voidLineLabel: string;
refundLineLabel: string;
/** Totals labels — same words as invoice but separated for clarity */
subtotal: string;
vatLabel: string;
totalCredited: string;
/** Footer note explaining the document */
footerVoidNote: string;
footerRefundNote: string;
/** VAT note (reverse-charge or normal) — same logic as invoice */
vatNoteSwiss: string;
vatNoteReverseCharge: string;
vatNoteOutOfScope: string;
@@ -195,51 +150,38 @@ const MESSAGES: Record<string, CreditNoteStrings> = {
},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function pickStrings(locale: string): CreditNoteStrings {
return MESSAGES[locale] ?? MESSAGES.de;
}
// Swiss number formatting — matches billing-pdf for consistency
function fmtChf(n: number): string {
return n.toLocaleString("de-CH", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const fixed = n.toFixed(2);
const [intPart, decPart] = fixed.split(".");
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, "'");
return decPart ? `${withSep}.${decPart}` : withSep;
}
function fmtDate(iso: string, locale: string): string {
const d = new Date(iso);
const localeMap: Record<string, string> = {
de: "de-CH",
en: "en-GB",
fr: "fr-CH",
it: "it-CH",
};
return d.toLocaleDateString(localeMap[locale] ?? "de-CH", {
year: "numeric",
month: "long",
day: "numeric",
});
const [y, m, d] = iso.split("T")[0].split("-").map(Number);
if (locale === "en") {
return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
return `${String(d).padStart(2, "0")}.${String(m).padStart(2, "0")}.${y}`;
}
function pickVatNote(
invoice: Invoice,
strings: CreditNoteStrings
): string | null {
// Mirror the invoice's VAT note logic — the credit note's VAT
// treatment must match the original invoice's, otherwise the
// accounting wouldn't reconcile.
const country = invoice.billingSnapshot.country?.toUpperCase();
const hasVat = invoice.billingSnapshot.vatNumber?.trim();
if (country === "CH" || country === "LI") {
return strings.vatNoteSwiss;
}
if (hasVat) {
return strings.vatNoteReverseCharge;
}
if (country === "CH" || country === "LI") return strings.vatNoteSwiss;
if (hasVat) return strings.vatNoteReverseCharge;
return strings.vatNoteOutOfScope;
}
@@ -287,7 +229,7 @@ const styles = StyleSheet.create({
billTo: {
marginBottom: 24,
padding: 8,
backgroundColor: "#fdf2f2",
backgroundColor: "#f7f7f5",
borderLeftWidth: 3,
borderLeftColor: BRAND.primary,
},
@@ -318,11 +260,7 @@ const styles = StyleSheet.create({
},
amountDesc: { flex: 1 },
amountValue: { width: 90, textAlign: "right" },
totals: {
marginLeft: "auto",
width: 220,
marginBottom: 20,
},
totals: { marginLeft: "auto", width: 220, marginBottom: 20 },
totalsRow: {
flexDirection: "row",
justifyContent: "space-between",
@@ -386,26 +324,6 @@ const styles = StyleSheet.create({
},
});
// Mini SVG logo — identical shape to billing-pdf, just recolored
// to the credit-note red so it matches the document accent.
function Logo() {
return (
<Svg width={26} height={26} viewBox="0 0 100 100">
<Polygon points="50,15 80,40 65,80 35,80 20,40" fill={BRAND.primary} />
<Polyline
points="35,80 50,60 65,80"
stroke="#ffffff"
strokeWidth={3}
fill="none"
/>
</Svg>
);
}
// ---------------------------------------------------------------------------
// Main document
// ---------------------------------------------------------------------------
interface CreditNotePdfProps {
creditNote: CreditNote;
invoice: Invoice;
@@ -417,20 +335,24 @@ function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
const vatNote = pickVatNote(invoice, strings);
const amountLabelTemplate =
creditNote.kind === "void" ? strings.voidLineLabel : strings.refundLineLabel;
const amountLabel = amountLabelTemplate.replace("{number}", invoice.invoiceNumber);
const amountLabel = amountLabelTemplate.replace(
"{number}",
invoice.invoiceNumber
);
const footerNote =
creditNote.kind === "void" ? strings.footerVoidNote : strings.footerRefundNote;
// Subtotal + VAT breakdown. We carry the VAT proportion from the
// credit note row itself (set by billing.ts based on the invoice's
// VAT rate × the refund/void amount).
// Stored convention: amount_chf is gross (incl. VAT),
// vat_amount_chf is the VAT portion. Subtotal computed for
// display.
const subtotal = creditNote.amountChf - creditNote.vatAmountChf;
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Header: logo+brand left, issuer block right */}
{/* Header — SAME hexagon logo as the invoice, tinted red.
Issuer block from BRAND.issuer (shared with invoice). */}
<View style={styles.headerRow}>
<View style={styles.logoBlock}>
<Logo />
<Logo size={42} color={BRAND.primary} />
<Text style={styles.brandName}>{BRAND.name}</Text>
</View>
<View style={styles.issuerBlock}>
@@ -449,7 +371,6 @@ function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
<Text style={styles.docTitle}>{strings.creditNote}</Text>
{/* Meta row: number, issue date, reference invoice */}
<View style={styles.metaTable}>
<View style={styles.metaCol}>
<Text style={styles.metaLabel}>{strings.creditNoteNumber}</Text>
@@ -467,7 +388,6 @@ function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
</View>
</View>
{/* Bill-to (mirrors invoice block) */}
<View style={styles.billTo}>
<Text style={styles.billToLabel}>{strings.billTo}</Text>
<Text style={styles.billToName}>{snap.companyName}</Text>
@@ -484,7 +404,6 @@ function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
{snap.vatNumber && <Text>MWST/VAT: {snap.vatNumber}</Text>}
</View>
{/* Amount line — single row, the credit note isn't itemized */}
<View style={styles.amountTable}>
<View style={styles.amountHeader}>
<Text style={styles.amountDesc}> </Text>
@@ -496,7 +415,6 @@ function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
</View>
</View>
{/* Totals */}
<View style={styles.totals}>
<View style={styles.totalsRow}>
<Text style={styles.totalsLabel}>{strings.subtotal}</Text>
@@ -520,7 +438,6 @@ function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
</View>
</View>
{/* Reason block — only if the admin provided one */}
{creditNote.reason && creditNote.reason.trim().length > 0 && (
<View style={styles.reasonBox}>
<Text style={styles.reasonLabel}>{strings.reason}</Text>
@@ -528,13 +445,11 @@ function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
</View>
)}
{/* Footer note explaining what this document is */}
<View style={styles.noteBox}>
<Text>{footerNote}</Text>
{vatNote && <Text style={{ marginTop: 6 }}>{vatNote}</Text>}
</View>
{/* Tiny footer with credit-note number on every page */}
<Text style={styles.footer} fixed>
{BRAND.issuer.legalName} · {creditNote.creditNoteNumber}
</Text>
@@ -548,6 +463,5 @@ export async function renderCreditNotePdf(
invoice: Invoice
): Promise<Buffer> {
const doc = <CreditNotePdfDocument creditNote={creditNote} invoice={invoice} />;
// @react-pdf/renderer's renderToBuffer returns a Node Buffer.
return renderToBuffer(doc) as unknown as Buffer;
}

View File

@@ -522,10 +522,30 @@ const MIGRATION_SQL = `
ON invoices(zitadel_org_id, issued_at DESC);
CREATE INDEX IF NOT EXISTS idx_invoices_status
ON invoices(status, due_at);
-- One invoice per org per billing month — protects the monthly
-- cron from double-issuing if it gets retried mid-run.
CREATE UNIQUE INDEX IF NOT EXISTS uniq_invoices_org_period
ON invoices(zitadel_org_id, period_start);
-- Phase 8: distinguish auto (cron) from custom (admin-entered)
-- invoices. All pre-Phase-8 rows backfill to 'auto' via the
-- column DEFAULT. Custom invoices skip the per-org/per-month
-- uniqueness guard (admin may issue multiple custom invoices
-- against the same org in the same month).
ALTER TABLE invoices
ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'auto'
CHECK (source IN ('auto','custom'));
-- period_start / period_end become nullable so custom invoices
-- (no fixed billing period) can leave them empty. Auto-cron
-- invoices keep their values; only the NOT NULL constraint is
-- relaxed. Idempotent: DROP NOT NULL on an already-nullable column
-- is a no-op in Postgres.
ALTER TABLE invoices ALTER COLUMN period_start DROP NOT NULL;
ALTER TABLE invoices ALTER COLUMN period_end DROP NOT NULL;
-- Replace the old unconditional uniqueness with a partial index
-- limited to auto invoices. Both invariants the cron relies on
-- (no double-issuance per period) survive; custom invoices are
-- unaffected. Idempotent: DROP IF EXISTS handles the migration
-- case, and CREATE IF NOT EXISTS handles re-runs.
DROP INDEX IF EXISTS uniq_invoices_org_period;
CREATE UNIQUE INDEX IF NOT EXISTS uniq_invoices_org_period_auto
ON invoices(zitadel_org_id, period_start)
WHERE source = 'auto';
-- Phase 7: credit notes. One row per void or refund event. The
-- credit_note_number follows CN-YYYY-NNNNN allocated from the
@@ -578,6 +598,36 @@ const MIGRATION_SQL = `
CREATE INDEX IF NOT EXISTS idx_invoice_refunds_invoice
ON invoice_refunds(invoice_id);
-- Phase 7 fix: the credit_notes.invoice_id and
-- invoice_refunds.invoice_id FKs were originally created without
-- ON DELETE CASCADE, which made admin "delete invoice" fail with
-- a FK violation when the invoice had any voids/refunds attached.
-- The production policy is to never delete an invoice that's been
-- refunded (the credit notes are part of the customer's records),
-- but the schema should allow the admin tool to clean up for test
-- data. We drop and re-add the FKs with CASCADE so a delete tears
-- down everything related. The DROP/ADD pair is idempotent — on a
-- DB that already has the CASCADE variant it's a no-op (we drop
-- by name and re-add identically).
DO $cnfk$
BEGIN
ALTER TABLE credit_notes
DROP CONSTRAINT IF EXISTS credit_notes_invoice_id_fkey;
ALTER TABLE credit_notes
ADD CONSTRAINT credit_notes_invoice_id_fkey
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE;
END
$cnfk$;
DO $irfk$
BEGIN
ALTER TABLE invoice_refunds
DROP CONSTRAINT IF EXISTS invoice_refunds_invoice_id_fkey;
ALTER TABLE invoice_refunds
ADD CONSTRAINT invoice_refunds_invoice_id_fkey
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE;
END
$irfk$;
-- Invoice line items. The kind column lets the PDF renderer
-- group lines (all monthly fees together, all AI usage together,
-- etc.) and the admin UI filter by category.
@@ -665,9 +715,39 @@ const MIGRATION_SQL = `
ADD CONSTRAINT invoice_lines_kind_check
CHECK (kind IN (
'tenant_monthly','tenant_setup','ai_usage','threema_messages',
'skill_usage','skill_setup','adjustment'
'skill_usage','skill_setup','adjustment',
-- Phase 8: lines on admin-created custom invoices. PDF
-- groups these under a "Services" section header.
'custom_line'
));
-- Phase 8: drafts for the admin "New invoice" flow. The payload
-- is a JSONB blob with the in-progress form (lines, dates,
-- locale, etc.). On Issue the payload is read, a real Invoice
-- row is allocated via createInvoice with source='custom', and
-- this draft row is deleted. Drafts are admin-only — they have
-- no invoice number, no PDF, and aren't reachable from any
-- customer-facing route.
--
-- Why a JSONB blob instead of mirroring the invoices schema:
-- drafts and issued invoices share only a fraction of fields
-- (no number, no totals computed yet, possibly incomplete
-- billing snapshot), and any parallel-table design would force
-- a costly conversion step. The blob keeps the schema minimal
-- and the read/write paths trivial.
CREATE TABLE IF NOT EXISTS invoice_drafts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
zitadel_org_id TEXT NOT NULL,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
payload JSONB NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_invoice_drafts_org
ON invoice_drafts(zitadel_org_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_invoice_drafts_recent
ON invoice_drafts(updated_at DESC);
-- Phase 4: Stripe webhook idempotency. Stripe guarantees at-least-once
-- delivery and retries failures with exponential backoff for up to 72h,
-- so the same event.id can arrive multiple times. We insert each
@@ -2342,19 +2422,31 @@ import type {
CreditNote,
CreditNoteKind,
InvoiceRefund,
CustomInvoiceDraftPayload,
InvoiceDraftRecord,
} from "@/types";
function rowToInvoice(row: any): Invoice {
// Phase 8: period_start / period_end are nullable for custom
// invoices (no fixed billing period). Convert defensively whether
// the driver returned a string or Date.
const periodStartIso = row.period_start == null
? null
: typeof row.period_start === "string"
? row.period_start
: row.period_start.toISOString().split("T")[0];
const periodEndIso = row.period_end == null
? null
: typeof row.period_end === "string"
? row.period_end
: row.period_end.toISOString().split("T")[0];
return {
id: row.id,
invoiceNumber: row.invoice_number,
zitadelOrgId: row.zitadel_org_id,
periodStart: typeof row.period_start === "string"
? row.period_start
: row.period_start.toISOString().split("T")[0],
periodEnd: typeof row.period_end === "string"
? row.period_end
: row.period_end.toISOString().split("T")[0],
source: (row.source ?? "auto") as "auto" | "custom",
periodStart: periodStartIso,
periodEnd: periodEndIso,
issuedAt: row.issued_at?.toISOString?.() ?? row.issued_at,
dueAt: typeof row.due_at === "string"
? row.due_at
@@ -2410,7 +2502,7 @@ const INVOICE_LIST_COLUMNS = `
total_chf, status, locale, payment_method, billing_snapshot,
stripe_payment_intent_id, pdf_filename, admin_notes, paid_at,
paid_by, paid_method_detail, void_reason, voided_at, voided_by,
refunded_total_chf, created_at,
refunded_total_chf, source, created_at,
(pdf_data IS NOT NULL) AS has_pdf
`;
@@ -2442,9 +2534,24 @@ export async function createInvoice(
try {
await client.query("BEGIN");
// Allocate number for the year of period_start. Locking the
// counter row prevents concurrent allocators from racing.
const year = parseInt(draft.periodStart.slice(0, 4), 10);
// Phase 8: pick the year for invoice-number allocation.
// - auto invoices: use period_start (the original behaviour)
// - custom invoices: use the override issued_at if set, else
// the year of "now"
// The sequence is shared across auto and custom — every invoice
// gets a number from the same year-scoped counter so the audit
// trail is gapless and sequential regardless of source.
let year: number;
if (draft.source === "custom") {
const yearSource = draft.issuedAt
? draft.issuedAt.slice(0, 4)
: new Date().getUTCFullYear().toString();
year = parseInt(yearSource, 10);
} else if (draft.periodStart) {
year = parseInt(draft.periodStart.slice(0, 4), 10);
} else {
year = new Date().getUTCFullYear();
}
const counterResult = await client.query(
`INSERT INTO invoice_number_counters (year, last_number)
VALUES ($1, 1)
@@ -2456,24 +2563,29 @@ export async function createInvoice(
const seq = counterResult.rows[0].last_number;
const invoiceNumber = `${year}-${String(seq).padStart(5, "0")}`;
// Insert invoice row. PDF goes inline as bytea for v1; we can
// migrate to MinIO/S3 later if storage gets noisy.
// Phase 8: optional override of issued_at (custom flow lets
// admin backdate or future-date). Empty → now(); set → that
// exact date.
const source = draft.source ?? "auto";
const inv = await client.query(
`INSERT INTO invoices (
invoice_number, zitadel_org_id, period_start, period_end,
issued_at, due_at, subtotal_chf, vat_rate, vat_amount_chf,
total_chf, status, locale, payment_method, billing_snapshot,
pdf_data, pdf_filename
pdf_data, pdf_filename, source
) VALUES (
$1, $2, $3::date, $4::date, now(), $5::date, $6, $7, $8, $9,
'open', $10, $11, $12::jsonb, $13, $14
$1, $2, $3::date, $4::date,
COALESCE($5::timestamptz, now()),
$6::date, $7, $8, $9, $10,
'open', $11, $12, $13::jsonb, $14, $15, $16
)
RETURNING ${INVOICE_LIST_COLUMNS}`,
[
invoiceNumber,
draft.zitadelOrgId,
draft.periodStart,
draft.periodEnd,
draft.periodStart, // may be null for custom
draft.periodEnd, // may be null for custom
draft.issuedAt ?? null,
draft.dueAt,
draft.subtotalChf,
draft.vatRate,
@@ -2484,6 +2596,7 @@ export async function createInvoice(
JSON.stringify(draft.billingSnapshot),
pdfBuffer,
pdfFilename,
source,
]
);
const invoiceId = inv.rows[0].id;
@@ -2526,9 +2639,15 @@ export async function createInvoice(
} catch (e: any) {
await client.query("ROLLBACK").catch(() => undefined);
// Translate the uniqueness violation into a user-friendly error.
// 23505 = unique_violation in Postgres.
if (e?.code === "23505" && /uniq_invoices_org_period/.test(e?.constraint ?? "")) {
const month = draft.periodStart.slice(0, 7);
// 23505 = unique_violation in Postgres. The partial index is
// named uniq_invoices_org_period_auto post-Phase-8; we match
// both for back-compat with DBs that haven't run the migration
// yet (the old name) or have run it (the new name).
if (
e?.code === "23505" &&
/uniq_invoices_org_period/.test(e?.constraint ?? "")
) {
const month = draft.periodStart?.slice(0, 7) ?? "this period";
throw new Error(
`An invoice already exists for this org and billing period (${month}). ` +
`Delete the existing invoice first if you want to regenerate.`
@@ -3737,3 +3856,119 @@ export async function isStripeRefundRecorded(
);
return result.rows.length > 0;
}
// ---------------------------------------------------------------------------
// Phase 8 — custom invoice drafts
// ---------------------------------------------------------------------------
function rowToInvoiceDraft(row: any): InvoiceDraftRecord {
return {
id: row.id,
zitadelOrgId: row.zitadel_org_id,
createdBy: row.created_by,
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
payload: row.payload as CustomInvoiceDraftPayload,
};
}
/**
* Create a new draft for the given org with the supplied payload.
* The payload is whatever the editor has so far (possibly minimal —
* just a date and an empty lines array). Returns the inserted row.
*/
export async function createInvoiceDraft(params: {
zitadelOrgId: string;
createdBy: string;
payload: CustomInvoiceDraftPayload;
}): Promise<InvoiceDraftRecord> {
await ensureSchema();
const result = await getPool().query(
`INSERT INTO invoice_drafts (zitadel_org_id, created_by, payload)
VALUES ($1, $2, $3::jsonb)
RETURNING *`,
[params.zitadelOrgId, params.createdBy, JSON.stringify(params.payload)]
);
return rowToInvoiceDraft(result.rows[0]);
}
/**
* Update an existing draft's payload. Updated_at gets bumped to now()
* so the drafts list can sort by recent activity. Returns the
* updated row, or null if no row with that id exists.
*
* Org boundary check is the caller's responsibility (the admin API
* route only accepts requests from platform admins, but you could
* pass a zitadelOrgId filter here too if you ever expose drafts to
* customer-level roles).
*/
export async function updateInvoiceDraft(
id: string,
payload: CustomInvoiceDraftPayload
): Promise<InvoiceDraftRecord | null> {
const result = await getPool().query(
`UPDATE invoice_drafts
SET payload = $2::jsonb,
updated_at = now()
WHERE id = $1
RETURNING *`,
[id, JSON.stringify(payload)]
);
return result.rows.length > 0 ? rowToInvoiceDraft(result.rows[0]) : null;
}
export async function getInvoiceDraftById(
id: string
): Promise<InvoiceDraftRecord | null> {
await ensureSchema();
const result = await getPool().query(
"SELECT * FROM invoice_drafts WHERE id = $1",
[id]
);
return result.rows.length > 0 ? rowToInvoiceDraft(result.rows[0]) : null;
}
/**
* List all open drafts across all orgs, newest first. Used by the
* admin "Drafts" tab so the admin can resume any in-progress
* invoice. Drafts are tiny (a JSONB payload), so we don't paginate
* by default — 200 rows is plenty of headroom for a solo-founder
* workflow.
*/
export async function listAllInvoiceDrafts(
limit = 200
): Promise<InvoiceDraftRecord[]> {
await ensureSchema();
const result = await getPool().query(
`SELECT * FROM invoice_drafts ORDER BY updated_at DESC LIMIT $1`,
[limit]
);
return result.rows.map(rowToInvoiceDraft);
}
/**
* Per-org listing — used if you ever want to surface drafts on an
* org-detail page or filter by org from the drafts list.
*/
export async function listInvoiceDraftsForOrg(
zitadelOrgId: string,
limit = 50
): Promise<InvoiceDraftRecord[]> {
await ensureSchema();
const result = await getPool().query(
`SELECT * FROM invoice_drafts
WHERE zitadel_org_id = $1
ORDER BY updated_at DESC
LIMIT $2`,
[zitadelOrgId, limit]
);
return result.rows.map(rowToInvoiceDraft);
}
export async function deleteInvoiceDraft(id: string): Promise<boolean> {
const result = await getPool().query(
"DELETE FROM invoice_drafts WHERE id = $1",
[id]
);
return (result.rowCount ?? 0) > 0;
}

View File

@@ -923,8 +923,8 @@ export async function sendInvoiceIssuedEmail(params: {
currency: string; // "CHF" — passed for future-proofing
dueAt: string; // ISO date
lineCount: number;
periodStart: string; // ISO date
periodEnd: string; // ISO date
periodStart: string | null; // ISO date; null for custom invoices
periodEnd: string | null; // ISO date; null for custom invoices
locale: "de" | "en" | "fr" | "it";
}): Promise<void> {
// All four locales — the email is sent in the invoice's locale,
@@ -960,7 +960,13 @@ export async function sendInvoiceIssuedEmail(params: {
const safeCompany = escapeHtml(params.companyName);
const safeNumber = escapeHtml(params.invoiceNumber);
const totalFmt = `${params.currency} ${params.totalChf.toFixed(2)}`;
const periodFmt = `${params.periodStart.slice(0, 10)}${params.periodEnd.slice(0, 10)}`;
// Phase 8: period is null for custom invoices. When missing, the
// template skips the "Service period:" line entirely; otherwise
// it renders the date range as before.
const periodFmt =
params.periodStart && params.periodEnd
? `${params.periodStart.slice(0, 10)}${params.periodEnd.slice(0, 10)}`
: null;
const dueFmt = params.dueAt.slice(0, 10);
// Both bodies built in the invoice's locale.
@@ -977,7 +983,9 @@ export async function sendInvoiceIssuedEmail(params: {
introByLocale[L],
"",
`${l.number}: ${params.invoiceNumber}`,
`${l.period}: ${periodFmt}`,
// Phase 8: omit the period line entirely for custom
// invoices (which have no billing period).
...(periodFmt ? [`${l.period}: ${periodFmt}`] : []),
`${l.total}: ${totalFmt}`,
`${l.due}: ${dueFmt}`,
`${l.lines}: ${params.lineCount}`,
@@ -995,7 +1003,7 @@ export async function sendInvoiceIssuedEmail(params: {
<p>${escapeHtml(introByLocale[L])}</p>
<table style="width:100%; border-collapse:collapse; margin:16px 0; font-size:14px;">
<tr><td style="color:#888; padding:6px 0; width:120px;">${l.number}</td><td><strong>${safeNumber}</strong></td></tr>
<tr><td style="color:#888; padding:6px 0;">${l.period}</td><td>${escapeHtml(periodFmt)}</td></tr>
${periodFmt ? `<tr><td style="color:#888; padding:6px 0;">${l.period}</td><td>${escapeHtml(periodFmt)}</td></tr>` : ""}
<tr><td style="color:#888; padding:6px 0;">${l.total}</td><td style="color:#10B981; font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
<tr><td style="color:#888; padding:6px 0;">${l.due}</td><td>${escapeHtml(dueFmt)}</td></tr>
<tr><td style="color:#888; padding:6px 0;">${l.lines}</td><td>${params.lineCount}</td></tr>
@@ -1261,10 +1269,12 @@ export async function sendCreditNoteEmail(params: {
const safeNumberINV = escapeHtml(params.invoiceNumber);
const safeReason = params.reason ? escapeHtml(params.reason) : null;
// Red accent (#DC2626) for the credit-note emails, mirroring the
// PDF accent so the document family reads visually consistent.
// The invoice email uses emerald; the credit note uses red.
const ACCENT = "#DC2626";
// PieCed brand emerald — same accent the invoice email uses.
// A credit note is still a PieCed IT document; the company
// identity stays consistent across the document family. The
// doc type is distinguished by the subject line and copy, not
// by colour.
const ACCENT = "#10B981";
try {
await getTransporter().sendMail({

118
src/lib/pdf-brand.tsx Normal file
View File

@@ -0,0 +1,118 @@
/**
* Shared brand constants and Logo component for all PDF documents
* (invoices, credit notes, future quotes / reminders).
*
* Phase 7 fix: previously each PDF generator carried its own copy
* of BRAND and its own Logo. When Cedric customized the invoice
* issuer block in his deployment (real Strasse Nr., PLZ, etc.),
* the credit note PDF kept the original placeholders because it
* had its own duplicate. Hoisting both here means every PDF reads
* the same source of truth.
*
* To change the brand: edit BRAND below. To change the logo:
* edit Logo below. To change the issuer info Cedric ships: edit
* BRAND.issuer — both billing-pdf.tsx and credit-note-pdf.tsx pick
* it up automatically.
*
* The Logo component accepts a `color` prop so the credit-note
* variant can render the SAME shape tinted red (the document
* family is visually consistent; only the accent colour signals
* "this is a credit, not an invoice").
*/
import React from "react";
import { Svg, Polygon, Polyline } from "@react-pdf/renderer";
// ---------------------------------------------------------------------------
// Brand constants
// ---------------------------------------------------------------------------
export const BRAND = {
name: "PieCed IT",
// Primary emerald — matches the logo SVG fill (#10B981).
primary: "#10B981",
// Slightly darker emerald for headings.
primaryDark: "#0a8060",
textColor: "#1a1a1a",
mutedColor: "#666",
borderColor: "#d4d4d4",
// Issuer block — change these to your real legal info.
// Both billing-pdf.tsx and credit-note-pdf.tsx read from here.
issuer: {
legalName: "PieCed IT",
addressLine1: "Cedric Mosimann",
addressLine2: "[Strasse Nr.]",
postalCity: "[PLZ] Basel",
country: "Switzerland",
email: "billing@pieced.ch",
web: "pieced.ch",
// Show "MWST-Nr. ..." on PDF when set.
vatNumber: null as string | null,
// Bank instructions — used by invoice PDF, ignored on credit
// notes (refunds flow back via the original payment method).
bankName: "[Bank name]",
bankIban: "[CHxx xxxx xxxx xxxx xxxx x]",
bankBic: "[BIC]",
},
};
// ---------------------------------------------------------------------------
// Logo — PieCed's hexagon-pattern mark. Same shape used everywhere
// and same brand colour. The credit note is still a PieCed IT
// document and reads with the same company identity as an invoice.
// ---------------------------------------------------------------------------
interface LogoProps {
size?: number;
/** Defaults to BRAND.primary. Override only for special cases
* (e.g. an inverse variant on a dark background). Standard
* documents — invoices, credit notes — all use BRAND.primary. */
color?: string;
}
export const Logo = ({ size = 60, color = BRAND.primary }: LogoProps) => (
<Svg width={size} height={size * (106 / 70)} viewBox="0 0 70 106">
{/* H1 solid */}
<Polygon
points="38.5,22.69 31.5,10.566 17.5,10.566 10.5,22.69 17.5,34.814 31.5,34.814"
fill={color}
stroke={color}
strokeWidth={1.6}
/>
{/* H2 outline */}
<Polygon
points="59.5,34.814 52.5,22.69 38.5,22.69 31.5,34.814 38.5,46.938 52.5,46.938"
fill="none"
stroke={color}
strokeWidth={1.8}
/>
{/* H3 outline */}
<Polygon
points="38.5,46.938 31.5,34.814 17.5,34.814 10.5,46.938 17.5,59.062 31.5,59.062"
fill="none"
stroke={color}
strokeWidth={1.8}
/>
{/* H4 solid */}
<Polygon
points="59.5,59.062 52.5,46.938 38.5,46.938 31.5,59.062 38.5,71.186 52.5,71.186"
fill={color}
stroke={color}
strokeWidth={1.6}
/>
{/* H5 partial */}
<Polyline
points="31.5,83.31 38.5,71.186 31.5,59.062 17.5,59.062 10.5,71.186"
fill="none"
stroke={color}
strokeWidth={1.8}
/>
{/* H6 partial */}
<Polyline
points="59.5,83.31 52.5,71.186 38.5,71.186 31.5,83.31 38.5,95.434"
fill="none"
stroke={color}
strokeWidth={1.8}
/>
</Svg>
);

View File

@@ -220,7 +220,13 @@ export async function createCheckoutSessionForInvoice(params: {
unit_amount: chfToRappen(invoice.totalChf),
product_data: {
name: `Invoice ${invoice.invoiceNumber}`,
description: `PieCed IT — ${invoice.periodStart.slice(0, 10)}${invoice.periodEnd.slice(0, 10)}`,
// Phase 8: custom invoices have no period — fall back
// to a description that just references the invoice
// number and due date.
description:
invoice.periodStart && invoice.periodEnd
? `PieCed IT — ${invoice.periodStart.slice(0, 10)}${invoice.periodEnd.slice(0, 10)}`
: `PieCed IT — due ${invoice.dueAt.slice(0, 10)}`,
},
},
},

View File

@@ -578,7 +578,7 @@
"subtitle": "Plattform-Preise verwalten, Rechnungen generieren und den Rechnungsstatus aller Organisationen prüfen.",
"backToAdmin": "Zurück zur Verwaltung",
"backToBilling": "Zurück zur Abrechnung",
"backToInvoices": "Zurück zu den Rechnungen",
"backToInvoices": "Zurück zu Rechnungen",
"totalOpenBalance": "Offener Saldo gesamt",
"orgsWithBalance": "Organisationen mit Saldo",
"overdueInvoices": "Überfällige Rechnungen",
@@ -699,7 +699,72 @@
"creditNotePdfHeader": "PDF",
"creditNoteKind_void": "Storno",
"creditNoteKind_refund": "Rückerstattung",
"creditNoteNoPdf": "—"
"creditNoteNoPdf": "—",
"refundAmountLabel": "Betrag",
"refundReasonLabel": "Grund",
"refundAmountInclVatHint": "inkl. MWST",
"newInvoiceBtn": "Neue Rechnung",
"draftsLink": "Entwürfe",
"backToDrafts": "Zurück zu Entwürfen",
"newInvoicePageTitle": "Neue Rechnung",
"newInvoicePageSubtitle": "Wählen Sie den Kunden, dem Sie eine Rechnung stellen möchten. Im nächsten Schritt fügen Sie die Positionen hinzu.",
"newInvoiceOrgLabel": "Kunde",
"newInvoiceOrgPlaceholder": "— Kunde wählen —",
"newInvoiceOrgNoBilling": "keine Rechnungsadresse",
"newInvoiceOrgBillingMissing": "Dieser Kunde hat keine hinterlegte Rechnungsadresse. Bitte abschliessen lassen oder im Admin-Panel hinterlegen, bevor die Rechnung ausgestellt wird.",
"newInvoiceLocaleLabel": "Dokumentensprache",
"newInvoiceOrgRequired": "Bitte einen Kunden wählen.",
"newInvoiceContinueBtn": "Weiter",
"creating": "Wird erstellt…",
"draftsPageTitle": "Rechnungsentwürfe",
"draftsPageSubtitle": "Laufende benutzerdefinierte Rechnungen. Bearbeitung fortsetzen oder verwerfen.",
"draftsEmpty": "Noch keine Entwürfe. Starten Sie eine neue Rechnung.",
"draftOrgCol": "Kunde",
"draftIssueDateCol": "Rechnungsdatum",
"draftLinesCol": "Positionen",
"draftSubtotalCol": "Zwischensumme (Schätzung)",
"draftUpdatedCol": "Zuletzt bearbeitet",
"draftActionsCol": "Aktionen",
"draftDeleteConfirm": "Diesen Entwurf verwerfen? Kann nicht rückgängig gemacht werden.",
"editBtn": "Bearbeiten",
"editorPageTitle": "Rechnungsentwurf bearbeiten",
"editorBillToHeading": "Rechnungsempfänger",
"editorNoBillingSnapshot": "Keine Rechnungsadresse für diesen Kunden hinterlegt. Ausstellung ist nicht möglich, bis Rechnungsinformationen erfasst wurden.",
"editorMetadataHeading": "Rechnungsdaten",
"editorIssueDateLabel": "Rechnungsdatum",
"editorDueDateLabel": "Fälligkeitsdatum",
"editorLocaleLabel": "Dokumentensprache",
"editorPaymentMethodLabel": "Zahlungsart",
"editorPaymentInvoice": "Banküberweisung (Rechnung)",
"editorPaymentCard": "Kreditkarte (Stripe)",
"editorLinesHeading": "Positionen",
"editorLineDescription": "Beschreibung",
"editorLineDescriptionPlaceholder": "z.B. Beratungsstunden, individuelle Integration, …",
"editorLineQty": "Menge",
"editorLineUnitPrice": "Einzelpreis",
"editorLineAmount": "Betrag",
"editorLineRemove": "Position entfernen",
"editorAddLine": "Position hinzufügen",
"editorAddDiscount": "Rabatt hinzufügen",
"editorAddDiscountHint": "Fügt eine Zeile mit negativem Einzelpreis hinzu. Beschreibung und Betrag nach Bedarf anpassen.",
"editorRabattDefaultDescription": "Rabatt",
"editorNotesHeading": "Interne Notizen",
"editorNotesPlaceholder": "Nur für Admin sichtbar (nicht auf der Rechnung)",
"editorNotesHint": "Wird dem Kunden nicht angezeigt.",
"editorTotalsHeading": "Beträge (Schätzung)",
"editorSubtotal": "Zwischensumme",
"editorVat": "MWST",
"editorTotal": "Gesamt",
"editorTotalsEstimateNote": "Schätzung basierend auf Kundenland. Die endgültige MWST wird bei Ausstellung berechnet.",
"editorSaveBtn": "Entwurf speichern",
"editorSavedBtn": "Gespeichert",
"editorPreviewBtn": "PDF-Vorschau",
"editorIssueBtn": "Rechnung ausstellen",
"editorDeleteBtn": "Entwurf verwerfen",
"editorIssueConfirm": "Rechnung jetzt ausstellen? Eine Rechnungsnummer wird zugewiesen, das PDF wird dem Kunden zugesendet und dieser Entwurf wird entfernt.",
"editorDeleteConfirm": "Diesen Entwurf verwerfen? Kann nicht rückgängig gemacht werden.",
"previewing": "Wird geöffnet…",
"issuing": "Wird ausgestellt…"
},
"skillCostDialog": {
"title": "Aktivierungskosten bestätigen",
@@ -772,7 +837,9 @@
"paid": "Bezahlt",
"overdue": "Überfällig",
"void": "Storniert",
"uncollectible": "Uneinbringlich"
"uncollectible": "Uneinbringlich",
"partially_refunded": "Teilrückerstattung",
"fully_refunded": "Vollständig rückerstattet"
},
"payWithCard": "Mit Karte bezahlen",
"redirectingToStripe": "Weiterleitung…",

View File

@@ -577,8 +577,8 @@
"title": "Billing administration",
"subtitle": "Manage platform pricing, generate invoices, and review billing status across all organizations.",
"backToAdmin": "Back to Admin",
"backToBilling": "Back to Billing",
"backToInvoices": "Back to Invoices",
"backToBilling": "Back to billing",
"backToInvoices": "Back to invoices",
"totalOpenBalance": "Total open balance",
"orgsWithBalance": "Orgs with balance",
"overdueInvoices": "Overdue invoices",
@@ -699,7 +699,72 @@
"creditNotePdfHeader": "PDF",
"creditNoteKind_void": "Void",
"creditNoteKind_refund": "Refund",
"creditNoteNoPdf": "—"
"creditNoteNoPdf": "—",
"refundAmountLabel": "Amount",
"refundReasonLabel": "Reason",
"refundAmountInclVatHint": "incl. VAT",
"newInvoiceBtn": "New invoice",
"draftsLink": "Drafts",
"backToDrafts": "Back to drafts",
"newInvoicePageTitle": "New invoice",
"newInvoicePageSubtitle": "Pick the customer you want to invoice. You'll add lines on the next step.",
"newInvoiceOrgLabel": "Customer",
"newInvoiceOrgPlaceholder": "— select customer —",
"newInvoiceOrgNoBilling": "no billing info",
"newInvoiceOrgBillingMissing": "This customer has no billing address on file. Ask them to complete onboarding or set the billing info from the admin panel before issuing.",
"newInvoiceLocaleLabel": "Document language",
"newInvoiceOrgRequired": "Please select a customer.",
"newInvoiceContinueBtn": "Continue",
"creating": "Creating…",
"draftsPageTitle": "Invoice drafts",
"draftsPageSubtitle": "Custom invoices in progress. Resume editing or discard.",
"draftsEmpty": "No drafts yet. Start a new invoice to begin.",
"draftOrgCol": "Customer",
"draftIssueDateCol": "Issue date",
"draftLinesCol": "Lines",
"draftSubtotalCol": "Subtotal (est.)",
"draftUpdatedCol": "Last edited",
"draftActionsCol": "Actions",
"draftDeleteConfirm": "Discard this draft? This cannot be undone.",
"editBtn": "Edit",
"editorPageTitle": "Edit invoice draft",
"editorBillToHeading": "Bill to",
"editorNoBillingSnapshot": "No billing address on file for this customer. Issuance will fail until billing info is set.",
"editorMetadataHeading": "Invoice details",
"editorIssueDateLabel": "Issue date",
"editorDueDateLabel": "Due date",
"editorLocaleLabel": "Document language",
"editorPaymentMethodLabel": "Payment method",
"editorPaymentInvoice": "Bank transfer (invoice)",
"editorPaymentCard": "Credit card (Stripe)",
"editorLinesHeading": "Line items",
"editorLineDescription": "Description",
"editorLineDescriptionPlaceholder": "e.g. Consulting hours, custom integration, …",
"editorLineQty": "Qty",
"editorLineUnitPrice": "Unit price",
"editorLineAmount": "Amount",
"editorLineRemove": "Remove line",
"editorAddLine": "Add line",
"editorAddDiscount": "Add discount",
"editorAddDiscountHint": "Adds a line with negative unit price. Edit description and amount as needed.",
"editorRabattDefaultDescription": "Discount",
"editorNotesHeading": "Internal notes",
"editorNotesPlaceholder": "Notes only visible to admin (not on the invoice PDF)",
"editorNotesHint": "Not shown to the customer.",
"editorTotalsHeading": "Totals (estimate)",
"editorSubtotal": "Subtotal",
"editorVat": "VAT",
"editorTotal": "Total",
"editorTotalsEstimateNote": "Estimate based on customer country. Final VAT is computed at issuance.",
"editorSaveBtn": "Save draft",
"editorSavedBtn": "Saved",
"editorPreviewBtn": "Preview PDF",
"editorIssueBtn": "Issue invoice",
"editorDeleteBtn": "Discard draft",
"editorIssueConfirm": "Issue this invoice now? An invoice number will be allocated, the PDF will be sent to the customer, and this draft will be removed.",
"editorDeleteConfirm": "Discard this draft? This cannot be undone.",
"previewing": "Opening…",
"issuing": "Issuing…"
},
"skillCostDialog": {
"title": "Confirm activation cost",
@@ -772,7 +837,9 @@
"paid": "Paid",
"overdue": "Overdue",
"void": "Void",
"uncollectible": "Uncollectible"
"uncollectible": "Uncollectible",
"partially_refunded": "Partially refunded",
"fully_refunded": "Fully refunded"
},
"payWithCard": "Pay with card",
"redirectingToStripe": "Redirecting…",

View File

@@ -699,7 +699,72 @@
"creditNotePdfHeader": "PDF",
"creditNoteKind_void": "Annulation",
"creditNoteKind_refund": "Remboursement",
"creditNoteNoPdf": "—"
"creditNoteNoPdf": "—",
"refundAmountLabel": "Montant",
"refundReasonLabel": "Motif",
"refundAmountInclVatHint": "TVA incluse",
"newInvoiceBtn": "Nouvelle facture",
"draftsLink": "Brouillons",
"backToDrafts": "Retour aux brouillons",
"newInvoicePageTitle": "Nouvelle facture",
"newInvoicePageSubtitle": "Choisissez le client à facturer. Vous ajouterez les lignes à l'étape suivante.",
"newInvoiceOrgLabel": "Client",
"newInvoiceOrgPlaceholder": "— sélectionner un client —",
"newInvoiceOrgNoBilling": "pas d'adresse de facturation",
"newInvoiceOrgBillingMissing": "Ce client n'a pas d'adresse de facturation. Demandez-lui de compléter l'inscription ou renseignez-la depuis le panneau d'administration avant d'émettre.",
"newInvoiceLocaleLabel": "Langue du document",
"newInvoiceOrgRequired": "Veuillez sélectionner un client.",
"newInvoiceContinueBtn": "Continuer",
"creating": "Création…",
"draftsPageTitle": "Brouillons de factures",
"draftsPageSubtitle": "Factures personnalisées en cours. Reprenez l'édition ou supprimez.",
"draftsEmpty": "Aucun brouillon pour le moment. Démarrez une nouvelle facture.",
"draftOrgCol": "Client",
"draftIssueDateCol": "Date d'émission",
"draftLinesCol": "Lignes",
"draftSubtotalCol": "Sous-total (est.)",
"draftUpdatedCol": "Modifié",
"draftActionsCol": "Actions",
"draftDeleteConfirm": "Supprimer ce brouillon ? Cette action est irréversible.",
"editBtn": "Modifier",
"editorPageTitle": "Modifier le brouillon de facture",
"editorBillToHeading": "Destinataire",
"editorNoBillingSnapshot": "Aucune adresse de facturation pour ce client. L'émission échouera tant que les informations de facturation ne sont pas renseignées.",
"editorMetadataHeading": "Détails de la facture",
"editorIssueDateLabel": "Date d'émission",
"editorDueDateLabel": "Date d'échéance",
"editorLocaleLabel": "Langue du document",
"editorPaymentMethodLabel": "Mode de paiement",
"editorPaymentInvoice": "Virement (facture)",
"editorPaymentCard": "Carte bancaire (Stripe)",
"editorLinesHeading": "Lignes",
"editorLineDescription": "Description",
"editorLineDescriptionPlaceholder": "p.ex. Heures de conseil, intégration sur mesure, …",
"editorLineQty": "Qté",
"editorLineUnitPrice": "Prix unitaire",
"editorLineAmount": "Montant",
"editorLineRemove": "Supprimer la ligne",
"editorAddLine": "Ajouter une ligne",
"editorAddDiscount": "Ajouter une remise",
"editorAddDiscountHint": "Ajoute une ligne avec un prix unitaire négatif. Modifiez la description et le montant si nécessaire.",
"editorRabattDefaultDescription": "Remise",
"editorNotesHeading": "Notes internes",
"editorNotesPlaceholder": "Notes visibles uniquement par l'administrateur (pas sur le PDF)",
"editorNotesHint": "Non visible par le client.",
"editorTotalsHeading": "Totaux (estimation)",
"editorSubtotal": "Sous-total",
"editorVat": "TVA",
"editorTotal": "Total",
"editorTotalsEstimateNote": "Estimation basée sur le pays du client. La TVA finale est calculée à l'émission.",
"editorSaveBtn": "Enregistrer le brouillon",
"editorSavedBtn": "Enregistré",
"editorPreviewBtn": "Aperçu PDF",
"editorIssueBtn": "Émettre la facture",
"editorDeleteBtn": "Supprimer le brouillon",
"editorIssueConfirm": "Émettre cette facture maintenant ? Un numéro de facture sera attribué, le PDF sera envoyé au client et ce brouillon sera supprimé.",
"editorDeleteConfirm": "Supprimer ce brouillon ? Cette action est irréversible.",
"previewing": "Ouverture…",
"issuing": "Émission…"
},
"skillCostDialog": {
"title": "Confirmer le coût d'activation",
@@ -772,7 +837,9 @@
"paid": "Payée",
"overdue": "En retard",
"void": "Annulée",
"uncollectible": "Irrécouvrable"
"uncollectible": "Irrécouvrable",
"partially_refunded": "Partiellement remboursée",
"fully_refunded": "Entièrement remboursée"
},
"payWithCard": "Payer par carte",
"redirectingToStripe": "Redirection…",

View File

@@ -699,7 +699,72 @@
"creditNotePdfHeader": "PDF",
"creditNoteKind_void": "Annullamento",
"creditNoteKind_refund": "Rimborso",
"creditNoteNoPdf": "—"
"creditNoteNoPdf": "—",
"refundAmountLabel": "Importo",
"refundReasonLabel": "Motivo",
"refundAmountInclVatHint": "IVA inclusa",
"newInvoiceBtn": "Nuova fattura",
"draftsLink": "Bozze",
"backToDrafts": "Torna alle bozze",
"newInvoicePageTitle": "Nuova fattura",
"newInvoicePageSubtitle": "Scegli il cliente da fatturare. Aggiungerai le righe nel passaggio successivo.",
"newInvoiceOrgLabel": "Cliente",
"newInvoiceOrgPlaceholder": "— seleziona cliente —",
"newInvoiceOrgNoBilling": "nessun indirizzo di fatturazione",
"newInvoiceOrgBillingMissing": "Questo cliente non ha un indirizzo di fatturazione registrato. Chiedi al cliente di completare l'onboarding o imposta i dati dal pannello admin prima di emettere.",
"newInvoiceLocaleLabel": "Lingua del documento",
"newInvoiceOrgRequired": "Selezionare un cliente.",
"newInvoiceContinueBtn": "Continua",
"creating": "Creazione…",
"draftsPageTitle": "Bozze di fatture",
"draftsPageSubtitle": "Fatture personalizzate in corso. Riprendi la modifica o scarta.",
"draftsEmpty": "Ancora nessuna bozza. Inizia una nuova fattura.",
"draftOrgCol": "Cliente",
"draftIssueDateCol": "Data emissione",
"draftLinesCol": "Righe",
"draftSubtotalCol": "Subtotale (stima)",
"draftUpdatedCol": "Modificato",
"draftActionsCol": "Azioni",
"draftDeleteConfirm": "Scartare questa bozza? Operazione irreversibile.",
"editBtn": "Modifica",
"editorPageTitle": "Modifica bozza di fattura",
"editorBillToHeading": "Destinatario",
"editorNoBillingSnapshot": "Nessun indirizzo di fatturazione per questo cliente. L'emissione fallirà finché i dati di fatturazione non saranno impostati.",
"editorMetadataHeading": "Dettagli fattura",
"editorIssueDateLabel": "Data emissione",
"editorDueDateLabel": "Data scadenza",
"editorLocaleLabel": "Lingua del documento",
"editorPaymentMethodLabel": "Metodo di pagamento",
"editorPaymentInvoice": "Bonifico (fattura)",
"editorPaymentCard": "Carta di credito (Stripe)",
"editorLinesHeading": "Voci",
"editorLineDescription": "Descrizione",
"editorLineDescriptionPlaceholder": "es. Ore di consulenza, integrazione su misura, …",
"editorLineQty": "Q.tà",
"editorLineUnitPrice": "Prezzo unitario",
"editorLineAmount": "Importo",
"editorLineRemove": "Rimuovi riga",
"editorAddLine": "Aggiungi riga",
"editorAddDiscount": "Aggiungi sconto",
"editorAddDiscountHint": "Aggiunge una riga con prezzo unitario negativo. Modifica descrizione e importo se necessario.",
"editorRabattDefaultDescription": "Sconto",
"editorNotesHeading": "Note interne",
"editorNotesPlaceholder": "Note visibili solo all'admin (non sul PDF)",
"editorNotesHint": "Non mostrato al cliente.",
"editorTotalsHeading": "Totali (stima)",
"editorSubtotal": "Subtotale",
"editorVat": "IVA",
"editorTotal": "Totale",
"editorTotalsEstimateNote": "Stima basata sul paese del cliente. L'IVA finale è calcolata all'emissione.",
"editorSaveBtn": "Salva bozza",
"editorSavedBtn": "Salvato",
"editorPreviewBtn": "Anteprima PDF",
"editorIssueBtn": "Emetti fattura",
"editorDeleteBtn": "Scarta bozza",
"editorIssueConfirm": "Emettere questa fattura ora? Verrà assegnato un numero di fattura, il PDF sarà inviato al cliente e questa bozza verrà rimossa.",
"editorDeleteConfirm": "Scartare questa bozza? Operazione irreversibile.",
"previewing": "Apertura…",
"issuing": "Emissione…"
},
"skillCostDialog": {
"title": "Conferma costi di attivazione",
@@ -772,7 +837,9 @@
"paid": "Pagata",
"overdue": "In ritardo",
"void": "Annullata",
"uncollectible": "Inesigibile"
"uncollectible": "Inesigibile",
"partially_refunded": "Rimborsata parzialmente",
"fully_refunded": "Rimborsata integralmente"
},
"payWithCard": "Paga con carta",
"redirectingToStripe": "Reindirizzamento…",

View File

@@ -616,7 +616,11 @@ export type InvoiceLineKind =
| "threema_messages"
| "skill_usage"
| "skill_setup"
| "adjustment";
| "adjustment"
// Phase 8 — line kind for ad-hoc invoices. Rendered under a
// "Services" / "Leistungen" header on the PDF. Negative
// unitPriceChf is allowed (used for Rabatt rows).
| "custom_line";
/**
* Snapshot of the customer's billing details captured at invoice
@@ -669,8 +673,19 @@ export interface Invoice {
id: string;
invoiceNumber: string;
zitadelOrgId: string;
periodStart: string; // ISO date (YYYY-MM-DD)
periodEnd: string;
/**
* Phase 8: invoice provenance. 'auto' = generated by the monthly
* cron from tenant usage; 'custom' = created via the admin
* "New invoice" flow. Custom invoices have nullable period_start
* / period_end and skip the per-org-per-month uniqueness guard.
* Defaults to 'auto' for all pre-Phase-8 rows (backfilled by the
* column DEFAULT).
*/
source: "auto" | "custom";
// Billing period — null on custom invoices that aren't tied to a
// billing period.
periodStart: string | null;
periodEnd: string | null;
issuedAt: string;
dueAt: string;
subtotalChf: number;
@@ -715,8 +730,21 @@ export interface InvoiceDetail {
*/
export interface InvoiceDraft {
zitadelOrgId: string;
periodStart: string;
periodEnd: string;
/**
* Phase 8: optional for custom invoices. The auto cron always
* sets both period_start and period_end; the custom flow may
* leave them null.
*/
periodStart: string | null;
periodEnd: string | null;
/**
* Phase 8: optional override of the issue date. When omitted,
* the DB uses now() at insertion time. The custom flow uses
* this to let admin backdate or future-date invoices.
*/
issuedAt?: string;
/** Phase 8: 'auto' (cron) or 'custom' (admin form). Defaults to 'auto'. */
source?: "auto" | "custom";
dueAt: string;
locale: string;
paymentMethod: InvoicePaymentMethod;
@@ -734,6 +762,65 @@ export interface InvoiceDraft {
warnings: string[];
}
// ---------------------------------------------------------------------------
// Phase 8 — custom invoice drafts
// ---------------------------------------------------------------------------
/**
* The shape persisted in the invoice_drafts.payload JSONB column.
* This is the in-progress form state the admin is composing — not
* yet an invoice. On "Issue" it's converted into a real Invoice row
* via billing.issueCustomInvoiceDraft and the draft row deleted.
*
* Kept separate from InvoiceDraft (which is the compute pipeline's
* type for in-flight monthly bills) so the two domains don't
* accidentally drift.
*/
export interface CustomInvoiceDraftPayload {
/** ISO date (YYYY-MM-DD). Defaults to today on creation. */
issueDate: string;
/** ISO date (YYYY-MM-DD). Defaults to issueDate + 30 days. */
dueDate: string;
/** Locale for the PDF and email; defaults to org's default. */
locale: "de" | "en" | "fr" | "it";
paymentMethod: InvoicePaymentMethod;
/**
* Optional notes only the admin sees in the portal (not on the PDF).
*/
adminNotes?: string;
lines: CustomInvoiceDraftLine[];
}
export interface CustomInvoiceDraftLine {
/** Free-text description, shown on the PDF as the line label. */
description: string;
/**
* Decimal quantity. Most cases are integer (1, 2, 10 hours) but
* we allow decimal for fractional hours (0.5) or ratios.
*/
quantity: number;
/**
* CHF per unit. Negative values are allowed for discount /
* Rabatt rows — the PDF shows them as negative amounts and the
* subtotal is the algebraic sum of all line amounts.
*/
unitPriceChf: number;
}
/**
* The DB row in invoice_drafts. The admin can save a draft, come
* back later, and issue it (or delete it) at any time. Drafts have
* no invoice number, no PDF, and are never visible to the customer.
*/
export interface InvoiceDraftRecord {
id: string;
zitadelOrgId: string;
createdBy: string;
createdAt: string;
updatedAt: string;
payload: CustomInvoiceDraftPayload;
}
// ---------------------------------------------------------------------------
// Skill activation requests — manual provisioning queue
// ---------------------------------------------------------------------------