Compare commits

..

21 Commits

Author SHA1 Message Date
6fed5b083b Phase7: Void/Refund logic
All checks were successful
Build and Push / build (push) Successful in 1m42s
2026-05-25 22:39:27 +02:00
4f868d751e Phase7: Void/Refund logic
All checks were successful
Build and Push / build (push) Successful in 1m43s
2026-05-25 22:00:24 +02:00
e15a668f8e Phase7: Void/Refund logic
Some checks failed
Build and Push / build (push) Failing after 52s
2026-05-25 21:54:51 +02:00
9cd9879a18 Phase6c: Optional Company contact name
All checks were successful
Build and Push / build (push) Successful in 1m42s
2026-05-25 20:21:26 +02:00
323786672f Phase6c: Optional Company contact name
All checks were successful
Build and Push / build (push) Successful in 1m42s
2026-05-25 14:08:18 +02:00
a1769eeb00 Phase6c: Optional Company contact name
All checks were successful
Build and Push / build (push) Successful in 1m40s
2026-05-25 13:50:16 +02:00
002867850d Phase6c: Optional Company contact name
All checks were successful
Build and Push / build (push) Successful in 1m38s
2026-05-25 13:28:56 +02:00
eea027b3b0 Phase6c: Optional Company contact name
All checks were successful
Build and Push / build (push) Successful in 1m38s
2026-05-25 13:14:36 +02:00
522246e386 Phase6c: Optional Company contact name
All checks were successful
Build and Push / build (push) Successful in 1m40s
2026-05-25 12:54:12 +02:00
b3131f7710 Phase6: Customer Billing details
All checks were successful
Build and Push / build (push) Successful in 1m43s
2026-05-25 12:15:48 +02:00
fadfdd3435 Phase6: Customer Billing details
All checks were successful
Build and Push / build (push) Successful in 1m46s
2026-05-25 11:47:14 +02:00
427c7c6204 Phase5: Automate bill creation
All checks were successful
Build and Push / build (push) Successful in 1m43s
2026-05-25 10:41:51 +02:00
6a8ad7b4be Phase4: Stripe
All checks were successful
Build and Push / build (push) Successful in 1m40s
2026-05-25 00:14:20 +02:00
875ade4351 Phase4: Stripe
All checks were successful
Build and Push / build (push) Successful in 1m40s
2026-05-24 23:59:05 +02:00
2a0bb10531 Phase4: Stripe
Some checks failed
Build and Push / build (push) Failing after 56s
2026-05-24 23:54:49 +02:00
262250564a Phase4: Stripe
Some checks failed
Build and Push / build (push) Failing after 53s
2026-05-24 23:48:39 +02:00
a680d6de9f Phase4: Stripe
Some checks failed
Build and Push / build (push) Failing after 38s
2026-05-24 23:37:48 +02:00
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
60 changed files with 7479 additions and 274 deletions

18
package-lock.json generated
View File

@@ -19,6 +19,7 @@
"pg": "^8.20.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"stripe": "^22.1.1",
"zod": "^3.24.0"
},
"devDependencies": {
@@ -7530,6 +7531,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/stripe": {
"version": "22.1.1",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-22.1.1.tgz",
"integrity": "sha512-cmodIYP27tBkJ8G7DuGgWw0PFuemlFZbuF3Wwr1TrjFjUa3T7NIgCe6TVwX8BO2ynu+xtTuDGfHafNDCPt9lXA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",

View File

@@ -21,6 +21,7 @@
"pg": "^8.20.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"stripe": "^22.1.1",
"zod": "^3.24.0"
},
"devDependencies": {

View File

@@ -1,7 +1,7 @@
import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { getInvoiceDetail } from "@/lib/db";
import { getInvoiceDetail, listCreditNotesForInvoice } from "@/lib/db";
import { BackLink } from "@/components/ui/back-link";
import { InvoiceDetailView } from "@/components/admin/billing/invoice-detail-view";
@@ -9,8 +9,12 @@ import { InvoiceDetailView } from "@/components/admin/billing/invoice-detail-vie
* /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.
* snapshot); the action bar (mark-paid, void, refund, delete, PDF
* download) is a client component for the interactive bits.
*
* Phase 7: also passes any linked credit notes so the detail view
* can show the "this invoice was voided / partially refunded" panel
* without an extra round-trip.
*/
export default async function AdminInvoiceDetailPage({
params,
@@ -25,11 +29,12 @@ export default async function AdminInvoiceDetailPage({
const { id } = await params;
const detail = await getInvoiceDetail(id);
if (!detail) notFound();
const creditNotes = await listCreditNotesForInvoice(id);
return (
<main className="max-w-4xl mx-auto px-6 py-8">
<BackLink href="/admin/billing/invoices" label={t("backToInvoices")} />
<InvoiceDetailView detail={detail} />
<InvoiceDetailView detail={detail} creditNotes={creditNotes} />
</main>
);
}

View File

@@ -0,0 +1,44 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import {
getLastSuccessfulCronRuns,
listRecentCronRuns,
} from "@/lib/db";
import { CronControls } from "@/components/admin/cron/cron-controls";
/**
* /admin/cron — automation dashboard.
*
* Shows:
* - Last successful run of each kind, with relative time
* - Two "Run now" buttons (admin-triggered manual sweeps)
* - Recent runs table (last 30)
*
* Platform-admin gated server-side.
*/
export default async function AdminCronPage() {
const user = await getSessionUser();
if (!user || !user.isPlatform) redirect("/login");
const t = await getTranslations("adminCron");
const [recent, lastSuccess] = await Promise.all([
listRecentCronRuns(30),
getLastSuccessfulCronRuns(),
]);
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>
<CronControls
initialRecent={recent}
initialLastSuccess={lastSuccess}
/>
</main>
);
}

View File

@@ -61,6 +61,12 @@ export default async function AdminPage() {
>
{t("billingTool")}
</a>
<a
href="/admin/cron"
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("cronTool")}
</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"

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,85 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import {
listCreditNotesForOrg,
listInvoices,
syncOverdueInvoices,
} from "@/lib/db";
import { CustomerInvoiceList } from "@/components/billing/customer-invoice-list";
import { CustomerCreditNoteList } from "@/components/billing/customer-credit-note-list";
import { RunningTotalWidget } from "@/components/billing/running-total-widget";
/**
* /billing — customer's billing home.
*
* Shows three 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.
* 3. CustomerCreditNoteList — Phase 7. Credit notes (voids and
* refunds) for this org, with PDF download links. Hidden
* entirely when there are none (the common case).
*
* Anyone signed in can view this. The data is org-scoped; even
* non-owner team members see the same view.
*/
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);
}
// Parallel fetch — invoices + credit notes are independent.
const [invoices, creditNotes] = await Promise.all([
listInvoices({ zitadelOrgId: user.orgId, limit: 200 }),
listCreditNotesForOrg(user.orgId, 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>
{/* Phase 6: pass the owner flag so the no-config CTA shows
the right call-to-action vs the right hint. */}
<RunningTotalWidget isOwner={user.roles.includes("owner")} />
</section>
<section className="animate-in animate-in-delay-2 mb-8">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("historyHeading")}
</h2>
<CustomerInvoiceList invoices={invoices} />
</section>
{/* Phase 7: credit-note section. CustomerCreditNoteList itself
returns null when there are no credit notes, so this whole
section disappears for orgs in normal operation. */}
{creditNotes.length > 0 && (
<section className="animate-in animate-in-delay-3">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("creditNotesHeading")}
</h2>
<CustomerCreditNoteList creditNotes={creditNotes} />
</section>
)}
</main>
);
}

View File

