From c8ed27157fb056709acd4065db5efd01993e942f Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 24 May 2026 13:51:38 +0200 Subject: [PATCH] Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt --- next.config.mjs | 6 +- package.json | 1 + .../[locale]/admin/billing/generate/page.tsx | 71 ++ .../admin/billing/invoices/[id]/page.tsx | 35 + .../[locale]/admin/billing/invoices/page.tsx | 39 + src/app/[locale]/admin/billing/page.tsx | 128 +++ .../[locale]/admin/billing/pricing/page.tsx | 55 ++ src/app/[locale]/admin/page.tsx | 20 +- src/app/api/admin/billing/generate/route.ts | 66 ++ .../billing/invoices/[id]/mark-paid/route.ts | 81 ++ .../admin/billing/invoices/[id]/pdf/route.ts | 39 + .../api/admin/billing/invoices/[id]/route.ts | 55 ++ src/app/api/admin/billing/invoices/route.ts | 44 ++ src/app/api/admin/billing/orgs/route.ts | 80 ++ src/app/api/admin/billing/pricing/route.ts | 59 ++ .../billing/skill-pricing/[skill]/route.ts | 33 + .../api/admin/billing/skill-pricing/route.ts | 76 ++ .../admin/billing/generate-form.tsx | 345 ++++++++ .../admin/billing/invoice-detail-view.tsx | 307 ++++++++ .../admin/billing/invoices-table.tsx | 183 +++++ .../admin/billing/pricing-editor.tsx | 399 ++++++++++ src/lib/billing-pdf.tsx | 651 ++++++++++++++++ src/lib/billing.ts | 737 ++++++++++++++++++ src/lib/db.ts | 427 ++++++++++ src/messages/de.json | 103 ++- src/messages/en.json | 103 ++- src/messages/fr.json | 103 ++- src/messages/it.json | 103 ++- src/types/index.ts | 127 +++ 29 files changed, 4465 insertions(+), 11 deletions(-) create mode 100644 src/app/[locale]/admin/billing/generate/page.tsx create mode 100644 src/app/[locale]/admin/billing/invoices/[id]/page.tsx create mode 100644 src/app/[locale]/admin/billing/invoices/page.tsx create mode 100644 src/app/[locale]/admin/billing/page.tsx create mode 100644 src/app/[locale]/admin/billing/pricing/page.tsx create mode 100644 src/app/api/admin/billing/generate/route.ts create mode 100644 src/app/api/admin/billing/invoices/[id]/mark-paid/route.ts create mode 100644 src/app/api/admin/billing/invoices/[id]/pdf/route.ts create mode 100644 src/app/api/admin/billing/invoices/[id]/route.ts create mode 100644 src/app/api/admin/billing/invoices/route.ts create mode 100644 src/app/api/admin/billing/orgs/route.ts create mode 100644 src/app/api/admin/billing/pricing/route.ts create mode 100644 src/app/api/admin/billing/skill-pricing/[skill]/route.ts create mode 100644 src/app/api/admin/billing/skill-pricing/route.ts create mode 100644 src/components/admin/billing/generate-form.tsx create mode 100644 src/components/admin/billing/invoice-detail-view.tsx create mode 100644 src/components/admin/billing/invoices-table.tsx create mode 100644 src/components/admin/billing/pricing-editor.tsx create mode 100644 src/lib/billing-pdf.tsx create mode 100644 src/lib/billing.ts diff --git a/next.config.mjs b/next.config.mjs index 79770af..ea2e86a 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -5,7 +5,11 @@ const withNextIntl = createNextIntlPlugin(); /** @type {import('next').NextConfig} */ const nextConfig = { output: "standalone", - serverExternalPackages: ["pg"], + // pg uses native node bindings, @react-pdf/renderer pulls in + // fontkit / pdfkit which don't play nicely with webpack bundling. + // Both are pure server-side concerns; mark external so Next ships + // them as Node modules rather than bundling. + serverExternalPackages: ["pg", "@react-pdf/renderer"], }; export default withNextIntl(nextConfig); diff --git a/package.json b/package.json index a3a6627..4d35413 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@kubernetes/client-node": "^1.4.0", + "@react-pdf/renderer": "^4.4.0", "@types/nodemailer": "^8.0.0", "@types/pg": "^8.20.0", "next": "^15.5.15", diff --git a/src/app/[locale]/admin/billing/generate/page.tsx b/src/app/[locale]/admin/billing/generate/page.tsx new file mode 100644 index 0000000..0acc586 --- /dev/null +++ b/src/app/[locale]/admin/billing/generate/page.tsx @@ -0,0 +1,71 @@ +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 { GenerateForm } from "@/components/admin/billing/generate-form"; + +/** + * /admin/billing/generate — testing tool to compute & commit an + * invoice for a given (org, period). + * + * Workflow: + * 1. Admin picks org + year/month + locale (default auto-detected + * from country). + * 2. "Preview" runs computeInvoiceDraft (dryRun) — shows lines, + * totals, warnings. + * 3. "Commit" persists + renders the PDF. + * + * The org dropdown is hydrated server-side here so the page loads + * with the list pre-populated. Per-org billing status (address + * present / open balance) is fetched on demand from /api/admin/ + * billing/orgs since it can change as admin edits. + */ +export default async function AdminBillingGeneratePage() { + const user = await getSessionUser(); + if (!user) redirect("/login"); + if (!user.isPlatform) redirect("/dashboard"); + const t = await getTranslations("adminBilling"); + + // Build initial org list from tenant labels. + const tenants = await listTenants(); + const orgMap = new Map(); + for (const t of tenants) { + const oid = t.metadata.labels?.["pieced.ch/zitadel-org-id"]; + if (!oid) continue; + if (!orgMap.has(oid)) orgMap.set(oid, []); + orgMap.get(oid)!.push(t.metadata.name); + } + // Hydrate company name + country in parallel. + const orgList = await Promise.all( + [...orgMap.entries()].map(async ([orgId, tenantNames]) => { + const billing = await getOrgBilling(orgId).catch(() => null); + return { + zitadelOrgId: orgId, + tenantNames, + companyName: billing?.companyName ?? null, + country: billing?.country ?? null, + hasBillingAddress: !!billing, + }; + }) + ); + orgList.sort((a, b) => + (a.companyName ?? a.zitadelOrgId).localeCompare( + b.companyName ?? b.zitadelOrgId + ) + ); + + return ( +
+ +
+

+ {t("generateTitle")} +

+

{t("generatePageDesc")}

+
+ +
+ ); +} diff --git a/src/app/[locale]/admin/billing/invoices/[id]/page.tsx b/src/app/[locale]/admin/billing/invoices/[id]/page.tsx new file mode 100644 index 0000000..f263e97 --- /dev/null +++ b/src/app/[locale]/admin/billing/invoices/[id]/page.tsx @@ -0,0 +1,35 @@ +import { notFound, redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import { getSessionUser } from "@/lib/session"; +import { getInvoiceDetail } from "@/lib/db"; +import { BackLink } from "@/components/ui/back-link"; +import { InvoiceDetailView } from "@/components/admin/billing/invoice-detail-view"; + +/** + * /admin/billing/invoices/[id] — full detail of one invoice. + * + * Server-renders the static body (header, lines, totals, billing + * snapshot); the action bar (mark-paid, delete, PDF download) is + * a client component for the interactive bits. + */ +export default async function AdminInvoiceDetailPage({ + 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 detail = await getInvoiceDetail(id); + if (!detail) notFound(); + + return ( +
+ + +
+ ); +} diff --git a/src/app/[locale]/admin/billing/invoices/page.tsx b/src/app/[locale]/admin/billing/invoices/page.tsx new file mode 100644 index 0000000..66665f5 --- /dev/null +++ b/src/app/[locale]/admin/billing/invoices/page.tsx @@ -0,0 +1,39 @@ +import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import { getSessionUser } from "@/lib/session"; +import { listInvoices, syncOverdueInvoices } from "@/lib/db"; +import { BackLink } from "@/components/ui/back-link"; +import { InvoicesTable } from "@/components/admin/billing/invoices-table"; + +/** + * /admin/billing/invoices — list of all issued invoices, filterable + * by status and month. Click a row to drill into detail. + * + * Server-renders the initial table with no filters applied (showing + * the most recent 200). Client filters trigger a fetch with query + * params and re-render in place. + */ +export default async function AdminInvoicesListPage() { + const user = await getSessionUser(); + if (!user) redirect("/login"); + if (!user.isPlatform) redirect("/dashboard"); + const t = await getTranslations("adminBilling"); + + await syncOverdueInvoices().catch((e) => + console.error("syncOverdueInvoices failed:", e) + ); + const invoices = await listInvoices({ limit: 200 }); + + return ( +
+ +
+

+ {t("invoicesTitle")} +

+

{t("invoicesPageDesc")}

+
+ +
+ ); +} diff --git a/src/app/[locale]/admin/billing/page.tsx b/src/app/[locale]/admin/billing/page.tsx new file mode 100644 index 0000000..de06f6e --- /dev/null +++ b/src/app/[locale]/admin/billing/page.tsx @@ -0,0 +1,128 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import { getSessionUser } from "@/lib/session"; +import { getOrgOpenBalances, syncOverdueInvoices } from "@/lib/db"; +import { Card } from "@/components/ui/card"; + +/** + * /admin/billing — landing page with sub-section links and a + * quick overview of orgs in arrears. + * + * Sub-pages: + * - /admin/billing/pricing — platform + skill prices + * - /admin/billing/generate — manual invoice generator (testing) + * - /admin/billing/invoices — invoice list/detail + * + * The Phase 2 customer-side /billing landing page is added in + * Phase 3. + */ +export default async function AdminBillingPage() { + const user = await getSessionUser(); + if (!user) redirect("/login"); + if (!user.isPlatform) redirect("/dashboard"); + const t = await getTranslations("adminBilling"); + + // Sweep open invoices past due → 'overdue' so the counters below + // reflect reality without needing a cron. + await syncOverdueInvoices().catch((e) => + console.error("syncOverdueInvoices failed:", e) + ); + const balances = await getOrgOpenBalances().catch(() => []); + const totalOpen = balances.reduce((acc, b) => acc + b.totalOpenChf, 0); + const totalOverdue = balances.reduce((acc, b) => acc + b.overdueCount, 0); + + return ( +
+
+

+ {t("title")} +

+

{t("subtitle")}

+
+ + {/* Stats strip */} +
+ +
{t("totalOpenBalance")}
+
+ CHF {totalOpen.toFixed(2)} +
+
+ +
{t("orgsWithBalance")}
+
{balances.length}
+
+ +
{t("overdueInvoices")}
+
+ {totalOverdue > 0 ? ( + {totalOverdue} + ) : ( + totalOverdue + )} +
+
+
+ + {/* Sub-tool cards */} +
+ + +
{t("pricingTitle")}
+
{t("pricingDesc")}
+
+ + + +
{t("generateTitle")}
+
{t("generateDesc")}
+
+ + + +
{t("invoicesTitle")}
+
{t("invoicesDesc")}
+
+ +
+ + {/* Orgs with open balance */} + {balances.length > 0 && ( +
+

{t("balancesTitle")}

+ + + + + + + + + + + + {balances.map((b) => ( + + + + + + + ))} + +
{t("orgIdCol")}{t("openCountCol")}{t("overdueCountCol")}{t("totalOpenCol")}
{b.zitadelOrgId}{b.openCount} + {b.overdueCount > 0 ? ( + {b.overdueCount} + ) : ( + 0 + )} + + CHF {b.totalOpenChf.toFixed(2)} +
+
+
+ )} +
+ ); +} diff --git a/src/app/[locale]/admin/billing/pricing/page.tsx b/src/app/[locale]/admin/billing/pricing/page.tsx new file mode 100644 index 0000000..b60a564 --- /dev/null +++ b/src/app/[locale]/admin/billing/pricing/page.tsx @@ -0,0 +1,55 @@ +import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import { getSessionUser } from "@/lib/session"; +import { getPlatformPricing, listSkillPricing } from "@/lib/db"; +import { PACKAGE_CATALOG } from "@/lib/packages"; +import { BackLink } from "@/components/ui/back-link"; +import { PricingEditor } from "@/components/admin/billing/pricing-editor"; + +/** + * /admin/billing/pricing — edit platform-wide pricing config + * (monthly fee, setup fee, Threema per-message, VAT rate for + * CH/LI) and per-skill daily prices. + * + * Single-row platform_pricing semantics: one global pricing + * config applies to every tenant. No per-tenant overrides in + * v1. + */ +export default async function AdminBillingPricingPage() { + const user = await getSessionUser(); + if (!user) redirect("/login"); + if (!user.isPlatform) redirect("/dashboard"); + const t = await getTranslations("adminBilling"); + + const [pricing, skillPricing] = await Promise.all([ + getPlatformPricing(), + listSkillPricing(), + ]); + + // Surface every package in the catalog so admin can price any of + // them — UI defaults the picker to skill-kind entries but doesn't + // hard-block other kinds (a future scenario where a non-skill + // package gets a per-day price shouldn't need a code change). + const catalog = Object.values(PACKAGE_CATALOG).map((p) => ({ + id: p.id, + name: p.name, + kind: p.kind, + })); + + return ( +
+ +
+

+ {t("pricingTitle")} +

+

{t("pricingPageDesc")}

+
+ +
+ ); +} diff --git a/src/app/[locale]/admin/page.tsx b/src/app/[locale]/admin/page.tsx index 5705b7f..96ab7b5 100644 --- a/src/app/[locale]/admin/page.tsx +++ b/src/app/[locale]/admin/page.tsx @@ -32,12 +32,20 @@ export default async function AdminPage() { {/* Sub-tools: links to other admin pages. Plain links rather than nav-shell entries — these are platform-team utilities, not main navigation. */} - - {t("openclawTool")} - +
+ + {t("billingTool")} + + + {t("openclawTool")} + +
diff --git a/src/app/api/admin/billing/generate/route.ts b/src/app/api/admin/billing/generate/route.ts new file mode 100644 index 0000000..0a5a5d9 --- /dev/null +++ b/src/app/api/admin/billing/generate/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { requirePlatformRole } from "@/lib/session"; +import { generateInvoice } from "@/lib/billing"; +import { safeError } from "@/lib/errors"; + +/** + * POST /api/admin/billing/generate + * + * Compute (and optionally commit) an invoice for an (org, year, + * month). Platform-only — this is the testing/admin tool. + * + * Body: + * { + * zitadelOrgId: string, + * year: number (e.g. 2026), + * month: number (1-12), + * locale?: 'de' | 'en' | 'fr' | 'it', // default: from country + * dryRun?: boolean // default: false + * } + * + * Response on success: + * { + * draft: InvoiceDraft, // line breakdown + warnings + * invoice: Invoice | null, // null when dryRun=true + * } + * + * If an invoice for that (org, period) already exists, returns + * 409 with a clear message. Use the delete endpoint first to + * regenerate. + */ + +const bodySchema = z.object({ + zitadelOrgId: z.string().min(1), + year: z.number().int().min(2020).max(2100), + month: z.number().int().min(1).max(12), + locale: z.enum(["de", "en", "fr", "it"]).optional(), + dryRun: z.boolean().optional().default(false), +}); + +export async function POST(request: Request) { + try { + await requirePlatformRole(); + } catch { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + const body = await request.json().catch(() => ({})); + const parsed = bodySchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request", details: parsed.error.flatten() }, + { status: 400 } + ); + } + try { + const result = await generateInvoice(parsed.data); + return NextResponse.json(result); + } catch (e: any) { + console.error("Invoice generation failed:", e); + const msg = safeError(e, "Generation failed"); + // Specific 409 for the "already exists" case so the UI can + // show a "delete first" link. + const status = /already exists/i.test(msg) ? 409 : 500; + return NextResponse.json({ error: msg }, { status }); + } +} diff --git a/src/app/api/admin/billing/invoices/[id]/mark-paid/route.ts b/src/app/api/admin/billing/invoices/[id]/mark-paid/route.ts new file mode 100644 index 0000000..b5de65e --- /dev/null +++ b/src/app/api/admin/billing/invoices/[id]/mark-paid/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { requirePlatformRole, getSessionUser } from "@/lib/session"; +import { markInvoicePaid } from "@/lib/db"; +import { safeError } from "@/lib/errors"; + +/** + * POST /api/admin/billing/invoices/[id]/mark-paid + * + * Manually mark an open/overdue invoice as paid. Used for the + * "pay by invoice" flow where the customer transfers money to + * the bank account printed on the PDF and the admin reconciles + * by hand. + * + * Body (all optional): + * { + * paidAt?: ISO timestamp, // defaults to now + * note?: string // free-form, stored in paid_method_detail + * } + * + * paid_by is set to the admin user's id automatically. + * Idempotent: trying to mark an already-paid invoice returns 409. + * + * Phase 4 will introduce a parallel auto-paid path triggered by + * Stripe webhooks; for Phase 2 this is the only way to flip the + * status. + */ + +const bodySchema = z.object({ + paidAt: z.string().datetime().optional(), + note: z.string().max(500).optional(), +}); + +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; + const body = await request.json().catch(() => ({})); + const parsed = bodySchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request", details: parsed.error.flatten() }, + { status: 400 } + ); + } + try { + const detail = parsed.data.note + ? `${user.id}: ${parsed.data.note}` + : user.id; + const invoice = await markInvoicePaid(id, { + paidBy: "manual", + paidMethodDetail: detail, + paidAt: parsed.data.paidAt ? new Date(parsed.data.paidAt) : undefined, + }); + if (!invoice) { + // Either not found or status not in {open, overdue}. + return NextResponse.json( + { error: "Invoice not found, or already paid/void." }, + { status: 409 } + ); + } + return NextResponse.json(invoice); + } catch (e) { + console.error("Failed to mark invoice paid:", e); + return NextResponse.json( + { error: safeError(e, "Mark-paid failed") }, + { status: 500 } + ); + } +} diff --git a/src/app/api/admin/billing/invoices/[id]/pdf/route.ts b/src/app/api/admin/billing/invoices/[id]/pdf/route.ts new file mode 100644 index 0000000..d540fab --- /dev/null +++ b/src/app/api/admin/billing/invoices/[id]/pdf/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { requirePlatformRole } from "@/lib/session"; +import { getInvoicePdf } from "@/lib/db"; + +/** + * GET /api/admin/billing/invoices/[id]/pdf + * + * Streams the stored PDF bytes for an invoice. The bytea column is + * read once and returned as an octet stream; no on-the-fly + * re-rendering — PDFs are immutable once issued. + * + * Phase 3 will add a parallel customer-facing route at + * /api/billing/invoices/[id]/pdf with org-scoped authorization. + */ +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + await requirePlatformRole(); + } catch { + return new NextResponse("Forbidden", { status: 403 }); + } + const { id } = await params; + const pdf = await getInvoicePdf(id); + if (!pdf) { + return new NextResponse("Not found", { status: 404 }); + } + // Construct a response that the browser will render inline (PDF + // viewer) but also offer to download with the right filename. + return new NextResponse(pdf.data, { + status: 200, + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": `inline; filename="${pdf.filename}"`, + "Cache-Control": "private, max-age=0, must-revalidate", + }, + }); +} diff --git a/src/app/api/admin/billing/invoices/[id]/route.ts b/src/app/api/admin/billing/invoices/[id]/route.ts new file mode 100644 index 0000000..a0b95bb --- /dev/null +++ b/src/app/api/admin/billing/invoices/[id]/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server"; +import { requirePlatformRole } from "@/lib/session"; +import { deleteInvoice, getInvoiceDetail } from "@/lib/db"; +import { safeError } from "@/lib/errors"; + +/** + * GET /api/admin/billing/invoices/[id] + * Detail view: invoice + lines. + * + * DELETE /api/admin/billing/invoices/[id] + * Hard delete (testing tool). Invoice number is consumed — gaps + * in the sequence are intentional and documented. Reminders + * (and their PDFs) cascade-delete via the FK. + */ +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; + const detail = await getInvoiceDetail(id); + if (!detail) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + return NextResponse.json(detail); +} + +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 ok = await deleteInvoice(id); + if (!ok) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + return NextResponse.json({ message: "Deleted." }); + } catch (e) { + console.error("Failed to delete invoice:", e); + return NextResponse.json( + { error: safeError(e, "Delete failed") }, + { status: 500 } + ); + } +} diff --git a/src/app/api/admin/billing/invoices/route.ts b/src/app/api/admin/billing/invoices/route.ts new file mode 100644 index 0000000..e7683df --- /dev/null +++ b/src/app/api/admin/billing/invoices/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { requirePlatformRole } from "@/lib/session"; +import { listInvoices, syncOverdueInvoices } from "@/lib/db"; +import type { InvoiceStatus } from "@/types"; + +/** + * GET /api/admin/billing/invoices + * + * List invoices for admin. Optional filters: + * ?status=open|paid|overdue|void|uncollectible + * ?orgId=... + * ?month=YYYY-MM + * ?limit=200 + * + * Refreshes overdue status on each call (cheap UPDATE), so the + * admin list always reflects the latest due-date math without + * needing a cron. + */ +export async function GET(request: Request) { + try { + await requirePlatformRole(); + } catch { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + await syncOverdueInvoices().catch((e) => + console.error("syncOverdueInvoices failed:", e) + ); + + const { searchParams } = new URL(request.url); + const status = searchParams.get("status") as InvoiceStatus | null; + const orgId = searchParams.get("orgId"); + const month = searchParams.get("month"); + const limitParam = searchParams.get("limit"); + const limit = limitParam ? Math.max(1, Math.min(1000, parseInt(limitParam, 10))) : 200; + + const invoices = await listInvoices({ + status: status ?? undefined, + zitadelOrgId: orgId ?? undefined, + periodMonth: month ?? undefined, + limit, + }); + return NextResponse.json(invoices); +} diff --git a/src/app/api/admin/billing/orgs/route.ts b/src/app/api/admin/billing/orgs/route.ts new file mode 100644 index 0000000..07b5c48 --- /dev/null +++ b/src/app/api/admin/billing/orgs/route.ts @@ -0,0 +1,80 @@ +import { NextResponse } from "next/server"; +import { requirePlatformRole } from "@/lib/session"; +import { listTenants } from "@/lib/k8s"; +import { getOrgBilling, getOrgOpenBalances } from "@/lib/db"; + +/** + * GET /api/admin/billing/orgs + * + * Returns the orgs known to the platform via tenant labels, with + * their billing-address-on-file status and open balance summary. + * Powers the generate form's org dropdown and the billing landing + * page's open-balance table. + * + * Each entry: + * { + * zitadelOrgId: string, + * tenantCount: number, + * hasBillingAddress: boolean, + * companyName: string | null, + * openCount: number, + * overdueCount: number, + * totalOpenChf: number + * } + */ +export async function GET() { + try { + await requirePlatformRole(); + } catch { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + // Org membership is derived from tenant labels — there's no + // separate "orgs" table on the portal. listTenants reads from + // K8s, which is the source of truth. + const tenants = await listTenants(); + const orgIdToTenants = new Map(); + for (const t of tenants) { + const oid = t.metadata.labels?.["pieced.ch/zitadel-org-id"]; + if (!oid) continue; + if (!orgIdToTenants.has(oid)) orgIdToTenants.set(oid, []); + orgIdToTenants.get(oid)!.push(t.metadata.name); + } + + const balances = await getOrgOpenBalances(); + const balanceMap = new Map(balances.map((b) => [b.zitadelOrgId, b])); + + // Hydrate billing-address presence + company name per org. + const results = await Promise.all( + [...orgIdToTenants.entries()].map(async ([orgId, tenantNames]) => { + const billing = await getOrgBilling(orgId).catch(() => null); + const bal = balanceMap.get(orgId); + return { + zitadelOrgId: orgId, + tenantCount: tenantNames.length, + tenantNames, + hasBillingAddress: !!billing, + companyName: billing?.companyName ?? null, + country: billing?.country ?? null, + openCount: bal?.openCount ?? 0, + overdueCount: bal?.overdueCount ?? 0, + totalOpenChf: bal?.totalOpenChf ?? 0, + }; + }) + ); + + // Sort: orgs with overdue first, then open, then by name. + results.sort((a, b) => { + if (a.overdueCount !== b.overdueCount) { + return b.overdueCount - a.overdueCount; + } + if (a.openCount !== b.openCount) { + return b.openCount - a.openCount; + } + return (a.companyName ?? a.zitadelOrgId).localeCompare( + b.companyName ?? b.zitadelOrgId + ); + }); + + return NextResponse.json(results); +} diff --git a/src/app/api/admin/billing/pricing/route.ts b/src/app/api/admin/billing/pricing/route.ts new file mode 100644 index 0000000..6dfc8f5 --- /dev/null +++ b/src/app/api/admin/billing/pricing/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { requirePlatformRole } from "@/lib/session"; +import { getPlatformPricing, updatePlatformPricing } from "@/lib/db"; +import { safeError } from "@/lib/errors"; + +/** + * GET /api/admin/billing/pricing + * Returns the single-row platform pricing config. + * + * PUT /api/admin/billing/pricing + * Updates one or more pricing fields. Missing fields are left + * unchanged. + * + * Both endpoints are platform-role only. + */ + +const updateSchema = z.object({ + tenantMonthlyFeeChf: z.number().min(0).max(99_999_999).optional(), + tenantSetupFeeChf: z.number().min(0).max(99_999_999).optional(), + threemaMessageChf: z.number().min(0).max(1000).optional(), + vatRateChli: z.number().min(0).max(100).optional(), +}); + +export async function GET() { + try { + await requirePlatformRole(); + } catch { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + const pricing = await getPlatformPricing(); + return NextResponse.json(pricing); +} + +export async function PUT(request: Request) { + try { + await requirePlatformRole(); + } catch { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + const body = await request.json().catch(() => ({})); + const parsed = updateSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid pricing payload", details: parsed.error.flatten() }, + { status: 400 } + ); + } + try { + const updated = await updatePlatformPricing(parsed.data); + return NextResponse.json(updated); + } catch (e) { + console.error("Failed to update platform pricing:", e); + return NextResponse.json( + { error: safeError(e, "Update failed") }, + { status: 500 } + ); + } +} diff --git a/src/app/api/admin/billing/skill-pricing/[skill]/route.ts b/src/app/api/admin/billing/skill-pricing/[skill]/route.ts new file mode 100644 index 0000000..51b75f1 --- /dev/null +++ b/src/app/api/admin/billing/skill-pricing/[skill]/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { requirePlatformRole } from "@/lib/session"; +import { removeSkillPricing } from "@/lib/db"; +import { safeError } from "@/lib/errors"; + +/** + * DELETE /api/admin/billing/skill-pricing/[skill] + * Remove pricing for a skill. Toggle events continue to be + * recorded; the skill simply becomes free starting from the next + * generated invoice. Historical invoices already issued are + * unaffected (they carry frozen line amounts). + */ +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ skill: string }> } +) { + try { + await requirePlatformRole(); + } catch { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + const { skill } = await params; + try { + await removeSkillPricing(skill); + return NextResponse.json({ message: "Removed." }); + } catch (e) { + console.error("Failed to remove skill pricing:", e); + return NextResponse.json( + { error: safeError(e, "Remove failed") }, + { status: 500 } + ); + } +} diff --git a/src/app/api/admin/billing/skill-pricing/route.ts b/src/app/api/admin/billing/skill-pricing/route.ts new file mode 100644 index 0000000..64e4359 --- /dev/null +++ b/src/app/api/admin/billing/skill-pricing/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { requirePlatformRole } from "@/lib/session"; +import { listSkillPricing, setSkillPricing } from "@/lib/db"; +import { getPackageDef } from "@/lib/packages"; +import { safeError } from "@/lib/errors"; + +/** + * GET /api/admin/billing/skill-pricing + * List all configured skill prices. + * + * PUT /api/admin/billing/skill-pricing + * Upsert a daily price for a single skill. Body: + * { skillId: string, dailyPriceChf: number } + * + * Both endpoints are platform-only. + * + * Note on skillId validation: we accept any package id that exists + * in PACKAGE_CATALOG. The PIN to "skills only" is enforced at the + * UI layer, not here, so admins can price a non-skill package in + * an emergency without code changes. + */ + +const upsertSchema = z.object({ + skillId: z.string().min(1).max(100), + dailyPriceChf: z.number().min(0).max(1_000_000), +}); + +export async function GET() { + try { + await requirePlatformRole(); + } catch { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + const rows = await listSkillPricing(); + return NextResponse.json(rows); +} + +export async function PUT(request: Request) { + try { + await requirePlatformRole(); + } catch { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + const body = await request.json().catch(() => ({})); + const parsed = upsertSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid payload", details: parsed.error.flatten() }, + { status: 400 } + ); + } + // Validate the skill id exists in PACKAGE_CATALOG. Returns null + // for unknown ids; we reject those rather than persist a row that + // would never match a real toggle event. + const pkg = getPackageDef(parsed.data.skillId); + if (!pkg) { + return NextResponse.json( + { error: `Unknown package id: ${parsed.data.skillId}` }, + { status: 400 } + ); + } + try { + const row = await setSkillPricing( + parsed.data.skillId, + parsed.data.dailyPriceChf + ); + return NextResponse.json(row); + } catch (e) { + console.error("Failed to upsert skill pricing:", e); + return NextResponse.json( + { error: safeError(e, "Upsert failed") }, + { status: 500 } + ); + } +} diff --git a/src/components/admin/billing/generate-form.tsx b/src/components/admin/billing/generate-form.tsx new file mode 100644 index 0000000..f9186d6 --- /dev/null +++ b/src/components/admin/billing/generate-form.tsx @@ -0,0 +1,345 @@ +"use client"; + +import { useState, Fragment } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { Card, CardHeader } from "@/components/ui/card"; +import type { InvoiceDraft } from "@/types"; + +interface OrgEntry { + zitadelOrgId: string; + tenantNames: 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" }, +]; + +/** + * Two-step flow: preview (dryRun) → commit. + * + * Preview displays the InvoiceDraft (lines, subtotal, VAT, total) + * plus any warnings. Admin reviews and either commits or aborts. + * Commit re-runs the generator without dryRun and redirects to the + * persisted invoice's detail page. + */ +export function GenerateForm({ orgs }: Props) { + const t = useTranslations("adminBilling"); + const router = useRouter(); + + // Default to previous calendar month — that's the typical "bill + // for last month" use case. + const now = new Date(); + const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); + const [orgId, setOrgId] = useState(orgs[0]?.zitadelOrgId ?? ""); + const [year, setYear] = useState(String(prevMonth.getFullYear())); + const [month, setMonth] = useState(String(prevMonth.getMonth() + 1)); + const [locale, setLocale] = useState(""); + const [draft, setDraft] = useState(null); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + const selectedOrg = orgs.find((o) => o.zitadelOrgId === orgId); + // Auto-detect default locale from country if admin hasn't picked + // one. Same logic as billing.ts's defaultLocaleForCountry. + const effectiveLocale = + locale || + (() => { + const c = (selectedOrg?.country || "").toUpperCase(); + if (["CH", "LI", "AT", "DE"].includes(c)) return "de"; + if (["FR", "BE", "LU"].includes(c)) return "fr"; + if (c === "IT") return "it"; + return "en"; + })(); + + const preview = async () => { + setError(""); + setDraft(null); + setBusy(true); + try { + const res = await fetch("/api/admin/billing/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + zitadelOrgId: orgId, + year: Number(year), + month: Number(month), + locale: effectiveLocale, + dryRun: true, + }), + }); + const j = await res.json(); + if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`); + setDraft(j.draft); + } catch (e: any) { + setError(e.message); + } finally { + setBusy(false); + } + }; + + const commit = async () => { + if (!draft) return; + if (!confirm(t("confirmGenerate"))) return; + setError(""); + setBusy(true); + try { + const res = await fetch("/api/admin/billing/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + zitadelOrgId: orgId, + year: Number(year), + month: Number(month), + locale: effectiveLocale, + dryRun: false, + }), + }); + const j = await res.json(); + if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`); + // Navigate to the new invoice's detail page. + if (j.invoice?.id) { + router.push(`/admin/billing/invoices/${j.invoice.id}`); + } + } catch (e: any) { + setError(e.message); + setBusy(false); + } + }; + + return ( +
+ + {t("generateFormTitle")} + {orgs.length === 0 ? ( +

{t("noOrgsToGenerate")}

+ ) : ( +
+ +
+ + + +
+
+ + {draft && ( + + )} + {error && ( + {error} + )} +
+
+ )} +
+ + {draft && } +
+ ); +} + +function DraftPreview({ draft }: { draft: InvoiceDraft }) { + const t = useTranslations("adminBilling"); + + // Group lines by tenant for the preview (matches PDF layout). + const linesByTenant = new Map(); + for (const ln of draft.lines) { + const key = ln.tenantName; + if (!linesByTenant.has(key)) linesByTenant.set(key, []); + linesByTenant.get(key)!.push(ln); + } + const tenantOrder = [...linesByTenant.keys()].sort((a, b) => { + if (a === null) return 1; + if (b === null) return -1; + return a.localeCompare(b); + }); + + return ( + + + {t("previewTitle")} — {draft.periodStart} → {draft.periodEnd} + + + {draft.warnings.length > 0 && ( +
+
{t("warningsTitle")}
+ {draft.warnings.map((w, i) => ( +
• {w}
+ ))} +
+ )} + + + + + + + + + + + + {tenantOrder.map((tenantKey) => { + const lines = linesByTenant.get(tenantKey)!; + return ( + + {tenantKey && ( + + + + )} + {lines.map((ln, i) => ( + + + + + + + ))} + + ); + })} + {draft.lines.length === 0 && ( + + + + )} + +
{t("descCol")}{t("qtyCol")}{t("unitPriceCol")}{t("amountCol")}
+ + {tenantKey} + +
+
{ln.description}
+
+ {ln.kind} +
+
+ {ln.quantity} + {ln.unitLabel ? ` ${ln.unitLabel}` : ""} + + {ln.unitPriceChf.toFixed(4)} + + {ln.amountChf.toFixed(2)} +
+ {t("noLinesGenerated")} +
+ +
+
+ {t("subtotal")} + CHF {draft.subtotalChf.toFixed(2)} +
+
+ + {t("vat")} ({draft.vatRate.toFixed(2)}%) + + CHF {draft.vatAmountChf.toFixed(2)} +
+
+ {t("total")} + CHF {draft.totalChf.toFixed(2)} +
+
+
+ ); +} diff --git a/src/components/admin/billing/invoice-detail-view.tsx b/src/components/admin/billing/invoice-detail-view.tsx new file mode 100644 index 0000000..ba28386 --- /dev/null +++ b/src/components/admin/billing/invoice-detail-view.tsx @@ -0,0 +1,307 @@ +"use client"; + +import { useState, Fragment } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { Card, CardHeader } from "@/components/ui/card"; +import type { InvoiceDetail, InvoiceStatus } from "@/types"; + +interface Props { + detail: InvoiceDetail; +} + +/** + * Renders the invoice header (status, totals, action bar) then + * line items grouped by tenant, then billing snapshot. Actions are + * mark-paid (POST), delete (DELETE), PDF download (link to /pdf). + * + * On successful action we router.refresh() — the server-side page + * re-renders against the new DB state. For delete we navigate + * away first. + */ +export function InvoiceDetailView({ detail }: Props) { + const t = useTranslations("adminBilling"); + const router = useRouter(); + const { invoice, lines } = detail; + + const [busyAction, setBusyAction] = useState( + null + ); + const [actionError, setActionError] = useState(""); + const [noteInput, setNoteInput] = useState(""); + const [noteOpen, setNoteOpen] = useState(false); + + const markPaid = async () => { + setActionError(""); + setBusyAction("mark-paid"); + try { + const res = await fetch( + `/api/admin/billing/invoices/${invoice.id}/mark-paid`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ note: noteInput || undefined }), + } + ); + const j = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`); + setNoteOpen(false); + setNoteInput(""); + router.refresh(); + } catch (e: any) { + setActionError(e.message); + } finally { + setBusyAction(null); + } + }; + + const deleteInvoice = async () => { + if (!confirm(t("confirmDeleteInvoice", { num: invoice.invoiceNumber }))) + return; + setActionError(""); + setBusyAction("delete"); + try { + const res = await fetch(`/api/admin/billing/invoices/${invoice.id}`, { + method: "DELETE", + }); + if (!res.ok) { + const j = await res.json().catch(() => ({})); + throw new Error(j.error || `HTTP ${res.status}`); + } + router.push("/admin/billing/invoices"); + } catch (e: any) { + setActionError(e.message); + setBusyAction(null); + } + }; + + // Group lines by tenant for display (matches PDF layout). + const linesByTenant = new Map(); + for (const ln of lines) { + const k = ln.tenantName; + if (!linesByTenant.has(k)) linesByTenant.set(k, []); + linesByTenant.get(k)!.push(ln); + } + const tenantOrder = [...linesByTenant.keys()].sort((a, b) => { + if (a === null) return 1; + if (b === null) return -1; + return a.localeCompare(b); + }); + + return ( +
+
+
+

+ {invoice.invoiceNumber} +

+
+ + + {invoice.periodStart} → {invoice.periodEnd} + + · + + {t("dueOnLabel")}: {invoice.dueAt} + + · + + {invoice.locale} + +
+
+
+
{t("totalLabel")}
+
+ CHF {invoice.totalChf.toFixed(2)} +
+
+
+ + {/* Action bar */} + +
+ {invoice.hasPdf && ( + + {t("downloadPdfBtn")} + + )} + {(invoice.status === "open" || invoice.status === "overdue") && ( + <> + {!noteOpen ? ( + + ) : ( +
+ setNoteInput(e.target.value)} + className="flex-grow px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm" + autoFocus + /> + + +
+ )} + + )} + +
+ {actionError && ( +
{actionError}
+ )} + {invoice.paidAt && ( +
+ {t("paidOnLabel")}: {invoice.paidAt} · {invoice.paidBy} ·{" "} + {invoice.paidMethodDetail} +
+ )} +
+ + {/* Lines */} + + {t("lineItemsTitle")} + + + + + + + + + + + {tenantOrder.map((tenantKey) => { + const tenantLines = linesByTenant.get(tenantKey)!; + return ( + + {tenantKey && ( + + + + )} + {tenantLines.map((ln) => ( + + + + + + + ))} + + ); + })} + +
{t("descCol")}{t("qtyCol")}{t("unitPriceCol")}{t("amountCol")}
+ + {tenantKey} + +
+
{ln.description}
+
+ {ln.kind} +
+
+ {ln.quantity} + {ln.unitLabel ? ` ${ln.unitLabel}` : ""} + + {ln.unitPriceChf.toFixed(4)} + + {ln.amountChf.toFixed(2)} +
+
+
+ {t("subtotal")} + CHF {invoice.subtotalChf.toFixed(2)} +
+
+ + {t("vat")} ({invoice.vatRate.toFixed(2)}%) + + CHF {invoice.vatAmountChf.toFixed(2)} +
+
+ {t("total")} + CHF {invoice.totalChf.toFixed(2)} +
+
+
+ + {/* Billing snapshot */} + + {t("billToSnapshotTitle")} +
+
+ {invoice.billingSnapshot.companyName} +
+
{invoice.billingSnapshot.streetAddress}
+
+ {invoice.billingSnapshot.postalCode}{" "} + {invoice.billingSnapshot.city} +
+
{invoice.billingSnapshot.country}
+ {invoice.billingSnapshot.vatNumber && ( +
+ VAT: {invoice.billingSnapshot.vatNumber} +
+ )} +
+ {invoice.billingSnapshot.billingEmail} +
+
+
+
+ ); +} + +function StatusPill({ status }: { status: InvoiceStatus }) { + const t = useTranslations("adminBilling"); + const color = + status === "paid" + ? "bg-success/15 text-success" + : status === "overdue" + ? "bg-error/15 text-error" + : status === "void" || status === "uncollectible" + ? "bg-text-muted/15 text-text-muted" + : "bg-accent/15 text-accent"; + return ( + + {t(`status_${status}` as any)} + + ); +} diff --git a/src/components/admin/billing/invoices-table.tsx b/src/components/admin/billing/invoices-table.tsx new file mode 100644 index 0000000..8141933 --- /dev/null +++ b/src/components/admin/billing/invoices-table.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { Card } from "@/components/ui/card"; +import type { Invoice, InvoiceStatus } from "@/types"; + +interface Props { + initialInvoices: Invoice[]; +} + +const STATUS_FILTERS: (InvoiceStatus | "all")[] = [ + "all", + "open", + "overdue", + "paid", + "void", +]; + +/** + * Filterable invoice list. Filters live in URL-less local state + * (simpler than syncing to query string for a v1 admin tool); a + * page refresh resets. + * + * Re-fetching strategy: when filters change, hit the API directly + * rather than router.refresh() so we don't bounce the user through + * a full page render. + */ +export function InvoicesTable({ initialInvoices }: Props) { + const t = useTranslations("adminBilling"); + const [statusFilter, setStatusFilter] = useState("all"); + const [monthFilter, setMonthFilter] = useState(""); + const [invoices, setInvoices] = useState(initialInvoices); + const [busy, setBusy] = useState(false); + + useEffect(() => { + // Effect runs after initial render too; skip refetch on mount + // when filters are at their defaults — the server already + // gave us the right initial set. + if (statusFilter === "all" && monthFilter === "") return; + let cancelled = false; + setBusy(true); + const params = new URLSearchParams(); + if (statusFilter !== "all") params.set("status", statusFilter); + if (monthFilter) params.set("month", monthFilter); + fetch(`/api/admin/billing/invoices?${params}`) + .then((r) => r.json()) + .then((data) => { + if (!cancelled) setInvoices(data); + }) + .catch((e) => console.error("Failed to load invoices:", e)) + .finally(() => { + if (!cancelled) setBusy(false); + }); + return () => { + cancelled = true; + }; + }, [statusFilter, monthFilter]); + + return ( +
+ +
+ + + {monthFilter && ( + + )} + {busy && ( + + {t("loading")} + + )} +
+
+ + + {invoices.length === 0 ? ( +

+ {t("noInvoicesFound")} +

+ ) : ( + + + + + + + + + + + + + {invoices.map((inv) => ( + + + + + + + + + ))} + +
{t("invoiceNumberCol")}{t("orgCol")}{t("periodCol")}{t("statusCol")}{t("totalCol")}{t("dueCol")}
+ + {inv.invoiceNumber} + + +
+ {inv.billingSnapshot.companyName || ( + {inv.zitadelOrgId} + )} +
+
+ {inv.periodStart.slice(0, 7)} + + + + CHF {inv.totalChf.toFixed(2)} + + {inv.dueAt} +
+ )} +
+
+ ); +} + +function StatusPill({ status }: { status: InvoiceStatus }) { + const t = useTranslations("adminBilling"); + const color = + status === "paid" + ? "bg-success/15 text-success" + : status === "overdue" + ? "bg-error/15 text-error" + : status === "void" || status === "uncollectible" + ? "bg-text-muted/15 text-text-muted" + : "bg-accent/15 text-accent"; + return ( + + {t(`status_${status}` as any)} + + ); +} diff --git a/src/components/admin/billing/pricing-editor.tsx b/src/components/admin/billing/pricing-editor.tsx new file mode 100644 index 0000000..06ea516 --- /dev/null +++ b/src/components/admin/billing/pricing-editor.tsx @@ -0,0 +1,399 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { Card, CardHeader } from "@/components/ui/card"; +import type { PlatformPricing, SkillPricing } from "@/types"; + +interface CatalogEntry { + id: string; + name: string; + kind: string; +} + +interface Props { + initialPricing: PlatformPricing; + initialSkillPricing: SkillPricing[]; + catalog: CatalogEntry[]; +} + +/** + * Two-card layout: + * 1. Platform pricing form (4 inputs, save = PUT to /pricing). + * 2. Skill pricing table — list of priced skills, "Add skill" + * picker below. + * + * No optimistic updates — every save round-trips and we + * router.refresh() afterwards so the server-side render stays + * the source of truth. + */ +export function PricingEditor({ + initialPricing, + initialSkillPricing, + catalog, +}: Props) { + const t = useTranslations("adminBilling"); + const router = useRouter(); + + // -- Platform pricing form ---------------------------------------------- + const [monthly, setMonthly] = useState( + String(initialPricing.tenantMonthlyFeeChf) + ); + const [setup, setSetup] = useState(String(initialPricing.tenantSetupFeeChf)); + const [threema, setThreema] = useState( + String(initialPricing.threemaMessageChf) + ); + const [vat, setVat] = useState(String(initialPricing.vatRateChli)); + const [savingPricing, setSavingPricing] = useState(false); + const [pricingError, setPricingError] = useState(""); + const [pricingSaved, setPricingSaved] = useState(false); + + const savePricing = async (e: React.FormEvent) => { + e.preventDefault(); + setSavingPricing(true); + setPricingError(""); + setPricingSaved(false); + try { + const res = await fetch("/api/admin/billing/pricing", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + tenantMonthlyFeeChf: Number(monthly), + tenantSetupFeeChf: Number(setup), + threemaMessageChf: Number(threema), + vatRateChli: Number(vat), + }), + }); + if (!res.ok) { + const j = await res.json().catch(() => ({})); + throw new Error(j.error || `HTTP ${res.status}`); + } + setPricingSaved(true); + router.refresh(); + } catch (e: any) { + setPricingError(e.message); + } finally { + setSavingPricing(false); + } + }; + + // -- Skill pricing ------------------------------------------------------ + // Server is authoritative — we don't keep an editable local copy of the + // table; instead each action posts to the API and we router.refresh(). + const [newSkillId, setNewSkillId] = useState( + catalog.find((c) => c.kind === "skill")?.id ?? "" + ); + const [newSkillPrice, setNewSkillPrice] = useState("0.10"); + const [addingSkill, setAddingSkill] = useState(false); + const [skillError, setSkillError] = useState(""); + + const addOrUpdateSkill = async ( + e: React.FormEvent, + overrideId?: string, + overridePrice?: string + ) => { + e.preventDefault(); + setAddingSkill(true); + setSkillError(""); + try { + const res = await fetch("/api/admin/billing/skill-pricing", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + skillId: overrideId ?? newSkillId, + dailyPriceChf: Number(overridePrice ?? newSkillPrice), + }), + }); + if (!res.ok) { + const j = await res.json().catch(() => ({})); + throw new Error(j.error || `HTTP ${res.status}`); + } + router.refresh(); + } catch (e: any) { + setSkillError(e.message); + } finally { + setAddingSkill(false); + } + }; + + const deleteSkill = async (skillId: string) => { + if (!confirm(t("confirmDeleteSkillPrice", { skill: skillId }))) return; + setSkillError(""); + try { + const res = await fetch( + `/api/admin/billing/skill-pricing/${encodeURIComponent(skillId)}`, + { method: "DELETE" } + ); + if (!res.ok) { + const j = await res.json().catch(() => ({})); + throw new Error(j.error || `HTTP ${res.status}`); + } + router.refresh(); + } catch (e: any) { + setSkillError(e.message); + } + }; + + // Catalog filtered to skill-kind entries for the picker, but keeping + // existing pricing rows even if they reference non-skill packages. + const skillCatalogOptions = catalog.filter((c) => c.kind === "skill"); + const catalogIndex = new Map(catalog.map((c) => [c.id, c])); + const pricedIds = new Set(initialSkillPricing.map((s) => s.skillId)); + + return ( +
+ + {t("platformPricingTitle")} +
+
+ + + + +
+
+ + {pricingSaved && ( + {t("savedOk")} + )} + {pricingError && ( + {pricingError} + )} +
+
+
+ + + {t("skillPricingTitle")} +

{t("skillPricingDesc")}

+ + {initialSkillPricing.length > 0 ? ( + + + + + + + + + + {initialSkillPricing.map((sp) => { + const entry = catalogIndex.get(sp.skillId); + return ( + + + + + + ); + })} + +
{t("skillCol")}{t("dailyPriceCol")}{t("actionsCol")}
+
{sp.skillId}
+ {entry && ( +
{entry.name}
+ )} +
+ + addOrUpdateSkill( + new Event("submit") as any, + sp.skillId, + String(price) + ) + } + /> + + +
+ ) : ( +

{t("noSkillsPriced")}

+ )} + +
addOrUpdateSkill(e)} + className="flex items-end gap-3" + > + + + +
+ {skillError && ( +

{skillError}

+ )} +
+
+ ); +} + +/** + * Tiny inline editor for a single skill's daily price. Mounts in + * "view" mode showing the current value as a clickable badge; + * clicking turns it into an input + save/cancel buttons. + */ +function InlinePriceEditor({ + skillId, + initialPrice, + onSave, +}: { + skillId: string; + initialPrice: number; + onSave: (price: number) => Promise | void; +}) { + const t = useTranslations("adminBilling"); + const [editing, setEditing] = useState(false); + const [value, setValue] = useState(String(initialPrice)); + const [busy, setBusy] = useState(false); + + if (!editing) { + return ( + + ); + } + return ( + + setValue(e.target.value)} + className="w-20 px-2 py-1 text-sm border border-border bg-surface-2 rounded" + autoFocus + /> + + + + ); +} diff --git a/src/lib/billing-pdf.tsx b/src/lib/billing-pdf.tsx new file mode 100644 index 0000000..7f791aa --- /dev/null +++ b/src/lib/billing-pdf.tsx @@ -0,0 +1,651 @@ +/** + * Invoice PDF rendering via @react-pdf/renderer. + * + * Design notes: + * + * - The template is a React component (JSX). Visual tweaks happen + * here — colors, fonts, spacing, layout. To swap branding later, + * edit BRAND_* constants below or replace the logo component. + * + * - All strings are pulled from MESSAGES[locale]. To add a new + * language, copy the German block and translate. Locale is + * frozen on the invoice at issue time (invoices.locale column); + * re-rendering a historical invoice always uses the same locale. + * + * - The logo is inlined as React-PDF SVG primitives so no asset + * loading or font-bundle wrangling is needed. It travels with + * the code. + * + * - VAT note (reverse charge etc.) is appended below the totals + * block. Notes are localized in the same MESSAGES map. + * + * - QR-bill (Swiss bank transfer) is intentionally NOT included + * in v1 — it lands in Phase 7. We render plain bank instructions + * as text. + */ + +import React from "react"; +import { + Document, + Page, + Text, + View, + StyleSheet, + Svg, + Polygon, + Polyline, + renderToBuffer, +} from "@react-pdf/renderer"; +import type { Invoice, InvoiceLine, InvoiceLineKind } from "@/types"; + +// --------------------------------------------------------------------------- +// Brand constants — edit here to tweak look without touching layout +// --------------------------------------------------------------------------- + +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 +// --------------------------------------------------------------------------- + +interface PdfStrings { + invoice: string; + invoiceNumber: string; + issueDate: string; + dueDate: string; + period: string; + billTo: string; + description: string; + quantity: string; + unitPrice: string; + amount: string; + subtotal: string; + vat: string; + total: string; + paymentInstructions: string; + paymentRefHint: string; + thankYou: string; + page: string; + of: string; + // Per-line-kind labels (used as section headers) + kindLabels: Record; + // VAT compliance notes + reverseCharge: string; + exportNote: string; +} + +const MESSAGES: Record = { + de: { + invoice: "Rechnung", + invoiceNumber: "Rechnungs-Nr.", + issueDate: "Rechnungsdatum", + dueDate: "Zahlbar bis", + period: "Abrechnungsperiode", + billTo: "Rechnungsempfänger", + description: "Beschreibung", + quantity: "Menge", + unitPrice: "Einzelpreis", + amount: "Betrag", + subtotal: "Zwischensumme", + vat: "MWST", + total: "Total", + paymentInstructions: "Zahlungsinformationen", + paymentRefHint: "Bitte verwenden Sie die Rechnungsnummer als Referenz.", + thankYou: "Vielen Dank für Ihr Vertrauen.", + page: "Seite", + of: "von", + kindLabels: { + tenant_monthly: "Monatliche Grundgebühr", + tenant_setup: "Einrichtungsgebühr", + ai_usage: "KI-Nutzung", + threema_messages: "Threema-Nachrichten", + skill_usage: "Skill-Nutzung", + adjustment: "Anpassung", + }, + reverseCharge: + "Steuerschuldnerschaft des Leistungsempfängers (Reverse Charge).", + exportNote: "Dienstleistungsexport — keine MWST in Rechnung gestellt.", + }, + en: { + invoice: "Invoice", + invoiceNumber: "Invoice no.", + issueDate: "Issue date", + dueDate: "Due date", + period: "Billing period", + billTo: "Bill to", + description: "Description", + quantity: "Qty", + unitPrice: "Unit price", + amount: "Amount", + subtotal: "Subtotal", + vat: "VAT", + total: "Total", + paymentInstructions: "Payment instructions", + paymentRefHint: "Please use the invoice number as the payment reference.", + thankYou: "Thank you for your business.", + page: "Page", + of: "of", + kindLabels: { + tenant_monthly: "Monthly fee", + tenant_setup: "Setup fee", + ai_usage: "AI usage", + threema_messages: "Threema messages", + skill_usage: "Skill usage", + adjustment: "Adjustment", + }, + reverseCharge: + "Reverse charge — VAT to be accounted for by the recipient.", + exportNote: "Export of services — VAT not applicable.", + }, + fr: { + invoice: "Facture", + invoiceNumber: "N° facture", + issueDate: "Date d'émission", + dueDate: "Échéance", + period: "Période de facturation", + billTo: "Destinataire", + description: "Description", + quantity: "Qté", + unitPrice: "Prix unitaire", + amount: "Montant", + subtotal: "Sous-total", + vat: "TVA", + total: "Total", + paymentInstructions: "Informations de paiement", + paymentRefHint: "Veuillez utiliser le n° de facture comme référence.", + thankYou: "Merci de votre confiance.", + page: "Page", + of: "sur", + kindLabels: { + tenant_monthly: "Forfait mensuel", + tenant_setup: "Frais de configuration", + ai_usage: "Utilisation IA", + threema_messages: "Messages Threema", + skill_usage: "Utilisation Skill", + adjustment: "Ajustement", + }, + reverseCharge: + "Autoliquidation — TVA à acquitter par le destinataire.", + exportNote: "Exportation de services — TVA non applicable.", + }, + it: { + invoice: "Fattura", + invoiceNumber: "N. fattura", + issueDate: "Data di emissione", + dueDate: "Scadenza", + period: "Periodo di fatturazione", + billTo: "Destinatario", + description: "Descrizione", + quantity: "Qtà", + unitPrice: "Prezzo unitario", + amount: "Importo", + subtotal: "Subtotale", + vat: "IVA", + total: "Totale", + paymentInstructions: "Istruzioni di pagamento", + paymentRefHint: "Si prega di utilizzare il n. di fattura come riferimento.", + thankYou: "Grazie per la fiducia.", + page: "Pagina", + of: "di", + kindLabels: { + tenant_monthly: "Canone mensile", + tenant_setup: "Spese di attivazione", + ai_usage: "Utilizzo IA", + threema_messages: "Messaggi Threema", + skill_usage: "Utilizzo Skill", + adjustment: "Rettifica", + }, + reverseCharge: + "Inversione contabile — IVA a carico del destinatario.", + exportNote: "Esportazione di servizi — IVA non applicabile.", + }, +}; + +function getStrings(locale: string): PdfStrings { + return MESSAGES[locale] ?? MESSAGES.de; +} + +// --------------------------------------------------------------------------- +// Stylesheet +// --------------------------------------------------------------------------- + +const styles = StyleSheet.create({ + page: { + paddingTop: 40, + paddingBottom: 60, + paddingHorizontal: 40, + fontSize: 9, + color: BRAND.textColor, + lineHeight: 1.4, + }, + headerRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "flex-start", + marginBottom: 28, + }, + logoWrap: { width: 60, height: 90 }, + issuerBlock: { textAlign: "right", fontSize: 8.5, color: BRAND.mutedColor }, + issuerName: { fontSize: 11, color: BRAND.primaryDark, marginBottom: 2 }, + invoiceTitle: { fontSize: 22, color: BRAND.primaryDark, marginBottom: 8 }, + metaTable: { + flexDirection: "row", + justifyContent: "space-between", + marginBottom: 20, + }, + metaCol: { flexGrow: 1, marginRight: 16 }, + metaLabel: { color: BRAND.mutedColor, fontSize: 8, marginBottom: 2 }, + metaValue: { fontSize: 10, marginBottom: 6 }, + billToBlock: { + marginBottom: 24, + padding: 10, + backgroundColor: "#f7f7f5", + borderLeftWidth: 3, + borderLeftColor: BRAND.primary, + }, + billToLabel: { fontSize: 8, color: BRAND.mutedColor, marginBottom: 4 }, + billToName: { fontSize: 11, marginBottom: 2 }, + table: { marginBottom: 14 }, + tableHeader: { + flexDirection: "row", + backgroundColor: BRAND.primaryDark, + color: "#ffffff", + paddingVertical: 5, + paddingHorizontal: 6, + fontSize: 8.5, + }, + tableRow: { + flexDirection: "row", + borderBottomWidth: 0.5, + borderBottomColor: BRAND.borderColor, + paddingVertical: 5, + paddingHorizontal: 6, + }, + // Column widths (sum ≈ 100%) + colDesc: { width: "52%" }, + colQty: { width: "12%", textAlign: "right" }, + colUnit: { width: "16%", textAlign: "right" }, + colAmt: { width: "20%", textAlign: "right" }, + totalsBlock: { + alignSelf: "flex-end", + width: "45%", + marginTop: 8, + }, + totalsRow: { + flexDirection: "row", + justifyContent: "space-between", + paddingVertical: 3, + }, + totalsLabel: { color: BRAND.mutedColor }, + totalsValue: { textAlign: "right" }, + totalsGrand: { + flexDirection: "row", + justifyContent: "space-between", + borderTopWidth: 1, + borderTopColor: BRAND.primaryDark, + paddingTop: 6, + marginTop: 4, + }, + totalsGrandLabel: { color: BRAND.primaryDark, fontSize: 11 }, + totalsGrandValue: { color: BRAND.primaryDark, fontSize: 11, textAlign: "right" }, + noteBox: { + marginTop: 18, + padding: 8, + backgroundColor: "#fff8e7", + borderLeftWidth: 2, + borderLeftColor: "#d4a017", + fontSize: 8.5, + }, + paymentBlock: { + marginTop: 24, + paddingTop: 12, + borderTopWidth: 0.5, + borderTopColor: BRAND.borderColor, + }, + paymentTitle: { fontSize: 10, color: BRAND.primaryDark, marginBottom: 6 }, + paymentLine: { fontSize: 9, marginBottom: 1 }, + footer: { + position: "absolute", + bottom: 24, + left: 40, + right: 40, + flexDirection: "row", + justifyContent: "space-between", + fontSize: 7.5, + color: BRAND.mutedColor, + borderTopWidth: 0.5, + borderTopColor: BRAND.borderColor, + paddingTop: 8, + }, +}); + +// --------------------------------------------------------------------------- +// 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 }) => ( + + {/* H1 solid */} + + {/* H2 outline */} + + {/* H3 outline */} + + {/* H4 solid */} + + {/* H5 partial */} + + {/* H6 partial */} + + +); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function fmtChf(n: number, decimals: number = 2): string { + // Swiss thousands separator + decimal point: 1'234.56 + const fixed = n.toFixed(decimals); + 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 { + // Parse YYYY-MM-DD as a calendar date (no timezone shifts). + // For PDF rendering we want a stable representation regardless + // of server timezone. + const [y, m, d] = iso.split("T")[0].split("-").map(Number); + // Locale-specific date format + if (locale === "en") { + return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + timeZone: "UTC", + }); + } + // DE/FR/IT default: DD.MM.YYYY + return `${String(d).padStart(2, "0")}.${String(m).padStart(2, "0")}.${y}`; +} + +// --------------------------------------------------------------------------- +// Document +// --------------------------------------------------------------------------- + +interface InvoicePdfProps { + invoice: Invoice; + lines: InvoiceLine[]; +} + +const InvoicePdf: React.FC = ({ invoice, lines }) => { + const s = getStrings(invoice.locale); + const snap = invoice.billingSnapshot; + + // Group lines by tenant for visual separation. Lines without a + // tenant_name (org-level adjustments) go to the end. + const linesByTenant = new Map(); + for (const ln of lines) { + const key = ln.tenantName; + if (!linesByTenant.has(key)) linesByTenant.set(key, []); + linesByTenant.get(key)!.push(ln); + } + const tenantOrder = [...linesByTenant.keys()].sort((a, b) => { + if (a === null) return 1; + if (b === null) return -1; + return a.localeCompare(b); + }); + + // VAT note: pick the right localized note based on rate + address. + // Zero rate + EU country = reverse charge; zero rate + other = export. + let vatNote: string | null = null; + if (invoice.vatRate === 0) { + const country = (snap.country || "").toUpperCase(); + const isEu = [ + "AT","BE","BG","HR","CY","CZ","DK","EE","FI","FR","DE","GR","HU", + "IE","IT","LV","LT","LU","MT","NL","PL","PT","RO","SK","SI","ES","SE", + ].includes(country); + vatNote = isEu ? s.reverseCharge : s.exportNote; + } + + return ( + + + {/* Header: logo left, issuer right */} + + + + + + {BRAND.issuer.legalName} + {BRAND.issuer.addressLine1} + {BRAND.issuer.addressLine2} + {BRAND.issuer.postalCity} + {BRAND.issuer.country} + {BRAND.issuer.email} + {BRAND.issuer.vatNumber && ( + MWST-Nr. {BRAND.issuer.vatNumber} + )} + + + + {s.invoice} + + {/* Meta row: 3 columns */} + + + {s.invoiceNumber} + {invoice.invoiceNumber} + {s.issueDate} + + {fmtDate(invoice.issuedAt, invoice.locale)} + + + + {s.period} + + {fmtDate(invoice.periodStart, invoice.locale)} —{" "} + {fmtDate(invoice.periodEnd, invoice.locale)} + + {s.dueDate} + + {fmtDate(invoice.dueAt, invoice.locale)} + + + + + {/* Bill-to */} + + {s.billTo} + {snap.companyName} + {snap.streetAddress} + + {snap.postalCode} {snap.city} + + {snap.country} + {snap.vatNumber && VAT: {snap.vatNumber}} + {snap.billingEmail} + + + {/* Line items table */} + + + {s.description} + {s.quantity} + {s.unitPrice} + {s.amount} (CHF) + + {tenantOrder.map((tenantKey) => { + const tenantLines = linesByTenant.get(tenantKey)!; + return ( + + {tenantKey && ( + + + {tenantKey} + + + )} + {tenantLines.map((ln) => ( + + {ln.description} + + {ln.quantity} + {ln.unitLabel ? ` ${ln.unitLabel}` : ""} + + {fmtChf(ln.unitPriceChf, 5)} + {fmtChf(ln.amountChf)} + + ))} + + ); + })} + + + {/* Totals */} + + + {s.subtotal} + {fmtChf(invoice.subtotalChf)} + + + + {s.vat} ({invoice.vatRate.toFixed(2)}%) + + {fmtChf(invoice.vatAmountChf)} + + + + {s.total} (CHF) + + {fmtChf(invoice.totalChf)} + + + + {vatNote && ( + + {vatNote} + + )} + + {/* Payment instructions */} + + {s.paymentInstructions} + {BRAND.issuer.legalName} + {BRAND.issuer.bankName} + IBAN: {BRAND.issuer.bankIban} + BIC: {BRAND.issuer.bankBic} + + {s.paymentRefHint} + + + {s.thankYou} + + + + {/* Footer with page numbers — react-pdf supplies render fn args */} + ( + <> + + {BRAND.issuer.legalName} · {BRAND.issuer.web} · {BRAND.issuer.email} + + + {s.page} {pageNumber} {s.of} {totalPages} + + + )} + fixed + /> + + + ); +}; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Render an invoice to a PDF buffer. Caller stores the buffer in + * `invoices.pdf_data` (bytea). Side-effect-free; can be called + * outside a DB transaction. + * + * Typical runtime is 50–200ms on a typical invoice with a dozen + * lines. + */ +export async function renderInvoicePdf( + invoice: Invoice, + lines: InvoiceLine[] +): Promise { + return renderToBuffer(); +} diff --git a/src/lib/billing.ts b/src/lib/billing.ts new file mode 100644 index 0000000..6fd122d --- /dev/null +++ b/src/lib/billing.ts @@ -0,0 +1,737 @@ +/** + * Billing computation pipeline. + * + * Public entry points: + * - computeInvoiceDraft({ zitadelOrgId, year, month, locale? }) + * Builds an in-memory InvoiceDraft from the live signals + * (LiteLLM spend, Threema relay usage, tenant skill events, + * lifecycle, suspension). Does NOT persist or render the PDF. + * + * - generateInvoice({ zitadelOrgId, year, month, locale?, dryRun? }) + * Calls computeInvoiceDraft, renders the PDF, persists the + * invoice transactionally. Returns the persisted Invoice + * (or the draft if dryRun=true). + * + * Design choices: + * + * - All compute is over UTC calendar days. "Active during day D" + * means the tenant existed and was not fully suspended at some + * moment in [D 00:00 UTC, D+1 00:00 UTC). This matches the + * skill billing rule ("same-day toggle = 1 day") for monthly + * fee proration too. + * + * - Computation is independent of persistence. Callers can preview + * without committing (the admin generate form does this on first + * click), and the same compute path is reused when committing. + * + * - The compute path collects warnings rather than throwing on + * recoverable issues (missing LiteLLM team for a tenant, etc.). + * The UI surfaces these to the admin before they confirm. + */ + +import type { + Invoice, + InvoiceBillingSnapshot, + InvoiceDraft, + InvoiceLine, + InvoiceLineKind, + InvoicePaymentMethod, + PiecedTenant, + PlatformPricing, + SkillPricing, + TenantBillingLifecycle, + TenantSkillEvent, + TenantSuspensionEvent, +} from "@/types"; +import { + createInvoice, + getInvoiceById, + getOrgBilling, + getOrgBillingConfig, + getPlatformPricing, + getTenantBillingLifecycle, + listSkillEventsForTenant, + listSkillPricing, + listSuspensionEventsForTenant, + tenantHasSetupFeeBilled, + updateInvoicePdf, +} from "./db"; +import { listTenants } from "./k8s"; +import { getTeamSpendLogsV2 } from "./litellm"; +import { getUsage as getThreemaUsage } from "./threema-relay"; +import { renderInvoicePdf } from "./billing-pdf"; + +// --------------------------------------------------------------------------- +// Period helpers +// --------------------------------------------------------------------------- + +/** + * Returns the [periodStart, periodEnd] inclusive calendar dates for + * the given month, plus the count of days in the month. + * + * Dates returned as ISO `YYYY-MM-DD` strings (no time). Convertible + * to UTC midnight via `new Date(`${date}T00:00:00Z`)`. + */ +export function monthBounds(year: number, month: number): { + periodStart: string; + periodEnd: string; + daysInMonth: number; +} { + if (month < 1 || month > 12) throw new Error(`Invalid month: ${month}`); + const start = new Date(Date.UTC(year, month - 1, 1)); + // Day 0 of next month = last day of this month + const end = new Date(Date.UTC(year, month, 0)); + return { + periodStart: start.toISOString().split("T")[0], + periodEnd: end.toISOString().split("T")[0], + daysInMonth: end.getUTCDate(), + }; +} + +function isoDate(d: Date): string { + return d.toISOString().split("T")[0]; +} + +function dueDate(periodEnd: string, netDays: number = 30): string { + // due_at = period_end + netDays + const d = new Date(`${periodEnd}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + netDays); + return isoDate(d); +} + +// --------------------------------------------------------------------------- +// Day-set computation (calendar-day model, UTC) +// --------------------------------------------------------------------------- + +/** + * Iterates UTC calendar days in [periodStart, periodEnd] inclusive. + * Yields { date: 'YYYY-MM-DD', dayStartMs, dayEndMs } where dayEnd + * is exclusive (next-day-midnight UTC). + */ +function* iterDays(periodStart: string, periodEnd: string) { + const start = new Date(`${periodStart}T00:00:00Z`).getTime(); + const end = new Date(`${periodEnd}T00:00:00Z`).getTime(); + for (let t = start; t <= end; t += 86_400_000) { + yield { + date: isoDate(new Date(t)), + dayStartMs: t, + dayEndMs: t + 86_400_000, + }; + } +} + +/** + * Was the tenant "running" (created, not deleted, not suspended) at + * any moment in the half-open interval [dayStartMs, dayEndMs)? + * + * Inputs: tenant lifecycle and the timeline of suspension events + * sorted ascending by occurredAt. + * + * The state-at-day-start is reconstructed from suspension events + * BEFORE the day. If the count of suspension events before the day + * is odd, the tenant was suspended at day start (because we record + * suspend then resume, so an odd prefix-count means the last + * recorded transition is "suspended"). This is robust as long as + * events are correctly ordered. + * + * Actually we use the actual event kinds from the events list, + * not the parity heuristic — the heuristic is documentation for + * intuition. + */ +function activeDuringDay( + lifecycle: TenantBillingLifecycle, + suspensionEvents: TenantSuspensionEvent[], + dayStartMs: number, + dayEndMs: number +): boolean { + // Lifecycle gate: tenant must have existed during some part of the day. + const createdMs = new Date(lifecycle.createdAt).getTime(); + const deletedMs = lifecycle.deletedAt + ? new Date(lifecycle.deletedAt).getTime() + : Infinity; + if (createdMs >= dayEndMs) return false; + if (deletedMs <= dayStartMs) return false; + // Effective existence window within this day + const existsFrom = Math.max(createdMs, dayStartMs); + const existsTo = Math.min(deletedMs, dayEndMs); + if (existsFrom >= existsTo) return false; + + // Determine suspended state at existsFrom by replaying events. + // Initial state at lifecycle.createdAt is 'running' (we don't + // record an explicit 'created → running' event; this is the + // implicit baseline). + let suspended = false; + for (const e of suspensionEvents) { + const ts = new Date(e.occurredAt).getTime(); + if (ts > existsFrom) break; + suspended = e.eventKind === "suspended"; + } + + // Walk events from existsFrom to existsTo. If at any moment the + // tenant is running, the day counts. + if (!suspended) return true; + for (const e of suspensionEvents) { + const ts = new Date(e.occurredAt).getTime(); + if (ts <= existsFrom) continue; + if (ts >= existsTo) break; + if (e.eventKind === "resumed") return true; + } + return false; +} + +/** + * Was the skill 'enabled' at any moment in the day? + * + * Same shape as activeDuringDay but driven by skill events instead + * of suspension events. + * + * Important: callers must include events from before periodStart in + * `prevState` (state at day start), since a skill enabled three + * months ago and never disabled has no events in the billing + * window but is still enabled. + */ +function skillActiveDuringDay( + events: TenantSkillEvent[], + initiallyEnabled: boolean, + dayStartMs: number, + dayEndMs: number +): boolean { + let enabled = initiallyEnabled; + // First, replay events that occurred AT OR BEFORE dayStartMs to + // get the state at day start. + for (const e of events) { + const ts = new Date(e.occurredAt).getTime(); + if (ts > dayStartMs) break; + enabled = e.eventKind === "enabled"; + } + if (enabled) return true; + // Walk events in [dayStart, dayEnd). If any 'enabled' event + // appears, the day counts. + for (const e of events) { + const ts = new Date(e.occurredAt).getTime(); + if (ts <= dayStartMs) continue; + if (ts >= dayEndMs) break; + if (e.eventKind === "enabled") return true; + } + return false; +} + +// --------------------------------------------------------------------------- +// Rounding +// --------------------------------------------------------------------------- + +/** Round to 2dp, half-up. */ +function round2(n: number): number { + return Math.round(n * 100) / 100; +} + +// --------------------------------------------------------------------------- +// VAT logic +// --------------------------------------------------------------------------- + +const EU_COUNTRIES = new Set([ + "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", + "DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL", + "PL", "PT", "RO", "SK", "SI", "ES", "SE", +]); + +/** + * Determine VAT rate from billing address and the platform default. + * See README for the legal interpretation; this implements the + * defaults you confirmed: + * + * - CH or LI: platform_pricing.vat_rate_chli (default 8.10) + * - EU + VAT number: 0% (reverse charge — B2B) + * - EU without VAT: CH MWST (B2C consumer, we charge our rate) + * - other: 0% (export of services) + */ +function vatRateForAddress( + snapshot: InvoiceBillingSnapshot, + platformPricing: PlatformPricing +): { rate: number; note: string | null } { + const country = snapshot.country?.toUpperCase().trim() ?? ""; + if (country === "CH" || country === "LI") { + return { rate: platformPricing.vatRateChli, note: null }; + } + if (EU_COUNTRIES.has(country)) { + if (snapshot.vatNumber && snapshot.vatNumber.trim().length > 0) { + return { + rate: 0, + note: + "Steuerschuldnerschaft des Leistungsempfängers / Reverse charge — VAT to be accounted for by the recipient.", + }; + } + return { rate: platformPricing.vatRateChli, note: null }; + } + return { rate: 0, note: "Export of services — VAT not applicable." }; +} + +// --------------------------------------------------------------------------- +// Locale default +// --------------------------------------------------------------------------- + +/** + * Pick a default invoice locale from the billing country. Admins + * can override at generation time. We default to German for + * CH/LI/AT/DE; French for FR/BE/LU; Italian for IT; English + * otherwise. + */ +export function defaultLocaleForCountry(country: string): string { + const c = (country || "").toUpperCase().trim(); + if (["CH", "LI", "AT", "DE"].includes(c)) return "de"; + if (["FR", "BE", "LU"].includes(c)) return "fr"; + if (c === "IT") return "it"; + return "en"; +} + +// --------------------------------------------------------------------------- +// Tenant signal collectors +// --------------------------------------------------------------------------- + +/** + * Sum AI usage spend for a tenant over the billing period via + * LiteLLM. Returns the CHF total (already in CHF — LiteLLM stores + * costs after the platform's USD→CHF conversion) and the request + * count for the metadata. + * + * Tolerates missing litellmTeamId on the tenant: such tenants are + * skipped and the warning is surfaced upstream. + */ +async function collectAiUsage( + tenant: PiecedTenant, + periodStart: string, + periodEnd: string +): Promise<{ spendChf: number; requestCount: number } | null> { + const teamId = tenant.status?.litellmTeamId; + if (!teamId) return null; + const keyAlias = tenant.metadata.name; + let spendChf = 0; + let requestCount = 0; + let page = 1; + // 50-page cap matches the existing usage route's defensive cap. + while (page <= 50) { + const result = await getTeamSpendLogsV2( + teamId, + periodStart, + periodEnd, + page, + 100, + keyAlias + ); + const rows: any[] = result.data ?? []; + for (const r of rows) { + spendChf += Number(r.spend ?? 0); + requestCount += 1; + } + if (page >= (result.total_pages || 1)) break; + page++; + } + return { spendChf: round2(spendChf), requestCount }; +} + +/** + * Sum Threema messages (in + out) for the tenant over the period. + * Returns null if the relay refuses or the tenant has no Threema + * package — billing is skipped silently in that case. + */ +async function collectThreemaUsage( + tenant: PiecedTenant, + periodStart: string, + periodEnd: string +): Promise<{ inCount: number; outCount: number } | null> { + const packages = tenant.spec.packages ?? []; + if (!packages.includes("threema")) return null; + const usage = await getThreemaUsage( + tenant.metadata.name, + periodStart, + periodEnd + ).catch(() => null); + if (!usage) return null; + return { + inCount: Number(usage.totals?.in ?? 0), + outCount: Number(usage.totals?.out ?? 0), + }; +} + +// --------------------------------------------------------------------------- +// Per-tenant line builders +// --------------------------------------------------------------------------- + +async function buildTenantLines(opts: { + tenant: PiecedTenant; + periodStart: string; + periodEnd: string; + daysInMonth: number; + platformPricing: PlatformPricing; + skillPricing: SkillPricing[]; + warnings: string[]; + displayOrderOffset: number; +}): Promise[]> { + const { + tenant, + periodStart, + periodEnd, + daysInMonth, + platformPricing, + skillPricing, + warnings, + } = opts; + let displayOrder = opts.displayOrderOffset; + const tenantName = tenant.metadata.name; + const lines: Omit[] = []; + + // Lifecycle & suspension events — required for monthly proration. + const lifecycle = await getTenantBillingLifecycle(tenantName); + if (!lifecycle) { + warnings.push( + `Tenant "${tenantName}" has no billing lifecycle row — run the Phase 1 backfill.` + ); + return lines; + } + + // Period interval in millis (extended by one day on each side as + // buffer for events that occur at month boundaries). + const periodStartMs = new Date(`${periodStart}T00:00:00Z`).getTime(); + const periodEndMs = new Date(`${periodEnd}T00:00:00Z`).getTime() + 86_400_000; + + const suspensionEvents = await listSuspensionEventsForTenant( + tenantName, + new Date(periodStartMs - 365 * 86_400_000), // look back a year for state-at-start + new Date(periodEndMs) + ); + + // --- tenant_monthly (prorated, suspended days excluded) ------------------- + if (platformPricing.tenantMonthlyFeeChf > 0) { + let billableDays = 0; + let suspendedDays = 0; + for (const day of iterDays(periodStart, periodEnd)) { + if (activeDuringDay(lifecycle, suspensionEvents, day.dayStartMs, day.dayEndMs)) { + billableDays++; + } else { + // Distinguish "not yet existed / deleted" from "suspended" + // for the metadata audit trail. Cheap re-check. + const createdMs = new Date(lifecycle.createdAt).getTime(); + const deletedMs = lifecycle.deletedAt + ? new Date(lifecycle.deletedAt).getTime() + : Infinity; + if (createdMs < day.dayEndMs && deletedMs > day.dayStartMs) { + suspendedDays++; + } + } + } + if (billableDays > 0) { + const unit = platformPricing.tenantMonthlyFeeChf / daysInMonth; + const amount = round2(unit * billableDays); + lines.push({ + tenantName, + kind: "tenant_monthly", + description: `Monthly fee for ${tenantName} (${billableDays}/${daysInMonth} days)`, + quantity: billableDays, + unitLabel: "days", + unitPriceChf: round2(unit * 1e5) / 1e5, + amountChf: amount, + metadata: { + billable_days: billableDays, + suspended_days: suspendedDays, + days_in_month: daysInMonth, + }, + displayOrder: displayOrder++, + }); + } + } + + // --- tenant_setup (first invoice only) ----------------------------------- + if (platformPricing.tenantSetupFeeChf > 0) { + const alreadyBilled = await tenantHasSetupFeeBilled(tenantName); + if (!alreadyBilled) { + lines.push({ + tenantName, + kind: "tenant_setup", + description: `Setup fee for ${tenantName}`, + quantity: 1, + unitLabel: null, + unitPriceChf: platformPricing.tenantSetupFeeChf, + amountChf: round2(platformPricing.tenantSetupFeeChf), + metadata: null, + displayOrder: displayOrder++, + }); + } + } + + // --- ai_usage -------------------------------------------------------------- + const aiUsage = await collectAiUsage(tenant, periodStart, periodEnd).catch( + (e) => { + warnings.push( + `AI usage fetch failed for ${tenantName}: ${e instanceof Error ? e.message : String(e)}` + ); + return null; + } + ); + if (aiUsage === null && tenant.status?.litellmTeamId) { + // teamId exists but fetch returned null — already warned above + } else if (aiUsage === null) { + warnings.push( + `Tenant ${tenantName} has no LiteLLM team yet — AI usage skipped.` + ); + } else if (aiUsage.spendChf > 0) { + lines.push({ + tenantName, + kind: "ai_usage", + description: `AI inference usage (${aiUsage.requestCount} requests)`, + quantity: 1, + unitLabel: null, + unitPriceChf: aiUsage.spendChf, + amountChf: aiUsage.spendChf, + metadata: { + litellm_key_alias: tenantName, + spend_chf: aiUsage.spendChf, + requests: aiUsage.requestCount, + }, + displayOrder: displayOrder++, + }); + } + + // --- threema_messages ----------------------------------------------------- + if (platformPricing.threemaMessageChf > 0) { + const threema = await collectThreemaUsage(tenant, periodStart, periodEnd); + if (threema && (threema.inCount + threema.outCount) > 0) { + const total = threema.inCount + threema.outCount; + lines.push({ + tenantName, + kind: "threema_messages", + description: `Threema messages (${threema.inCount} in + ${threema.outCount} out)`, + quantity: total, + unitLabel: "msgs", + unitPriceChf: platformPricing.threemaMessageChf, + amountChf: round2(total * platformPricing.threemaMessageChf), + metadata: { + in_count: threema.inCount, + out_count: threema.outCount, + total_count: total, + }, + displayOrder: displayOrder++, + }); + } + } + + // --- skill_usage ---------------------------------------------------------- + // For each priced skill, count distinct UTC days the skill was + // enabled during the period. + if (skillPricing.length > 0) { + // Fetch all skill events for the tenant within the period plus + // a long lookback so we can determine state-at-period-start. + // The state-at-day-start logic in skillActiveDuringDay walks + // these events forward. + const allEvents = await listSkillEventsForTenant( + tenantName, + new Date(0), + new Date(periodEndMs) + ); + for (const sp of skillPricing) { + const skillEvents = allEvents.filter((e) => e.skillId === sp.skillId); + // Skip cheaply if no events ever existed for this skill on + // this tenant. + if (skillEvents.length === 0) continue; + // Initial state assumption: false. The very first event is + // always 'enabled' (we only record toggles, and the implicit + // pre-toggle state for a never-seen skill is 'disabled'). + let billableDays = 0; + for (const day of iterDays(periodStart, periodEnd)) { + if (skillActiveDuringDay(skillEvents, false, day.dayStartMs, day.dayEndMs)) { + billableDays++; + } + } + if (billableDays > 0) { + lines.push({ + tenantName, + kind: "skill_usage", + description: `Skill: ${sp.skillId} (${billableDays} day${billableDays === 1 ? "" : "s"})`, + quantity: billableDays, + unitLabel: "days", + unitPriceChf: sp.dailyPriceChf, + amountChf: round2(billableDays * sp.dailyPriceChf), + metadata: { + skill_id: sp.skillId, + billable_days: billableDays, + event_count: skillEvents.length, + }, + displayOrder: displayOrder++, + }); + } + } + } + + return lines; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export async function computeInvoiceDraft(opts: { + zitadelOrgId: string; + year: number; + month: number; + locale?: string; + paymentMethod?: InvoicePaymentMethod; +}): Promise { + const { zitadelOrgId, year, month } = opts; + const { periodStart, periodEnd, daysInMonth } = monthBounds(year, month); + const warnings: string[] = []; + + // 1. Billing address. Required — without it we can't produce a + // valid invoice. + const orgBilling = await getOrgBilling(zitadelOrgId); + if (!orgBilling) { + throw new Error( + `Org ${zitadelOrgId} has no billing address on file. ` + + `The customer must complete /settings/billing before an invoice can be issued.` + ); + } + const snapshot: InvoiceBillingSnapshot = { + companyName: orgBilling.companyName, + streetAddress: orgBilling.streetAddress, + postalCode: orgBilling.postalCode, + city: orgBilling.city, + country: orgBilling.country, + vatNumber: orgBilling.vatNumber ?? null, + billingEmail: orgBilling.billingEmail, + notes: orgBilling.notes ?? null, + }; + + // 2. Platform pricing + skill prices. + const platformPricing = await getPlatformPricing(); + const skillPricing = await listSkillPricing(); + + // 3. Find all tenants for this org. We list from K8s (source of + // truth) and filter by the zitadel-org-id label. + const allTenants = await listTenants(); + const orgTenants = allTenants.filter( + (t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === zitadelOrgId + ); + if (orgTenants.length === 0) { + warnings.push(`No tenants found for org ${zitadelOrgId}.`); + } + + // 4. Build lines, grouped per tenant (display order preserved). + const lines: Omit[] = []; + let nextDisplayOrder = 0; + // Sort tenants by name for stable line ordering across regenerations. + orgTenants.sort((a, b) => a.metadata.name.localeCompare(b.metadata.name)); + for (const tenant of orgTenants) { + const tenantLines = await buildTenantLines({ + tenant, + periodStart, + periodEnd, + daysInMonth, + platformPricing, + skillPricing, + warnings, + displayOrderOffset: nextDisplayOrder, + }); + lines.push(...tenantLines); + nextDisplayOrder += tenantLines.length; + } + + // 5. Subtotal & VAT. + const subtotal = round2(lines.reduce((acc, l) => acc + l.amountChf, 0)); + const vat = vatRateForAddress(snapshot, platformPricing); + const vatAmount = round2((subtotal * vat.rate) / 100); + const total = round2(subtotal + vatAmount); + if (vat.note) warnings.push(vat.note); + + // 6. Payment method: prefer pay-by-invoice if the admin enabled + // it for the org, otherwise default to invoice. Card payment + // is wired in Phase 4 — for Phase 2 every invoice is 'invoice'. + const orgConfig = await getOrgBillingConfig(zitadelOrgId); + const paymentMethod: InvoicePaymentMethod = + opts.paymentMethod ?? (orgConfig.payByInvoice ? "invoice" : "invoice"); + + // 7. Locale resolution + const locale = opts.locale ?? defaultLocaleForCountry(snapshot.country); + + return { + zitadelOrgId, + periodStart, + periodEnd, + dueAt: dueDate(periodEnd, 30), + locale, + paymentMethod, + billingSnapshot: snapshot, + lines, + subtotalChf: subtotal, + vatRate: vat.rate, + vatAmountChf: vatAmount, + totalChf: total, + warnings, + }; +} + +/** + * Compute + render + persist in one step. If dryRun is true, the + * draft is returned without persisting and no PDF is rendered (the + * preview UI hits this). + */ +export async function generateInvoice(opts: { + zitadelOrgId: string; + year: number; + month: number; + locale?: string; + dryRun?: boolean; +}): Promise<{ draft: InvoiceDraft; invoice: Invoice | null }> { + const draft = await computeInvoiceDraft(opts); + if (opts.dryRun) { + return { draft, invoice: null }; + } + // Render the PDF first — if it fails, we never touch the DB. + // The PDF render needs the invoice number, which is allocated + // inside createInvoice's transaction. To keep the PDF rendering + // outside the DB transaction (it can be slow), we render with a + // placeholder number, allocate the real number inside the tx, + // then re-render? No — instead we generate a temporary draft + // number for the PDF and accept that the displayed number on + // the PDF matches what we'll persist (because the allocator is + // serialized). + // + // Practical approach: render the PDF inside createInvoice's tx, + // immediately after allocation. This is fine because react-pdf + // is reasonably fast (~50–200 ms for a typical invoice) and + // happens once per invoice. + // + // To avoid restructuring createInvoice, we do this in two + // passes: (1) reserve a number via createInvoice with a + // placeholder PDF; (2) render with the real number; (3) UPDATE + // pdf_data. The trade-off is two write trips but keeps the code + // shape simple. We accept it. + // + // Reasoning behind two-pass: if PDF render is moved inside the + // tx and fails (font missing, etc.), the allocated counter rolls + // back — good. But it also means the connection is held during + // render. At v1 scale that's fine; the choice is reversible. + + // Pass 1: allocate number + persist with empty PDF. + const placeholder = await createInvoice(draft, null, null); + try { + const pdfBuffer = await renderInvoicePdf( + placeholder, + draft.lines.map((l, i) => ({ + ...l, + id: `tmp-${i}`, + invoiceId: placeholder.id, + })) + ); + const filename = `${placeholder.invoiceNumber}.pdf`; + // Pass 2: store the PDF bytes. + await updateInvoicePdf(placeholder.id, pdfBuffer, filename); + const finalInvoice = await getInvoiceById(placeholder.id); + return { draft, invoice: finalInvoice ?? placeholder }; + } catch (e) { + // Render failed — leave the persisted row in place so admin can + // inspect it, but surface the error. + throw new Error( + `Invoice ${placeholder.invoiceNumber} persisted but PDF rendering failed: ${ + e instanceof Error ? e.message : String(e) + }. Use the admin "delete invoice" tool to clean up if needed.` + ); + } +} diff --git a/src/lib/db.ts b/src/lib/db.ts index 6d15a2d..35240f6 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -470,6 +470,12 @@ const MIGRATION_SQL = ` paid_method_detail TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); + -- Phase 2 addition: PDF locale, frozen at issue time so re-rendering + -- an old invoice produces an identical document. Defaults to 'de' + -- since most pilot customers are Swiss B2B; the generator UI lets + -- admin override at issue time. + ALTER TABLE invoices + ADD COLUMN IF NOT EXISTS locale TEXT NOT NULL DEFAULT 'de'; CREATE INDEX IF NOT EXISTS idx_invoices_org ON invoices(zitadel_org_id, issued_at DESC); CREATE INDEX IF NOT EXISTS idx_invoices_status @@ -2124,3 +2130,424 @@ export async function backfillTenantBillingLifecycle(tenants: { } return { lifecycleInserted, eventsInserted, suspensionEventsInserted }; } + +// --------------------------------------------------------------------------- +// Billing — Phase 2: invoice persistence +// --------------------------------------------------------------------------- +// +// Invoice creation is intentionally a single transaction: allocate +// number, INSERT invoice, INSERT lines, store PDF — all-or-nothing. +// The Postgres invoice_number_counters row lock serializes +// concurrent allocators for the same year, producing gapless +// numbering even under bursts. + +import type { + Invoice, + InvoiceBillingSnapshot, + InvoiceDetail, + InvoiceDraft, + InvoiceLine, + InvoiceStatus, +} from "@/types"; + +function rowToInvoice(row: any): Invoice { + 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], + issuedAt: row.issued_at?.toISOString?.() ?? row.issued_at, + dueAt: typeof row.due_at === "string" + ? row.due_at + : row.due_at.toISOString().split("T")[0], + subtotalChf: Number(row.subtotal_chf), + vatRate: Number(row.vat_rate), + vatAmountChf: Number(row.vat_amount_chf), + totalChf: Number(row.total_chf), + status: row.status as InvoiceStatus, + locale: row.locale ?? "de", + paymentMethod: row.payment_method, + billingSnapshot: row.billing_snapshot as InvoiceBillingSnapshot, + stripePaymentIntentId: row.stripe_payment_intent_id ?? null, + pdfFilename: row.pdf_filename ?? null, + hasPdf: row.has_pdf ?? row.pdf_data !== null, + adminNotes: row.admin_notes ?? null, + paidAt: row.paid_at?.toISOString?.() ?? row.paid_at ?? null, + paidBy: row.paid_by ?? null, + paidMethodDetail: row.paid_method_detail ?? null, + createdAt: row.created_at?.toISOString?.() ?? row.created_at, + }; +} + +function rowToInvoiceLine(row: any): InvoiceLine { + return { + id: row.id, + invoiceId: row.invoice_id, + tenantName: row.tenant_name ?? null, + kind: row.kind, + description: row.description, + quantity: Number(row.quantity), + unitLabel: row.unit_label ?? null, + unitPriceChf: Number(row.unit_price_chf), + amountChf: Number(row.amount_chf), + metadata: row.metadata ?? null, + displayOrder: row.display_order, + }; +} + +// Standard SELECT projection that includes a cheap NOT-NULL probe of +// pdf_data instead of pulling the bytes themselves. Crucial for list +// endpoints — a few KB per row across hundreds of invoices is wasted +// network and memory. +const INVOICE_LIST_COLUMNS = ` + id, 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, + stripe_payment_intent_id, pdf_filename, admin_notes, paid_at, + paid_by, paid_method_detail, created_at, + (pdf_data IS NOT NULL) AS has_pdf +`; + +/** + * Persist a fully-computed invoice draft with its lines and PDF in + * a single transaction. Allocates the year-scoped invoice number + * inside the same transaction so a rollback restores the counter + * (gapless guarantee). + * + * The caller is responsible for upstream validation: + * - the (org, period) uniqueness (the unique index will reject + * duplicates, but we return a clear error message rather than + * leaking the constraint name) + * - the draft's lines/totals are consistent (compute pipeline + * ensures this) + * + * `pdfBuffer` is the rendered PDF bytes; pass null if PDF is + * generated separately or stored in a side channel. For Phase 2 we + * always render synchronously and pass the buffer here. + */ +export async function createInvoice( + draft: InvoiceDraft, + pdfBuffer: Buffer | null, + pdfFilename: string | null +): Promise { + await ensureSchema(); + const pool = getPool(); + const client = await pool.connect(); + 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); + const counterResult = await client.query( + `INSERT INTO invoice_number_counters (year, last_number) + VALUES ($1, 1) + ON CONFLICT (year) DO UPDATE SET + last_number = invoice_number_counters.last_number + 1 + RETURNING last_number`, + [year] + ); + 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. + 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 + ) VALUES ( + $1, $2, $3::date, $4::date, now(), $5::date, $6, $7, $8, $9, + 'open', $10, $11, $12::jsonb, $13, $14 + ) + RETURNING ${INVOICE_LIST_COLUMNS}`, + [ + invoiceNumber, + draft.zitadelOrgId, + draft.periodStart, + draft.periodEnd, + draft.dueAt, + draft.subtotalChf, + draft.vatRate, + draft.vatAmountChf, + draft.totalChf, + draft.locale, + draft.paymentMethod, + JSON.stringify(draft.billingSnapshot), + pdfBuffer, + pdfFilename, + ] + ); + const invoiceId = inv.rows[0].id; + + // Insert lines in batch — one INSERT statement is significantly + // faster than per-line round-trips, which matters when an invoice + // accumulates many ai_usage / skill_usage lines. + if (draft.lines.length > 0) { + const placeholders: string[] = []; + const values: any[] = []; + let idx = 1; + for (const line of draft.lines) { + placeholders.push( + `($${idx++}, $${idx++}, $${idx++}, $${idx++}, $${idx++}, $${idx++}, $${idx++}, $${idx++}, $${idx++}::jsonb, $${idx++})` + ); + values.push( + invoiceId, + line.tenantName, + line.kind, + line.description, + line.quantity, + line.unitLabel, + line.unitPriceChf, + line.amountChf, + line.metadata ? JSON.stringify(line.metadata) : null, + line.displayOrder + ); + } + await client.query( + `INSERT INTO invoice_lines ( + invoice_id, tenant_name, kind, description, quantity, + unit_label, unit_price_chf, amount_chf, metadata, display_order + ) VALUES ${placeholders.join(", ")}`, + values + ); + } + + await client.query("COMMIT"); + return rowToInvoice(inv.rows[0]); + } 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); + throw new Error( + `An invoice already exists for this org and billing period (${month}). ` + + `Delete the existing invoice first if you want to regenerate.` + ); + } + throw e; + } finally { + client.release(); + } +} + +export async function getInvoiceById(id: string): Promise { + await ensureSchema(); + const result = await getPool().query( + `SELECT ${INVOICE_LIST_COLUMNS} FROM invoices WHERE id = $1`, + [id] + ); + return result.rows.length > 0 ? rowToInvoice(result.rows[0]) : null; +} + +export async function getInvoiceDetail( + id: string +): Promise { + const invoice = await getInvoiceById(id); + if (!invoice) return null; + const lines = await getPool().query( + `SELECT * FROM invoice_lines WHERE invoice_id = $1 + ORDER BY display_order, id`, + [id] + ); + return { invoice, lines: lines.rows.map(rowToInvoiceLine) }; +} + +/** + * Fetch the PDF bytes for an invoice. Returns null if no PDF was + * stored (shouldn't happen in v1; defensive against partial state). + */ +export async function getInvoicePdf( + id: string +): Promise<{ data: Buffer; filename: string } | null> { + await ensureSchema(); + const result = await getPool().query( + "SELECT pdf_data, pdf_filename, invoice_number FROM invoices WHERE id = $1", + [id] + ); + if (result.rows.length === 0) return null; + const row = result.rows[0]; + if (!row.pdf_data) return null; + return { + data: row.pdf_data, + filename: row.pdf_filename ?? `${row.invoice_number}.pdf`, + }; +} + +/** + * List invoices, optionally filtered. Used by the admin invoice + * list page and (Phase 3) the customer-facing /billing page. + * + * The customer-facing call site MUST pass `zitadelOrgId` to scope + * results — this helper does not enforce that itself. + */ +export async function listInvoices(filters: { + zitadelOrgId?: string; + status?: InvoiceStatus; + /** Inclusive YYYY-MM filter on period_start. */ + periodMonth?: string; + limit?: number; +} = {}): Promise { + await ensureSchema(); + const where: string[] = []; + const values: any[] = []; + let idx = 1; + if (filters.zitadelOrgId) { + where.push(`zitadel_org_id = $${idx++}`); + values.push(filters.zitadelOrgId); + } + if (filters.status) { + where.push(`status = $${idx++}`); + values.push(filters.status); + } + if (filters.periodMonth) { + where.push(`to_char(period_start, 'YYYY-MM') = $${idx++}`); + values.push(filters.periodMonth); + } + const limit = filters.limit ?? 200; + const sql = + `SELECT ${INVOICE_LIST_COLUMNS} FROM invoices ` + + (where.length > 0 ? `WHERE ${where.join(" AND ")} ` : "") + + `ORDER BY issued_at DESC LIMIT $${idx}`; + values.push(limit); + const result = await getPool().query(sql, values); + return result.rows.map(rowToInvoice); +} + +/** + * Sweep open invoices past their due date to `overdue` status. + * Cheap idempotent UPDATE; safe to call on every admin list view + * to keep status fresh without a dedicated cron. + */ +export async function syncOverdueInvoices(): Promise { + await ensureSchema(); + const result = await getPool().query( + `UPDATE invoices + SET status = 'overdue' + WHERE status = 'open' + AND due_at < CURRENT_DATE` + ); + return result.rowCount ?? 0; +} + +export async function markInvoicePaid( + id: string, + opts: { paidBy: string; paidMethodDetail?: string | null; paidAt?: Date } +): Promise { + await ensureSchema(); + const result = await getPool().query( + `UPDATE invoices + SET status = 'paid', + paid_at = COALESCE($2::timestamptz, now()), + paid_by = $3, + paid_method_detail = $4 + WHERE id = $1 + AND status IN ('open', 'overdue') + RETURNING ${INVOICE_LIST_COLUMNS}`, + [ + id, + opts.paidAt ?? null, + opts.paidBy, + opts.paidMethodDetail ?? null, + ] + ); + return result.rows.length > 0 ? rowToInvoice(result.rows[0]) : null; +} + +/** + * Hard delete an invoice and its lines (CASCADE). + * + * This is the testing tool — Swiss bookkeeping requires immutable + * invoices in production, but during pilot/testing we need to + * iterate. The gap left in the invoice number sequence is + * intentional and documented; no attempt to "recycle" numbers. + * + * Reminders (and their PDFs) cascade-delete via the FK. + */ +export async function deleteInvoice(id: string): Promise { + await ensureSchema(); + const result = await getPool().query( + "DELETE FROM invoices WHERE id = $1 RETURNING id", + [id] + ); + return (result.rowCount ?? 0) > 0; +} + +/** + * Has this tenant ever been billed a setup fee? Drives the + * compute pipeline's "include setup line on first invoice" + * decision. Looks at invoice_lines directly so it survives org + * billing config edits. + */ +export async function tenantHasSetupFeeBilled( + tenantName: string +): Promise { + await ensureSchema(); + const result = await getPool().query( + `SELECT 1 FROM invoice_lines + WHERE tenant_name = $1 AND kind = 'tenant_setup' + LIMIT 1`, + [tenantName] + ); + return result.rows.length > 0; +} + +/** + * Aggregate open balance per org for the admin overview. Returns + * orgs with at least one open or overdue invoice; orgs in good + * standing don't appear. + */ +export async function getOrgOpenBalances(): Promise<{ + zitadelOrgId: string; + openCount: number; + overdueCount: number; + totalOpenChf: number; +}[]> { + await ensureSchema(); + const result = await getPool().query( + `SELECT + zitadel_org_id, + COUNT(*) FILTER (WHERE status = 'open') AS open_count, + COUNT(*) FILTER (WHERE status = 'overdue') AS overdue_count, + SUM(total_chf) FILTER (WHERE status IN ('open', 'overdue')) AS total_open + FROM invoices + WHERE status IN ('open', 'overdue') + GROUP BY zitadel_org_id + ORDER BY total_open DESC` + ); + return result.rows.map((r) => ({ + zitadelOrgId: r.zitadel_org_id, + openCount: Number(r.open_count), + overdueCount: Number(r.overdue_count), + totalOpenChf: Number(r.total_open), + })); +} + +/** + * Update the stored PDF for an invoice. Used by the two-pass + * compute pipeline: insert invoice with empty PDF → render PDF with + * the allocated invoice number → write bytes back. + * + * Could be merged into createInvoice via a render callback in a + * future cleanup, but two passes are simpler and the extra UPDATE + * is cheap. + */ +export async function updateInvoicePdf( + invoiceId: string, + pdfBuffer: Buffer, + filename: string +): Promise { + await ensureSchema(); + await getPool().query( + "UPDATE invoices SET pdf_data = $2, pdf_filename = $3 WHERE id = $1", + [invoiceId, pdfBuffer, filename] + ); +} diff --git a/src/messages/de.json b/src/messages/de.json index c744564..4c4a947 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -384,7 +384,8 @@ "spendChf": "Kosten (CHF)", "resumeRequestBadge": "Wieder", "resumeRequestTooltip": "Reaktivierungsanfrage für einen bestehenden Tenant. Bei Genehmigung wird der Tenant wieder aktiviert; keine Provisionierung läuft.", - "openclawTool": "OpenClaw-Versionen" + "openclawTool": "OpenClaw-Versionen", + "billingTool": "Abrechnung →" }, "channelUsers": { "title": "Autorisierte Benutzer", @@ -553,5 +554,105 @@ "defaultPrefix": "Standard:", "saveOverride": "Override speichern", "clearOverride": "Override entfernen" + }, + "adminBilling": { + "title": "Abrechnungsverwaltung", + "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", + "totalOpenBalance": "Offener Saldo gesamt", + "orgsWithBalance": "Organisationen mit Saldo", + "overdueInvoices": "Überfällige Rechnungen", + "pricingTitle": "Preise", + "pricingDesc": "Plattform- & Skill-Preise, MWST-Satz.", + "pricingPageDesc": "Plattformweite Preise und Skill-Tagespreise bearbeiten.", + "generateTitle": "Rechnung erstellen", + "generateDesc": "Rechnung für eine Organisation und einen Monat berechnen und ausstellen.", + "generatePageDesc": "Organisation, Periode und Sprache wählen. Die Vorschau zeigt die berechneten Positionen; mit Bestätigen wird die Rechnung ausgestellt und das PDF erzeugt.", + "invoicesTitle": "Rechnungen", + "invoicesDesc": "Alle Rechnungen anzeigen, als bezahlt markieren, PDFs herunterladen.", + "invoicesPageDesc": "Alle von der Plattform ausgestellten Rechnungen. Mit dem Statusfilter offene oder überfällige Positionen einsehen.", + "balancesTitle": "Organisationen mit offenem Saldo", + "orgIdCol": "Zitadel-Org-ID", + "openCountCol": "Offen", + "overdueCountCol": "Überfällig", + "totalOpenCol": "Gesamt offen", + "platformPricingTitle": "Plattform-Preise", + "monthlyFeeLabel": "Monatliche Tenant-Gebühr", + "setupFeeLabel": "Einrichtungsgebühr Tenant", + "threemaMessageLabel": "Threema pro Nachricht", + "vatRateLabel": "MWST-Satz (CH/LI)", + "save": "Speichern", + "saving": "Speichere…", + "savedOk": "Gespeichert", + "skillPricingTitle": "Skill-Preise", + "skillPricingDesc": "Tagespreis pro Skill. Ein zu beliebigem Zeitpunkt an einem UTC-Tag aktivierter Skill zählt als ein abrechenbarer Tag.", + "skillCol": "Skill", + "dailyPriceCol": "Tagespreis", + "actionsCol": "", + "remove": "Entfernen", + "noSkillsPriced": "Noch keine Skills bepreist.", + "addSkillLabel": "Skill hinzufügen", + "dailyPriceLabel": "Tagespreis", + "add": "Hinzufügen", + "confirmDeleteSkillPrice": "Preis für {skill} entfernen?", + "clickToEdit": "Zum Bearbeiten klicken", + "generateFormTitle": "Rechnung erstellen", + "noOrgsToGenerate": "Keine Organisationen mit Tenants gefunden.", + "orgLabel": "Organisation", + "noBillingAddrTag": "keine Rechnungsadresse", + "noBillingAddrWarning": "Diese Organisation hat keine Rechnungsadresse hinterlegt. Der Kunde muss /settings/billing ausfüllen, bevor eine Rechnung ausgestellt werden kann.", + "tenantsLabel": "Tenants", + "yearLabel": "Jahr", + "monthLabel": "Monat", + "localeLabel": "PDF-Sprache", + "localeAuto": "Automatisch", + "previewBtn": "Vorschau", + "commitBtn": "Bestätigen & ausstellen", + "computing": "Berechne…", + "confirmGenerate": "Diese Rechnung ausstellen? Es wird eine Rechnungsnummer vergeben und das PDF erzeugt.", + "previewTitle": "Entwurfsvorschau", + "warningsTitle": "Hinweise", + "noLinesGenerated": "Keine abrechenbaren Positionen für diese Periode.", + "descCol": "Beschreibung", + "qtyCol": "Menge", + "unitPriceCol": "Einzelpreis", + "amountCol": "Betrag (CHF)", + "subtotal": "Zwischensumme", + "vat": "MWST", + "total": "Total", + "statusFilterLabel": "Status", + "allStatuses": "Alle", + "monthFilterLabel": "Periode", + "clearFilter": "Zurücksetzen", + "loading": "Lade…", + "noInvoicesFound": "Keine Rechnungen entsprechen den aktuellen Filtern.", + "invoiceNumberCol": "Nummer", + "orgCol": "Organisation", + "periodCol": "Periode", + "statusCol": "Status", + "totalCol": "Total", + "dueCol": "Fällig", + "status_draft": "Entwurf", + "status_open": "Offen", + "status_paid": "Bezahlt", + "status_overdue": "Überfällig", + "status_void": "Storniert", + "status_uncollectible": "Uneinbringlich", + "dueOnLabel": "Fällig", + "totalLabel": "Total", + "downloadPdfBtn": "PDF herunterladen", + "markPaidBtn": "Als bezahlt markieren", + "paidNotePlaceholder": "Optionale Notiz (z. B. Bankreferenz, Eingangsdatum)", + "confirm": "Bestätigen", + "cancel": "Abbrechen", + "deleteBtn": "Löschen", + "deleting": "Lösche…", + "deleteHint": "Rechnung hart löschen (Test-Tool). Die Nummer bleibt vergeben.", + "confirmDeleteInvoice": "Rechnung {num} löschen? Dies ist eine harte Löschung — die Rechnungsnummer bleibt verbraucht.", + "paidOnLabel": "Bezahlt am", + "lineItemsTitle": "Positionen", + "billToSnapshotTitle": "Rechnungsempfänger" } } diff --git a/src/messages/en.json b/src/messages/en.json index 0a69aae..334c942 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -384,7 +384,8 @@ "spendChf": "Spend (CHF)", "resumeRequestBadge": "Resume", "resumeRequestTooltip": "Reactivation request for an existing tenant. Approving will un-suspend the tenant; no provisioning runs.", - "openclawTool": "OpenClaw versions" + "openclawTool": "OpenClaw versions", + "billingTool": "Billing →" }, "channelUsers": { "title": "Authorized Users", @@ -553,5 +554,105 @@ "defaultPrefix": "Default:", "saveOverride": "Save override", "clearOverride": "Clear override" + }, + "adminBilling": { + "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", + "totalOpenBalance": "Total open balance", + "orgsWithBalance": "Orgs with balance", + "overdueInvoices": "Overdue invoices", + "pricingTitle": "Pricing", + "pricingDesc": "Platform & skill prices, VAT rate.", + "pricingPageDesc": "Edit platform-wide pricing and per-skill daily rates.", + "generateTitle": "Generate invoice", + "generateDesc": "Compute and issue an invoice for a given org & month.", + "generatePageDesc": "Pick an org, period and locale. Preview shows the computed lines; commit issues the invoice and renders the PDF.", + "invoicesTitle": "Invoices", + "invoicesDesc": "Browse all issued invoices, mark paid, download PDFs.", + "invoicesPageDesc": "All invoices issued by the platform. Use the status filter to focus on open or overdue items.", + "balancesTitle": "Orgs with open balance", + "orgIdCol": "Zitadel org ID", + "openCountCol": "Open", + "overdueCountCol": "Overdue", + "totalOpenCol": "Total open", + "platformPricingTitle": "Platform pricing", + "monthlyFeeLabel": "Tenant monthly fee", + "setupFeeLabel": "Tenant setup fee", + "threemaMessageLabel": "Threema per message", + "vatRateLabel": "VAT rate (CH/LI)", + "save": "Save", + "saving": "Saving…", + "savedOk": "Saved", + "skillPricingTitle": "Skill pricing", + "skillPricingDesc": "Per-skill daily price. A skill enabled at any point during a UTC day counts as one billable day.", + "skillCol": "Skill", + "dailyPriceCol": "Daily price", + "actionsCol": "", + "remove": "Remove", + "noSkillsPriced": "No skills are priced yet.", + "addSkillLabel": "Add skill", + "dailyPriceLabel": "Daily price", + "add": "Add", + "confirmDeleteSkillPrice": "Remove pricing for {skill}?", + "clickToEdit": "Click to edit", + "generateFormTitle": "Generate invoice", + "noOrgsToGenerate": "No organizations with tenants found.", + "orgLabel": "Organization", + "noBillingAddrTag": "no billing address", + "noBillingAddrWarning": "This org has no billing address on file. The customer must complete /settings/billing before an invoice can be issued.", + "tenantsLabel": "tenants", + "yearLabel": "Year", + "monthLabel": "Month", + "localeLabel": "PDF language", + "localeAuto": "Auto", + "previewBtn": "Preview", + "commitBtn": "Commit & issue", + "computing": "Computing…", + "confirmGenerate": "Issue this invoice? This action allocates an invoice number and renders the PDF.", + "previewTitle": "Draft preview", + "warningsTitle": "Warnings", + "noLinesGenerated": "No billable lines for this period.", + "descCol": "Description", + "qtyCol": "Qty", + "unitPriceCol": "Unit price", + "amountCol": "Amount (CHF)", + "subtotal": "Subtotal", + "vat": "VAT", + "total": "Total", + "statusFilterLabel": "Status", + "allStatuses": "All", + "monthFilterLabel": "Period", + "clearFilter": "Clear", + "loading": "Loading…", + "noInvoicesFound": "No invoices match the current filters.", + "invoiceNumberCol": "Number", + "orgCol": "Organization", + "periodCol": "Period", + "statusCol": "Status", + "totalCol": "Total", + "dueCol": "Due", + "status_draft": "Draft", + "status_open": "Open", + "status_paid": "Paid", + "status_overdue": "Overdue", + "status_void": "Void", + "status_uncollectible": "Uncollectible", + "dueOnLabel": "Due", + "totalLabel": "Total", + "downloadPdfBtn": "Download PDF", + "markPaidBtn": "Mark as paid", + "paidNotePlaceholder": "Optional note (e.g. bank reference, deposit date)", + "confirm": "Confirm", + "cancel": "Cancel", + "deleteBtn": "Delete", + "deleting": "Deleting…", + "deleteHint": "Hard-delete this invoice (testing tool). Number is consumed.", + "confirmDeleteInvoice": "Delete invoice {num}? This is a hard delete — the invoice number stays consumed.", + "paidOnLabel": "Paid", + "lineItemsTitle": "Line items", + "billToSnapshotTitle": "Billed to" } } diff --git a/src/messages/fr.json b/src/messages/fr.json index d582663..7afec6d 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -384,7 +384,8 @@ "spendChf": "Coûts (CHF)", "resumeRequestBadge": "Reprise", "resumeRequestTooltip": "Demande de réactivation d'un locataire existant. L'approbation le réactivera ; aucun provisionnement ne s'exécute.", - "openclawTool": "Versions OpenClaw" + "openclawTool": "Versions OpenClaw", + "billingTool": "Facturation →" }, "channelUsers": { "title": "Utilisateurs autorisés", @@ -553,5 +554,105 @@ "defaultPrefix": "Défaut :", "saveOverride": "Enregistrer la surcharge", "clearOverride": "Supprimer la surcharge" + }, + "adminBilling": { + "title": "Administration de la facturation", + "subtitle": "Gérer les tarifs de la plateforme, générer des factures et examiner le statut de facturation des organisations.", + "backToAdmin": "Retour à l'administration", + "backToBilling": "Retour à la facturation", + "backToInvoices": "Retour aux factures", + "totalOpenBalance": "Solde ouvert total", + "orgsWithBalance": "Organisations avec solde", + "overdueInvoices": "Factures en retard", + "pricingTitle": "Tarifs", + "pricingDesc": "Tarifs plateforme & skills, taux TVA.", + "pricingPageDesc": "Modifier les tarifs de la plateforme et les prix journaliers par skill.", + "generateTitle": "Générer une facture", + "generateDesc": "Calculer et émettre une facture pour une organisation et un mois.", + "generatePageDesc": "Choisir une organisation, une période et une langue. L'aperçu affiche les lignes calculées; valider émet la facture et génère le PDF.", + "invoicesTitle": "Factures", + "invoicesDesc": "Parcourir les factures, marquer comme payées, télécharger les PDF.", + "invoicesPageDesc": "Toutes les factures émises par la plateforme. Utiliser le filtre de statut pour cibler les éléments ouverts ou en retard.", + "balancesTitle": "Organisations avec solde ouvert", + "orgIdCol": "ID org Zitadel", + "openCountCol": "Ouvert", + "overdueCountCol": "En retard", + "totalOpenCol": "Total ouvert", + "platformPricingTitle": "Tarifs plateforme", + "monthlyFeeLabel": "Forfait mensuel tenant", + "setupFeeLabel": "Frais de configuration tenant", + "threemaMessageLabel": "Threema par message", + "vatRateLabel": "Taux TVA (CH/LI)", + "save": "Enregistrer", + "saving": "Enregistrement…", + "savedOk": "Enregistré", + "skillPricingTitle": "Tarifs des skills", + "skillPricingDesc": "Prix journalier par skill. Un skill activé à tout moment au cours d'une journée UTC compte comme un jour facturable.", + "skillCol": "Skill", + "dailyPriceCol": "Prix/jour", + "actionsCol": "", + "remove": "Retirer", + "noSkillsPriced": "Aucun skill n'a encore de prix.", + "addSkillLabel": "Ajouter un skill", + "dailyPriceLabel": "Prix/jour", + "add": "Ajouter", + "confirmDeleteSkillPrice": "Retirer le prix pour {skill}?", + "clickToEdit": "Cliquer pour modifier", + "generateFormTitle": "Générer une facture", + "noOrgsToGenerate": "Aucune organisation avec tenants trouvée.", + "orgLabel": "Organisation", + "noBillingAddrTag": "pas d'adresse de facturation", + "noBillingAddrWarning": "Cette organisation n'a pas d'adresse de facturation enregistrée. Le client doit compléter /settings/billing avant qu'une facture puisse être émise.", + "tenantsLabel": "tenants", + "yearLabel": "Année", + "monthLabel": "Mois", + "localeLabel": "Langue PDF", + "localeAuto": "Auto", + "previewBtn": "Aperçu", + "commitBtn": "Valider & émettre", + "computing": "Calcul…", + "confirmGenerate": "Émettre cette facture? Cette action attribue un numéro de facture et génère le PDF.", + "previewTitle": "Aperçu du brouillon", + "warningsTitle": "Avertissements", + "noLinesGenerated": "Aucune ligne facturable pour cette période.", + "descCol": "Description", + "qtyCol": "Qté", + "unitPriceCol": "Prix unitaire", + "amountCol": "Montant (CHF)", + "subtotal": "Sous-total", + "vat": "TVA", + "total": "Total", + "statusFilterLabel": "Statut", + "allStatuses": "Tous", + "monthFilterLabel": "Période", + "clearFilter": "Effacer", + "loading": "Chargement…", + "noInvoicesFound": "Aucune facture ne correspond aux filtres.", + "invoiceNumberCol": "Numéro", + "orgCol": "Organisation", + "periodCol": "Période", + "statusCol": "Statut", + "totalCol": "Total", + "dueCol": "Échéance", + "status_draft": "Brouillon", + "status_open": "Ouverte", + "status_paid": "Payée", + "status_overdue": "En retard", + "status_void": "Annulée", + "status_uncollectible": "Irrécouvrable", + "dueOnLabel": "Échéance", + "totalLabel": "Total", + "downloadPdfBtn": "Télécharger le PDF", + "markPaidBtn": "Marquer comme payée", + "paidNotePlaceholder": "Note facultative (ex. référence bancaire, date de paiement)", + "confirm": "Confirmer", + "cancel": "Annuler", + "deleteBtn": "Supprimer", + "deleting": "Suppression…", + "deleteHint": "Suppression définitive (outil de test). Le numéro reste utilisé.", + "confirmDeleteInvoice": "Supprimer la facture {num}? Suppression définitive — le numéro reste utilisé.", + "paidOnLabel": "Payée le", + "lineItemsTitle": "Lignes", + "billToSnapshotTitle": "Destinataire" } } diff --git a/src/messages/it.json b/src/messages/it.json index 215d5b4..0da4f53 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -384,7 +384,8 @@ "spendChf": "Costi (CHF)", "resumeRequestBadge": "Ripresa", "resumeRequestTooltip": "Richiesta di riattivazione di un tenant esistente. L'approvazione lo riattiverà; non viene eseguito alcun provisioning.", - "openclawTool": "Versioni OpenClaw" + "openclawTool": "Versioni OpenClaw", + "billingTool": "Fatturazione →" }, "channelUsers": { "title": "Utenti autorizzati", @@ -553,5 +554,105 @@ "defaultPrefix": "Predefinito:", "saveOverride": "Salva override", "clearOverride": "Rimuovi override" + }, + "adminBilling": { + "title": "Amministrazione fatturazione", + "subtitle": "Gestire prezzi della piattaforma, generare fatture e verificare lo stato di fatturazione delle organizzazioni.", + "backToAdmin": "Torna ad amministrazione", + "backToBilling": "Torna alla fatturazione", + "backToInvoices": "Torna alle fatture", + "totalOpenBalance": "Saldo aperto totale", + "orgsWithBalance": "Organizzazioni con saldo", + "overdueInvoices": "Fatture scadute", + "pricingTitle": "Prezzi", + "pricingDesc": "Prezzi piattaforma & skill, aliquota IVA.", + "pricingPageDesc": "Modificare i prezzi della piattaforma e i prezzi giornalieri per skill.", + "generateTitle": "Genera fattura", + "generateDesc": "Calcolare ed emettere una fattura per organizzazione e mese.", + "generatePageDesc": "Scegli organizzazione, periodo e lingua. L'anteprima mostra le righe calcolate; conferma emette la fattura e genera il PDF.", + "invoicesTitle": "Fatture", + "invoicesDesc": "Sfoglia le fatture, segna come pagate, scarica i PDF.", + "invoicesPageDesc": "Tutte le fatture emesse dalla piattaforma. Usa il filtro di stato per focalizzarti su voci aperte o scadute.", + "balancesTitle": "Organizzazioni con saldo aperto", + "orgIdCol": "ID org Zitadel", + "openCountCol": "Aperte", + "overdueCountCol": "Scadute", + "totalOpenCol": "Totale aperto", + "platformPricingTitle": "Prezzi piattaforma", + "monthlyFeeLabel": "Canone mensile tenant", + "setupFeeLabel": "Spese di attivazione tenant", + "threemaMessageLabel": "Threema per messaggio", + "vatRateLabel": "Aliquota IVA (CH/LI)", + "save": "Salva", + "saving": "Salvataggio…", + "savedOk": "Salvato", + "skillPricingTitle": "Prezzi skill", + "skillPricingDesc": "Prezzo giornaliero per skill. Una skill attiva in qualsiasi momento di un giorno UTC conta come un giorno fatturabile.", + "skillCol": "Skill", + "dailyPriceCol": "Prezzo/giorno", + "actionsCol": "", + "remove": "Rimuovi", + "noSkillsPriced": "Nessuna skill ha ancora un prezzo.", + "addSkillLabel": "Aggiungi skill", + "dailyPriceLabel": "Prezzo/giorno", + "add": "Aggiungi", + "confirmDeleteSkillPrice": "Rimuovere il prezzo per {skill}?", + "clickToEdit": "Clicca per modificare", + "generateFormTitle": "Genera fattura", + "noOrgsToGenerate": "Nessuna organizzazione con tenant trovata.", + "orgLabel": "Organizzazione", + "noBillingAddrTag": "nessun indirizzo di fatturazione", + "noBillingAddrWarning": "Questa organizzazione non ha un indirizzo di fatturazione registrato. Il cliente deve completare /settings/billing prima che una fattura possa essere emessa.", + "tenantsLabel": "tenant", + "yearLabel": "Anno", + "monthLabel": "Mese", + "localeLabel": "Lingua PDF", + "localeAuto": "Auto", + "previewBtn": "Anteprima", + "commitBtn": "Conferma & emetti", + "computing": "Calcolo…", + "confirmGenerate": "Emettere questa fattura? L'operazione assegna un numero di fattura e genera il PDF.", + "previewTitle": "Anteprima bozza", + "warningsTitle": "Avvisi", + "noLinesGenerated": "Nessuna riga fatturabile per questo periodo.", + "descCol": "Descrizione", + "qtyCol": "Qtà", + "unitPriceCol": "Prezzo unitario", + "amountCol": "Importo (CHF)", + "subtotal": "Subtotale", + "vat": "IVA", + "total": "Totale", + "statusFilterLabel": "Stato", + "allStatuses": "Tutti", + "monthFilterLabel": "Periodo", + "clearFilter": "Pulisci", + "loading": "Caricamento…", + "noInvoicesFound": "Nessuna fattura corrisponde ai filtri.", + "invoiceNumberCol": "Numero", + "orgCol": "Organizzazione", + "periodCol": "Periodo", + "statusCol": "Stato", + "totalCol": "Totale", + "dueCol": "Scadenza", + "status_draft": "Bozza", + "status_open": "Aperta", + "status_paid": "Pagata", + "status_overdue": "Scaduta", + "status_void": "Annullata", + "status_uncollectible": "Inesigibile", + "dueOnLabel": "Scadenza", + "totalLabel": "Totale", + "downloadPdfBtn": "Scarica PDF", + "markPaidBtn": "Segna come pagata", + "paidNotePlaceholder": "Nota opzionale (es. riferimento bancario, data di pagamento)", + "confirm": "Conferma", + "cancel": "Annulla", + "deleteBtn": "Elimina", + "deleting": "Eliminazione…", + "deleteHint": "Eliminazione definitiva (strumento di test). Il numero rimane consumato.", + "confirmDeleteInvoice": "Eliminare la fattura {num}? Eliminazione definitiva — il numero rimane consumato.", + "paidOnLabel": "Pagata il", + "lineItemsTitle": "Righe", + "billToSnapshotTitle": "Destinatario" } } diff --git a/src/types/index.ts b/src/types/index.ts index b3a1d84..20dfb46 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -520,3 +520,130 @@ export interface OrgBillingConfig { createdAt: string; updatedAt: string; } + +// --------------------------------------------------------------------------- +// Billing — Phase 2: invoices and lines +// --------------------------------------------------------------------------- + +export type InvoiceStatus = + | "draft" + | "open" + | "paid" + | "overdue" + | "void" + | "uncollectible"; + +export type InvoicePaymentMethod = "invoice" | "card"; + +export type InvoiceLineKind = + | "tenant_monthly" + | "tenant_setup" + | "ai_usage" + | "threema_messages" + | "skill_usage" + | "adjustment"; + +/** + * Snapshot of the customer's billing details captured at invoice + * issue time. Subsequent edits to org_billing do not mutate + * historical invoices. + * + * Field names mirror OrgBilling (minus the timestamps) so the + * snapshot is a straightforward copy at issue time. + */ +export interface InvoiceBillingSnapshot { + companyName: string; + streetAddress: string; + postalCode: string; + city: string; + country: string; + vatNumber: string | null; + billingEmail: string; + notes: string | null; +} + +/** + * One line on an invoice. The `metadata` shape varies by `kind`: + * tenant_monthly: { proration_days, days_in_month, billable_days, suspended_days } + * tenant_setup: {} + * ai_usage: { litellm_key_alias, spend_chf, requests } + * threema_messages: { in_count, out_count, total_count } + * skill_usage: { skill_id, billable_days, event_count } + * adjustment: { reason, admin_user_id } + */ +export interface InvoiceLine { + id: string; + invoiceId: string; + tenantName: string | null; + kind: InvoiceLineKind; + description: string; + quantity: number; + unitLabel: string | null; + unitPriceChf: number; + amountChf: number; + metadata: Record | null; + displayOrder: number; +} + +/** + * Immutable invoice record. The PDF blob is fetched separately via + * the download endpoint to avoid loading bytea on every list query. + */ +export interface Invoice { + id: string; + invoiceNumber: string; + zitadelOrgId: string; + periodStart: string; // ISO date (YYYY-MM-DD) + periodEnd: string; + issuedAt: string; + dueAt: string; + subtotalChf: number; + vatRate: number; + vatAmountChf: number; + totalChf: number; + status: InvoiceStatus; + locale: string; + paymentMethod: InvoicePaymentMethod; + billingSnapshot: InvoiceBillingSnapshot; + stripePaymentIntentId: string | null; + pdfFilename: string | null; + hasPdf: boolean; // computed: pdf_data IS NOT NULL + adminNotes: string | null; + paidAt: string | null; + paidBy: string | null; + paidMethodDetail: string | null; + createdAt: string; +} + +/** Invoice with its line items, used by detail views. */ +export interface InvoiceDetail { + invoice: Invoice; + lines: InvoiceLine[]; +} + +/** + * In-memory draft produced by the computation pipeline before the + * invoice is allocated a number and persisted. Used by both the + * preview endpoint (return without persisting) and the commit + * endpoint (compute → persist atomically). + */ +export interface InvoiceDraft { + zitadelOrgId: string; + periodStart: string; + periodEnd: string; + dueAt: string; + locale: string; + paymentMethod: InvoicePaymentMethod; + billingSnapshot: InvoiceBillingSnapshot; + lines: Omit[]; + subtotalChf: number; + vatRate: number; + vatAmountChf: number; + totalChf: number; + /** + * Non-blocking warnings the compute pipeline surfaced — e.g. + * "tenant X has no LiteLLM team, AI usage skipped". Rendered in + * the admin UI to help the operator decide whether to commit. + */ + warnings: string[]; +}