Compare commits

..

4 Commits

Author SHA1 Message Date
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
18 changed files with 717 additions and 149 deletions

View File

@@ -49,7 +49,9 @@ export default async function CustomerBillingPage() {
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3"> <h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("currentPeriodHeading")} {t("currentPeriodHeading")}
</h2> </h2>
<RunningTotalWidget /> {/* 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>
<section className="animate-in animate-in-delay-2"> <section className="animate-in animate-in-delay-2">

View File

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

View File

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

View File

@@ -1,30 +1,31 @@
import { getTranslations } from "next-intl/server";
import { redirect, notFound } from "next/navigation"; 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 { 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 * Owner-only by visibility: non-owner members get a 404 (same
* the client form. The form posts to PUT /api/billing on submit. * 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` * First-time visitors see an empty form. Subsequent visits see
* role redirects to /settings (which also wouldn't list billing for * the current values, editable. Save creates or updates via the
* them). 403 here would be friendlier than redirect, but the most * shared upsert path; the row's existence drives whether the
* likely cause of a `user` landing on this URL is sharing a bookmark * monthly issuance cron will pick this org up.
* with their owner — silent redirect is gentle.
*/ */
export default async function BillingSettingsPage() { export default async function BillingSettingsPage() {
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) redirect("/login"); if (!user) redirect("/login");
if (!canMutate(user)) { // Non-owners get a 404 — see comment above.
redirect("/settings"); if (!user.roles.includes("owner")) notFound();
}
const t = await getTranslations("settingsBilling");
const billing = await getOrgBilling(user.orgId); const t = await getTranslations("settingsBilling");
const existing = await getOrgBilling(user.orgId);
return ( return (
<main className="max-w-3xl mx-auto px-6 py-8"> <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"> <h1 className="font-display text-2xl font-semibold accent-rule">
{t("title")} {t("title")}
</h1> </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> </div>
<BillingSettingsForm
initial={billing}
isPersonal={user.isPersonal}
orgName={user.orgName}
userName={user.name}
userEmail={user.email}
/>
</main> </main>
); );
} }

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

@@ -116,8 +116,23 @@ export function CronControls({ initialRecent, initialLastSuccess }: Props) {
}); });
}; };
// 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 ( return (
<div className="space-y-8"> <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"> <section className="grid gap-4 md:grid-cols-2">
<Card> <Card>
<h2 className="text-xs uppercase tracking-wider text-text-muted mb-2"> <h2 className="text-xs uppercase tracking-wider text-text-muted mb-2">
@@ -192,7 +207,12 @@ export function CronControls({ initialRecent, initialLastSuccess }: Props) {
</thead> </thead>
<tbody> <tbody>
{recent.map((r) => ( {recent.map((r) => (
<tr key={r.id} className="border-t border-border align-top"> <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"> <td className="py-2 text-xs font-mono">
{fmtRelative(r.startedAt)} {fmtRelative(r.startedAt)}
</td> </td>

View File

@@ -11,6 +11,17 @@ type CurrentResponse =
| { draft: InvoiceDraft } | { draft: InvoiceDraft }
| { error: string; code?: string }; | { 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. * Live running total for the current calendar month.
* *
@@ -28,7 +39,7 @@ type CurrentResponse =
* No polling — the page is static enough that an explicit * No polling — the page is static enough that an explicit
* "refresh" link is good enough if the user wants newer numbers. * "refresh" link is good enough if the user wants newer numbers.
*/ */
export function RunningTotalWidget() { export function RunningTotalWidget({ isOwner }: Props) {
const t = useTranslations("customerBilling"); const t = useTranslations("customerBilling");
const fmt = useFormatter(); const fmt = useFormatter();
const [data, setData] = useState<CurrentResponse | null>(null); const [data, setData] = useState<CurrentResponse | null>(null);
@@ -62,13 +73,29 @@ export function RunningTotalWidget() {
); );
} }
if (!data || "error" in data) { if (!data || "error" in data) {
const noConfig =
data && "code" in data && data.code === "COMPUTE_FAILED";
return ( return (
<Card> <Card>
<p className="text-sm text-text-secondary py-2"> <p className="text-sm text-text-secondary py-2">
{data && "code" in data && data.code === "COMPUTE_FAILED" {noConfig ? t("noBillingConfig") : t("currentPeriodError")}
? t("noBillingConfig")
: t("currentPeriodError")}
</p> </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> </Card>
); );
} }

View File

@@ -2,6 +2,7 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { OnboardingWizard } from "./wizard"; import { OnboardingWizard } from "./wizard";
import type { OrgBilling } from "@/types";
interface OnboardingFlowProps { interface OnboardingFlowProps {
orgName: string; orgName: string;
@@ -19,6 +20,12 @@ interface OnboardingFlowProps {
* /settings/billing. * /settings/billing.
*/ */
hasOrgBilling?: boolean; 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 * Bug 6: when present, the wizard is rendered in edit mode against
* the given pending request. See `OnboardingWizard` for the full * the given pending request. See `OnboardingWizard` for the full
@@ -45,6 +52,7 @@ export function OnboardingFlow({
userName, userName,
userEmail, userEmail,
hasOrgBilling, hasOrgBilling,
existingOrgBilling,
editingRequest, editingRequest,
}: OnboardingFlowProps) { }: OnboardingFlowProps) {
const router = useRouter(); const router = useRouter();
@@ -55,6 +63,7 @@ export function OnboardingFlow({
userName={userName} userName={userName}
userEmail={userEmail} userEmail={userEmail}
hasOrgBilling={hasOrgBilling} hasOrgBilling={hasOrgBilling}
existingOrgBilling={existingOrgBilling}
editingRequest={editingRequest} editingRequest={editingRequest}
onComplete={() => { onComplete={() => {
// Navigate back to /dashboard and re-fetch on the server. The // Navigate back to /dashboard and re-fetch on the server. The

View File

@@ -13,6 +13,7 @@ import {
SUPPORTED_COUNTRIES, SUPPORTED_COUNTRIES,
type SupportedCountry, type SupportedCountry,
} from "@/lib/validation"; } from "@/lib/validation";
import type { OrgBilling } from "@/types";
type Step = "welcome" | "configure" | "billing" | "confirm"; type Step = "welcome" | "configure" | "billing" | "confirm";
@@ -96,6 +97,17 @@ interface WizardProps {
* fix it before admin approves. * fix it before admin approves.
*/ */
hasOrgBilling?: boolean; 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 * Bug 6: when present, the wizard renders in "edit" mode — fields
* are pre-populated from the request, the SOUL.md auto-fetch is * are pre-populated from the request, the SOUL.md auto-fetch is
@@ -134,6 +146,7 @@ export function OnboardingWizard({
userName, userName,
userEmail, userEmail,
hasOrgBilling, hasOrgBilling,
existingOrgBilling,
editingRequest, editingRequest,
onComplete, onComplete,
}: WizardProps) { }: WizardProps) {
@@ -319,7 +332,23 @@ export function OnboardingWizard({
} }
// confirm: validate the union (defence in depth — submit handler // confirm: validate the union (defence in depth — submit handler
// also runs onboardingSchema before POST). // 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) { if (r.success) {
setErrors({}); setErrors({});
return true; return true;
@@ -1101,42 +1130,84 @@ export function OnboardingWizard({
<ReviewRow <ReviewRow
label={t("reviewBillingTo")} label={t("reviewBillingTo")}
value={ value={
<div className="text-text-primary text-right"> (() => {
{/* For personal: skip the company line so the // Phase 6 fix3: when the org has billing on file
invoice rendering matches what the user actually // and we're not editing, render the saved
entered. For company: include it as the first // org_billing record (the authoritative source)
line. */} // rather than config.billingAddress, which is the
{!isPersonal && // wizard's empty default state because the billing
config.billingAddress.company && // step was skipped. In edit mode, fall back to
config.billingAddress.company.trim().length > 0 && ( // config.billingAddress, which is pre-populated
<div>{config.billingAddress.company}</div> // from the request being edited.
)} const useSaved =
<div>{config.billingAddress.street}</div> hasOrgBilling && !isEditing && existingOrgBilling;
<div> const company = useSaved
{config.billingAddress.postalCode}{" "} ? existingOrgBilling!.companyName
{config.billingAddress.city} : config.billingAddress.company;
</div> const street = useSaved
<div className="text-text-muted"> ? existingOrgBilling!.streetAddress
{tCountries( : config.billingAddress.street;
config.billingAddress.country as SupportedCountry const postalCode = useSaved
)} ? existingOrgBilling!.postalCode
</div> : config.billingAddress.postalCode;
</div> 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 {/* Bug 35: VAT review row. Company customers see this so
they can verify the VAT id they typed before submitting. they can verify the VAT id they typed before submitting.
Personal customers never see it — they don't have a 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 && {!isPersonal &&
config.billingAddress.vatNumber && (() => {
config.billingAddress.vatNumber.trim().length > 0 && ( const vat =
<ReviewRow hasOrgBilling && !isEditing && existingOrgBilling
label={t("billingVatNumber")} ? existingOrgBilling.vatNumber
value={config.billingAddress.vatNumber} : config.billingAddress.vatNumber;
mono return vat && vat.trim().length > 0 ? (
/> <ReviewRow
)} label={t("billingVatNumber")}
value={vat}
mono
/>
) : null;
})()}
<ReviewRow <ReviewRow
label={t("reviewContactEmail")} label={t("reviewContactEmail")}
value={userEmail || ""} value={userEmail || ""}

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

@@ -80,6 +80,11 @@ interface PdfStrings {
dueDate: string; dueDate: string;
period: string; period: string;
billTo: 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; description: string;
quantity: string; quantity: string;
unitPrice: string; unitPrice: string;
@@ -107,6 +112,7 @@ const MESSAGES: Record<string, PdfStrings> = {
dueDate: "Zahlbar bis", dueDate: "Zahlbar bis",
period: "Abrechnungsperiode", period: "Abrechnungsperiode",
billTo: "Rechnungsempfänger", billTo: "Rechnungsempfänger",
attentionPrefix: "z.Hd.",
description: "Beschreibung", description: "Beschreibung",
quantity: "Menge", quantity: "Menge",
unitPrice: "Einzelpreis", unitPrice: "Einzelpreis",
@@ -139,6 +145,7 @@ const MESSAGES: Record<string, PdfStrings> = {
dueDate: "Due date", dueDate: "Due date",
period: "Billing period", period: "Billing period",
billTo: "Bill to", billTo: "Bill to",
attentionPrefix: "Attn:",
description: "Description", description: "Description",
quantity: "Qty", quantity: "Qty",
unitPrice: "Unit price", unitPrice: "Unit price",
@@ -171,6 +178,7 @@ const MESSAGES: Record<string, PdfStrings> = {
dueDate: "Échéance", dueDate: "Échéance",
period: "Période de facturation", period: "Période de facturation",
billTo: "Destinataire", billTo: "Destinataire",
attentionPrefix: "À l'attention de",
description: "Description", description: "Description",
quantity: "Qté", quantity: "Qté",
unitPrice: "Prix unitaire", unitPrice: "Prix unitaire",
@@ -203,6 +211,7 @@ const MESSAGES: Record<string, PdfStrings> = {
dueDate: "Scadenza", dueDate: "Scadenza",
period: "Periodo di fatturazione", period: "Periodo di fatturazione",
billTo: "Destinatario", billTo: "Destinatario",
attentionPrefix: "c.a.",
description: "Descrizione", description: "Descrizione",
quantity: "Qtà", quantity: "Qtà",
unitPrice: "Prezzo unitario", unitPrice: "Prezzo unitario",
@@ -524,6 +533,15 @@ const InvoicePdf: React.FC<InvoicePdfProps> = ({ invoice, lines }) => {
<View style={styles.billToBlock}> <View style={styles.billToBlock}>
<Text style={styles.billToLabel}>{s.billTo}</Text> <Text style={styles.billToLabel}>{s.billTo}</Text>
<Text style={styles.billToName}>{snap.companyName}</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.streetAddress}</Text>
<Text> <Text>
{snap.postalCode} {snap.city} {snap.postalCode} {snap.city}

View File

@@ -645,6 +645,7 @@ export async function computeInvoiceDraft(opts: {
} }
const snapshot: InvoiceBillingSnapshot = { const snapshot: InvoiceBillingSnapshot = {
companyName: orgBilling.companyName, companyName: orgBilling.companyName,
contactName: orgBilling.contactName ?? null,
streetAddress: orgBilling.streetAddress, streetAddress: orgBilling.streetAddress,
postalCode: orgBilling.postalCode, postalCode: orgBilling.postalCode,
city: orgBilling.city, city: orgBilling.city,

View File

@@ -198,6 +198,12 @@ const MIGRATION_SQL = `
CREATE TABLE IF NOT EXISTS org_billing ( CREATE TABLE IF NOT EXISTS org_billing (
zitadel_org_id TEXT PRIMARY KEY, zitadel_org_id TEXT PRIMARY KEY,
company_name TEXT NOT NULL, company_name TEXT NOT NULL,
-- Phase 6 fix: optional contact-person line shown on the
-- invoice PDF below the company name (e.g. "z.Hd. Herr Müller").
-- Not normally needed since invoices are delivered by email
-- link, but useful when customers forward the PDF internally
-- for AP routing in larger organizations.
contact_name TEXT,
street_address TEXT NOT NULL, street_address TEXT NOT NULL,
postal_code TEXT NOT NULL, postal_code TEXT NOT NULL,
city TEXT NOT NULL, city TEXT NOT NULL,
@@ -208,6 +214,10 @@ const MIGRATION_SQL = `
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now() updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
-- Phase 6 fix: ensure the column exists on databases that were
-- created before contact_name was added to the base schema above.
-- IF NOT EXISTS makes this safe to run repeatedly via ensureSchema.
ALTER TABLE org_billing ADD COLUMN IF NOT EXISTS contact_name TEXT;
-- Feature 5: lightweight customer support / feedback tickets. -- Feature 5: lightweight customer support / feedback tickets.
-- Scoped strictly per-user (zitadel_user_id), not per-org — -- Scoped strictly per-user (zitadel_user_id), not per-org —
@@ -1262,6 +1272,7 @@ function rowToOrgBilling(row: any): OrgBilling {
return { return {
zitadelOrgId: row.zitadel_org_id, zitadelOrgId: row.zitadel_org_id,
companyName: row.company_name, companyName: row.company_name,
contactName: row.contact_name ?? null,
streetAddress: row.street_address, streetAddress: row.street_address,
postalCode: row.postal_code, postalCode: row.postal_code,
city: row.city, city: row.city,
@@ -1306,12 +1317,13 @@ export async function upsertOrgBilling(
await ensureSchema(); await ensureSchema();
const result = await getPool().query( const result = await getPool().query(
`INSERT INTO org_billing ( `INSERT INTO org_billing (
zitadel_org_id, company_name, street_address, postal_code, zitadel_org_id, company_name, contact_name, street_address,
city, country, vat_number, billing_email, notes postal_code, city, country, vat_number, billing_email, notes
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (zitadel_org_id) DO UPDATE SET ON CONFLICT (zitadel_org_id) DO UPDATE SET
company_name = EXCLUDED.company_name, company_name = EXCLUDED.company_name,
contact_name = EXCLUDED.contact_name,
street_address = EXCLUDED.street_address, street_address = EXCLUDED.street_address,
postal_code = EXCLUDED.postal_code, postal_code = EXCLUDED.postal_code,
city = EXCLUDED.city, city = EXCLUDED.city,
@@ -1324,6 +1336,7 @@ export async function upsertOrgBilling(
[ [
data.zitadelOrgId, data.zitadelOrgId,
data.companyName, data.companyName,
data.contactName ?? null,
data.streetAddress, data.streetAddress,
data.postalCode, data.postalCode,
data.city, data.city,

View File

@@ -121,7 +121,8 @@
"saveChanges": "Änderungen speichern", "saveChanges": "Änderungen speichern",
"billingVatNumber": "MWST-Nummer", "billingVatNumber": "MWST-Nummer",
"billingVatHelp": "Ihre registrierte MWST-Nummer. Falls Ihre Firma von der MWST befreit ist, leer lassen und in den Notizen erläutern.", "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": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -482,25 +483,31 @@
"billingDescriptionPersonal": "Adresse und Rechnungs-E-Mail für alle Ihre Tenants." "billingDescriptionPersonal": "Adresse und Rechnungs-E-Mail für alle Ihre Tenants."
}, },
"settingsBilling": { "settingsBilling": {
"title": "Abrechnung", "title": "Rechnungsdaten",
"subtitle": "Wird beim ersten Onboarding einmalig erfasst und für jeden Tenant Ihrer Organisation wiederverwendet. Aktualisieren Sie hier, wenn sich Ihre Abrechnungsdaten ändern.", "subtitle": "Rechnungsadresse, MWST-Nummer und Rechnungskontakt Ihres Unternehmens. Erforderlich, bevor Rechnungen für Ihre Organisation ausgestellt werden können.",
"companyName": "Firmenname", "companyNameLabel": "Firmenname",
"streetAddress": "Strasse", "streetAddressLabel": "Strasse und Hausnummer",
"postalCode": "PLZ", "postalCodeLabel": "PLZ",
"city": "Ort", "cityLabel": "Ort",
"country": "Land", "countryLabel": "Ländercode",
"vatNumber": "MWST-Nummer", "countryHint": "ISO 3166-1 alpha-2 — z.B. CH, DE, AT, FR, IT, GB, US",
"vatHelp": "Ihre registrierte MWST-Nummer (z. B. CHE-123.456.789 MWST für die Schweiz).", "vatNumberLabel": "MWST-Nummer (optional)",
"billingEmail": "Rechnungs-E-Mail", "vatNumberHint": "Für Schweizer Kunden: CHE-XXX.XXX.XXX MWST. EU-Kunden mit USt-IdNr. erhalten eine Reverse-Charge-Rechnung (0% MWST).",
"billingEmailHelp": "An diese Adresse werden Rechnungen und Abrechnungskommunikation gesendet.", "billingEmailLabel": "Rechnungs-E-Mail",
"notes": "Notizen", "billingEmailHint": "Rechnungen und Zahlungserinnerungen werden an diese Adresse gesendet. Kann von Ihrer Konto-E-Mail abweichen.",
"notesPlaceholder": "Alles, was die Buchhaltung wissen muss MWST-Befreiung, besondere Rechnungsstellung usw.", "notesLabel": "Bemerkungen (optional)",
"save": "Speichern", "notesHint": "Referenznummern, Bestellnummern oder andere Angaben, die auf der Rechnung erscheinen sollen.",
"saveChanges": "Änderungen speichern",
"createBilling": "Rechnungsdaten speichern",
"saving": "Speichern…",
"saved": "Gespeichert.", "saved": "Gespeichert.",
"saveFailed": "Konnte nicht gespeichert werden. Bitte erneut versuchen.", "missingRequired": "Bitte alle Pflichtfelder ausfüllen.",
"lastUpdated": "Zuletzt aktualisiert {when}", "invalidCountry": "Ländercode muss aus 2 Buchstaben bestehen (z.B. CH).",
"fullName": "Voller Name", "invalidEmail": "Bitte eine gültige E-Mail-Adresse eingeben.",
"notesPlaceholderPersonal": "Was wir wissen sollten — bevorzugte Zahlungsart, Rechnungsreferenz, etc." "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": { "support": {
"title": "Support", "title": "Support",
@@ -742,7 +749,9 @@
"payWithCard": "Mit Karte bezahlen", "payWithCard": "Mit Karte bezahlen",
"redirectingToStripe": "Weiterleitung…", "redirectingToStripe": "Weiterleitung…",
"paymentReceived": "Zahlung erhalten — vielen Dank!", "paymentReceived": "Zahlung erhalten — vielen Dank!",
"paymentCancelled": "Zahlung abgebrochen." "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."
}, },
"adminCron": { "adminCron": {
"title": "Abrechnungsautomatisierung", "title": "Abrechnungsautomatisierung",
@@ -772,6 +781,8 @@
"kind": { "kind": {
"monthly_issue": "Rechnungsstellung", "monthly_issue": "Rechnungsstellung",
"reminders": "Mahnungen" "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."
} }
} }

View File

@@ -121,7 +121,8 @@
"saveChanges": "Save changes", "saveChanges": "Save changes",
"billingVatNumber": "VAT number", "billingVatNumber": "VAT number",
"billingVatHelp": "Your registered VAT identifier. If your company is VAT-exempt, leave blank and explain in the notes field.", "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": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -482,25 +483,31 @@
"billingDescriptionPersonal": "Address and invoice email used for all your tenants." "billingDescriptionPersonal": "Address and invoice email used for all your tenants."
}, },
"settingsBilling": { "settingsBilling": {
"title": "Billing", "title": "Billing details",
"subtitle": "Captured once at first onboarding and reused for every tenant in your organization. Update here whenever your billing details change.", "subtitle": "Your company's billing address, VAT number, and invoice contact. Required before invoices can be issued for your organization.",
"companyName": "Company name", "companyNameLabel": "Company name",
"streetAddress": "Street address", "streetAddressLabel": "Street address",
"postalCode": "Postal code", "postalCodeLabel": "Postal code",
"city": "City", "cityLabel": "City",
"country": "Country", "countryLabel": "Country code",
"vatNumber": "VAT number", "countryHint": "ISO 3166-1 alpha-2 — e.g. CH, DE, AT, FR, IT, GB, US",
"vatHelp": "Your registered VAT identifier (e.g. CHE-123.456.789 MWST for Switzerland).", "vatNumberLabel": "VAT number (optional)",
"billingEmail": "Billing email", "vatNumberHint": "For Swiss customers: CHE-XXX.XXX.XXX MWST. EU customers with a VAT number get a 0% reverse-charge invoice.",
"billingEmailHelp": "Where invoices and billing communication will be sent.", "billingEmailLabel": "Billing email",
"notes": "Notes", "billingEmailHint": "Invoices and payment reminders are sent here. Can differ from your account email.",
"notesPlaceholder": "Anything else accounting needs to know — VAT exemption, special invoicing arrangements, etc.", "notesLabel": "Notes (optional)",
"save": "Save", "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.", "saved": "Saved.",
"saveFailed": "Could not save. Please try again.", "missingRequired": "Please fill in all required fields.",
"lastUpdated": "Last updated {when}", "invalidCountry": "Country code must be 2 letters (e.g. CH).",
"fullName": "Full name", "invalidEmail": "Please enter a valid email address.",
"notesPlaceholderPersonal": "Anything we should know — preferred payment method, billing reference, etc." "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": { "support": {
"title": "Support", "title": "Support",
@@ -742,7 +749,9 @@
"payWithCard": "Pay with card", "payWithCard": "Pay with card",
"redirectingToStripe": "Redirecting…", "redirectingToStripe": "Redirecting…",
"paymentReceived": "Payment received — thank you!", "paymentReceived": "Payment received — thank you!",
"paymentCancelled": "Payment cancelled." "paymentCancelled": "Payment cancelled.",
"configureBillingCta": "Configure billing details",
"noBillingConfigNonOwner": "Only the organization owner can configure billing details. Please contact them to complete this step."
}, },
"adminCron": { "adminCron": {
"title": "Billing automation", "title": "Billing automation",
@@ -772,6 +781,8 @@
"kind": { "kind": {
"monthly_issue": "Issuance", "monthly_issue": "Issuance",
"reminders": "Reminders" "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."
} }
} }

View File

@@ -121,7 +121,8 @@
"saveChanges": "Enregistrer les modifications", "saveChanges": "Enregistrer les modifications",
"billingVatNumber": "Numéro de TVA", "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.", "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": { "dashboard": {
"title": "Tableau de bord", "title": "Tableau de bord",
@@ -482,25 +483,31 @@
"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."
}, },
"settingsBilling": { "settingsBilling": {
"title": "Facturation", "title": "Informations de 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.", "subtitle": "Adresse de facturation, numéro de TVA et contact pour les factures. Requis avant l'émission de toute facture pour votre organisation.",
"companyName": "Nom de l'entreprise", "companyNameLabel": "Nom de l'entreprise",
"streetAddress": "Adresse", "streetAddressLabel": "Adresse",
"postalCode": "Code postal", "postalCodeLabel": "Code postal",
"city": "Ville", "cityLabel": "Ville",
"country": "Pays", "countryLabel": "Code pays",
"vatNumber": "Numéro de TVA", "countryHint": "ISO 3166-1 alpha-2 — p. ex. CH, DE, AT, FR, IT, GB, US",
"vatHelp": "Votre identifiant TVA enregistré (par ex. CHE-123.456.789 TVA pour la Suisse).", "vatNumberLabel": "Numéro de TVA (facultatif)",
"billingEmail": "E-mail de facturation", "vatNumberHint": "Pour les clients suisses : CHE-XXX.XXX.XXX TVA. Les clients UE avec un n° de TVA reçoivent une facture à 0% (autoliquidation).",
"billingEmailHelp": "Adresse à laquelle les factures et la communication de facturation seront envoyées.", "billingEmailLabel": "E-mail de facturation",
"notes": "Notes", "billingEmailHint": "Les factures et rappels de paiement sont envoyés à cette adresse. Peut différer de l'e-mail du compte.",
"notesPlaceholder": "Tout ce que la comptabilité doit savoir exonération de TVA, modalités de facturation particulières, etc.", "notesLabel": "Notes (facultatif)",
"save": "Enregistrer", "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é.", "saved": "Enregistré.",
"saveFailed": "Impossible d'enregistrer. Veuillez réessayer.", "missingRequired": "Veuillez remplir tous les champs obligatoires.",
"lastUpdated": "Dernière mise à jour {when}", "invalidCountry": "Le code pays doit comporter 2 lettres (p. ex. CH).",
"fullName": "Nom complet", "invalidEmail": "Veuillez saisir une adresse e-mail valide.",
"notesPlaceholderPersonal": "Tout ce que nous devons savoir — moyen de paiement préféré, référence de facturation, etc." "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": { "support": {
"title": "Support", "title": "Support",
@@ -742,7 +749,9 @@
"payWithCard": "Payer par carte", "payWithCard": "Payer par carte",
"redirectingToStripe": "Redirection…", "redirectingToStripe": "Redirection…",
"paymentReceived": "Paiement reçu — merci !", "paymentReceived": "Paiement reçu — merci !",
"paymentCancelled": "Paiement annulé." "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."
}, },
"adminCron": { "adminCron": {
"title": "Automatisation de la facturation", "title": "Automatisation de la facturation",
@@ -772,6 +781,8 @@
"kind": { "kind": {
"monthly_issue": "Émission", "monthly_issue": "Émission",
"reminders": "Rappels" "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."
} }
} }

View File

@@ -121,7 +121,8 @@
"saveChanges": "Salva modifiche", "saveChanges": "Salva modifiche",
"billingVatNumber": "Partita IVA", "billingVatNumber": "Partita IVA",
"billingVatHelp": "Il tuo identificativo IVA registrato. Se la tua azienda è esente IVA, lascia vuoto e spiega nelle note.", "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": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -482,25 +483,31 @@
"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."
}, },
"settingsBilling": { "settingsBilling": {
"title": "Fatturazione", "title": "Dati di 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.", "subtitle": "Indirizzo di fatturazione, partita IVA e contatto fatture della tua azienda. Necessari prima che possano essere emesse fatture per la tua organizzazione.",
"companyName": "Ragione sociale", "companyNameLabel": "Nome azienda",
"streetAddress": "Indirizzo", "streetAddressLabel": "Indirizzo",
"postalCode": "CAP", "postalCodeLabel": "CAP",
"city": "Città", "cityLabel": "Città",
"country": "Paese", "countryLabel": "Codice paese",
"vatNumber": "Partita IVA", "countryHint": "ISO 3166-1 alpha-2 — es. CH, DE, AT, FR, IT, GB, US",
"vatHelp": "Il tuo identificativo IVA registrato (es. CHE-123.456.789 IVA per la Svizzera).", "vatNumberLabel": "Partita IVA (facoltativa)",
"billingEmail": "E-mail di fatturazione", "vatNumberHint": "Per clienti svizzeri: CHE-XXX.XXX.XXX IVA. Clienti UE con partita IVA ricevono fattura in reverse charge (0% IVA).",
"billingEmailHelp": "Indirizzo a cui verranno inviate le fatture e le comunicazioni di fatturazione.", "billingEmailLabel": "E-mail di fatturazione",
"notes": "Note", "billingEmailHint": "Le fatture e i solleciti vengono inviati a questo indirizzo. Può differire dall'e-mail dell'account.",
"notesPlaceholder": "Qualsiasi cosa la contabilità debba sapere — esenzione IVA, modalità di fatturazione particolari, ecc.", "notesLabel": "Note (facoltative)",
"save": "Salva", "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.", "saved": "Salvato.",
"saveFailed": "Impossibile salvare. Riprova.", "missingRequired": "Compila tutti i campi obbligatori.",
"lastUpdated": "Ultimo aggiornamento {when}", "invalidCountry": "Il codice paese deve essere di 2 lettere (es. CH).",
"fullName": "Nome completo", "invalidEmail": "Inserisci un indirizzo e-mail valido.",
"notesPlaceholderPersonal": "Qualsiasi cosa dovremmo sapere — metodo di pagamento preferito, riferimento per fatturazione, ecc." "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": { "support": {
"title": "Supporto", "title": "Supporto",
@@ -742,7 +749,9 @@
"payWithCard": "Paga con carta", "payWithCard": "Paga con carta",
"redirectingToStripe": "Reindirizzamento…", "redirectingToStripe": "Reindirizzamento…",
"paymentReceived": "Pagamento ricevuto — grazie!", "paymentReceived": "Pagamento ricevuto — grazie!",
"paymentCancelled": "Pagamento annullato." "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."
}, },
"adminCron": { "adminCron": {
"title": "Automazione fatturazione", "title": "Automazione fatturazione",
@@ -772,6 +781,8 @@
"kind": { "kind": {
"monthly_issue": "Emissione", "monthly_issue": "Emissione",
"reminders": "Solleciti" "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."
} }
} }

View File

@@ -234,6 +234,12 @@ export interface BillingAddress {
export interface OrgBilling { export interface OrgBilling {
zitadelOrgId: string; zitadelOrgId: string;
companyName: 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; streetAddress: string;
postalCode: string; postalCode: string;
city: string; city: string;
@@ -575,6 +581,7 @@ export type InvoiceLineKind =
*/ */
export interface InvoiceBillingSnapshot { export interface InvoiceBillingSnapshot {
companyName: string; companyName: string;
contactName: string | null;
streetAddress: string; streetAddress: string;
postalCode: string; postalCode: string;
city: string; city: string;