diff --git a/src/app/[locale]/billing/[invoiceNumber]/page.tsx b/src/app/[locale]/billing/[invoiceNumber]/page.tsx new file mode 100644 index 0000000..ffeb754 --- /dev/null +++ b/src/app/[locale]/billing/[invoiceNumber]/page.tsx @@ -0,0 +1,35 @@ +import { redirect, notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import { getSessionUser } from "@/lib/session"; +import { getInvoiceByNumberForOrg } from "@/lib/db"; +import { BackLink } from "@/components/ui/back-link"; +import { CustomerInvoiceDetail } from "@/components/billing/customer-invoice-detail"; + +/** + * /billing/[invoiceNumber] — single-invoice view. + * + * Lookup is by the human-readable invoice number (the YYYY-NNNNN + * format printed on the PDF and in the issuance email). Org + * filter is enforced in the DB query — a customer trying another + * org's number gets 404, not 403, to avoid leaking the existence + * of other orgs' invoices. + */ +export default async function CustomerInvoiceDetailPage({ + params, +}: { + params: Promise<{ invoiceNumber: string; locale: string }>; +}) { + const user = await getSessionUser(); + if (!user) redirect("/login"); + const { invoiceNumber } = await params; + const t = await getTranslations("customerBilling"); + const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId); + if (!detail) notFound(); + + return ( +
+ + +
+ ); +} diff --git a/src/app/[locale]/billing/page.tsx b/src/app/[locale]/billing/page.tsx new file mode 100644 index 0000000..22207db --- /dev/null +++ b/src/app/[locale]/billing/page.tsx @@ -0,0 +1,63 @@ +import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import { getSessionUser } from "@/lib/session"; +import { listInvoices, syncOverdueInvoices } from "@/lib/db"; +import { CustomerInvoiceList } from "@/components/billing/customer-invoice-list"; +import { RunningTotalWidget } from "@/components/billing/running-total-widget"; + +/** + * /billing — customer's billing home. + * + * Shows two things: + * 1. RunningTotalWidget — current calendar month's accruing cost + * (or the already-issued invoice for the current month, if + * that ran early). + * 2. CustomerInvoiceList — every issued invoice for this org, + * newest first. Status is reflected with a colored badge. + * + * Anyone signed in can view this. The data is org-scoped; even + * non-owner team members see the same view. Phase 4 will add a + * "settings.payByInvoice" toggle visibility-gated to owners only. + */ +export default async function CustomerBillingPage() { + const user = await getSessionUser(); + if (!user) redirect("/login"); + const t = await getTranslations("customerBilling"); + + // Sync overdue status before listing — cheap, idempotent. + try { + await syncOverdueInvoices(); + } catch (e) { + console.warn("syncOverdueInvoices failed in /billing:", e); + } + + const invoices = await listInvoices({ + zitadelOrgId: user.orgId, + limit: 200, + }); + + return ( +
+
+

+ {t("title")} +

+

{t("subtitle")}

+
+ +
+

+ {t("currentPeriodHeading")} +

+ +
+ +
+

+ {t("historyHeading")} +

+ +
+
+ ); +} diff --git a/src/app/api/billing/current/route.ts b/src/app/api/billing/current/route.ts new file mode 100644 index 0000000..b15901d --- /dev/null +++ b/src/app/api/billing/current/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from "next/server"; +import { getSessionUser } from "@/lib/session"; +import { computeInvoiceDraft } from "@/lib/billing"; +import { listInvoices } from "@/lib/db"; + +/** + * GET /api/billing/current + * + * Running total for the current calendar month — what the + * customer will be billed if no further activity happens. Uses + * the same compute pipeline as the final invoice (LiteLLM spend, + * Threema usage, skill day-counting, proration) so the number + * the customer sees matches what they'll eventually receive + * within the limits of intra-month drift. + * + * If an invoice has ALREADY been issued for the current month + * (e.g. cron ran early, admin manually generated), we return + * that issued invoice instead — no point showing a draft that + * duplicates a real invoice. + * + * Returns: + * { issued: Invoice } // current-month invoice exists + * { draft: InvoiceDraft } // still accruing + * { error: ... } // org missing billing config + * + * Cost: 1 LiteLLM HTTP call + 1 Threema HTTP call + a handful of + * DB queries per skill. Sub-second typically. No caching; called + * on demand from the customer billing page. + */ +export async function GET() { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + // Resolve current calendar month from UTC. Billing is UTC-day + // based throughout (see billing.ts iterDays comment), so the + // running total inherits that same semantics. + const now = new Date(); + const year = now.getUTCFullYear(); + const month = now.getUTCMonth() + 1; // 1-12 + const periodMonth = `${year}-${String(month).padStart(2, "0")}`; + + // 1. Has the current month already been invoiced? + const existing = await listInvoices({ + zitadelOrgId: user.orgId, + periodMonth, + limit: 1, + }); + if (existing.length > 0) { + return NextResponse.json({ issued: existing[0] }); + } + + // 2. Otherwise compute the draft. Falls through to error if the + // org doesn't have a billing config yet (no Address on file). + try { + const { draft } = await computeInvoiceDraft({ + zitadelOrgId: user.orgId, + year, + month, + }); + return NextResponse.json({ draft }); + } catch (e: any) { + // Most likely: org_billing row missing. We surface a 200 with a + // soft error code rather than 500 — the customer-side widget + // displays a helpful "complete your billing details" message + // instead of a stack trace. + return NextResponse.json( + { + error: e?.message ?? "Could not compute running total.", + code: e?.code ?? "COMPUTE_FAILED", + }, + { status: 200 } + ); + } +} diff --git a/src/app/api/billing/invoices/[invoiceNumber]/pdf/route.ts b/src/app/api/billing/invoices/[invoiceNumber]/pdf/route.ts new file mode 100644 index 0000000..5198b75 --- /dev/null +++ b/src/app/api/billing/invoices/[invoiceNumber]/pdf/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { getSessionUser } from "@/lib/session"; +import { getInvoiceByNumberForOrg, getInvoicePdf } from "@/lib/db"; + +/** + * GET /api/billing/invoices/[invoiceNumber]/pdf + * + * Customer-facing PDF download. Same Uint8Array.from() variance + * fix as the admin route — see /api/admin/billing/invoices/[id]/pdf + * for the rationale. + * + * Authorization: looks up the invoice by number with org scope + * baked into the query, then re-fetches the PDF blob by id. A + * customer can't probe another org's invoice numbers — they get + * 404 either way. + */ +export async function GET( + _request: Request, + { params }: { params: Promise<{ invoiceNumber: string }> } +) { + const user = await getSessionUser(); + if (!user) { + return new NextResponse("Unauthorized", { status: 401 }); + } + const { invoiceNumber } = await params; + const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId); + if (!detail) { + return new NextResponse("Not found", { status: 404 }); + } + const pdf = await getInvoicePdf(detail.invoice.id); + if (!pdf) { + return new NextResponse("PDF not available", { status: 404 }); + } + const body = Uint8Array.from(pdf.data); + return new NextResponse(body, { + 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/billing/invoices/[invoiceNumber]/route.ts b/src/app/api/billing/invoices/[invoiceNumber]/route.ts new file mode 100644 index 0000000..fffb65c --- /dev/null +++ b/src/app/api/billing/invoices/[invoiceNumber]/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; +import { getSessionUser } from "@/lib/session"; +import { getInvoiceByNumberForOrg } from "@/lib/db"; + +/** + * GET /api/billing/invoices/[invoiceNumber] + * + * Customer-scoped detail lookup by invoice number (the human- + * readable YYYY-NNNNN format the customer sees on the PDF). The + * org filter is part of the DB query — a customer probing another + * org's invoice number gets the same 404 as a non-existent one. + */ +export async function GET( + _request: Request, + { params }: { params: Promise<{ invoiceNumber: string }> } +) { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { invoiceNumber } = await params; + const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId); + if (!detail) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + return NextResponse.json(detail); +} diff --git a/src/app/api/billing/invoices/route.ts b/src/app/api/billing/invoices/route.ts new file mode 100644 index 0000000..8c3c624 --- /dev/null +++ b/src/app/api/billing/invoices/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { getSessionUser } from "@/lib/session"; +import { listInvoices, syncOverdueInvoices } from "@/lib/db"; + +/** + * GET /api/billing/invoices + * + * Customer-scoped list of invoices for the caller's org. Returns + * a flat array of Invoice headers (no line items — those are + * fetched separately by /[invoiceNumber]). + * + * Status filter is implicit: we return every invoice the + * customer's org has, all statuses (issued/paid/overdue/void) + * because the customer wants a single billing-history view. + * + * Before returning we run syncOverdueInvoices() so the displayed + * status reflects the current date — issued invoices past their + * due_at flip to 'overdue'. Cheap, idempotent, and avoids needing + * a separate cron for this transition. + */ +export async function GET() { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + // Personal accounts have an org too — they share the same shape; + // their invoices show up under that synthetic org id. + try { + await syncOverdueInvoices(); + } catch (e) { + // Non-fatal — display stale status rather than 500. + console.warn("syncOverdueInvoices failed in /api/billing/invoices:", e); + } + const invoices = await listInvoices({ + zitadelOrgId: user.orgId, + limit: 200, + }); + return NextResponse.json(invoices); +} diff --git a/src/components/billing/customer-invoice-detail.tsx b/src/components/billing/customer-invoice-detail.tsx new file mode 100644 index 0000000..56cfff9 --- /dev/null +++ b/src/components/billing/customer-invoice-detail.tsx @@ -0,0 +1,148 @@ +import { useTranslations, useFormatter } from "next-intl"; +import { Card } from "@/components/ui/card"; +import type { Invoice, InvoiceLine } from "@/types"; + +interface Props { + invoice: Invoice; + lines: InvoiceLine[]; +} + +const statusColors: Record = { + issued: "text-text-secondary bg-surface-3", + paid: "text-success bg-success/10", + overdue: "text-error bg-error/10", + void: "text-text-muted bg-surface-3", +}; + +/** + * Read-only invoice detail. Flat list of lines — no per-tenant + * grouping (one invoice per customer; the tenant context is + * already embedded in each line description). + * + * The download link points at /api/billing/invoices/[n]/pdf + * which serves the stored PDF blob inline. Customers using a + * link from their email will hit the same route via this page. + */ +export function CustomerInvoiceDetail({ invoice, lines }: Props) { + const t = useTranslations("customerBilling"); + const fmt = useFormatter(); + + return ( +
+
+
+
+

+ {invoice.invoiceNumber} +

+ + {t(`status.${invoice.status}` as any)} + +
+

+ {fmt.dateTime(new Date(invoice.periodStart), { dateStyle: "long" })} + + {fmt.dateTime(new Date(invoice.periodEnd), { dateStyle: "long" })} +

+
+ + {t("downloadPdf")} + +
+ + +
+
+ {t("billedToLabel")} + {invoice.billingSnapshot.companyName} +
+
+ {t("issuedAtLabel")} + + {fmt.dateTime(new Date(invoice.issuedAt), { dateStyle: "medium" })} + +
+
+ {t("dueAtLabel")} + + {fmt.dateTime(new Date(invoice.dueAt), { dateStyle: "medium" })} + +
+ {invoice.status === "paid" && invoice.paidAt && ( +
+ {t("paidAtLabel")} + + {fmt.dateTime(new Date(invoice.paidAt), { dateStyle: "medium" })} + +
+ )} +
+
+ + + + + + + + + + + + + {lines.map((ln) => ( + + + + + + + ))} + + + + + + + + + + + + + + + +
{t("descriptionCol")}{t("qtyCol")}{t("unitCol")}{t("amountCol")}
{ln.description} + {ln.quantity} + {ln.unitLabel ? ` ${ln.unitLabel}` : ""} + + {ln.unitPriceChf.toFixed(2)} + + {ln.amountChf.toFixed(2)} +
+ {t("subtotalLabel")} + + {invoice.subtotalChf.toFixed(2)} +
+ {t("vatLabel", { rate: invoice.vatRate.toFixed(2) })} + + {invoice.vatAmountChf.toFixed(2)} +
+ {t("totalLabel")} + + CHF {invoice.totalChf.toFixed(2)} +
+
+
+ ); +} diff --git a/src/components/billing/customer-invoice-list.tsx b/src/components/billing/customer-invoice-list.tsx new file mode 100644 index 0000000..50a5d0d --- /dev/null +++ b/src/components/billing/customer-invoice-list.tsx @@ -0,0 +1,92 @@ +import { useTranslations, useFormatter } from "next-intl"; +import { Link } from "@/i18n/navigation"; +import { Card } from "@/components/ui/card"; +import type { Invoice } from "@/types"; + +interface Props { + invoices: Invoice[]; +} + +const statusColors: Record = { + issued: "text-text-secondary bg-surface-3", + paid: "text-success bg-success/10", + overdue: "text-error bg-error/10", + void: "text-text-muted bg-surface-3 line-through", +}; + +/** + * Customer's invoice history table. Server component — gets a + * pre-fetched Invoice[] from /billing/page.tsx. Each row links + * to /billing/ for the full detail view. + * + * Columns: number, period, due date, total, status. Status is + * displayed with a colored badge so the customer can scan for + * outstanding ones at a glance. + */ +export function CustomerInvoiceList({ invoices }: Props) { + const t = useTranslations("customerBilling"); + const fmt = useFormatter(); + + if (invoices.length === 0) { + return ( + +

+ {t("emptyHistory")} +

+
+ ); + } + + return ( + + + + + + + + + + + + + {invoices.map((inv) => ( + + + + + + + + ))} + +
{t("numberCol")}{t("periodCol")}{t("dueCol")}{t("totalCol")}{t("statusCol")}
+ + {inv.invoiceNumber} + + + {fmt.dateTime(new Date(inv.periodStart), { dateStyle: "medium" })} + + {fmt.dateTime(new Date(inv.periodEnd), { dateStyle: "medium" })} + + {fmt.dateTime(new Date(inv.dueAt), { dateStyle: "medium" })} + + CHF {inv.totalChf.toFixed(2)} + + + {t(`status.${inv.status}` as any)} + +
+
+ ); +} diff --git a/src/components/billing/running-total-widget.tsx b/src/components/billing/running-total-widget.tsx new file mode 100644 index 0000000..b897488 --- /dev/null +++ b/src/components/billing/running-total-widget.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations, useFormatter } from "next-intl"; +import { Link } from "@/i18n/navigation"; +import { Card } from "@/components/ui/card"; +import type { Invoice, InvoiceDraft } from "@/types"; + +type CurrentResponse = + | { issued: Invoice } + | { draft: InvoiceDraft } + | { error: string; code?: string }; + +/** + * Live running total for the current calendar month. + * + * Loads /api/billing/current on mount. Three result shapes: + * + * - { issued } — current-month invoice already exists; we + * link to it instead of showing a draft total. + * - { draft } — still accruing; show subtotal+VAT+total and + * a small line breakdown. + * - { error } — most likely the org has no billing config + * yet; show a friendly hint, not a stack trace. + * + * Client-side because the compute can take a second or two + * (LiteLLM + Threema HTTP calls) and we want a loading spinner. + * No polling — the page is static enough that an explicit + * "refresh" link is good enough if the user wants newer numbers. + */ +export function RunningTotalWidget() { + const t = useTranslations("customerBilling"); + const fmt = useFormatter(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshCounter, setRefreshCounter] = useState(0); + + useEffect(() => { + let cancelled = false; + setLoading(true); + fetch("/api/billing/current") + .then(async (res) => { + const j = (await res.json()) as CurrentResponse; + if (!cancelled) setData(j); + }) + .catch((e) => { + if (!cancelled) setData({ error: String(e), code: "FETCH_FAILED" }); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [refreshCounter]); + + if (loading) { + return ( + +

{t("computing")}

+
+ ); + } + if (!data || "error" in data) { + return ( + +

+ {data && "code" in data && data.code === "COMPUTE_FAILED" + ? t("noBillingConfig") + : t("currentPeriodError")} +

+
+ ); + } + if ("issued" in data) { + const inv = data.issued; + return ( + +
+
+

{t("currentInvoiceIssued")}

+ + {inv.invoiceNumber} + +
+
+

{t("totalLabel")}

+

+ CHF {inv.totalChf.toFixed(2)} +

+
+
+
+ ); + } + // draft + const draft = data.draft; + const periodLabel = `${fmt.dateTime(new Date(draft.periodStart), { + dateStyle: "long", + })} → ${fmt.dateTime(new Date(draft.periodEnd), { dateStyle: "long" })}`; + return ( + +
+
+

{t("accruedSoFar")}

+

{periodLabel}

+
+
+

{t("estimatedTotal")}

+

+ CHF {draft.totalChf.toFixed(2)} +

+ +
+
+ {draft.lines.length > 0 && ( +
+ + {t("breakdownToggle", { count: draft.lines.length })} + + + + {draft.lines.map((ln, i) => ( + + + + + ))} + + + + + + + + + +
{ln.description} + {ln.amountChf.toFixed(2)} +
+ {t("subtotalLabel")} + + {draft.subtotalChf.toFixed(2)} +
+ {t("vatLabel", { rate: draft.vatRate.toFixed(2) })} + + {draft.vatAmountChf.toFixed(2)} +
+
+ )} +

{t("draftNote")}

+
+ ); +} diff --git a/src/components/layout/nav-shell.tsx b/src/components/layout/nav-shell.tsx index db615ac..bb822d4 100644 --- a/src/components/layout/nav-shell.tsx +++ b/src/components/layout/nav-shell.tsx @@ -85,6 +85,20 @@ function NavBar() { {t("support")} )} + {/* Phase 3: Billing visible to anyone signed in. The + page is org-scoped server-side — non-owner members + see the same invoice history their owner does, but + actions like "configure billing details" are gated + separately on the settings page. Personal accounts + see their own (single-tenant) invoices. */} + {user && ( + + {t("billing")} + + )} {user?.isPlatform && ( {t("admin")} diff --git a/src/lib/billing.ts b/src/lib/billing.ts index 89670f7..6adc501 100644 --- a/src/lib/billing.ts +++ b/src/lib/billing.ts @@ -61,6 +61,7 @@ import { listTenants } from "./k8s"; import { getTeamSpendLogsV2 } from "./litellm"; import { getUsage as getThreemaUsage } from "./threema-relay"; import { renderInvoicePdf } from "./billing-pdf"; +import { sendInvoiceIssuedEmail } from "./email"; import { formatLineDescription } from "./billing-i18n"; // --------------------------------------------------------------------------- @@ -779,6 +780,50 @@ export async function generateInvoice(opts: { // Pass 2: store the PDF bytes. await updateInvoicePdf(placeholder.id, pdfBuffer, filename); const finalInvoice = await getInvoiceById(placeholder.id); + + // Phase 3: best-effort notification to the billing contact. + // We send AFTER the PDF is fully persisted (so the deep link + // in the email immediately resolves to a downloadable PDF) but + // BEFORE returning, since the cron caller doesn't otherwise + // know to trigger this. Failure is logged, never thrown — a + // mail-server hiccup must not roll back an issued invoice. + // The recipient is the billing email captured in the invoice + // snapshot (immutable; reflects who was on file at issue time). + try { + const settled = finalInvoice ?? placeholder; + const snapshot = settled.billingSnapshot; + if (snapshot.billingEmail) { + const supportedLocales: Array<"en" | "de" | "fr" | "it"> = [ + "en", "de", "fr", "it", + ]; + const locale = supportedLocales.includes(settled.locale as any) + ? (settled.locale as "en" | "de" | "fr" | "it") + : "de"; + await sendInvoiceIssuedEmail({ + to: snapshot.billingEmail, + contactName: snapshot.companyName, // no separate contact-name field + companyName: snapshot.companyName, + invoiceNumber: settled.invoiceNumber, + totalChf: settled.totalChf, + currency: "CHF", + dueAt: settled.dueAt, + lineCount: draft.lines.length, + periodStart: settled.periodStart, + periodEnd: settled.periodEnd, + locale, + }); + } else { + console.warn( + `Invoice ${settled.invoiceNumber} issued but billing snapshot has no email — notification skipped.` + ); + } + } catch (e) { + console.error( + `Invoice ${placeholder.invoiceNumber} issued; notification email failed:`, + e + ); + } + return { draft, invoice: finalInvoice ?? placeholder }; } catch (e) { // Render failed — leave the persisted row in place so admin can diff --git a/src/lib/db.ts b/src/lib/db.ts index 66bf433..40bd2b1 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -2407,6 +2407,38 @@ export async function getInvoiceDetail( return { invoice, lines: lines.rows.map(rowToInvoiceLine) }; } +/** + * Phase 3 — customer-scoped lookup by human-readable invoice + * number with ownership enforcement in a single query. The org + * filter is part of the WHERE clause so a customer can't probe + * another org's invoice numbers (which are sequential and easy + * to guess) and get a different status code (404 vs 403) than + * for their own — both miss-and-not-yours return null. + * + * Used by /api/billing/invoices/[invoiceNumber] and the + * /billing/[invoiceNumber] customer page. + */ +export async function getInvoiceByNumberForOrg( + invoiceNumber: string, + zitadelOrgId: string +): Promise { + await ensureSchema(); + const head = await getPool().query( + `SELECT ${INVOICE_LIST_COLUMNS} FROM invoices + WHERE invoice_number = $1 AND zitadel_org_id = $2 + LIMIT 1`, + [invoiceNumber, zitadelOrgId] + ); + if (head.rows.length === 0) return null; + const invoice = rowToInvoice(head.rows[0]); + const lines = await getPool().query( + `SELECT * FROM invoice_lines WHERE invoice_id = $1 + ORDER BY display_order, id`, + [invoice.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). diff --git a/src/lib/email.ts b/src/lib/email.ts index ff7ad63..a0f895e 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -900,3 +900,117 @@ export async function sendSkillActivationRejectionEmail(params: { console.error("Failed to send skill activation rejection email:", err); } } + +// --------------------------------------------------------------------------- +// Invoice issuance — Phase 3 +// --------------------------------------------------------------------------- + +/** + * Notify the billing contact when a new invoice has been issued. + * Includes a brief summary (total + due date + line count) so the + * recipient can triage without opening the portal, plus a deep + * link to /billing/ where they can download the + * PDF. The PDF itself is NOT attached — it lives in the portal, + * keeps mail payloads small, and avoids the audit-trail headache + * of "which copy is authoritative". + */ +export async function sendInvoiceIssuedEmail(params: { + to: string; + contactName: string; + companyName: string; + invoiceNumber: string; + totalChf: number; + currency: string; // "CHF" — passed for future-proofing + dueAt: string; // ISO date + lineCount: number; + periodStart: string; // ISO date + periodEnd: string; // ISO date + locale: "de" | "en" | "fr" | "it"; +}): Promise { + // All four locales — the email is sent in the invoice's locale, + // which was frozen at issue time. No fallback to admin's locale. + const L = params.locale; + const subjectsByLocale: Record = { + en: `New invoice ${params.invoiceNumber} from PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`, + de: `Neue Rechnung ${params.invoiceNumber} von PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`, + fr: `Nouvelle facture ${params.invoiceNumber} de PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`, + it: `Nuova fattura ${params.invoiceNumber} da PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`, + }; + const greetingsByLocale: Record = { + en: `Hello ${params.contactName},`, + de: `Sehr geehrte/r ${params.contactName},`, + fr: `Bonjour ${params.contactName},`, + it: `Gentile ${params.contactName},`, + }; + const introByLocale: Record = { + en: `A new invoice has been issued for ${params.companyName}.`, + de: `Für ${params.companyName} wurde eine neue Rechnung ausgestellt.`, + fr: `Une nouvelle facture a été émise pour ${params.companyName}.`, + it: `È stata emessa una nuova fattura per ${params.companyName}.`, + }; + const labels: Record> = { + en: { number: "Invoice", period: "Period", total: "Total", due: "Due by", lines: "Line items", cta: "View invoice & download PDF", signoff: "Best regards", brand: "PieCed IT" }, + de: { number: "Rechnung", period: "Zeitraum", total: "Gesamt", due: "Zahlbar bis", lines: "Positionen", cta: "Rechnung ansehen & PDF herunterladen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT" }, + fr: { number: "Facture", period: "Période", total: "Total", due: "À régler avant", lines: "Lignes", cta: "Voir la facture & télécharger le PDF", signoff: "Cordialement", brand: "PieCed IT" }, + it: { number: "Fattura", period: "Periodo", total: "Totale", due: "Scadenza", lines: "Voci", cta: "Visualizza fattura & scarica PDF", signoff: "Cordiali saluti", brand: "PieCed IT" }, + }; + const l = labels[L]; + + const safeName = escapeHtml(params.contactName); + const safeCompany = escapeHtml(params.companyName); + const safeNumber = escapeHtml(params.invoiceNumber); + const totalFmt = `${params.currency} ${params.totalChf.toFixed(2)}`; + const periodFmt = `${params.periodStart.slice(0, 10)} → ${params.periodEnd.slice(0, 10)}`; + const dueFmt = params.dueAt.slice(0, 10); + + // Both bodies built in the invoice's locale. + const link = `https://app.pieced.ch/billing/${encodeURIComponent(params.invoiceNumber)}`; + + try { + await getTransporter().sendMail({ + from: getFrom(), + to: params.to, + subject: subjectsByLocale[L], + text: [ + greetingsByLocale[L], + "", + introByLocale[L], + "", + `${l.number}: ${params.invoiceNumber}`, + `${l.period}: ${periodFmt}`, + `${l.total}: ${totalFmt}`, + `${l.due}: ${dueFmt}`, + `${l.lines}: ${params.lineCount}`, + "", + `${l.cta}:`, + link, + "", + `${l.signoff},`, + l.brand, + ].join("\n"), + html: ` +
+

${escapeHtml(introByLocale[L])}

+

${escapeHtml(greetingsByLocale[L])}

+

${escapeHtml(introByLocale[L])}

+ + + + + + +
${l.number}${safeNumber}
${l.period}${escapeHtml(periodFmt)}
${l.total}${escapeHtml(totalFmt)}
${l.due}${escapeHtml(dueFmt)}
${l.lines}${params.lineCount}
+

+ + ${l.cta} + +

+
+

${l.brand}

+
+ `, + }); + } catch (err) { + console.error("Failed to send invoice issued email:", err); + } +} diff --git a/src/messages/de.json b/src/messages/de.json index 47d9823..4928e93 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -15,7 +15,8 @@ "team": "Team", "settings": "Einstellungen", "optional": "optional", - "support": "Support" + "support": "Support", + "billing": "Abrechnung" }, "login": { "title": "PieCed Portal", @@ -695,5 +696,45 @@ "reasonLabel": "Grund (wird dem Kunden angezeigt)", "reasonPlaceholder": "Erklären Sie, warum die Aktivierung nicht erfolgen kann — z. B. fehlende Kundendaten, Hardware nicht verfügbar usw.", "reasonRequired": "Ein Grund ist für die Ablehnung erforderlich." + }, + "customerBilling": { + "title": "Abrechnung", + "subtitle": "Aktueller Zeitraum und Rechnungshistorie. Ausgestellte Rechnungen stehen als PDF-Download bereit.", + "backToBilling": "Zurück zur Abrechnung", + "currentPeriodHeading": "Aktueller Zeitraum", + "historyHeading": "Rechnungshistorie", + "computing": "Berechne aktuellen Periodenbetrag…", + "currentPeriodError": "Aktueller Periodenbetrag konnte nicht geladen werden. Bitte später erneut versuchen.", + "noBillingConfig": "Abrechnungsdaten sind noch nicht hinterlegt. Sobald die Rechnungsadresse Ihrer Organisation eingetragen ist, erscheint hier der laufende Betrag.", + "accruedSoFar": "Bisher in diesem Monat", + "estimatedTotal": "Geschätzter Gesamtbetrag", + "currentInvoiceIssued": "Aktueller Monat bereits abgerechnet", + "refresh": "aktualisieren", + "breakdownToggle": "Aufschlüsselung anzeigen ({count} Positionen)", + "draftNote": "Live-Schätzung. Die endgültige Rechnung kann durch Monatsendrundung, nachgemeldete Nutzungsdaten oder manuelle Anpassungen leicht abweichen.", + "emptyHistory": "Noch keine Rechnungen ausgestellt. Nach Abschluss Ihres ersten Monats erscheinen sie hier.", + "numberCol": "Nummer", + "periodCol": "Zeitraum", + "dueCol": "Fällig", + "totalCol": "Gesamt", + "statusCol": "Status", + "descriptionCol": "Beschreibung", + "qtyCol": "Menge", + "unitCol": "Einzelpreis", + "amountCol": "Betrag", + "billedToLabel": "Rechnungsempfänger", + "issuedAtLabel": "Ausgestellt", + "dueAtLabel": "Zahlbar bis", + "paidAtLabel": "Bezahlt am", + "subtotalLabel": "Zwischensumme", + "vatLabel": "MWST ({rate}%)", + "totalLabel": "Gesamt", + "downloadPdf": "PDF herunterladen", + "status": { + "issued": "Ausgestellt", + "paid": "Bezahlt", + "overdue": "Überfällig", + "void": "Storniert" + } } } diff --git a/src/messages/en.json b/src/messages/en.json index a53bdce..f40d8b9 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -15,7 +15,8 @@ "team": "Team", "settings": "Settings", "optional": "optional", - "support": "Support" + "support": "Support", + "billing": "Billing" }, "login": { "title": "PieCed Portal", @@ -695,5 +696,45 @@ "reasonLabel": "Reason (shown to the customer)", "reasonPlaceholder": "Explain why this can't be activated — e.g. missing customer data, hardware unavailable, etc.", "reasonRequired": "A reason is required to reject." + }, + "customerBilling": { + "title": "Billing", + "subtitle": "Your current period and invoice history. Issued invoices are available as PDF downloads.", + "backToBilling": "Back to billing", + "currentPeriodHeading": "Current period", + "historyHeading": "Invoice history", + "computing": "Computing current period total…", + "currentPeriodError": "Could not load the current period total. Please try again later.", + "noBillingConfig": "Billing details haven't been configured yet. Once your organization's billing address is on file, this widget will show the running total.", + "accruedSoFar": "Accrued this month", + "estimatedTotal": "Estimated total", + "currentInvoiceIssued": "Current month already invoiced", + "refresh": "refresh", + "breakdownToggle": "Show breakdown ({count} line items)", + "draftNote": "Live estimate. The final invoice may differ slightly due to end-of-month rounding, late-arriving usage data, or manual adjustments.", + "emptyHistory": "No invoices issued yet. Once your first month closes, you'll see it here.", + "numberCol": "Number", + "periodCol": "Period", + "dueCol": "Due", + "totalCol": "Total", + "statusCol": "Status", + "descriptionCol": "Description", + "qtyCol": "Qty", + "unitCol": "Unit", + "amountCol": "Amount", + "billedToLabel": "Billed to", + "issuedAtLabel": "Issued", + "dueAtLabel": "Due by", + "paidAtLabel": "Paid on", + "subtotalLabel": "Subtotal", + "vatLabel": "VAT ({rate}%)", + "totalLabel": "Total", + "downloadPdf": "Download PDF", + "status": { + "issued": "Issued", + "paid": "Paid", + "overdue": "Overdue", + "void": "Void" + } } } diff --git a/src/messages/fr.json b/src/messages/fr.json index fe54547..865c6e1 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -15,7 +15,8 @@ "team": "Équipe", "settings": "Paramètres", "optional": "facultatif", - "support": "Support" + "support": "Support", + "billing": "Facturation" }, "login": { "title": "Portail PieCed", @@ -695,5 +696,45 @@ "reasonLabel": "Motif (visible par le client)", "reasonPlaceholder": "Expliquez pourquoi l'activation ne peut pas se faire — ex. données client manquantes, matériel indisponible, etc.", "reasonRequired": "Un motif est requis pour refuser." + }, + "customerBilling": { + "title": "Facturation", + "subtitle": "Période en cours et historique des factures. Les factures émises sont disponibles en téléchargement PDF.", + "backToBilling": "Retour à la facturation", + "currentPeriodHeading": "Période en cours", + "historyHeading": "Historique des factures", + "computing": "Calcul du total de la période en cours…", + "currentPeriodError": "Impossible de charger le total de la période en cours. Veuillez réessayer plus tard.", + "noBillingConfig": "Les informations de facturation ne sont pas encore configurées. Une fois l'adresse de facturation de votre organisation enregistrée, le total en cours apparaîtra ici.", + "accruedSoFar": "Cumulé ce mois", + "estimatedTotal": "Total estimé", + "currentInvoiceIssued": "Mois en cours déjà facturé", + "refresh": "actualiser", + "breakdownToggle": "Afficher le détail ({count} lignes)", + "draftNote": "Estimation en direct. La facture finale peut légèrement varier en raison d'arrondis de fin de mois, de données d'utilisation tardives ou d'ajustements manuels.", + "emptyHistory": "Aucune facture émise pour le moment. Après la clôture de votre premier mois, elles apparaîtront ici.", + "numberCol": "Numéro", + "periodCol": "Période", + "dueCol": "Échéance", + "totalCol": "Total", + "statusCol": "Statut", + "descriptionCol": "Description", + "qtyCol": "Qté", + "unitCol": "Prix unitaire", + "amountCol": "Montant", + "billedToLabel": "Facturé à", + "issuedAtLabel": "Émise le", + "dueAtLabel": "À régler avant", + "paidAtLabel": "Payée le", + "subtotalLabel": "Sous-total", + "vatLabel": "TVA ({rate}%)", + "totalLabel": "Total", + "downloadPdf": "Télécharger le PDF", + "status": { + "issued": "Émise", + "paid": "Payée", + "overdue": "En retard", + "void": "Annulée" + } } } diff --git a/src/messages/it.json b/src/messages/it.json index a30e1db..de08971 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -15,7 +15,8 @@ "team": "Team", "settings": "Impostazioni", "optional": "facoltativo", - "support": "Supporto" + "support": "Supporto", + "billing": "Fatturazione" }, "login": { "title": "Portale PieCed", @@ -695,5 +696,45 @@ "reasonLabel": "Motivo (mostrato al cliente)", "reasonPlaceholder": "Spiega perché l'attivazione non può procedere — es. dati cliente mancanti, hardware non disponibile, ecc.", "reasonRequired": "Un motivo è necessario per rifiutare." + }, + "customerBilling": { + "title": "Fatturazione", + "subtitle": "Periodo corrente e cronologia delle fatture. Le fatture emesse sono disponibili come download PDF.", + "backToBilling": "Torna alla fatturazione", + "currentPeriodHeading": "Periodo corrente", + "historyHeading": "Cronologia fatture", + "computing": "Calcolo del totale del periodo corrente…", + "currentPeriodError": "Impossibile caricare il totale del periodo corrente. Riprova più tardi.", + "noBillingConfig": "I dati di fatturazione non sono ancora configurati. Una volta registrato l'indirizzo di fatturazione della tua organizzazione, il totale corrente apparirà qui.", + "accruedSoFar": "Accumulato questo mese", + "estimatedTotal": "Totale stimato", + "currentInvoiceIssued": "Mese corrente già fatturato", + "refresh": "aggiorna", + "breakdownToggle": "Mostra dettaglio ({count} voci)", + "draftNote": "Stima in tempo reale. La fattura finale può variare leggermente per arrotondamenti di fine mese, dati di utilizzo in ritardo o aggiustamenti manuali.", + "emptyHistory": "Nessuna fattura emessa ancora. Dopo la chiusura del primo mese, appariranno qui.", + "numberCol": "Numero", + "periodCol": "Periodo", + "dueCol": "Scadenza", + "totalCol": "Totale", + "statusCol": "Stato", + "descriptionCol": "Descrizione", + "qtyCol": "Qtà", + "unitCol": "Prezzo unitario", + "amountCol": "Importo", + "billedToLabel": "Fatturato a", + "issuedAtLabel": "Emessa il", + "dueAtLabel": "Scadenza", + "paidAtLabel": "Pagata il", + "subtotalLabel": "Subtotale", + "vatLabel": "IVA ({rate}%)", + "totalLabel": "Totale", + "downloadPdf": "Scarica PDF", + "status": { + "issued": "Emessa", + "paid": "Pagata", + "overdue": "In ritardo", + "void": "Annullata" + } } }