Compare commits

...

5 Commits

Author SHA1 Message Date
4a5ae0bb8b Phase3: Billing Customerpage/Mailings
All checks were successful
Build and Push / build (push) Successful in 1m37s
2026-05-24 22:21:26 +02:00
c21b48c704 Phase3: Billing Customerpage/Mailings
All checks were successful
Build and Push / build (push) Successful in 1m33s
2026-05-24 21:47:37 +02:00
cf190e5ac5 Phase3: Billing Customerpage/Mailings
Some checks failed
Build and Push / build (push) Failing after 46s
2026-05-24 21:44:10 +02:00
a3b080f542 Phase2.5: Skill SetUp Process
All checks were successful
Build and Push / build (push) Successful in 1m41s
2026-05-24 18:35:36 +02:00
229bfea263 Phase2.5: Skill SetUp Process
All checks were successful
Build and Push / build (push) Successful in 1m39s
2026-05-24 17:51:09 +02:00
23 changed files with 1270 additions and 81 deletions

View File

@@ -0,0 +1,35 @@
import { redirect, notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { getInvoiceByNumberForOrg } from "@/lib/db";
import { BackLink } from "@/components/ui/back-link";
import { CustomerInvoiceDetail } from "@/components/billing/customer-invoice-detail";
/**
* /billing/[invoiceNumber] — single-invoice view.
*
* Lookup is by the human-readable invoice number (the YYYY-NNNNN
* format printed on the PDF and in the issuance email). Org
* filter is enforced in the DB query — a customer trying another
* org's number gets 404, not 403, to avoid leaking the existence
* of other orgs' invoices.
*/
export default async function CustomerInvoiceDetailPage({
params,
}: {
params: Promise<{ invoiceNumber: string; locale: string }>;
}) {
const user = await getSessionUser();
if (!user) redirect("/login");
const { invoiceNumber } = await params;
const t = await getTranslations("customerBilling");
const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId);
if (!detail) notFound();
return (
<main className="max-w-3xl mx-auto px-6 py-8">
<BackLink href="/billing" label={t("backToBilling")} />
<CustomerInvoiceDetail invoice={detail.invoice} lines={detail.lines} />
</main>
);
}

View File

@@ -0,0 +1,63 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { listInvoices, syncOverdueInvoices } from "@/lib/db";
import { CustomerInvoiceList } from "@/components/billing/customer-invoice-list";
import { RunningTotalWidget } from "@/components/billing/running-total-widget";
/**
* /billing — customer's billing home.
*
* Shows two things:
* 1. RunningTotalWidget — current calendar month's accruing cost
* (or the already-issued invoice for the current month, if
* that ran early).
* 2. CustomerInvoiceList — every issued invoice for this org,
* newest first. Status is reflected with a colored badge.
*
* Anyone signed in can view this. The data is org-scoped; even
* non-owner team members see the same view. Phase 4 will add a
* "settings.payByInvoice" toggle visibility-gated to owners only.
*/
export default async function CustomerBillingPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
const t = await getTranslations("customerBilling");
// Sync overdue status before listing — cheap, idempotent.
try {
await syncOverdueInvoices();
} catch (e) {
console.warn("syncOverdueInvoices failed in /billing:", e);
}
const invoices = await listInvoices({
zitadelOrgId: user.orgId,
limit: 200,
});
return (
<main className="max-w-5xl mx-auto px-6 py-8">
<div className="mb-8 animate-in">
<h1 className="font-display text-2xl font-semibold accent-rule">
{t("title")}
</h1>
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
</div>
<section className="mb-8 animate-in animate-in-delay-1">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("currentPeriodHeading")}
</h2>
<RunningTotalWidget />
</section>
<section className="animate-in animate-in-delay-2">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("historyHeading")}
</h2>
<CustomerInvoiceList invoices={invoices} />
</section>
</main>
);
}

View File

@@ -8,6 +8,7 @@ import {
import { getPackageDef } from "@/lib/packages";
import { listOrgUsers } from "@/lib/zitadel";
import { sendSkillActivationRejectionEmail } from "@/lib/email";
import { deletePackageSecrets } from "@/lib/openbao";
/**
* POST /api/admin/skills/pending/[id]/reject
@@ -79,6 +80,33 @@ export async function POST(
);
}
// Cleanup: if the package needed customer-provided secrets, the
// user submitted them BEFORE the gate fired (handleSubmitSecrets
// in PackageCard writes to OpenBao then PATCHes). Those secrets
// are now orphaned — the package never made it into spec, won't
// be re-attempted unless the user retries with fresh credentials.
// Best-effort delete: keep the OpenBao path clean, avoid stale
// creds lurking. Idempotent (404 is fine). Failure is logged but
// not propagated — the rejection itself already succeeded.
//
// We deliberately skip customProvisioning packages here. Those
// mint platform-side credentials via a dedicated endpoint and
// need symmetric deprovisioning (POST /[pkg.id] → DELETE
// /[pkg.id]). Calling deletePackageSecrets wouldn't revoke them
// — admin handles that path manually if the rejected request had
// already minted resources.
const def = getPackageDef(req.skillId);
if (def?.requiresSecrets && !def.customProvisioning) {
try {
await deletePackageSecrets(req.tenantName, req.skillId);
} catch (e) {
console.error(
`Failed to delete orphan secrets for ${req.tenantName}/${req.skillId} after reject:`,
e
);
}
}
// Email the requester with the reason — best-effort.
try {
const orgUsers = await listOrgUsers(req.zitadelOrgId);

View File

@@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { computeInvoiceDraft } from "@/lib/billing";
import { listInvoices } from "@/lib/db";
/**
* GET /api/billing/current
*
* Running total for the current calendar month — what the
* customer will be billed if no further activity happens. Uses
* the same compute pipeline as the final invoice (LiteLLM spend,
* Threema usage, skill day-counting, proration) so the number
* the customer sees matches what they'll eventually receive
* within the limits of intra-month drift.
*
* If an invoice has ALREADY been issued for the current month
* (e.g. cron ran early, admin manually generated), we return
* that issued invoice instead — no point showing a draft that
* duplicates a real invoice.
*
* Returns:
* { issued: Invoice } // current-month invoice exists
* { draft: InvoiceDraft } // still accruing
* { error: ... } // org missing billing config
*
* Cost: 1 LiteLLM HTTP call + 1 Threema HTTP call + a handful of
* DB queries per skill. Sub-second typically. No caching; called
* on demand from the customer billing page.
*/
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Resolve current calendar month from UTC. Billing is UTC-day
// based throughout (see billing.ts iterDays comment), so the
// running total inherits that same semantics.
const now = new Date();
const year = now.getUTCFullYear();
const month = now.getUTCMonth() + 1; // 1-12
const periodMonth = `${year}-${String(month).padStart(2, "0")}`;
// 1. Has the current month already been invoiced?
const existing = await listInvoices({
zitadelOrgId: user.orgId,
periodMonth,
limit: 1,
});
if (existing.length > 0) {
return NextResponse.json({ issued: existing[0] });
}
// 2. Otherwise compute the draft. Falls through to error if the
// org doesn't have a billing config yet (no Address on file).
try {
const draft = await computeInvoiceDraft({
zitadelOrgId: user.orgId,
year,
month,
});
return NextResponse.json({ draft });
} catch (e: any) {
// Most likely: org_billing row missing. We surface a 200 with a
// soft error code rather than 500 — the customer-side widget
// displays a helpful "complete your billing details" message
// instead of a stack trace.
return NextResponse.json(
{
error: e?.message ?? "Could not compute running total.",
code: e?.code ?? "COMPUTE_FAILED",
},
{ status: 200 }
);
}
}

View File