@@ -76,6 +76,7 @@ export default async function NewInstancePage() {
userName={user.name}
userEmail={user.email}
hasOrgBilling={hasOrgBilling}
existingOrgBilling={orgBilling}
/>
</div>
</div>

View File

@@ -317,6 +317,7 @@ export default async function DashboardPage() {
userName={user.name}
userEmail={user.email}
hasOrgBilling={hasOrgBilling}
existingOrgBilling={orgBilling}
/>
</div>
</div>

View File

@@ -1,30 +1,31 @@
import { getTranslations } from "next-intl/server";
import { redirect, notFound } from "next/navigation";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { getOrgBilling } from "@/lib/db";
import { BillingSettingsForm } from "@/components/settings/billing-settings-form";
import { BillingSettingsForm } from "@/components/settings/billing-form";
/**
* /settings/billing — view and edit org-scoped billing (Bug 34/35).
* /settings/billing — customer-side billing details management.
*
* Server-side fetches the existing record (if any) and passes it to
* the client form. The form posts to PUT /api/billing on submit.
* Owner-only by visibility: non-owner members get a 404 (same
* response as if the page didn't exist). The link to this page
* is also hidden from non-owners on /billing and elsewhere, but
* the page itself enforces too — a non-owner who learns the URL
* still gets 404, not 403, so the page's existence doesn't leak.
*
* Access: same gate as the API — owners and platform admins. `user`
* role redirects to /settings (which also wouldn't list billing for
* them). 403 here would be friendlier than redirect, but the most
* likely cause of a `user` landing on this URL is sharing a bookmark
* with their owner — silent redirect is gentle.
* First-time visitors see an empty form. Subsequent visits see
* the current values, editable. Save creates or updates via the
* shared upsert path; the row's existence drives whether the
* monthly issuance cron will pick this org up.
*/
export default async function BillingSettingsPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
if (!canMutate(user)) {
redirect("/settings");
}
const t = await getTranslations("settingsBilling");
// Non-owners get a 404 — see comment above.
if (!user.roles.includes("owner")) notFound();
const billing = await getOrgBilling(user.orgId);
const t = await getTranslations("settingsBilling");
const existing = await getOrgBilling(user.orgId);
return (
<main className="max-w-3xl mx-auto px-6 py-8">
@@ -32,16 +33,16 @@ export default async function BillingSettingsPage() {
<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>
<p className="text-sm text-text-secondary mt-3">
{user.isPersonal ? t("subtitlePersonal") : t("subtitle")}
</p>
</div>
<div className="animate-in animate-in-delay-1">
<BillingSettingsForm
initial={existing}
isPersonal={user.isPersonal}
/>
</div>
<BillingSettingsForm
initial={billing}
isPersonal={user.isPersonal}
orgName={user.orgName}
userName={user.name}
userEmail={user.email}
/>
</main>
);
}

View File

@@ -20,8 +20,9 @@ export default async function SettingsPage() {
const t = await getTranslations("settings");
// Build the list of settings cards. Each entry has a stable key, a
// route, and a visibility predicate. Currently only billing; this
// shape leaves headroom for adding more without restructuring.
// route, and a visibility predicate. Phase 6 fix5: profile is
// visible to every signed-in user (it's their own identity).
// Billing stays gated behind canMutate.
const sections: Array<{
key: string;
href: string;
@@ -29,6 +30,14 @@ export default async function SettingsPage() {
description: string;
visible: boolean;
}> = [
{
key: "profile",
href: "/settings/profile",
title: t("profileTitle"),
description: t("profileDescription"),
// Every signed-in user can edit their own first/last name.
visible: true,
},
{
key: "billing",
href: "/settings/billing",

View File

@@ -0,0 +1,68 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { getHumanUserDetail } from "@/lib/zitadel";
import { ProfileSettingsForm } from "@/components/settings/profile-form";
/**
* /settings/profile — every authenticated user can edit their own
* first + last name. Email is shown read-only; changing it requires
* verification and is left to ZITADEL's own self-service flow.
*
* Personal vs company accounts:
* - Both can edit their first/last name in ZITADEL.
* - Personal accounts get an extra hint: editing the ZITADEL name
* does NOT change how the customer's name appears on invoices.
* Invoice identity is in org_billing.company_name (the "Full
* name" field on /settings/billing) and is intentionally
* editable separately, because legal/billing identity may not
* match preferred display identity.
* - Company accounts see an org-membership hint instead.
*
* Server-fetches the current profile from ZITADEL via the
* service-account PAT so the form starts with the canonical values
* rather than whatever happens to be in the JWT (the JWT name might
* be stale if the user updated their name in ZITADEL Console).
*/
export default async function ProfileSettingsPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
const t = await getTranslations("settingsProfile");
let initial = { firstName: "", lastName: "", email: user.email };
try {
const profile = await getHumanUserDetail(user.id);
initial = {
firstName: profile.givenName,
lastName: profile.familyName,
email: profile.email || user.email,
};
} catch (e) {
// Identity provider unreachable: render the form with whatever
// we know from the session. The session has a combined `name`,
// not split parts, so we leave first/last empty and let the user
// re-enter. Server logs catch the underlying failure.
console.error("ProfileSettingsPage: getHumanUserDetail failed:", e);
}
return (
<main className="max-w-3xl 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">
{user.isPersonal ? t("subtitlePersonal") : t("subtitle")}
</p>
</div>
<div className="animate-in animate-in-delay-1">
<ProfileSettingsForm
initial={initial}
isPersonal={user.isPersonal}
orgName={user.orgName}
/>
</div>
</main>
);
}

View File

@@ -0,0 +1,88 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requirePlatformRole, getSessionUser } from "@/lib/session";
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
import { safeError } from "@/lib/errors";
/**
* POST /api/admin/billing/invoices/[id]/refund
*
* Phase 7. Refunds a paid invoice (full or partial) and issues a
* credit note. For Stripe-paid invoices, calls Stripe's Refund API
* before any local recording. For invoice-paid customers (bank
* transfer), records the refund locally and assumes the admin
* handled the actual money movement out-of-band.
*
* Body:
* {
* amountChf: number, // positive, <= remaining refundable
* reason: string // required, free-text, max 500
* }
*
* Authorization: platform admin.
*
* Status codes:
* 200 — refund issued, credit note returned
* 400 — bad request (zero/negative amount, etc.)
* 401 / 403 — not authenticated / not platform admin
* 409 — invoice not in a refundable state, or amount exceeds remaining
* 500 — Stripe call failed or another internal error
*
* Idempotency caveats: this endpoint is NOT idempotent against
* client retries. Issuing two refunds quickly will result in two
* Stripe refund calls (and two credit notes). The admin UI should
* disable the submit button while the request is in flight to
* prevent accidental double-clicks. The Stripe charge.refunded
* webhook is idempotent and will not double-count if it fires
* after this endpoint already recorded the refund.
*/
const bodySchema = z.object({
amountChf: z.number().positive().multipleOf(0.01),
reason: z.string().trim().min(1).max(500),
});
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 creditNote = await refundInvoice({
invoiceId: id,
amountChf: parsed.data.amountChf,
reason: parsed.data.reason,
refundedBy: user.id,
});
return NextResponse.json({ creditNote });
} catch (e) {
if (e instanceof RefundNotAllowedError) {
return NextResponse.json(
{ error: e.message, currentStatus: e.currentStatus },
{ status: 409 }
);
}
return NextResponse.json(
{ error: safeError(e, "Refund failed") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requirePlatformRole, getSessionUser } from "@/lib/session";
import { voidInvoice, VoidNotAllowedError } from "@/lib/billing";
import { safeError } from "@/lib/errors";
/**
* POST /api/admin/billing/invoices/[id]/void
*
* Phase 7. Voids an unpaid invoice and issues a credit note.
*
* Body:
* {
* reason: string // required, free-text, max 500
* }
*
* Authorization: platform admin (same as mark-paid, generate, etc.).
* The acting user's ID lands in invoices.voided_by and on the
* credit_notes.issued_by audit columns.
*
* Status codes:
* 200 — voided, credit note returned in body
* 400 — bad request (missing reason etc.)
* 401 / 403 — not authenticated / not platform admin
* 409 — invoice not in a voidable state
* 500 — anything else (Stripe shouldn't apply here, but if PDF
* render fails the void still went through — see body
* payload for the credit-note number to re-render later)
*/
const bodySchema = z.object({
reason: z.string().trim().min(1).max(500),
});
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 creditNote = await voidInvoice({
invoiceId: id,
reason: parsed.data.reason,
voidedBy: user.id,
});
return NextResponse.json({ creditNote });
} catch (e) {
if (e instanceof VoidNotAllowedError) {
return NextResponse.json(
{ error: e.message, currentStatus: e.currentStatus },
{ status: 409 }
);
}
return NextResponse.json(
{ error: safeError(e, "Void failed") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,68 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser, requirePlatformRole } from "@/lib/session";
import { runMonthlyIssuance } from "@/lib/cron";
import { safeError } from "@/lib/errors";
/**
* POST /api/admin/cron/issue-monthly
*
* Admin-side manual trigger for the issuance sweep — same business
* logic as /api/cron/issue-monthly, different auth (session-based
* platform role check) and the option to override the target
* year/month from the request body.
*
* Body (all optional):
* { year?: number, month?: number }
*
* Default target is the previous local month — matching what the
* automated cron would do. Override is useful for catching up after
* a failed run or re-billing a past month after fixing data.
*/
const bodySchema = z.object({
year: z.number().int().min(2000).max(3000).optional(),
month: z.number().int().min(1).max(12).optional(),
});
export async function POST(request: Request) {
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 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 }
);
}
if (
(parsed.data.year && !parsed.data.month) ||
(parsed.data.month && !parsed.data.year)
) {
return NextResponse.json(
{ error: "year and month must both be provided, or neither" },
{ status: 400 }
);
}
try {
const { runId, summary } = await runMonthlyIssuance({
triggeredBy: user.id,
year: parsed.data.year,
month: parsed.data.month,
});
return NextResponse.json({ runId, ...summary });
} catch (e) {
return NextResponse.json(
{ error: safeError(e, "Issuance sweep failed.") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import {
getLastSuccessfulCronRuns,
listRecentCronRuns,
} from "@/lib/db";
/**
* GET /api/admin/cron/runs
*
* Returns recent cron run history plus per-kind "last successful"
* summary for the admin /admin/cron dashboard.
*
* Response: { recent: CronRun[]; lastSuccess: { monthlyIssue, reminders } }
*/
export async function GET() {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const [recent, lastSuccess] = await Promise.all([
listRecentCronRuns(30),
getLastSuccessfulCronRuns(),
]);
return NextResponse.json({ recent, lastSuccess });
}

View File

@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { getSessionUser, requirePlatformRole } from "@/lib/session";
import { runReminderSweep } from "@/lib/cron";
import { safeError } from "@/lib/errors";
/**
* POST /api/admin/cron/send-reminders
*
* Admin-side manual trigger for the reminder sweep. Same logic
* as the machine path; session-based platform-role auth.
*/
export async function POST() {
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 });
}
try {
const { runId, summary } = await runReminderSweep({
triggeredBy: user.id,
});
return NextResponse.json({ runId, ...summary });
} catch (e) {
return NextResponse.json(
{ error: safeError(e, "Reminder sweep failed.") },
{ status: 500 }
);
}
}

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,105 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import {
getInvoiceByNumberForOrg,
getOrgBilling,
} from "@/lib/db";
import {
createCheckoutSessionForInvoice,
ensureStripeCustomerForOrg,
} from "@/lib/stripe";
import { safeError } from "@/lib/errors";
/**
* POST /api/billing/invoices/[invoiceNumber]/pay
*
* Initiates a Stripe Checkout Session for an open invoice. Returns
* `{ url }` — the browser is expected to navigate to that URL,
* where Stripe hosts the payment UI.
*
* Authorization: caller must belong to the invoice's org (the DB
* query enforces this — wrong-org returns 404, indistinguishable
* from a non-existent invoice).
*
* Preconditions enforced server-side:
* - Invoice exists for caller's org
* - Invoice status is 'open' or 'overdue' (paid/void/draft/uncollectible
* all reject — already-paid invoices in particular must not
* create a second Checkout Session, even though Stripe would
* deduplicate the actual charge)
*
* The Stripe Customer for the org is lazily ensured here — first
* card click on an org creates the customer; subsequent clicks
* reuse the persisted stripe_customer_id.
*/
export async function POST(
_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 });
}
const inv = detail.invoice;
if (inv.status !== "open" && inv.status !== "overdue") {
return NextResponse.json(
{
error:
inv.status === "paid"
? "This invoice has already been paid."
: `This invoice cannot be paid online (status: ${inv.status}).`,
},
{ status: 409 }
);
}
// We need org_billing for the customer creation address. The
// invoice has a SNAPSHOT but that's frozen at issue time; for
// creating/updating the Stripe customer we want the current
// address (which may have been corrected since the invoice).
// Snapshot is still authoritative on the invoice PDF and total.
const orgBilling = await getOrgBilling(user.orgId);
if (!orgBilling) {
return NextResponse.json(
{ error: "Billing details are not configured for your organization." },
{ status: 400 }
);
}
try {
const customerId = await ensureStripeCustomerForOrg({
zitadelOrgId: user.orgId,
companyName: orgBilling.companyName,
billingEmail: orgBilling.billingEmail,
address: {
line1: orgBilling.streetAddress,
postalCode: orgBilling.postalCode,
city: orgBilling.city,
country: orgBilling.country,
},
});
const baseUrl =
process.env.APP_BASE_URL ?? "https://app.pieced.ch";
const { url } = await createCheckoutSessionForInvoice({
invoice: inv,
customerId,
baseUrl,
});
return NextResponse.json({ url });
} catch (e) {
console.error(
`Failed to create Checkout Session for invoice ${invoiceNumber}:`,
e
);
return NextResponse.json(
{ error: safeError(e, "Failed to start card payment.") },
{ status: 500 }
);
}
}

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

@@ -0,0 +1,64 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import {
getCreditNoteByNumber,
getCreditNoteByNumberForOrg,
getCreditNotePdf,
} from "@/lib/db";
/**
* GET /api/credit-notes/[number]/pdf
*
* Phase 7. Customer-facing PDF download for a credit note. Returns
* the binary PDF with Content-Disposition: inline so the browser
* renders it in-tab (matching the invoice download behaviour). The
* customer's email links here.
*
* Authorization:
* - The caller must be authenticated.
* - For customer-org callers, the credit note must belong to their
* org (orgId-scoped lookup).
* - Platform admins can fetch any credit note (cross-org lookup).
*
* Returns 404 in both "doesn't exist" and "exists but not yours"
* cases — leak-safe identical to invoice lookup.
*/
export async function GET(
_request: Request,
{ params }: { params: Promise<{ number: string }> }
) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { number } = await params;
// URL-decoded number — the route param comes URL-encoded.
const decodedNumber = decodeURIComponent(number);
const cn = user.isPlatform
? await getCreditNoteByNumber(decodedNumber)
: await getCreditNoteByNumberForOrg(decodedNumber, user.orgId);
if (!cn) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const pdf = await getCreditNotePdf(cn.id);
if (!pdf) {
// The credit note exists but the PDF was never attached. Most
// likely a render failure during issuance — the credit note
// row is still authoritative, the PDF needs re-rendering.
return NextResponse.json(
{
error:
"Credit note exists but its PDF has not been rendered. Please contact support.",
},
{ status: 502 }
);
}
return new NextResponse(new Uint8Array(pdf.data), {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="${pdf.filename}"`,
"Cache-Control": "private, no-cache",
},
});
}

View File

@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { runMonthlyIssuance, verifyCronBearer } from "@/lib/cron";
import { safeError } from "@/lib/errors";
/**
* POST /api/cron/issue-monthly
*
* Machine entry point for the monthly issuance sweep. Authentication
* is the shared bearer token in CRON_BEARER_TOKEN, injected from
* OpenBao via the portal-cron K8s Secret. The K8s CronJob sends:
*
* curl -X POST -H "Authorization: Bearer $CRON_BEARER_TOKEN" \
* https://app.pieced.ch/api/cron/issue-monthly
*
* The sweep targets the calendar month that ended just before
* "now" in Europe/Zurich. Running it on June 1st at 00:30 Swiss
* time bills May; running it on July 5th bills June; etc. The
* uniqueness constraint on (org, period_start) makes re-runs
* harmless — already-issued orgs are counted as skipped.
*
* Returns the summary {success, failure, skipped} JSON. The
* CronJob doesn't look at the response body (just the status
* code) but having a useful one helps debugging via curl.
*/
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (!verifyCronBearer(request.headers.get("authorization"))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { runId, summary } = await runMonthlyIssuance({
triggeredBy: "cron",
});
return NextResponse.json({ runId, ...summary });
} catch (e) {
return NextResponse.json(
{ error: safeError(e, "Issuance sweep failed.") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { runReminderSweep, verifyCronBearer } from "@/lib/cron";
import { safeError } from "@/lib/errors";
/**
* POST /api/cron/send-reminders
*
* Machine entry point for the daily reminder sweep. Same auth
* (bearer token in CRON_BEARER_TOKEN) and the same response
* contract as /api/cron/issue-monthly.
*
* Schedule: 09:00 Europe/Zurich daily. Picks invoices that are
* past their due date and haven't received the corresponding
* reminder level yet; sends one email per invoice per run.
*/
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (!verifyCronBearer(request.headers.get("authorization"))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { runId, summary } = await runReminderSweep({
triggeredBy: "cron",
});
return NextResponse.json({ runId, ...summary });
} catch (e) {
return NextResponse.json(
{ error: safeError(e, "Reminder sweep failed.") },
{ status: 500 }
);
}
}

View File

@@ -252,11 +252,24 @@ export async function POST(request: Request) {
}
}
// For follow-up instances, prefer the on-file company name and contact
// details; the user can't change those by re-typing them in the wizard.
// The audit copy of company name on this request stays inherited
// from the first request in the org — it's a historical snapshot
// of the company name at the time the request was created, and
// org_billing is now the canonical source for current values.
//
// Phase 6 fix4: contactName and contactEmail are NOT inherited.
// They identify whoever submitted THIS specific request (drives
// admin display, support ticket routing, and email greetings).
// The previous "prior?.contactName ?? user.name" pattern locked
// the contact to whoever first onboarded the org, which broke for
// any subsequent submission by a different user — admin saw the
// wrong name, support emails went to the wrong person, and the
// actual submitter had no way to correct it because the wizard
// doesn't expose a contact-name input. The fix is simply to use
// the current session user every time.
const companyName = prior?.companyName ?? user.orgName;
const contactName = prior?.contactName ?? user.name;
const contactEmail = prior?.contactEmail ?? user.email;
const contactName = user.name;
const contactEmail = user.email;
// Bug 35: org-scoped billing.
//

View File

@@ -0,0 +1,90 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser } from "@/lib/session";
import { getOrgBilling, upsertOrgBilling } from "@/lib/db";
/**
* GET /api/settings/billing — read the caller's org_billing row.
* Returns null if the org hasn't configured billing yet — the
* form renders empty and the PUT will create on first save.
*
* PUT /api/settings/billing — upsert the row.
*
* Authorization: caller must have role "owner" in their org.
* Non-owners get 403 (they shouldn't have reached the page UI
* anyway, which hides the link, but the API enforces too — a
* non-owner who hits this directly with curl gets refused).
*
* Personal accounts are inherently their own owner (single-user
* org), so user.roles.includes("owner") returns true and they
* can manage their own billing.
*/
const upsertSchema = z.object({
companyName: z.string().trim().min(1).max(200),
// Phase 6 fix: optional "z.Hd." / "Attn:" line. Personal accounts
// never send this (the UI hides the field); orgs may set or leave
// it empty.
contactName: z.string().trim().max(200).optional().nullable(),
streetAddress: z.string().trim().min(1).max(200),
postalCode: z.string().trim().min(1).max(20),
city: z.string().trim().min(1).max(100),
// ISO 3166-1 alpha-2. We normalise to uppercase server-side.
country: z
.string()
.trim()
.length(2)
.regex(/^[A-Za-z]{2}$/, "Use a 2-letter ISO country code (CH, DE, …)"),
vatNumber: z.string().trim().max(40).optional().nullable(),
billingEmail: z.string().trim().email().max(200),
notes: z.string().trim().max(2000).optional().nullable(),
});
function requireOwner(user: { roles: string[] } | null) {
if (!user) return false;
return user.roles.includes("owner");
}
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!requireOwner(user as any)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const billing = await getOrgBilling(user.orgId);
return NextResponse.json({ billing });
}
export async function PUT(request: Request) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!requireOwner(user as any)) {
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 request", details: parsed.error.flatten() },
{ status: 400 }
);
}
const data = parsed.data;
const billing = await upsertOrgBilling({
zitadelOrgId: user.orgId,
companyName: data.companyName,
contactName: data.contactName ?? null,
streetAddress: data.streetAddress,
postalCode: data.postalCode,
city: data.city,
country: data.country.toUpperCase(),
vatNumber: data.vatNumber ?? null,
billingEmail: data.billingEmail,
notes: data.notes ?? null,
});
return NextResponse.json({ billing });
}

View File

@@ -0,0 +1,81 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser } from "@/lib/session";
import {
getHumanUserDetail,
updateHumanUserProfile,
} from "@/lib/zitadel";
/**
* GET /api/settings/profile — read the caller's ZITADEL profile.
* Returns first/last/display name and email. Used by the settings
* page server component to populate the form.
*
* PUT /api/settings/profile — update first + last name. Email is
* NOT mutable here — changing email needs verification flow that
* ZITADEL's own self-service UI already provides; we don't
* duplicate that.
*
* Authorization: any authenticated user can edit their own profile.
* The PAT (ZITADEL_SA_PAT) is used to call the ZITADEL v2 user
* service, but only against the caller's own userId. There is no
* userId field on the request — it's always derived from the
* session, so the route can't be abused to edit other users.
*/
const updateSchema = z.object({
firstName: z.string().trim().min(1).max(100),
lastName: z.string().trim().min(1).max(100),
});
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const profile = await getHumanUserDetail(user.id);
return NextResponse.json({ profile });
} catch (e: any) {
// Surface ZITADEL-side failures (e.g. user not found, PAT expired)
// as 502 — the portal couldn't reach its identity provider, which
// is operationally different from a 4xx on the caller's input.
console.error("getHumanUserDetail failed:", e);
return NextResponse.json(
{ error: "Could not load profile from identity provider" },
{ status: 502 }
);
}
}
export async function PUT(request: Request) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json().catch(() => ({}));
const parsed = updateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request", details: parsed.error.flatten() },
{ status: 400 }
);
}
try {
const result = await updateHumanUserProfile({
userId: user.id,
givenName: parsed.data.firstName,
familyName: parsed.data.lastName,
});
return NextResponse.json({
displayName: result.displayName,
changeDate: result.changeDate,
});
} catch (e: any) {
console.error("updateHumanUserProfile failed:", e);
return NextResponse.json(
{ error: "Could not update profile in identity provider" },
{ status: 502 }
);
}
}

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

@@ -0,0 +1,325 @@
import { NextResponse } from "next/server";
import type Stripe from "stripe";
import { getStripeClient, getWebhookSecret } from "@/lib/stripe";
import {
getInvoiceByStripePaymentIntent,
isStripeRefundRecorded,
markInvoicePaid,
markStripeEventProcessed,
setInvoiceStripePaymentIntent,
tryRecordStripeEvent,
} from "@/lib/db";
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
/**
* POST /api/stripe/webhook
*
* Receives signed events from Stripe. The lifecycle:
*
* 1. Read RAW body (request.text(), NOT request.json() — Stripe's
* HMAC is computed over the raw bytes and any JSON re-parse
* will subtly mangle whitespace or property ordering and the
* signature will fail).
* 2. Verify signature against the configured webhook secret. If
* verification fails → 400. An attacker forging webhook calls
* could otherwise mark our invoices paid.
* 3. Idempotency: INSERT the event id into stripe_events. If the
* INSERT conflicts (duplicate delivery, which is normal — Stripe
* retries failed deliveries for up to 72h), return 200 immediately
* so Stripe doesn't keep retrying.
* 4. Process the event based on type. Currently we care about:
* - checkout.session.completed → flip invoice to paid
* - charge.refunded → log; void/credit handling is Phase 7
* - payment_intent.payment_failed → log only; the failure is
* already shown to the user on
* the Stripe page, no action.
* Unknown event types are ack'd with 200 (we may have other
* events enabled at the Stripe end that we don't yet care about,
* and 200 + log is cheaper than 404 + Stripe retries).
* 5. Stamp processed_at on success.
*
* Return contract: 2xx ack → Stripe stops retrying. Any non-2xx →
* Stripe retries with exponential backoff up to 72h. We aim for
* 200 on every reachable path (verified, deduplicated, or processed),
* and only 400 for signature failures (those would never succeed
* on retry anyway, so retrying is wasted effort).
*
* Performance: handlers run synchronously here because PieCed's
* event volume is tiny. If/when that changes, the obvious refactor
* is to enqueue (Phase 7) and ack first — but at v1 the inline
* model is simpler to reason about and harder to lose events with.
*/
// Next.js: explicitly disable static optimization; this route MUST
// run on every request and must not be cached.
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
// 1. Raw body — Stripe verifies the signature over these exact bytes.
const rawBody = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) {
return new NextResponse("Missing stripe-signature header", {
status: 400,
});
}
// 2. Verify signature.
let event: Stripe.Event;
try {
const stripe = getStripeClient();
const secret = getWebhookSecret();
event = stripe.webhooks.constructEvent(rawBody, signature, secret);
} catch (err) {
console.error("Stripe webhook signature verification failed:", err);
// 400 — never retry. The webhook configuration is wrong on
// either end (rotated secret, wrong endpoint, etc.); retries
// won't fix it.
return new NextResponse("Invalid signature", { status: 400 });
}
// 3. Idempotency. INSERT event.id → fail-fast on duplicate.
let firstDelivery: boolean;
try {
firstDelivery = await tryRecordStripeEvent(
event.id,
event.type,
event
);
} catch (err) {
console.error(
`Failed to record stripe event ${event.id} (${event.type}):`,
err
);
// 5xx so Stripe retries — this is a DB hiccup, not a logic error.
return new NextResponse("DB error", { status: 500 });
}
if (!firstDelivery) {
// Already processed; ack happily.
return new NextResponse("Duplicate delivery; acknowledged.", {
status: 200,
});
}
// 4. Process. Each handler is responsible for being safe to run
// exactly once (we already deduplicated by event.id above).
try {
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutCompleted(
event.data.object as Stripe.Checkout.Session
);
break;
case "charge.refunded":
await handleChargeRefunded(event.data.object as Stripe.Charge);
break;
case "payment_intent.payment_failed":
await handlePaymentFailed(
event.data.object as Stripe.PaymentIntent
);
break;
default:
// Unknown event — log so we notice if Stripe starts sending
// something we should handle, but ack so we don't accumulate
// retries forever.
console.log(
`Stripe webhook: ignoring event type ${event.type} (id ${event.id})`
);
}
} catch (err) {
console.error(
`Stripe webhook handler failed for ${event.type} (id ${event.id}):`,
err
);
// 5xx → Stripe retries. The handler is idempotent because the
// stripe_events row already exists, so on the next attempt we'd
// short-circuit at step 3. To actually retry the work we'd need
// to DELETE the stripe_events row first; for v1 we don't bother
// and let a human investigate the logs.
return new NextResponse("Handler error", { status: 500 });
}
// 5. Mark processed.
try {
await markStripeEventProcessed(event.id);
} catch (err) {
// Non-fatal — the event was already processed, this is just the
// bookkeeping flag. Log and move on.
console.error(
`Failed to mark stripe event ${event.id} processed:`,
err
);
}
return new NextResponse("OK", { status: 200 });
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
async function handleCheckoutCompleted(
session: Stripe.Checkout.Session
): Promise<void> {
// Defensive: paid sessions are what we want; sessions can also
// complete in "unpaid" state (rare for mode=payment, more common
// for async/delayed methods like SEPA). Only flip the invoice
// when payment actually cleared.
if (session.payment_status !== "paid") {
console.log(
`Checkout session ${session.id} completed but payment_status=${session.payment_status}; waiting for downstream events.`
);
return;
}
const invoiceId =
session.metadata?.invoice_id ?? session.client_reference_id ?? null;
if (!invoiceId) {
console.error(
`Checkout session ${session.id} completed without invoice_id metadata; cannot link to invoice.`
);
return;
}
const paymentIntentId =
typeof session.payment_intent === "string"
? session.payment_intent
: session.payment_intent?.id;
// Persist the PaymentIntent id on the invoice for traceability +
// future refund correlation.
if (paymentIntentId) {
await setInvoiceStripePaymentIntent(invoiceId, paymentIntentId);
}
// Flip status. markInvoicePaid is idempotent — re-running on an
// already-paid invoice returns null and we log + skip.
const updated = await markInvoicePaid(invoiceId, {
paidBy: "stripe",
paidMethodDetail: paymentIntentId
? `Stripe Checkout (${paymentIntentId})`
: "Stripe Checkout",
paidAt: session.created ? new Date(session.created * 1000) : undefined,
});
if (!updated) {
// Already paid or void/draft — fine, nothing to do.
console.log(
`Invoice ${invoiceId} was not in a payable state when Stripe webhook arrived (likely already paid).`
);
return;
}
console.log(
`Invoice ${invoiceId} marked paid via Stripe (session ${session.id}, intent ${paymentIntentId}).`
);
}
async function handleChargeRefunded(charge: Stripe.Charge): Promise<void> {
// Phase 7: mirror Stripe refunds into the portal so credit notes
// are issued for refunds initiated in the Stripe Dashboard. For
// refunds initiated via /api/admin/.../refund, this handler is a
// no-op (each refund's stripe_refund_id is already recorded
// before the webhook lands — refundInvoice records it
// synchronously after the Stripe API call).
//
// A charge can have multiple refund objects (multiple partial
// refunds against the same charge accumulate here). We iterate
// and process any that aren't yet recorded in our DB.
const paymentIntentId =
typeof charge.payment_intent === "string"
? charge.payment_intent
: charge.payment_intent?.id;
if (!paymentIntentId) {
console.error(
`charge.refunded for charge ${charge.id} has no payment_intent; cannot link to invoice.`
);
return;
}
const invoice = await getInvoiceByStripePaymentIntent(paymentIntentId);
if (!invoice) {
console.error(
`charge.refunded for payment_intent ${paymentIntentId} has no matching invoice; ignoring.`
);
return;
}
const refundsList = charge.refunds?.data ?? [];
if (refundsList.length === 0) {
// Some charge.refunded events fire with the refunds list
// collapsed (the object includes the aggregated amount_refunded
// but the data array can be omitted depending on Stripe's
// expansion choices). In that case there's nothing for us to
// iterate over here; the actual `refund.created` /
// `refund.updated` events carry per-refund detail and we'd need
// to enable those in Stripe to handle them. For v1 we log and
// rely on the in-portal admin path (refundInvoice) being the
// only refund initiator.
console.log(
`charge.refunded for charge ${charge.id} arrived without refund objects in data; in-portal flow assumed.`
);
return;
}
for (const refund of refundsList) {
try {
// Idempotency: skip refunds we already recorded (either via
// portal admin action or a prior webhook delivery).
if (await isStripeRefundRecorded(refund.id)) {
continue;
}
const amountChf = (refund.amount ?? 0) / 100;
if (amountChf <= 0) continue;
// Map Stripe refund status to ours. Anything other than the
// canonical four falls through to 'pending' so we don't lose
// the record entirely.
let status: "pending" | "succeeded" | "failed" | "canceled" = "pending";
if (refund.status === "succeeded") status = "succeeded";
else if (refund.status === "failed") status = "failed";
else if (refund.status === "canceled") status = "canceled";
// For refunds that originated in Stripe Dashboard we don't
// have a reason to display. Use a sentinel string so the
// credit note PDF has something to print. Admin can edit
// post-hoc if needed (no UI for that today, but the DB row
// is reachable).
const reason = refund.reason
? `Stripe Dashboard: ${refund.reason}`
: "Refund issued via Stripe Dashboard";
// refundInvoice with existingStripeRefund: don't call Stripe
// again (we'd error since the refund already exists), just
// mirror the record into our DB and issue the credit note.
await refundInvoice({
invoiceId: invoice.id,
amountChf,
reason,
refundedBy: "stripe-webhook",
existingStripeRefund: { id: refund.id, status },
});
console.log(
`Mirrored Stripe refund ${refund.id} for invoice ${invoice.invoiceNumber} (CHF ${amountChf.toFixed(2)}).`
);
} catch (e) {
if (e instanceof RefundNotAllowedError) {
// The invoice was already fully refunded by an earlier
// webhook delivery or by an in-portal action. That's fine.
console.log(
`Stripe refund ${refund.id}: ${e.message} (already accounted for).`
);
continue;
}
// For any other error, log but continue to the next refund —
// we don't want one bad refund to block the rest.
console.error(
`Failed to mirror Stripe refund ${refund.id} for invoice ${invoice.invoiceNumber}:`,
e
);
}
}
}
async function handlePaymentFailed(
intent: Stripe.PaymentIntent
): Promise<void> {
// The Stripe-hosted page already shows the failure to the user.
// We log here for support visibility and to surface in Workbench.
// No invoice state change — it stays 'open' until paid.
console.log(
`PaymentIntent ${intent.id} failed: ${
intent.last_payment_error?.message ?? "(no message)"
}`
);
}

View File

@@ -4,33 +4,61 @@ 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";
import type { CreditNote, InvoiceDetail, InvoiceStatus } from "@/types";
interface Props {
detail: InvoiceDetail;
/**
* Phase 7: credit notes linked to this invoice (voids + refunds).
* Empty array when none. Passed from the server page; client
* doesn't re-fetch — router.refresh() rebuilds after actions.
*/
creditNotes?: CreditNote[];
}
/**
* 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).
* mark-paid (POST), void (POST), refund (POST), delete (DELETE),
* PDF download (link to /pdf).
*
* Phase 7 adds void + refund. The action bar shows:
* - status open/overdue → Mark paid, Void, Delete
* - status paid → Refund, Delete
* - status partially_refunded → Refund (for remainder), Delete
* - status fully_refunded / void → Delete only (read-only otherwise)
*
* On successful action we router.refresh() — the server-side page
* re-renders against the new DB state. For delete we navigate
* away first.
* re-renders against the new DB state, including any new credit
* notes.
*/
export function InvoiceDetailView({ detail }: Props) {
export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
const t = useTranslations("adminBilling");
const router = useRouter();
const { invoice, lines } = detail;
const [busyAction, setBusyAction] = useState<null | "mark-paid" | "delete">(
null
);
const [busyAction, setBusyAction] = useState<
null | "mark-paid" | "delete" | "void" | "refund"
>(null);
const [actionError, setActionError] = useState("");
const [noteInput, setNoteInput] = useState("");
const [noteOpen, setNoteOpen] = useState(false);
// Phase 7 — void modal state
const [voidOpen, setVoidOpen] = useState(false);
const [voidReason, setVoidReason] = useState("");
// Phase 7 — refund modal state. Amount defaults to the full
// remaining refundable on open.
const [refundOpen, setRefundOpen] = useState(false);
const [refundAmount, setRefundAmount] = useState("");
const [refundReason, setRefundReason] = useState("");
const remainingRefundable =
Math.round(
(invoice.totalChf - invoice.refundedTotalChf) * 100
) / 100;
const markPaid = async () => {
setActionError("");
setBusyAction("mark-paid");
@@ -75,6 +103,84 @@ export function InvoiceDetailView({ detail }: Props) {
}
};
// Phase 7 — void: marks an unpaid invoice as cancelled and issues
// a credit note. Backend rejects if the invoice is paid (use
// refund) or already voided/refunded.
const voidInvoice = async () => {
if (!voidReason.trim()) {
setActionError(t("voidReasonRequired"));
return;
}
setActionError("");
setBusyAction("void");
try {
const res = await fetch(
`/api/admin/billing/invoices/${invoice.id}/void`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason: voidReason }),
}
);
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
setVoidOpen(false);
setVoidReason("");
router.refresh();
} catch (e: any) {
setActionError(e.message);
} finally {
setBusyAction(null);
}
};
// Phase 7 — refund: paid invoices only. Amount may be partial;
// backend caps at remaining refundable.
const refundInvoice = async () => {
const amt = parseFloat(refundAmount);
if (!isFinite(amt) || amt <= 0) {
setActionError(t("refundAmountInvalid"));
return;
}
if (amt - remainingRefundable > 0.005) {
setActionError(
t("refundAmountExceeds", {
max: remainingRefundable.toFixed(2),
})
);
return;
}
if (!refundReason.trim()) {
setActionError(t("refundReasonRequired"));
return;
}
setActionError("");
setBusyAction("refund");
try {
const res = await fetch(
`/api/admin/billing/invoices/${invoice.id}/refund`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
amountChf: Math.round(amt * 100) / 100,
reason: refundReason,
}),
}
);
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
setRefundOpen(false);
setRefundAmount("");
setRefundReason("");
router.refresh();
} catch (e: any) {
setActionError(e.message);
} finally {
setBusyAction(null);
}
};
// Group lines by tenant for display (matches PDF layout).
const linesByTenant = new Map<string | null, typeof lines>();
for (const ln of lines) {
@@ -171,6 +277,144 @@ export function InvoiceDetailView({ detail }: Props) {
)}
</>
)}
{/* Phase 7 — Void: visible only for open/overdue invoices.
Same gating as Mark Paid but mutually exclusive with it
via the chosen action. Opens a small inline form so
the admin can enter a reason; reason is required and
lands on the credit-note PDF. */}
{(invoice.status === "open" || invoice.status === "overdue") && (
<>
{!voidOpen ? (
<button
onClick={() => {
setVoidOpen(true);
setNoteOpen(false);
setRefundOpen(false);
}}
disabled={busyAction !== null}
className="px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
>
{t("voidBtn")}
</button>
) : (
<div className="flex items-center gap-2 flex-grow">
<input
type="text"
placeholder={t("voidReasonPlaceholder")}
value={voidReason}
onChange={(e) => setVoidReason(e.target.value)}
maxLength={500}
className="flex-grow px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
autoFocus
/>
<button
onClick={voidInvoice}
disabled={busyAction !== null}
className="px-3 py-1.5 rounded-md bg-error text-white text-sm disabled:opacity-50"
>
{busyAction === "void" ? t("saving") : t("confirmVoid")}
</button>
<button
onClick={() => {
setVoidOpen(false);
setVoidReason("");
}}
className="px-3 py-1.5 rounded-md border border-border text-sm"
>
{t("cancel")}
</button>
</div>
)}
</>
)}
{/* Phase 7 — Refund: paid invoices, including ones already
partially refunded (as long as some refundable amount
remains). Opens an inline form with amount + reason.
The remaining-refundable hint helps admin pick the
right number. */}
{(invoice.status === "paid" ||
invoice.status === "partially_refunded") &&
remainingRefundable > 0 && (
<>
{!refundOpen ? (
<button
onClick={() => {
setRefundOpen(true);
setNoteOpen(false);
setVoidOpen(false);
setRefundAmount(remainingRefundable.toFixed(2));
}}
disabled={busyAction !== null}
className="px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
>
{t("refundBtn")}
</button>
) : (
<div className="flex flex-col gap-2 flex-grow">
<div className="text-xs text-text-muted">
{t("refundRemainingHint", {
max: remainingRefundable.toFixed(2),
})}
</div>
<div className="flex items-center gap-4 flex-wrap">
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-text-muted">
{t("refundAmountLabel")}
</label>
<input
type="number"
step="0.01"
min="0.01"
max={remainingRefundable}
placeholder="CHF"
value={refundAmount}
onChange={(e) => setRefundAmount(e.target.value)}
className="w-32 px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm font-mono"
autoFocus
/>
<span className="text-[10px] text-text-muted italic">
{t("refundAmountInclVatHint")}
</span>
</div>
<div className="flex flex-col gap-1 flex-grow min-w-[200px]">
<label className="text-[10px] uppercase tracking-wider text-text-muted">
{t("refundReasonLabel")}
</label>
<input
type="text"
placeholder={t("refundReasonPlaceholder")}
value={refundReason}
onChange={(e) => setRefundReason(e.target.value)}
maxLength={500}
className="w-full px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
/>
</div>
<div className="flex items-center gap-2 self-end">
<button
onClick={refundInvoice}
disabled={busyAction !== null}
className="px-3 py-1.5 rounded-md bg-error text-white text-sm disabled:opacity-50"
>
{busyAction === "refund"
? t("saving")
: t("confirmRefund")}
</button>
<button
onClick={() => {
setRefundOpen(false);
setRefundAmount("");
setRefundReason("");
}}
className="px-3 py-1.5 rounded-md border border-border text-sm"
>
{t("cancel")}
</button>
</div>
</div>
</div>
)}
</>
)}
<button
onClick={deleteInvoice}
disabled={busyAction !== null}
@@ -189,8 +433,90 @@ export function InvoiceDetailView({ detail }: Props) {
{invoice.paidMethodDetail}
</div>
)}
{/* Phase 7 — void/refund summary lines, shown when applicable.
Surfaces the auditing context that the columns alone don't
(who voided, what the reason was, how much has been
refunded vs how much remains). */}
{invoice.voidedAt && (
<div className="mt-3 text-xs text-text-muted">
{t("voidedOnLabel")}: {invoice.voidedAt} · {invoice.voidedBy}
{invoice.voidReason ? ` · ${invoice.voidReason}` : ""}
</div>
)}
{invoice.refundedTotalChf > 0 && (
<div className="mt-3 text-xs text-text-muted">
{t("refundedTotalLabel")}: CHF{" "}
{invoice.refundedTotalChf.toFixed(2)} ·{" "}
{t("refundedRemainingLabel")}: CHF{" "}
{remainingRefundable.toFixed(2)}
</div>
)}
</Card>
{/* Phase 7 — linked credit notes panel. Hidden when there are
none (most invoices). When present, lists each credit note
with kind, amount, reason, issued date, and PDF download. */}
{creditNotes.length > 0 && (
<Card>
<CardHeader>{t("creditNotesPanelTitle")}</CardHeader>
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2">{t("creditNoteNumberHeader")}</th>
<th className="pb-2">{t("creditNoteKindHeader")}</th>
<th className="pb-2 text-right">
{t("creditNoteAmountHeader")}
</th>
<th className="pb-2">{t("creditNoteReasonHeader")}</th>
<th className="pb-2">{t("creditNoteIssuedHeader")}</th>
<th className="pb-2 text-right">{t("creditNotePdfHeader")}</th>
</tr>
</thead>
<tbody>
{creditNotes.map((cn) => (
<tr key={cn.id} className="border-t border-border">
<td className="py-2 font-mono text-xs">
{cn.creditNoteNumber}
</td>
<td className="py-2">
<span className="px-2 py-0.5 rounded text-xs text-error bg-error/10">
{t(`creditNoteKind_${cn.kind}` as any)}
</span>
</td>
<td className="py-2 text-right font-mono">
CHF {cn.amountChf.toFixed(2)}
</td>
<td className="py-2 text-text-secondary text-xs">
{cn.reason ?? "—"}
</td>
<td className="py-2 text-xs text-text-muted">
{cn.issuedAt.slice(0, 10)}
</td>
<td className="py-2 text-right">
{cn.hasPdf ? (
<a
href={`/api/credit-notes/${encodeURIComponent(
cn.creditNoteNumber
)}/pdf`}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline text-xs"
>
{t("downloadPdfBtn")}
</a>
) : (
<span className="text-text-muted text-xs italic">
{t("creditNoteNoPdf")}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</Card>
)}
{/* Lines */}
<Card>
<CardHeader>{t("lineItemsTitle")}</CardHeader>
@@ -296,7 +622,9 @@ function StatusPill({ status }: { status: InvoiceStatus }) {
? "bg-error/15 text-error"
: status === "void" || status === "uncollectible"
? "bg-text-muted/15 text-text-muted"
: "bg-accent/15 text-accent";
: status === "partially_refunded" || status === "fully_refunded"
? "bg-error/15 text-error"
: "bg-accent/15 text-accent";
return (
<span
className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${color}`}

View File

@@ -0,0 +1,249 @@
"use client";
import { useState } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { Card } from "@/components/ui/card";
import type { CronRun } from "@/types";
interface Props {
initialRecent: CronRun[];
initialLastSuccess: {
monthlyIssue: CronRun | null;
reminders: CronRun | null;
};
}
/**
* Admin cron dashboard. Server pre-loads `initialRecent` and
* `initialLastSuccess`; "Run now" clicks POST to the admin
* endpoints, then re-fetch the history via GET /api/admin/cron/runs.
*
* The trigger buttons disable while busy and surface the resulting
* counters inline so the admin gets immediate feedback without
* needing to scroll to the history table.
*/
export function CronControls({ initialRecent, initialLastSuccess }: Props) {
const t = useTranslations("adminCron");
const fmt = useFormatter();
const [recent, setRecent] = useState(initialRecent);
const [lastSuccess, setLastSuccess] = useState(initialLastSuccess);
const [busy, setBusy] = useState<null | "issue" | "reminders">(null);
const [flash, setFlash] = useState<null | {
kind: "issue" | "reminders";
ok: boolean;
summary: string;
}>(null);
const refresh = async () => {
try {
const res = await fetch("/api/admin/cron/runs");
if (!res.ok) return;
const data = await res.json();
setRecent(data.recent);
setLastSuccess(data.lastSuccess);
} catch {
// swallow — refresh is opportunistic
}
};
const triggerIssue = async () => {
setBusy("issue");
setFlash(null);
try {
const res = await fetch("/api/admin/cron/issue-monthly", {
method: "POST",
});
const j = await res.json();
if (!res.ok) {
setFlash({
kind: "issue",
ok: false,
summary: j.error ?? `HTTP ${res.status}`,
});
} else {
setFlash({
kind: "issue",
ok: true,
summary: t("flashIssueOk", {
success: j.successCount,
skipped: j.skippedCount,
failure: j.failureCount,
}),
});
}
await refresh();
} finally {
setBusy(null);
}
};
const triggerReminders = async () => {
setBusy("reminders");
setFlash(null);
try {
const res = await fetch("/api/admin/cron/send-reminders", {
method: "POST",
});
const j = await res.json();
if (!res.ok) {
setFlash({
kind: "reminders",
ok: false,
summary: j.error ?? `HTTP ${res.status}`,
});
} else {
setFlash({
kind: "reminders",
ok: true,
summary: t("flashRemindersOk", {
success: j.successCount,
skipped: j.skippedCount,
failure: j.failureCount,
}),
});
}
await refresh();
} finally {
setBusy(null);
}
};
const fmtRelative = (iso: string | null) => {
if (!iso) return t("never");
return fmt.dateTime(new Date(iso), {
dateStyle: "medium",
timeStyle: "short",
});
};
// Phase 6: surface failures prominently. Any run in the recent
// window with a non-zero failure_count drives a top-of-page
// banner — the row in the table is already red, but a banner
// means the admin doesn't have to scroll to notice.
const recentFailures = recent.filter((r) => r.failureCount > 0);
const hasRecentFailures = recentFailures.length > 0;
return (
<div className="space-y-8">
{hasRecentFailures && (
<div className="p-4 rounded-md border border-error bg-error/10 text-sm text-error">
<p className="font-medium mb-1">{t("failureBannerTitle")}</p>
<p className="text-xs">
{t("failureBannerBody", { count: recentFailures.length })}
</p>
</div>
)}
<section className="grid gap-4 md:grid-cols-2">
<Card>
<h2 className="text-xs uppercase tracking-wider text-text-muted mb-2">
{t("monthlyIssue")}
</h2>
<p className="text-xs text-text-secondary mb-1">
{t("scheduleIssueLabel")}: <span className="font-mono">{t("scheduleIssueValue")}</span>
</p>
<p className="text-xs text-text-secondary mb-3">
{t("lastSuccess")}: <span className="font-mono">{fmtRelative(lastSuccess.monthlyIssue?.startedAt ?? null)}</span>
</p>
<button
onClick={triggerIssue}
disabled={busy !== null}
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
>
{busy === "issue" ? t("running") : t("runIssueNow")}
</button>
</Card>
<Card>
<h2 className="text-xs uppercase tracking-wider text-text-muted mb-2">
{t("reminders")}
</h2>
<p className="text-xs text-text-secondary mb-1">
{t("scheduleReminderLabel")}: <span className="font-mono">{t("scheduleReminderValue")}</span>
</p>
<p className="text-xs text-text-secondary mb-3">
{t("lastSuccess")}: <span className="font-mono">{fmtRelative(lastSuccess.reminders?.startedAt ?? null)}</span>
</p>
<button
onClick={triggerReminders}
disabled={busy !== null}
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
>
{busy === "reminders" ? t("running") : t("runRemindersNow")}
</button>
</Card>
</section>
{flash && (
<div
className={`p-3 rounded-md border text-sm ${
flash.ok
? "border-success bg-success/10 text-success"
: "border-error bg-error/10 text-error"
}`}
>
{flash.summary}
</div>
)}
<section>
<h2 className="text-xs uppercase tracking-wider text-text-muted mb-3">
{t("recentRuns")}
</h2>
<Card>
{recent.length === 0 ? (
<p className="text-sm text-text-muted italic py-4">
{t("noRunsYet")}
</p>
) : (
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2">{t("startedCol")}</th>
<th className="pb-2">{t("kindCol")}</th>
<th className="pb-2">{t("triggeredByCol")}</th>
<th className="pb-2 text-right">{t("okCol")}</th>
<th className="pb-2 text-right">{t("skipCol")}</th>
<th className="pb-2 text-right">{t("failCol")}</th>
</tr>
</thead>
<tbody>
{recent.map((r) => (
<tr
key={r.id}
className={`border-t border-border align-top ${
r.failureCount > 0 ? "bg-error/5" : ""
}`}
>
<td className="py-2 text-xs font-mono">
{fmtRelative(r.startedAt)}
</td>
<td className="py-2 text-xs">
{t(`kind.${r.runKind}` as any)}
</td>
<td className="py-2 text-xs text-text-secondary font-mono">
{r.triggeredBy === "cron"
? t("triggeredByCron")
: r.triggeredBy.slice(0, 8) + "…"}
</td>
<td className="py-2 text-right font-mono text-xs text-success">
{r.successCount}
</td>
<td className="py-2 text-right font-mono text-xs text-text-secondary">
{r.skippedCount}
</td>
<td
className={`py-2 text-right font-mono text-xs ${
r.failureCount > 0 ? "text-error" : "text-text-muted"
}`}
>
{r.failureCount}
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
</section>
</div>
);
}

View File

@@ -0,0 +1,101 @@
import { useTranslations, useFormatter } from "next-intl";
import { Card } from "@/components/ui/card";
import type { CreditNote } from "@/types";
interface Props {
creditNotes: CreditNote[];
}
const kindColors: Record<string, string> = {
// Voids = the invoice was cancelled; gentle red.
void: "text-error bg-error/10",
// Refunds = money returned; also red but slightly differentiated.
refund: "text-error bg-error/10",
};
/**
* Phase 7 — customer's credit-note history below the invoice list.
*
* Hidden entirely when the org has zero credit notes (most orgs in
* normal operation). When present, each row shows the credit-note
* number, the invoice it relates to, kind (void / refund), amount,
* and a download link to the PDF.
*
* No detail page — clicking the PDF link opens the document inline
* (browser PDF viewer), which IS the credit-note detail view. A
* separate per-credit-note web page would duplicate what's in the
* PDF and add no value.
*/
export function CustomerCreditNoteList({ creditNotes }: Props) {
const t = useTranslations("customerBilling");
const fmt = useFormatter();
if (creditNotes.length === 0) {
return null;
}
return (
<Card>
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2">{t("creditNoteNumberCol")}</th>
<th className="pb-2">{t("creditNoteInvoiceCol")}</th>
<th className="pb-2">{t("creditNoteIssuedCol")}</th>
<th className="pb-2 text-right">{t("creditNoteAmountCol")}</th>
<th className="pb-2 text-right">{t("creditNoteKindCol")}</th>
<th className="pb-2 text-right">{t("creditNotePdfCol")}</th>
</tr>
</thead>
<tbody>
{creditNotes.map((cn) => (
<tr
key={cn.id}
className="border-t border-border align-middle"
>
<td className="py-2 font-mono text-xs">
{cn.creditNoteNumber}
</td>
<td className="py-2 font-mono text-xs text-text-secondary">
{cn.invoiceNumber}
</td>
<td className="py-2 text-text-secondary">
{fmt.dateTime(new Date(cn.issuedAt), { dateStyle: "medium" })}
</td>
<td className="py-2 text-right font-mono">
CHF {cn.amountChf.toFixed(2)}
</td>
<td className="py-2 text-right">
<span
className={`px-2 py-0.5 rounded text-xs ${
kindColors[cn.kind] ?? ""
}`}
>
{t(`creditNoteKind_${cn.kind}` as any)}
</span>
</td>
<td className="py-2 text-right">
{cn.hasPdf ? (
<a
href={`/api/credit-notes/${encodeURIComponent(
cn.creditNoteNumber
)}/pdf`}
className="text-accent hover:underline text-xs"
target="_blank"
rel="noopener noreferrer"
>
{t("downloadPdf")}
</a>
) : (
<span className="text-text-muted text-xs italic">
{t("creditNoteNoPdf")}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</Card>
);
}

View File

@@ -0,0 +1,160 @@
import { useTranslations, useFormatter } from "next-intl";
import { Card } from "@/components/ui/card";
import type { Invoice, InvoiceLine } from "@/types";
import { PayInvoiceButton } from "./pay-invoice-button";
import { PaymentStatusBanner } from "./payment-status-banner";
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">
<PaymentStatusBanner />
<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>
<div className="flex items-start gap-2 flex-wrap">
{/* Phase 4: Pay-with-card available for open + overdue.
Paid/void/draft/uncollectible hide the button — the
API also enforces this, so client-side hiding is just
for the visible affordance. */}
{(invoice.status === "open" || invoice.status === "overdue") && (
<PayInvoiceButton invoiceNumber={invoice.invoiceNumber} />
)}
<a
href={`/api/billing/invoices/${encodeURIComponent(invoice.invoiceNumber)}/pdf`}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 rounded-md bg-surface-3 hover:bg-surface-2 border border-border text-sm font-medium transition-colors"
>
{t("downloadPdf")}
</a>
</div>
</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,99 @@
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",
// Phase 7: refund states. Red tinting matches the credit-note
// PDF accent so customers reading the table get a visual cue
// that something was credited back. partially_refunded reads
// as a partial state (mixed colour), fully_refunded reads as
// closed (line-through like void).
partially_refunded: "text-error bg-error/10",
fully_refunded: "text-text-muted bg-error/10 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,64 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
interface Props {
invoiceNumber: string;
}
/**
* Pay-with-card button. Posts to /api/billing/invoices/[n]/pay,
* which returns a Stripe Checkout Session URL; we redirect the
* browser there.
*
* The button is rendered only by the parent for status='open' or
* 'overdue' invoices — the API enforces this too, but pre-filtering
* UI-side keeps the dead state out of the customer's face.
*/
export function PayInvoiceButton({ invoiceNumber }: Props) {
const t = useTranslations("customerBilling");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const onClick = async () => {
setBusy(true);
setError(null);
try {
const res = await fetch(
`/api/billing/invoices/${encodeURIComponent(invoiceNumber)}/pay`,
{ method: "POST" }
);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error ?? `HTTP ${res.status}`);
}
if (!data.url) {
throw new Error("Payment session URL missing from response.");
}
// Hard navigation, not Next.js router — Stripe Checkout is a
// separate origin and the browser needs to fully leave our app.
window.location.href = data.url;
} catch (e: any) {
setError(e?.message ?? String(e));
setBusy(false);
}
};
return (
<div className="flex flex-col items-end gap-1">
<button
onClick={onClick}
disabled={busy}
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
>
{busy ? t("redirectingToStripe") : t("payWithCard")}
</button>
{error && (
<span className="text-xs text-error max-w-[260px] text-right">
{error}
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,67 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
/**
* Banner shown after a return from Stripe Checkout.
*
* ?paid=1 → green success banner. The webhook may or may
* not have processed yet, so we phrase the message
* as "Payment received, status will update shortly"
* and don't claim the status is already paid. A
* light auto-refresh after a few seconds nudges
* the page to pick up the new status badge.
*
* ?cancelled=1 → neutral grey banner: "Payment cancelled". The
* invoice stays in 'open' state.
*
* The banner cleans up the query params from the URL so a page
* reload doesn't repeat the message. We use router.replace() to
* keep history clean.
*/
export function PaymentStatusBanner() {
const router = useRouter();
const t = useTranslations("customerBilling");
const [state, setState] = useState<"paid" | "cancelled" | null>(null);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.has("paid")) {
setState("paid");
// The webhook usually arrives before the browser redirect
// completes, so the page often renders with status='paid'
// on first load and this refresh is a no-op. In the rare
// case where it arrives slightly after, a short refresh
// picks up the status flip. 1.5s is comfortable for both.
const timer = setTimeout(() => {
router.refresh();
}, 1500);
// Strip the query string out of the URL.
const cleanUrl = window.location.pathname;
window.history.replaceState({}, "", cleanUrl);
return () => clearTimeout(timer);
} else if (params.has("cancelled")) {
setState("cancelled");
const cleanUrl = window.location.pathname;
window.history.replaceState({}, "", cleanUrl);
}
}, [router]);
if (state === "paid") {
return (
<div className="mb-4 p-3 rounded-md border border-success bg-success/10 text-sm text-success">
{t("paymentReceived")}
</div>
);
}
if (state === "cancelled") {
return (
<div className="mb-4 p-3 rounded-md border border-border bg-surface-2 text-sm text-text-secondary">
{t("paymentCancelled")}
</div>
);
}
return null;
}

View File

@@ -0,0 +1,189 @@
"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 };
interface Props {
/**
* Whether the viewing user has org-owner role. Drives the
* "complete your billing details" CTA — only owners can edit
* billing settings, so non-owners see a softer message asking
* them to contact their org owner instead. The flag is computed
* server-side and passed in to avoid a second API round-trip.
*/
isOwner: boolean;
}
/**
* 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({ isOwner }: Props) {
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) {
const noConfig =
data && "code" in data && data.code === "COMPUTE_FAILED";
return (
<Card>
<p className="text-sm text-text-secondary py-2">
{noConfig ? t("noBillingConfig") : t("currentPeriodError")}
</p>
{/* Phase 6: owner-only CTA. Non-owners can't edit billing
settings, so we show them a "contact owner" hint instead
— that's gentler than a button that 404s on click. */}
{noConfig && isOwner && (
<Link
href="/settings/billing"
className="inline-block mt-2 px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors"
>
{t("configureBillingCta")}
</Link>
)}
{noConfig && !isOwner && (
<p className="text-xs text-text-muted italic mt-2">
{t("noBillingConfigNonOwner")}
</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

@@ -2,6 +2,7 @@
import { useRouter } from "next/navigation";
import { OnboardingWizard } from "./wizard";
import type { OrgBilling } from "@/types";
interface OnboardingFlowProps {
orgName: string;
@@ -19,6 +20,12 @@ interface OnboardingFlowProps {
* /settings/billing.
*/
hasOrgBilling?: boolean;
/**
* Phase 6 fix3: the actual org_billing record (or null). Drives
* the review-step "Billing to" rendering AND the confirm-step
* validation skip when the billing step was skipped.
*/
existingOrgBilling?: OrgBilling | null;
/**
* Bug 6: when present, the wizard is rendered in edit mode against
* the given pending request. See `OnboardingWizard` for the full
@@ -45,6 +52,7 @@ export function OnboardingFlow({
userName,
userEmail,
hasOrgBilling,
existingOrgBilling,
editingRequest,
}: OnboardingFlowProps) {
const router = useRouter();
@@ -55,6 +63,7 @@ export function OnboardingFlow({
userName={userName}
userEmail={userEmail}
hasOrgBilling={hasOrgBilling}
existingOrgBilling={existingOrgBilling}
editingRequest={editingRequest}
onComplete={() => {
// Navigate back to /dashboard and re-fetch on the server. The

View File

@@ -13,6 +13,7 @@ import {
SUPPORTED_COUNTRIES,
type SupportedCountry,
} from "@/lib/validation";
import type { OrgBilling } from "@/types";
type Step = "welcome" | "configure" | "billing" | "confirm";
@@ -96,6 +97,17 @@ interface WizardProps {
* fix it before admin approves.
*/
hasOrgBilling?: boolean;
/**
* Phase 6 fix3: the actual org_billing record when one exists.
* Used to render real values on the review-step "Billing to" block
* (rather than the wizard's empty default config.billingAddress)
* AND to skip the confirm-step's client-side validation of
* billingAddress — same logic that already strips billingAddress
* at submit time. Null when no org_billing row exists yet.
* Ignored in edit mode (the editingRequest carries its own
* billingAddress snapshot).
*/
existingOrgBilling?: OrgBilling | null;
/**
* Bug 6: when present, the wizard renders in "edit" mode — fields
* are pre-populated from the request, the SOUL.md auto-fetch is
@@ -134,6 +146,7 @@ export function OnboardingWizard({
userName,
userEmail,
hasOrgBilling,
existingOrgBilling,
editingRequest,
onComplete,
}: WizardProps) {
@@ -319,7 +332,23 @@ export function OnboardingWizard({
}
// confirm: validate the union (defence in depth — submit handler
// also runs onboardingSchema before POST).
const r = onboardingSchema.safeParse(config);
//
// Phase 6 fix3: when hasOrgBilling=true AND not editing, the
// billing step was skipped and config.billingAddress is the
// empty default. zod's .optional() doesn't help here because the
// field IS present (empty object), so billingAddressSchema
// validates it and fails with required-field errors that the
// user has no way to fix — the form to enter the values was
// skipped on purpose. Strip the field for validation, matching
// the same strip we already do at submit time.
const configForValidation =
hasOrgBilling && !isEditing
? (() => {
const { billingAddress: _b, ...rest } = config;
return rest;
})()
: config;
const r = onboardingSchema.safeParse(configForValidation);
if (r.success) {
setErrors({});
return true;
@@ -1101,42 +1130,84 @@ export function OnboardingWizard({
<ReviewRow
label={t("reviewBillingTo")}
value={
<div className="text-text-primary text-right">
{/* For personal: skip the company line so the
invoice rendering matches what the user actually
entered. For company: include it as the first
line. */}
{!isPersonal &&
config.billingAddress.company &&
config.billingAddress.company.trim().length > 0 && (
<div>{config.billingAddress.company}</div>
)}
<div>{config.billingAddress.street}</div>
<div>
{config.billingAddress.postalCode}{" "}
{config.billingAddress.city}
</div>
<div className="text-text-muted">
{tCountries(
config.billingAddress.country as SupportedCountry
)}
</div>
</div>
(() => {
// Phase 6 fix3: when the org has billing on file
// and we're not editing, render the saved
// org_billing record (the authoritative source)
// rather than config.billingAddress, which is the
// wizard's empty default state because the billing
// step was skipped. In edit mode, fall back to
// config.billingAddress, which is pre-populated
// from the request being edited.
const useSaved =
hasOrgBilling && !isEditing && existingOrgBilling;
const company = useSaved
? existingOrgBilling!.companyName
: config.billingAddress.company;
const street = useSaved
? existingOrgBilling!.streetAddress
: config.billingAddress.street;
const postalCode = useSaved
? existingOrgBilling!.postalCode
: config.billingAddress.postalCode;
const city = useSaved
? existingOrgBilling!.city
: config.billingAddress.city;
const country = useSaved
? existingOrgBilling!.country
: config.billingAddress.country;
const contactName = useSaved
? existingOrgBilling!.contactName
: null;
return (
<div className="text-text-primary text-right">
{/* For personal: skip the company line so the
invoice rendering matches what the user actually
entered. For company: include it as the first
line. */}
{!isPersonal &&
company &&
company.trim().length > 0 && <div>{company}</div>}
{/* Phase 6 fix2: optional contact-person line
("z.Hd. <name>") only present when the saved
org_billing has it set. */}
{contactName && contactName.trim().length > 0 && (
<div className="text-text-muted">
{t("reviewContactPersonPrefix")} {contactName}
</div>
)}
<div>{street}</div>
<div>
{postalCode} {city}
</div>
<div className="text-text-muted">
{tCountries(country as SupportedCountry)}
</div>
</div>
);
})()
}
/>
{/* Bug 35: VAT review row. Company customers see this so
they can verify the VAT id they typed before submitting.
Personal customers never see it — they don't have a
VAT number, the form didn't ask, the review hides it. */}
VAT number, the form didn't ask, the review hides it.
Phase 6 fix3: when reading from existingOrgBilling,
the value comes from there too. */}
{!isPersonal &&
config.billingAddress.vatNumber &&
config.billingAddress.vatNumber.trim().length > 0 && (
<ReviewRow
label={t("billingVatNumber")}
value={config.billingAddress.vatNumber}
mono
/>
)}
(() => {
const vat =
hasOrgBilling && !isEditing && existingOrgBilling
? existingOrgBilling.vatNumber
: config.billingAddress.vatNumber;
return vat && vat.trim().length > 0 ? (
<ReviewRow
label={t("billingVatNumber")}
value={vat}
mono
/>
) : null;
})()}
<ReviewRow
label={t("reviewContactEmail")}
value={userEmail || ""}

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

@@ -0,0 +1,263 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card";
import type { OrgBilling } from "@/types";
interface Props {
initial: OrgBilling | null;
/**
* Personal-account (individual customer) flag from the session.
* Individuals get a "Full name" field instead of "Company name",
* and the VAT input is hidden entirely — they don't have one and
* showing the field would only confuse. The underlying column is
* still `company_name` in the DB and the invoice PDF; for an
* individual that field carries their full name, which is
* exactly what should print on the invoice.
*/
isPersonal: boolean;
}
/**
* Customer billing settings form. Drives PUT /api/settings/billing
* which upserts org_billing for the caller's org.
*
* Validation is the same regex as the server-side zod schema for
* the country field (ISO 3166-1 alpha-2). Other fields are checked
* for required + max-length client-side; the server is the
* authority and re-validates everything.
*
* On success we router.refresh() the page so the server component
* re-fetches and any "create now" -> "edit" wording flips.
*/
export function BillingSettingsForm({ initial, isPersonal }: Props) {
const t = useTranslations("settingsBilling");
const router = useRouter();
const [form, setForm] = useState({
companyName: initial?.companyName ?? "",
contactName: initial?.contactName ?? "",
streetAddress: initial?.streetAddress ?? "",
postalCode: initial?.postalCode ?? "",
city: initial?.city ?? "",
country: initial?.country ?? "CH",
vatNumber: initial?.vatNumber ?? "",
billingEmail: initial?.billingEmail ?? "",
notes: initial?.notes ?? "",
});
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [savedFlash, setSavedFlash] = useState(false);
const set =
(field: keyof typeof form) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setForm((f) => ({ ...f, [field]: e.target.value }));
const submit = async () => {
setError(null);
setSavedFlash(false);
// Client-side gate on required fields — the server re-validates.
if (
!form.companyName.trim() ||
!form.streetAddress.trim() ||
!form.postalCode.trim() ||
!form.city.trim() ||
!form.country.trim() ||
!form.billingEmail.trim()
) {
setError(t("missingRequired"));
return;
}
if (!/^[A-Za-z]{2}$/.test(form.country.trim())) {
setError(t("invalidCountry"));
return;
}
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.billingEmail.trim())) {
setError(t("invalidEmail"));
return;
}
setBusy(true);
try {
const res = await fetch("/api/settings/billing", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
companyName: form.companyName.trim(),
// Personal accounts don't have a contact-name field
// (companyName IS their name); force null so stale state
// from a previously-org-flagged account can't carry over.
contactName: isPersonal ? null : form.contactName.trim() || null,
streetAddress: form.streetAddress.trim(),
postalCode: form.postalCode.trim(),
city: form.city.trim(),
country: form.country.trim().toUpperCase(),
// Personal accounts never have a VAT number — force null
// regardless of stale state, in case a value was stored
// before the account got flagged as personal.
vatNumber: isPersonal ? null : form.vatNumber.trim() || null,
billingEmail: form.billingEmail.trim(),
notes: form.notes.trim() || null,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error ?? `HTTP ${res.status}`);
}
setSavedFlash(true);
router.refresh();
} catch (e: any) {
setError(e?.message ?? String(e));
} finally {
setBusy(false);
}
};
return (
<Card>
<div className="space-y-4">
<Field
label={isPersonal ? t("fullNameLabel") : t("companyNameLabel")}
required
>
<input
type="text"
value={form.companyName}
onChange={set("companyName")}
maxLength={200}
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
/>
</Field>
{!isPersonal && (
<Field label={t("contactNameLabel")} hint={t("contactNameHint")}>
<input
type="text"
value={form.contactName}
onChange={set("contactName")}
maxLength={200}
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
/>
</Field>
)}
<Field label={t("streetAddressLabel")} required>
<input
type="text"
value={form.streetAddress}
onChange={set("streetAddress")}
maxLength={200}
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
/>
</Field>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Field label={t("postalCodeLabel")} required>
<input
type="text"
value={form.postalCode}
onChange={set("postalCode")}
maxLength={20}
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
/>
</Field>
<Field label={t("cityLabel")} required>
<input
type="text"
value={form.city}
onChange={set("city")}
maxLength={100}
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
/>
</Field>
<Field
label={t("countryLabel")}
required
hint={t("countryHint")}
>
<input
type="text"
value={form.country}
onChange={(e) =>
setForm((f) => ({
...f,
country: e.target.value.toUpperCase().slice(0, 2),
}))
}
maxLength={2}
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm uppercase font-mono"
/>
</Field>
</div>
{!isPersonal && (
<Field label={t("vatNumberLabel")} hint={t("vatNumberHint")}>
<input
type="text"
value={form.vatNumber}
onChange={set("vatNumber")}
maxLength={40}
placeholder="CHE-123.456.789 MWST"
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm font-mono"
/>
</Field>
)}
<Field label={t("billingEmailLabel")} required hint={t("billingEmailHint")}>
<input
type="email"
value={form.billingEmail}
onChange={set("billingEmail")}
maxLength={200}
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
/>
</Field>
<Field label={t("notesLabel")} hint={t("notesHint")}>
<textarea
value={form.notes}
onChange={set("notes")}
maxLength={2000}
rows={3}
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
/>
</Field>
{error && (
<p className="text-sm text-error">{error}</p>
)}
{savedFlash && (
<p className="text-sm text-success">{t("saved")}</p>
)}
<div className="flex justify-end">
<button
onClick={submit}
disabled={busy}
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
>
{busy ? t("saving") : initial ? t("saveChanges") : t("createBilling")}
</button>
</div>
</div>
</Card>
);
}
function Field({
label,
required,
hint,
children,
}: {
label: string;
required?: boolean;
hint?: string;
children: React.ReactNode;
}) {
return (
<div>
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{label}
{required && <span className="text-error ml-1">*</span>}
</label>
{children}
{hint && (
<p className="text-xs text-text-muted mt-1 italic">{hint}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,187 @@
"use client";
import { useState } from "react";
import { useSession } from "next-auth/react";
import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card";
interface Props {
initial: {
firstName: string;
lastName: string;
email: string;
};
/**
* Personal-account flag. Drives a small hint about how the ZITADEL
* name relates (or doesn't) to invoice identity — see the page
* server component for the long explanation.
*/
isPersonal: boolean;
/**
* For company accounts: the display org name. Shown in a small
* read-only "Member of <org>" hint so the user understands which
* identity they're editing. Ignored for personals (orgName is an
* opaque "personal-XXXX" string in that case).
*/
orgName: string;
}
/**
* Edits first/last name in ZITADEL via PUT /api/settings/profile.
* Email is shown read-only — changing email requires verification
* flow that ZITADEL's own self-service UI handles.
*
* On save, we trigger NextAuth's `update()` from useSession() with
* the new display name. That routes through our jwt callback
* (trigger='update' branch) which overlays token.name without a
* logout/login. After the cookie is updated we trigger a full page
* reload — every server-rendered surface (nav-shell, dashboard
* welcome, instance cards) re-reads the cookie on the next request
* and renders with the new name. router.refresh() alone wasn't
* enough: it re-runs only the current route's server components,
* leaving outer-tree segments stale until the user navigates.
*/
export function ProfileSettingsForm({ initial, isPersonal, orgName }: Props) {
const t = useTranslations("settingsProfile");
const { update } = useSession();
const [form, setForm] = useState({
firstName: initial.firstName,
lastName: initial.lastName,
});
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [savedFlash, setSavedFlash] = useState(false);
const submit = async () => {
setError(null);
setSavedFlash(false);
if (!form.firstName.trim() || !form.lastName.trim()) {
setError(t("missingRequired"));
return;
}
setBusy(true);
try {
const res = await fetch("/api/settings/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
firstName: form.firstName.trim(),
lastName: form.lastName.trim(),
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error ?? `HTTP ${res.status}`);
}
// Phase 6 fix5: push the new display name into the session
// token. The jwt callback handles trigger='update' and overlays
// token.name; the next session callback maps token.name back
// to session.user.name. No re-login needed.
await update({ name: data.displayName });
setSavedFlash(true);
// Force a full reload so EVERY server-rendered component picks
// up the new session cookie immediately — router.refresh() only
// re-runs the current route's server components, leaving the
// nav-shell (rendered higher in the tree) and other cached
// segments showing the old name until the user navigates.
// The 800ms delay lets the "Saved" flash render briefly before
// the page reloads, so the user gets visible feedback.
setTimeout(() => {
window.location.reload();
}, 800);
} catch (e: any) {
setError(e?.message ?? String(e));
} finally {
setBusy(false);
}
};
return (
<Card>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Field label={t("firstNameLabel")} required>
<input
type="text"
value={form.firstName}
onChange={(e) =>
setForm((f) => ({ ...f, firstName: e.target.value }))
}
maxLength={100}
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
/>
</Field>
<Field label={t("lastNameLabel")} required>
<input
type="text"
value={form.lastName}
onChange={(e) =>
setForm((f) => ({ ...f, lastName: e.target.value }))
}
maxLength={100}
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
/>
</Field>
</div>
<Field label={t("emailLabel")} hint={t("emailReadOnlyHint")}>
<input
type="email"
value={initial.email}
readOnly
disabled
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border text-sm text-text-muted cursor-not-allowed"
/>
</Field>
{/* Personal vs company hint. Personals get the
"this won't change your invoice name" warning since their
ZITADEL name and their invoice identity are intentionally
decoupled. Company accounts get a benign "member of"
context line so they know which org's identity they're
editing. */}
{isPersonal ? (
<p className="text-xs text-text-muted italic">
{t("personalAccountHint")}
</p>
) : (
<p className="text-xs text-text-muted italic">
{t("companyAccountHint", { orgName })}
</p>
)}
{error && <p className="text-sm text-error">{error}</p>}
{savedFlash && <p className="text-sm text-success">{t("saved")}</p>}
<div className="flex justify-end">
<button
onClick={submit}
disabled={busy}
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
>
{busy ? t("saving") : t("saveChanges")}
</button>
</div>
</div>
</Card>
);
}
function Field({
label,
required,
hint,
children,
}: {
label: string;
required?: boolean;
hint?: string;
children: React.ReactNode;
}) {
return (
<div>
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{label}
{required && <span className="text-error ml-1">*</span>}
</label>
{children}
{hint && <p className="text-xs text-text-muted mt-1 italic">{hint}</p>}
</div>
);
}

View File

@@ -49,7 +49,31 @@ export const authConfig: NextAuthConfig = {
},
],
callbacks: {
async jwt({ token, account, profile }) {
async jwt({ token, account, profile, trigger, session }) {
// Phase 6 fix5: client-side `useSession().update({ name })` calls
// route through this branch. We trust the new value because the
// PUT /api/settings/profile route already wrote it to ZITADEL
// and re-fetched the canonical displayName before returning.
// The session callback reads token.name directly (see below) so
// the update propagates without depending on auth.js's implicit
// token→session.user mapping, which is flaky for the name claim
// in the v5 OIDC provider configuration.
//
// Defensive: only the `name` field is accepted from the update
// payload, even if the client passes additional keys. Other
// identity claims (orgId, roles, sub) come from ZITADEL at
// sign-in time and are not user-mutable from a settings page.
//
// Returns a NEW token object (spread) rather than mutating, so
// there is no ambiguity for auth.js about whether the token
// changed and needs re-encoding into the session cookie.
if (trigger === "update" && session) {
const update = session as { name?: unknown };
if (typeof update.name === "string") {
return { ...token, name: update.name };
}
return token;
}
if (account && profile) {
const claims = profile as unknown as ZitadelClaims;
token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"];
@@ -58,6 +82,19 @@ export const authConfig: NextAuthConfig = {
claims["urn:zitadel:iam:org:project:roles"]
);
token.accessToken = account.access_token;
// Phase 6 fix5: explicitly pin the standard name/email claims
// onto the token from the OIDC profile. Previously these came
// through auth.js's implicit mapping, which works on first
// sign-in but isn't reliable after update() — once the update
// path overrides token.name, the read-back path needs token
// to be the authoritative source. Setting them explicitly
// here keeps sign-in and update on the same path.
if (typeof profile.name === "string") {
token.name = profile.name;
}
if (typeof profile.email === "string") {
token.email = profile.email;
}
// Pin token.sub to the OIDC subject. Auth.js v5 otherwise puts a
// freshly generated UUID in token.sub on initial sign-in,
// ignoring what profile() returns for `id`. That UUID then
@@ -80,10 +117,19 @@ export const authConfig: NextAuthConfig = {
async session({ session, token }) {
const roles = (token.roles as Role[]) ?? [];
const orgName = (token.orgName as string) ?? "";
// Phase 6 fix5: read name and email directly from the token.
// Previously this code relied on `session.user?.name`, expecting
// auth.js to map token.name → session.user.name automatically.
// That mapping is brittle: it works on first sign-in (because
// OIDC profile() populates session.user) but not after update()
// overrides token.name. Reading from token is the canonical
// path regardless of how the token was last written.
const tokenName = (token.name as string | undefined) ?? "";
const tokenEmail = (token.email as string | undefined) ?? "";
const sessionUser: SessionUser = {
id: token.sub!,
name: session.user?.name ?? "",
email: session.user?.email ?? "",
name: tokenName || session.user?.name || "",
email: tokenEmail || session.user?.email || "",
orgId: token.orgId as string,
orgName,
roles,
@@ -96,6 +142,14 @@ export const authConfig: NextAuthConfig = {
isPersonal: isPersonalOrgName(orgName),
};
(session as any).platformUser = sessionUser;
// Also overwrite session.user so any client-side code that uses
// the standard NextAuth shape (session.user.name) sees the new
// value. Pre-fix5 code paths read from session.user.name; this
// keeps them working without per-component changes.
if (session.user) {
session.user.name = sessionUser.name;
session.user.email = sessionUser.email;
}
return session;
},
},

View File

@@ -31,44 +31,18 @@ import {
Text,
View,
StyleSheet,
Svg,
Polygon,
Polyline,
renderToBuffer,
} from "@react-pdf/renderer";
import type { Invoice, InvoiceLine, InvoiceLineKind } from "@/types";
import { BRAND, Logo } from "./pdf-brand";
// ---------------------------------------------------------------------------
// Brand constants — edit here to tweak look without touching layout
// Brand: imported from lib/pdf-brand. Edit there to change issuer
// info, colours, or the logo. Both billing-pdf.tsx and credit-note-pdf.tsx
// share the same source of truth so a brand change applies to every
// PDF the portal produces.
// ---------------------------------------------------------------------------
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
// ---------------------------------------------------------------------------
@@ -80,6 +54,11 @@ interface PdfStrings {
dueDate: string;
period: string;
billTo: string;
// Phase 6 fix: prefix shown before the optional contact-person
// name on the bill-to block. "z.Hd." (DE) / "Attn:" (EN) /
// "À l'attention de" (FR) / "c.a." (IT). Empty/unused when the
// invoice has no contactName on its snapshot.
attentionPrefix: string;
description: string;
quantity: string;
unitPrice: string;
@@ -107,6 +86,7 @@ const MESSAGES: Record<string, PdfStrings> = {
dueDate: "Zahlbar bis",
period: "Abrechnungsperiode",
billTo: "Rechnungsempfänger",
attentionPrefix: "z.Hd.",
description: "Beschreibung",
quantity: "Menge",
unitPrice: "Einzelpreis",
@@ -139,6 +119,7 @@ const MESSAGES: Record<string, PdfStrings> = {
dueDate: "Due date",
period: "Billing period",
billTo: "Bill to",
attentionPrefix: "Attn:",
description: "Description",
quantity: "Qty",
unitPrice: "Unit price",
@@ -171,6 +152,7 @@ const MESSAGES: Record<string, PdfStrings> = {
dueDate: "Échéance",
period: "Période de facturation",
billTo: "Destinataire",
attentionPrefix: "À l'attention de",
description: "Description",
quantity: "Qté",
unitPrice: "Prix unitaire",
@@ -203,6 +185,7 @@ const MESSAGES: Record<string, PdfStrings> = {
dueDate: "Scadenza",
period: "Periodo di fatturazione",
billTo: "Destinatario",
attentionPrefix: "c.a.",
description: "Descrizione",
quantity: "Qtà",
unitPrice: "Prezzo unitario",
@@ -349,62 +332,6 @@ const styles = StyleSheet.create({
});
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
@@ -524,6 +451,15 @@ const InvoicePdf: React.FC<InvoicePdfProps> = ({ invoice, lines }) => {
<View style={styles.billToBlock}>
<Text style={styles.billToLabel}>{s.billTo}</Text>
<Text style={styles.billToName}>{snap.companyName}</Text>
{/* Phase 6 fix: optional "z.Hd." / "Attn:" line for routing
the printed invoice internally at the customer. Prints
between the company name and street address, in the
invoice's locale (frozen at issue time). */}
{snap.contactName && (
<Text>
{s.attentionPrefix} {snap.contactName}
</Text>
)}
<Text>{snap.streetAddress}</Text>
<Text>
{snap.postalCode} {snap.city}

View File

@@ -30,6 +30,7 @@
*/
import type {
CreditNote,
Invoice,
InvoiceBillingSnapshot,
InvoiceDraft,
@@ -44,6 +45,8 @@ import type {
TenantSuspensionEvent,
} from "@/types";
import {
attachCreditNotePdf,
createCreditNote,
createInvoice,
getInvoiceById,
getOrgBilling,
@@ -53,6 +56,8 @@ import {
listSkillEventsForTenant,
listSkillPricing,
listSuspensionEventsForTenant,
markInvoiceVoided,
recordInvoiceRefund,
tenantHasSetupFeeBilled,
tenantSkillHasBeenBilled,
updateInvoicePdf,
@@ -61,6 +66,9 @@ import { listTenants } from "./k8s";
import { getTeamSpendLogsV2 } from "./litellm";
import { getUsage as getThreemaUsage } from "./threema-relay";
import { renderInvoicePdf } from "./billing-pdf";
import { renderCreditNotePdf } from "./credit-note-pdf";
import { sendCreditNoteEmail, sendInvoiceIssuedEmail } from "./email";
import { createInvoiceRefund } from "./stripe";
import { formatLineDescription } from "./billing-i18n";
// ---------------------------------------------------------------------------
@@ -644,6 +652,7 @@ export async function computeInvoiceDraft(opts: {
}
const snapshot: InvoiceBillingSnapshot = {
companyName: orgBilling.companyName,
contactName: orgBilling.contactName ?? null,
streetAddress: orgBilling.streetAddress,
postalCode: orgBilling.postalCode,
city: orgBilling.city,
@@ -779,6 +788,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
@@ -790,3 +843,362 @@ export async function generateInvoice(opts: {
);
}
}
// ---------------------------------------------------------------------------
// Phase 7 — void and refund orchestration
// ---------------------------------------------------------------------------
export class VoidNotAllowedError extends Error {
constructor(message: string, public readonly currentStatus: string) {
super(message);
this.name = "VoidNotAllowedError";
}
}
export class RefundNotAllowedError extends Error {
constructor(message: string, public readonly currentStatus: string) {
super(message);
this.name = "RefundNotAllowedError";
}
}
/**
* Sanitize a locale string to the supported four. Used when picking
* which translation block to render emails/PDFs with. We never
* fall back to admin's locale here — the credit note inherits the
* invoice's locale so both documents read consistently to the
* customer.
*/
function pickSupportedLocale(
locale: string | null | undefined
): "de" | "en" | "fr" | "it" {
const supported = ["de", "en", "fr", "it"] as const;
return (supported as readonly string[]).includes(locale ?? "")
? (locale as "de" | "en" | "fr" | "it")
: "de";
}
/**
* Round a CHF amount to 2 decimal places. Used when proportionally
* splitting VAT between subtotal and refund amount — avoids
* accumulating fractional rappen across operations.
*/
function roundChf(amount: number): number {
return Math.round(amount * 100) / 100;
}
/**
* Void an unpaid invoice. State transition: open/overdue → void.
*
* Side effects, in order:
* 1. Mark the invoice voided (status, void_reason, voided_at, voided_by)
* 2. Insert credit_notes row (kind='void', amount=full invoice total)
* 3. Render the credit-note PDF and attach it to the row
* 4. Best-effort email to the billing contact
*
* Not allowed:
* - status='paid' (use refundInvoice instead — voiding paid
* invoices would create a record mismatch with the payment
* processor)
* - status='void' (already voided)
* - status='draft' (drafts aren't issued; nothing to void)
* - status='partially_refunded' / 'fully_refunded' (use refund
* for the remaining amount instead)
*
* Throws VoidNotAllowedError if the invoice is in a non-voidable
* state. Caller surfaces this as 409 Conflict to the admin.
*/
export async function voidInvoice(params: {
invoiceId: string;
reason: string;
voidedBy: string;
}): Promise<CreditNote> {
const invoice = await getInvoiceById(params.invoiceId);
if (!invoice) {
throw new Error(`Invoice not found: ${params.invoiceId}`);
}
// Only unpaid invoices can be voided. The state machine puts
// paid invoices on the refund path; voiding them would skip the
// payment reversal and leave the customer's money in our account
// with no obligation showing in the portal.
if (!["open", "overdue"].includes(invoice.status)) {
throw new VoidNotAllowedError(
`Cannot void invoice in status '${invoice.status}'. Voids are allowed only for open or overdue invoices; paid invoices must be refunded.`,
invoice.status
);
}
const locale = pickSupportedLocale(invoice.locale);
// The credit note matches the invoice 1:1 in amount and VAT.
// We carry the same VAT breakdown so the PDF can render
// "subtotal + VAT" the same way the original invoice did.
const creditNote = await createCreditNote({
invoiceId: invoice.id,
zitadelOrgId: invoice.zitadelOrgId,
kind: "void",
amountChf: invoice.totalChf,
vatAmountChf: invoice.vatAmountChf,
reason: params.reason || null,
issuedBy: params.voidedBy,
locale,
billingSnapshot: invoice.billingSnapshot,
});
// Mark invoice voided AFTER the credit note row exists, so the
// status change has a credit note to point at. If anything below
// here fails (PDF render, email), the invoice is still correctly
// voided and the credit note row exists — just without a PDF
// until manually re-rendered.
await markInvoiceVoided({
invoiceId: invoice.id,
reason: params.reason,
voidedBy: params.voidedBy,
});
// Render PDF + attach. PDF failure here doesn't undo the void —
// the customer can be told their invoice is voided and the PDF
// can be re-issued later. We surface the error in the response
// so admin knows to retry, but the void itself stands.
try {
const pdfBuffer = await renderCreditNotePdf(creditNote, invoice);
const filename = `${creditNote.creditNoteNumber}.pdf`;
await attachCreditNotePdf(creditNote.id, pdfBuffer, filename);
} catch (e) {
console.error(
`Credit note ${creditNote.creditNoteNumber} created but PDF render failed; re-render manually.`,
e
);
}
// Best-effort email. Same fail-soft pattern as invoice issuance.
try {
const snap = invoice.billingSnapshot;
if (snap.billingEmail) {
await sendCreditNoteEmail({
to: snap.billingEmail,
contactName: snap.contactName || snap.companyName,
companyName: snap.companyName,
creditNoteNumber: creditNote.creditNoteNumber,
invoiceNumber: invoice.invoiceNumber,
amountChf: creditNote.amountChf,
currency: "CHF",
kind: "void",
reason: params.reason || null,
locale,
});
}
} catch (e) {
console.error(
`Credit note ${creditNote.creditNoteNumber} issued; email send failed.`,
e
);
}
return creditNote;
}
/**
* Refund a paid invoice (in part or in full). State transition:
* paid → partially_refunded (if amount < remaining)
* paid → fully_refunded (if amount >= remaining)
* partially_refunded → fully_refunded (if cumulative >= total)
*
* Side effects, in order:
* 1. If the invoice was Stripe-paid (payment_method='card' with a
* stripe_payment_intent_id) AND no `existingStripeRefund` was
* passed, call Stripe to issue the refund. Stripe is the source
* of truth for actual money movement; we mirror its outcome
* locally.
* 2. Insert credit_notes row (kind='refund', amount=refund amount,
* VAT proportional)
* 3. Insert invoice_refunds row, linking to the credit note and to
* the Stripe refund (if any). recordInvoiceRefund updates the
* invoice's status atomically based on the new running total.
* 4. Render PDF + attach
* 5. Best-effort email
*
* `existingStripeRefund` is for the webhook path: when Stripe fires
* `charge.refunded` for a refund that was initiated directly in the
* Stripe Dashboard (not via this portal), the webhook needs to
* mirror the refund into the DB and issue a credit note WITHOUT
* calling Stripe again. Pass the refund id and status to skip the
* Stripe call.
*
* Not allowed:
* - status not in {paid, partially_refunded} — full refunds are
* only meaningful against actual payment
* - amount <= 0 or > remaining refundable
*
* For invoice-paid (non-Stripe) customers the Stripe step is
* skipped; refund settlement happens out-of-band (bank transfer)
* and admin records the action in the portal.
*/
export async function refundInvoice(params: {
invoiceId: string;
amountChf: number;
reason: string;
refundedBy: string;
/**
* Webhook path: a Stripe refund that has already been created
* (in the Stripe Dashboard or via a prior API call) and now needs
* to be mirrored into the portal. When set, the Stripe API call
* is skipped and the provided id/status are recorded as-is.
*/
existingStripeRefund?: {
id: string;
status: "pending" | "succeeded" | "failed" | "canceled";
};
}): Promise<CreditNote> {
const invoice = await getInvoiceById(params.invoiceId);
if (!invoice) {
throw new Error(`Invoice not found: ${params.invoiceId}`);
}
if (!["paid", "partially_refunded"].includes(invoice.status)) {
throw new RefundNotAllowedError(
`Cannot refund invoice in status '${invoice.status}'. Refunds are allowed only for paid invoices.`,
invoice.status
);
}
if (params.amountChf <= 0) {
throw new RefundNotAllowedError(
"Refund amount must be greater than zero.",
invoice.status
);
}
const remaining = roundChf(invoice.totalChf - invoice.refundedTotalChf);
if (params.amountChf - remaining > 0.005) {
// Allow a 0.005 tolerance to account for floating-point dust;
// anything genuinely larger is a real over-refund attempt.
throw new RefundNotAllowedError(
`Refund amount CHF ${params.amountChf.toFixed(2)} exceeds remaining refundable CHF ${remaining.toFixed(2)}.`,
invoice.status
);
}
const locale = pickSupportedLocale(invoice.locale);
// Proportional VAT split: refunded VAT / total VAT = refunded
// amount / total amount. Keep the proportion explicit so the
// credit note's "subtotal + VAT" lines reconcile to the same
// VAT rate as the original invoice.
const vatPortion =
invoice.totalChf > 0
? roundChf((params.amountChf * invoice.vatAmountChf) / invoice.totalChf)
: 0;
// Step 1: Stripe (only for card-paid invoices, and only when the
// caller hasn't already created the refund). We do this BEFORE
// any local DB writes for refund tracking — Stripe is the source
// of truth for money movement, and if the Stripe call fails we
// must NOT have recorded the refund locally (the customer would
// see a credit note for money they never received).
//
// The charge.refunded webhook will also fire later, but we record
// the refund here too so the admin gets immediate confirmation
// and the credit note can be issued without waiting for the
// webhook round-trip. The webhook is idempotent (dedups by
// stripe_refund_id) so it's safe to do both.
let stripeRefundId: string | null = null;
let stripeStatus: "pending" | "succeeded" | "failed" | "canceled" =
"succeeded";
const isStripePaid =
invoice.paymentMethod === "card" && !!invoice.stripePaymentIntentId;
if (params.existingStripeRefund) {
// Webhook path: don't call Stripe again; trust the provided id.
stripeRefundId = params.existingStripeRefund.id;
stripeStatus = params.existingStripeRefund.status;
} else if (isStripePaid) {
try {
const refund = await createInvoiceRefund({
paymentIntentId: invoice.stripePaymentIntentId!,
amountChf: params.amountChf,
reason: "requested_by_customer",
metadata: {
invoice_number: invoice.invoiceNumber,
refunded_by: params.refundedBy,
},
});
stripeRefundId = refund.id;
// Map Stripe statuses to our enum. Anything other than
// 'succeeded' or 'pending' is treated as a failure — we
// don't record the credit note in that case (see below).
if (refund.status === "succeeded") stripeStatus = "succeeded";
else if (refund.status === "pending") stripeStatus = "pending";
else if (refund.status === "canceled") stripeStatus = "canceled";
else stripeStatus = "failed";
} catch (e) {
throw new Error(
`Stripe refund failed: ${e instanceof Error ? e.message : String(e)}`
);
}
if (stripeStatus === "failed" || stripeStatus === "canceled") {
throw new Error(
`Stripe refund returned non-success status: ${stripeStatus}`
);
}
}
// Step 2: insert credit note (PDF still null at this point).
const creditNote = await createCreditNote({
invoiceId: invoice.id,
zitadelOrgId: invoice.zitadelOrgId,
kind: "refund",
amountChf: params.amountChf,
vatAmountChf: vatPortion,
reason: params.reason || null,
issuedBy: params.refundedBy,
locale,
billingSnapshot: invoice.billingSnapshot,
});
// Step 3: record the refund event and bump invoice status.
// recordInvoiceRefund handles status transitions and idempotency.
await recordInvoiceRefund({
invoiceId: invoice.id,
stripeRefundId,
amountChf: params.amountChf,
reason: params.reason || null,
refundedBy: params.refundedBy,
creditNoteId: creditNote.id,
status: stripeStatus,
});
// Step 4: render + attach PDF. As with voidInvoice, a PDF failure
// here doesn't undo the refund — the refund happened (in Stripe
// and the DB), only the document is missing. Admin can re-render.
try {
const pdfBuffer = await renderCreditNotePdf(creditNote, invoice);
const filename = `${creditNote.creditNoteNumber}.pdf`;
await attachCreditNotePdf(creditNote.id, pdfBuffer, filename);
} catch (e) {
console.error(
`Credit note ${creditNote.creditNoteNumber} created but PDF render failed; re-render manually.`,
e
);
}
// Step 5: best-effort email.
try {
const snap = invoice.billingSnapshot;
if (snap.billingEmail) {
await sendCreditNoteEmail({
to: snap.billingEmail,
contactName: snap.contactName || snap.companyName,
companyName: snap.companyName,
creditNoteNumber: creditNote.creditNoteNumber,
invoiceNumber: invoice.invoiceNumber,
amountChf: creditNote.amountChf,
currency: "CHF",
kind: "refund",
reason: params.reason || null,
locale,
});
}
} catch (e) {
console.error(
`Credit note ${creditNote.creditNoteNumber} issued; email send failed.`,
e
);
}
return creditNote;
}

468
src/lib/credit-note-pdf.tsx Normal file
View File

@@ -0,0 +1,468 @@
/**
* Credit-note PDF rendering via @react-pdf/renderer.
*
* Phase 7. Renders the same brand identity as the invoice PDF
* (hexagon logo, issuer block, layout) with one accent override:
* red instead of emerald. That difference is enough to make voids
* and refunds visually unmistakable from an invoice at a glance,
* while keeping every other element (logo shape, fonts, structure,
* issuer info, page footer) identical so the document family reads
* as one brand.
*
* Brand + Logo come from lib/pdf-brand. Edit there to change
* issuer info, colours, or the logo glyph — both invoice and
* credit-note PDFs pick the changes up.
*/
import React from "react";
import {
Document,
Page,
Text,
View,
StyleSheet,
renderToBuffer,
} from "@react-pdf/renderer";
import type { CreditNote, Invoice } from "@/types";
import { BRAND, Logo, ACCENT_CREDIT_NOTE } from "./pdf-brand";
const ACCENT = ACCENT_CREDIT_NOTE;
// ---------------------------------------------------------------------------
// Localized strings
// ---------------------------------------------------------------------------
interface CreditNoteStrings {
creditNote: string;
creditNoteNumber: string;
issueDate: string;
billTo: string;
attentionPrefix: string;
referenceInvoice: string;
reason: string;
voidLineLabel: string;
refundLineLabel: string;
subtotal: string;
vatLabel: string;
totalCredited: string;
footerVoidNote: string;
footerRefundNote: string;
vatNoteSwiss: string;
vatNoteReverseCharge: string;
vatNoteOutOfScope: string;
}
const MESSAGES: Record<string, CreditNoteStrings> = {
de: {
creditNote: "Gutschrift",
creditNoteNumber: "Gutschrift-Nr.",
issueDate: "Ausstellungsdatum",
billTo: "Empfänger",
attentionPrefix: "z.Hd.",
referenceInvoice: "Bezug Rechnung",
reason: "Begründung",
voidLineLabel: "Stornierung Rechnung {number}",
refundLineLabel: "Rückerstattung Rechnung {number}",
subtotal: "Zwischensumme",
vatLabel: "MWST",
totalCredited: "Gesamtbetrag Gutschrift",
footerVoidNote:
"Diese Gutschrift storniert die oben referenzierte Rechnung. Ein Zahlungsausgleich ist nicht erforderlich.",
footerRefundNote:
"Diese Gutschrift dokumentiert die Rückerstattung des oben genannten Betrags. Die Auszahlung erfolgt über den ursprünglichen Zahlungsweg.",
vatNoteSwiss:
"MWST gemäss schweizerischem Mehrwertsteuergesetz (MWSTG).",
vatNoteReverseCharge:
"Reverse Charge: Steuerschuldnerschaft des Leistungsempfängers nach Art. 196 EU-MwStSyst-RL bzw. nationaler Umsetzung.",
vatNoteOutOfScope:
"Leistung ausserhalb des Geltungsbereichs der schweizerischen MWST.",
},
en: {
creditNote: "Credit note",
creditNoteNumber: "Credit note no.",
issueDate: "Issue date",
billTo: "Bill to",
attentionPrefix: "Attn:",
referenceInvoice: "Reference invoice",
reason: "Reason",
voidLineLabel: "Void of invoice {number}",
refundLineLabel: "Refund for invoice {number}",
subtotal: "Subtotal",
vatLabel: "VAT",
totalCredited: "Total credited",
footerVoidNote:
"This credit note voids the referenced invoice. No payment is required.",
footerRefundNote:
"This credit note documents the refund of the amount above. Settlement occurs via the original payment method.",
vatNoteSwiss:
"VAT charged in accordance with Swiss VAT law (MWSTG).",
vatNoteReverseCharge:
"Reverse charge: VAT to be accounted for by the recipient per Art. 196 EU VAT Directive or national implementation.",
vatNoteOutOfScope:
"Service supplied outside the scope of Swiss VAT.",
},
fr: {
creditNote: "Note de crédit",
creditNoteNumber: "N° de note de crédit",
issueDate: "Date d'émission",
billTo: "Destinataire",
attentionPrefix: "À l'attention de",
referenceInvoice: "Facture de référence",
reason: "Motif",
voidLineLabel: "Annulation de la facture {number}",
refundLineLabel: "Remboursement de la facture {number}",
subtotal: "Sous-total",
vatLabel: "TVA",
totalCredited: "Total du crédit",
footerVoidNote:
"Cette note de crédit annule la facture référencée ci-dessus. Aucun paiement n'est requis.",
footerRefundNote:
"Cette note de crédit documente le remboursement du montant ci-dessus. Le règlement s'effectue via le moyen de paiement initial.",
vatNoteSwiss:
"TVA facturée conformément à la loi suisse sur la TVA (LTVA).",
vatNoteReverseCharge:
"Autoliquidation : TVA à acquitter par le destinataire selon l'art. 196 de la directive TVA UE ou sa mise en œuvre nationale.",
vatNoteOutOfScope:
"Prestation hors du champ d'application de la TVA suisse.",
},
it: {
creditNote: "Nota di credito",
creditNoteNumber: "N. nota di credito",
issueDate: "Data di emissione",
billTo: "Destinatario",
attentionPrefix: "c.a.",
referenceInvoice: "Fattura di riferimento",
reason: "Motivo",
voidLineLabel: "Annullamento della fattura {number}",
refundLineLabel: "Rimborso della fattura {number}",
subtotal: "Subtotale",
vatLabel: "IVA",
totalCredited: "Totale accreditato",
footerVoidNote:
"Questa nota di credito annulla la fattura sopra indicata. Non è richiesto alcun pagamento.",
footerRefundNote:
"Questa nota di credito documenta il rimborso dell'importo sopra indicato. Il regolamento avviene tramite il metodo di pagamento originale.",
vatNoteSwiss:
"IVA addebitata in conformità alla legge svizzera sull'IVA (LIVA).",
vatNoteReverseCharge:
"Inversione contabile: IVA dovuta dal destinatario ai sensi dell'art. 196 della direttiva IVA UE o della sua attuazione nazionale.",
vatNoteOutOfScope:
"Prestazione fuori dal campo di applicazione dell'IVA svizzera.",
},
};
function pickStrings(locale: string): CreditNoteStrings {
return MESSAGES[locale] ?? MESSAGES.de;
}
// Swiss number formatting — matches billing-pdf for consistency
function fmtChf(n: number): string {
const fixed = n.toFixed(2);
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 {
const [y, m, d] = iso.split("T")[0].split("-").map(Number);
if (locale === "en") {
return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
return `${String(d).padStart(2, "0")}.${String(m).padStart(2, "0")}.${y}`;
}
function pickVatNote(
invoice: Invoice,
strings: CreditNoteStrings
): string | null {
const country = invoice.billingSnapshot.country?.toUpperCase();
const hasVat = invoice.billingSnapshot.vatNumber?.trim();
if (country === "CH" || country === "LI") return strings.vatNoteSwiss;
if (hasVat) return strings.vatNoteReverseCharge;
return strings.vatNoteOutOfScope;
}
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const styles = StyleSheet.create({
page: {
paddingTop: 36,
paddingBottom: 50,
paddingHorizontal: 50,
fontSize: 10,
fontFamily: "Helvetica",
color: BRAND.textColor,
},
headerRow: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 32,
},
logoBlock: { flexDirection: "row", alignItems: "center" },
brandName: {
fontSize: 16,
color: ACCENT.primaryDark,
marginLeft: 8,
fontFamily: "Helvetica-Bold",
},
issuerBlock: { textAlign: "right", fontSize: 8.5, color: BRAND.mutedColor },
issuerName: { fontSize: 11, color: ACCENT.primaryDark, marginBottom: 2 },
docTitle: {
fontSize: 22,
color: ACCENT.primaryDark,
marginBottom: 8,
fontFamily: "Helvetica-Bold",
},
metaTable: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 20,
},
metaCol: { flexDirection: "column", minWidth: 140 },
metaLabel: { fontSize: 8, color: BRAND.mutedColor, marginBottom: 2 },
metaValue: { fontSize: 10 },
billTo: {
marginBottom: 24,
padding: 8,
backgroundColor: "#fdf2f2",
borderLeftWidth: 3,
borderLeftColor: ACCENT.primary,
},
billToLabel: { fontSize: 8, color: BRAND.mutedColor, marginBottom: 4 },
billToName: { fontSize: 11, marginBottom: 2 },
amountTable: {
borderTopWidth: 1,
borderTopColor: BRAND.borderColor,
borderBottomWidth: 1,
borderBottomColor: BRAND.borderColor,
marginBottom: 16,
},
amountHeader: {
flexDirection: "row",
backgroundColor: ACCENT.primaryDark,
color: "#ffffff",
paddingVertical: 5,
paddingHorizontal: 6,
fontSize: 9,
fontFamily: "Helvetica-Bold",
},
amountRow: {
flexDirection: "row",
paddingVertical: 8,
paddingHorizontal: 6,
borderBottomWidth: 1,
borderBottomColor: "#f0f0f0",
},
amountDesc: { flex: 1 },
amountValue: { width: 90, textAlign: "right" },
totals: { marginLeft: "auto", width: 220, marginBottom: 20 },
totalsRow: {
flexDirection: "row",
justifyContent: "space-between",
paddingVertical: 3,
},
totalsLabel: { color: BRAND.mutedColor, fontSize: 10 },
totalsValue: { fontSize: 10 },
totalsGrand: {
flexDirection: "row",
justifyContent: "space-between",
borderTopWidth: 1,
borderTopColor: ACCENT.primaryDark,
paddingTop: 6,
marginTop: 4,
},
totalsGrandLabel: {
color: ACCENT.primaryDark,
fontSize: 11,
fontFamily: "Helvetica-Bold",
},
totalsGrandValue: {
color: ACCENT.primaryDark,
fontSize: 11,
textAlign: "right",
fontFamily: "Helvetica-Bold",
},
reasonBox: {
marginTop: 4,
marginBottom: 18,
padding: 8,
backgroundColor: "#fafafa",
borderLeftWidth: 2,
borderLeftColor: BRAND.borderColor,
},
reasonLabel: {
fontSize: 8,
color: BRAND.mutedColor,
marginBottom: 2,
textTransform: "uppercase",
letterSpacing: 0.5,
},
reasonText: { fontSize: 9.5, color: BRAND.textColor },
noteBox: {
marginTop: 12,
padding: 8,
fontSize: 8.5,
color: BRAND.mutedColor,
lineHeight: 1.5,
},
footer: {
position: "absolute",
bottom: 24,
left: 50,
right: 50,
fontSize: 7.5,
color: BRAND.mutedColor,
textAlign: "center",
borderTopWidth: 0.5,
borderTopColor: BRAND.borderColor,
paddingTop: 6,
},
});
interface CreditNotePdfProps {
creditNote: CreditNote;
invoice: Invoice;
}
function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
const strings = pickStrings(creditNote.locale);
const snap = creditNote.billingSnapshot;
const vatNote = pickVatNote(invoice, strings);
const amountLabelTemplate =
creditNote.kind === "void" ? strings.voidLineLabel : strings.refundLineLabel;
const amountLabel = amountLabelTemplate.replace(
"{number}",
invoice.invoiceNumber
);
const footerNote =
creditNote.kind === "void" ? strings.footerVoidNote : strings.footerRefundNote;
// Stored convention: amount_chf is gross (incl. VAT),
// vat_amount_chf is the VAT portion. Subtotal computed for
// display.
const subtotal = creditNote.amountChf - creditNote.vatAmountChf;
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Header — SAME hexagon logo as the invoice, tinted red.
Issuer block from BRAND.issuer (shared with invoice). */}
<View style={styles.headerRow}>
<View style={styles.logoBlock}>
<Logo size={42} color={ACCENT.primary} />
<Text style={styles.brandName}>{BRAND.name}</Text>
</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>
<Text>{BRAND.issuer.web}</Text>
{BRAND.issuer.vatNumber && (
<Text>MWST-Nr. {BRAND.issuer.vatNumber}</Text>
)}
</View>
</View>
<Text style={styles.docTitle}>{strings.creditNote}</Text>
<View style={styles.metaTable}>
<View style={styles.metaCol}>
<Text style={styles.metaLabel}>{strings.creditNoteNumber}</Text>
<Text style={styles.metaValue}>{creditNote.creditNoteNumber}</Text>
</View>
<View style={styles.metaCol}>
<Text style={styles.metaLabel}>{strings.issueDate}</Text>
<Text style={styles.metaValue}>
{fmtDate(creditNote.issuedAt, creditNote.locale)}
</Text>
</View>
<View style={styles.metaCol}>
<Text style={styles.metaLabel}>{strings.referenceInvoice}</Text>
<Text style={styles.metaValue}>{invoice.invoiceNumber}</Text>
</View>
</View>
<View style={styles.billTo}>
<Text style={styles.billToLabel}>{strings.billTo}</Text>
<Text style={styles.billToName}>{snap.companyName}</Text>
{snap.contactName && snap.contactName.trim().length > 0 && (
<Text>
{strings.attentionPrefix} {snap.contactName}
</Text>
)}
<Text>{snap.streetAddress}</Text>
<Text>
{snap.postalCode} {snap.city}
</Text>
<Text>{snap.country}</Text>
{snap.vatNumber && <Text>MWST/VAT: {snap.vatNumber}</Text>}
</View>
<View style={styles.amountTable}>
<View style={styles.amountHeader}>
<Text style={styles.amountDesc}> </Text>
<Text style={styles.amountValue}>CHF</Text>
</View>
<View style={styles.amountRow}>
<Text style={styles.amountDesc}>{amountLabel}</Text>
<Text style={styles.amountValue}>{fmtChf(subtotal)}</Text>
</View>
</View>
<View style={styles.totals}>
<View style={styles.totalsRow}>
<Text style={styles.totalsLabel}>{strings.subtotal}</Text>
<Text style={styles.totalsValue}>CHF {fmtChf(subtotal)}</Text>
</View>
{creditNote.vatAmountChf > 0 && (
<View style={styles.totalsRow}>
<Text style={styles.totalsLabel}>
{strings.vatLabel} ({Number(invoice.vatRate).toFixed(1)}%)
</Text>
<Text style={styles.totalsValue}>
CHF {fmtChf(creditNote.vatAmountChf)}
</Text>
</View>
)}
<View style={styles.totalsGrand}>
<Text style={styles.totalsGrandLabel}>{strings.totalCredited}</Text>
<Text style={styles.totalsGrandValue}>
CHF {fmtChf(creditNote.amountChf)}
</Text>
</View>
</View>
{creditNote.reason && creditNote.reason.trim().length > 0 && (
<View style={styles.reasonBox}>
<Text style={styles.reasonLabel}>{strings.reason}</Text>
<Text style={styles.reasonText}>{creditNote.reason}</Text>
</View>
)}
<View style={styles.noteBox}>
<Text>{footerNote}</Text>
{vatNote && <Text style={{ marginTop: 6 }}>{vatNote}</Text>}
</View>
<Text style={styles.footer} fixed>
{BRAND.issuer.legalName} · {creditNote.creditNoteNumber}
</Text>
</Page>
</Document>
);
}
export async function renderCreditNotePdf(
creditNote: CreditNote,
invoice: Invoice
): Promise<Buffer> {
const doc = <CreditNotePdfDocument creditNote={creditNote} invoice={invoice} />;
return renderToBuffer(doc) as unknown as Buffer;
}

360
src/lib/cron.ts Normal file
View File

@@ -0,0 +1,360 @@
/**
* Phase 5 — Automated billing cron logic.
*
* This module hosts the two sweeps:
* - runMonthlyIssuance() — invoked monthly to generate invoices
* for orgs opted into auto-issuance. Idempotent via the
* uniq_invoices_org_period constraint on invoices: a re-run
* for an org that's already been billed for the target period
* gets caught as a duplicate and counted as a skip, not a
* failure.
* - runReminderSweep() — invoked daily. Walks open/overdue
* invoices, sends the appropriate reminder level (1/2/3) once
* per invoice via the invoice_reminders unique-key constraint.
*
* Both entry points return a summary {success, failure, skipped}
* that the caller persists via finishCronRun(). The shared
* structure means the HTTP routes (machine + admin variants) are
* trivial wrappers.
*
* Time-of-month math is timezone-aware: we read the calendar in
* Europe/Zurich rather than UTC, because the K8s CronJob schedules
* at 00:30 local time on the 1st — UTC at that moment is still in
* the previous month, and a naive `getUTCMonth() - 1` would bill
* the wrong period.
*/
import {
finishCronRun,
getLastSuccessfulCronRuns,
getOrgBilling,
getReminderLevelsSent,
listAutoIssueOrgIds,
listInvoicesPendingReminders,
recordReminderSent,
startCronRun,
syncOverdueInvoices,
} from "./db";
import { generateInvoice } from "./billing";
import { sendInvoiceReminderEmail } from "./email";
// The org_billing snapshot's company_name field doubles as the
// recipient name when no separate "billing contact" exists in
// our schema. Same convention as Phase 3's issuance email.
// All cron timing assumes Switzerland's calendar — the operator,
// the customers, and the legal basis (Swiss MWST) are all here.
const TZ = "Europe/Zurich";
export type CronSummary = {
successCount: number;
failureCount: number;
skippedCount: number;
errorDetails: Array<{
orgId?: string;
invoiceId?: string;
reason: string;
}>;
};
// ---------------------------------------------------------------------------
// Monthly issuance
// ---------------------------------------------------------------------------
/**
* The (year, month) of the calendar month that ended JUST BEFORE
* `now` in the configured timezone. This is what the issuance
* sweep bills.
*
* Reading the local-time calendar avoids a UTC-vs-local off-by-one
* when the sweep runs at 00:30 Zurich and UTC is still in the
* previous month.
*/
export function previousLocalMonth(
now: Date = new Date()
): { year: number; month: number } {
const fmt = new Intl.DateTimeFormat("en-CA", {
timeZone: TZ,
year: "numeric",
month: "2-digit",
});
const parts = fmt.formatToParts(now);
const year = Number(parts.find((p) => p.type === "year")!.value);
const month = Number(parts.find((p) => p.type === "month")!.value);
if (month === 1) return { year: year - 1, month: 12 };
return { year, month: month - 1 };
}
export async function runMonthlyIssuance(opts: {
triggeredBy: string;
/** Override target year/month — defaults to previous local month. */
year?: number;
month?: number;
}): Promise<{ runId: string; summary: CronSummary }> {
const target =
opts.year && opts.month
? { year: opts.year, month: opts.month }
: previousLocalMonth();
const runId = await startCronRun("monthly_issue", opts.triggeredBy);
const summary: CronSummary = {
successCount: 0,
failureCount: 0,
skippedCount: 0,
errorDetails: [],
};
try {
const orgIds = await listAutoIssueOrgIds();
for (const orgId of orgIds) {
try {
const orgBilling = await getOrgBilling(orgId);
if (!orgBilling) {
// Auto-issue is enabled but billing details are missing.
// Skip rather than fail — the admin needs to complete the
// address before invoicing can succeed.
summary.skippedCount += 1;
summary.errorDetails.push({
orgId,
reason: "org_billing not configured",
});
continue;
}
// Derive invoice locale from the org's country. PieCed is
// Swiss-default; CH/LI/AT/DE customers get the German PDF,
// FR/BE/LU customers get French, IT customers get Italian,
// anything else falls through to English. Customers needing
// a different locale can still trigger a manual issuance
// with an explicit override from the admin UI.
const locale = pickLocaleForCountry(orgBilling.country);
const { invoice } = await generateInvoice({
zitadelOrgId: orgId,
year: target.year,
month: target.month,
locale,
});
if (invoice) {
summary.successCount += 1;
} else {
// dryRun path — shouldn't happen in production. Defensive.
summary.skippedCount += 1;
}
} catch (e: any) {
// The uniqueness constraint on (zitadel_org_id, period_start)
// surfaces as "An invoice already exists for this org and
// billing period" from createInvoice. Re-running the cron
// mid-month or after a partial completion is therefore safe:
// already-billed orgs end up as skipped, not failed.
const msg = String(e?.message ?? e);
const isAlreadyIssued = /already exists for this org and billing period/i.test(
msg
);
if (isAlreadyIssued) {
summary.skippedCount += 1;
} else {
summary.failureCount += 1;
summary.errorDetails.push({ orgId, reason: msg });
console.error(
`runMonthlyIssuance: org ${orgId} failed:`,
e
);
}
}
}
await finishCronRun(runId, summary);
return { runId, summary };
} catch (e) {
// Catastrophic — the sweep itself failed (DB down, etc).
summary.failureCount += 1;
summary.errorDetails.push({
reason: `sweep aborted: ${e instanceof Error ? e.message : String(e)}`,
});
await finishCronRun(runId, summary).catch(() => undefined);
throw e;
}
}
// ---------------------------------------------------------------------------
// Reminder sweep
// ---------------------------------------------------------------------------
/**
* Which reminder level (if any) is due now for this invoice?
*
* Logic:
* - days_past_due >= 30 AND level 3 not yet sent → 3 (final)
* - else days_past_due >= 14 AND level 2 not yet sent → 2
* - else days_past_due >= 7 AND level 1 not yet sent → 1
* - else → null (nothing to do this run)
*
* One reminder per cron run per invoice — highest applicable
* un-sent level wins. If a customer fell behind quickly and is
* already 35 days past due without ever having received levels
* 1 or 2 (e.g. the cron was broken for a while), they get level
* 3 directly. We don't backfill lower levels.
*/
function nextReminderLevel(
daysPastDue: number,
sent: Set<number>
): 1 | 2 | 3 | null {
if (daysPastDue >= 30 && !sent.has(3)) return 3;
if (daysPastDue >= 14 && !sent.has(2)) return 2;
if (daysPastDue >= 7 && !sent.has(1)) return 1;
return null;
}
function daysBetween(later: Date, earlier: Date): number {
const ms = later.getTime() - earlier.getTime();
return Math.floor(ms / (1000 * 60 * 60 * 24));
}
/**
* Pick a default invoice locale based on the org's country
* (ISO 3166-1 alpha-2 code from org_billing.country). PieCed is
* primarily a Swiss-German operator; CH/LI/AT/DE get German,
* FR/BE/LU get French, IT gets Italian, anything else falls
* through to English.
*
* This only drives the automated issuance default. Manual
* issuance from the admin UI takes an explicit override.
*/
function pickLocaleForCountry(country: string): "de" | "en" | "fr" | "it" {
const c = 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";
}
export async function runReminderSweep(opts: {
triggeredBy: string;
}): Promise<{ runId: string; summary: CronSummary }> {
const runId = await startCronRun("reminders", opts.triggeredBy);
const summary: CronSummary = {
successCount: 0,
failureCount: 0,
skippedCount: 0,
errorDetails: [],
};
try {
// Flip stale 'open' → 'overdue' first so the listing reflects
// current status, and audit trails stay accurate.
await syncOverdueInvoices().catch((e) => {
console.warn("syncOverdueInvoices failed during reminder sweep:", e);
});
const candidates = await listInvoicesPendingReminders();
const now = new Date();
for (const inv of candidates) {
try {
const sent = await getReminderLevelsSent(inv.id);
const dueAt = new Date(inv.dueAt);
const days = daysBetween(now, dueAt);
const level = nextReminderLevel(days, sent);
if (level === null) {
summary.skippedCount += 1;
continue;
}
const billing = inv.billingSnapshot;
if (!billing.billingEmail) {
summary.skippedCount += 1;
summary.errorDetails.push({
invoiceId: inv.id,
reason: "no billing email on snapshot",
});
continue;
}
const supportedLocales: Array<"de" | "en" | "fr" | "it"> = [
"de", "en", "fr", "it",
];
const locale = supportedLocales.includes(inv.locale as any)
? (inv.locale as "de" | "en" | "fr" | "it")
: "de";
await sendInvoiceReminderEmail({
to: billing.billingEmail,
contactName: billing.companyName,
companyName: billing.companyName,
invoiceNumber: inv.invoiceNumber,
totalChf: inv.totalChf,
currency: "CHF",
dueAt: inv.dueAt,
daysPastDue: days,
level,
locale,
});
// Record AFTER the send. If the SMTP send fails the email
// helper logs and doesn't throw, so we'd still record — but
// that's a tradeoff we accept: at-least-once delivery semantics
// with logged warnings is better than at-most-once where a
// transient failure stops the customer from ever getting
// reminded. If duplicate-reminder fatigue becomes a real
// problem in production, switch to: send first, only record
// on confirmed transporter success.
await recordReminderSent({
invoiceId: inv.id,
level,
sentBy: opts.triggeredBy,
emailSentTo: billing.billingEmail,
});
summary.successCount += 1;
} catch (e: any) {
summary.failureCount += 1;
summary.errorDetails.push({
invoiceId: inv.id,
reason: String(e?.message ?? e),
});
console.error(
`runReminderSweep: invoice ${inv.id} failed:`,
e
);
}
}
await finishCronRun(runId, summary);
return { runId, summary };
} catch (e) {
summary.failureCount += 1;
summary.errorDetails.push({
reason: `sweep aborted: ${e instanceof Error ? e.message : String(e)}`,
});
await finishCronRun(runId, summary).catch(() => undefined);
throw e;
}
}
// ---------------------------------------------------------------------------
// Auth — bearer token for the machine endpoints
// ---------------------------------------------------------------------------
/**
* Constant-time bearer token check. The CRON_BEARER_TOKEN env var
* is injected from OpenBao via the portal-cron K8s Secret. Both
* the CronJob and the portal Deployment reference it; the
* CronJob sends it in the Authorization header, the portal checks
* with timing-safe equals to defeat character-by-character probing.
*/
export function verifyCronBearer(authHeader: string | null): boolean {
if (!authHeader) return false;
const expected = process.env.CRON_BEARER_TOKEN;
if (!expected || expected.length < 16) {
// Treat misconfiguration as a hard refusal so a missing/
// accidentally-empty token doesn't silently grant access.
return false;
}
if (!authHeader.startsWith("Bearer ")) return false;
const got = authHeader.slice("Bearer ".length).trim();
if (got.length !== expected.length) return false;
// Constant-time byte compare. Node's Buffer.compare and the
// crypto.timingSafeEqual function both work, but the latter
// throws on length mismatch; the length pre-check above
// protects against that.
let diff = 0;
for (let i = 0; i < got.length; i++) {
diff |= got.charCodeAt(i) ^ expected.charCodeAt(i);
}
return diff === 0;
}
// Re-export for the admin UI to render "last run X ago" indicators.
export { getLastSuccessfulCronRuns };

File diff suppressed because it is too large Load Diff

View File

@@ -900,3 +900,414 @@ 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);
}
}
// ---------------------------------------------------------------------------
// Reminder emails — Phase 5
// ---------------------------------------------------------------------------
/**
* Send a payment reminder for an open/overdue invoice.
*
* Three escalation levels:
* 1 — Gentle nudge: ~7 days past due. Friendly tone, "in case
* you missed it".
* 2 — Firmer reminder: ~14 days past due. Clear that payment is
* outstanding, please pay.
* 3 — Final notice: ~30 days past due. Explicit consequences
* (service may be suspended). Last automated touch — beyond
* this, admin involvement is expected.
*
* Failure is logged, never thrown — the cron sweep must continue
* past a single failed send.
*/
export async function sendInvoiceReminderEmail(params: {
to: string;
contactName: string;
companyName: string;
invoiceNumber: string;
totalChf: number;
currency: string;
dueAt: string;
daysPastDue: number;
level: 1 | 2 | 3;
locale: "de" | "en" | "fr" | "it";
}): Promise<void> {
const L = params.locale;
// Per-locale strings keyed by the three escalation levels.
// Kept inline (rather than the next-intl message files) because
// the email layer doesn't import from React's i18n context.
const SUBJECTS: Record<typeof L, Record<1 | 2 | 3, string>> = {
en: {
1: `Friendly reminder: invoice ${params.invoiceNumber} is overdue`,
2: `Second reminder: invoice ${params.invoiceNumber} is still unpaid`,
3: `Final notice: invoice ${params.invoiceNumber} requires immediate payment`,
},
de: {
1: `Freundliche Erinnerung: Rechnung ${params.invoiceNumber} ist überfällig`,
2: `Zweite Mahnung: Rechnung ${params.invoiceNumber} ist weiterhin unbezahlt`,
3: `Letzte Mahnung: Rechnung ${params.invoiceNumber} erfordert sofortige Zahlung`,
},
fr: {
1: `Rappel amical : la facture ${params.invoiceNumber} est en retard`,
2: `Deuxième rappel : la facture ${params.invoiceNumber} reste impayée`,
3: `Dernier avis : la facture ${params.invoiceNumber} doit être réglée sans délai`,
},
it: {
1: `Promemoria amichevole: la fattura ${params.invoiceNumber} è scaduta`,
2: `Secondo sollecito: la fattura ${params.invoiceNumber} è ancora insoluta`,
3: `Avviso finale: la fattura ${params.invoiceNumber} richiede pagamento immediato`,
},
};
const INTROS: Record<typeof L, Record<1 | 2 | 3, string>> = {
en: {
1: "We noticed this invoice hasn't been settled yet — in case it slipped through.",
2: "This invoice remains unpaid. Please arrange payment at your earliest convenience.",
3: "This invoice is significantly overdue. Service may be suspended if payment is not received promptly.",
},
de: {
1: "Diese Rechnung scheint noch nicht beglichen — falls sie übersehen wurde, möchten wir freundlich daran erinnern.",
2: "Diese Rechnung ist weiterhin unbezahlt. Bitte veranlassen Sie die Zahlung umgehend.",
3: "Diese Rechnung ist erheblich überfällig. Bei nicht zeitnaher Zahlung kann der Dienst ausgesetzt werden.",
},
fr: {
1: "Cette facture n'a pas encore été réglée — au cas où elle vous aurait échappé.",
2: "Cette facture reste impayée. Merci d'effectuer le paiement dans les meilleurs délais.",
3: "Cette facture est en grand retard. Le service pourra être suspendu en l'absence de paiement rapide.",
},
it: {
1: "Questa fattura non risulta ancora saldata — nel caso vi fosse sfuggita.",
2: "Questa fattura risulta ancora insoluta. Si prega di provvedere al pagamento al più presto.",
3: "Questa fattura è significativamente in ritardo. In assenza di pagamento tempestivo il servizio potrà essere sospeso.",
},
};
const LABELS: Record<typeof L, Record<string, string>> = {
en: { num: "Invoice", total: "Total", due: "Due date", days: "Days past due", cta: "View invoice & pay", signoff: "Best regards", brand: "PieCed IT", greeting: "Hello" },
de: { num: "Rechnung", total: "Gesamt", due: "Fälligkeitsdatum", days: "Tage überfällig", cta: "Rechnung ansehen & bezahlen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT", greeting: "Sehr geehrte/r" },
fr: { num: "Facture", total: "Total", due: "Échéance", days: "Jours de retard", cta: "Voir la facture & payer", signoff: "Cordialement", brand: "PieCed IT", greeting: "Bonjour" },
it: { num: "Fattura", total: "Totale", due: "Scadenza", days: "Giorni di ritardo", cta: "Vedi fattura & paga", signoff: "Cordiali saluti", brand: "PieCed IT", greeting: "Gentile" },
};
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 dueFmt = params.dueAt.slice(0, 10);
const link = `https://app.pieced.ch/billing/${encodeURIComponent(params.invoiceNumber)}`;
// Final-notice gets red accent; earlier levels keep the brand green.
const accent = params.level === 3 ? "#dc2626" : "#10B981";
try {
await getTransporter().sendMail({
from: getFrom(),
to: params.to,
subject: SUBJECTS[L][params.level],
text: [
`${l.greeting} ${params.contactName},`,
"",
INTROS[L][params.level],
"",
`${l.num}: ${params.invoiceNumber}`,
`${l.total}: ${totalFmt}`,
`${l.due}: ${dueFmt}`,
`${l.days}: ${params.daysPastDue}`,
"",
`${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:${accent};">${escapeHtml(SUBJECTS[L][params.level])}</h2>
<p>${l.greeting} ${safeName},</p>
<p>${escapeHtml(INTROS[L][params.level])}</p>
<table style="width:100%;border-collapse:collapse;margin:16px 0;font-size:14px;">
<tr><td style="color:#888;padding:6px 0;width:140px;">${l.num}</td><td><strong>${safeNumber}</strong></td></tr>
<tr><td style="color:#888;padding:6px 0;">${l.total}</td><td style="color:${accent};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.days}</td><td>${params.daysPastDue}</td></tr>
</table>
<p>
<a href="${link}" style="display:inline-block;padding:10px 24px;background:${accent};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 reminder L${params.level} for invoice ${params.invoiceNumber}:`,
err
);
}
}
// ---------------------------------------------------------------------------
// Credit note emails — Phase 7
// ---------------------------------------------------------------------------
/**
* Send a credit-note notification to the customer's billing email.
*
* Covers both kinds (void and refund). The subject and body adapt
* based on `kind` — voids ("we've cancelled invoice X, no payment
* needed") read very differently from refunds ("we've refunded CHF
* X, expect to see it on your card statement within 5-10 days").
*
* Link-only — the PDF is not attached. The customer downloads it
* from /api/credit-notes/<number>/pdf when they click through, which
* also gives them a permanent in-portal record next to their
* invoices. Same approach as invoice emails.
*
* Best-effort: failures are logged and swallowed. A mail-server
* hiccup must never roll back a credit-note issuance.
*/
export async function sendCreditNoteEmail(params: {
to: string;
contactName: string;
companyName: string;
creditNoteNumber: string;
invoiceNumber: string;
amountChf: number;
currency: string;
kind: "void" | "refund";
reason: string | null;
locale: "de" | "en" | "fr" | "it";
}): Promise<void> {
const L = params.locale;
const totalFmt = `${params.currency} ${params.amountChf.toFixed(2)}`;
const link = `https://app.pieced.ch/billing/cn/${encodeURIComponent(
params.creditNoteNumber
)}`;
// Subject lines diverge between void and refund — different
// mental models for the recipient. Void: "your charge is
// cancelled". Refund: "your money is on the way back".
const subjectsByLocale: Record<typeof L, { void: string; refund: string }> = {
en: {
void: `Invoice ${params.invoiceNumber} cancelled — credit note ${params.creditNoteNumber}`,
refund: `Refund of ${totalFmt} for invoice ${params.invoiceNumber} — credit note ${params.creditNoteNumber}`,
},
de: {
void: `Rechnung ${params.invoiceNumber} storniert — Gutschrift ${params.creditNoteNumber}`,
refund: `Rückerstattung ${totalFmt} für Rechnung ${params.invoiceNumber} — Gutschrift ${params.creditNoteNumber}`,
},
fr: {
void: `Facture ${params.invoiceNumber} annulée — note de crédit ${params.creditNoteNumber}`,
refund: `Remboursement ${totalFmt} pour la facture ${params.invoiceNumber} — note de crédit ${params.creditNoteNumber}`,
},
it: {
void: `Fattura ${params.invoiceNumber} annullata — nota di credito ${params.creditNoteNumber}`,
refund: `Rimborso ${totalFmt} per fattura ${params.invoiceNumber} — nota di credito ${params.creditNoteNumber}`,
},
};
const greetingsByLocale: Record<typeof L, string> = {
en: `Hello ${params.contactName},`,
de: `Sehr geehrte/r ${params.contactName},`,
fr: `Bonjour ${params.contactName},`,
it: `Gentile ${params.contactName},`,
};
// Intro: distinct phrasing per kind in each locale.
const introsByLocale: Record<typeof L, { void: string; refund: string }> = {
en: {
void: `We've cancelled invoice ${params.invoiceNumber}. The invoice is no longer payable, and a credit note has been issued for your records.`,
refund: `We've refunded ${totalFmt} for invoice ${params.invoiceNumber}. The refund will appear on the original payment method within 510 business days, depending on your bank.`,
},
de: {
void: `Wir haben Rechnung ${params.invoiceNumber} storniert. Die Rechnung ist nicht mehr zahlbar; eine Gutschrift wurde für Ihre Unterlagen ausgestellt.`,
refund: `Wir haben ${totalFmt} für Rechnung ${params.invoiceNumber} zurückerstattet. Der Betrag wird je nach Bank innerhalb von 510 Geschäftstagen auf dem ursprünglichen Zahlungsweg gutgeschrieben.`,
},
fr: {
void: `Nous avons annulé la facture ${params.invoiceNumber}. La facture n'est plus exigible ; une note de crédit a été émise pour vos archives.`,
refund: `Nous avons remboursé ${totalFmt} pour la facture ${params.invoiceNumber}. Le montant apparaîtra sur le moyen de paiement initial sous 5 à 10 jours ouvrés, selon votre banque.`,
},
it: {
void: `Abbiamo annullato la fattura ${params.invoiceNumber}. La fattura non è più dovuta; è stata emessa una nota di credito per la sua documentazione.`,
refund: `Abbiamo rimborsato ${totalFmt} per la fattura ${params.invoiceNumber}. L'importo apparirà sul metodo di pagamento originale entro 510 giorni lavorativi, a seconda della banca.`,
},
};
const labels: Record<typeof L, Record<string, string>> = {
en: { creditNote: "Credit note", invoice: "Invoice", amount: "Amount", reason: "Reason", cta: "View credit note & download PDF", signoff: "Best regards", brand: "PieCed IT" },
de: { creditNote: "Gutschrift", invoice: "Rechnung", amount: "Betrag", reason: "Begründung", cta: "Gutschrift ansehen & PDF herunterladen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT" },
fr: { creditNote: "Note de crédit", invoice: "Facture", amount: "Montant", reason: "Motif", cta: "Voir la note de crédit & télécharger le PDF", signoff: "Cordialement", brand: "PieCed IT" },
it: { creditNote: "Nota di credito", invoice: "Fattura", amount: "Importo", reason: "Motivo", cta: "Visualizza nota di credito & scarica PDF", signoff: "Cordiali saluti", brand: "PieCed IT" },
};
const l = labels[L];
const subject = subjectsByLocale[L][params.kind];
const intro = introsByLocale[L][params.kind];
const safeName = escapeHtml(params.contactName);
const safeNumberCN = escapeHtml(params.creditNoteNumber);
const safeNumberINV = escapeHtml(params.invoiceNumber);
const safeReason = params.reason ? escapeHtml(params.reason) : null;
// Red accent (#DC2626) for the credit-note emails, mirroring the
// PDF accent so the document family reads visually consistent.
// The invoice email uses emerald; the credit note uses red.
const ACCENT = "#DC2626";
try {
await getTransporter().sendMail({
from: getFrom(),
to: params.to,
subject,
text: [
greetingsByLocale[L],
"",
intro,
"",
`${l.creditNote}: ${params.creditNoteNumber}`,
`${l.invoice}: ${params.invoiceNumber}`,
`${l.amount}: ${totalFmt}`,
...(params.reason ? [`${l.reason}: ${params.reason}`] : []),
"",
`${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: ${ACCENT};">${escapeHtml(intro)}</h2>
<p>${safeName === "" ? "" : escapeHtml(greetingsByLocale[L])}</p>
<table style="width:100%; border-collapse:collapse; margin:16px 0; font-size:14px;">
<tr><td style="color:#888; padding:6px 0; width:140px;">${l.creditNote}</td><td><strong>${safeNumberCN}</strong></td></tr>
<tr><td style="color:#888; padding:6px 0;">${l.invoice}</td><td>${safeNumberINV}</td></tr>
<tr><td style="color:#888; padding:6px 0;">${l.amount}</td><td style="color:${ACCENT}; font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
${safeReason ? `<tr><td style="color:#888; padding:6px 0; vertical-align:top;">${l.reason}</td><td style="color:#bbb;">${safeReason}</td></tr>` : ""}
</table>
<p>
<a href="${link}" style="display:inline-block; padding:10px 24px; background:${ACCENT}; 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 credit note email:", err);
}
}

126
src/lib/pdf-brand.tsx Normal file
View File

@@ -0,0 +1,126 @@
/**
* Shared brand constants and Logo component for all PDF documents
* (invoices, credit notes, future quotes / reminders).
*
* Phase 7 fix: previously each PDF generator carried its own copy
* of BRAND and its own Logo. When Cedric customized the invoice
* issuer block in his deployment (real Strasse Nr., PLZ, etc.),
* the credit note PDF kept the original placeholders because it
* had its own duplicate. Hoisting both here means every PDF reads
* the same source of truth.
*
* To change the brand: edit BRAND below. To change the logo:
* edit Logo below. To change the issuer info Cedric ships: edit
* BRAND.issuer — both billing-pdf.tsx and credit-note-pdf.tsx pick
* it up automatically.
*
* The Logo component accepts a `color` prop so the credit-note
* variant can render the SAME shape tinted red (the document
* family is visually consistent; only the accent colour signals
* "this is a credit, not an invoice").
*/
import React from "react";
import { Svg, Polygon, Polyline } from "@react-pdf/renderer";
// ---------------------------------------------------------------------------
// Brand constants
// ---------------------------------------------------------------------------
export 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.
// Both billing-pdf.tsx and credit-note-pdf.tsx read from here.
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 — used by invoice PDF, ignored on credit
// notes (refunds flow back via the original payment method).
bankName: "[Bank name]",
bankIban: "[CHxx xxxx xxxx xxxx xxxx x]",
bankBic: "[BIC]",
},
};
// ---------------------------------------------------------------------------
// Accent colours for document variants — credit notes are red so
// customers can tell them apart from invoices at a glance.
// ---------------------------------------------------------------------------
export const ACCENT_CREDIT_NOTE = {
primary: "#DC2626",
primaryDark: "#991B1B",
};
// ---------------------------------------------------------------------------
// Logo — PieCed's hexagon-pattern mark. Same shape used everywhere;
// only the colour changes per document type.
// ---------------------------------------------------------------------------
interface LogoProps {
size?: number;
/** Defaults to BRAND.primary (emerald). Pass ACCENT_CREDIT_NOTE.primary
* on credit notes for the red variant. */
color?: string;
}
export const Logo = ({ size = 60, color = BRAND.primary }: LogoProps) => (
<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={color}
stroke={color}
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={color}
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={color}
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={color}
stroke={color}
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={color}
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={color}
strokeWidth={1.8}
/>
</Svg>
);

314
src/lib/stripe.ts Normal file
View File

@@ -0,0 +1,314 @@
/**
* Server-side Stripe client + helpers for Phase 4 (card payments).
*
* Architecture (see Phase 4 notes):
* 1. Customer clicks "Pay with card" on /billing/<number>.
* 2. Server creates Stripe Checkout Session (mode='payment') with
* the invoice total as a single line item. We pass `customer`
* to reuse an existing Stripe Customer if the org already has
* one, otherwise we create one and persist its id in
* org_billing_config.stripe_customer_id.
* 3. Returns session.url; the browser redirects there.
* 4. Customer pays; Stripe redirects to success_url with the
* session id appended.
* 5. /api/stripe/webhook receives `checkout.session.completed`,
* verifies signature, looks up the invoice id from metadata,
* flips the invoice to 'paid'.
*
* Env vars:
* STRIPE_SECRET_KEY (required) - sk_test_... in sandbox, sk_live_... in prod
* STRIPE_WEBHOOK_SECRET (required for webhook) - whsec_...
* APP_BASE_URL (required) - e.g. https://app.pieced.ch
*
* SDK: stripe@22.x (Node SDK v22), pinned API version 2026-03-25.dahlia.
* Pinning the API version means a `npm update` of the SDK won't
* silently change request/response shapes; we explicitly bump when
* we want a new API version.
*/
import Stripe from "stripe";
import type { Invoice } from "@/types";
// Pinned API version. `as const` narrows this to a string-literal
// type that the Stripe constructor's `apiVersion` field accepts
// exactly. When the installed SDK bumps to a new pinned version,
// TypeScript will surface the mismatch at the `new Stripe(...)` call
// below — bump this string deliberately alongside the SDK upgrade
// and review the API changelog before doing so.
const STRIPE_API_VERSION = "2026-04-22.dahlia" as const;
// Cache the client across hot reloads / serverless invocations.
// We don't instantiate at module load because some build steps run
// without runtime env vars set — only fail when actually used.
let cachedClient: Stripe | null = null;
export function getStripeClient(): Stripe {
if (cachedClient) return cachedClient;
const key = process.env.STRIPE_SECRET_KEY;
if (!key) {
throw new Error(
"STRIPE_SECRET_KEY is not set. Configure it in your environment."
);
}
cachedClient = new Stripe(key, {
apiVersion: STRIPE_API_VERSION,
// Identify ourselves in Stripe's request logs so support can
// distinguish PieCed traffic from other integrations on the
// same account.
appInfo: {
name: "PieCed Portal",
version: "1.0.0",
url: "https://app.pieced.ch",
},
});
return cachedClient;
}
/**
* Return the configured webhook secret. Separated so the webhook
* handler can fail fast with a clear error message rather than the
* generic "STRIPE_SECRET_KEY missing" path above.
*/
export function getWebhookSecret(): string {
const secret = process.env.STRIPE_WEBHOOK_SECRET;
if (!secret) {
throw new Error(
"STRIPE_WEBHOOK_SECRET is not set. Get it from the webhook endpoint in your Stripe dashboard."
);
}
return secret;
}
/**
* Convert a CHF decimal amount (e.g. 123.45) to integer rappen
* (e.g. 12345). Stripe API requires integer amounts in the
* currency's smallest unit. Centralised so we don't have rounding
* drift between callers.
*/
export function chfToRappen(amountChf: number): number {
// toFixed(2) avoids floating-point ugliness (0.1 + 0.2 = 0.30000000000000004).
return Math.round(parseFloat(amountChf.toFixed(2)) * 100);
}
/**
* Look up or create the Stripe Customer for a PieCed org.
*
* Lazy creation: orgs that only pay by invoice never get a Stripe
* Customer. The first "Pay with card" click triggers creation; the
* id is persisted in org_billing_config so subsequent invoices
* reuse it.
*
* Returns the Stripe customer id (`cus_...`).
*/
export async function ensureStripeCustomerForOrg(params: {
zitadelOrgId: string;
// Snapshot taken at click-time, NOT at invoice issuance — the
// org's current address goes on the Stripe customer object.
// Stripe's address on file is independent of any one invoice.
companyName: string;
billingEmail: string;
address: {
line1: string;
postalCode: string;
city: string;
country: string; // ISO 3166-1 alpha-2 (e.g. "CH")
};
}): Promise<string> {
// Lazy import to avoid pulling pg into edge-runtime modules that
// might import this file. Same pattern used elsewhere in lib/.
const { getOrgBillingConfig, updateOrgBillingConfig } = await import("./db");
const existing = await getOrgBillingConfig(params.zitadelOrgId);
if (existing.stripeCustomerId) {
return existing.stripeCustomerId;
}
const stripe = getStripeClient();
const customer = await stripe.customers.create({
email: params.billingEmail,
name: params.companyName,
address: {
line1: params.address.line1,
postal_code: params.address.postalCode,
city: params.address.city,
country: params.address.country,
},
metadata: {
zitadel_org_id: params.zitadelOrgId,
},
});
await updateOrgBillingConfig(params.zitadelOrgId, {
stripeCustomerId: customer.id,
});
return customer.id;
}
/**
* Create a Checkout Session for paying a single invoice by card.
*
* Design notes:
*
* - Single line item with the invoice total (gross, VAT included).
* Our own invoice PDF already breaks down lines + VAT; the Stripe
* page is the checkout, not a duplicate of the invoice.
*
* - `automatic_tax` is disabled because the invoice already has
* VAT computed by our pipeline. Letting Stripe re-calculate
* would double-charge or contradict our PDF.
*
* - `payment_method_types` is NOT set, so Stripe surfaces dynamic
* payment methods configured on the account (cards, TWINT for
* Swiss customers, Apple Pay, Google Pay, etc.) automatically.
*
* - `metadata` and `payment_intent_data.metadata` BOTH carry the
* invoice id. The session-level copy is enough for the
* `checkout.session.completed` webhook; the intent-level copy
* lets us correlate refunds and disputes which fire on the
* PaymentIntent and don't include session metadata.
*
* - `client_reference_id` is set to our invoice id as a stable
* reference. Visible in the Stripe dashboard, useful for support.
*
* - `locale` follows the invoice's locale so the customer sees
* the Stripe page in their language (frozen at invoice issue
* time; consistent with PDF + email).
*/
export async function createCheckoutSessionForInvoice(params: {
invoice: Invoice;
customerId: string;
baseUrl: string;
}): Promise<{ url: string; sessionId: string }> {
const stripe = getStripeClient();
const { invoice, customerId, baseUrl } = params;
// Stripe Checkout supports a limited set of locales; map our
// four to Stripe's codes and fall back to 'auto' if anything
// outside the set ever appears.
//
// We deliberately don't annotate this with
// `Stripe.Checkout.SessionCreateParams.Locale` — stripe-node v22
// ships with a known type-export regression
// (stripe/stripe-node#2662) where params types under namespaced
// resources aren't re-exported from the resource barrel. The
// `as const` literal narrowing gives the variable the union type
// `"de" | "fr" | "it" | "en" | "auto"`, which `sessions.create`
// accepts at the call site via its own inline parameter typing.
// When the SDK fixes the re-export, we can put the annotation
// back without touching the call site.
const stripeLocale =
invoice.locale === "de"
? ("de" as const)
: invoice.locale === "fr"
? ("fr" as const)
: invoice.locale === "it"
? ("it" as const)
: invoice.locale === "en"
? ("en" as const)
: ("auto" as const);
const successUrl = `${baseUrl}/billing/${encodeURIComponent(invoice.invoiceNumber)}?paid=1&session_id={CHECKOUT_SESSION_ID}`;
const cancelUrl = `${baseUrl}/billing/${encodeURIComponent(invoice.invoiceNumber)}?cancelled=1`;
const session = await stripe.checkout.sessions.create({
mode: "payment",
customer: customerId,
client_reference_id: invoice.id,
locale: stripeLocale,
line_items: [
{
quantity: 1,
price_data: {
currency: "chf",
unit_amount: chfToRappen(invoice.totalChf),
product_data: {
name: `Invoice ${invoice.invoiceNumber}`,
description: `PieCed IT — ${invoice.periodStart.slice(0, 10)}${invoice.periodEnd.slice(0, 10)}`,
},
},
},
],
metadata: {
invoice_id: invoice.id,
invoice_number: invoice.invoiceNumber,
zitadel_org_id: invoice.zitadelOrgId,
},
payment_intent_data: {
// Mirror invoice id at the PaymentIntent level so refunds &
// disputes (which fire on the PI, not the session) can be
// correlated to our invoice without an extra lookup.
metadata: {
invoice_id: invoice.id,
invoice_number: invoice.invoiceNumber,
zitadel_org_id: invoice.zitadelOrgId,
},
// Statement descriptor shown on the customer's card
// statement. Limited to 22 chars total; we use the prefix
// since Stripe will prepend the merchant name from the
// account anyway. Keep it short and recognisable.
description: `Invoice ${invoice.invoiceNumber}`,
},
success_url: successUrl,
cancel_url: cancelUrl,
// VAT is already in invoice.totalChf — don't let Stripe touch tax.
automatic_tax: { enabled: false },
});
if (!session.url) {
throw new Error(
`Stripe returned a session without a redirect URL (id=${session.id})`
);
}
return { url: session.url, sessionId: session.id };
}
// ---------------------------------------------------------------------------
// Phase 7 — refunds
// ---------------------------------------------------------------------------
/**
* Create a Stripe Refund against an invoice's PaymentIntent.
*
* The amount is in CHF; we convert to rappen for Stripe's smallest-
* currency-unit API. Pass 0 or undefined for `amountChf` to refund
* the full charge.
*
* Returns the Stripe refund object so the caller can record the
* refund id and final status. Stripe processes refunds asynchronously
* for some payment methods, so the initial status may be 'pending'
* — the charge.refunded webhook delivers the eventual succeeded /
* failed transition.
*
* Throws on Stripe API errors (no charge, insufficient balance,
* etc.). The caller surfaces these to the admin via the API
* response — we don't swallow them because partial-refund logic
* shouldn't be guessing about server state.
*/
export async function createInvoiceRefund(params: {
paymentIntentId: string;
amountChf?: number;
reason?: "duplicate" | "fraudulent" | "requested_by_customer";
metadata?: Record<string, string>;
}): Promise<{
id: string;
amountChf: number;
status: string;
}> {
const stripe = getStripeClient();
const refundParams: Parameters<typeof stripe.refunds.create>[0] = {
payment_intent: params.paymentIntentId,
metadata: params.metadata,
};
if (params.amountChf && params.amountChf > 0) {
refundParams.amount = chfToRappen(params.amountChf);
}
if (params.reason) {
refundParams.reason = params.reason;
}
const refund = await stripe.refunds.create(refundParams);
// The amount on the response is in rappen; convert back. If no
// amount was passed, Stripe defaults to the full remaining
// charge, which is what we read back.
return {
id: refund.id,
amountChf: refund.amount != null ? refund.amount / 100 : 0,
status: refund.status ?? "unknown",
};
}

View File

@@ -528,3 +528,113 @@ export async function registerCustomer(params: {
throw err;
}
}
// ---------------------------------------------------------------------------
// v2 User API — profile updates (Phase 6 fix5)
// ---------------------------------------------------------------------------
/**
* Update a human user's profile (first name + last name + display
* name). Returns the new `details.changeDate` from ZITADEL so the
* caller can confirm the write landed.
*
* The v2 user service endpoint is technically a PUT but accepts
* partial bodies — only the `profile` block is sent. ZITADEL
* preserves email, password, and other fields across the call
* (verified empirically in zitadel-server#7786 and documented in
* v2.63+ of zitadel-server).
*
* `displayName` IS sent explicitly, set to "givenName familyName".
* Empirically (and contra what some docs suggest), ZITADEL does
* NOT recompute displayName when only the name parts change — it
* keeps whatever displayName was previously stored, including the
* one set at user creation time. That stale displayName is what
* ZITADEL surfaces in the OIDC `name` claim, so without this
* explicit write the portal session would never see the updated
* name (even after sign-out / sign-in).
*
* Auth: the portal's service-account PAT (ZITADEL_SA_PAT). The PAT
* must have user-write permission in the user's resource org.
* Today portal-zitadel-sa-pat already has user-write for
* createHumanUser etc. — same scope covers this.
*/
export interface UpdateHumanUserProfileResult {
changeDate: string;
/** The displayName ZITADEL stored, which the OIDC `name` claim will
* carry on the user's next session. */
displayName: string;
}
export async function updateHumanUserProfile(params: {
userId: string;
givenName: string;
familyName: string;
}): Promise<UpdateHumanUserProfileResult> {
const path = `/v2/users/human/${encodeURIComponent(params.userId)}`;
// Compose the displayName ourselves so ZITADEL stores something
// sensible. Empty-string fallback only triggers if both name parts
// are blank, which the API zod schema prevents anyway.
const displayName =
`${params.givenName.trim()} ${params.familyName.trim()}`.trim();
type ZitadelUpdateResponse = {
details?: { changeDate?: string };
};
await zitadelFetch<ZitadelUpdateResponse>(path, "PUT", {
profile: {
givenName: params.givenName,
familyName: params.familyName,
displayName,
},
});
// Re-fetch the user to read back the canonical displayName ZITADEL
// committed. Should match what we sent, but reading from the source
// of truth catches any sanitization ZITADEL might apply.
const detail = await getHumanUserDetail(params.userId);
return {
changeDate: new Date().toISOString(),
displayName: detail.displayName || displayName,
};
}
/**
* Fetch a human user's current profile (given/family/display name +
* email). Used by the settings page to populate the form and by the
* update helper above to read back the computed displayName.
*/
export interface HumanUserDetail {
userId: string;
givenName: string;
familyName: string;
displayName: string;
email: string;
}
export async function getHumanUserDetail(
userId: string
): Promise<HumanUserDetail> {
type ZitadelGetUserResponse = {
user?: {
userId?: string;
human?: {
profile?: {
givenName?: string;
familyName?: string;
displayName?: string;
};
email?: { email?: string };
};
};
};
const response = await zitadelFetch<ZitadelGetUserResponse>(
`/v2/users/${encodeURIComponent(userId)}`,
"GET"
);
const human = response.user?.human;
return {
userId: response.user?.userId ?? userId,
givenName: human?.profile?.givenName ?? "",
familyName: human?.profile?.familyName ?? "",
displayName: human?.profile?.displayName ?? "",
email: human?.email?.email ?? "",
};
}

View File

@@ -15,7 +15,8 @@
"team": "Team",
"settings": "Einstellungen",
"optional": "optional",
"support": "Support"
"support": "Support",
"billing": "Abrechnung"
},
"login": {
"title": "PieCed Portal",
@@ -120,7 +121,8 @@
"saveChanges": "Änderungen speichern",
"billingVatNumber": "MWST-Nummer",
"billingVatHelp": "Ihre registrierte MWST-Nummer. Falls Ihre Firma von der MWST befreit ist, leer lassen und in den Notizen erläutern.",
"billingNotesPlaceholderPersonal": "Was wir wissen sollten — bevorzugte Zahlungsart, Rechnungsreferenz, etc."
"billingNotesPlaceholderPersonal": "Was wir wissen sollten — bevorzugte Zahlungsart, Rechnungsreferenz, etc.",
"reviewContactPersonPrefix": "z.Hd."
},
"dashboard": {
"title": "Dashboard",
@@ -287,7 +289,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 +317,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 +394,8 @@
"resumeRequestTooltip": "Reaktivierungsanfrage für einen bestehenden Tenant. Bei Genehmigung wird der Tenant wieder aktiviert; keine Provisionierung läuft.",
"openclawTool": "OpenClaw-Versionen",
"billingTool": "Abrechnung →",
"skillsQueueTool": "Aktivierungs-Warteschlange"
"skillsQueueTool": "Aktivierungs-Warteschlange",
"cronTool": "Automatisierung"
},
"channelUsers": {
"title": "Autorisierte Benutzer",
@@ -475,28 +480,36 @@
"billingTitle": "Abrechnung",
"billingDescription": "Adresse, MWST-Nummer und Rechnungs-E-Mail für alle Ihre Tenants.",
"nothingForYou": "Für Ihre Rolle gibt es hier noch nichts. Inhaber können Organisationseinstellungen verwalten.",
"billingDescriptionPersonal": "Adresse und Rechnungs-E-Mail für alle Ihre Tenants."
"billingDescriptionPersonal": "Adresse und Rechnungs-E-Mail für alle Ihre Tenants.",
"profileTitle": "Profil",
"profileDescription": "Bearbeiten Sie Ihren Vor- und Nachnamen, wie er im Portal erscheint."
},
"settingsBilling": {
"title": "Abrechnung",
"subtitle": "Wird beim ersten Onboarding einmalig erfasst und für jeden Tenant Ihrer Organisation wiederverwendet. Aktualisieren Sie hier, wenn sich Ihre Abrechnungsdaten ändern.",
"companyName": "Firmenname",
"streetAddress": "Strasse",
"postalCode": "PLZ",
"city": "Ort",
"country": "Land",
"vatNumber": "MWST-Nummer",
"vatHelp": "Ihre registrierte MWST-Nummer (z. B. CHE-123.456.789 MWST für die Schweiz).",
"billingEmail": "Rechnungs-E-Mail",
"billingEmailHelp": "An diese Adresse werden Rechnungen und Abrechnungskommunikation gesendet.",
"notes": "Notizen",
"notesPlaceholder": "Alles, was die Buchhaltung wissen muss MWST-Befreiung, besondere Rechnungsstellung usw.",
"save": "Speichern",
"title": "Rechnungsdaten",
"subtitle": "Rechnungsadresse, MWST-Nummer und Rechnungskontakt Ihres Unternehmens. Erforderlich, bevor Rechnungen für Ihre Organisation ausgestellt werden können.",
"companyNameLabel": "Firmenname",
"streetAddressLabel": "Strasse und Hausnummer",
"postalCodeLabel": "PLZ",
"cityLabel": "Ort",
"countryLabel": "Ländercode",
"countryHint": "ISO 3166-1 alpha-2 — z.B. CH, DE, AT, FR, IT, GB, US",
"vatNumberLabel": "MWST-Nummer (optional)",
"vatNumberHint": "Für Schweizer Kunden: CHE-XXX.XXX.XXX MWST. EU-Kunden mit USt-IdNr. erhalten eine Reverse-Charge-Rechnung (0% MWST).",
"billingEmailLabel": "Rechnungs-E-Mail",
"billingEmailHint": "Rechnungen und Zahlungserinnerungen werden an diese Adresse gesendet. Kann von Ihrer Konto-E-Mail abweichen.",
"notesLabel": "Bemerkungen (optional)",
"notesHint": "Referenznummern, Bestellnummern oder andere Angaben, die auf der Rechnung erscheinen sollen.",
"saveChanges": "Änderungen speichern",
"createBilling": "Rechnungsdaten speichern",
"saving": "Speichern…",
"saved": "Gespeichert.",
"saveFailed": "Konnte nicht gespeichert werden. Bitte erneut versuchen.",
"lastUpdated": "Zuletzt aktualisiert {when}",
"fullName": "Voller Name",
"notesPlaceholderPersonal": "Was wir wissen sollten — bevorzugte Zahlungsart, Rechnungsreferenz, etc."
"missingRequired": "Bitte alle Pflichtfelder ausfüllen.",
"invalidCountry": "Ländercode muss aus 2 Buchstaben bestehen (z.B. CH).",
"invalidEmail": "Bitte eine gültige E-Mail-Adresse eingeben.",
"fullNameLabel": "Vor- und Nachname",
"subtitlePersonal": "Ihre Rechnungsadresse und Rechnungskontakt. Erforderlich, bevor Rechnungen ausgestellt werden können.",
"contactNameLabel": "Ansprechperson (optional)",
"contactNameHint": "Erscheint als 'z.Hd. <Name>' auf der Rechnung unter dem Firmennamen. Hilfreich für die Zuordnung in der Buchhaltung grösserer Firmen."
},
"support": {
"title": "Support",
@@ -660,7 +673,36 @@
"lineItemsTitle": "Positionen",
"billToSnapshotTitle": "Rechnungsempfänger",
"setupFeeCol": "Einrichtungsgebühr",
"skillSetupFeeLabel": "Einrichtungsgebühr"
"skillSetupFeeLabel": "Einrichtungsgebühr",
"status_partially_refunded": "Teilrückerstattung",
"status_fully_refunded": "Vollständig rückerstattet",
"voidBtn": "Stornieren",
"voidReasonPlaceholder": "Stornierungsgrund (auf Gutschrift gedruckt)",
"voidReasonRequired": "Bitte einen Grund für die Stornierung angeben.",
"confirmVoid": "Stornierung bestätigen",
"voidedOnLabel": "Storniert",
"refundBtn": "Rückerstatten",
"refundReasonPlaceholder": "Grund der Rückerstattung (auf Gutschrift gedruckt)",
"refundReasonRequired": "Bitte einen Grund für die Rückerstattung angeben.",
"refundAmountInvalid": "Rückerstattungsbetrag muss eine positive Zahl sein.",
"refundAmountExceeds": "Rückerstattungsbetrag überschreitet den verbleibenden Betrag von CHF {max}.",
"refundRemainingHint": "Verbleibend erstattbar: CHF {max}",
"confirmRefund": "Rückerstattung bestätigen",
"refundedTotalLabel": "Rückerstattet",
"refundedRemainingLabel": "Verbleibend erstattbar",
"creditNotesPanelTitle": "Gutschriften",
"creditNoteNumberHeader": "Nummer",
"creditNoteKindHeader": "Typ",
"creditNoteAmountHeader": "Betrag",
"creditNoteReasonHeader": "Grund",
"creditNoteIssuedHeader": "Ausgestellt",
"creditNotePdfHeader": "PDF",
"creditNoteKind_void": "Storno",
"creditNoteKind_refund": "Rückerstattung",
"creditNoteNoPdf": "—",
"refundAmountLabel": "Betrag",
"refundReasonLabel": "Grund",
"refundAmountInclVatHint": "inkl. MWST"
},
"skillCostDialog": {
"title": "Aktivierungskosten bestätigen",
@@ -693,5 +735,112 @@
"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",
"partially_refunded": "Teilrückerstattung",
"fully_refunded": "Vollständig rückerstattet"
},
"payWithCard": "Mit Karte bezahlen",
"redirectingToStripe": "Weiterleitung…",
"paymentReceived": "Zahlung erhalten — vielen Dank!",
"paymentCancelled": "Zahlung abgebrochen.",
"configureBillingCta": "Rechnungsdaten einrichten",
"noBillingConfigNonOwner": "Nur der Organisations-Owner kann die Rechnungsdaten einrichten. Bitte wenden Sie sich an diese Person, um diesen Schritt abzuschliessen.",
"creditNotesHeading": "Gutschriften",
"creditNoteNumberCol": "Gutschrift",
"creditNoteInvoiceCol": "Rechnung",
"creditNoteIssuedCol": "Ausgestellt",
"creditNoteAmountCol": "Betrag",
"creditNoteKindCol": "Typ",
"creditNotePdfCol": "PDF",
"creditNoteKind_void": "Storno",
"creditNoteKind_refund": "Rückerstattung",
"creditNoteNoPdf": "PDF nicht verfügbar"
},
"adminCron": {
"title": "Abrechnungsautomatisierung",
"subtitle": "Monatliche Rechnungsstellung und tägliche Mahnungsläufe. Beides läuft automatisch; mit den Schaltflächen unten können Sie einen Lauf manuell auslösen.",
"monthlyIssue": "Monatliche Rechnungsstellung",
"reminders": "Mahnungen",
"scheduleIssueLabel": "Zeitplan",
"scheduleIssueValue": "00:30 Europe/Zurich am 1.",
"scheduleReminderLabel": "Zeitplan",
"scheduleReminderValue": "09:00 Europe/Zurich täglich",
"lastSuccess": "Letzter Erfolg",
"never": "nie",
"runIssueNow": "Letzten Monat jetzt abrechnen",
"runRemindersNow": "Mahnungslauf jetzt starten",
"running": "Läuft…",
"flashIssueOk": "Rechnungsstellung abgeschlossen: {success} Rechnungen erstellt, {skipped} übersprungen, {failure} fehlgeschlagen.",
"flashRemindersOk": "Mahnungen versendet: {success} erfolgreich, {skipped} übersprungen, {failure} fehlgeschlagen.",
"recentRuns": "Letzte Läufe (max. 30)",
"noRunsYet": "Noch keine Automatisierungsläufe erfasst.",
"startedCol": "Gestartet",
"kindCol": "Art",
"triggeredByCol": "Ausgelöst von",
"okCol": "OK",
"skipCol": "Übersprungen",
"failCol": "Fehler",
"triggeredByCron": "Cron",
"kind": {
"monthly_issue": "Rechnungsstellung",
"reminders": "Mahnungen"
},
"failureBannerTitle": "Fehler in jüngsten Automatisierungsläufen",
"failureBannerBody": "{count} Lauf/Läufe im aktuellen Fenster haben mindestens einen Fehler gemeldet. Bitte die Tabelle unten prüfen — betroffene Zeilen sind rot hervorgehoben."
},
"settingsProfile": {
"title": "Profil",
"subtitle": "Ihr Anzeigename, der im Portal, in Tenant-Anfragen und in Support-Tickets erscheint.",
"subtitlePersonal": "Ihr Anzeigename, der im Portal erscheint. Um Ihren Namen auf Rechnungen zu ändern, bearbeiten Sie ihn unter Rechnungsdaten.",
"firstNameLabel": "Vorname",
"lastNameLabel": "Nachname",
"emailLabel": "E-Mail",
"emailReadOnlyHint": "Die E-Mail-Adresse kann hier nicht geändert werden. Verwenden Sie die Selbstbedienungseinstellungen Ihres Identitätsanbieters.",
"personalAccountHint": "Dies ist ein persönliches Konto. Eine Änderung Ihres Namens hier ändert NICHT, wie Ihr Name auf Rechnungen erscheint — bearbeiten Sie diesen separat unter Rechnungsdaten.",
"companyAccountHint": "Sie sind als Mitglied von {orgName} angemeldet.",
"saveChanges": "Änderungen speichern",
"saving": "Speichern…",
"saved": "Gespeichert.",
"missingRequired": "Vor- und Nachname sind erforderlich."
}
}

View File

@@ -15,7 +15,8 @@
"team": "Team",
"settings": "Settings",
"optional": "optional",
"support": "Support"
"support": "Support",
"billing": "Billing"
},
"login": {
"title": "PieCed Portal",
@@ -120,7 +121,8 @@
"saveChanges": "Save changes",
"billingVatNumber": "VAT number",
"billingVatHelp": "Your registered VAT identifier. If your company is VAT-exempt, leave blank and explain in the notes field.",
"billingNotesPlaceholderPersonal": "Anything we should know — preferred payment method, billing reference, etc."
"billingNotesPlaceholderPersonal": "Anything we should know — preferred payment method, billing reference, etc.",
"reviewContactPersonPrefix": "Attn:"
},
"dashboard": {
"title": "Dashboard",
@@ -287,7 +289,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 +317,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 +394,8 @@
"resumeRequestTooltip": "Reactivation request for an existing tenant. Approving will un-suspend the tenant; no provisioning runs.",
"openclawTool": "OpenClaw versions",
"billingTool": "Billing →",
"skillsQueueTool": "Activation Queue"
"skillsQueueTool": "Activation Queue",
"cronTool": "Automation"
},
"channelUsers": {
"title": "Authorized Users",
@@ -475,28 +480,36 @@
"billingTitle": "Billing",
"billingDescription": "Address, VAT number, and invoice email used for all your tenants.",
"nothingForYou": "There's nothing here for your role yet. Owners can manage org settings.",
"billingDescriptionPersonal": "Address and invoice email used for all your tenants."
"billingDescriptionPersonal": "Address and invoice email used for all your tenants.",
"profileTitle": "Profile",
"profileDescription": "Edit your first and last name as shown across the portal."
},
"settingsBilling": {
"title": "Billing",
"subtitle": "Captured once at first onboarding and reused for every tenant in your organization. Update here whenever your billing details change.",
"companyName": "Company name",
"streetAddress": "Street address",
"postalCode": "Postal code",
"city": "City",
"country": "Country",
"vatNumber": "VAT number",
"vatHelp": "Your registered VAT identifier (e.g. CHE-123.456.789 MWST for Switzerland).",
"billingEmail": "Billing email",
"billingEmailHelp": "Where invoices and billing communication will be sent.",
"notes": "Notes",
"notesPlaceholder": "Anything else accounting needs to know — VAT exemption, special invoicing arrangements, etc.",
"save": "Save",
"title": "Billing details",
"subtitle": "Your company's billing address, VAT number, and invoice contact. Required before invoices can be issued for your organization.",
"companyNameLabel": "Company name",
"streetAddressLabel": "Street address",
"postalCodeLabel": "Postal code",
"cityLabel": "City",
"countryLabel": "Country code",
"countryHint": "ISO 3166-1 alpha-2 — e.g. CH, DE, AT, FR, IT, GB, US",
"vatNumberLabel": "VAT number (optional)",
"vatNumberHint": "For Swiss customers: CHE-XXX.XXX.XXX MWST. EU customers with a VAT number get a 0% reverse-charge invoice.",
"billingEmailLabel": "Billing email",
"billingEmailHint": "Invoices and payment reminders are sent here. Can differ from your account email.",
"notesLabel": "Notes (optional)",
"notesHint": "Reference numbers, purchase order tags, or anything else you'd like printed on invoices.",
"saveChanges": "Save changes",
"createBilling": "Save billing details",
"saving": "Saving…",
"saved": "Saved.",
"saveFailed": "Could not save. Please try again.",
"lastUpdated": "Last updated {when}",
"fullName": "Full name",
"notesPlaceholderPersonal": "Anything we should know — preferred payment method, billing reference, etc."
"missingRequired": "Please fill in all required fields.",
"invalidCountry": "Country code must be 2 letters (e.g. CH).",
"invalidEmail": "Please enter a valid email address.",
"fullNameLabel": "Full name",
"subtitlePersonal": "Your billing address and invoice contact. Required before invoices can be issued.",
"contactNameLabel": "Contact person (optional)",
"contactNameHint": "Prints as 'Attn: <name>' on the invoice below the company name. Useful for AP routing in larger organizations."
},
"support": {
"title": "Support",
@@ -660,7 +673,36 @@
"lineItemsTitle": "Line items",
"billToSnapshotTitle": "Billed to",
"setupFeeCol": "Setup fee",
"skillSetupFeeLabel": "Setup fee"
"skillSetupFeeLabel": "Setup fee",
"status_partially_refunded": "Partially refunded",
"status_fully_refunded": "Fully refunded",
"voidBtn": "Void",
"voidReasonPlaceholder": "Reason for voiding (printed on credit note)",
"voidReasonRequired": "Please provide a reason for voiding.",
"confirmVoid": "Confirm void",
"voidedOnLabel": "Voided",
"refundBtn": "Refund",
"refundReasonPlaceholder": "Reason for refund (printed on credit note)",
"refundReasonRequired": "Please provide a reason for the refund.",
"refundAmountInvalid": "Refund amount must be a positive number.",
"refundAmountExceeds": "Refund amount exceeds remaining refundable CHF {max}.",
"refundRemainingHint": "Remaining refundable: CHF {max}",
"confirmRefund": "Confirm refund",
"refundedTotalLabel": "Refunded total",
"refundedRemainingLabel": "Remaining refundable",
"creditNotesPanelTitle": "Credit notes",
"creditNoteNumberHeader": "Number",
"creditNoteKindHeader": "Type",
"creditNoteAmountHeader": "Amount",
"creditNoteReasonHeader": "Reason",
"creditNoteIssuedHeader": "Issued",
"creditNotePdfHeader": "PDF",
"creditNoteKind_void": "Void",
"creditNoteKind_refund": "Refund",
"creditNoteNoPdf": "—",
"refundAmountLabel": "Amount",
"refundReasonLabel": "Reason",
"refundAmountInclVatHint": "incl. VAT"
},
"skillCostDialog": {
"title": "Confirm activation cost",
@@ -693,5 +735,112 @@
"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",
"partially_refunded": "Partially refunded",
"fully_refunded": "Fully refunded"
},
"payWithCard": "Pay with card",
"redirectingToStripe": "Redirecting…",
"paymentReceived": "Payment received — thank you!",
"paymentCancelled": "Payment cancelled.",
"configureBillingCta": "Configure billing details",
"noBillingConfigNonOwner": "Only the organization owner can configure billing details. Please contact them to complete this step.",
"creditNotesHeading": "Credit notes",
"creditNoteNumberCol": "Credit note",
"creditNoteInvoiceCol": "Invoice",
"creditNoteIssuedCol": "Issued",
"creditNoteAmountCol": "Amount",
"creditNoteKindCol": "Type",
"creditNotePdfCol": "PDF",
"creditNoteKind_void": "Void",
"creditNoteKind_refund": "Refund",
"creditNoteNoPdf": "PDF unavailable"
},
"adminCron": {
"title": "Billing automation",
"subtitle": "Monthly issuance and daily reminder sweeps. Both run automatically; use the buttons below to trigger a sweep on demand.",
"monthlyIssue": "Monthly issuance",
"reminders": "Reminders",
"scheduleIssueLabel": "Schedule",
"scheduleIssueValue": "00:30 Europe/Zurich on the 1st",
"scheduleReminderLabel": "Schedule",
"scheduleReminderValue": "09:00 Europe/Zurich daily",
"lastSuccess": "Last success",
"never": "never",
"runIssueNow": "Run last month's issuance now",
"runRemindersNow": "Run reminder sweep now",
"running": "Running…",
"flashIssueOk": "Issuance complete: {success} invoices issued, {skipped} skipped, {failure} failed.",
"flashRemindersOk": "Reminders sent: {success} succeeded, {skipped} skipped, {failure} failed.",
"recentRuns": "Recent runs (last 30)",
"noRunsYet": "No automation runs recorded yet.",
"startedCol": "Started",
"kindCol": "Kind",
"triggeredByCol": "Triggered by",
"okCol": "OK",
"skipCol": "Skipped",
"failCol": "Failed",
"triggeredByCron": "cron",
"kind": {
"monthly_issue": "Issuance",
"reminders": "Reminders"
},
"failureBannerTitle": "Recent automation failures detected",
"failureBannerBody": "{count} run(s) in the recent window reported at least one failure. Review the table below — the affected rows are highlighted in red."
},
"settingsProfile": {
"title": "Profile",
"subtitle": "Your display name as shown across the portal, in tenant requests, and in support tickets.",
"subtitlePersonal": "Your display name as shown across the portal. To change how your name appears on invoices, edit it in Billing details.",
"firstNameLabel": "First name",
"lastNameLabel": "Last name",
"emailLabel": "Email",
"emailReadOnlyHint": "Email can't be changed here. Use your identity provider's self-service settings to change your email.",
"personalAccountHint": "This is a personal account. Changing your name here does NOT update how your name appears on invoices — edit that separately in Billing details.",
"companyAccountHint": "You're signed in as a member of {orgName}.",
"saveChanges": "Save changes",
"saving": "Saving…",
"saved": "Saved.",
"missingRequired": "First and last name are required."
}
}

View File

@@ -15,7 +15,8 @@
"team": "Équipe",
"settings": "Paramètres",
"optional": "facultatif",
"support": "Support"
"support": "Support",
"billing": "Facturation"
},
"login": {
"title": "Portail PieCed",
@@ -120,7 +121,8 @@
"saveChanges": "Enregistrer les modifications",
"billingVatNumber": "Numéro de TVA",
"billingVatHelp": "Votre identifiant TVA enregistré. Si votre entreprise est exonérée de TVA, laissez vide et précisez dans les notes.",
"billingNotesPlaceholderPersonal": "Tout ce que nous devons savoir — moyen de paiement préféré, référence de facturation, etc."
"billingNotesPlaceholderPersonal": "Tout ce que nous devons savoir — moyen de paiement préféré, référence de facturation, etc.",
"reviewContactPersonPrefix": "À l'attention de"
},
"dashboard": {
"title": "Tableau de bord",
@@ -287,7 +289,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 +317,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 +394,8 @@
"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 d'activation"
"skillsQueueTool": "File d'activation",
"cronTool": "Automatisation"
},
"channelUsers": {
"title": "Utilisateurs autorisés",
@@ -475,28 +480,36 @@
"billingTitle": "Facturation",
"billingDescription": "Adresse, numéro de TVA et e-mail de facturation utilisés pour tous vos locataires.",
"nothingForYou": "Il n'y a rien ici pour votre rôle pour le moment. Les propriétaires peuvent gérer les paramètres de l'organisation.",
"billingDescriptionPersonal": "Adresse et e-mail de facturation utilisés pour tous vos locataires."
"billingDescriptionPersonal": "Adresse et e-mail de facturation utilisés pour tous vos locataires.",
"profileTitle": "Profil",
"profileDescription": "Modifiez votre prénom et nom tels qu'ils apparaissent dans le portail."
},
"settingsBilling": {
"title": "Facturation",
"subtitle": "Saisie une fois lors de l'inscription et réutilisée pour chaque locataire de votre organisation. Mettez à jour ici dès que vos coordonnées de facturation changent.",
"companyName": "Nom de l'entreprise",
"streetAddress": "Adresse",
"postalCode": "Code postal",
"city": "Ville",
"country": "Pays",
"vatNumber": "Numéro de TVA",
"vatHelp": "Votre identifiant TVA enregistré (par ex. CHE-123.456.789 TVA pour la Suisse).",
"billingEmail": "E-mail de facturation",
"billingEmailHelp": "Adresse à laquelle les factures et la communication de facturation seront envoyées.",
"notes": "Notes",
"notesPlaceholder": "Tout ce que la comptabilité doit savoir exonération de TVA, modalités de facturation particulières, etc.",
"save": "Enregistrer",
"title": "Informations de facturation",
"subtitle": "Adresse de facturation, numéro de TVA et contact pour les factures. Requis avant l'émission de toute facture pour votre organisation.",
"companyNameLabel": "Nom de l'entreprise",
"streetAddressLabel": "Adresse",
"postalCodeLabel": "Code postal",
"cityLabel": "Ville",
"countryLabel": "Code pays",
"countryHint": "ISO 3166-1 alpha-2 — p. ex. CH, DE, AT, FR, IT, GB, US",
"vatNumberLabel": "Numéro de TVA (facultatif)",
"vatNumberHint": "Pour les clients suisses : CHE-XXX.XXX.XXX TVA. Les clients UE avec un n° de TVA reçoivent une facture à 0% (autoliquidation).",
"billingEmailLabel": "E-mail de facturation",
"billingEmailHint": "Les factures et rappels de paiement sont envoyés à cette adresse. Peut différer de l'e-mail du compte.",
"notesLabel": "Notes (facultatif)",
"notesHint": "Numéros de référence, bons de commande, ou toute autre information à imprimer sur les factures.",
"saveChanges": "Enregistrer les modifications",
"createBilling": "Enregistrer les informations",
"saving": "Enregistrement…",
"saved": "Enregistré.",
"saveFailed": "Impossible d'enregistrer. Veuillez réessayer.",
"lastUpdated": "Dernière mise à jour {when}",
"fullName": "Nom complet",
"notesPlaceholderPersonal": "Tout ce que nous devons savoir — moyen de paiement préféré, référence de facturation, etc."
"missingRequired": "Veuillez remplir tous les champs obligatoires.",
"invalidCountry": "Le code pays doit comporter 2 lettres (p. ex. CH).",
"invalidEmail": "Veuillez saisir une adresse e-mail valide.",
"fullNameLabel": "Nom et prénom",
"subtitlePersonal": "Votre adresse de facturation et votre contact. Requis avant l'émission de toute facture.",
"contactNameLabel": "Personne à contacter (facultatif)",
"contactNameHint": "S'imprime « À l'attention de <nom> » sur la facture, sous le nom de l'entreprise. Utile pour le routage en comptabilité dans les grandes organisations."
},
"support": {
"title": "Support",
@@ -660,7 +673,36 @@
"lineItemsTitle": "Lignes",
"billToSnapshotTitle": "Destinataire",
"setupFeeCol": "Frais de configuration",
"skillSetupFeeLabel": "Frais de configuration"
"skillSetupFeeLabel": "Frais de configuration",
"status_partially_refunded": "Partiellement remboursée",
"status_fully_refunded": "Entièrement remboursée",
"voidBtn": "Annuler",
"voidReasonPlaceholder": "Motif de l'annulation (imprimé sur la note de crédit)",
"voidReasonRequired": "Veuillez indiquer un motif d'annulation.",
"confirmVoid": "Confirmer l'annulation",
"voidedOnLabel": "Annulée",
"refundBtn": "Rembourser",
"refundReasonPlaceholder": "Motif du remboursement (imprimé sur la note de crédit)",
"refundReasonRequired": "Veuillez indiquer un motif de remboursement.",
"refundAmountInvalid": "Le montant du remboursement doit être un nombre positif.",
"refundAmountExceeds": "Le montant dépasse le restant remboursable de CHF {max}.",
"refundRemainingHint": "Restant remboursable : CHF {max}",
"confirmRefund": "Confirmer le remboursement",
"refundedTotalLabel": "Remboursé",
"refundedRemainingLabel": "Restant remboursable",
"creditNotesPanelTitle": "Notes de crédit",
"creditNoteNumberHeader": "Numéro",
"creditNoteKindHeader": "Type",
"creditNoteAmountHeader": "Montant",
"creditNoteReasonHeader": "Motif",
"creditNoteIssuedHeader": "Émise",
"creditNotePdfHeader": "PDF",
"creditNoteKind_void": "Annulation",
"creditNoteKind_refund": "Remboursement",
"creditNoteNoPdf": "—",
"refundAmountLabel": "Montant",
"refundReasonLabel": "Motif",
"refundAmountInclVatHint": "TVA incluse"
},
"skillCostDialog": {
"title": "Confirmer le coût d'activation",
@@ -693,5 +735,112 @@
"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 PDF",
"status": {
"draft": "Brouillon",
"open": "Ouverte",
"paid": "Payée",
"overdue": "En retard",
"void": "Annulée",
"uncollectible": "Irrécouvrable",
"partially_refunded": "Partiellement remboursée",
"fully_refunded": "Entièrement remboursée"
},
"payWithCard": "Payer par carte",
"redirectingToStripe": "Redirection…",
"paymentReceived": "Paiement reçu — merci !",
"paymentCancelled": "Paiement annulé.",
"configureBillingCta": "Configurer les informations de facturation",
"noBillingConfigNonOwner": "Seul le propriétaire de l'organisation peut configurer les informations de facturation. Veuillez le contacter pour terminer cette étape.",
"creditNotesHeading": "Notes de crédit",
"creditNoteNumberCol": "Note de crédit",
"creditNoteInvoiceCol": "Facture",
"creditNoteIssuedCol": "Émise",
"creditNoteAmountCol": "Montant",
"creditNoteKindCol": "Type",
"creditNotePdfCol": "PDF",
"creditNoteKind_void": "Annulation",
"creditNoteKind_refund": "Remboursement",
"creditNoteNoPdf": "PDF indisponible"
},
"adminCron": {
"title": "Automatisation de la facturation",
"subtitle": "Émission mensuelle et balayage quotidien des rappels. Les deux s'exécutent automatiquement ; utilisez les boutons ci-dessous pour déclencher un lancement à la demande.",
"monthlyIssue": "Émission mensuelle",
"reminders": "Rappels",
"scheduleIssueLabel": "Planning",
"scheduleIssueValue": "00:30 Europe/Zurich le 1er",
"scheduleReminderLabel": "Planning",
"scheduleReminderValue": "09:00 Europe/Zurich quotidien",
"lastSuccess": "Dernière réussite",
"never": "jamais",
"runIssueNow": "Facturer le mois dernier maintenant",
"runRemindersNow": "Lancer les rappels maintenant",
"running": "En cours…",
"flashIssueOk": "Émission terminée : {success} factures émises, {skipped} ignorées, {failure} échouées.",
"flashRemindersOk": "Rappels envoyés : {success} réussis, {skipped} ignorés, {failure} échoués.",
"recentRuns": "Lancements récents (30 derniers)",
"noRunsYet": "Aucun lancement automatique enregistré pour le moment.",
"startedCol": "Démarré",
"kindCol": "Type",
"triggeredByCol": "Déclenché par",
"okCol": "OK",
"skipCol": "Ignorés",
"failCol": "Échoués",
"triggeredByCron": "cron",
"kind": {
"monthly_issue": "Émission",
"reminders": "Rappels"
},
"failureBannerTitle": "Échecs récents détectés",
"failureBannerBody": "{count} lancement(s) récent(s) ont signalé au moins un échec. Consultez le tableau ci-dessous — les lignes concernées sont en rouge."
},
"settingsProfile": {
"title": "Profil",
"subtitle": "Votre nom d'affichage tel qu'il apparaît dans le portail, les demandes de tenant et les tickets d'assistance.",
"subtitlePersonal": "Votre nom d'affichage tel qu'il apparaît dans le portail. Pour modifier votre nom sur les factures, modifiez-le dans Informations de facturation.",
"firstNameLabel": "Prénom",
"lastNameLabel": "Nom",
"emailLabel": "E-mail",
"emailReadOnlyHint": "L'e-mail ne peut pas être modifié ici. Utilisez les paramètres en libre-service de votre fournisseur d'identité.",
"personalAccountHint": "Ceci est un compte personnel. Modifier votre nom ici ne change PAS la façon dont votre nom apparaît sur les factures — modifiez-le séparément dans Informations de facturation.",
"companyAccountHint": "Vous êtes connecté en tant que membre de {orgName}.",
"saveChanges": "Enregistrer les modifications",
"saving": "Enregistrement…",
"saved": "Enregistré.",
"missingRequired": "Le prénom et le nom sont obligatoires."
}
}

View File

@@ -15,7 +15,8 @@
"team": "Team",
"settings": "Impostazioni",
"optional": "facoltativo",
"support": "Supporto"
"support": "Supporto",
"billing": "Fatturazione"
},
"login": {
"title": "Portale PieCed",
@@ -120,7 +121,8 @@
"saveChanges": "Salva modifiche",
"billingVatNumber": "Partita IVA",
"billingVatHelp": "Il tuo identificativo IVA registrato. Se la tua azienda è esente IVA, lascia vuoto e spiega nelle note.",
"billingNotesPlaceholderPersonal": "Qualsiasi cosa dovremmo sapere — metodo di pagamento preferito, riferimento per fatturazione, ecc."
"billingNotesPlaceholderPersonal": "Qualsiasi cosa dovremmo sapere — metodo di pagamento preferito, riferimento per fatturazione, ecc.",
"reviewContactPersonPrefix": "c.a."
},
"dashboard": {
"title": "Dashboard",
@@ -287,7 +289,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 +317,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 +394,8 @@
"resumeRequestTooltip": "Richiesta di riattivazione di un tenant esistente. L'approvazione lo riattiverà; non viene eseguito alcun provisioning.",
"openclawTool": "Versioni OpenClaw",
"billingTool": "Fatturazione →",
"skillsQueueTool": "Coda di attivazione"
"skillsQueueTool": "Coda di attivazione",
"cronTool": "Automazione"
},
"channelUsers": {
"title": "Utenti autorizzati",
@@ -475,28 +480,36 @@
"billingTitle": "Fatturazione",
"billingDescription": "Indirizzo, numero di IVA ed e-mail di fatturazione usati per tutti i tuoi tenant.",
"nothingForYou": "Al momento non c'è nulla qui per il tuo ruolo. I proprietari possono gestire le impostazioni dell'organizzazione.",
"billingDescriptionPersonal": "Indirizzo ed e-mail di fatturazione usati per tutti i tuoi tenant."
"billingDescriptionPersonal": "Indirizzo ed e-mail di fatturazione usati per tutti i tuoi tenant.",
"profileTitle": "Profilo",
"profileDescription": "Modifica il tuo nome e cognome come appaiono nel portale."
},
"settingsBilling": {
"title": "Fatturazione",
"subtitle": "Acquisita una sola volta al primo onboarding e riutilizzata per ogni tenant della tua organizzazione. Aggiorna qui ogni volta che i dati di fatturazione cambiano.",
"companyName": "Ragione sociale",
"streetAddress": "Indirizzo",
"postalCode": "CAP",
"city": "Città",
"country": "Paese",
"vatNumber": "Partita IVA",
"vatHelp": "Il tuo identificativo IVA registrato (es. CHE-123.456.789 IVA per la Svizzera).",
"billingEmail": "E-mail di fatturazione",
"billingEmailHelp": "Indirizzo a cui verranno inviate le fatture e le comunicazioni di fatturazione.",
"notes": "Note",
"notesPlaceholder": "Qualsiasi cosa la contabilità debba sapere — esenzione IVA, modalità di fatturazione particolari, ecc.",
"save": "Salva",
"title": "Dati di fatturazione",
"subtitle": "Indirizzo di fatturazione, partita IVA e contatto fatture della tua azienda. Necessari prima che possano essere emesse fatture per la tua organizzazione.",
"companyNameLabel": "Nome azienda",
"streetAddressLabel": "Indirizzo",
"postalCodeLabel": "CAP",
"cityLabel": "Città",
"countryLabel": "Codice paese",
"countryHint": "ISO 3166-1 alpha-2 — es. CH, DE, AT, FR, IT, GB, US",
"vatNumberLabel": "Partita IVA (facoltativa)",
"vatNumberHint": "Per clienti svizzeri: CHE-XXX.XXX.XXX IVA. Clienti UE con partita IVA ricevono fattura in reverse charge (0% IVA).",
"billingEmailLabel": "E-mail di fatturazione",
"billingEmailHint": "Le fatture e i solleciti vengono inviati a questo indirizzo. Può differire dall'e-mail dell'account.",
"notesLabel": "Note (facoltative)",
"notesHint": "Numeri di riferimento, ordini d'acquisto o altre informazioni da riportare in fattura.",
"saveChanges": "Salva modifiche",
"createBilling": "Salva dati di fatturazione",
"saving": "Salvataggio…",
"saved": "Salvato.",
"saveFailed": "Impossibile salvare. Riprova.",
"lastUpdated": "Ultimo aggiornamento {when}",
"fullName": "Nome completo",
"notesPlaceholderPersonal": "Qualsiasi cosa dovremmo sapere — metodo di pagamento preferito, riferimento per fatturazione, ecc."
"missingRequired": "Compila tutti i campi obbligatori.",
"invalidCountry": "Il codice paese deve essere di 2 lettere (es. CH).",
"invalidEmail": "Inserisci un indirizzo e-mail valido.",
"fullNameLabel": "Nome e cognome",
"subtitlePersonal": "Il tuo indirizzo di fatturazione e contatto. Necessari prima che possano essere emesse fatture.",
"contactNameLabel": "Persona di contatto (facoltativa)",
"contactNameHint": "Stampato come 'c.a. <nome>' sulla fattura, sotto il nome dell'azienda. Utile per l'instradamento contabile in grandi organizzazioni."
},
"support": {
"title": "Supporto",
@@ -660,7 +673,36 @@
"lineItemsTitle": "Righe",
"billToSnapshotTitle": "Destinatario",
"setupFeeCol": "Spese di attivazione",
"skillSetupFeeLabel": "Spese di attivazione"
"skillSetupFeeLabel": "Spese di attivazione",
"status_partially_refunded": "Rimborsata parzialmente",
"status_fully_refunded": "Rimborsata integralmente",
"voidBtn": "Annulla",
"voidReasonPlaceholder": "Motivo dell'annullamento (stampato sulla nota di credito)",
"voidReasonRequired": "Indicare un motivo per l'annullamento.",
"confirmVoid": "Conferma annullamento",
"voidedOnLabel": "Annullata",
"refundBtn": "Rimborsa",
"refundReasonPlaceholder": "Motivo del rimborso (stampato sulla nota di credito)",
"refundReasonRequired": "Indicare un motivo per il rimborso.",
"refundAmountInvalid": "L'importo del rimborso deve essere un numero positivo.",
"refundAmountExceeds": "L'importo supera il residuo rimborsabile di CHF {max}.",
"refundRemainingHint": "Residuo rimborsabile: CHF {max}",
"confirmRefund": "Conferma rimborso",
"refundedTotalLabel": "Rimborsato",
"refundedRemainingLabel": "Residuo rimborsabile",
"creditNotesPanelTitle": "Note di credito",
"creditNoteNumberHeader": "Numero",
"creditNoteKindHeader": "Tipo",
"creditNoteAmountHeader": "Importo",
"creditNoteReasonHeader": "Motivo",
"creditNoteIssuedHeader": "Emessa",
"creditNotePdfHeader": "PDF",
"creditNoteKind_void": "Annullamento",
"creditNoteKind_refund": "Rimborso",
"creditNoteNoPdf": "—",
"refundAmountLabel": "Importo",
"refundReasonLabel": "Motivo",
"refundAmountInclVatHint": "IVA inclusa"
},
"skillCostDialog": {
"title": "Conferma costi di attivazione",
@@ -693,5 +735,112 @@
"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",
"partially_refunded": "Rimborsata parzialmente",
"fully_refunded": "Rimborsata integralmente"
},
"payWithCard": "Paga con carta",
"redirectingToStripe": "Reindirizzamento…",
"paymentReceived": "Pagamento ricevuto — grazie!",
"paymentCancelled": "Pagamento annullato.",
"configureBillingCta": "Configura dati di fatturazione",
"noBillingConfigNonOwner": "Solo il proprietario dell'organizzazione può configurare i dati di fatturazione. Contattalo per completare questo passaggio.",
"creditNotesHeading": "Note di credito",
"creditNoteNumberCol": "Nota di credito",
"creditNoteInvoiceCol": "Fattura",
"creditNoteIssuedCol": "Emessa",
"creditNoteAmountCol": "Importo",
"creditNoteKindCol": "Tipo",
"creditNotePdfCol": "PDF",
"creditNoteKind_void": "Annullamento",
"creditNoteKind_refund": "Rimborso",
"creditNoteNoPdf": "PDF non disponibile"
},
"adminCron": {
"title": "Automazione fatturazione",
"subtitle": "Emissione mensile e invio quotidiano dei solleciti. Entrambi vengono eseguiti automaticamente; usa i pulsanti sotto per avviare un'esecuzione su richiesta.",
"monthlyIssue": "Emissione mensile",
"reminders": "Solleciti",
"scheduleIssueLabel": "Pianificazione",
"scheduleIssueValue": "00:30 Europe/Zurich il 1°",
"scheduleReminderLabel": "Pianificazione",
"scheduleReminderValue": "09:00 Europe/Zurich quotidianamente",
"lastSuccess": "Ultimo successo",
"never": "mai",
"runIssueNow": "Fattura il mese scorso ora",
"runRemindersNow": "Avvia solleciti ora",
"running": "In corso…",
"flashIssueOk": "Emissione completata: {success} fatture emesse, {skipped} ignorate, {failure} fallite.",
"flashRemindersOk": "Solleciti inviati: {success} riusciti, {skipped} ignorati, {failure} falliti.",
"recentRuns": "Esecuzioni recenti (ultime 30)",
"noRunsYet": "Nessuna esecuzione automatica registrata.",
"startedCol": "Avviata",
"kindCol": "Tipo",
"triggeredByCol": "Avviata da",
"okCol": "OK",
"skipCol": "Ignorati",
"failCol": "Falliti",
"triggeredByCron": "cron",
"kind": {
"monthly_issue": "Emissione",
"reminders": "Solleciti"
},
"failureBannerTitle": "Fallimenti recenti rilevati",
"failureBannerBody": "{count} esecuzione/i recente/i hanno segnalato almeno un fallimento. Controlla la tabella sotto — le righe interessate sono in rosso."
},
"settingsProfile": {
"title": "Profilo",
"subtitle": "Il tuo nome visualizzato come appare nel portale, nelle richieste tenant e nei ticket di supporto.",
"subtitlePersonal": "Il tuo nome visualizzato come appare nel portale. Per modificare il tuo nome in fattura, modificalo in Dati di fatturazione.",
"firstNameLabel": "Nome",
"lastNameLabel": "Cognome",
"emailLabel": "E-mail",
"emailReadOnlyHint": "L'e-mail non può essere modificata qui. Usa le impostazioni self-service del tuo provider di identità.",
"personalAccountHint": "Questo è un account personale. Modificare il tuo nome qui NON cambia come appare in fattura — modificalo separatamente in Dati di fatturazione.",
"companyAccountHint": "Sei connesso come membro di {orgName}.",
"saveChanges": "Salva modifiche",
"saving": "Salvataggio…",
"saved": "Salvato.",
"missingRequired": "Nome e cognome sono obbligatori."
}
}

View File

@@ -234,6 +234,12 @@ export interface BillingAddress {
export interface OrgBilling {
zitadelOrgId: string;
companyName: string;
// Optional contact-person line ("z.Hd. / Attn:") shown on the
// invoice PDF below the company name. Useful when invoicing
// larger companies where the mailroom needs a name to route
// the document. Personal accounts don't expose this in the UI —
// their "Full name" already lives in companyName.
contactName?: string | null;
streetAddress: string;
postalCode: string;
city: string;
@@ -538,10 +544,71 @@ export type InvoiceStatus =
| "paid"
| "overdue"
| "void"
| "uncollectible";
| "uncollectible"
// Phase 7: refund states. partially_refunded = at least one refund
// recorded but sum < total. fully_refunded = sum >= total. Voiding
// applies to unpaid invoices; refunding applies to paid invoices —
// the two states are mutually exclusive transitions from 'paid'
// versus 'open'.
| "partially_refunded"
| "fully_refunded";
export type InvoicePaymentMethod = "invoice" | "card";
// Phase 7 — credit notes are independent documents (separate
// numbering, separate PDF) that record a void or refund against an
// original invoice. Issued as part of voidInvoice() or
// refundInvoice() flows; the customer downloads them from
// /api/credit-notes/<number>/pdf.
export type CreditNoteKind = "void" | "refund";
export interface CreditNote {
id: string;
creditNoteNumber: string;
invoiceId: string;
invoiceNumber: string;
zitadelOrgId: string;
kind: CreditNoteKind;
amountChf: number;
vatAmountChf: number;
reason: string | null;
issuedAt: string;
issuedBy: string;
locale: string;
pdfFilename: string | null;
hasPdf: boolean;
billingSnapshot: InvoiceBillingSnapshot;
}
// Phase 7 — per-refund-event record (one row per Stripe Refund
// object, or per admin-initiated refund for invoice-paid customers).
// Aggregated into invoices.refunded_total_chf for query convenience.
export interface InvoiceRefund {
id: string;
invoiceId: string;
stripeRefundId: string | null;
amountChf: number;
reason: string | null;
status: "pending" | "succeeded" | "failed" | "canceled";
refundedAt: string;
refundedBy: string;
creditNoteId: string | null;
}
// Phase 5 — Cron run history rows for the admin /admin/cron page.
export type CronRunKind = "monthly_issue" | "reminders";
export interface CronRun {
id: string;
runKind: CronRunKind;
triggeredBy: string;
startedAt: string;
finishedAt: string | null;
successCount: number;
failureCount: number;
skippedCount: number;
errorDetails: unknown | null;
}
export type InvoiceLineKind =
| "tenant_monthly"
| "tenant_setup"
@@ -561,6 +628,7 @@ export type InvoiceLineKind =
*/
export interface InvoiceBillingSnapshot {
companyName: string;
contactName: string | null;
streetAddress: string;
postalCode: string;
city: string;
@@ -620,6 +688,16 @@ export interface Invoice {
paidAt: string | null;
paidBy: string | null;
paidMethodDetail: string | null;
// Phase 7 — void tracking. Populated when status='void'. The reason
// free-text is rendered on the credit note PDF.
voidReason: string | null;
voidedAt: string | null;
voidedBy: string | null;
// Phase 7 — running sum of refunds applied to this invoice. Zero
// for invoices that have never been refunded. Drives status
// transitions (partially_refunded vs fully_refunded) and the
// running-total widget on /billing.
refundedTotalChf: number;
createdAt: string;
}