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")}
|
||||
</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
|
||||
user. Customers see their own tickets only; platform
|
||||
admins see the queue. */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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",
|
||||
"settings": "Einstellungen",
|
||||
"optional": "optional",
|
||||
"support": "Support"
|
||||
"support": "Support",
|
||||
"billing": "Abrechnung"
|
||||
},
|
||||
"login": {
|
||||
"title": "PieCed Portal",
|
||||
@@ -695,5 +696,47 @@
|
||||
"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": {
|
||||
"draft": "Entwurf",
|
||||
"open": "Offen",
|
||||
"paid": "Bezahlt",
|
||||
"overdue": "Überfällig",
|
||||
"void": "Storniert",
|
||||
"uncollectible": "Uneinbringlich"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"team": "Team",
|
||||
"settings": "Settings",
|
||||
"optional": "optional",
|
||||
"support": "Support"
|
||||
"support": "Support",
|
||||
"billing": "Billing"
|
||||
},
|
||||
"login": {
|
||||
"title": "PieCed Portal",
|
||||
@@ -695,5 +696,47 @@
|
||||
"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": {
|
||||
"draft": "Draft",
|
||||
"open": "Open",
|
||||
"paid": "Paid",
|
||||
"overdue": "Overdue",
|
||||
"void": "Void",
|
||||
"uncollectible": "Uncollectible"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"team": "Équipe",
|
||||
"settings": "Paramètres",
|
||||
"optional": "facultatif",
|
||||
"support": "Support"
|
||||
"support": "Support",
|
||||
"billing": "Facturation"
|
||||
},
|
||||
"login": {
|
||||
"title": "Portail PieCed",
|
||||
@@ -695,5 +696,47 @@
|
||||
"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": {
|
||||
"draft": "Brouillon",
|
||||
"open": "Ouverte",
|
||||
"paid": "Payée",
|
||||
"overdue": "En retard",
|
||||
"void": "Annulée",
|
||||
"uncollectible": "Irrécouvrable"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"team": "Team",
|
||||
"settings": "Impostazioni",
|
||||
"optional": "facoltativo",
|
||||
"support": "Supporto"
|
||||
"support": "Supporto",
|
||||
"billing": "Fatturazione"
|
||||
},
|
||||
"login": {
|
||||
"title": "Portale PieCed",
|
||||
@@ -695,5 +696,47 @@
|
||||
"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": {
|
||||
"draft": "Bozza",
|
||||
"open": "Aperta",
|
||||
"paid": "Pagata",
|
||||
"overdue": "In ritardo",
|
||||
"void": "Annullata",
|
||||
"uncollectible": "Inesigibile"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user