@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { getInvoiceByNumberForOrg, getInvoicePdf } from "@/lib/db";
/**
* GET /api/billing/invoices/[invoiceNumber]/pdf
*
* Customer-facing PDF download. Same Uint8Array.from() variance
* fix as the admin route — see /api/admin/billing/invoices/[id]/pdf
* for the rationale.
*
* Authorization: looks up the invoice by number with org scope
* baked into the query, then re-fetches the PDF blob by id. A
* customer can't probe another org's invoice numbers — they get
* 404 either way.
*/
export async function GET(
_request: Request,
{ params }: { params: Promise<{ invoiceNumber: string }> }
) {
const user = await getSessionUser();
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { invoiceNumber } = await params;
const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId);
if (!detail) {
return new NextResponse("Not found", { status: 404 });
}
const pdf = await getInvoicePdf(detail.invoice.id);
if (!pdf) {
return new NextResponse("PDF not available", { status: 404 });
}
const body = Uint8Array.from(pdf.data);
return new NextResponse(body, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="${pdf.filename}"`,
"Cache-Control": "private, max-age=0, must-revalidate",
},
});
}

View File

@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { getInvoiceByNumberForOrg } from "@/lib/db";
/**
* GET /api/billing/invoices/[invoiceNumber]
*
* Customer-scoped detail lookup by invoice number (the human-
* readable YYYY-NNNNN format the customer sees on the PDF). The
* org filter is part of the DB query — a customer probing another
* org's invoice number gets the same 404 as a non-existent one.
*/
export async function GET(
_request: Request,
{ params }: { params: Promise<{ invoiceNumber: string }> }
) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { invoiceNumber } = await params;
const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId);
if (!detail) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(detail);
}

View File

@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { listInvoices, syncOverdueInvoices } from "@/lib/db";
/**
* GET /api/billing/invoices
*
* Customer-scoped list of invoices for the caller's org. Returns
* a flat array of Invoice headers (no line items — those are
* fetched separately by /[invoiceNumber]).
*
* Status filter is implicit: we return every invoice the
* customer's org has, all statuses (issued/paid/overdue/void)
* because the customer wants a single billing-history view.
*
* Before returning we run syncOverdueInvoices() so the displayed
* status reflects the current date — issued invoices past their
* due_at flip to 'overdue'. Cheap, idempotent, and avoids needing
* a separate cron for this transition.
*/
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Personal accounts have an org too — they share the same shape;
// their invoices show up under that synthetic org id.
try {
await syncOverdueInvoices();
} catch (e) {
// Non-fatal — display stale status rather than 500.
console.warn("syncOverdueInvoices failed in /api/billing/invoices:", e);
}
const invoices = await listInvoices({
zitadelOrgId: user.orgId,
limit: 200,
});
return NextResponse.json(invoices);
}

View File

@@ -4,6 +4,8 @@ import {
getSkillActivationRequestById,
updateSkillActivationRequestStatus,
} from "@/lib/db";
import { getPackageDef } from "@/lib/packages";
import { deletePackageSecrets } from "@/lib/openbao";
/**
* POST /api/skills/requests/[id]/withdraw
@@ -50,5 +52,23 @@ export async function POST(
{ status: 409 }
);
}
// Cleanup: same logic as reject — the user submitted secrets
// before the gate fired, and those are now orphaned in OpenBao.
// Best-effort delete; failure logged but not propagated. Skip
// customProvisioning packages (their deprovisioning is a
// separate, dedicated endpoint).
const def = getPackageDef(req.skillId);
if (def?.requiresSecrets && !def.customProvisioning) {
try {
await deletePackageSecrets(req.tenantName, req.skillId);
} catch (e) {
console.error(
`Failed to delete orphan secrets for ${req.tenantName}/${req.skillId} after withdraw:`,
e
);
}
}
return NextResponse.json(updated);
}

View File

@@ -34,6 +34,7 @@ export function PricingEditor({
catalog,
}: Props) {
const t = useTranslations("adminBilling");
const tPackages = useTranslations("packages");
const router = useRouter();
// -- Platform pricing form ----------------------------------------------
@@ -78,12 +79,19 @@ export function PricingEditor({
}
};
// -- Skill pricing ------------------------------------------------------
// -- Package 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.category === "skill")?.id ?? ""
);
//
// Naming carry-over: the underlying DB table is `skill_pricing` and the
// column is `skill_id`, dating from when only skills were priced. The
// model now applies to any PackageDef in the catalog regardless of
// category — core, channel, or skill. The state variable names below
// (newSkill*, addingSkill, etc.) retain the legacy "skill" prefix
// because renaming the entire surface for purely cosmetic reasons
// would create churn for no functional gain. Treat "skill" here as
// shorthand for "priced package".
const [newSkillId, setNewSkillId] = useState(catalog[0]?.id ?? "");
const [newSkillPrice, setNewSkillPrice] = useState("0.10");
const [newSkillSetupFee, setNewSkillSetupFee] = useState("0");
const [addingSkill, setAddingSkill] = useState(false);
@@ -147,9 +155,16 @@ export function PricingEditor({
}
};
// 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.category === "skill");
// Pricing applies to any catalog entry regardless of category. Grouped
// dropdown sorts options by category for visual scanning — core,
// channel, and skill in a single picker.
const skillCatalogOptions = [...catalog].sort((a, b) => {
const order = { core: 0, channel: 1, skill: 2 } as Record<string, number>;
const ca = order[a.category] ?? 99;
const cb = order[b.category] ?? 99;
if (ca !== cb) return ca - cb;
return a.name.localeCompare(b.name);
});
const catalogIndex = new Map(catalog.map((c) => [c.id, c]));
const pricedIds = new Set(initialSkillPricing.map((s) => s.skillId));
@@ -260,7 +275,12 @@ export function PricingEditor({
<td className="py-2">
<div className="font-mono text-xs">{sp.skillId}</div>
{entry && (
<div className="text-xs text-text-muted">{entry.name}</div>
<div className="text-xs text-text-muted flex items-center gap-2">
<span>{entry.name}</span>
<span className="text-[10px] uppercase tracking-wider bg-surface-3 px-1.5 py-0.5 rounded">
{entry.category}
</span>
</div>
)}
</td>
<td className="py-2 text-right">
@@ -311,13 +331,45 @@ export function PricingEditor({
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>
))}
{(() => {
// Group available options by category for the picker.
// Already-priced packages are filtered out (admin
// edits those inline above).
const available = skillCatalogOptions.filter(
(c) => !pricedIds.has(c.id)
);
const byCat = new Map<string, typeof available>();
for (const c of available) {
if (!byCat.has(c.category)) byCat.set(c.category, []);
byCat.get(c.category)!.push(c);
}
// Labels for the optgroups. Reuse the existing
// packages.categories.* scope which already has
// translations in all four locales.
const labels: Record<string, string> = {
core: tPackages("categories.core"),
channel: tPackages("categories.channels"),
skill: tPackages("categories.skills"),
};
const order: Array<"core" | "channel" | "skill"> = [
"core",
"channel",
"skill",
];
return order.map((cat) => {
const items = byCat.get(cat);
if (!items || items.length === 0) return null;
return (
<optgroup key={cat} label={labels[cat] ?? cat}>
{items.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.id})
</option>
))}
</optgroup>
);
});
})()}
</select>
</label>
<label className="w-28">

View File

@@ -0,0 +1,148 @@
import { useTranslations, useFormatter } from "next-intl";
import { Card } from "@/components/ui/card";
import type { Invoice, InvoiceLine } from "@/types";
interface Props {
invoice: Invoice;
lines: InvoiceLine[];
}
const statusColors: Record<string, string> = {
open: "text-text-secondary bg-surface-3",
paid: "text-success bg-success/10",
overdue: "text-error bg-error/10",
void: "text-text-muted bg-surface-3",
};
/**
* Read-only invoice detail. Flat list of lines — no per-tenant
* grouping (one invoice per customer; the tenant context is
* already embedded in each line description).
*
* The download link points at /api/billing/invoices/[n]/pdf
* which serves the stored PDF blob inline. Customers using a
* link from their email will hit the same route via this page.
*/
export function CustomerInvoiceDetail({ invoice, lines }: Props) {
const t = useTranslations("customerBilling");
const fmt = useFormatter();
return (
<div className="space-y-6 animate-in">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="font-display text-2xl font-semibold">
{invoice.invoiceNumber}
</h1>
<span
className={`text-[10px] uppercase tracking-wider px-2 py-1 rounded-md font-semibold ${
statusColors[invoice.status] ?? "text-text-muted bg-surface-3"
}`}
>
{t(`status.${invoice.status}` as any)}
</span>
</div>
<p className="text-sm text-text-secondary">
{fmt.dateTime(new Date(invoice.periodStart), { dateStyle: "long" })}
<span className="text-text-muted mx-1"></span>
{fmt.dateTime(new Date(invoice.periodEnd), { dateStyle: "long" })}
</p>
</div>
<a
href={`/api/billing/invoices/${encodeURIComponent(invoice.invoiceNumber)}/pdf`}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors"
>
{t("downloadPdf")}
</a>
</div>
<Card>
<div className="space-y-2 mb-4">
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("billedToLabel")}</span>
<span>{invoice.billingSnapshot.companyName}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("issuedAtLabel")}</span>
<span>
{fmt.dateTime(new Date(invoice.issuedAt), { dateStyle: "medium" })}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("dueAtLabel")}</span>
<span>
{fmt.dateTime(new Date(invoice.dueAt), { dateStyle: "medium" })}
</span>
</div>
{invoice.status === "paid" && invoice.paidAt && (
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("paidAtLabel")}</span>
<span>
{fmt.dateTime(new Date(invoice.paidAt), { dateStyle: "medium" })}
</span>
</div>
)}
</div>
</Card>
<Card>
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2">{t("descriptionCol")}</th>
<th className="pb-2 text-right">{t("qtyCol")}</th>
<th className="pb-2 text-right">{t("unitCol")}</th>
<th className="pb-2 text-right">{t("amountCol")}</th>
</tr>
</thead>
<tbody>
{lines.map((ln) => (
<tr key={ln.id} className="border-t border-border align-top">
<td className="py-2">{ln.description}</td>
<td className="py-2 text-right font-mono text-xs">
{ln.quantity}
{ln.unitLabel ? ` ${ln.unitLabel}` : ""}
</td>
<td className="py-2 text-right font-mono text-xs">
{ln.unitPriceChf.toFixed(2)}
</td>
<td className="py-2 text-right font-mono">
{ln.amountChf.toFixed(2)}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-border">
<td colSpan={3} className="pt-3 text-right text-text-muted">
{t("subtotalLabel")}
</td>
<td className="pt-3 text-right font-mono">
{invoice.subtotalChf.toFixed(2)}
</td>
</tr>
<tr>
<td colSpan={3} className="pt-1 text-right text-text-muted">
{t("vatLabel", { rate: invoice.vatRate.toFixed(2) })}
</td>
<td className="pt-1 text-right font-mono">
{invoice.vatAmountChf.toFixed(2)}
</td>
</tr>
<tr>
<td colSpan={3} className="pt-2 text-right font-semibold">
{t("totalLabel")}
</td>
<td className="pt-2 text-right font-mono font-semibold text-base">
CHF {invoice.totalChf.toFixed(2)}
</td>
</tr>
</tfoot>
</table>
</Card>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import { useTranslations, useFormatter } from "next-intl";
import { Link } from "@/i18n/navigation";
import { Card } from "@/components/ui/card";
import type { Invoice } from "@/types";
interface Props {
invoices: Invoice[];
}
const statusColors: Record<string, string> = {
open: "text-text-secondary bg-surface-3",
paid: "text-success bg-success/10",
overdue: "text-error bg-error/10",
void: "text-text-muted bg-surface-3 line-through",
};
/**
* Customer's invoice history table. Server component — gets a
* pre-fetched Invoice[] from /billing/page.tsx. Each row links
* to /billing/<invoice-number> for the full detail view.
*
* Columns: number, period, due date, total, status. Status is
* displayed with a colored badge so the customer can scan for
* outstanding ones at a glance.
*/
export function CustomerInvoiceList({ invoices }: Props) {
const t = useTranslations("customerBilling");
const fmt = useFormatter();
if (invoices.length === 0) {
return (
<Card>
<p className="text-sm text-text-muted italic text-center py-8">
{t("emptyHistory")}
</p>
</Card>
);
}
return (
<Card>
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2">{t("numberCol")}</th>
<th className="pb-2">{t("periodCol")}</th>
<th className="pb-2">{t("dueCol")}</th>
<th className="pb-2 text-right">{t("totalCol")}</th>
<th className="pb-2 text-right">{t("statusCol")}</th>
</tr>
</thead>
<tbody>
{invoices.map((inv) => (
<tr
key={inv.id}
className="border-t border-border hover:bg-surface-2 transition-colors"
>
<td className="py-2">
<Link
href={`/billing/${inv.invoiceNumber}`}
className="font-mono text-xs text-accent hover:underline"
>
{inv.invoiceNumber}
</Link>
</td>
<td className="py-2 text-xs text-text-secondary">
{fmt.dateTime(new Date(inv.periodStart), { dateStyle: "medium" })}
<span className="text-text-muted mx-1"></span>
{fmt.dateTime(new Date(inv.periodEnd), { dateStyle: "medium" })}
</td>
<td className="py-2 text-xs text-text-secondary">
{fmt.dateTime(new Date(inv.dueAt), { dateStyle: "medium" })}
</td>
<td className="py-2 text-right font-mono">
CHF {inv.totalChf.toFixed(2)}
</td>
<td className="py-2 text-right">
<span
className={`text-[10px] uppercase tracking-wider px-2 py-1 rounded-md font-semibold ${
statusColors[inv.status] ?? "text-text-muted bg-surface-3"
}`}
>
{t(`status.${inv.status}` as any)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</Card>
);
}

View File

@@ -0,0 +1,162 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { Link } from "@/i18n/navigation";
import { Card } from "@/components/ui/card";
import type { Invoice, InvoiceDraft } from "@/types";
type CurrentResponse =
| { issued: Invoice }
| { draft: InvoiceDraft }
| { error: string; code?: string };
/**
* Live running total for the current calendar month.
*
* Loads /api/billing/current on mount. Three result shapes:
*
* - { issued } — current-month invoice already exists; we
* link to it instead of showing a draft total.
* - { draft } — still accruing; show subtotal+VAT+total and
* a small line breakdown.
* - { error } — most likely the org has no billing config
* yet; show a friendly hint, not a stack trace.
*
* Client-side because the compute can take a second or two
* (LiteLLM + Threema HTTP calls) and we want a loading spinner.
* No polling — the page is static enough that an explicit
* "refresh" link is good enough if the user wants newer numbers.
*/
export function RunningTotalWidget() {
const t = useTranslations("customerBilling");
const fmt = useFormatter();
const [data, setData] = useState<CurrentResponse | null>(null);
const [loading, setLoading] = useState(true);
const [refreshCounter, setRefreshCounter] = useState(0);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch("/api/billing/current")
.then(async (res) => {
const j = (await res.json()) as CurrentResponse;
if (!cancelled) setData(j);
})
.catch((e) => {
if (!cancelled) setData({ error: String(e), code: "FETCH_FAILED" });
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [refreshCounter]);
if (loading) {
return (
<Card>
<p className="text-sm text-text-muted italic py-4">{t("computing")}</p>
</Card>
);
}
if (!data || "error" in data) {
return (
<Card>
<p className="text-sm text-text-secondary py-2">
{data && "code" in data && data.code === "COMPUTE_FAILED"
? t("noBillingConfig")
: t("currentPeriodError")}
</p>
</Card>
);
}
if ("issued" in data) {
const inv = data.issued;
return (
<Card>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<p className="text-xs text-text-muted">{t("currentInvoiceIssued")}</p>
<Link
href={`/billing/${inv.invoiceNumber}`}
className="font-mono text-sm text-accent hover:underline"
>
{inv.invoiceNumber}
</Link>
</div>
<div className="text-right">
<p className="text-xs text-text-muted">{t("totalLabel")}</p>
<p className="font-mono text-lg font-semibold">
CHF {inv.totalChf.toFixed(2)}
</p>
</div>
</div>
</Card>
);
}
// draft
const draft = data.draft;
const periodLabel = `${fmt.dateTime(new Date(draft.periodStart), {
dateStyle: "long",
})} → ${fmt.dateTime(new Date(draft.periodEnd), { dateStyle: "long" })}`;
return (
<Card>
<div className="flex items-start justify-between gap-4 flex-wrap mb-3">
<div>
<p className="text-xs text-text-muted">{t("accruedSoFar")}</p>
<p className="text-xs text-text-secondary">{periodLabel}</p>
</div>
<div className="text-right">
<p className="text-xs text-text-muted">{t("estimatedTotal")}</p>
<p className="font-mono text-2xl font-semibold text-accent">
CHF {draft.totalChf.toFixed(2)}
</p>
<button
onClick={() => setRefreshCounter((n) => n + 1)}
className="text-[10px] text-text-muted hover:text-text-secondary underline mt-1 cursor-pointer"
>
{t("refresh")}
</button>
</div>
</div>
{draft.lines.length > 0 && (
<details className="text-xs">
<summary className="cursor-pointer text-text-muted hover:text-text-secondary">
{t("breakdownToggle", { count: draft.lines.length })}
</summary>
<table className="w-full mt-2 text-xs">
<tbody>
{draft.lines.map((ln, i) => (
<tr key={i} className="border-t border-border">
<td className="py-1 pr-2">{ln.description}</td>
<td className="py-1 text-right font-mono">
{ln.amountChf.toFixed(2)}
</td>
</tr>
))}
<tr className="border-t border-border">
<td className="py-1 pr-2 text-text-muted text-right">
{t("subtotalLabel")}
</td>
<td className="py-1 text-right font-mono">
{draft.subtotalChf.toFixed(2)}
</td>
</tr>
<tr>
<td className="py-1 pr-2 text-text-muted text-right">
{t("vatLabel", { rate: draft.vatRate.toFixed(2) })}
</td>
<td className="py-1 text-right font-mono">
{draft.vatAmountChf.toFixed(2)}
</td>
</tr>
</tbody>
</table>
</details>
)}
<p className="text-[10px] text-text-muted mt-3 italic">{t("draftNote")}</p>
</Card>
);
}

View File

@@ -74,6 +74,20 @@ function NavBar() {
{t("settings")}
</NavLink>
)}
{/* Phase 3: Billing visible to anyone signed in. The
page is org-scoped server-side — non-owner members
see the same invoice history their owner does, but
actions like "configure billing details" are gated
separately on the settings page. Personal accounts
see their own (single-tenant) invoices. */}
{user && (
<NavLink
href="/billing"
active={pathname.startsWith("/billing")}
>
{t("billing")}
</NavLink>
)}
{/* Feature 5: Support is available to every signed-in
user. Customers see their own tickets only; platform
admins see the queue. */}

View File

@@ -240,11 +240,23 @@ export function PackageCard({
<span className="text-[10px] text-text-muted">{t("packages.requiresApiKey")}</span>
)}
{/* Phase 2.5: pending or rejected request takes precedence
over the toggle. Approved/withdrawn never reach here. */}
over the toggle. Approved/withdrawn never reach here.
For packages that needed secrets, surface that they're
safely stored — the user might otherwise worry the
credentials they typed got lost when the activation
was deferred. */}
{canEdit && activationRequest?.status === "pending" ? (
<div className="ml-auto flex items-center gap-2">
<span className="text-[10px] text-warning italic">
<div className="ml-auto flex flex-col items-end gap-1">
<span
className="text-[10px] text-warning italic"
title={pkg.requiresSecrets ? t("packages.credentialsSavedTip") : undefined}
>
{t("packages.manualReviewPending")}
{pkg.requiresSecrets && (
<span className="text-text-muted ml-1 not-italic">
· {t("packages.credentialsSaved")}
</span>
)}
</span>
<button
onClick={withdrawRequest}

View File

@@ -70,15 +70,22 @@ export function SkillCostDialog({
</div>
)}
{showDaily && (
/* Display reference monthly cost (daily × 30) plus the
actual daily rate as a sub-note. Billing is always
per UTC day enabled — partial months prorate to that
same daily rate, full months land at roughly the
figure shown (varies ±~3% by month length). */
<div className="flex justify-between items-baseline">
<div>
<div className="text-sm">{t("dailyPriceLabel")}</div>
<div className="text-sm">{t("monthlyPriceLabel")}</div>
<div className="text-xs text-text-muted">
{t("dailyPriceNote")}
{t("monthlyPriceNote", {
daily: dailyPriceChf.toFixed(2),
})}
</div>
</div>
<div className="text-sm font-mono">
CHF {dailyPriceChf.toFixed(2)} / {t("dayUnit")}
CHF {(dailyPriceChf * 30).toFixed(2)} / {t("monthUnit")}
</div>
</div>
)}

View File

@@ -61,6 +61,7 @@ import { listTenants } from "./k8s";
import { getTeamSpendLogsV2 } from "./litellm";
import { getUsage as getThreemaUsage } from "./threema-relay";
import { renderInvoicePdf } from "./billing-pdf";
import { sendInvoiceIssuedEmail } from "./email";
import { formatLineDescription } from "./billing-i18n";
// ---------------------------------------------------------------------------
@@ -779,6 +780,50 @@ export async function generateInvoice(opts: {
// Pass 2: store the PDF bytes.
await updateInvoicePdf(placeholder.id, pdfBuffer, filename);
const finalInvoice = await getInvoiceById(placeholder.id);
// Phase 3: best-effort notification to the billing contact.
// We send AFTER the PDF is fully persisted (so the deep link
// in the email immediately resolves to a downloadable PDF) but
// BEFORE returning, since the cron caller doesn't otherwise
// know to trigger this. Failure is logged, never thrown — a
// mail-server hiccup must not roll back an issued invoice.
// The recipient is the billing email captured in the invoice
// snapshot (immutable; reflects who was on file at issue time).
try {
const settled = finalInvoice ?? placeholder;
const snapshot = settled.billingSnapshot;
if (snapshot.billingEmail) {
const supportedLocales: Array<"en" | "de" | "fr" | "it"> = [
"en", "de", "fr", "it",
];
const locale = supportedLocales.includes(settled.locale as any)
? (settled.locale as "en" | "de" | "fr" | "it")
: "de";
await sendInvoiceIssuedEmail({
to: snapshot.billingEmail,
contactName: snapshot.companyName, // no separate contact-name field
companyName: snapshot.companyName,
invoiceNumber: settled.invoiceNumber,
totalChf: settled.totalChf,
currency: "CHF",
dueAt: settled.dueAt,
lineCount: draft.lines.length,
periodStart: settled.periodStart,
periodEnd: settled.periodEnd,
locale,
});
} else {
console.warn(
`Invoice ${settled.invoiceNumber} issued but billing snapshot has no email — notification skipped.`
);
}
} catch (e) {
console.error(
`Invoice ${placeholder.invoiceNumber} issued; notification email failed:`,
e
);
}
return { draft, invoice: finalInvoice ?? placeholder };
} catch (e) {
// Render failed — leave the persisted row in place so admin can

View File

@@ -2407,6 +2407,38 @@ export async function getInvoiceDetail(
return { invoice, lines: lines.rows.map(rowToInvoiceLine) };
}
/**
* Phase 3 — customer-scoped lookup by human-readable invoice
* number with ownership enforcement in a single query. The org
* filter is part of the WHERE clause so a customer can't probe
* another org's invoice numbers (which are sequential and easy
* to guess) and get a different status code (404 vs 403) than
* for their own — both miss-and-not-yours return null.
*
* Used by /api/billing/invoices/[invoiceNumber] and the
* /billing/[invoiceNumber] customer page.
*/
export async function getInvoiceByNumberForOrg(
invoiceNumber: string,
zitadelOrgId: string
): Promise<InvoiceDetail | null> {
await ensureSchema();
const head = await getPool().query(
`SELECT ${INVOICE_LIST_COLUMNS} FROM invoices
WHERE invoice_number = $1 AND zitadel_org_id = $2
LIMIT 1`,
[invoiceNumber, zitadelOrgId]
);
if (head.rows.length === 0) return null;
const invoice = rowToInvoice(head.rows[0]);
const lines = await getPool().query(
`SELECT * FROM invoice_lines WHERE invoice_id = $1
ORDER BY display_order, id`,
[invoice.id]
);
return { invoice, lines: lines.rows.map(rowToInvoiceLine) };
}
/**
* Fetch the PDF bytes for an invoice. Returns null if no PDF was
* stored (shouldn't happen in v1; defensive against partial state).

View File

@@ -900,3 +900,117 @@ export async function sendSkillActivationRejectionEmail(params: {
console.error("Failed to send skill activation rejection email:", err);
}
}
// ---------------------------------------------------------------------------
// Invoice issuance — Phase 3
// ---------------------------------------------------------------------------
/**
* Notify the billing contact when a new invoice has been issued.
* Includes a brief summary (total + due date + line count) so the
* recipient can triage without opening the portal, plus a deep
* link to /billing/<invoice number> where they can download the
* PDF. The PDF itself is NOT attached — it lives in the portal,
* keeps mail payloads small, and avoids the audit-trail headache
* of "which copy is authoritative".
*/
export async function sendInvoiceIssuedEmail(params: {
to: string;
contactName: string;
companyName: string;
invoiceNumber: string;
totalChf: number;
currency: string; // "CHF" — passed for future-proofing
dueAt: string; // ISO date
lineCount: number;
periodStart: string; // ISO date
periodEnd: string; // ISO date
locale: "de" | "en" | "fr" | "it";
}): Promise<void> {
// All four locales — the email is sent in the invoice's locale,
// which was frozen at issue time. No fallback to admin's locale.
const L = params.locale;
const subjectsByLocale: Record<typeof L, string> = {
en: `New invoice ${params.invoiceNumber} from PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`,
de: `Neue Rechnung ${params.invoiceNumber} von PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`,
fr: `Nouvelle facture ${params.invoiceNumber} de PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`,
it: `Nuova fattura ${params.invoiceNumber} da PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`,
};
const greetingsByLocale: Record<typeof L, string> = {
en: `Hello ${params.contactName},`,
de: `Sehr geehrte/r ${params.contactName},`,
fr: `Bonjour ${params.contactName},`,
it: `Gentile ${params.contactName},`,
};
const introByLocale: Record<typeof L, string> = {
en: `A new invoice has been issued for ${params.companyName}.`,
de: `Für ${params.companyName} wurde eine neue Rechnung ausgestellt.`,
fr: `Une nouvelle facture a été émise pour ${params.companyName}.`,
it: `È stata emessa una nuova fattura per ${params.companyName}.`,
};
const labels: Record<typeof L, Record<string, string>> = {
en: { number: "Invoice", period: "Period", total: "Total", due: "Due by", lines: "Line items", cta: "View invoice & download PDF", signoff: "Best regards", brand: "PieCed IT" },
de: { number: "Rechnung", period: "Zeitraum", total: "Gesamt", due: "Zahlbar bis", lines: "Positionen", cta: "Rechnung ansehen & PDF herunterladen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT" },
fr: { number: "Facture", period: "Période", total: "Total", due: "À régler avant", lines: "Lignes", cta: "Voir la facture & télécharger le PDF", signoff: "Cordialement", brand: "PieCed IT" },
it: { number: "Fattura", period: "Periodo", total: "Totale", due: "Scadenza", lines: "Voci", cta: "Visualizza fattura & scarica PDF", signoff: "Cordiali saluti", brand: "PieCed IT" },
};
const l = labels[L];
const safeName = escapeHtml(params.contactName);
const safeCompany = escapeHtml(params.companyName);
const safeNumber = escapeHtml(params.invoiceNumber);
const totalFmt = `${params.currency} ${params.totalChf.toFixed(2)}`;
const periodFmt = `${params.periodStart.slice(0, 10)}${params.periodEnd.slice(0, 10)}`;
const dueFmt = params.dueAt.slice(0, 10);
// Both bodies built in the invoice's locale.
const link = `https://app.pieced.ch/billing/${encodeURIComponent(params.invoiceNumber)}`;
try {
await getTransporter().sendMail({
from: getFrom(),
to: params.to,
subject: subjectsByLocale[L],
text: [
greetingsByLocale[L],
"",
introByLocale[L],
"",
`${l.number}: ${params.invoiceNumber}`,
`${l.period}: ${periodFmt}`,
`${l.total}: ${totalFmt}`,
`${l.due}: ${dueFmt}`,
`${l.lines}: ${params.lineCount}`,
"",
`${l.cta}:`,
link,
"",
`${l.signoff},`,
l.brand,
].join("\n"),
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 560px; padding: 24px; background: #1a1a1a; color: #e5e5e5;">
<h2 style="margin: 0 0 16px; color: #10B981;">${escapeHtml(introByLocale[L])}</h2>
<p>${escapeHtml(greetingsByLocale[L])}</p>
<p>${escapeHtml(introByLocale[L])}</p>
<table style="width:100%; border-collapse:collapse; margin:16px 0; font-size:14px;">
<tr><td style="color:#888; padding:6px 0; width:120px;">${l.number}</td><td><strong>${safeNumber}</strong></td></tr>
<tr><td style="color:#888; padding:6px 0;">${l.period}</td><td>${escapeHtml(periodFmt)}</td></tr>
<tr><td style="color:#888; padding:6px 0;">${l.total}</td><td style="color:#10B981; font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
<tr><td style="color:#888; padding:6px 0;">${l.due}</td><td>${escapeHtml(dueFmt)}</td></tr>
<tr><td style="color:#888; padding:6px 0;">${l.lines}</td><td>${params.lineCount}</td></tr>
</table>
<p>
<a href="${link}" style="display:inline-block; padding:10px 24px; background:#10B981; color:#fff; text-decoration:none; border-radius:8px; font-weight:500;">
${l.cta}
</a>
</p>
<hr style="border:none; border-top:1px solid #333; margin:24px 0;" />
<p style="color:#666; font-size:12px;">${l.brand}</p>
</div>
`,
});
} catch (err) {
console.error("Failed to send invoice issued email:", err);
}
}

View File

@@ -231,6 +231,7 @@ export const PACKAGE_CATALOG: PackageDef[] = [
},
{
id: "gog",
requiresManualSetup: true,
name: "Google Workspace (Gog)",
descriptionKey: "packages.gog.description",
requiresSecrets: true,

View File

@@ -15,7 +15,8 @@
"team": "Team",
"settings": "Einstellungen",
"optional": "optional",
"support": "Support"
"support": "Support",
"billing": "Abrechnung"
},
"login": {
"title": "PieCed Portal",
@@ -287,7 +288,7 @@
"clientSecretPlaceholder": "GOCSPX-…",
"refreshTokenLabel": "Google OAuth Refresh-Token",
"refreshTokenPlaceholder": "1//0g…",
"instructions": "Die Google-Workspace-Integration verwendet OAuth und erfordert derzeit manuelles Onboarding. Bitte eröffnen Sie ein Support-Ticket, um den Setup-Prozess zu starten — wir tauschen die Client-Zugangsdaten und ein Refresh-Token offline aus und aktivieren dann dieses Paket für Ihren Mandanten.",
"instructions": "Google Workspace nutzt OAuth. Erstellen Sie einen OAuth-Client in Ihrem Google-Cloud-Projekt, autorisieren Sie ihn mit den benötigten Scopes (Gmail, Kalender, Drive usw.) und fügen Sie die Zugangsdaten unten ein. Mit dem Absenden werden sie sicher gespeichert und Ihre Aktivierung zur Admin-Prüfung eingereiht — nach Genehmigung wird die Integration automatisch aktiviert.",
"disclaimer": "Mit der Aktivierung der Google-Workspace-Integration autorisieren Sie PieCed, in Ihrem Namen auf Gmail, Kalender, Drive, Docs, Sheets und Kontakte zuzugreifen. Daten fliessen über die Google-APIs, vorbehaltlich der Google-Bedingungen."
},
"mail": {
@@ -315,7 +316,9 @@
"manualReviewPending": "Manuelle Prüfung ausstehend",
"withdraw": "Zurückziehen",
"activationRejected": "Abgelehnt",
"tryAgain": "Erneut versuchen"
"tryAgain": "Erneut versuchen",
"credentialsSaved": "Zugangsdaten gespeichert",
"credentialsSavedTip": "Die eingegebenen Zugangsdaten sind sicher gespeichert und werden verwendet, sobald die Aktivierung vom Admin genehmigt wurde. Sie müssen sie nicht erneut eingeben."
},
"admin": {
"title": "Plattform-Admin",
@@ -390,7 +393,7 @@
"resumeRequestTooltip": "Reaktivierungsanfrage für einen bestehenden Tenant. Bei Genehmigung wird der Tenant wieder aktiviert; keine Provisionierung läuft.",
"openclawTool": "OpenClaw-Versionen",
"billingTool": "Abrechnung →",
"skillsQueueTool": "Skill-Warteschlange"
"skillsQueueTool": "Aktivierungs-Warteschlange"
},
"channelUsers": {
"title": "Autorisierte Benutzer",
@@ -591,17 +594,17 @@
"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",
"skillPricingTitle": "Paket-Preise",
"skillPricingDesc": "Tagespreis und einmalige Einrichtungsgebühr für jedes Paket — Core, Kanal oder Skill. Die Preisgestaltung gilt für jeden Tenant, der das Paket aktiviert.",
"skillCol": "Paket",
"dailyPriceCol": "Tagespreis",
"actionsCol": "",
"remove": "Entfernen",
"noSkillsPriced": "Noch keine Skills bepreist.",
"addSkillLabel": "Skill hinzufügen",
"noSkillsPriced": "Noch keine Pakete bepreist.",
"addSkillLabel": "Paket hinzufügen",
"dailyPriceLabel": "Tagespreis",
"add": "Hinzufügen",
"confirmDeleteSkillPrice": "Preis für {skill} entfernen?",
"confirmDeleteSkillPrice": "Preisgestaltung für {skill} entfernen? Bereits abgerechnete Zeiträume bleiben unberührt.",
"clickToEdit": "Zum Bearbeiten klicken",
"generateFormTitle": "Rechnung erstellen",
"noOrgsToGenerate": "Keine Organisationen mit Tenants gefunden.",
@@ -667,17 +670,17 @@
"intro": "Die Aktivierung von {skill} verursacht folgende Kosten:",
"setupFeeLabel": "Einrichtungsgebühr",
"setupFeeNote": "Einmalig, nur bei erster Aktivierung",
"dailyPriceLabel": "Tagespreis",
"dailyPriceNote": "Pro Kalendertag (UTC) berechnet, an dem der Skill aktiviert ist",
"dayUnit": "Tag",
"monthlyPriceLabel": "Monatspreis",
"monthlyPriceNote": "CHF {daily}/Tag aktiv; Teilmonate werden taggenau berechnet",
"monthUnit": "Monat",
"disclaimer": "Diese Kosten erscheinen auf Ihrer nächsten Monatsrechnung. Mit der Bestätigung stimmen Sie ihnen zu.",
"cancel": "Abbrechen",
"confirm": "Bestätigen & aktivieren",
"confirming": "Aktiviere…"
},
"adminSkills": {
"title": "Skill-Aktivierungs-Warteschlange",
"subtitle": "Kundenanfragen für Skills, die manuelle plattformseitige Einrichtung benötigen. Genehmigen, sobald die Konfiguration steht; ablehnen mit Grund, wenn die Aktivierung nicht möglich ist.",
"title": "Aktivierungs-Warteschlange",
"subtitle": "Kundenanfragen für Pakete, die manuelle plattformseitige Einrichtung benötigen. Genehmigen, sobald die Konfiguration steht; ablehnen mit Grund, wenn die Aktivierung nicht möglich ist.",
"backToAdmin": "Zurück zur Verwaltung",
"emptyQueue": "Keine ausstehenden Skill-Aktivierungsanfragen.",
"requestedAtCol": "Angefragt",
@@ -693,5 +696,47 @@
"reasonLabel": "Grund (wird dem Kunden angezeigt)",
"reasonPlaceholder": "Erklären Sie, warum die Aktivierung nicht erfolgen kann — z. B. fehlende Kundendaten, Hardware nicht verfügbar usw.",
"reasonRequired": "Ein Grund ist für die Ablehnung erforderlich."
},
"customerBilling": {
"title": "Abrechnung",
"subtitle": "Aktueller Zeitraum und Rechnungshistorie. Ausgestellte Rechnungen stehen als PDF-Download bereit.",
"backToBilling": "Zurück zur Abrechnung",
"currentPeriodHeading": "Aktueller Zeitraum",
"historyHeading": "Rechnungshistorie",
"computing": "Berechne aktuellen Periodenbetrag…",
"currentPeriodError": "Aktueller Periodenbetrag konnte nicht geladen werden. Bitte später erneut versuchen.",
"noBillingConfig": "Abrechnungsdaten sind noch nicht hinterlegt. Sobald die Rechnungsadresse Ihrer Organisation eingetragen ist, erscheint hier der laufende Betrag.",
"accruedSoFar": "Bisher in diesem Monat",
"estimatedTotal": "Geschätzter Gesamtbetrag",
"currentInvoiceIssued": "Aktueller Monat bereits abgerechnet",
"refresh": "aktualisieren",
"breakdownToggle": "Aufschlüsselung anzeigen ({count} Positionen)",
"draftNote": "Live-Schätzung. Die endgültige Rechnung kann durch Monatsendrundung, nachgemeldete Nutzungsdaten oder manuelle Anpassungen leicht abweichen.",
"emptyHistory": "Noch keine Rechnungen ausgestellt. Nach Abschluss Ihres ersten Monats erscheinen sie hier.",
"numberCol": "Nummer",
"periodCol": "Zeitraum",
"dueCol": "Fällig",
"totalCol": "Gesamt",
"statusCol": "Status",
"descriptionCol": "Beschreibung",
"qtyCol": "Menge",
"unitCol": "Einzelpreis",
"amountCol": "Betrag",
"billedToLabel": "Rechnungsempfänger",
"issuedAtLabel": "Ausgestellt",
"dueAtLabel": "Zahlbar bis",
"paidAtLabel": "Bezahlt am",
"subtotalLabel": "Zwischensumme",
"vatLabel": "MWST ({rate}%)",
"totalLabel": "Gesamt",
"downloadPdf": "PDF herunterladen",
"status": {
"draft": "Entwurf",
"open": "Offen",
"paid": "Bezahlt",
"overdue": "Überfällig",
"void": "Storniert",
"uncollectible": "Uneinbringlich"
}
}
}

View File

@@ -15,7 +15,8 @@
"team": "Team",
"settings": "Settings",
"optional": "optional",
"support": "Support"
"support": "Support",
"billing": "Billing"
},
"login": {
"title": "PieCed Portal",
@@ -287,7 +288,7 @@
"clientSecretPlaceholder": "GOCSPX-…",
"refreshTokenLabel": "Google OAuth Refresh Token",
"refreshTokenPlaceholder": "1//0g…",
"instructions": "Google Workspace integration uses OAuth and requires manual onboarding for now. Please open a support ticket to start the setup — we'll exchange the client credentials and a refresh token offline, then enable this package on your tenant.",
"instructions": "Google Workspace uses OAuth. Create an OAuth client in your Google Cloud project, authorize it with the scopes you need (Gmail, Calendar, Drive, etc.), then paste the credentials below. Submission stores them securely and queues your activation for admin review — once approved, the integration activates automatically.",
"disclaimer": "By enabling Google Workspace integration you authorize PieCed to access Gmail, Calendar, Drive, Docs, Sheets, and Contacts on your behalf. Data flows through Google's APIs subject to Google's terms."
},
"mail": {
@@ -315,7 +316,9 @@
"manualReviewPending": "Manual review pending",
"withdraw": "Withdraw",
"activationRejected": "Rejected",
"tryAgain": "Try again"
"tryAgain": "Try again",
"credentialsSaved": "credentials saved",
"credentialsSavedTip": "The credentials you entered are securely stored and will be used as soon as admin approves the activation. You don't need to re-enter them."
},
"admin": {
"title": "Platform Admin",
@@ -390,7 +393,7 @@
"resumeRequestTooltip": "Reactivation request for an existing tenant. Approving will un-suspend the tenant; no provisioning runs.",
"openclawTool": "OpenClaw versions",
"billingTool": "Billing →",
"skillsQueueTool": "Skills Queue"
"skillsQueueTool": "Activation Queue"
},
"channelUsers": {
"title": "Authorized Users",
@@ -591,17 +594,17 @@
"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",
"skillPricingTitle": "Package pricing",
"skillPricingDesc": "Set per-day rate and one-time setup fee for any package — core, channel, or skill. Pricing applies to every tenant that enables the package.",
"skillCol": "Package",
"dailyPriceCol": "Daily price",
"actionsCol": "",
"remove": "Remove",
"noSkillsPriced": "No skills are priced yet.",
"addSkillLabel": "Add skill",
"noSkillsPriced": "No packages priced yet.",
"addSkillLabel": "Add package",
"dailyPriceLabel": "Daily price",
"add": "Add",
"confirmDeleteSkillPrice": "Remove pricing for {skill}?",
"confirmDeleteSkillPrice": "Remove pricing for {skill}? Already-billed periods are unaffected.",
"clickToEdit": "Click to edit",
"generateFormTitle": "Generate invoice",
"noOrgsToGenerate": "No organizations with tenants found.",
@@ -667,17 +670,17 @@
"intro": "Activating {skill} will incur the following charges:",
"setupFeeLabel": "Setup fee",
"setupFeeNote": "One-time, charged on first activation only",
"dailyPriceLabel": "Daily price",
"dailyPriceNote": "Charged for each calendar day (UTC) the skill is enabled",
"dayUnit": "day",
"monthlyPriceLabel": "Monthly price",
"monthlyPriceNote": "CHF {daily}/day enabled; partial months prorated by day",
"monthUnit": "month",
"disclaimer": "These charges appear on your next monthly invoice. By confirming you agree to incur them.",
"cancel": "Cancel",
"confirm": "Confirm & activate",
"confirming": "Activating…"
},
"adminSkills": {
"title": "Skill activation queue",
"subtitle": "Customer requests to activate skills that need manual platform-side setup. Approve once configuration is in place; reject with a reason if the activation can't proceed.",
"title": "Activation queue",
"subtitle": "Customer requests to activate packages that need manual platform-side setup. Approve once configuration is in place; reject with a reason if the activation can't proceed.",
"backToAdmin": "Back to Admin",
"emptyQueue": "No pending skill activation requests.",
"requestedAtCol": "Requested",
@@ -693,5 +696,47 @@
"reasonLabel": "Reason (shown to the customer)",
"reasonPlaceholder": "Explain why this can't be activated — e.g. missing customer data, hardware unavailable, etc.",
"reasonRequired": "A reason is required to reject."
},
"customerBilling": {
"title": "Billing",
"subtitle": "Your current period and invoice history. Issued invoices are available as PDF downloads.",
"backToBilling": "Back to billing",
"currentPeriodHeading": "Current period",
"historyHeading": "Invoice history",
"computing": "Computing current period total…",
"currentPeriodError": "Could not load the current period total. Please try again later.",
"noBillingConfig": "Billing details haven't been configured yet. Once your organization's billing address is on file, this widget will show the running total.",
"accruedSoFar": "Accrued this month",
"estimatedTotal": "Estimated total",
"currentInvoiceIssued": "Current month already invoiced",
"refresh": "refresh",
"breakdownToggle": "Show breakdown ({count} line items)",
"draftNote": "Live estimate. The final invoice may differ slightly due to end-of-month rounding, late-arriving usage data, or manual adjustments.",
"emptyHistory": "No invoices issued yet. Once your first month closes, you'll see it here.",
"numberCol": "Number",
"periodCol": "Period",
"dueCol": "Due",
"totalCol": "Total",
"statusCol": "Status",
"descriptionCol": "Description",
"qtyCol": "Qty",
"unitCol": "Unit",
"amountCol": "Amount",
"billedToLabel": "Billed to",
"issuedAtLabel": "Issued",
"dueAtLabel": "Due by",
"paidAtLabel": "Paid on",
"subtotalLabel": "Subtotal",
"vatLabel": "VAT ({rate}%)",
"totalLabel": "Total",
"downloadPdf": "Download PDF",
"status": {
"draft": "Draft",
"open": "Open",
"paid": "Paid",
"overdue": "Overdue",
"void": "Void",
"uncollectible": "Uncollectible"
}
}
}

View File

@@ -15,7 +15,8 @@
"team": "Équipe",
"settings": "Paramètres",
"optional": "facultatif",
"support": "Support"
"support": "Support",
"billing": "Facturation"
},
"login": {
"title": "Portail PieCed",
@@ -287,7 +288,7 @@
"clientSecretPlaceholder": "GOCSPX-…",
"refreshTokenLabel": "Jeton de rafraîchissement Google OAuth",
"refreshTokenPlaceholder": "1//0g…",
"instructions": "L'intégration de Google Workspace utilise OAuth et nécessite actuellement une intégration manuelle. Veuillez ouvrir un ticket de support pour démarrer la configuration — nous échangerons hors ligne les identifiants client et un jeton de rafraîchissement, puis activerons ce package sur votre tenant.",
"instructions": "Google Workspace utilise OAuth. Créez un client OAuth dans votre projet Google Cloud, autorisez-le avec les scopes nécessaires (Gmail, Agenda, Drive, etc.), puis collez les identifiants ci-dessous. La soumission les stocke en sécurité et place votre activation dans la file de revue administrative — après approbation, l'intégration s'active automatiquement.",
"disclaimer": "En activant l'intégration de Google Workspace, vous autorisez PieCed à accéder à Gmail, Agenda, Drive, Docs, Sheets et Contacts en votre nom. Les données transitent par les API de Google, soumises aux conditions de Google."
},
"mail": {
@@ -315,7 +316,9 @@
"manualReviewPending": "Revue manuelle en attente",
"withdraw": "Retirer",
"activationRejected": "Refusée",
"tryAgain": "Réessayer"
"tryAgain": "Réessayer",
"credentialsSaved": "identifiants enregistrés",
"credentialsSavedTip": "Les identifiants saisis sont stockés en sécurité et seront utilisés dès l'approbation de l'activation par l'administrateur. Vous n'avez pas besoin de les ressaisir."
},
"admin": {
"title": "Admin plateforme",
@@ -390,7 +393,7 @@
"resumeRequestTooltip": "Demande de réactivation d'un locataire existant. L'approbation le réactivera ; aucun provisionnement ne s'exécute.",
"openclawTool": "Versions OpenClaw",
"billingTool": "Facturation →",
"skillsQueueTool": "File des skills"
"skillsQueueTool": "File d'activation"
},
"channelUsers": {
"title": "Utilisateurs autorisés",
@@ -591,17 +594,17 @@
"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",
"skillPricingTitle": "Tarification des paquets",
"skillPricingDesc": "Tarif journalier et frais de configuration uniques pour chaque paquet — core, canal ou skill. La tarification s'applique à chaque tenant activant le paquet.",
"skillCol": "Paquet",
"dailyPriceCol": "Prix/jour",
"actionsCol": "",
"remove": "Retirer",
"noSkillsPriced": "Aucun skill n'a encore de prix.",
"addSkillLabel": "Ajouter un skill",
"noSkillsPriced": "Aucun paquet tarifé.",
"addSkillLabel": "Ajouter un paquet",
"dailyPriceLabel": "Prix/jour",
"add": "Ajouter",
"confirmDeleteSkillPrice": "Retirer le prix pour {skill}?",
"confirmDeleteSkillPrice": "Supprimer la tarification de {skill} ? Les périodes déjà facturées ne sont pas affectées.",
"clickToEdit": "Cliquer pour modifier",
"generateFormTitle": "Générer une facture",
"noOrgsToGenerate": "Aucune organisation avec tenants trouvée.",
@@ -667,17 +670,17 @@
"intro": "L'activation de {skill} entraînera les frais suivants :",
"setupFeeLabel": "Frais de configuration",
"setupFeeNote": "Unique, facturé uniquement à la première activation",
"dailyPriceLabel": "Prix journalier",
"dailyPriceNote": "Facturé pour chaque jour calendaire (UTC) où le skill est activé",
"dayUnit": "jour",
"monthlyPriceLabel": "Prix mensuel",
"monthlyPriceNote": "CHF {daily}/jour actif ; mois partiels prorata journalier",
"monthUnit": "mois",
"disclaimer": "Ces frais figureront sur votre prochaine facture mensuelle. En confirmant, vous acceptez de les engager.",
"cancel": "Annuler",
"confirm": "Confirmer & activer",
"confirming": "Activation…"
},
"adminSkills": {
"title": "File d'activation des skills",
"subtitle": "Demandes clients d'activation de skills nécessitant une configuration manuelle côté plateforme. Approuver une fois la configuration en place ; refuser avec un motif si l'activation est impossible.",
"title": "File d'activation",
"subtitle": "Demandes clients d'activation de paquets nécessitant une configuration manuelle côté plateforme. Approuver une fois la configuration en place ; refuser avec un motif si l'activation est impossible.",
"backToAdmin": "Retour à l'administration",
"emptyQueue": "Aucune demande d'activation en attente.",
"requestedAtCol": "Demandée le",
@@ -693,5 +696,47 @@
"reasonLabel": "Motif (visible par le client)",
"reasonPlaceholder": "Expliquez pourquoi l'activation ne peut pas se faire — ex. données client manquantes, matériel indisponible, etc.",
"reasonRequired": "Un motif est requis pour refuser."
},
"customerBilling": {
"title": "Facturation",
"subtitle": "Période en cours et historique des factures. Les factures émises sont disponibles en téléchargement PDF.",
"backToBilling": "Retour à la facturation",
"currentPeriodHeading": "Période en cours",
"historyHeading": "Historique des factures",
"computing": "Calcul du total de la période en cours…",
"currentPeriodError": "Impossible de charger le total de la période en cours. Veuillez réessayer plus tard.",
"noBillingConfig": "Les informations de facturation ne sont pas encore configurées. Une fois l'adresse de facturation de votre organisation enregistrée, le total en cours apparaîtra ici.",
"accruedSoFar": "Cumulé ce mois",
"estimatedTotal": "Total estimé",
"currentInvoiceIssued": "Mois en cours déjà facturé",
"refresh": "actualiser",
"breakdownToggle": "Afficher le détail ({count} lignes)",
"draftNote": "Estimation en direct. La facture finale peut légèrement varier en raison d'arrondis de fin de mois, de données d'utilisation tardives ou d'ajustements manuels.",
"emptyHistory": "Aucune facture émise pour le moment. Après la clôture de votre premier mois, elles apparaîtront ici.",
"numberCol": "Numéro",
"periodCol": "Période",
"dueCol": "Échéance",
"totalCol": "Total",
"statusCol": "Statut",
"descriptionCol": "Description",
"qtyCol": "Qté",
"unitCol": "Prix unitaire",
"amountCol": "Montant",
"billedToLabel": "Facturé à",
"issuedAtLabel": "Émise le",
"dueAtLabel": "À régler avant",
"paidAtLabel": "Payée le",
"subtotalLabel": "Sous-total",
"vatLabel": "TVA ({rate}%)",
"totalLabel": "Total",
"downloadPdf": "Télécharger le PDF",
"status": {
"draft": "Brouillon",
"open": "Ouverte",
"paid": "Payée",
"overdue": "En retard",
"void": "Annulée",
"uncollectible": "Irrécouvrable"
}
}
}

View File

@@ -15,7 +15,8 @@
"team": "Team",
"settings": "Impostazioni",
"optional": "facoltativo",
"support": "Supporto"
"support": "Supporto",
"billing": "Fatturazione"
},
"login": {
"title": "Portale PieCed",
@@ -287,7 +288,7 @@
"clientSecretPlaceholder": "GOCSPX-…",
"refreshTokenLabel": "Token di refresh Google OAuth",
"refreshTokenPlaceholder": "1//0g…",
"instructions": "L'integrazione con Google Workspace utilizza OAuth e richiede attualmente un onboarding manuale. Apri un ticket di supporto per avviare la configurazione — scambieremo le credenziali del client e un token di refresh offline, quindi abiliteremo questo pacchetto sul tuo tenant.",
"instructions": "Google Workspace utilizza OAuth. Crea un client OAuth nel tuo progetto Google Cloud, autorizzalo con gli scope necessari (Gmail, Calendar, Drive, ecc.), quindi incolla le credenziali qui sotto. L'invio le memorizza in modo sicuro e mette in coda l'attivazione per la revisione amministrativa — dopo l'approvazione, l'integrazione si attiva automaticamente.",
"disclaimer": "Abilitando l'integrazione con Google Workspace autorizzi PieCed ad accedere per tuo conto a Gmail, Calendar, Drive, Docs, Sheets e Contatti. I dati transitano attraverso le API di Google, soggetti ai termini di Google."
},
"mail": {
@@ -315,7 +316,9 @@
"manualReviewPending": "Revisione manuale in attesa",
"withdraw": "Ritira",
"activationRejected": "Rifiutata",
"tryAgain": "Riprova"
"tryAgain": "Riprova",
"credentialsSaved": "credenziali salvate",
"credentialsSavedTip": "Le credenziali inserite sono memorizzate in modo sicuro e saranno utilizzate non appena l'attivazione viene approvata dall'amministratore. Non è necessario reinserirle."
},
"admin": {
"title": "Admin piattaforma",
@@ -390,7 +393,7 @@
"resumeRequestTooltip": "Richiesta di riattivazione di un tenant esistente. L'approvazione lo riattiverà; non viene eseguito alcun provisioning.",
"openclawTool": "Versioni OpenClaw",
"billingTool": "Fatturazione →",
"skillsQueueTool": "Coda skill"
"skillsQueueTool": "Coda di attivazione"
},
"channelUsers": {
"title": "Utenti autorizzati",
@@ -591,17 +594,17 @@
"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",
"skillPricingTitle": "Prezzi dei pacchetti",
"skillPricingDesc": "Tariffa giornaliera e spese di attivazione una tantum per qualsiasi pacchetto — core, canale o skill. La tariffazione si applica a ogni tenant che attiva il pacchetto.",
"skillCol": "Pacchetto",
"dailyPriceCol": "Prezzo/giorno",
"actionsCol": "",
"remove": "Rimuovi",
"noSkillsPriced": "Nessuna skill ha ancora un prezzo.",
"addSkillLabel": "Aggiungi skill",
"noSkillsPriced": "Nessun pacchetto con prezzo.",
"addSkillLabel": "Aggiungi pacchetto",
"dailyPriceLabel": "Prezzo/giorno",
"add": "Aggiungi",
"confirmDeleteSkillPrice": "Rimuovere il prezzo per {skill}?",
"confirmDeleteSkillPrice": "Rimuovere la tariffazione per {skill}? I periodi già fatturati non sono influenzati.",
"clickToEdit": "Clicca per modificare",
"generateFormTitle": "Genera fattura",
"noOrgsToGenerate": "Nessuna organizzazione con tenant trovata.",
@@ -667,17 +670,17 @@
"intro": "L'attivazione di {skill} comporterà i seguenti costi:",
"setupFeeLabel": "Spese di attivazione",
"setupFeeNote": "Una tantum, addebitate solo alla prima attivazione",
"dailyPriceLabel": "Prezzo giornaliero",
"dailyPriceNote": "Addebitato per ogni giorno di calendario (UTC) in cui lo skill è attivato",
"dayUnit": "giorno",
"monthlyPriceLabel": "Prezzo mensile",
"monthlyPriceNote": "CHF {daily}/giorno attivo; mesi parziali calcolati al giorno",
"monthUnit": "mese",
"disclaimer": "Questi costi appariranno sulla prossima fattura mensile. Confermando accetti di sostenerli.",
"cancel": "Annulla",
"confirm": "Conferma & attiva",
"confirming": "Attivazione…"
},
"adminSkills": {
"title": "Coda di attivazione skill",
"subtitle": "Richieste dei clienti per attivare skill che richiedono configurazione manuale lato piattaforma. Approva quando la configurazione è pronta; rifiuta con motivazione se l'attivazione non è possibile.",
"title": "Coda di attivazione",
"subtitle": "Richieste dei clienti per attivare pacchetti che richiedono configurazione manuale lato piattaforma. Approva quando la configurazione è pronta; rifiuta con motivazione se l'attivazione non è possibile.",
"backToAdmin": "Torna ad amministrazione",
"emptyQueue": "Nessuna richiesta di attivazione skill in attesa.",
"requestedAtCol": "Richiesta",
@@ -693,5 +696,47 @@
"reasonLabel": "Motivo (mostrato al cliente)",
"reasonPlaceholder": "Spiega perché l'attivazione non può procedere — es. dati cliente mancanti, hardware non disponibile, ecc.",
"reasonRequired": "Un motivo è necessario per rifiutare."
},
"customerBilling": {
"title": "Fatturazione",
"subtitle": "Periodo corrente e cronologia delle fatture. Le fatture emesse sono disponibili come download PDF.",
"backToBilling": "Torna alla fatturazione",
"currentPeriodHeading": "Periodo corrente",
"historyHeading": "Cronologia fatture",
"computing": "Calcolo del totale del periodo corrente…",
"currentPeriodError": "Impossibile caricare il totale del periodo corrente. Riprova più tardi.",
"noBillingConfig": "I dati di fatturazione non sono ancora configurati. Una volta registrato l'indirizzo di fatturazione della tua organizzazione, il totale corrente apparirà qui.",
"accruedSoFar": "Accumulato questo mese",
"estimatedTotal": "Totale stimato",
"currentInvoiceIssued": "Mese corrente già fatturato",
"refresh": "aggiorna",
"breakdownToggle": "Mostra dettaglio ({count} voci)",
"draftNote": "Stima in tempo reale. La fattura finale può variare leggermente per arrotondamenti di fine mese, dati di utilizzo in ritardo o aggiustamenti manuali.",
"emptyHistory": "Nessuna fattura emessa ancora. Dopo la chiusura del primo mese, appariranno qui.",
"numberCol": "Numero",
"periodCol": "Periodo",
"dueCol": "Scadenza",
"totalCol": "Totale",
"statusCol": "Stato",
"descriptionCol": "Descrizione",
"qtyCol": "Qtà",
"unitCol": "Prezzo unitario",
"amountCol": "Importo",
"billedToLabel": "Fatturato a",
"issuedAtLabel": "Emessa il",
"dueAtLabel": "Scadenza",
"paidAtLabel": "Pagata il",
"subtotalLabel": "Subtotale",
"vatLabel": "IVA ({rate}%)",
"totalLabel": "Totale",
"downloadPdf": "Scarica PDF",
"status": {
"draft": "Bozza",
"open": "Aperta",
"paid": "Pagata",
"overdue": "In ritardo",
"void": "Annullata",
"uncollectible": "Inesigibile"
}
}
}