Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt
Some checks failed
Build and Push / build (push) Failing after 28s
Some checks failed
Build and Push / build (push) Failing after 28s
This commit is contained in:
71
src/app/[locale]/admin/billing/generate/page.tsx
Normal file
71
src/app/[locale]/admin/billing/generate/page.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { getOrgBilling } from "@/lib/db";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { GenerateForm } from "@/components/admin/billing/generate-form";
|
||||
|
||||
/**
|
||||
* /admin/billing/generate — testing tool to compute & commit an
|
||||
* invoice for a given (org, period).
|
||||
*
|
||||
* Workflow:
|
||||
* 1. Admin picks org + year/month + locale (default auto-detected
|
||||
* from country).
|
||||
* 2. "Preview" runs computeInvoiceDraft (dryRun) — shows lines,
|
||||
* totals, warnings.
|
||||
* 3. "Commit" persists + renders the PDF.
|
||||
*
|
||||
* The org dropdown is hydrated server-side here so the page loads
|
||||
* with the list pre-populated. Per-org billing status (address
|
||||
* present / open balance) is fetched on demand from /api/admin/
|
||||
* billing/orgs since it can change as admin edits.
|
||||
*/
|
||||
export default async function AdminBillingGeneratePage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
// Build initial org list from tenant labels.
|
||||
const tenants = await listTenants();
|
||||
const orgMap = new Map<string, string[]>();
|
||||
for (const t of tenants) {
|
||||
const oid = t.metadata.labels?.["pieced.ch/zitadel-org-id"];
|
||||
if (!oid) continue;
|
||||
if (!orgMap.has(oid)) orgMap.set(oid, []);
|
||||
orgMap.get(oid)!.push(t.metadata.name);
|
||||
}
|
||||
// Hydrate company name + country in parallel.
|
||||
const orgList = await Promise.all(
|
||||
[...orgMap.entries()].map(async ([orgId, tenantNames]) => {
|
||||
const billing = await getOrgBilling(orgId).catch(() => null);
|
||||
return {
|
||||
zitadelOrgId: orgId,
|
||||
tenantNames,
|
||||
companyName: billing?.companyName ?? null,
|
||||
country: billing?.country ?? null,
|
||||
hasBillingAddress: !!billing,
|
||||
};
|
||||
})
|
||||
);
|
||||
orgList.sort((a, b) =>
|
||||
(a.companyName ?? a.zitadelOrgId).localeCompare(
|
||||
b.companyName ?? b.zitadelOrgId
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="max-w-4xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin/billing" label={t("backToBilling")} />
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("generateTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("generatePageDesc")}</p>
|
||||
</div>
|
||||
<GenerateForm orgs={orgList} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
35
src/app/[locale]/admin/billing/invoices/[id]/page.tsx
Normal file
35
src/app/[locale]/admin/billing/invoices/[id]/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getInvoiceDetail } from "@/lib/db";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { InvoiceDetailView } from "@/components/admin/billing/invoice-detail-view";
|
||||
|
||||
/**
|
||||
* /admin/billing/invoices/[id] — full detail of one invoice.
|
||||
*
|
||||
* Server-renders the static body (header, lines, totals, billing
|
||||
* snapshot); the action bar (mark-paid, delete, PDF download) is
|
||||
* a client component for the interactive bits.
|
||||
*/
|
||||
export default async function AdminInvoiceDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
const { id } = await params;
|
||||
const detail = await getInvoiceDetail(id);
|
||||
if (!detail) notFound();
|
||||
|
||||
return (
|
||||
<main className="max-w-4xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin/billing/invoices" label={t("backToInvoices")} />
|
||||
<InvoiceDetailView detail={detail} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
39
src/app/[locale]/admin/billing/invoices/page.tsx
Normal file
39
src/app/[locale]/admin/billing/invoices/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listInvoices, syncOverdueInvoices } from "@/lib/db";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { InvoicesTable } from "@/components/admin/billing/invoices-table";
|
||||
|
||||
/**
|
||||
* /admin/billing/invoices — list of all issued invoices, filterable
|
||||
* by status and month. Click a row to drill into detail.
|
||||
*
|
||||
* Server-renders the initial table with no filters applied (showing
|
||||
* the most recent 200). Client filters trigger a fetch with query
|
||||
* params and re-render in place.
|
||||
*/
|
||||
export default async function AdminInvoicesListPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
await syncOverdueInvoices().catch((e) =>
|
||||
console.error("syncOverdueInvoices failed:", e)
|
||||
);
|
||||
const invoices = await listInvoices({ limit: 200 });
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin/billing" label={t("backToBilling")} />
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("invoicesTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("invoicesPageDesc")}</p>
|
||||
</div>
|
||||
<InvoicesTable initialInvoices={invoices} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
128
src/app/[locale]/admin/billing/page.tsx
Normal file
128
src/app/[locale]/admin/billing/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getOrgOpenBalances, syncOverdueInvoices } from "@/lib/db";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
/**
|
||||
* /admin/billing — landing page with sub-section links and a
|
||||
* quick overview of orgs in arrears.
|
||||
*
|
||||
* Sub-pages:
|
||||
* - /admin/billing/pricing — platform + skill prices
|
||||
* - /admin/billing/generate — manual invoice generator (testing)
|
||||
* - /admin/billing/invoices — invoice list/detail
|
||||
*
|
||||
* The Phase 2 customer-side /billing landing page is added in
|
||||
* Phase 3.
|
||||
*/
|
||||
export default async function AdminBillingPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
// Sweep open invoices past due → 'overdue' so the counters below
|
||||
// reflect reality without needing a cron.
|
||||
await syncOverdueInvoices().catch((e) =>
|
||||
console.error("syncOverdueInvoices failed:", e)
|
||||
);
|
||||
const balances = await getOrgOpenBalances().catch(() => []);
|
||||
const totalOpen = balances.reduce((acc, b) => acc + b.totalOpenChf, 0);
|
||||
const totalOverdue = balances.reduce((acc, b) => acc + b.overdueCount, 0);
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
{/* Stats strip */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-8 animate-in animate-in-delay-1">
|
||||
<Card>
|
||||
<div className="text-xs text-text-muted">{t("totalOpenBalance")}</div>
|
||||
<div className="text-2xl font-semibold mt-1">
|
||||
CHF {totalOpen.toFixed(2)}
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-xs text-text-muted">{t("orgsWithBalance")}</div>
|
||||
<div className="text-2xl font-semibold mt-1">{balances.length}</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-xs text-text-muted">{t("overdueInvoices")}</div>
|
||||
<div className="text-2xl font-semibold mt-1">
|
||||
{totalOverdue > 0 ? (
|
||||
<span className="text-error">{totalOverdue}</span>
|
||||
) : (
|
||||
totalOverdue
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sub-tool cards */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-8 animate-in animate-in-delay-2">
|
||||
<Link href="/admin/billing/pricing">
|
||||
<Card interactive>
|
||||
<div className="font-semibold mb-1">{t("pricingTitle")}</div>
|
||||
<div className="text-sm text-text-muted">{t("pricingDesc")}</div>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href="/admin/billing/generate">
|
||||
<Card interactive>
|
||||
<div className="font-semibold mb-1">{t("generateTitle")}</div>
|
||||
<div className="text-sm text-text-muted">{t("generateDesc")}</div>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href="/admin/billing/invoices">
|
||||
<Card interactive>
|
||||
<div className="font-semibold mb-1">{t("invoicesTitle")}</div>
|
||||
<div className="text-sm text-text-muted">{t("invoicesDesc")}</div>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Orgs with open balance */}
|
||||
{balances.length > 0 && (
|
||||
<div className="animate-in animate-in-delay-3">
|
||||
<h2 className="text-lg font-semibold mb-3">{t("balancesTitle")}</h2>
|
||||
<Card>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
<th className="pb-2">{t("orgIdCol")}</th>
|
||||
<th className="pb-2 text-right">{t("openCountCol")}</th>
|
||||
<th className="pb-2 text-right">{t("overdueCountCol")}</th>
|
||||
<th className="pb-2 text-right">{t("totalOpenCol")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{balances.map((b) => (
|
||||
<tr key={b.zitadelOrgId} className="border-t border-border">
|
||||
<td className="py-2 font-mono text-xs">{b.zitadelOrgId}</td>
|
||||
<td className="py-2 text-right">{b.openCount}</td>
|
||||
<td className="py-2 text-right">
|
||||
{b.overdueCount > 0 ? (
|
||||
<span className="text-error">{b.overdueCount}</span>
|
||||
) : (
|
||||
<span className="text-text-muted">0</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
CHF {b.totalOpenChf.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
55
src/app/[locale]/admin/billing/pricing/page.tsx
Normal file
55
src/app/[locale]/admin/billing/pricing/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getPlatformPricing, listSkillPricing } from "@/lib/db";
|
||||
import { PACKAGE_CATALOG } from "@/lib/packages";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { PricingEditor } from "@/components/admin/billing/pricing-editor";
|
||||
|
||||
/**
|
||||
* /admin/billing/pricing — edit platform-wide pricing config
|
||||
* (monthly fee, setup fee, Threema per-message, VAT rate for
|
||||
* CH/LI) and per-skill daily prices.
|
||||
*
|
||||
* Single-row platform_pricing semantics: one global pricing
|
||||
* config applies to every tenant. No per-tenant overrides in
|
||||
* v1.
|
||||
*/
|
||||
export default async function AdminBillingPricingPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
const [pricing, skillPricing] = await Promise.all([
|
||||
getPlatformPricing(),
|
||||
listSkillPricing(),
|
||||
]);
|
||||
|
||||
// Surface every package in the catalog so admin can price any of
|
||||
// them — UI defaults the picker to skill-kind entries but doesn't
|
||||
// hard-block other kinds (a future scenario where a non-skill
|
||||
// package gets a per-day price shouldn't need a code change).
|
||||
const catalog = Object.values(PACKAGE_CATALOG).map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
kind: p.kind,
|
||||
}));
|
||||
|
||||
return (
|
||||
<main className="max-w-4xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin/billing" label={t("backToBilling")} />
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("pricingTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("pricingPageDesc")}</p>
|
||||
</div>
|
||||
<PricingEditor
|
||||
initialPricing={pricing}
|
||||
initialSkillPricing={skillPricing}
|
||||
catalog={catalog}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -32,12 +32,20 @@ export default async function AdminPage() {
|
||||
{/* Sub-tools: links to other admin pages. Plain links rather
|
||||
than nav-shell entries — these are platform-team utilities,
|
||||
not main navigation. */}
|
||||
<a
|
||||
href="/admin/openclaw"
|
||||
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
|
||||
>
|
||||
{t("openclawTool")}
|
||||
</a>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href="/admin/billing"
|
||||
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
|
||||
>
|
||||
{t("billingTool")}
|
||||
</a>
|
||||
<a
|
||||
href="/admin/openclaw"
|
||||
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
|
||||
>
|
||||
{t("openclawTool")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="animate-in animate-in-delay-1">
|
||||
|
||||
66
src/app/api/admin/billing/generate/route.ts
Normal file
66
src/app/api/admin/billing/generate/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { generateInvoice } from "@/lib/billing";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/admin/billing/generate
|
||||
*
|
||||
* Compute (and optionally commit) an invoice for an (org, year,
|
||||
* month). Platform-only — this is the testing/admin tool.
|
||||
*
|
||||
* Body:
|
||||
* {
|
||||
* zitadelOrgId: string,
|
||||
* year: number (e.g. 2026),
|
||||
* month: number (1-12),
|
||||
* locale?: 'de' | 'en' | 'fr' | 'it', // default: from country
|
||||
* dryRun?: boolean // default: false
|
||||
* }
|
||||
*
|
||||
* Response on success:
|
||||
* {
|
||||
* draft: InvoiceDraft, // line breakdown + warnings
|
||||
* invoice: Invoice | null, // null when dryRun=true
|
||||
* }
|
||||
*
|
||||
* If an invoice for that (org, period) already exists, returns
|
||||
* 409 with a clear message. Use the delete endpoint first to
|
||||
* regenerate.
|
||||
*/
|
||||
|
||||
const bodySchema = z.object({
|
||||
zitadelOrgId: z.string().min(1),
|
||||
year: z.number().int().min(2020).max(2100),
|
||||
month: z.number().int().min(1).max(12),
|
||||
locale: z.enum(["de", "en", "fr", "it"]).optional(),
|
||||
dryRun: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const result = await generateInvoice(parsed.data);
|
||||
return NextResponse.json(result);
|
||||
} catch (e: any) {
|
||||
console.error("Invoice generation failed:", e);
|
||||
const msg = safeError(e, "Generation failed");
|
||||
// Specific 409 for the "already exists" case so the UI can
|
||||
// show a "delete first" link.
|
||||
const status = /already exists/i.test(msg) ? 409 : 500;
|
||||
return NextResponse.json({ error: msg }, { status });
|
||||
}
|
||||
}
|
||||
81
src/app/api/admin/billing/invoices/[id]/mark-paid/route.ts
Normal file
81
src/app/api/admin/billing/invoices/[id]/mark-paid/route.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requirePlatformRole, getSessionUser } from "@/lib/session";
|
||||
import { markInvoicePaid } from "@/lib/db";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/admin/billing/invoices/[id]/mark-paid
|
||||
*
|
||||
* Manually mark an open/overdue invoice as paid. Used for the
|
||||
* "pay by invoice" flow where the customer transfers money to
|
||||
* the bank account printed on the PDF and the admin reconciles
|
||||
* by hand.
|
||||
*
|
||||
* Body (all optional):
|
||||
* {
|
||||
* paidAt?: ISO timestamp, // defaults to now
|
||||
* note?: string // free-form, stored in paid_method_detail
|
||||
* }
|
||||
*
|
||||
* paid_by is set to the admin user's id automatically.
|
||||
* Idempotent: trying to mark an already-paid invoice returns 409.
|
||||
*
|
||||
* Phase 4 will introduce a parallel auto-paid path triggered by
|
||||
* Stripe webhooks; for Phase 2 this is the only way to flip the
|
||||
* status.
|
||||
*/
|
||||
|
||||
const bodySchema = z.object({
|
||||
paidAt: z.string().datetime().optional(),
|
||||
note: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
let user;
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
user = await getSessionUser();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const detail = parsed.data.note
|
||||
? `${user.id}: ${parsed.data.note}`
|
||||
: user.id;
|
||||
const invoice = await markInvoicePaid(id, {
|
||||
paidBy: "manual",
|
||||
paidMethodDetail: detail,
|
||||
paidAt: parsed.data.paidAt ? new Date(parsed.data.paidAt) : undefined,
|
||||
});
|
||||
if (!invoice) {
|
||||
// Either not found or status not in {open, overdue}.
|
||||
return NextResponse.json(
|
||||
{ error: "Invoice not found, or already paid/void." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(invoice);
|
||||
} catch (e) {
|
||||
console.error("Failed to mark invoice paid:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Mark-paid failed") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
39
src/app/api/admin/billing/invoices/[id]/pdf/route.ts
Normal file
39
src/app/api/admin/billing/invoices/[id]/pdf/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { getInvoicePdf } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/admin/billing/invoices/[id]/pdf
|
||||
*
|
||||
* Streams the stored PDF bytes for an invoice. The bytea column is
|
||||
* read once and returned as an octet stream; no on-the-fly
|
||||
* re-rendering — PDFs are immutable once issued.
|
||||
*
|
||||
* Phase 3 will add a parallel customer-facing route at
|
||||
* /api/billing/invoices/[id]/pdf with org-scoped authorization.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return new NextResponse("Forbidden", { status: 403 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const pdf = await getInvoicePdf(id);
|
||||
if (!pdf) {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
// Construct a response that the browser will render inline (PDF
|
||||
// viewer) but also offer to download with the right filename.
|
||||
return new NextResponse(pdf.data, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `inline; filename="${pdf.filename}"`,
|
||||
"Cache-Control": "private, max-age=0, must-revalidate",
|
||||
},
|
||||
});
|
||||
}
|
||||
55
src/app/api/admin/billing/invoices/[id]/route.ts
Normal file
55
src/app/api/admin/billing/invoices/[id]/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { deleteInvoice, getInvoiceDetail } from "@/lib/db";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* GET /api/admin/billing/invoices/[id]
|
||||
* Detail view: invoice + lines.
|
||||
*
|
||||
* DELETE /api/admin/billing/invoices/[id]
|
||||
* Hard delete (testing tool). Invoice number is consumed — gaps
|
||||
* in the sequence are intentional and documented. Reminders
|
||||
* (and their PDFs) cascade-delete via the FK.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const detail = await getInvoiceDetail(id);
|
||||
if (!detail) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json(detail);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const { id } = await params;
|
||||
try {
|
||||
const ok = await deleteInvoice(id);
|
||||
if (!ok) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ message: "Deleted." });
|
||||
} catch (e) {
|
||||
console.error("Failed to delete invoice:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Delete failed") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
44
src/app/api/admin/billing/invoices/route.ts
Normal file
44
src/app/api/admin/billing/invoices/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { listInvoices, syncOverdueInvoices } from "@/lib/db";
|
||||
import type { InvoiceStatus } from "@/types";
|
||||
|
||||
/**
|
||||
* GET /api/admin/billing/invoices
|
||||
*
|
||||
* List invoices for admin. Optional filters:
|
||||
* ?status=open|paid|overdue|void|uncollectible
|
||||
* ?orgId=...
|
||||
* ?month=YYYY-MM
|
||||
* ?limit=200
|
||||
*
|
||||
* Refreshes overdue status on each call (cheap UPDATE), so the
|
||||
* admin list always reflects the latest due-date math without
|
||||
* needing a cron.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
await syncOverdueInvoices().catch((e) =>
|
||||
console.error("syncOverdueInvoices failed:", e)
|
||||
);
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const status = searchParams.get("status") as InvoiceStatus | null;
|
||||
const orgId = searchParams.get("orgId");
|
||||
const month = searchParams.get("month");
|
||||
const limitParam = searchParams.get("limit");
|
||||
const limit = limitParam ? Math.max(1, Math.min(1000, parseInt(limitParam, 10))) : 200;
|
||||
|
||||
const invoices = await listInvoices({
|
||||
status: status ?? undefined,
|
||||
zitadelOrgId: orgId ?? undefined,
|
||||
periodMonth: month ?? undefined,
|
||||
limit,
|
||||
});
|
||||
return NextResponse.json(invoices);
|
||||
}
|
||||
80
src/app/api/admin/billing/orgs/route.ts
Normal file
80
src/app/api/admin/billing/orgs/route.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { getOrgBilling, getOrgOpenBalances } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/admin/billing/orgs
|
||||
*
|
||||
* Returns the orgs known to the platform via tenant labels, with
|
||||
* their billing-address-on-file status and open balance summary.
|
||||
* Powers the generate form's org dropdown and the billing landing
|
||||
* page's open-balance table.
|
||||
*
|
||||
* Each entry:
|
||||
* {
|
||||
* zitadelOrgId: string,
|
||||
* tenantCount: number,
|
||||
* hasBillingAddress: boolean,
|
||||
* companyName: string | null,
|
||||
* openCount: number,
|
||||
* overdueCount: number,
|
||||
* totalOpenChf: number
|
||||
* }
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Org membership is derived from tenant labels — there's no
|
||||
// separate "orgs" table on the portal. listTenants reads from
|
||||
// K8s, which is the source of truth.
|
||||
const tenants = await listTenants();
|
||||
const orgIdToTenants = new Map<string, string[]>();
|
||||
for (const t of tenants) {
|
||||
const oid = t.metadata.labels?.["pieced.ch/zitadel-org-id"];
|
||||
if (!oid) continue;
|
||||
if (!orgIdToTenants.has(oid)) orgIdToTenants.set(oid, []);
|
||||
orgIdToTenants.get(oid)!.push(t.metadata.name);
|
||||
}
|
||||
|
||||
const balances = await getOrgOpenBalances();
|
||||
const balanceMap = new Map(balances.map((b) => [b.zitadelOrgId, b]));
|
||||
|
||||
// Hydrate billing-address presence + company name per org.
|
||||
const results = await Promise.all(
|
||||
[...orgIdToTenants.entries()].map(async ([orgId, tenantNames]) => {
|
||||
const billing = await getOrgBilling(orgId).catch(() => null);
|
||||
const bal = balanceMap.get(orgId);
|
||||
return {
|
||||
zitadelOrgId: orgId,
|
||||
tenantCount: tenantNames.length,
|
||||
tenantNames,
|
||||
hasBillingAddress: !!billing,
|
||||
companyName: billing?.companyName ?? null,
|
||||
country: billing?.country ?? null,
|
||||
openCount: bal?.openCount ?? 0,
|
||||
overdueCount: bal?.overdueCount ?? 0,
|
||||
totalOpenChf: bal?.totalOpenChf ?? 0,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Sort: orgs with overdue first, then open, then by name.
|
||||
results.sort((a, b) => {
|
||||
if (a.overdueCount !== b.overdueCount) {
|
||||
return b.overdueCount - a.overdueCount;
|
||||
}
|
||||
if (a.openCount !== b.openCount) {
|
||||
return b.openCount - a.openCount;
|
||||
}
|
||||
return (a.companyName ?? a.zitadelOrgId).localeCompare(
|
||||
b.companyName ?? b.zitadelOrgId
|
||||
);
|
||||
});
|
||||
|
||||
return NextResponse.json(results);
|
||||
}
|
||||
59
src/app/api/admin/billing/pricing/route.ts
Normal file
59
src/app/api/admin/billing/pricing/route.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { getPlatformPricing, updatePlatformPricing } from "@/lib/db";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* GET /api/admin/billing/pricing
|
||||
* Returns the single-row platform pricing config.
|
||||
*
|
||||
* PUT /api/admin/billing/pricing
|
||||
* Updates one or more pricing fields. Missing fields are left
|
||||
* unchanged.
|
||||
*
|
||||
* Both endpoints are platform-role only.
|
||||
*/
|
||||
|
||||
const updateSchema = z.object({
|
||||
tenantMonthlyFeeChf: z.number().min(0).max(99_999_999).optional(),
|
||||
tenantSetupFeeChf: z.number().min(0).max(99_999_999).optional(),
|
||||
threemaMessageChf: z.number().min(0).max(1000).optional(),
|
||||
vatRateChli: z.number().min(0).max(100).optional(),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const pricing = await getPlatformPricing();
|
||||
return NextResponse.json(pricing);
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = updateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid pricing payload", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const updated = await updatePlatformPricing(parsed.data);
|
||||
return NextResponse.json(updated);
|
||||
} catch (e) {
|
||||
console.error("Failed to update platform pricing:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Update failed") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
33
src/app/api/admin/billing/skill-pricing/[skill]/route.ts
Normal file
33
src/app/api/admin/billing/skill-pricing/[skill]/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { removeSkillPricing } from "@/lib/db";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/billing/skill-pricing/[skill]
|
||||
* Remove pricing for a skill. Toggle events continue to be
|
||||
* recorded; the skill simply becomes free starting from the next
|
||||
* generated invoice. Historical invoices already issued are
|
||||
* unaffected (they carry frozen line amounts).
|
||||
*/
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ skill: string }> }
|
||||
) {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const { skill } = await params;
|
||||
try {
|
||||
await removeSkillPricing(skill);
|
||||
return NextResponse.json({ message: "Removed." });
|
||||
} catch (e) {
|
||||
console.error("Failed to remove skill pricing:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Remove failed") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
76
src/app/api/admin/billing/skill-pricing/route.ts
Normal file
76
src/app/api/admin/billing/skill-pricing/route.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { listSkillPricing, setSkillPricing } from "@/lib/db";
|
||||
import { getPackageDef } from "@/lib/packages";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* GET /api/admin/billing/skill-pricing
|
||||
* List all configured skill prices.
|
||||
*
|
||||
* PUT /api/admin/billing/skill-pricing
|
||||
* Upsert a daily price for a single skill. Body:
|
||||
* { skillId: string, dailyPriceChf: number }
|
||||
*
|
||||
* Both endpoints are platform-only.
|
||||
*
|
||||
* Note on skillId validation: we accept any package id that exists
|
||||
* in PACKAGE_CATALOG. The PIN to "skills only" is enforced at the
|
||||
* UI layer, not here, so admins can price a non-skill package in
|
||||
* an emergency without code changes.
|
||||
*/
|
||||
|
||||
const upsertSchema = z.object({
|
||||
skillId: z.string().min(1).max(100),
|
||||
dailyPriceChf: z.number().min(0).max(1_000_000),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const rows = await listSkillPricing();
|
||||
return NextResponse.json(rows);
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = upsertSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid payload", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
// Validate the skill id exists in PACKAGE_CATALOG. Returns null
|
||||
// for unknown ids; we reject those rather than persist a row that
|
||||
// would never match a real toggle event.
|
||||
const pkg = getPackageDef(parsed.data.skillId);
|
||||
if (!pkg) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown package id: ${parsed.data.skillId}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const row = await setSkillPricing(
|
||||
parsed.data.skillId,
|
||||
parsed.data.dailyPriceChf
|
||||
);
|
||||
return NextResponse.json(row);
|
||||
} catch (e) {
|
||||
console.error("Failed to upsert skill pricing:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Upsert failed") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
345
src/components/admin/billing/generate-form.tsx
Normal file
345
src/components/admin/billing/generate-form.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
"use client";
|
||||
|
||||
import { useState, Fragment } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardHeader } from "@/components/ui/card";
|
||||
import type { InvoiceDraft } from "@/types";
|
||||
|
||||
interface OrgEntry {
|
||||
zitadelOrgId: string;
|
||||
tenantNames: string[];
|
||||
companyName: string | null;
|
||||
country: string | null;
|
||||
hasBillingAddress: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
orgs: OrgEntry[];
|
||||
}
|
||||
|
||||
const LOCALE_OPTIONS = [
|
||||
{ value: "de", label: "Deutsch" },
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "fr", label: "Français" },
|
||||
{ value: "it", label: "Italiano" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Two-step flow: preview (dryRun) → commit.
|
||||
*
|
||||
* Preview displays the InvoiceDraft (lines, subtotal, VAT, total)
|
||||
* plus any warnings. Admin reviews and either commits or aborts.
|
||||
* Commit re-runs the generator without dryRun and redirects to the
|
||||
* persisted invoice's detail page.
|
||||
*/
|
||||
export function GenerateForm({ orgs }: Props) {
|
||||
const t = useTranslations("adminBilling");
|
||||
const router = useRouter();
|
||||
|
||||
// Default to previous calendar month — that's the typical "bill
|
||||
// for last month" use case.
|
||||
const now = new Date();
|
||||
const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
const [orgId, setOrgId] = useState(orgs[0]?.zitadelOrgId ?? "");
|
||||
const [year, setYear] = useState(String(prevMonth.getFullYear()));
|
||||
const [month, setMonth] = useState(String(prevMonth.getMonth() + 1));
|
||||
const [locale, setLocale] = useState<string>("");
|
||||
const [draft, setDraft] = useState<InvoiceDraft | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const selectedOrg = orgs.find((o) => o.zitadelOrgId === orgId);
|
||||
// Auto-detect default locale from country if admin hasn't picked
|
||||
// one. Same logic as billing.ts's defaultLocaleForCountry.
|
||||
const effectiveLocale =
|
||||
locale ||
|
||||
(() => {
|
||||
const c = (selectedOrg?.country || "").toUpperCase();
|
||||
if (["CH", "LI", "AT", "DE"].includes(c)) return "de";
|
||||
if (["FR", "BE", "LU"].includes(c)) return "fr";
|
||||
if (c === "IT") return "it";
|
||||
return "en";
|
||||
})();
|
||||
|
||||
const preview = async () => {
|
||||
setError("");
|
||||
setDraft(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch("/api/admin/billing/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
zitadelOrgId: orgId,
|
||||
year: Number(year),
|
||||
month: Number(month),
|
||||
locale: effectiveLocale,
|
||||
dryRun: true,
|
||||
}),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||
setDraft(j.draft);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const commit = async () => {
|
||||
if (!draft) return;
|
||||
if (!confirm(t("confirmGenerate"))) return;
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch("/api/admin/billing/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
zitadelOrgId: orgId,
|
||||
year: Number(year),
|
||||
month: Number(month),
|
||||
locale: effectiveLocale,
|
||||
dryRun: false,
|
||||
}),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||
// Navigate to the new invoice's detail page.
|
||||
if (j.invoice?.id) {
|
||||
router.push(`/admin/billing/invoices/${j.invoice.id}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>{t("generateFormTitle")}</CardHeader>
|
||||
{orgs.length === 0 ? (
|
||||
<p className="text-sm text-text-muted italic">{t("noOrgsToGenerate")}</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<label className="block">
|
||||
<span className="text-sm text-text-secondary">{t("orgLabel")}</span>
|
||||
<select
|
||||
value={orgId}
|
||||
onChange={(e) => {
|
||||
setOrgId(e.target.value);
|
||||
setDraft(null);
|
||||
}}
|
||||
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||
>
|
||||
{orgs.map((o) => (
|
||||
<option key={o.zitadelOrgId} value={o.zitadelOrgId}>
|
||||
{o.companyName ?? o.zitadelOrgId}
|
||||
{!o.hasBillingAddress ? ` — ${t("noBillingAddrTag")}` : ""}
|
||||
{` (${o.tenantNames.length} ${t("tenantsLabel")})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedOrg && !selectedOrg.hasBillingAddress && (
|
||||
<p className="text-xs text-error mt-1">
|
||||
{t("noBillingAddrWarning")}
|
||||
</p>
|
||||
)}
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="block">
|
||||
<span className="text-sm text-text-secondary">{t("yearLabel")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min="2020"
|
||||
max="2100"
|
||||
value={year}
|
||||
onChange={(e) => {
|
||||
setYear(e.target.value);
|
||||
setDraft(null);
|
||||
}}
|
||||
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm text-text-secondary">{t("monthLabel")}</span>
|
||||
<select
|
||||
value={month}
|
||||
onChange={(e) => {
|
||||
setMonth(e.target.value);
|
||||
setDraft(null);
|
||||
}}
|
||||
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||
>
|
||||
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{String(m).padStart(2, "0")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm text-text-secondary">
|
||||
{t("localeLabel")}
|
||||
</span>
|
||||
<select
|
||||
value={locale}
|
||||
onChange={(e) => {
|
||||
setLocale(e.target.value);
|
||||
setDraft(null);
|
||||
}}
|
||||
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||
>
|
||||
<option value="">
|
||||
{t("localeAuto")} ({effectiveLocale})
|
||||
</option>
|
||||
{LOCALE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
onClick={preview}
|
||||
disabled={busy || !selectedOrg?.hasBillingAddress}
|
||||
className="px-4 py-2 rounded-md border border-border text-sm disabled:opacity-50"
|
||||
>
|
||||
{busy && !draft ? t("computing") : t("previewBtn")}
|
||||
</button>
|
||||
{draft && (
|
||||
<button
|
||||
onClick={commit}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("saving") : t("commitBtn")}
|
||||
</button>
|
||||
)}
|
||||
{error && (
|
||||
<span className="text-sm text-error">{error}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{draft && <DraftPreview draft={draft} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DraftPreview({ draft }: { draft: InvoiceDraft }) {
|
||||
const t = useTranslations("adminBilling");
|
||||
|
||||
// Group lines by tenant for the preview (matches PDF layout).
|
||||
const linesByTenant = new Map<string | null, typeof draft.lines>();
|
||||
for (const ln of draft.lines) {
|
||||
const key = ln.tenantName;
|
||||
if (!linesByTenant.has(key)) linesByTenant.set(key, []);
|
||||
linesByTenant.get(key)!.push(ln);
|
||||
}
|
||||
const tenantOrder = [...linesByTenant.keys()].sort((a, b) => {
|
||||
if (a === null) return 1;
|
||||
if (b === null) return -1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
{t("previewTitle")} — {draft.periodStart} → {draft.periodEnd}
|
||||
</CardHeader>
|
||||
|
||||
{draft.warnings.length > 0 && (
|
||||
<div className="mb-4 p-3 rounded-md border border-warning bg-warning/10 text-sm space-y-1">
|
||||
<div className="font-semibold text-warning">{t("warningsTitle")}</div>
|
||||
{draft.warnings.map((w, i) => (
|
||||
<div key={i} className="text-text-secondary">• {w}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
<th className="pb-2">{t("descCol")}</th>
|
||||
<th className="pb-2 text-right">{t("qtyCol")}</th>
|
||||
<th className="pb-2 text-right">{t("unitPriceCol")}</th>
|
||||
<th className="pb-2 text-right">{t("amountCol")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tenantOrder.map((tenantKey) => {
|
||||
const lines = linesByTenant.get(tenantKey)!;
|
||||
return (
|
||||
<Fragment key={tenantKey ?? "_org"}>
|
||||
{tenantKey && (
|
||||
<tr className="border-t border-border">
|
||||
<td colSpan={4} className="py-1.5 pt-3">
|
||||
<span className="text-xs font-semibold text-accent">
|
||||
{tenantKey}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{lines.map((ln, i) => (
|
||||
<tr
|
||||
key={`${tenantKey}-${i}`}
|
||||
className="border-t border-border"
|
||||
>
|
||||
<td className="py-1.5">
|
||||
<div>{ln.description}</div>
|
||||
<div className="text-xs text-text-muted font-mono">
|
||||
{ln.kind}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1.5 text-right">
|
||||
{ln.quantity}
|
||||
{ln.unitLabel ? ` ${ln.unitLabel}` : ""}
|
||||
</td>
|
||||
<td className="py-1.5 text-right font-mono text-xs">
|
||||
{ln.unitPriceChf.toFixed(4)}
|
||||
</td>
|
||||
<td className="py-1.5 text-right">
|
||||
{ln.amountChf.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{draft.lines.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="py-4 text-center text-text-muted italic">
|
||||
{t("noLinesGenerated")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">{t("subtotal")}</span>
|
||||
<span>CHF {draft.subtotalChf.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">
|
||||
{t("vat")} ({draft.vatRate.toFixed(2)}%)
|
||||
</span>
|
||||
<span>CHF {draft.vatAmountChf.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between pt-1 border-t border-border font-semibold">
|
||||
<span>{t("total")}</span>
|
||||
<span>CHF {draft.totalChf.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
307
src/components/admin/billing/invoice-detail-view.tsx
Normal file
307
src/components/admin/billing/invoice-detail-view.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
"use client";
|
||||
|
||||
import { useState, Fragment } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardHeader } from "@/components/ui/card";
|
||||
import type { InvoiceDetail, InvoiceStatus } from "@/types";
|
||||
|
||||
interface Props {
|
||||
detail: InvoiceDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the invoice header (status, totals, action bar) then
|
||||
* line items grouped by tenant, then billing snapshot. Actions are
|
||||
* mark-paid (POST), delete (DELETE), PDF download (link to /pdf).
|
||||
*
|
||||
* On successful action we router.refresh() — the server-side page
|
||||
* re-renders against the new DB state. For delete we navigate
|
||||
* away first.
|
||||
*/
|
||||
export function InvoiceDetailView({ detail }: Props) {
|
||||
const t = useTranslations("adminBilling");
|
||||
const router = useRouter();
|
||||
const { invoice, lines } = detail;
|
||||
|
||||
const [busyAction, setBusyAction] = useState<null | "mark-paid" | "delete">(
|
||||
null
|
||||
);
|
||||
const [actionError, setActionError] = useState("");
|
||||
const [noteInput, setNoteInput] = useState("");
|
||||
const [noteOpen, setNoteOpen] = useState(false);
|
||||
|
||||
const markPaid = async () => {
|
||||
setActionError("");
|
||||
setBusyAction("mark-paid");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/billing/invoices/${invoice.id}/mark-paid`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ note: noteInput || undefined }),
|
||||
}
|
||||
);
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||
setNoteOpen(false);
|
||||
setNoteInput("");
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setActionError(e.message);
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteInvoice = async () => {
|
||||
if (!confirm(t("confirmDeleteInvoice", { num: invoice.invoiceNumber })))
|
||||
return;
|
||||
setActionError("");
|
||||
setBusyAction("delete");
|
||||
try {
|
||||
const res = await fetch(`/api/admin/billing/invoices/${invoice.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
throw new Error(j.error || `HTTP ${res.status}`);
|
||||
}
|
||||
router.push("/admin/billing/invoices");
|
||||
} catch (e: any) {
|
||||
setActionError(e.message);
|
||||
setBusyAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Group lines by tenant for display (matches PDF layout).
|
||||
const linesByTenant = new Map<string | null, typeof lines>();
|
||||
for (const ln of lines) {
|
||||
const k = ln.tenantName;
|
||||
if (!linesByTenant.has(k)) linesByTenant.set(k, []);
|
||||
linesByTenant.get(k)!.push(ln);
|
||||
}
|
||||
const tenantOrder = [...linesByTenant.keys()].sort((a, b) => {
|
||||
if (a === null) return 1;
|
||||
if (b === null) return -1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-in">
|
||||
<div className="flex items-end justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{invoice.invoiceNumber}
|
||||
</h1>
|
||||
<div className="flex items-center gap-3 mt-3 text-sm">
|
||||
<StatusPill status={invoice.status} />
|
||||
<span className="text-text-muted">
|
||||
{invoice.periodStart} → {invoice.periodEnd}
|
||||
</span>
|
||||
<span className="text-text-muted">·</span>
|
||||
<span className="text-text-muted">
|
||||
{t("dueOnLabel")}: {invoice.dueAt}
|
||||
</span>
|
||||
<span className="text-text-muted">·</span>
|
||||
<span className="text-text-muted font-mono text-xs">
|
||||
{invoice.locale}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-text-muted">{t("totalLabel")}</div>
|
||||
<div className="text-2xl font-semibold font-mono">
|
||||
CHF {invoice.totalChf.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action bar */}
|
||||
<Card>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{invoice.hasPdf && (
|
||||
<a
|
||||
href={`/api/admin/billing/invoices/${invoice.id}/pdf`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-4 py-2 rounded-md border border-border text-sm hover:bg-surface-2"
|
||||
>
|
||||
{t("downloadPdfBtn")}
|
||||
</a>
|
||||
)}
|
||||
{(invoice.status === "open" || invoice.status === "overdue") && (
|
||||
<>
|
||||
{!noteOpen ? (
|
||||
<button
|
||||
onClick={() => setNoteOpen(true)}
|
||||
disabled={busyAction !== null}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||
>
|
||||
{t("markPaidBtn")}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 flex-grow">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("paidNotePlaceholder")}
|
||||
value={noteInput}
|
||||
onChange={(e) => setNoteInput(e.target.value)}
|
||||
className="flex-grow px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={markPaid}
|
||||
disabled={busyAction !== null}
|
||||
className="px-3 py-1.5 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||
>
|
||||
{busyAction === "mark-paid" ? t("saving") : t("confirm")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setNoteOpen(false);
|
||||
setNoteInput("");
|
||||
}}
|
||||
className="px-3 py-1.5 rounded-md border border-border text-sm"
|
||||
>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={deleteInvoice}
|
||||
disabled={busyAction !== null}
|
||||
className="ml-auto px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
|
||||
title={t("deleteHint")}
|
||||
>
|
||||
{busyAction === "delete" ? t("deleting") : t("deleteBtn")}
|
||||
</button>
|
||||
</div>
|
||||
{actionError && (
|
||||
<div className="mt-3 text-sm text-error">{actionError}</div>
|
||||
)}
|
||||
{invoice.paidAt && (
|
||||
<div className="mt-3 text-xs text-text-muted">
|
||||
{t("paidOnLabel")}: {invoice.paidAt} · {invoice.paidBy} ·{" "}
|
||||
{invoice.paidMethodDetail}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Lines */}
|
||||
<Card>
|
||||
<CardHeader>{t("lineItemsTitle")}</CardHeader>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
<th className="pb-2">{t("descCol")}</th>
|
||||
<th className="pb-2 text-right">{t("qtyCol")}</th>
|
||||
<th className="pb-2 text-right">{t("unitPriceCol")}</th>
|
||||
<th className="pb-2 text-right">{t("amountCol")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tenantOrder.map((tenantKey) => {
|
||||
const tenantLines = linesByTenant.get(tenantKey)!;
|
||||
return (
|
||||
<Fragment key={tenantKey ?? "_org"}>
|
||||
{tenantKey && (
|
||||
<tr>
|
||||
<td colSpan={4} className="pt-3 pb-1">
|
||||
<span className="text-xs font-semibold text-accent">
|
||||
{tenantKey}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{tenantLines.map((ln) => (
|
||||
<tr key={ln.id} className="border-t border-border">
|
||||
<td className="py-1.5">
|
||||
<div>{ln.description}</div>
|
||||
<div className="text-xs text-text-muted font-mono">
|
||||
{ln.kind}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1.5 text-right">
|
||||
{ln.quantity}
|
||||
{ln.unitLabel ? ` ${ln.unitLabel}` : ""}
|
||||
</td>
|
||||
<td className="py-1.5 text-right font-mono text-xs">
|
||||
{ln.unitPriceChf.toFixed(4)}
|
||||
</td>
|
||||
<td className="py-1.5 text-right">
|
||||
{ln.amountChf.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">{t("subtotal")}</span>
|
||||
<span>CHF {invoice.subtotalChf.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">
|
||||
{t("vat")} ({invoice.vatRate.toFixed(2)}%)
|
||||
</span>
|
||||
<span>CHF {invoice.vatAmountChf.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between pt-1 border-t border-border font-semibold">
|
||||
<span>{t("total")}</span>
|
||||
<span>CHF {invoice.totalChf.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Billing snapshot */}
|
||||
<Card>
|
||||
<CardHeader>{t("billToSnapshotTitle")}</CardHeader>
|
||||
<div className="text-sm space-y-1">
|
||||
<div className="font-semibold">
|
||||
{invoice.billingSnapshot.companyName}
|
||||
</div>
|
||||
<div>{invoice.billingSnapshot.streetAddress}</div>
|
||||
<div>
|
||||
{invoice.billingSnapshot.postalCode}{" "}
|
||||
{invoice.billingSnapshot.city}
|
||||
</div>
|
||||
<div>{invoice.billingSnapshot.country}</div>
|
||||
{invoice.billingSnapshot.vatNumber && (
|
||||
<div className="text-text-muted">
|
||||
VAT: {invoice.billingSnapshot.vatNumber}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-text-muted">
|
||||
{invoice.billingSnapshot.billingEmail}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ status }: { status: InvoiceStatus }) {
|
||||
const t = useTranslations("adminBilling");
|
||||
const color =
|
||||
status === "paid"
|
||||
? "bg-success/15 text-success"
|
||||
: status === "overdue"
|
||||
? "bg-error/15 text-error"
|
||||
: status === "void" || status === "uncollectible"
|
||||
? "bg-text-muted/15 text-text-muted"
|
||||
: "bg-accent/15 text-accent";
|
||||
return (
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${color}`}
|
||||
>
|
||||
{t(`status_${status}` as any)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
183
src/components/admin/billing/invoices-table.tsx
Normal file
183
src/components/admin/billing/invoices-table.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { Invoice, InvoiceStatus } from "@/types";
|
||||
|
||||
interface Props {
|
||||
initialInvoices: Invoice[];
|
||||
}
|
||||
|
||||
const STATUS_FILTERS: (InvoiceStatus | "all")[] = [
|
||||
"all",
|
||||
"open",
|
||||
"overdue",
|
||||
"paid",
|
||||
"void",
|
||||
];
|
||||
|
||||
/**
|
||||
* Filterable invoice list. Filters live in URL-less local state
|
||||
* (simpler than syncing to query string for a v1 admin tool); a
|
||||
* page refresh resets.
|
||||
*
|
||||
* Re-fetching strategy: when filters change, hit the API directly
|
||||
* rather than router.refresh() so we don't bounce the user through
|
||||
* a full page render.
|
||||
*/
|
||||
export function InvoicesTable({ initialInvoices }: Props) {
|
||||
const t = useTranslations("adminBilling");
|
||||
const [statusFilter, setStatusFilter] = useState<InvoiceStatus | "all">("all");
|
||||
const [monthFilter, setMonthFilter] = useState("");
|
||||
const [invoices, setInvoices] = useState(initialInvoices);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Effect runs after initial render too; skip refetch on mount
|
||||
// when filters are at their defaults — the server already
|
||||
// gave us the right initial set.
|
||||
if (statusFilter === "all" && monthFilter === "") return;
|
||||
let cancelled = false;
|
||||
setBusy(true);
|
||||
const params = new URLSearchParams();
|
||||
if (statusFilter !== "all") params.set("status", statusFilter);
|
||||
if (monthFilter) params.set("month", monthFilter);
|
||||
fetch(`/api/admin/billing/invoices?${params}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (!cancelled) setInvoices(data);
|
||||
})
|
||||
.catch((e) => console.error("Failed to load invoices:", e))
|
||||
.finally(() => {
|
||||
if (!cancelled) setBusy(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [statusFilter, monthFilter]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<label className="block">
|
||||
<span className="text-xs text-text-muted">{t("statusFilterLabel")}</span>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) =>
|
||||
setStatusFilter(e.target.value as InvoiceStatus | "all")
|
||||
}
|
||||
className="mt-1 px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
|
||||
>
|
||||
{STATUS_FILTERS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s === "all" ? t("allStatuses") : t(`status_${s}` as any)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs text-text-muted">{t("monthFilterLabel")}</span>
|
||||
<input
|
||||
type="month"
|
||||
value={monthFilter}
|
||||
onChange={(e) => setMonthFilter(e.target.value)}
|
||||
className="mt-1 px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
{monthFilter && (
|
||||
<button
|
||||
onClick={() => setMonthFilter("")}
|
||||
className="text-xs text-text-muted hover:underline"
|
||||
>
|
||||
{t("clearFilter")}
|
||||
</button>
|
||||
)}
|
||||
{busy && (
|
||||
<span className="text-xs text-text-muted ml-auto">
|
||||
{t("loading")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
{invoices.length === 0 ? (
|
||||
<p className="text-sm text-text-muted italic text-center py-6">
|
||||
{t("noInvoicesFound")}
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
<th className="pb-2">{t("invoiceNumberCol")}</th>
|
||||
<th className="pb-2">{t("orgCol")}</th>
|
||||
<th className="pb-2">{t("periodCol")}</th>
|
||||
<th className="pb-2">{t("statusCol")}</th>
|
||||
<th className="pb-2 text-right">{t("totalCol")}</th>
|
||||
<th className="pb-2 text-right">{t("dueCol")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoices.map((inv) => (
|
||||
<tr
|
||||
key={inv.id}
|
||||
className="border-t border-border hover:bg-surface-2 cursor-pointer"
|
||||
>
|
||||
<td className="py-2">
|
||||
<Link
|
||||
href={`/admin/billing/invoices/${inv.id}`}
|
||||
className="font-mono text-xs hover:underline"
|
||||
>
|
||||
{inv.invoiceNumber}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<div className="text-xs">
|
||||
{inv.billingSnapshot.companyName || (
|
||||
<span className="font-mono">{inv.zitadelOrgId}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 text-xs font-mono">
|
||||
{inv.periodStart.slice(0, 7)}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<StatusPill status={inv.status} />
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
CHF {inv.totalChf.toFixed(2)}
|
||||
</td>
|
||||
<td className="py-2 text-right text-xs text-text-muted">
|
||||
{inv.dueAt}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ status }: { status: InvoiceStatus }) {
|
||||
const t = useTranslations("adminBilling");
|
||||
const color =
|
||||
status === "paid"
|
||||
? "bg-success/15 text-success"
|
||||
: status === "overdue"
|
||||
? "bg-error/15 text-error"
|
||||
: status === "void" || status === "uncollectible"
|
||||
? "bg-text-muted/15 text-text-muted"
|
||||
: "bg-accent/15 text-accent";
|
||||
return (
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${color}`}
|
||||
>
|
||||
{t(`status_${status}` as any)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
399
src/components/admin/billing/pricing-editor.tsx
Normal file
399
src/components/admin/billing/pricing-editor.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardHeader } from "@/components/ui/card";
|
||||
import type { PlatformPricing, SkillPricing } from "@/types";
|
||||
|
||||
interface CatalogEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
initialPricing: PlatformPricing;
|
||||
initialSkillPricing: SkillPricing[];
|
||||
catalog: CatalogEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-card layout:
|
||||
* 1. Platform pricing form (4 inputs, save = PUT to /pricing).
|
||||
* 2. Skill pricing table — list of priced skills, "Add skill"
|
||||
* picker below.
|
||||
*
|
||||
* No optimistic updates — every save round-trips and we
|
||||
* router.refresh() afterwards so the server-side render stays
|
||||
* the source of truth.
|
||||
*/
|
||||
export function PricingEditor({
|
||||
initialPricing,
|
||||
initialSkillPricing,
|
||||
catalog,
|
||||
}: Props) {
|
||||
const t = useTranslations("adminBilling");
|
||||
const router = useRouter();
|
||||
|
||||
// -- Platform pricing form ----------------------------------------------
|
||||
const [monthly, setMonthly] = useState(
|
||||
String(initialPricing.tenantMonthlyFeeChf)
|
||||
);
|
||||
const [setup, setSetup] = useState(String(initialPricing.tenantSetupFeeChf));
|
||||
const [threema, setThreema] = useState(
|
||||
String(initialPricing.threemaMessageChf)
|
||||
);
|
||||
const [vat, setVat] = useState(String(initialPricing.vatRateChli));
|
||||
const [savingPricing, setSavingPricing] = useState(false);
|
||||
const [pricingError, setPricingError] = useState("");
|
||||
const [pricingSaved, setPricingSaved] = useState(false);
|
||||
|
||||
const savePricing = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSavingPricing(true);
|
||||
setPricingError("");
|
||||
setPricingSaved(false);
|
||||
try {
|
||||
const res = await fetch("/api/admin/billing/pricing", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
tenantMonthlyFeeChf: Number(monthly),
|
||||
tenantSetupFeeChf: Number(setup),
|
||||
threemaMessageChf: Number(threema),
|
||||
vatRateChli: Number(vat),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
throw new Error(j.error || `HTTP ${res.status}`);
|
||||
}
|
||||
setPricingSaved(true);
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setPricingError(e.message);
|
||||
} finally {
|
||||
setSavingPricing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// -- Skill pricing ------------------------------------------------------
|
||||
// Server is authoritative — we don't keep an editable local copy of the
|
||||
// table; instead each action posts to the API and we router.refresh().
|
||||
const [newSkillId, setNewSkillId] = useState(
|
||||
catalog.find((c) => c.kind === "skill")?.id ?? ""
|
||||
);
|
||||
const [newSkillPrice, setNewSkillPrice] = useState("0.10");
|
||||
const [addingSkill, setAddingSkill] = useState(false);
|
||||
const [skillError, setSkillError] = useState("");
|
||||
|
||||
const addOrUpdateSkill = async (
|
||||
e: React.FormEvent,
|
||||
overrideId?: string,
|
||||
overridePrice?: string
|
||||
) => {
|
||||
e.preventDefault();
|
||||
setAddingSkill(true);
|
||||
setSkillError("");
|
||||
try {
|
||||
const res = await fetch("/api/admin/billing/skill-pricing", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
skillId: overrideId ?? newSkillId,
|
||||
dailyPriceChf: Number(overridePrice ?? newSkillPrice),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
throw new Error(j.error || `HTTP ${res.status}`);
|
||||
}
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setSkillError(e.message);
|
||||
} finally {
|
||||
setAddingSkill(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSkill = async (skillId: string) => {
|
||||
if (!confirm(t("confirmDeleteSkillPrice", { skill: skillId }))) return;
|
||||
setSkillError("");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/billing/skill-pricing/${encodeURIComponent(skillId)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
throw new Error(j.error || `HTTP ${res.status}`);
|
||||
}
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setSkillError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Catalog filtered to skill-kind entries for the picker, but keeping
|
||||
// existing pricing rows even if they reference non-skill packages.
|
||||
const skillCatalogOptions = catalog.filter((c) => c.kind === "skill");
|
||||
const catalogIndex = new Map(catalog.map((c) => [c.id, c]));
|
||||
const pricedIds = new Set(initialSkillPricing.map((s) => s.skillId));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>{t("platformPricingTitle")}</CardHeader>
|
||||
<form onSubmit={savePricing} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<label className="block">
|
||||
<span className="text-sm text-text-secondary">
|
||||
{t("monthlyFeeLabel")} (CHF)
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={monthly}
|
||||
onChange={(e) => setMonthly(e.target.value)}
|
||||
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm text-text-secondary">
|
||||
{t("setupFeeLabel")} (CHF)
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={setup}
|
||||
onChange={(e) => setSetup(e.target.value)}
|
||||
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm text-text-secondary">
|
||||
{t("threemaMessageLabel")} (CHF)
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
min="0"
|
||||
value={threema}
|
||||
onChange={(e) => setThreema(e.target.value)}
|
||||
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm text-text-secondary">
|
||||
{t("vatRateLabel")} (%)
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
value={vat}
|
||||
onChange={(e) => setVat(e.target.value)}
|
||||
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingPricing}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||
>
|
||||
{savingPricing ? t("saving") : t("save")}
|
||||
</button>
|
||||
{pricingSaved && (
|
||||
<span className="text-sm text-success">{t("savedOk")}</span>
|
||||
)}
|
||||
{pricingError && (
|
||||
<span className="text-sm text-error">{pricingError}</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>{t("skillPricingTitle")}</CardHeader>
|
||||
<p className="text-sm text-text-muted mb-4">{t("skillPricingDesc")}</p>
|
||||
|
||||
{initialSkillPricing.length > 0 ? (
|
||||
<table className="w-full text-sm mb-6">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
<th className="pb-2">{t("skillCol")}</th>
|
||||
<th className="pb-2 text-right">{t("dailyPriceCol")}</th>
|
||||
<th className="pb-2 text-right">{t("actionsCol")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{initialSkillPricing.map((sp) => {
|
||||
const entry = catalogIndex.get(sp.skillId);
|
||||
return (
|
||||
<tr
|
||||
key={sp.skillId}
|
||||
className="border-t border-border align-top"
|
||||
>
|
||||
<td className="py-2">
|
||||
<div className="font-mono text-xs">{sp.skillId}</div>
|
||||
{entry && (
|
||||
<div className="text-xs text-text-muted">{entry.name}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<InlinePriceEditor
|
||||
skillId={sp.skillId}
|
||||
initialPrice={sp.dailyPriceChf}
|
||||
onSave={(price) =>
|
||||
addOrUpdateSkill(
|
||||
new Event("submit") as any,
|
||||
sp.skillId,
|
||||
String(price)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<button
|
||||
onClick={() => deleteSkill(sp.skillId)}
|
||||
className="text-xs text-error hover:underline"
|
||||
>
|
||||
{t("remove")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted italic mb-4">{t("noSkillsPriced")}</p>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={(e) => addOrUpdateSkill(e)}
|
||||
className="flex items-end gap-3"
|
||||
>
|
||||
<label className="flex-grow">
|
||||
<span className="text-xs text-text-muted">{t("addSkillLabel")}</span>
|
||||
<select
|
||||
value={newSkillId}
|
||||
onChange={(e) => setNewSkillId(e.target.value)}
|
||||
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||
>
|
||||
{skillCatalogOptions
|
||||
.filter((c) => !pricedIds.has(c.id))
|
||||
.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name} ({c.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="w-32">
|
||||
<span className="text-xs text-text-muted">
|
||||
{t("dailyPriceLabel")} (CHF)
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={newSkillPrice}
|
||||
onChange={(e) => setNewSkillPrice(e.target.value)}
|
||||
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={addingSkill || !newSkillId}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||
>
|
||||
{addingSkill ? t("saving") : t("add")}
|
||||
</button>
|
||||
</form>
|
||||
{skillError && (
|
||||
<p className="text-sm text-error mt-2">{skillError}</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiny inline editor for a single skill's daily price. Mounts in
|
||||
* "view" mode showing the current value as a clickable badge;
|
||||
* clicking turns it into an input + save/cancel buttons.
|
||||
*/
|
||||
function InlinePriceEditor({
|
||||
skillId,
|
||||
initialPrice,
|
||||
onSave,
|
||||
}: {
|
||||
skillId: string;
|
||||
initialPrice: number;
|
||||
onSave: (price: number) => Promise<void> | void;
|
||||
}) {
|
||||
const t = useTranslations("adminBilling");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [value, setValue] = useState(String(initialPrice));
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="text-sm font-mono hover:underline"
|
||||
title={t("clickToEdit")}
|
||||
>
|
||||
CHF {initialPrice.toFixed(2)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
className="w-20 px-2 py-1 text-sm border border-border bg-surface-2 rounded"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await onSave(Number(value));
|
||||
setEditing(false);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
disabled={busy}
|
||||
className="text-xs px-2 py-1 bg-accent text-white rounded"
|
||||
>
|
||||
{busy ? "…" : "✓"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setValue(String(initialPrice));
|
||||
setEditing(false);
|
||||
}}
|
||||
className="text-xs px-2 py-1 border border-border rounded"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
651
src/lib/billing-pdf.tsx
Normal file
651
src/lib/billing-pdf.tsx
Normal file
@@ -0,0 +1,651 @@
|
||||
/**
|
||||
* Invoice PDF rendering via @react-pdf/renderer.
|
||||
*
|
||||
* Design notes:
|
||||
*
|
||||
* - The template is a React component (JSX). Visual tweaks happen
|
||||
* here — colors, fonts, spacing, layout. To swap branding later,
|
||||
* edit BRAND_* constants below or replace the logo component.
|
||||
*
|
||||
* - All strings are pulled from MESSAGES[locale]. To add a new
|
||||
* language, copy the German block and translate. Locale is
|
||||
* frozen on the invoice at issue time (invoices.locale column);
|
||||
* re-rendering a historical invoice always uses the same locale.
|
||||
*
|
||||
* - The logo is inlined as React-PDF SVG primitives so no asset
|
||||
* loading or font-bundle wrangling is needed. It travels with
|
||||
* the code.
|
||||
*
|
||||
* - VAT note (reverse charge etc.) is appended below the totals
|
||||
* block. Notes are localized in the same MESSAGES map.
|
||||
*
|
||||
* - QR-bill (Swiss bank transfer) is intentionally NOT included
|
||||
* in v1 — it lands in Phase 7. We render plain bank instructions
|
||||
* as text.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Document,
|
||||
Page,
|
||||
Text,
|
||||
View,
|
||||
StyleSheet,
|
||||
Svg,
|
||||
Polygon,
|
||||
Polyline,
|
||||
renderToBuffer,
|
||||
} from "@react-pdf/renderer";
|
||||
import type { Invoice, InvoiceLine, InvoiceLineKind } from "@/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Brand constants — edit here to tweak look without touching layout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BRAND = {
|
||||
name: "PieCed IT",
|
||||
// Primary emerald — matches the logo SVG fill (#10B981).
|
||||
primary: "#10B981",
|
||||
// Slightly darker emerald for headings.
|
||||
primaryDark: "#0a8060",
|
||||
textColor: "#1a1a1a",
|
||||
mutedColor: "#666",
|
||||
borderColor: "#d4d4d4",
|
||||
// Issuer block — change these to your real legal info.
|
||||
issuer: {
|
||||
legalName: "PieCed IT",
|
||||
addressLine1: "Cedric Mosimann",
|
||||
addressLine2: "[Strasse Nr.]",
|
||||
postalCity: "[PLZ] Basel",
|
||||
country: "Switzerland",
|
||||
email: "billing@pieced.ch",
|
||||
web: "pieced.ch",
|
||||
// Show "MWST-Nr. ..." on PDF when set.
|
||||
vatNumber: null as string | null,
|
||||
// Bank instructions — Phase 7 replaces with QR-bill.
|
||||
bankName: "[Bank name]",
|
||||
bankIban: "[CHxx xxxx xxxx xxxx xxxx x]",
|
||||
bankBic: "[BIC]",
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Localized strings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PdfStrings {
|
||||
invoice: string;
|
||||
invoiceNumber: string;
|
||||
issueDate: string;
|
||||
dueDate: string;
|
||||
period: string;
|
||||
billTo: string;
|
||||
description: string;
|
||||
quantity: string;
|
||||
unitPrice: string;
|
||||
amount: string;
|
||||
subtotal: string;
|
||||
vat: string;
|
||||
total: string;
|
||||
paymentInstructions: string;
|
||||
paymentRefHint: string;
|
||||
thankYou: string;
|
||||
page: string;
|
||||
of: string;
|
||||
// Per-line-kind labels (used as section headers)
|
||||
kindLabels: Record<InvoiceLineKind, string>;
|
||||
// VAT compliance notes
|
||||
reverseCharge: string;
|
||||
exportNote: string;
|
||||
}
|
||||
|
||||
const MESSAGES: Record<string, PdfStrings> = {
|
||||
de: {
|
||||
invoice: "Rechnung",
|
||||
invoiceNumber: "Rechnungs-Nr.",
|
||||
issueDate: "Rechnungsdatum",
|
||||
dueDate: "Zahlbar bis",
|
||||
period: "Abrechnungsperiode",
|
||||
billTo: "Rechnungsempfänger",
|
||||
description: "Beschreibung",
|
||||
quantity: "Menge",
|
||||
unitPrice: "Einzelpreis",
|
||||
amount: "Betrag",
|
||||
subtotal: "Zwischensumme",
|
||||
vat: "MWST",
|
||||
total: "Total",
|
||||
paymentInstructions: "Zahlungsinformationen",
|
||||
paymentRefHint: "Bitte verwenden Sie die Rechnungsnummer als Referenz.",
|
||||
thankYou: "Vielen Dank für Ihr Vertrauen.",
|
||||
page: "Seite",
|
||||
of: "von",
|
||||
kindLabels: {
|
||||
tenant_monthly: "Monatliche Grundgebühr",
|
||||
tenant_setup: "Einrichtungsgebühr",
|
||||
ai_usage: "KI-Nutzung",
|
||||
threema_messages: "Threema-Nachrichten",
|
||||
skill_usage: "Skill-Nutzung",
|
||||
adjustment: "Anpassung",
|
||||
},
|
||||
reverseCharge:
|
||||
"Steuerschuldnerschaft des Leistungsempfängers (Reverse Charge).",
|
||||
exportNote: "Dienstleistungsexport — keine MWST in Rechnung gestellt.",
|
||||
},
|
||||
en: {
|
||||
invoice: "Invoice",
|
||||
invoiceNumber: "Invoice no.",
|
||||
issueDate: "Issue date",
|
||||
dueDate: "Due date",
|
||||
period: "Billing period",
|
||||
billTo: "Bill to",
|
||||
description: "Description",
|
||||
quantity: "Qty",
|
||||
unitPrice: "Unit price",
|
||||
amount: "Amount",
|
||||
subtotal: "Subtotal",
|
||||
vat: "VAT",
|
||||
total: "Total",
|
||||
paymentInstructions: "Payment instructions",
|
||||
paymentRefHint: "Please use the invoice number as the payment reference.",
|
||||
thankYou: "Thank you for your business.",
|
||||
page: "Page",
|
||||
of: "of",
|
||||
kindLabels: {
|
||||
tenant_monthly: "Monthly fee",
|
||||
tenant_setup: "Setup fee",
|
||||
ai_usage: "AI usage",
|
||||
threema_messages: "Threema messages",
|
||||
skill_usage: "Skill usage",
|
||||
adjustment: "Adjustment",
|
||||
},
|
||||
reverseCharge:
|
||||
"Reverse charge — VAT to be accounted for by the recipient.",
|
||||
exportNote: "Export of services — VAT not applicable.",
|
||||
},
|
||||
fr: {
|
||||
invoice: "Facture",
|
||||
invoiceNumber: "N° facture",
|
||||
issueDate: "Date d'émission",
|
||||
dueDate: "Échéance",
|
||||
period: "Période de facturation",
|
||||
billTo: "Destinataire",
|
||||
description: "Description",
|
||||
quantity: "Qté",
|
||||
unitPrice: "Prix unitaire",
|
||||
amount: "Montant",
|
||||
subtotal: "Sous-total",
|
||||
vat: "TVA",
|
||||
total: "Total",
|
||||
paymentInstructions: "Informations de paiement",
|
||||
paymentRefHint: "Veuillez utiliser le n° de facture comme référence.",
|
||||
thankYou: "Merci de votre confiance.",
|
||||
page: "Page",
|
||||
of: "sur",
|
||||
kindLabels: {
|
||||
tenant_monthly: "Forfait mensuel",
|
||||
tenant_setup: "Frais de configuration",
|
||||
ai_usage: "Utilisation IA",
|
||||
threema_messages: "Messages Threema",
|
||||
skill_usage: "Utilisation Skill",
|
||||
adjustment: "Ajustement",
|
||||
},
|
||||
reverseCharge:
|
||||
"Autoliquidation — TVA à acquitter par le destinataire.",
|
||||
exportNote: "Exportation de services — TVA non applicable.",
|
||||
},
|
||||
it: {
|
||||
invoice: "Fattura",
|
||||
invoiceNumber: "N. fattura",
|
||||
issueDate: "Data di emissione",
|
||||
dueDate: "Scadenza",
|
||||
period: "Periodo di fatturazione",
|
||||
billTo: "Destinatario",
|
||||
description: "Descrizione",
|
||||
quantity: "Qtà",
|
||||
unitPrice: "Prezzo unitario",
|
||||
amount: "Importo",
|
||||
subtotal: "Subtotale",
|
||||
vat: "IVA",
|
||||
total: "Totale",
|
||||
paymentInstructions: "Istruzioni di pagamento",
|
||||
paymentRefHint: "Si prega di utilizzare il n. di fattura come riferimento.",
|
||||
thankYou: "Grazie per la fiducia.",
|
||||
page: "Pagina",
|
||||
of: "di",
|
||||
kindLabels: {
|
||||
tenant_monthly: "Canone mensile",
|
||||
tenant_setup: "Spese di attivazione",
|
||||
ai_usage: "Utilizzo IA",
|
||||
threema_messages: "Messaggi Threema",
|
||||
skill_usage: "Utilizzo Skill",
|
||||
adjustment: "Rettifica",
|
||||
},
|
||||
reverseCharge:
|
||||
"Inversione contabile — IVA a carico del destinatario.",
|
||||
exportNote: "Esportazione di servizi — IVA non applicabile.",
|
||||
},
|
||||
};
|
||||
|
||||
function getStrings(locale: string): PdfStrings {
|
||||
return MESSAGES[locale] ?? MESSAGES.de;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stylesheet
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
paddingTop: 40,
|
||||
paddingBottom: 60,
|
||||
paddingHorizontal: 40,
|
||||
fontSize: 9,
|
||||
color: BRAND.textColor,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: 28,
|
||||
},
|
||||
logoWrap: { width: 60, height: 90 },
|
||||
issuerBlock: { textAlign: "right", fontSize: 8.5, color: BRAND.mutedColor },
|
||||
issuerName: { fontSize: 11, color: BRAND.primaryDark, marginBottom: 2 },
|
||||
invoiceTitle: { fontSize: 22, color: BRAND.primaryDark, marginBottom: 8 },
|
||||
metaTable: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 20,
|
||||
},
|
||||
metaCol: { flexGrow: 1, marginRight: 16 },
|
||||
metaLabel: { color: BRAND.mutedColor, fontSize: 8, marginBottom: 2 },
|
||||
metaValue: { fontSize: 10, marginBottom: 6 },
|
||||
billToBlock: {
|
||||
marginBottom: 24,
|
||||
padding: 10,
|
||||
backgroundColor: "#f7f7f5",
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: BRAND.primary,
|
||||
},
|
||||
billToLabel: { fontSize: 8, color: BRAND.mutedColor, marginBottom: 4 },
|
||||
billToName: { fontSize: 11, marginBottom: 2 },
|
||||
table: { marginBottom: 14 },
|
||||
tableHeader: {
|
||||
flexDirection: "row",
|
||||
backgroundColor: BRAND.primaryDark,
|
||||
color: "#ffffff",
|
||||
paddingVertical: 5,
|
||||
paddingHorizontal: 6,
|
||||
fontSize: 8.5,
|
||||
},
|
||||
tableRow: {
|
||||
flexDirection: "row",
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: BRAND.borderColor,
|
||||
paddingVertical: 5,
|
||||
paddingHorizontal: 6,
|
||||
},
|
||||
// Column widths (sum ≈ 100%)
|
||||
colDesc: { width: "52%" },
|
||||
colQty: { width: "12%", textAlign: "right" },
|
||||
colUnit: { width: "16%", textAlign: "right" },
|
||||
colAmt: { width: "20%", textAlign: "right" },
|
||||
totalsBlock: {
|
||||
alignSelf: "flex-end",
|
||||
width: "45%",
|
||||
marginTop: 8,
|
||||
},
|
||||
totalsRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 3,
|
||||
},
|
||||
totalsLabel: { color: BRAND.mutedColor },
|
||||
totalsValue: { textAlign: "right" },
|
||||
totalsGrand: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: BRAND.primaryDark,
|
||||
paddingTop: 6,
|
||||
marginTop: 4,
|
||||
},
|
||||
totalsGrandLabel: { color: BRAND.primaryDark, fontSize: 11 },
|
||||
totalsGrandValue: { color: BRAND.primaryDark, fontSize: 11, textAlign: "right" },
|
||||
noteBox: {
|
||||
marginTop: 18,
|
||||
padding: 8,
|
||||
backgroundColor: "#fff8e7",
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: "#d4a017",
|
||||
fontSize: 8.5,
|
||||
},
|
||||
paymentBlock: {
|
||||
marginTop: 24,
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor: BRAND.borderColor,
|
||||
},
|
||||
paymentTitle: { fontSize: 10, color: BRAND.primaryDark, marginBottom: 6 },
|
||||
paymentLine: { fontSize: 9, marginBottom: 1 },
|
||||
footer: {
|
||||
position: "absolute",
|
||||
bottom: 24,
|
||||
left: 40,
|
||||
right: 40,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
fontSize: 7.5,
|
||||
color: BRAND.mutedColor,
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor: BRAND.borderColor,
|
||||
paddingTop: 8,
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logo — inlined SVG primitives
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* PieCed honeycomb logo. Re-renders the same 6-hex glyph as the
|
||||
* portal's `public/pieced-logo.svg` using React-PDF's SVG support.
|
||||
* Width/height are independent of the original viewBox so we can
|
||||
* scale it without losing stroke quality.
|
||||
*/
|
||||
const Logo = ({ size = 60 }: { size?: number }) => (
|
||||
<Svg width={size} height={size * (106 / 70)} viewBox="0 0 70 106">
|
||||
{/* H1 solid */}
|
||||
<Polygon
|
||||
points="38.5,22.69 31.5,10.566 17.5,10.566 10.5,22.69 17.5,34.814 31.5,34.814"
|
||||
fill="#10B981"
|
||||
stroke="#10B981"
|
||||
strokeWidth={1.6}
|
||||
/>
|
||||
{/* H2 outline */}
|
||||
<Polygon
|
||||
points="59.5,34.814 52.5,22.69 38.5,22.69 31.5,34.814 38.5,46.938 52.5,46.938"
|
||||
fill="none"
|
||||
stroke="#10B981"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
{/* H3 outline */}
|
||||
<Polygon
|
||||
points="38.5,46.938 31.5,34.814 17.5,34.814 10.5,46.938 17.5,59.062 31.5,59.062"
|
||||
fill="none"
|
||||
stroke="#10B981"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
{/* H4 solid */}
|
||||
<Polygon
|
||||
points="59.5,59.062 52.5,46.938 38.5,46.938 31.5,59.062 38.5,71.186 52.5,71.186"
|
||||
fill="#10B981"
|
||||
stroke="#10B981"
|
||||
strokeWidth={1.6}
|
||||
/>
|
||||
{/* H5 partial */}
|
||||
<Polyline
|
||||
points="31.5,83.31 38.5,71.186 31.5,59.062 17.5,59.062 10.5,71.186"
|
||||
fill="none"
|
||||
stroke="#10B981"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
{/* H6 partial */}
|
||||
<Polyline
|
||||
points="59.5,83.31 52.5,71.186 38.5,71.186 31.5,83.31 38.5,95.434"
|
||||
fill="none"
|
||||
stroke="#10B981"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fmtChf(n: number, decimals: number = 2): string {
|
||||
// Swiss thousands separator + decimal point: 1'234.56
|
||||
const fixed = n.toFixed(decimals);
|
||||
const [intPart, decPart] = fixed.split(".");
|
||||
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, "'");
|
||||
return decPart ? `${withSep}.${decPart}` : withSep;
|
||||
}
|
||||
|
||||
function fmtDate(iso: string, locale: string): string {
|
||||
// Parse YYYY-MM-DD as a calendar date (no timezone shifts).
|
||||
// For PDF rendering we want a stable representation regardless
|
||||
// of server timezone.
|
||||
const [y, m, d] = iso.split("T")[0].split("-").map(Number);
|
||||
// Locale-specific date format
|
||||
if (locale === "en") {
|
||||
return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
}
|
||||
// DE/FR/IT default: DD.MM.YYYY
|
||||
return `${String(d).padStart(2, "0")}.${String(m).padStart(2, "0")}.${y}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Document
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface InvoicePdfProps {
|
||||
invoice: Invoice;
|
||||
lines: InvoiceLine[];
|
||||
}
|
||||
|
||||
const InvoicePdf: React.FC<InvoicePdfProps> = ({ invoice, lines }) => {
|
||||
const s = getStrings(invoice.locale);
|
||||
const snap = invoice.billingSnapshot;
|
||||
|
||||
// Group lines by tenant for visual separation. Lines without a
|
||||
// tenant_name (org-level adjustments) go to the end.
|
||||
const linesByTenant = new Map<string | null, InvoiceLine[]>();
|
||||
for (const ln of lines) {
|
||||
const key = ln.tenantName;
|
||||
if (!linesByTenant.has(key)) linesByTenant.set(key, []);
|
||||
linesByTenant.get(key)!.push(ln);
|
||||
}
|
||||
const tenantOrder = [...linesByTenant.keys()].sort((a, b) => {
|
||||
if (a === null) return 1;
|
||||
if (b === null) return -1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
// VAT note: pick the right localized note based on rate + address.
|
||||
// Zero rate + EU country = reverse charge; zero rate + other = export.
|
||||
let vatNote: string | null = null;
|
||||
if (invoice.vatRate === 0) {
|
||||
const country = (snap.country || "").toUpperCase();
|
||||
const isEu = [
|
||||
"AT","BE","BG","HR","CY","CZ","DK","EE","FI","FR","DE","GR","HU",
|
||||
"IE","IT","LV","LT","LU","MT","NL","PL","PT","RO","SK","SI","ES","SE",
|
||||
].includes(country);
|
||||
vatNote = isEu ? s.reverseCharge : s.exportNote;
|
||||
}
|
||||
|
||||
return (
|
||||
<Document title={`${s.invoice} ${invoice.invoiceNumber}`}>
|
||||
<Page size="A4" style={styles.page}>
|
||||
{/* Header: logo left, issuer right */}
|
||||
<View style={styles.headerRow}>
|
||||
<View style={styles.logoWrap}>
|
||||
<Logo size={60} />
|
||||
</View>
|
||||
<View style={styles.issuerBlock}>
|
||||
<Text style={styles.issuerName}>{BRAND.issuer.legalName}</Text>
|
||||
<Text>{BRAND.issuer.addressLine1}</Text>
|
||||
<Text>{BRAND.issuer.addressLine2}</Text>
|
||||
<Text>{BRAND.issuer.postalCity}</Text>
|
||||
<Text>{BRAND.issuer.country}</Text>
|
||||
<Text>{BRAND.issuer.email}</Text>
|
||||
{BRAND.issuer.vatNumber && (
|
||||
<Text>MWST-Nr. {BRAND.issuer.vatNumber}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.invoiceTitle}>{s.invoice}</Text>
|
||||
|
||||
{/* Meta row: 3 columns */}
|
||||
<View style={styles.metaTable}>
|
||||
<View style={styles.metaCol}>
|
||||
<Text style={styles.metaLabel}>{s.invoiceNumber}</Text>
|
||||
<Text style={styles.metaValue}>{invoice.invoiceNumber}</Text>
|
||||
<Text style={styles.metaLabel}>{s.issueDate}</Text>
|
||||
<Text style={styles.metaValue}>
|
||||
{fmtDate(invoice.issuedAt, invoice.locale)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.metaCol}>
|
||||
<Text style={styles.metaLabel}>{s.period}</Text>
|
||||
<Text style={styles.metaValue}>
|
||||
{fmtDate(invoice.periodStart, invoice.locale)} —{" "}
|
||||
{fmtDate(invoice.periodEnd, invoice.locale)}
|
||||
</Text>
|
||||
<Text style={styles.metaLabel}>{s.dueDate}</Text>
|
||||
<Text style={styles.metaValue}>
|
||||
{fmtDate(invoice.dueAt, invoice.locale)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Bill-to */}
|
||||
<View style={styles.billToBlock}>
|
||||
<Text style={styles.billToLabel}>{s.billTo}</Text>
|
||||
<Text style={styles.billToName}>{snap.companyName}</Text>
|
||||
<Text>{snap.streetAddress}</Text>
|
||||
<Text>
|
||||
{snap.postalCode} {snap.city}
|
||||
</Text>
|
||||
<Text>{snap.country}</Text>
|
||||
{snap.vatNumber && <Text>VAT: {snap.vatNumber}</Text>}
|
||||
<Text>{snap.billingEmail}</Text>
|
||||
</View>
|
||||
|
||||
{/* Line items table */}
|
||||
<View style={styles.table}>
|
||||
<View style={styles.tableHeader}>
|
||||
<Text style={styles.colDesc}>{s.description}</Text>
|
||||
<Text style={styles.colQty}>{s.quantity}</Text>
|
||||
<Text style={styles.colUnit}>{s.unitPrice}</Text>
|
||||
<Text style={styles.colAmt}>{s.amount} (CHF)</Text>
|
||||
</View>
|
||||
{tenantOrder.map((tenantKey) => {
|
||||
const tenantLines = linesByTenant.get(tenantKey)!;
|
||||
return (
|
||||
<View key={tenantKey ?? "_org"}>
|
||||
{tenantKey && (
|
||||
<View
|
||||
style={{
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 6,
|
||||
backgroundColor: "#f0f9f4",
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 9, color: BRAND.primaryDark }}>
|
||||
{tenantKey}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{tenantLines.map((ln) => (
|
||||
<View key={ln.id} style={styles.tableRow}>
|
||||
<Text style={styles.colDesc}>{ln.description}</Text>
|
||||
<Text style={styles.colQty}>
|
||||
{ln.quantity}
|
||||
{ln.unitLabel ? ` ${ln.unitLabel}` : ""}
|
||||
</Text>
|
||||
<Text style={styles.colUnit}>{fmtChf(ln.unitPriceChf, 5)}</Text>
|
||||
<Text style={styles.colAmt}>{fmtChf(ln.amountChf)}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* Totals */}
|
||||
<View style={styles.totalsBlock}>
|
||||
<View style={styles.totalsRow}>
|
||||
<Text style={styles.totalsLabel}>{s.subtotal}</Text>
|
||||
<Text style={styles.totalsValue}>{fmtChf(invoice.subtotalChf)}</Text>
|
||||
</View>
|
||||
<View style={styles.totalsRow}>
|
||||
<Text style={styles.totalsLabel}>
|
||||
{s.vat} ({invoice.vatRate.toFixed(2)}%)
|
||||
</Text>
|
||||
<Text style={styles.totalsValue}>{fmtChf(invoice.vatAmountChf)}</Text>
|
||||
</View>
|
||||
<View style={styles.totalsGrand}>
|
||||
<Text style={styles.totalsGrandLabel}>
|
||||
{s.total} (CHF)
|
||||
</Text>
|
||||
<Text style={styles.totalsGrandValue}>{fmtChf(invoice.totalChf)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{vatNote && (
|
||||
<View style={styles.noteBox}>
|
||||
<Text>{vatNote}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Payment instructions */}
|
||||
<View style={styles.paymentBlock}>
|
||||
<Text style={styles.paymentTitle}>{s.paymentInstructions}</Text>
|
||||
<Text style={styles.paymentLine}>{BRAND.issuer.legalName}</Text>
|
||||
<Text style={styles.paymentLine}>{BRAND.issuer.bankName}</Text>
|
||||
<Text style={styles.paymentLine}>IBAN: {BRAND.issuer.bankIban}</Text>
|
||||
<Text style={styles.paymentLine}>BIC: {BRAND.issuer.bankBic}</Text>
|
||||
<Text style={[styles.paymentLine, { marginTop: 6, color: BRAND.mutedColor }]}>
|
||||
{s.paymentRefHint}
|
||||
</Text>
|
||||
<Text style={[styles.paymentLine, { marginTop: 12, color: BRAND.primaryDark }]}>
|
||||
{s.thankYou}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Footer with page numbers — react-pdf supplies render fn args */}
|
||||
<View
|
||||
style={styles.footer}
|
||||
render={({ pageNumber, totalPages }) => (
|
||||
<>
|
||||
<Text>
|
||||
{BRAND.issuer.legalName} · {BRAND.issuer.web} · {BRAND.issuer.email}
|
||||
</Text>
|
||||
<Text>
|
||||
{s.page} {pageNumber} {s.of} {totalPages}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
fixed
|
||||
/>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Render an invoice to a PDF buffer. Caller stores the buffer in
|
||||
* `invoices.pdf_data` (bytea). Side-effect-free; can be called
|
||||
* outside a DB transaction.
|
||||
*
|
||||
* Typical runtime is 50–200ms on a typical invoice with a dozen
|
||||
* lines.
|
||||
*/
|
||||
export async function renderInvoicePdf(
|
||||
invoice: Invoice,
|
||||
lines: InvoiceLine[]
|
||||
): Promise<Buffer> {
|
||||
return renderToBuffer(<InvoicePdf invoice={invoice} lines={lines} />);
|
||||
}
|
||||
737
src/lib/billing.ts
Normal file
737
src/lib/billing.ts
Normal file
@@ -0,0 +1,737 @@
|
||||
/**
|
||||
* Billing computation pipeline.
|
||||
*
|
||||
* Public entry points:
|
||||
* - computeInvoiceDraft({ zitadelOrgId, year, month, locale? })
|
||||
* Builds an in-memory InvoiceDraft from the live signals
|
||||
* (LiteLLM spend, Threema relay usage, tenant skill events,
|
||||
* lifecycle, suspension). Does NOT persist or render the PDF.
|
||||
*
|
||||
* - generateInvoice({ zitadelOrgId, year, month, locale?, dryRun? })
|
||||
* Calls computeInvoiceDraft, renders the PDF, persists the
|
||||
* invoice transactionally. Returns the persisted Invoice
|
||||
* (or the draft if dryRun=true).
|
||||
*
|
||||
* Design choices:
|
||||
*
|
||||
* - All compute is over UTC calendar days. "Active during day D"
|
||||
* means the tenant existed and was not fully suspended at some
|
||||
* moment in [D 00:00 UTC, D+1 00:00 UTC). This matches the
|
||||
* skill billing rule ("same-day toggle = 1 day") for monthly
|
||||
* fee proration too.
|
||||
*
|
||||
* - Computation is independent of persistence. Callers can preview
|
||||
* without committing (the admin generate form does this on first
|
||||
* click), and the same compute path is reused when committing.
|
||||
*
|
||||
* - The compute path collects warnings rather than throwing on
|
||||
* recoverable issues (missing LiteLLM team for a tenant, etc.).
|
||||
* The UI surfaces these to the admin before they confirm.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Invoice,
|
||||
InvoiceBillingSnapshot,
|
||||
InvoiceDraft,
|
||||
InvoiceLine,
|
||||
InvoiceLineKind,
|
||||
InvoicePaymentMethod,
|
||||
PiecedTenant,
|
||||
PlatformPricing,
|
||||
SkillPricing,
|
||||
TenantBillingLifecycle,
|
||||
TenantSkillEvent,
|
||||
TenantSuspensionEvent,
|
||||
} from "@/types";
|
||||
import {
|
||||
createInvoice,
|
||||
getInvoiceById,
|
||||
getOrgBilling,
|
||||
getOrgBillingConfig,
|
||||
getPlatformPricing,
|
||||
getTenantBillingLifecycle,
|
||||
listSkillEventsForTenant,
|
||||
listSkillPricing,
|
||||
listSuspensionEventsForTenant,
|
||||
tenantHasSetupFeeBilled,
|
||||
updateInvoicePdf,
|
||||
} from "./db";
|
||||
import { listTenants } from "./k8s";
|
||||
import { getTeamSpendLogsV2 } from "./litellm";
|
||||
import { getUsage as getThreemaUsage } from "./threema-relay";
|
||||
import { renderInvoicePdf } from "./billing-pdf";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Period helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the [periodStart, periodEnd] inclusive calendar dates for
|
||||
* the given month, plus the count of days in the month.
|
||||
*
|
||||
* Dates returned as ISO `YYYY-MM-DD` strings (no time). Convertible
|
||||
* to UTC midnight via `new Date(`${date}T00:00:00Z`)`.
|
||||
*/
|
||||
export function monthBounds(year: number, month: number): {
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
daysInMonth: number;
|
||||
} {
|
||||
if (month < 1 || month > 12) throw new Error(`Invalid month: ${month}`);
|
||||
const start = new Date(Date.UTC(year, month - 1, 1));
|
||||
// Day 0 of next month = last day of this month
|
||||
const end = new Date(Date.UTC(year, month, 0));
|
||||
return {
|
||||
periodStart: start.toISOString().split("T")[0],
|
||||
periodEnd: end.toISOString().split("T")[0],
|
||||
daysInMonth: end.getUTCDate(),
|
||||
};
|
||||
}
|
||||
|
||||
function isoDate(d: Date): string {
|
||||
return d.toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
function dueDate(periodEnd: string, netDays: number = 30): string {
|
||||
// due_at = period_end + netDays
|
||||
const d = new Date(`${periodEnd}T00:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() + netDays);
|
||||
return isoDate(d);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Day-set computation (calendar-day model, UTC)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Iterates UTC calendar days in [periodStart, periodEnd] inclusive.
|
||||
* Yields { date: 'YYYY-MM-DD', dayStartMs, dayEndMs } where dayEnd
|
||||
* is exclusive (next-day-midnight UTC).
|
||||
*/
|
||||
function* iterDays(periodStart: string, periodEnd: string) {
|
||||
const start = new Date(`${periodStart}T00:00:00Z`).getTime();
|
||||
const end = new Date(`${periodEnd}T00:00:00Z`).getTime();
|
||||
for (let t = start; t <= end; t += 86_400_000) {
|
||||
yield {
|
||||
date: isoDate(new Date(t)),
|
||||
dayStartMs: t,
|
||||
dayEndMs: t + 86_400_000,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Was the tenant "running" (created, not deleted, not suspended) at
|
||||
* any moment in the half-open interval [dayStartMs, dayEndMs)?
|
||||
*
|
||||
* Inputs: tenant lifecycle and the timeline of suspension events
|
||||
* sorted ascending by occurredAt.
|
||||
*
|
||||
* The state-at-day-start is reconstructed from suspension events
|
||||
* BEFORE the day. If the count of suspension events before the day
|
||||
* is odd, the tenant was suspended at day start (because we record
|
||||
* suspend then resume, so an odd prefix-count means the last
|
||||
* recorded transition is "suspended"). This is robust as long as
|
||||
* events are correctly ordered.
|
||||
*
|
||||
* Actually we use the actual event kinds from the events list,
|
||||
* not the parity heuristic — the heuristic is documentation for
|
||||
* intuition.
|
||||
*/
|
||||
function activeDuringDay(
|
||||
lifecycle: TenantBillingLifecycle,
|
||||
suspensionEvents: TenantSuspensionEvent[],
|
||||
dayStartMs: number,
|
||||
dayEndMs: number
|
||||
): boolean {
|
||||
// Lifecycle gate: tenant must have existed during some part of the day.
|
||||
const createdMs = new Date(lifecycle.createdAt).getTime();
|
||||
const deletedMs = lifecycle.deletedAt
|
||||
? new Date(lifecycle.deletedAt).getTime()
|
||||
: Infinity;
|
||||
if (createdMs >= dayEndMs) return false;
|
||||
if (deletedMs <= dayStartMs) return false;
|
||||
// Effective existence window within this day
|
||||
const existsFrom = Math.max(createdMs, dayStartMs);
|
||||
const existsTo = Math.min(deletedMs, dayEndMs);
|
||||
if (existsFrom >= existsTo) return false;
|
||||
|
||||
// Determine suspended state at existsFrom by replaying events.
|
||||
// Initial state at lifecycle.createdAt is 'running' (we don't
|
||||
// record an explicit 'created → running' event; this is the
|
||||
// implicit baseline).
|
||||
let suspended = false;
|
||||
for (const e of suspensionEvents) {
|
||||
const ts = new Date(e.occurredAt).getTime();
|
||||
if (ts > existsFrom) break;
|
||||
suspended = e.eventKind === "suspended";
|
||||
}
|
||||
|
||||
// Walk events from existsFrom to existsTo. If at any moment the
|
||||
// tenant is running, the day counts.
|
||||
if (!suspended) return true;
|
||||
for (const e of suspensionEvents) {
|
||||
const ts = new Date(e.occurredAt).getTime();
|
||||
if (ts <= existsFrom) continue;
|
||||
if (ts >= existsTo) break;
|
||||
if (e.eventKind === "resumed") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Was the skill 'enabled' at any moment in the day?
|
||||
*
|
||||
* Same shape as activeDuringDay but driven by skill events instead
|
||||
* of suspension events.
|
||||
*
|
||||
* Important: callers must include events from before periodStart in
|
||||
* `prevState` (state at day start), since a skill enabled three
|
||||
* months ago and never disabled has no events in the billing
|
||||
* window but is still enabled.
|
||||
*/
|
||||
function skillActiveDuringDay(
|
||||
events: TenantSkillEvent[],
|
||||
initiallyEnabled: boolean,
|
||||
dayStartMs: number,
|
||||
dayEndMs: number
|
||||
): boolean {
|
||||
let enabled = initiallyEnabled;
|
||||
// First, replay events that occurred AT OR BEFORE dayStartMs to
|
||||
// get the state at day start.
|
||||
for (const e of events) {
|
||||
const ts = new Date(e.occurredAt).getTime();
|
||||
if (ts > dayStartMs) break;
|
||||
enabled = e.eventKind === "enabled";
|
||||
}
|
||||
if (enabled) return true;
|
||||
// Walk events in [dayStart, dayEnd). If any 'enabled' event
|
||||
// appears, the day counts.
|
||||
for (const e of events) {
|
||||
const ts = new Date(e.occurredAt).getTime();
|
||||
if (ts <= dayStartMs) continue;
|
||||
if (ts >= dayEndMs) break;
|
||||
if (e.eventKind === "enabled") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rounding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Round to 2dp, half-up. */
|
||||
function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VAT logic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EU_COUNTRIES = new Set([
|
||||
"AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR",
|
||||
"DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL",
|
||||
"PL", "PT", "RO", "SK", "SI", "ES", "SE",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Determine VAT rate from billing address and the platform default.
|
||||
* See README for the legal interpretation; this implements the
|
||||
* defaults you confirmed:
|
||||
*
|
||||
* - CH or LI: platform_pricing.vat_rate_chli (default 8.10)
|
||||
* - EU + VAT number: 0% (reverse charge — B2B)
|
||||
* - EU without VAT: CH MWST (B2C consumer, we charge our rate)
|
||||
* - other: 0% (export of services)
|
||||
*/
|
||||
function vatRateForAddress(
|
||||
snapshot: InvoiceBillingSnapshot,
|
||||
platformPricing: PlatformPricing
|
||||
): { rate: number; note: string | null } {
|
||||
const country = snapshot.country?.toUpperCase().trim() ?? "";
|
||||
if (country === "CH" || country === "LI") {
|
||||
return { rate: platformPricing.vatRateChli, note: null };
|
||||
}
|
||||
if (EU_COUNTRIES.has(country)) {
|
||||
if (snapshot.vatNumber && snapshot.vatNumber.trim().length > 0) {
|
||||
return {
|
||||
rate: 0,
|
||||
note:
|
||||
"Steuerschuldnerschaft des Leistungsempfängers / Reverse charge — VAT to be accounted for by the recipient.",
|
||||
};
|
||||
}
|
||||
return { rate: platformPricing.vatRateChli, note: null };
|
||||
}
|
||||
return { rate: 0, note: "Export of services — VAT not applicable." };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Locale default
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pick a default invoice locale from the billing country. Admins
|
||||
* can override at generation time. We default to German for
|
||||
* CH/LI/AT/DE; French for FR/BE/LU; Italian for IT; English
|
||||
* otherwise.
|
||||
*/
|
||||
export function defaultLocaleForCountry(country: string): string {
|
||||
const c = (country || "").toUpperCase().trim();
|
||||
if (["CH", "LI", "AT", "DE"].includes(c)) return "de";
|
||||
if (["FR", "BE", "LU"].includes(c)) return "fr";
|
||||
if (c === "IT") return "it";
|
||||
return "en";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tenant signal collectors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sum AI usage spend for a tenant over the billing period via
|
||||
* LiteLLM. Returns the CHF total (already in CHF — LiteLLM stores
|
||||
* costs after the platform's USD→CHF conversion) and the request
|
||||
* count for the metadata.
|
||||
*
|
||||
* Tolerates missing litellmTeamId on the tenant: such tenants are
|
||||
* skipped and the warning is surfaced upstream.
|
||||
*/
|
||||
async function collectAiUsage(
|
||||
tenant: PiecedTenant,
|
||||
periodStart: string,
|
||||
periodEnd: string
|
||||
): Promise<{ spendChf: number; requestCount: number } | null> {
|
||||
const teamId = tenant.status?.litellmTeamId;
|
||||
if (!teamId) return null;
|
||||
const keyAlias = tenant.metadata.name;
|
||||
let spendChf = 0;
|
||||
let requestCount = 0;
|
||||
let page = 1;
|
||||
// 50-page cap matches the existing usage route's defensive cap.
|
||||
while (page <= 50) {
|
||||
const result = await getTeamSpendLogsV2(
|
||||
teamId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
page,
|
||||
100,
|
||||
keyAlias
|
||||
);
|
||||
const rows: any[] = result.data ?? [];
|
||||
for (const r of rows) {
|
||||
spendChf += Number(r.spend ?? 0);
|
||||
requestCount += 1;
|
||||
}
|
||||
if (page >= (result.total_pages || 1)) break;
|
||||
page++;
|
||||
}
|
||||
return { spendChf: round2(spendChf), requestCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum Threema messages (in + out) for the tenant over the period.
|
||||
* Returns null if the relay refuses or the tenant has no Threema
|
||||
* package — billing is skipped silently in that case.
|
||||
*/
|
||||
async function collectThreemaUsage(
|
||||
tenant: PiecedTenant,
|
||||
periodStart: string,
|
||||
periodEnd: string
|
||||
): Promise<{ inCount: number; outCount: number } | null> {
|
||||
const packages = tenant.spec.packages ?? [];
|
||||
if (!packages.includes("threema")) return null;
|
||||
const usage = await getThreemaUsage(
|
||||
tenant.metadata.name,
|
||||
periodStart,
|
||||
periodEnd
|
||||
).catch(() => null);
|
||||
if (!usage) return null;
|
||||
return {
|
||||
inCount: Number(usage.totals?.in ?? 0),
|
||||
outCount: Number(usage.totals?.out ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-tenant line builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function buildTenantLines(opts: {
|
||||
tenant: PiecedTenant;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
daysInMonth: number;
|
||||
platformPricing: PlatformPricing;
|
||||
skillPricing: SkillPricing[];
|
||||
warnings: string[];
|
||||
displayOrderOffset: number;
|
||||
}): Promise<Omit<InvoiceLine, "id" | "invoiceId">[]> {
|
||||
const {
|
||||
tenant,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
daysInMonth,
|
||||
platformPricing,
|
||||
skillPricing,
|
||||
warnings,
|
||||
} = opts;
|
||||
let displayOrder = opts.displayOrderOffset;
|
||||
const tenantName = tenant.metadata.name;
|
||||
const lines: Omit<InvoiceLine, "id" | "invoiceId">[] = [];
|
||||
|
||||
// Lifecycle & suspension events — required for monthly proration.
|
||||
const lifecycle = await getTenantBillingLifecycle(tenantName);
|
||||
if (!lifecycle) {
|
||||
warnings.push(
|
||||
`Tenant "${tenantName}" has no billing lifecycle row — run the Phase 1 backfill.`
|
||||
);
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Period interval in millis (extended by one day on each side as
|
||||
// buffer for events that occur at month boundaries).
|
||||
const periodStartMs = new Date(`${periodStart}T00:00:00Z`).getTime();
|
||||
const periodEndMs = new Date(`${periodEnd}T00:00:00Z`).getTime() + 86_400_000;
|
||||
|
||||
const suspensionEvents = await listSuspensionEventsForTenant(
|
||||
tenantName,
|
||||
new Date(periodStartMs - 365 * 86_400_000), // look back a year for state-at-start
|
||||
new Date(periodEndMs)
|
||||
);
|
||||
|
||||
// --- tenant_monthly (prorated, suspended days excluded) -------------------
|
||||
if (platformPricing.tenantMonthlyFeeChf > 0) {
|
||||
let billableDays = 0;
|
||||
let suspendedDays = 0;
|
||||
for (const day of iterDays(periodStart, periodEnd)) {
|
||||
if (activeDuringDay(lifecycle, suspensionEvents, day.dayStartMs, day.dayEndMs)) {
|
||||
billableDays++;
|
||||
} else {
|
||||
// Distinguish "not yet existed / deleted" from "suspended"
|
||||
// for the metadata audit trail. Cheap re-check.
|
||||
const createdMs = new Date(lifecycle.createdAt).getTime();
|
||||
const deletedMs = lifecycle.deletedAt
|
||||
? new Date(lifecycle.deletedAt).getTime()
|
||||
: Infinity;
|
||||
if (createdMs < day.dayEndMs && deletedMs > day.dayStartMs) {
|
||||
suspendedDays++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (billableDays > 0) {
|
||||
const unit = platformPricing.tenantMonthlyFeeChf / daysInMonth;
|
||||
const amount = round2(unit * billableDays);
|
||||
lines.push({
|
||||
tenantName,
|
||||
kind: "tenant_monthly",
|
||||
description: `Monthly fee for ${tenantName} (${billableDays}/${daysInMonth} days)`,
|
||||
quantity: billableDays,
|
||||
unitLabel: "days",
|
||||
unitPriceChf: round2(unit * 1e5) / 1e5,
|
||||
amountChf: amount,
|
||||
metadata: {
|
||||
billable_days: billableDays,
|
||||
suspended_days: suspendedDays,
|
||||
days_in_month: daysInMonth,
|
||||
},
|
||||
displayOrder: displayOrder++,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- tenant_setup (first invoice only) -----------------------------------
|
||||
if (platformPricing.tenantSetupFeeChf > 0) {
|
||||
const alreadyBilled = await tenantHasSetupFeeBilled(tenantName);
|
||||
if (!alreadyBilled) {
|
||||
lines.push({
|
||||
tenantName,
|
||||
kind: "tenant_setup",
|
||||
description: `Setup fee for ${tenantName}`,
|
||||
quantity: 1,
|
||||
unitLabel: null,
|
||||
unitPriceChf: platformPricing.tenantSetupFeeChf,
|
||||
amountChf: round2(platformPricing.tenantSetupFeeChf),
|
||||
metadata: null,
|
||||
displayOrder: displayOrder++,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- ai_usage --------------------------------------------------------------
|
||||
const aiUsage = await collectAiUsage(tenant, periodStart, periodEnd).catch(
|
||||
(e) => {
|
||||
warnings.push(
|
||||
`AI usage fetch failed for ${tenantName}: ${e instanceof Error ? e.message : String(e)}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
);
|
||||
if (aiUsage === null && tenant.status?.litellmTeamId) {
|
||||
// teamId exists but fetch returned null — already warned above
|
||||
} else if (aiUsage === null) {
|
||||
warnings.push(
|
||||
`Tenant ${tenantName} has no LiteLLM team yet — AI usage skipped.`
|
||||
);
|
||||
} else if (aiUsage.spendChf > 0) {
|
||||
lines.push({
|
||||
tenantName,
|
||||
kind: "ai_usage",
|
||||
description: `AI inference usage (${aiUsage.requestCount} requests)`,
|
||||
quantity: 1,
|
||||
unitLabel: null,
|
||||
unitPriceChf: aiUsage.spendChf,
|
||||
amountChf: aiUsage.spendChf,
|
||||
metadata: {
|
||||
litellm_key_alias: tenantName,
|
||||
spend_chf: aiUsage.spendChf,
|
||||
requests: aiUsage.requestCount,
|
||||
},
|
||||
displayOrder: displayOrder++,
|
||||
});
|
||||
}
|
||||
|
||||
// --- threema_messages -----------------------------------------------------
|
||||
if (platformPricing.threemaMessageChf > 0) {
|
||||
const threema = await collectThreemaUsage(tenant, periodStart, periodEnd);
|
||||
if (threema && (threema.inCount + threema.outCount) > 0) {
|
||||
const total = threema.inCount + threema.outCount;
|
||||
lines.push({
|
||||
tenantName,
|
||||
kind: "threema_messages",
|
||||
description: `Threema messages (${threema.inCount} in + ${threema.outCount} out)`,
|
||||
quantity: total,
|
||||
unitLabel: "msgs",
|
||||
unitPriceChf: platformPricing.threemaMessageChf,
|
||||
amountChf: round2(total * platformPricing.threemaMessageChf),
|
||||
metadata: {
|
||||
in_count: threema.inCount,
|
||||
out_count: threema.outCount,
|
||||
total_count: total,
|
||||
},
|
||||
displayOrder: displayOrder++,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- skill_usage ----------------------------------------------------------
|
||||
// For each priced skill, count distinct UTC days the skill was
|
||||
// enabled during the period.
|
||||
if (skillPricing.length > 0) {
|
||||
// Fetch all skill events for the tenant within the period plus
|
||||
// a long lookback so we can determine state-at-period-start.
|
||||
// The state-at-day-start logic in skillActiveDuringDay walks
|
||||
// these events forward.
|
||||
const allEvents = await listSkillEventsForTenant(
|
||||
tenantName,
|
||||
new Date(0),
|
||||
new Date(periodEndMs)
|
||||
);
|
||||
for (const sp of skillPricing) {
|
||||
const skillEvents = allEvents.filter((e) => e.skillId === sp.skillId);
|
||||
// Skip cheaply if no events ever existed for this skill on
|
||||
// this tenant.
|
||||
if (skillEvents.length === 0) continue;
|
||||
// Initial state assumption: false. The very first event is
|
||||
// always 'enabled' (we only record toggles, and the implicit
|
||||
// pre-toggle state for a never-seen skill is 'disabled').
|
||||
let billableDays = 0;
|
||||
for (const day of iterDays(periodStart, periodEnd)) {
|
||||
if (skillActiveDuringDay(skillEvents, false, day.dayStartMs, day.dayEndMs)) {
|
||||
billableDays++;
|
||||
}
|
||||
}
|
||||
if (billableDays > 0) {
|
||||
lines.push({
|
||||
tenantName,
|
||||
kind: "skill_usage",
|
||||
description: `Skill: ${sp.skillId} (${billableDays} day${billableDays === 1 ? "" : "s"})`,
|
||||
quantity: billableDays,
|
||||
unitLabel: "days",
|
||||
unitPriceChf: sp.dailyPriceChf,
|
||||
amountChf: round2(billableDays * sp.dailyPriceChf),
|
||||
metadata: {
|
||||
skill_id: sp.skillId,
|
||||
billable_days: billableDays,
|
||||
event_count: skillEvents.length,
|
||||
},
|
||||
displayOrder: displayOrder++,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function computeInvoiceDraft(opts: {
|
||||
zitadelOrgId: string;
|
||||
year: number;
|
||||
month: number;
|
||||
locale?: string;
|
||||
paymentMethod?: InvoicePaymentMethod;
|
||||
}): Promise<InvoiceDraft> {
|
||||
const { zitadelOrgId, year, month } = opts;
|
||||
const { periodStart, periodEnd, daysInMonth } = monthBounds(year, month);
|
||||
const warnings: string[] = [];
|
||||
|
||||
// 1. Billing address. Required — without it we can't produce a
|
||||
// valid invoice.
|
||||
const orgBilling = await getOrgBilling(zitadelOrgId);
|
||||
if (!orgBilling) {
|
||||
throw new Error(
|
||||
`Org ${zitadelOrgId} has no billing address on file. ` +
|
||||
`The customer must complete /settings/billing before an invoice can be issued.`
|
||||
);
|
||||
}
|
||||
const snapshot: InvoiceBillingSnapshot = {
|
||||
companyName: orgBilling.companyName,
|
||||
streetAddress: orgBilling.streetAddress,
|
||||
postalCode: orgBilling.postalCode,
|
||||
city: orgBilling.city,
|
||||
country: orgBilling.country,
|
||||
vatNumber: orgBilling.vatNumber ?? null,
|
||||
billingEmail: orgBilling.billingEmail,
|
||||
notes: orgBilling.notes ?? null,
|
||||
};
|
||||
|
||||
// 2. Platform pricing + skill prices.
|
||||
const platformPricing = await getPlatformPricing();
|
||||
const skillPricing = await listSkillPricing();
|
||||
|
||||
// 3. Find all tenants for this org. We list from K8s (source of
|
||||
// truth) and filter by the zitadel-org-id label.
|
||||
const allTenants = await listTenants();
|
||||
const orgTenants = allTenants.filter(
|
||||
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === zitadelOrgId
|
||||
);
|
||||
if (orgTenants.length === 0) {
|
||||
warnings.push(`No tenants found for org ${zitadelOrgId}.`);
|
||||
}
|
||||
|
||||
// 4. Build lines, grouped per tenant (display order preserved).
|
||||
const lines: Omit<InvoiceLine, "id" | "invoiceId">[] = [];
|
||||
let nextDisplayOrder = 0;
|
||||
// Sort tenants by name for stable line ordering across regenerations.
|
||||
orgTenants.sort((a, b) => a.metadata.name.localeCompare(b.metadata.name));
|
||||
for (const tenant of orgTenants) {
|
||||
const tenantLines = await buildTenantLines({
|
||||
tenant,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
daysInMonth,
|
||||
platformPricing,
|
||||
skillPricing,
|
||||
warnings,
|
||||
displayOrderOffset: nextDisplayOrder,
|
||||
});
|
||||
lines.push(...tenantLines);
|
||||
nextDisplayOrder += tenantLines.length;
|
||||
}
|
||||
|
||||
// 5. Subtotal & VAT.
|
||||
const subtotal = round2(lines.reduce((acc, l) => acc + l.amountChf, 0));
|
||||
const vat = vatRateForAddress(snapshot, platformPricing);
|
||||
const vatAmount = round2((subtotal * vat.rate) / 100);
|
||||
const total = round2(subtotal + vatAmount);
|
||||
if (vat.note) warnings.push(vat.note);
|
||||
|
||||
// 6. Payment method: prefer pay-by-invoice if the admin enabled
|
||||
// it for the org, otherwise default to invoice. Card payment
|
||||
// is wired in Phase 4 — for Phase 2 every invoice is 'invoice'.
|
||||
const orgConfig = await getOrgBillingConfig(zitadelOrgId);
|
||||
const paymentMethod: InvoicePaymentMethod =
|
||||
opts.paymentMethod ?? (orgConfig.payByInvoice ? "invoice" : "invoice");
|
||||
|
||||
// 7. Locale resolution
|
||||
const locale = opts.locale ?? defaultLocaleForCountry(snapshot.country);
|
||||
|
||||
return {
|
||||
zitadelOrgId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
dueAt: dueDate(periodEnd, 30),
|
||||
locale,
|
||||
paymentMethod,
|
||||
billingSnapshot: snapshot,
|
||||
lines,
|
||||
subtotalChf: subtotal,
|
||||
vatRate: vat.rate,
|
||||
vatAmountChf: vatAmount,
|
||||
totalChf: total,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute + render + persist in one step. If dryRun is true, the
|
||||
* draft is returned without persisting and no PDF is rendered (the
|
||||
* preview UI hits this).
|
||||
*/
|
||||
export async function generateInvoice(opts: {
|
||||
zitadelOrgId: string;
|
||||
year: number;
|
||||
month: number;
|
||||
locale?: string;
|
||||
dryRun?: boolean;
|
||||
}): Promise<{ draft: InvoiceDraft; invoice: Invoice | null }> {
|
||||
const draft = await computeInvoiceDraft(opts);
|
||||
if (opts.dryRun) {
|
||||
return { draft, invoice: null };
|
||||
}
|
||||
// Render the PDF first — if it fails, we never touch the DB.
|
||||
// The PDF render needs the invoice number, which is allocated
|
||||
// inside createInvoice's transaction. To keep the PDF rendering
|
||||
// outside the DB transaction (it can be slow), we render with a
|
||||
// placeholder number, allocate the real number inside the tx,
|
||||
// then re-render? No — instead we generate a temporary draft
|
||||
// number for the PDF and accept that the displayed number on
|
||||
// the PDF matches what we'll persist (because the allocator is
|
||||
// serialized).
|
||||
//
|
||||
// Practical approach: render the PDF inside createInvoice's tx,
|
||||
// immediately after allocation. This is fine because react-pdf
|
||||
// is reasonably fast (~50–200 ms for a typical invoice) and
|
||||
// happens once per invoice.
|
||||
//
|
||||
// To avoid restructuring createInvoice, we do this in two
|
||||
// passes: (1) reserve a number via createInvoice with a
|
||||
// placeholder PDF; (2) render with the real number; (3) UPDATE
|
||||
// pdf_data. The trade-off is two write trips but keeps the code
|
||||
// shape simple. We accept it.
|
||||
//
|
||||
// Reasoning behind two-pass: if PDF render is moved inside the
|
||||
// tx and fails (font missing, etc.), the allocated counter rolls
|
||||
// back — good. But it also means the connection is held during
|
||||
// render. At v1 scale that's fine; the choice is reversible.
|
||||
|
||||
// Pass 1: allocate number + persist with empty PDF.
|
||||
const placeholder = await createInvoice(draft, null, null);
|
||||
try {
|
||||
const pdfBuffer = await renderInvoicePdf(
|
||||
placeholder,
|
||||
draft.lines.map((l, i) => ({
|
||||
...l,
|
||||
id: `tmp-${i}`,
|
||||
invoiceId: placeholder.id,
|
||||
}))
|
||||
);
|
||||
const filename = `${placeholder.invoiceNumber}.pdf`;
|
||||
// Pass 2: store the PDF bytes.
|
||||
await updateInvoicePdf(placeholder.id, pdfBuffer, filename);
|
||||
const finalInvoice = await getInvoiceById(placeholder.id);
|
||||
return { draft, invoice: finalInvoice ?? placeholder };
|
||||
} catch (e) {
|
||||
// Render failed — leave the persisted row in place so admin can
|
||||
// inspect it, but surface the error.
|
||||
throw new Error(
|
||||
`Invoice ${placeholder.invoiceNumber} persisted but PDF rendering failed: ${
|
||||
e instanceof Error ? e.message : String(e)
|
||||
}. Use the admin "delete invoice" tool to clean up if needed.`
|
||||
);
|
||||
}
|
||||
}
|
||||
427
src/lib/db.ts
427
src/lib/db.ts
@@ -470,6 +470,12 @@ const MIGRATION_SQL = `
|
||||
paid_method_detail TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
-- Phase 2 addition: PDF locale, frozen at issue time so re-rendering
|
||||
-- an old invoice produces an identical document. Defaults to 'de'
|
||||
-- since most pilot customers are Swiss B2B; the generator UI lets
|
||||
-- admin override at issue time.
|
||||
ALTER TABLE invoices
|
||||
ADD COLUMN IF NOT EXISTS locale TEXT NOT NULL DEFAULT 'de';
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_org
|
||||
ON invoices(zitadel_org_id, issued_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_status
|
||||
@@ -2124,3 +2130,424 @@ export async function backfillTenantBillingLifecycle(tenants: {
|
||||
}
|
||||
return { lifecycleInserted, eventsInserted, suspensionEventsInserted };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Billing — Phase 2: invoice persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Invoice creation is intentionally a single transaction: allocate
|
||||
// number, INSERT invoice, INSERT lines, store PDF — all-or-nothing.
|
||||
// The Postgres invoice_number_counters row lock serializes
|
||||
// concurrent allocators for the same year, producing gapless
|
||||
// numbering even under bursts.
|
||||
|
||||
import type {
|
||||
Invoice,
|
||||
InvoiceBillingSnapshot,
|
||||
InvoiceDetail,
|
||||
InvoiceDraft,
|
||||
InvoiceLine,
|
||||
InvoiceStatus,
|
||||
} from "@/types";
|
||||
|
||||
function rowToInvoice(row: any): Invoice {
|
||||
return {
|
||||
id: row.id,
|
||||
invoiceNumber: row.invoice_number,
|
||||
zitadelOrgId: row.zitadel_org_id,
|
||||
periodStart: typeof row.period_start === "string"
|
||||
? row.period_start
|
||||
: row.period_start.toISOString().split("T")[0],
|
||||
periodEnd: typeof row.period_end === "string"
|
||||
? row.period_end
|
||||
: row.period_end.toISOString().split("T")[0],
|
||||
issuedAt: row.issued_at?.toISOString?.() ?? row.issued_at,
|
||||
dueAt: typeof row.due_at === "string"
|
||||
? row.due_at
|
||||
: row.due_at.toISOString().split("T")[0],
|
||||
subtotalChf: Number(row.subtotal_chf),
|
||||
vatRate: Number(row.vat_rate),
|
||||
vatAmountChf: Number(row.vat_amount_chf),
|
||||
totalChf: Number(row.total_chf),
|
||||
status: row.status as InvoiceStatus,
|
||||
locale: row.locale ?? "de",
|
||||
paymentMethod: row.payment_method,
|
||||
billingSnapshot: row.billing_snapshot as InvoiceBillingSnapshot,
|
||||
stripePaymentIntentId: row.stripe_payment_intent_id ?? null,
|
||||
pdfFilename: row.pdf_filename ?? null,
|
||||
hasPdf: row.has_pdf ?? row.pdf_data !== null,
|
||||
adminNotes: row.admin_notes ?? null,
|
||||
paidAt: row.paid_at?.toISOString?.() ?? row.paid_at ?? null,
|
||||
paidBy: row.paid_by ?? null,
|
||||
paidMethodDetail: row.paid_method_detail ?? null,
|
||||
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function rowToInvoiceLine(row: any): InvoiceLine {
|
||||
return {
|
||||
id: row.id,
|
||||
invoiceId: row.invoice_id,
|
||||
tenantName: row.tenant_name ?? null,
|
||||
kind: row.kind,
|
||||
description: row.description,
|
||||
quantity: Number(row.quantity),
|
||||
unitLabel: row.unit_label ?? null,
|
||||
unitPriceChf: Number(row.unit_price_chf),
|
||||
amountChf: Number(row.amount_chf),
|
||||
metadata: row.metadata ?? null,
|
||||
displayOrder: row.display_order,
|
||||
};
|
||||
}
|
||||
|
||||
// Standard SELECT projection that includes a cheap NOT-NULL probe of
|
||||
// pdf_data instead of pulling the bytes themselves. Crucial for list
|
||||
// endpoints — a few KB per row across hundreds of invoices is wasted
|
||||
// network and memory.
|
||||
const INVOICE_LIST_COLUMNS = `
|
||||
id, invoice_number, zitadel_org_id, period_start, period_end,
|
||||
issued_at, due_at, subtotal_chf, vat_rate, vat_amount_chf,
|
||||
total_chf, status, locale, payment_method, billing_snapshot,
|
||||
stripe_payment_intent_id, pdf_filename, admin_notes, paid_at,
|
||||
paid_by, paid_method_detail, created_at,
|
||||
(pdf_data IS NOT NULL) AS has_pdf
|
||||
`;
|
||||
|
||||
/**
|
||||
* Persist a fully-computed invoice draft with its lines and PDF in
|
||||
* a single transaction. Allocates the year-scoped invoice number
|
||||
* inside the same transaction so a rollback restores the counter
|
||||
* (gapless guarantee).
|
||||
*
|
||||
* The caller is responsible for upstream validation:
|
||||
* - the (org, period) uniqueness (the unique index will reject
|
||||
* duplicates, but we return a clear error message rather than
|
||||
* leaking the constraint name)
|
||||
* - the draft's lines/totals are consistent (compute pipeline
|
||||
* ensures this)
|
||||
*
|
||||
* `pdfBuffer` is the rendered PDF bytes; pass null if PDF is
|
||||
* generated separately or stored in a side channel. For Phase 2 we
|
||||
* always render synchronously and pass the buffer here.
|
||||
*/
|
||||
export async function createInvoice(
|
||||
draft: InvoiceDraft,
|
||||
pdfBuffer: Buffer | null,
|
||||
pdfFilename: string | null
|
||||
): Promise<Invoice> {
|
||||
await ensureSchema();
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// Allocate number for the year of period_start. Locking the
|
||||
// counter row prevents concurrent allocators from racing.
|
||||
const year = parseInt(draft.periodStart.slice(0, 4), 10);
|
||||
const counterResult = await client.query(
|
||||
`INSERT INTO invoice_number_counters (year, last_number)
|
||||
VALUES ($1, 1)
|
||||
ON CONFLICT (year) DO UPDATE SET
|
||||
last_number = invoice_number_counters.last_number + 1
|
||||
RETURNING last_number`,
|
||||
[year]
|
||||
);
|
||||
const seq = counterResult.rows[0].last_number;
|
||||
const invoiceNumber = `${year}-${String(seq).padStart(5, "0")}`;
|
||||
|
||||
// Insert invoice row. PDF goes inline as bytea for v1; we can
|
||||
// migrate to MinIO/S3 later if storage gets noisy.
|
||||
const inv = await client.query(
|
||||
`INSERT INTO invoices (
|
||||
invoice_number, zitadel_org_id, period_start, period_end,
|
||||
issued_at, due_at, subtotal_chf, vat_rate, vat_amount_chf,
|
||||
total_chf, status, locale, payment_method, billing_snapshot,
|
||||
pdf_data, pdf_filename
|
||||
) VALUES (
|
||||
$1, $2, $3::date, $4::date, now(), $5::date, $6, $7, $8, $9,
|
||||
'open', $10, $11, $12::jsonb, $13, $14
|
||||
)
|
||||
RETURNING ${INVOICE_LIST_COLUMNS}`,
|
||||
[
|
||||
invoiceNumber,
|
||||
draft.zitadelOrgId,
|
||||
draft.periodStart,
|
||||
draft.periodEnd,
|
||||
draft.dueAt,
|
||||
draft.subtotalChf,
|
||||
draft.vatRate,
|
||||
draft.vatAmountChf,
|
||||
draft.totalChf,
|
||||
draft.locale,
|
||||
draft.paymentMethod,
|
||||
JSON.stringify(draft.billingSnapshot),
|
||||
pdfBuffer,
|
||||
pdfFilename,
|
||||
]
|
||||
);
|
||||
const invoiceId = inv.rows[0].id;
|
||||
|
||||
// Insert lines in batch — one INSERT statement is significantly
|
||||
// faster than per-line round-trips, which matters when an invoice
|
||||
// accumulates many ai_usage / skill_usage lines.
|
||||
if (draft.lines.length > 0) {
|
||||
const placeholders: string[] = [];
|
||||
const values: any[] = [];
|
||||
let idx = 1;
|
||||
for (const line of draft.lines) {
|
||||
placeholders.push(
|
||||
`($${idx++}, $${idx++}, $${idx++}, $${idx++}, $${idx++}, $${idx++}, $${idx++}, $${idx++}, $${idx++}::jsonb, $${idx++})`
|
||||
);
|
||||
values.push(
|
||||
invoiceId,
|
||||
line.tenantName,
|
||||
line.kind,
|
||||
line.description,
|
||||
line.quantity,
|
||||
line.unitLabel,
|
||||
line.unitPriceChf,
|
||||
line.amountChf,
|
||||
line.metadata ? JSON.stringify(line.metadata) : null,
|
||||
line.displayOrder
|
||||
);
|
||||
}
|
||||
await client.query(
|
||||
`INSERT INTO invoice_lines (
|
||||
invoice_id, tenant_name, kind, description, quantity,
|
||||
unit_label, unit_price_chf, amount_chf, metadata, display_order
|
||||
) VALUES ${placeholders.join(", ")}`,
|
||||
values
|
||||
);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
return rowToInvoice(inv.rows[0]);
|
||||
} catch (e: any) {
|
||||
await client.query("ROLLBACK").catch(() => undefined);
|
||||
// Translate the uniqueness violation into a user-friendly error.
|
||||
// 23505 = unique_violation in Postgres.
|
||||
if (e?.code === "23505" && /uniq_invoices_org_period/.test(e?.constraint ?? "")) {
|
||||
const month = draft.periodStart.slice(0, 7);
|
||||
throw new Error(
|
||||
`An invoice already exists for this org and billing period (${month}). ` +
|
||||
`Delete the existing invoice first if you want to regenerate.`
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getInvoiceById(id: string): Promise<Invoice | null> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT ${INVOICE_LIST_COLUMNS} FROM invoices WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
return result.rows.length > 0 ? rowToInvoice(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
export async function getInvoiceDetail(
|
||||
id: string
|
||||
): Promise<InvoiceDetail | null> {
|
||||
const invoice = await getInvoiceById(id);
|
||||
if (!invoice) return null;
|
||||
const lines = await getPool().query(
|
||||
`SELECT * FROM invoice_lines WHERE invoice_id = $1
|
||||
ORDER BY display_order, id`,
|
||||
[id]
|
||||
);
|
||||
return { invoice, lines: lines.rows.map(rowToInvoiceLine) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the PDF bytes for an invoice. Returns null if no PDF was
|
||||
* stored (shouldn't happen in v1; defensive against partial state).
|
||||
*/
|
||||
export async function getInvoicePdf(
|
||||
id: string
|
||||
): Promise<{ data: Buffer; filename: string } | null> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
"SELECT pdf_data, pdf_filename, invoice_number FROM invoices WHERE id = $1",
|
||||
[id]
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
const row = result.rows[0];
|
||||
if (!row.pdf_data) return null;
|
||||
return {
|
||||
data: row.pdf_data,
|
||||
filename: row.pdf_filename ?? `${row.invoice_number}.pdf`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List invoices, optionally filtered. Used by the admin invoice
|
||||
* list page and (Phase 3) the customer-facing /billing page.
|
||||
*
|
||||
* The customer-facing call site MUST pass `zitadelOrgId` to scope
|
||||
* results — this helper does not enforce that itself.
|
||||
*/
|
||||
export async function listInvoices(filters: {
|
||||
zitadelOrgId?: string;
|
||||
status?: InvoiceStatus;
|
||||
/** Inclusive YYYY-MM filter on period_start. */
|
||||
periodMonth?: string;
|
||||
limit?: number;
|
||||
} = {}): Promise<Invoice[]> {
|
||||
await ensureSchema();
|
||||
const where: string[] = [];
|
||||
const values: any[] = [];
|
||||
let idx = 1;
|
||||
if (filters.zitadelOrgId) {
|
||||
where.push(`zitadel_org_id = $${idx++}`);
|
||||
values.push(filters.zitadelOrgId);
|
||||
}
|
||||
if (filters.status) {
|
||||
where.push(`status = $${idx++}`);
|
||||
values.push(filters.status);
|
||||
}
|
||||
if (filters.periodMonth) {
|
||||
where.push(`to_char(period_start, 'YYYY-MM') = $${idx++}`);
|
||||
values.push(filters.periodMonth);
|
||||
}
|
||||
const limit = filters.limit ?? 200;
|
||||
const sql =
|
||||
`SELECT ${INVOICE_LIST_COLUMNS} FROM invoices ` +
|
||||
(where.length > 0 ? `WHERE ${where.join(" AND ")} ` : "") +
|
||||
`ORDER BY issued_at DESC LIMIT $${idx}`;
|
||||
values.push(limit);
|
||||
const result = await getPool().query(sql, values);
|
||||
return result.rows.map(rowToInvoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweep open invoices past their due date to `overdue` status.
|
||||
* Cheap idempotent UPDATE; safe to call on every admin list view
|
||||
* to keep status fresh without a dedicated cron.
|
||||
*/
|
||||
export async function syncOverdueInvoices(): Promise<number> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`UPDATE invoices
|
||||
SET status = 'overdue'
|
||||
WHERE status = 'open'
|
||||
AND due_at < CURRENT_DATE`
|
||||
);
|
||||
return result.rowCount ?? 0;
|
||||
}
|
||||
|
||||
export async function markInvoicePaid(
|
||||
id: string,
|
||||
opts: { paidBy: string; paidMethodDetail?: string | null; paidAt?: Date }
|
||||
): Promise<Invoice | null> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`UPDATE invoices
|
||||
SET status = 'paid',
|
||||
paid_at = COALESCE($2::timestamptz, now()),
|
||||
paid_by = $3,
|
||||
paid_method_detail = $4
|
||||
WHERE id = $1
|
||||
AND status IN ('open', 'overdue')
|
||||
RETURNING ${INVOICE_LIST_COLUMNS}`,
|
||||
[
|
||||
id,
|
||||
opts.paidAt ?? null,
|
||||
opts.paidBy,
|
||||
opts.paidMethodDetail ?? null,
|
||||
]
|
||||
);
|
||||
return result.rows.length > 0 ? rowToInvoice(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard delete an invoice and its lines (CASCADE).
|
||||
*
|
||||
* This is the testing tool — Swiss bookkeeping requires immutable
|
||||
* invoices in production, but during pilot/testing we need to
|
||||
* iterate. The gap left in the invoice number sequence is
|
||||
* intentional and documented; no attempt to "recycle" numbers.
|
||||
*
|
||||
* Reminders (and their PDFs) cascade-delete via the FK.
|
||||
*/
|
||||
export async function deleteInvoice(id: string): Promise<boolean> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
"DELETE FROM invoices WHERE id = $1 RETURNING id",
|
||||
[id]
|
||||
);
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Has this tenant ever been billed a setup fee? Drives the
|
||||
* compute pipeline's "include setup line on first invoice"
|
||||
* decision. Looks at invoice_lines directly so it survives org
|
||||
* billing config edits.
|
||||
*/
|
||||
export async function tenantHasSetupFeeBilled(
|
||||
tenantName: string
|
||||
): Promise<boolean> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT 1 FROM invoice_lines
|
||||
WHERE tenant_name = $1 AND kind = 'tenant_setup'
|
||||
LIMIT 1`,
|
||||
[tenantName]
|
||||
);
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate open balance per org for the admin overview. Returns
|
||||
* orgs with at least one open or overdue invoice; orgs in good
|
||||
* standing don't appear.
|
||||
*/
|
||||
export async function getOrgOpenBalances(): Promise<{
|
||||
zitadelOrgId: string;
|
||||
openCount: number;
|
||||
overdueCount: number;
|
||||
totalOpenChf: number;
|
||||
}[]> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT
|
||||
zitadel_org_id,
|
||||
COUNT(*) FILTER (WHERE status = 'open') AS open_count,
|
||||
COUNT(*) FILTER (WHERE status = 'overdue') AS overdue_count,
|
||||
SUM(total_chf) FILTER (WHERE status IN ('open', 'overdue')) AS total_open
|
||||
FROM invoices
|
||||
WHERE status IN ('open', 'overdue')
|
||||
GROUP BY zitadel_org_id
|
||||
ORDER BY total_open DESC`
|
||||
);
|
||||
return result.rows.map((r) => ({
|
||||
zitadelOrgId: r.zitadel_org_id,
|
||||
openCount: Number(r.open_count),
|
||||
overdueCount: Number(r.overdue_count),
|
||||
totalOpenChf: Number(r.total_open),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the stored PDF for an invoice. Used by the two-pass
|
||||
* compute pipeline: insert invoice with empty PDF → render PDF with
|
||||
* the allocated invoice number → write bytes back.
|
||||
*
|
||||
* Could be merged into createInvoice via a render callback in a
|
||||
* future cleanup, but two passes are simpler and the extra UPDATE
|
||||
* is cheap.
|
||||
*/
|
||||
export async function updateInvoicePdf(
|
||||
invoiceId: string,
|
||||
pdfBuffer: Buffer,
|
||||
filename: string
|
||||
): Promise<void> {
|
||||
await ensureSchema();
|
||||
await getPool().query(
|
||||
"UPDATE invoices SET pdf_data = $2, pdf_filename = $3 WHERE id = $1",
|
||||
[invoiceId, pdfBuffer, filename]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -384,7 +384,8 @@
|
||||
"spendChf": "Kosten (CHF)",
|
||||
"resumeRequestBadge": "Wieder",
|
||||
"resumeRequestTooltip": "Reaktivierungsanfrage für einen bestehenden Tenant. Bei Genehmigung wird der Tenant wieder aktiviert; keine Provisionierung läuft.",
|
||||
"openclawTool": "OpenClaw-Versionen"
|
||||
"openclawTool": "OpenClaw-Versionen",
|
||||
"billingTool": "Abrechnung →"
|
||||
},
|
||||
"channelUsers": {
|
||||
"title": "Autorisierte Benutzer",
|
||||
@@ -553,5 +554,105 @@
|
||||
"defaultPrefix": "Standard:",
|
||||
"saveOverride": "Override speichern",
|
||||
"clearOverride": "Override entfernen"
|
||||
},
|
||||
"adminBilling": {
|
||||
"title": "Abrechnungsverwaltung",
|
||||
"subtitle": "Plattform-Preise verwalten, Rechnungen generieren und den Rechnungsstatus aller Organisationen prüfen.",
|
||||
"backToAdmin": "Zurück zur Verwaltung",
|
||||
"backToBilling": "Zurück zur Abrechnung",
|
||||
"backToInvoices": "Zurück zu den Rechnungen",
|
||||
"totalOpenBalance": "Offener Saldo gesamt",
|
||||
"orgsWithBalance": "Organisationen mit Saldo",
|
||||
"overdueInvoices": "Überfällige Rechnungen",
|
||||
"pricingTitle": "Preise",
|
||||
"pricingDesc": "Plattform- & Skill-Preise, MWST-Satz.",
|
||||
"pricingPageDesc": "Plattformweite Preise und Skill-Tagespreise bearbeiten.",
|
||||
"generateTitle": "Rechnung erstellen",
|
||||
"generateDesc": "Rechnung für eine Organisation und einen Monat berechnen und ausstellen.",
|
||||
"generatePageDesc": "Organisation, Periode und Sprache wählen. Die Vorschau zeigt die berechneten Positionen; mit Bestätigen wird die Rechnung ausgestellt und das PDF erzeugt.",
|
||||
"invoicesTitle": "Rechnungen",
|
||||
"invoicesDesc": "Alle Rechnungen anzeigen, als bezahlt markieren, PDFs herunterladen.",
|
||||
"invoicesPageDesc": "Alle von der Plattform ausgestellten Rechnungen. Mit dem Statusfilter offene oder überfällige Positionen einsehen.",
|
||||
"balancesTitle": "Organisationen mit offenem Saldo",
|
||||
"orgIdCol": "Zitadel-Org-ID",
|
||||
"openCountCol": "Offen",
|
||||
"overdueCountCol": "Überfällig",
|
||||
"totalOpenCol": "Gesamt offen",
|
||||
"platformPricingTitle": "Plattform-Preise",
|
||||
"monthlyFeeLabel": "Monatliche Tenant-Gebühr",
|
||||
"setupFeeLabel": "Einrichtungsgebühr Tenant",
|
||||
"threemaMessageLabel": "Threema pro Nachricht",
|
||||
"vatRateLabel": "MWST-Satz (CH/LI)",
|
||||
"save": "Speichern",
|
||||
"saving": "Speichere…",
|
||||
"savedOk": "Gespeichert",
|
||||
"skillPricingTitle": "Skill-Preise",
|
||||
"skillPricingDesc": "Tagespreis pro Skill. Ein zu beliebigem Zeitpunkt an einem UTC-Tag aktivierter Skill zählt als ein abrechenbarer Tag.",
|
||||
"skillCol": "Skill",
|
||||
"dailyPriceCol": "Tagespreis",
|
||||
"actionsCol": "",
|
||||
"remove": "Entfernen",
|
||||
"noSkillsPriced": "Noch keine Skills bepreist.",
|
||||
"addSkillLabel": "Skill hinzufügen",
|
||||
"dailyPriceLabel": "Tagespreis",
|
||||
"add": "Hinzufügen",
|
||||
"confirmDeleteSkillPrice": "Preis für {skill} entfernen?",
|
||||
"clickToEdit": "Zum Bearbeiten klicken",
|
||||
"generateFormTitle": "Rechnung erstellen",
|
||||
"noOrgsToGenerate": "Keine Organisationen mit Tenants gefunden.",
|
||||
"orgLabel": "Organisation",
|
||||
"noBillingAddrTag": "keine Rechnungsadresse",
|
||||
"noBillingAddrWarning": "Diese Organisation hat keine Rechnungsadresse hinterlegt. Der Kunde muss /settings/billing ausfüllen, bevor eine Rechnung ausgestellt werden kann.",
|
||||
"tenantsLabel": "Tenants",
|
||||
"yearLabel": "Jahr",
|
||||
"monthLabel": "Monat",
|
||||
"localeLabel": "PDF-Sprache",
|
||||
"localeAuto": "Automatisch",
|
||||
"previewBtn": "Vorschau",
|
||||
"commitBtn": "Bestätigen & ausstellen",
|
||||
"computing": "Berechne…",
|
||||
"confirmGenerate": "Diese Rechnung ausstellen? Es wird eine Rechnungsnummer vergeben und das PDF erzeugt.",
|
||||
"previewTitle": "Entwurfsvorschau",
|
||||
"warningsTitle": "Hinweise",
|
||||
"noLinesGenerated": "Keine abrechenbaren Positionen für diese Periode.",
|
||||
"descCol": "Beschreibung",
|
||||
"qtyCol": "Menge",
|
||||
"unitPriceCol": "Einzelpreis",
|
||||
"amountCol": "Betrag (CHF)",
|
||||
"subtotal": "Zwischensumme",
|
||||
"vat": "MWST",
|
||||
"total": "Total",
|
||||
"statusFilterLabel": "Status",
|
||||
"allStatuses": "Alle",
|
||||
"monthFilterLabel": "Periode",
|
||||
"clearFilter": "Zurücksetzen",
|
||||
"loading": "Lade…",
|
||||
"noInvoicesFound": "Keine Rechnungen entsprechen den aktuellen Filtern.",
|
||||
"invoiceNumberCol": "Nummer",
|
||||
"orgCol": "Organisation",
|
||||
"periodCol": "Periode",
|
||||
"statusCol": "Status",
|
||||
"totalCol": "Total",
|
||||
"dueCol": "Fällig",
|
||||
"status_draft": "Entwurf",
|
||||
"status_open": "Offen",
|
||||
"status_paid": "Bezahlt",
|
||||
"status_overdue": "Überfällig",
|
||||
"status_void": "Storniert",
|
||||
"status_uncollectible": "Uneinbringlich",
|
||||
"dueOnLabel": "Fällig",
|
||||
"totalLabel": "Total",
|
||||
"downloadPdfBtn": "PDF herunterladen",
|
||||
"markPaidBtn": "Als bezahlt markieren",
|
||||
"paidNotePlaceholder": "Optionale Notiz (z. B. Bankreferenz, Eingangsdatum)",
|
||||
"confirm": "Bestätigen",
|
||||
"cancel": "Abbrechen",
|
||||
"deleteBtn": "Löschen",
|
||||
"deleting": "Lösche…",
|
||||
"deleteHint": "Rechnung hart löschen (Test-Tool). Die Nummer bleibt vergeben.",
|
||||
"confirmDeleteInvoice": "Rechnung {num} löschen? Dies ist eine harte Löschung — die Rechnungsnummer bleibt verbraucht.",
|
||||
"paidOnLabel": "Bezahlt am",
|
||||
"lineItemsTitle": "Positionen",
|
||||
"billToSnapshotTitle": "Rechnungsempfänger"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,7 +384,8 @@
|
||||
"spendChf": "Spend (CHF)",
|
||||
"resumeRequestBadge": "Resume",
|
||||
"resumeRequestTooltip": "Reactivation request for an existing tenant. Approving will un-suspend the tenant; no provisioning runs.",
|
||||
"openclawTool": "OpenClaw versions"
|
||||
"openclawTool": "OpenClaw versions",
|
||||
"billingTool": "Billing →"
|
||||
},
|
||||
"channelUsers": {
|
||||
"title": "Authorized Users",
|
||||
@@ -553,5 +554,105 @@
|
||||
"defaultPrefix": "Default:",
|
||||
"saveOverride": "Save override",
|
||||
"clearOverride": "Clear override"
|
||||
},
|
||||
"adminBilling": {
|
||||
"title": "Billing administration",
|
||||
"subtitle": "Manage platform pricing, generate invoices, and review billing status across all organizations.",
|
||||
"backToAdmin": "Back to Admin",
|
||||
"backToBilling": "Back to Billing",
|
||||
"backToInvoices": "Back to Invoices",
|
||||
"totalOpenBalance": "Total open balance",
|
||||
"orgsWithBalance": "Orgs with balance",
|
||||
"overdueInvoices": "Overdue invoices",
|
||||
"pricingTitle": "Pricing",
|
||||
"pricingDesc": "Platform & skill prices, VAT rate.",
|
||||
"pricingPageDesc": "Edit platform-wide pricing and per-skill daily rates.",
|
||||
"generateTitle": "Generate invoice",
|
||||
"generateDesc": "Compute and issue an invoice for a given org & month.",
|
||||
"generatePageDesc": "Pick an org, period and locale. Preview shows the computed lines; commit issues the invoice and renders the PDF.",
|
||||
"invoicesTitle": "Invoices",
|
||||
"invoicesDesc": "Browse all issued invoices, mark paid, download PDFs.",
|
||||
"invoicesPageDesc": "All invoices issued by the platform. Use the status filter to focus on open or overdue items.",
|
||||
"balancesTitle": "Orgs with open balance",
|
||||
"orgIdCol": "Zitadel org ID",
|
||||
"openCountCol": "Open",
|
||||
"overdueCountCol": "Overdue",
|
||||
"totalOpenCol": "Total open",
|
||||
"platformPricingTitle": "Platform pricing",
|
||||
"monthlyFeeLabel": "Tenant monthly fee",
|
||||
"setupFeeLabel": "Tenant setup fee",
|
||||
"threemaMessageLabel": "Threema per message",
|
||||
"vatRateLabel": "VAT rate (CH/LI)",
|
||||
"save": "Save",
|
||||
"saving": "Saving…",
|
||||
"savedOk": "Saved",
|
||||
"skillPricingTitle": "Skill pricing",
|
||||
"skillPricingDesc": "Per-skill daily price. A skill enabled at any point during a UTC day counts as one billable day.",
|
||||
"skillCol": "Skill",
|
||||
"dailyPriceCol": "Daily price",
|
||||
"actionsCol": "",
|
||||
"remove": "Remove",
|
||||
"noSkillsPriced": "No skills are priced yet.",
|
||||
"addSkillLabel": "Add skill",
|
||||
"dailyPriceLabel": "Daily price",
|
||||
"add": "Add",
|
||||
"confirmDeleteSkillPrice": "Remove pricing for {skill}?",
|
||||
"clickToEdit": "Click to edit",
|
||||
"generateFormTitle": "Generate invoice",
|
||||
"noOrgsToGenerate": "No organizations with tenants found.",
|
||||
"orgLabel": "Organization",
|
||||
"noBillingAddrTag": "no billing address",
|
||||
"noBillingAddrWarning": "This org has no billing address on file. The customer must complete /settings/billing before an invoice can be issued.",
|
||||
"tenantsLabel": "tenants",
|
||||
"yearLabel": "Year",
|
||||
"monthLabel": "Month",
|
||||
"localeLabel": "PDF language",
|
||||
"localeAuto": "Auto",
|
||||
"previewBtn": "Preview",
|
||||
"commitBtn": "Commit & issue",
|
||||
"computing": "Computing…",
|
||||
"confirmGenerate": "Issue this invoice? This action allocates an invoice number and renders the PDF.",
|
||||
"previewTitle": "Draft preview",
|
||||
"warningsTitle": "Warnings",
|
||||
"noLinesGenerated": "No billable lines for this period.",
|
||||
"descCol": "Description",
|
||||
"qtyCol": "Qty",
|
||||
"unitPriceCol": "Unit price",
|
||||
"amountCol": "Amount (CHF)",
|
||||
"subtotal": "Subtotal",
|
||||
"vat": "VAT",
|
||||
"total": "Total",
|
||||
"statusFilterLabel": "Status",
|
||||
"allStatuses": "All",
|
||||
"monthFilterLabel": "Period",
|
||||
"clearFilter": "Clear",
|
||||
"loading": "Loading…",
|
||||
"noInvoicesFound": "No invoices match the current filters.",
|
||||
"invoiceNumberCol": "Number",
|
||||
"orgCol": "Organization",
|
||||
"periodCol": "Period",
|
||||
"statusCol": "Status",
|
||||
"totalCol": "Total",
|
||||
"dueCol": "Due",
|
||||
"status_draft": "Draft",
|
||||
"status_open": "Open",
|
||||
"status_paid": "Paid",
|
||||
"status_overdue": "Overdue",
|
||||
"status_void": "Void",
|
||||
"status_uncollectible": "Uncollectible",
|
||||
"dueOnLabel": "Due",
|
||||
"totalLabel": "Total",
|
||||
"downloadPdfBtn": "Download PDF",
|
||||
"markPaidBtn": "Mark as paid",
|
||||
"paidNotePlaceholder": "Optional note (e.g. bank reference, deposit date)",
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel",
|
||||
"deleteBtn": "Delete",
|
||||
"deleting": "Deleting…",
|
||||
"deleteHint": "Hard-delete this invoice (testing tool). Number is consumed.",
|
||||
"confirmDeleteInvoice": "Delete invoice {num}? This is a hard delete — the invoice number stays consumed.",
|
||||
"paidOnLabel": "Paid",
|
||||
"lineItemsTitle": "Line items",
|
||||
"billToSnapshotTitle": "Billed to"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,7 +384,8 @@
|
||||
"spendChf": "Coûts (CHF)",
|
||||
"resumeRequestBadge": "Reprise",
|
||||
"resumeRequestTooltip": "Demande de réactivation d'un locataire existant. L'approbation le réactivera ; aucun provisionnement ne s'exécute.",
|
||||
"openclawTool": "Versions OpenClaw"
|
||||
"openclawTool": "Versions OpenClaw",
|
||||
"billingTool": "Facturation →"
|
||||
},
|
||||
"channelUsers": {
|
||||
"title": "Utilisateurs autorisés",
|
||||
@@ -553,5 +554,105 @@
|
||||
"defaultPrefix": "Défaut :",
|
||||
"saveOverride": "Enregistrer la surcharge",
|
||||
"clearOverride": "Supprimer la surcharge"
|
||||
},
|
||||
"adminBilling": {
|
||||
"title": "Administration de la facturation",
|
||||
"subtitle": "Gérer les tarifs de la plateforme, générer des factures et examiner le statut de facturation des organisations.",
|
||||
"backToAdmin": "Retour à l'administration",
|
||||
"backToBilling": "Retour à la facturation",
|
||||
"backToInvoices": "Retour aux factures",
|
||||
"totalOpenBalance": "Solde ouvert total",
|
||||
"orgsWithBalance": "Organisations avec solde",
|
||||
"overdueInvoices": "Factures en retard",
|
||||
"pricingTitle": "Tarifs",
|
||||
"pricingDesc": "Tarifs plateforme & skills, taux TVA.",
|
||||
"pricingPageDesc": "Modifier les tarifs de la plateforme et les prix journaliers par skill.",
|
||||
"generateTitle": "Générer une facture",
|
||||
"generateDesc": "Calculer et émettre une facture pour une organisation et un mois.",
|
||||
"generatePageDesc": "Choisir une organisation, une période et une langue. L'aperçu affiche les lignes calculées; valider émet la facture et génère le PDF.",
|
||||
"invoicesTitle": "Factures",
|
||||
"invoicesDesc": "Parcourir les factures, marquer comme payées, télécharger les PDF.",
|
||||
"invoicesPageDesc": "Toutes les factures émises par la plateforme. Utiliser le filtre de statut pour cibler les éléments ouverts ou en retard.",
|
||||
"balancesTitle": "Organisations avec solde ouvert",
|
||||
"orgIdCol": "ID org Zitadel",
|
||||
"openCountCol": "Ouvert",
|
||||
"overdueCountCol": "En retard",
|
||||
"totalOpenCol": "Total ouvert",
|
||||
"platformPricingTitle": "Tarifs plateforme",
|
||||
"monthlyFeeLabel": "Forfait mensuel tenant",
|
||||
"setupFeeLabel": "Frais de configuration tenant",
|
||||
"threemaMessageLabel": "Threema par message",
|
||||
"vatRateLabel": "Taux TVA (CH/LI)",
|
||||
"save": "Enregistrer",
|
||||
"saving": "Enregistrement…",
|
||||
"savedOk": "Enregistré",
|
||||
"skillPricingTitle": "Tarifs des skills",
|
||||
"skillPricingDesc": "Prix journalier par skill. Un skill activé à tout moment au cours d'une journée UTC compte comme un jour facturable.",
|
||||
"skillCol": "Skill",
|
||||
"dailyPriceCol": "Prix/jour",
|
||||
"actionsCol": "",
|
||||
"remove": "Retirer",
|
||||
"noSkillsPriced": "Aucun skill n'a encore de prix.",
|
||||
"addSkillLabel": "Ajouter un skill",
|
||||
"dailyPriceLabel": "Prix/jour",
|
||||
"add": "Ajouter",
|
||||
"confirmDeleteSkillPrice": "Retirer le prix pour {skill}?",
|
||||
"clickToEdit": "Cliquer pour modifier",
|
||||
"generateFormTitle": "Générer une facture",
|
||||
"noOrgsToGenerate": "Aucune organisation avec tenants trouvée.",
|
||||
"orgLabel": "Organisation",
|
||||
"noBillingAddrTag": "pas d'adresse de facturation",
|
||||
"noBillingAddrWarning": "Cette organisation n'a pas d'adresse de facturation enregistrée. Le client doit compléter /settings/billing avant qu'une facture puisse être émise.",
|
||||
"tenantsLabel": "tenants",
|
||||
"yearLabel": "Année",
|
||||
"monthLabel": "Mois",
|
||||
"localeLabel": "Langue PDF",
|
||||
"localeAuto": "Auto",
|
||||
"previewBtn": "Aperçu",
|
||||
"commitBtn": "Valider & émettre",
|
||||
"computing": "Calcul…",
|
||||
"confirmGenerate": "Émettre cette facture? Cette action attribue un numéro de facture et génère le PDF.",
|
||||
"previewTitle": "Aperçu du brouillon",
|
||||
"warningsTitle": "Avertissements",
|
||||
"noLinesGenerated": "Aucune ligne facturable pour cette période.",
|
||||
"descCol": "Description",
|
||||
"qtyCol": "Qté",
|
||||
"unitPriceCol": "Prix unitaire",
|
||||
"amountCol": "Montant (CHF)",
|
||||
"subtotal": "Sous-total",
|
||||
"vat": "TVA",
|
||||
"total": "Total",
|
||||
"statusFilterLabel": "Statut",
|
||||
"allStatuses": "Tous",
|
||||
"monthFilterLabel": "Période",
|
||||
"clearFilter": "Effacer",
|
||||
"loading": "Chargement…",
|
||||
"noInvoicesFound": "Aucune facture ne correspond aux filtres.",
|
||||
"invoiceNumberCol": "Numéro",
|
||||
"orgCol": "Organisation",
|
||||
"periodCol": "Période",
|
||||
"statusCol": "Statut",
|
||||
"totalCol": "Total",
|
||||
"dueCol": "Échéance",
|
||||
"status_draft": "Brouillon",
|
||||
"status_open": "Ouverte",
|
||||
"status_paid": "Payée",
|
||||
"status_overdue": "En retard",
|
||||
"status_void": "Annulée",
|
||||
"status_uncollectible": "Irrécouvrable",
|
||||
"dueOnLabel": "Échéance",
|
||||
"totalLabel": "Total",
|
||||
"downloadPdfBtn": "Télécharger le PDF",
|
||||
"markPaidBtn": "Marquer comme payée",
|
||||
"paidNotePlaceholder": "Note facultative (ex. référence bancaire, date de paiement)",
|
||||
"confirm": "Confirmer",
|
||||
"cancel": "Annuler",
|
||||
"deleteBtn": "Supprimer",
|
||||
"deleting": "Suppression…",
|
||||
"deleteHint": "Suppression définitive (outil de test). Le numéro reste utilisé.",
|
||||
"confirmDeleteInvoice": "Supprimer la facture {num}? Suppression définitive — le numéro reste utilisé.",
|
||||
"paidOnLabel": "Payée le",
|
||||
"lineItemsTitle": "Lignes",
|
||||
"billToSnapshotTitle": "Destinataire"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,7 +384,8 @@
|
||||
"spendChf": "Costi (CHF)",
|
||||
"resumeRequestBadge": "Ripresa",
|
||||
"resumeRequestTooltip": "Richiesta di riattivazione di un tenant esistente. L'approvazione lo riattiverà; non viene eseguito alcun provisioning.",
|
||||
"openclawTool": "Versioni OpenClaw"
|
||||
"openclawTool": "Versioni OpenClaw",
|
||||
"billingTool": "Fatturazione →"
|
||||
},
|
||||
"channelUsers": {
|
||||
"title": "Utenti autorizzati",
|
||||
@@ -553,5 +554,105 @@
|
||||
"defaultPrefix": "Predefinito:",
|
||||
"saveOverride": "Salva override",
|
||||
"clearOverride": "Rimuovi override"
|
||||
},
|
||||
"adminBilling": {
|
||||
"title": "Amministrazione fatturazione",
|
||||
"subtitle": "Gestire prezzi della piattaforma, generare fatture e verificare lo stato di fatturazione delle organizzazioni.",
|
||||
"backToAdmin": "Torna ad amministrazione",
|
||||
"backToBilling": "Torna alla fatturazione",
|
||||
"backToInvoices": "Torna alle fatture",
|
||||
"totalOpenBalance": "Saldo aperto totale",
|
||||
"orgsWithBalance": "Organizzazioni con saldo",
|
||||
"overdueInvoices": "Fatture scadute",
|
||||
"pricingTitle": "Prezzi",
|
||||
"pricingDesc": "Prezzi piattaforma & skill, aliquota IVA.",
|
||||
"pricingPageDesc": "Modificare i prezzi della piattaforma e i prezzi giornalieri per skill.",
|
||||
"generateTitle": "Genera fattura",
|
||||
"generateDesc": "Calcolare ed emettere una fattura per organizzazione e mese.",
|
||||
"generatePageDesc": "Scegli organizzazione, periodo e lingua. L'anteprima mostra le righe calcolate; conferma emette la fattura e genera il PDF.",
|
||||
"invoicesTitle": "Fatture",
|
||||
"invoicesDesc": "Sfoglia le fatture, segna come pagate, scarica i PDF.",
|
||||
"invoicesPageDesc": "Tutte le fatture emesse dalla piattaforma. Usa il filtro di stato per focalizzarti su voci aperte o scadute.",
|
||||
"balancesTitle": "Organizzazioni con saldo aperto",
|
||||
"orgIdCol": "ID org Zitadel",
|
||||
"openCountCol": "Aperte",
|
||||
"overdueCountCol": "Scadute",
|
||||
"totalOpenCol": "Totale aperto",
|
||||
"platformPricingTitle": "Prezzi piattaforma",
|
||||
"monthlyFeeLabel": "Canone mensile tenant",
|
||||
"setupFeeLabel": "Spese di attivazione tenant",
|
||||
"threemaMessageLabel": "Threema per messaggio",
|
||||
"vatRateLabel": "Aliquota IVA (CH/LI)",
|
||||
"save": "Salva",
|
||||
"saving": "Salvataggio…",
|
||||
"savedOk": "Salvato",
|
||||
"skillPricingTitle": "Prezzi skill",
|
||||
"skillPricingDesc": "Prezzo giornaliero per skill. Una skill attiva in qualsiasi momento di un giorno UTC conta come un giorno fatturabile.",
|
||||
"skillCol": "Skill",
|
||||
"dailyPriceCol": "Prezzo/giorno",
|
||||
"actionsCol": "",
|
||||
"remove": "Rimuovi",
|
||||
"noSkillsPriced": "Nessuna skill ha ancora un prezzo.",
|
||||
"addSkillLabel": "Aggiungi skill",
|
||||
"dailyPriceLabel": "Prezzo/giorno",
|
||||
"add": "Aggiungi",
|
||||
"confirmDeleteSkillPrice": "Rimuovere il prezzo per {skill}?",
|
||||
"clickToEdit": "Clicca per modificare",
|
||||
"generateFormTitle": "Genera fattura",
|
||||
"noOrgsToGenerate": "Nessuna organizzazione con tenant trovata.",
|
||||
"orgLabel": "Organizzazione",
|
||||
"noBillingAddrTag": "nessun indirizzo di fatturazione",
|
||||
"noBillingAddrWarning": "Questa organizzazione non ha un indirizzo di fatturazione registrato. Il cliente deve completare /settings/billing prima che una fattura possa essere emessa.",
|
||||
"tenantsLabel": "tenant",
|
||||
"yearLabel": "Anno",
|
||||
"monthLabel": "Mese",
|
||||
"localeLabel": "Lingua PDF",
|
||||
"localeAuto": "Auto",
|
||||
"previewBtn": "Anteprima",
|
||||
"commitBtn": "Conferma & emetti",
|
||||
"computing": "Calcolo…",
|
||||
"confirmGenerate": "Emettere questa fattura? L'operazione assegna un numero di fattura e genera il PDF.",
|
||||
"previewTitle": "Anteprima bozza",
|
||||
"warningsTitle": "Avvisi",
|
||||
"noLinesGenerated": "Nessuna riga fatturabile per questo periodo.",
|
||||
"descCol": "Descrizione",
|
||||
"qtyCol": "Qtà",
|
||||
"unitPriceCol": "Prezzo unitario",
|
||||
"amountCol": "Importo (CHF)",
|
||||
"subtotal": "Subtotale",
|
||||
"vat": "IVA",
|
||||
"total": "Totale",
|
||||
"statusFilterLabel": "Stato",
|
||||
"allStatuses": "Tutti",
|
||||
"monthFilterLabel": "Periodo",
|
||||
"clearFilter": "Pulisci",
|
||||
"loading": "Caricamento…",
|
||||
"noInvoicesFound": "Nessuna fattura corrisponde ai filtri.",
|
||||
"invoiceNumberCol": "Numero",
|
||||
"orgCol": "Organizzazione",
|
||||
"periodCol": "Periodo",
|
||||
"statusCol": "Stato",
|
||||
"totalCol": "Totale",
|
||||
"dueCol": "Scadenza",
|
||||
"status_draft": "Bozza",
|
||||
"status_open": "Aperta",
|
||||
"status_paid": "Pagata",
|
||||
"status_overdue": "Scaduta",
|
||||
"status_void": "Annullata",
|
||||
"status_uncollectible": "Inesigibile",
|
||||
"dueOnLabel": "Scadenza",
|
||||
"totalLabel": "Totale",
|
||||
"downloadPdfBtn": "Scarica PDF",
|
||||
"markPaidBtn": "Segna come pagata",
|
||||
"paidNotePlaceholder": "Nota opzionale (es. riferimento bancario, data di pagamento)",
|
||||
"confirm": "Conferma",
|
||||
"cancel": "Annulla",
|
||||
"deleteBtn": "Elimina",
|
||||
"deleting": "Eliminazione…",
|
||||
"deleteHint": "Eliminazione definitiva (strumento di test). Il numero rimane consumato.",
|
||||
"confirmDeleteInvoice": "Eliminare la fattura {num}? Eliminazione definitiva — il numero rimane consumato.",
|
||||
"paidOnLabel": "Pagata il",
|
||||
"lineItemsTitle": "Righe",
|
||||
"billToSnapshotTitle": "Destinatario"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,3 +520,130 @@ export interface OrgBillingConfig {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Billing — Phase 2: invoices and lines
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type InvoiceStatus =
|
||||
| "draft"
|
||||
| "open"
|
||||
| "paid"
|
||||
| "overdue"
|
||||
| "void"
|
||||
| "uncollectible";
|
||||
|
||||
export type InvoicePaymentMethod = "invoice" | "card";
|
||||
|
||||
export type InvoiceLineKind =
|
||||
| "tenant_monthly"
|
||||
| "tenant_setup"
|
||||
| "ai_usage"
|
||||
| "threema_messages"
|
||||
| "skill_usage"
|
||||
| "adjustment";
|
||||
|
||||
/**
|
||||
* Snapshot of the customer's billing details captured at invoice
|
||||
* issue time. Subsequent edits to org_billing do not mutate
|
||||
* historical invoices.
|
||||
*
|
||||
* Field names mirror OrgBilling (minus the timestamps) so the
|
||||
* snapshot is a straightforward copy at issue time.
|
||||
*/
|
||||
export interface InvoiceBillingSnapshot {
|
||||
companyName: string;
|
||||
streetAddress: string;
|
||||
postalCode: string;
|
||||
city: string;
|
||||
country: string;
|
||||
vatNumber: string | null;
|
||||
billingEmail: string;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One line on an invoice. The `metadata` shape varies by `kind`:
|
||||
* tenant_monthly: { proration_days, days_in_month, billable_days, suspended_days }
|
||||
* tenant_setup: {}
|
||||
* ai_usage: { litellm_key_alias, spend_chf, requests }
|
||||
* threema_messages: { in_count, out_count, total_count }
|
||||
* skill_usage: { skill_id, billable_days, event_count }
|
||||
* adjustment: { reason, admin_user_id }
|
||||
*/
|
||||
export interface InvoiceLine {
|
||||
id: string;
|
||||
invoiceId: string;
|
||||
tenantName: string | null;
|
||||
kind: InvoiceLineKind;
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitLabel: string | null;
|
||||
unitPriceChf: number;
|
||||
amountChf: number;
|
||||
metadata: Record<string, unknown> | null;
|
||||
displayOrder: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable invoice record. The PDF blob is fetched separately via
|
||||
* the download endpoint to avoid loading bytea on every list query.
|
||||
*/
|
||||
export interface Invoice {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
zitadelOrgId: string;
|
||||
periodStart: string; // ISO date (YYYY-MM-DD)
|
||||
periodEnd: string;
|
||||
issuedAt: string;
|
||||
dueAt: string;
|
||||
subtotalChf: number;
|
||||
vatRate: number;
|
||||
vatAmountChf: number;
|
||||
totalChf: number;
|
||||
status: InvoiceStatus;
|
||||
locale: string;
|
||||
paymentMethod: InvoicePaymentMethod;
|
||||
billingSnapshot: InvoiceBillingSnapshot;
|
||||
stripePaymentIntentId: string | null;
|
||||
pdfFilename: string | null;
|
||||
hasPdf: boolean; // computed: pdf_data IS NOT NULL
|
||||
adminNotes: string | null;
|
||||
paidAt: string | null;
|
||||
paidBy: string | null;
|
||||
paidMethodDetail: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Invoice with its line items, used by detail views. */
|
||||
export interface InvoiceDetail {
|
||||
invoice: Invoice;
|
||||
lines: InvoiceLine[];
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory draft produced by the computation pipeline before the
|
||||
* invoice is allocated a number and persisted. Used by both the
|
||||
* preview endpoint (return without persisting) and the commit
|
||||
* endpoint (compute → persist atomically).
|
||||
*/
|
||||
export interface InvoiceDraft {
|
||||
zitadelOrgId: string;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
dueAt: string;
|
||||
locale: string;
|
||||
paymentMethod: InvoicePaymentMethod;
|
||||
billingSnapshot: InvoiceBillingSnapshot;
|
||||
lines: Omit<InvoiceLine, "id" | "invoiceId">[];
|
||||
subtotalChf: number;
|
||||
vatRate: number;
|
||||
vatAmountChf: number;
|
||||
totalChf: number;
|
||||
/**
|
||||
* Non-blocking warnings the compute pipeline surfaced — e.g.
|
||||
* "tenant X has no LiteLLM team, AI usage skipped". Rendered in
|
||||
* the admin UI to help the operator decide whether to commit.
|
||||
*/
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user