Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a5ae0bb8b | |||
| c21b48c704 | |||
| cf190e5ac5 |
35
src/app/[locale]/billing/[invoiceNumber]/page.tsx
Normal file
35
src/app/[locale]/billing/[invoiceNumber]/page.tsx
Normal file
@@ -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 (
|
||||||
|
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||||
|
<BackLink href="/billing" label={t("backToBilling")} />
|
||||||
|
<CustomerInvoiceDetail invoice={detail.invoice} lines={detail.lines} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
src/app/[locale]/billing/page.tsx
Normal file
63
src/app/[locale]/billing/page.tsx
Normal file
@@ -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 (
|
||||||
|
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||||
|
<div className="mb-8 animate-in">
|
||||||
|
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="mb-8 animate-in animate-in-delay-1">
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||||
|
{t("currentPeriodHeading")}
|
||||||
|
</h2>
|
||||||
|
<RunningTotalWidget />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="animate-in animate-in-delay-2">
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||||
|
{t("historyHeading")}
|
||||||
|
</h2>
|
||||||
|
<CustomerInvoiceList invoices={invoices} />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
src/app/api/billing/current/route.ts
Normal file
75
src/app/api/billing/current/route.ts
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/app/api/billing/invoices/[invoiceNumber]/pdf/route.ts
Normal file
43
src/app/api/billing/invoices/[invoiceNumber]/pdf/route.ts
Normal file
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
27
src/app/api/billing/invoices/[invoiceNumber]/route.ts
Normal file
27
src/app/api/billing/invoices/[invoiceNumber]/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
39
src/app/api/billing/invoices/route.ts
Normal file
39
src/app/api/billing/invoices/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
148
src/components/billing/customer-invoice-detail.tsx
Normal file
148
src/components/billing/customer-invoice-detail.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
open: "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 (
|
||||||
|
<div className="space-y-6 animate-in">
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<h1 className="font-display text-2xl font-semibold">
|
||||||
|
{invoice.invoiceNumber}
|
||||||
|
</h1>
|
||||||
|
<span
|
||||||
|
className={`text-[10px] uppercase tracking-wider px-2 py-1 rounded-md font-semibold ${
|
||||||
|
statusColors[invoice.status] ?? "text-text-muted bg-surface-3"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`status.${invoice.status}` as any)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
{fmt.dateTime(new Date(invoice.periodStart), { dateStyle: "long" })}
|
||||||
|
<span className="text-text-muted mx-1">→</span>
|
||||||
|
{fmt.dateTime(new Date(invoice.periodEnd), { dateStyle: "long" })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={`/api/billing/invoices/${encodeURIComponent(invoice.invoiceNumber)}/pdf`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors"
|
||||||
|
>
|
||||||
|
{t("downloadPdf")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-text-muted">{t("billedToLabel")}</span>
|
||||||
|
<span>{invoice.billingSnapshot.companyName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-text-muted">{t("issuedAtLabel")}</span>
|
||||||
|
<span>
|
||||||
|
{fmt.dateTime(new Date(invoice.issuedAt), { dateStyle: "medium" })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-text-muted">{t("dueAtLabel")}</span>
|
||||||
|
<span>
|
||||||
|
{fmt.dateTime(new Date(invoice.dueAt), { dateStyle: "medium" })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{invoice.status === "paid" && invoice.paidAt && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-text-muted">{t("paidAtLabel")}</span>
|
||||||
|
<span>
|
||||||
|
{fmt.dateTime(new Date(invoice.paidAt), { dateStyle: "medium" })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-xs text-text-muted text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="pb-2">{t("descriptionCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("qtyCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("unitCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("amountCol")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{lines.map((ln) => (
|
||||||
|
<tr key={ln.id} className="border-t border-border align-top">
|
||||||
|
<td className="py-2">{ln.description}</td>
|
||||||
|
<td className="py-2 text-right font-mono text-xs">
|
||||||
|
{ln.quantity}
|
||||||
|
{ln.unitLabel ? ` ${ln.unitLabel}` : ""}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right font-mono text-xs">
|
||||||
|
{ln.unitPriceChf.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right font-mono">
|
||||||
|
{ln.amountChf.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t border-border">
|
||||||
|
<td colSpan={3} className="pt-3 text-right text-text-muted">
|
||||||
|
{t("subtotalLabel")}
|
||||||
|
</td>
|
||||||
|
<td className="pt-3 text-right font-mono">
|
||||||
|
{invoice.subtotalChf.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="pt-1 text-right text-text-muted">
|
||||||
|
{t("vatLabel", { rate: invoice.vatRate.toFixed(2) })}
|
||||||
|
</td>
|
||||||
|
<td className="pt-1 text-right font-mono">
|
||||||
|
{invoice.vatAmountChf.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="pt-2 text-right font-semibold">
|
||||||
|
{t("totalLabel")}
|
||||||
|
</td>
|
||||||
|
<td className="pt-2 text-right font-mono font-semibold text-base">
|
||||||
|
CHF {invoice.totalChf.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
src/components/billing/customer-invoice-list.tsx
Normal file
92
src/components/billing/customer-invoice-list.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
open: "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/<invoice-number> 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 (
|
||||||
|
<Card>
|
||||||
|
<p className="text-sm text-text-muted italic text-center py-8">
|
||||||
|
{t("emptyHistory")}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-xs text-text-muted text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="pb-2">{t("numberCol")}</th>
|
||||||
|
<th className="pb-2">{t("periodCol")}</th>
|
||||||
|
<th className="pb-2">{t("dueCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("totalCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("statusCol")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{invoices.map((inv) => (
|
||||||
|
<tr
|
||||||
|
key={inv.id}
|
||||||
|
className="border-t border-border hover:bg-surface-2 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="py-2">
|
||||||
|
<Link
|
||||||
|
href={`/billing/${inv.invoiceNumber}`}
|
||||||
|
className="font-mono text-xs text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{inv.invoiceNumber}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-xs text-text-secondary">
|
||||||
|
{fmt.dateTime(new Date(inv.periodStart), { dateStyle: "medium" })}
|
||||||
|
<span className="text-text-muted mx-1">→</span>
|
||||||
|
{fmt.dateTime(new Date(inv.periodEnd), { dateStyle: "medium" })}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-xs text-text-secondary">
|
||||||
|
{fmt.dateTime(new Date(inv.dueAt), { dateStyle: "medium" })}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right font-mono">
|
||||||
|
CHF {inv.totalChf.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
<span
|
||||||
|
className={`text-[10px] uppercase tracking-wider px-2 py-1 rounded-md font-semibold ${
|
||||||
|
statusColors[inv.status] ?? "text-text-muted bg-surface-3"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`status.${inv.status}` as any)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
162
src/components/billing/running-total-widget.tsx
Normal file
162
src/components/billing/running-total-widget.tsx
Normal file
@@ -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<CurrentResponse | null>(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 (
|
||||||
|
<Card>
|
||||||
|
<p className="text-sm text-text-muted italic py-4">{t("computing")}</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!data || "error" in data) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<p className="text-sm text-text-secondary py-2">
|
||||||
|
{data && "code" in data && data.code === "COMPUTE_FAILED"
|
||||||
|
? t("noBillingConfig")
|
||||||
|
: t("currentPeriodError")}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ("issued" in data) {
|
||||||
|
const inv = data.issued;
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-text-muted">{t("currentInvoiceIssued")}</p>
|
||||||
|
<Link
|
||||||
|
href={`/billing/${inv.invoiceNumber}`}
|
||||||
|
className="font-mono text-sm text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{inv.invoiceNumber}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs text-text-muted">{t("totalLabel")}</p>
|
||||||
|
<p className="font-mono text-lg font-semibold">
|
||||||
|
CHF {inv.totalChf.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// draft
|
||||||
|
const draft = data.draft;
|
||||||
|
const periodLabel = `${fmt.dateTime(new Date(draft.periodStart), {
|
||||||
|
dateStyle: "long",
|
||||||
|
})} → ${fmt.dateTime(new Date(draft.periodEnd), { dateStyle: "long" })}`;
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap mb-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-text-muted">{t("accruedSoFar")}</p>
|
||||||
|
<p className="text-xs text-text-secondary">{periodLabel}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs text-text-muted">{t("estimatedTotal")}</p>
|
||||||
|
<p className="font-mono text-2xl font-semibold text-accent">
|
||||||
|
CHF {draft.totalChf.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setRefreshCounter((n) => n + 1)}
|
||||||
|
className="text-[10px] text-text-muted hover:text-text-secondary underline mt-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
{t("refresh")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{draft.lines.length > 0 && (
|
||||||
|
<details className="text-xs">
|
||||||
|
<summary className="cursor-pointer text-text-muted hover:text-text-secondary">
|
||||||
|
{t("breakdownToggle", { count: draft.lines.length })}
|
||||||
|
</summary>
|
||||||
|
<table className="w-full mt-2 text-xs">
|
||||||
|
<tbody>
|
||||||
|
{draft.lines.map((ln, i) => (
|
||||||
|
<tr key={i} className="border-t border-border">
|
||||||
|
<td className="py-1 pr-2">{ln.description}</td>
|
||||||
|
<td className="py-1 text-right font-mono">
|
||||||
|
{ln.amountChf.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr className="border-t border-border">
|
||||||
|
<td className="py-1 pr-2 text-text-muted text-right">
|
||||||
|
{t("subtotalLabel")}
|
||||||
|
</td>
|
||||||
|
<td className="py-1 text-right font-mono">
|
||||||
|
{draft.subtotalChf.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-1 pr-2 text-text-muted text-right">
|
||||||
|
{t("vatLabel", { rate: draft.vatRate.toFixed(2) })}
|
||||||
|
</td>
|
||||||
|
<td className="py-1 text-right font-mono">
|
||||||
|
{draft.vatAmountChf.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-text-muted mt-3 italic">{t("draftNote")}</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -74,6 +74,20 @@ function NavBar() {
|
|||||||
{t("settings")}
|
{t("settings")}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
)}
|
)}
|
||||||
|
{/* 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 && (
|
||||||
|
<NavLink
|
||||||
|
href="/billing"
|
||||||
|
active={pathname.startsWith("/billing")}
|
||||||
|
>
|
||||||
|
{t("billing")}
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
{/* Feature 5: Support is available to every signed-in
|
{/* Feature 5: Support is available to every signed-in
|
||||||
user. Customers see their own tickets only; platform
|
user. Customers see their own tickets only; platform
|
||||||
admins see the queue. */}
|
admins see the queue. */}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import { listTenants } from "./k8s";
|
|||||||
import { getTeamSpendLogsV2 } from "./litellm";
|
import { getTeamSpendLogsV2 } from "./litellm";
|
||||||
import { getUsage as getThreemaUsage } from "./threema-relay";
|
import { getUsage as getThreemaUsage } from "./threema-relay";
|
||||||
import { renderInvoicePdf } from "./billing-pdf";
|
import { renderInvoicePdf } from "./billing-pdf";
|
||||||
|
import { sendInvoiceIssuedEmail } from "./email";
|
||||||
import { formatLineDescription } from "./billing-i18n";
|
import { formatLineDescription } from "./billing-i18n";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -779,6 +780,50 @@ export async function generateInvoice(opts: {
|
|||||||
// Pass 2: store the PDF bytes.
|
// Pass 2: store the PDF bytes.
|
||||||
await updateInvoicePdf(placeholder.id, pdfBuffer, filename);
|
await updateInvoicePdf(placeholder.id, pdfBuffer, filename);
|
||||||
const finalInvoice = await getInvoiceById(placeholder.id);
|
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 };
|
return { draft, invoice: finalInvoice ?? placeholder };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Render failed — leave the persisted row in place so admin can
|
// Render failed — leave the persisted row in place so admin can
|
||||||
|
|||||||
@@ -2407,6 +2407,38 @@ export async function getInvoiceDetail(
|
|||||||
return { invoice, lines: lines.rows.map(rowToInvoiceLine) };
|
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<InvoiceDetail | null> {
|
||||||
|
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
|
* Fetch the PDF bytes for an invoice. Returns null if no PDF was
|
||||||
* stored (shouldn't happen in v1; defensive against partial state).
|
* stored (shouldn't happen in v1; defensive against partial state).
|
||||||
|
|||||||
114
src/lib/email.ts
114
src/lib/email.ts
@@ -900,3 +900,117 @@ export async function sendSkillActivationRejectionEmail(params: {
|
|||||||
console.error("Failed to send skill activation rejection email:", err);
|
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/<invoice number> 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<void> {
|
||||||
|
// 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<typeof L, string> = {
|
||||||
|
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<typeof L, string> = {
|
||||||
|
en: `Hello ${params.contactName},`,
|
||||||
|
de: `Sehr geehrte/r ${params.contactName},`,
|
||||||
|
fr: `Bonjour ${params.contactName},`,
|
||||||
|
it: `Gentile ${params.contactName},`,
|
||||||
|
};
|
||||||
|
const introByLocale: Record<typeof L, string> = {
|
||||||
|
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<typeof L, Record<string, string>> = {
|
||||||
|
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: `
|
||||||
|
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 560px; padding: 24px; background: #1a1a1a; color: #e5e5e5;">
|
||||||
|
<h2 style="margin: 0 0 16px; color: #10B981;">${escapeHtml(introByLocale[L])}</h2>
|
||||||
|
<p>${escapeHtml(greetingsByLocale[L])}</p>
|
||||||
|
<p>${escapeHtml(introByLocale[L])}</p>
|
||||||
|
<table style="width:100%; border-collapse:collapse; margin:16px 0; font-size:14px;">
|
||||||
|
<tr><td style="color:#888; padding:6px 0; width:120px;">${l.number}</td><td><strong>${safeNumber}</strong></td></tr>
|
||||||
|
<tr><td style="color:#888; padding:6px 0;">${l.period}</td><td>${escapeHtml(periodFmt)}</td></tr>
|
||||||
|
<tr><td style="color:#888; padding:6px 0;">${l.total}</td><td style="color:#10B981; font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
|
||||||
|
<tr><td style="color:#888; padding:6px 0;">${l.due}</td><td>${escapeHtml(dueFmt)}</td></tr>
|
||||||
|
<tr><td style="color:#888; padding:6px 0;">${l.lines}</td><td>${params.lineCount}</td></tr>
|
||||||
|
</table>
|
||||||
|
<p>
|
||||||
|
<a href="${link}" style="display:inline-block; padding:10px 24px; background:#10B981; color:#fff; text-decoration:none; border-radius:8px; font-weight:500;">
|
||||||
|
${l.cta}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<hr style="border:none; border-top:1px solid #333; margin:24px 0;" />
|
||||||
|
<p style="color:#666; font-size:12px;">${l.brand}</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to send invoice issued email:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"team": "Team",
|
"team": "Team",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"optional": "optional",
|
"optional": "optional",
|
||||||
"support": "Support"
|
"support": "Support",
|
||||||
|
"billing": "Abrechnung"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "PieCed Portal",
|
"title": "PieCed Portal",
|
||||||
@@ -695,5 +696,47 @@
|
|||||||
"reasonLabel": "Grund (wird dem Kunden angezeigt)",
|
"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.",
|
"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."
|
"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": {
|
||||||
|
"draft": "Entwurf",
|
||||||
|
"open": "Offen",
|
||||||
|
"paid": "Bezahlt",
|
||||||
|
"overdue": "Überfällig",
|
||||||
|
"void": "Storniert",
|
||||||
|
"uncollectible": "Uneinbringlich"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"team": "Team",
|
"team": "Team",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"optional": "optional",
|
"optional": "optional",
|
||||||
"support": "Support"
|
"support": "Support",
|
||||||
|
"billing": "Billing"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "PieCed Portal",
|
"title": "PieCed Portal",
|
||||||
@@ -695,5 +696,47 @@
|
|||||||
"reasonLabel": "Reason (shown to the customer)",
|
"reasonLabel": "Reason (shown to the customer)",
|
||||||
"reasonPlaceholder": "Explain why this can't be activated — e.g. missing customer data, hardware unavailable, etc.",
|
"reasonPlaceholder": "Explain why this can't be activated — e.g. missing customer data, hardware unavailable, etc.",
|
||||||
"reasonRequired": "A reason is required to reject."
|
"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": {
|
||||||
|
"draft": "Draft",
|
||||||
|
"open": "Open",
|
||||||
|
"paid": "Paid",
|
||||||
|
"overdue": "Overdue",
|
||||||
|
"void": "Void",
|
||||||
|
"uncollectible": "Uncollectible"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"team": "Équipe",
|
"team": "Équipe",
|
||||||
"settings": "Paramètres",
|
"settings": "Paramètres",
|
||||||
"optional": "facultatif",
|
"optional": "facultatif",
|
||||||
"support": "Support"
|
"support": "Support",
|
||||||
|
"billing": "Facturation"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "Portail PieCed",
|
"title": "Portail PieCed",
|
||||||
@@ -695,5 +696,47 @@
|
|||||||
"reasonLabel": "Motif (visible par le client)",
|
"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.",
|
"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."
|
"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": {
|
||||||
|
"draft": "Brouillon",
|
||||||
|
"open": "Ouverte",
|
||||||
|
"paid": "Payée",
|
||||||
|
"overdue": "En retard",
|
||||||
|
"void": "Annulée",
|
||||||
|
"uncollectible": "Irrécouvrable"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"team": "Team",
|
"team": "Team",
|
||||||
"settings": "Impostazioni",
|
"settings": "Impostazioni",
|
||||||
"optional": "facoltativo",
|
"optional": "facoltativo",
|
||||||
"support": "Supporto"
|
"support": "Supporto",
|
||||||
|
"billing": "Fatturazione"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "Portale PieCed",
|
"title": "Portale PieCed",
|
||||||
@@ -695,5 +696,47 @@
|
|||||||
"reasonLabel": "Motivo (mostrato al cliente)",
|
"reasonLabel": "Motivo (mostrato al cliente)",
|
||||||
"reasonPlaceholder": "Spiega perché l'attivazione non può procedere — es. dati cliente mancanti, hardware non disponibile, ecc.",
|
"reasonPlaceholder": "Spiega perché l'attivazione non può procedere — es. dati cliente mancanti, hardware non disponibile, ecc.",
|
||||||
"reasonRequired": "Un motivo è necessario per rifiutare."
|
"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": {
|
||||||
|
"draft": "Bozza",
|
||||||
|
"open": "Aperta",
|
||||||
|
"paid": "Pagata",
|
||||||
|
"overdue": "In ritardo",
|
||||||
|
"void": "Annullata",
|
||||||
|
"uncollectible": "Inesigibile"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user