Phase6: Customer Billing details
All checks were successful
Build and Push / build (push) Successful in 1m46s

This commit is contained in:
2026-05-25 11:47:14 +02:00
parent 427c7c6204
commit fadfdd3435
10 changed files with 493 additions and 110 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">
{t("currentPeriodHeading")}
</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 className="animate-in animate-in-delay-2">

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">
@@ -34,14 +35,9 @@ export default async function BillingSettingsPage() {
</h1>
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
</div>
<BillingSettingsForm
initial={billing}
isPersonal={user.isPersonal}
orgName={user.orgName}
userName={user.name}
userEmail={user.email}
/>
<div className="animate-in animate-in-delay-1">
<BillingSettingsForm initial={existing} />
</div>
</main>
);
}

View File

@@ -0,0 +1,85 @@
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),
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,
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 (
<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">
@@ -192,7 +207,12 @@ export function CronControls({ initialRecent, initialLastSuccess }: Props) {
</thead>
<tbody>
{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">
{fmtRelative(r.startedAt)}
</td>

View File

@@ -11,6 +11,17 @@ type CurrentResponse =
| { 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.
*
@@ -28,7 +39,7 @@ type CurrentResponse =
* No polling — the page is static enough that an explicit
* "refresh" link is good enough if the user wants newer numbers.
*/
export function RunningTotalWidget() {
export function RunningTotalWidget({ isOwner }: Props) {
const t = useTranslations("customerBilling");
const fmt = useFormatter();
const [data, setData] = useState<CurrentResponse | null>(null);
@@ -62,13 +73,29 @@ export function RunningTotalWidget() {
);
}
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">
{data && "code" in data && data.code === "COMPUTE_FAILED"
? t("noBillingConfig")
: t("currentPeriodError")}
{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>
);
}

View File

@@ -0,0 +1,229 @@
"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;
}
/**
* 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 }: Props) {
const t = useTranslations("settingsBilling");
const router = useRouter();
const [form, setForm] = useState({
companyName: initial?.companyName ?? "",
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(),
streetAddress: form.streetAddress.trim(),
postalCode: form.postalCode.trim(),
city: form.city.trim(),
country: form.country.trim().toUpperCase(),
vatNumber: 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={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>
<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>
<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

@@ -482,25 +482,27 @@
"billingDescriptionPersonal": "Adresse und Rechnungs-E-Mail für alle Ihre Tenants."
},
"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."
},
"support": {
"title": "Support",
@@ -742,7 +744,9 @@
"payWithCard": "Mit Karte bezahlen",
"redirectingToStripe": "Weiterleitung…",
"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": {
"title": "Abrechnungsautomatisierung",
@@ -772,6 +776,8 @@
"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."
}
}

View File

@@ -482,25 +482,27 @@
"billingDescriptionPersonal": "Address and invoice email used for all your tenants."
},
"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."
},
"support": {
"title": "Support",
@@ -742,7 +744,9 @@
"payWithCard": "Pay with card",
"redirectingToStripe": "Redirecting…",
"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": {
"title": "Billing automation",
@@ -772,6 +776,8 @@
"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."
}
}

View File

@@ -482,25 +482,27 @@
"billingDescriptionPersonal": "Adresse et e-mail de facturation utilisés pour tous vos locataires."
},
"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."
},
"support": {
"title": "Support",
@@ -742,7 +744,9 @@
"payWithCard": "Payer par carte",
"redirectingToStripe": "Redirection…",
"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": {
"title": "Automatisation de la facturation",
@@ -772,6 +776,8 @@
"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."
}
}

View File

@@ -482,25 +482,27 @@
"billingDescriptionPersonal": "Indirizzo ed e-mail di fatturazione usati per tutti i tuoi tenant."
},
"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."
},
"support": {
"title": "Supporto",
@@ -742,7 +744,9 @@
"payWithCard": "Paga con carta",
"redirectingToStripe": "Reindirizzamento…",
"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": {
"title": "Automazione fatturazione",
@@ -772,6 +776,8 @@
"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."
}
}