Compare commits

..

8 Commits

Author SHA1 Message Date
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
25 changed files with 1320 additions and 162 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

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

@@ -252,11 +252,24 @@ export async function POST(request: Request) {
} }
} }
// For follow-up instances, prefer the on-file company name and contact // The audit copy of company name on this request stays inherited
// details; the user can't change those by re-typing them in the wizard. // 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 companyName = prior?.companyName ?? user.orgName;
const contactName = prior?.contactName ?? user.name; const contactName = user.name;
const contactEmail = prior?.contactEmail ?? user.email; const contactEmail = user.email;
// Bug 35: org-scoped billing. // 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

@@ -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

@@ -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: { 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) { if (account && profile) {
const claims = profile as unknown as ZitadelClaims; const claims = profile as unknown as ZitadelClaims;
token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"]; token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"];
@@ -58,6 +82,19 @@ export const authConfig: NextAuthConfig = {
claims["urn:zitadel:iam:org:project:roles"] claims["urn:zitadel:iam:org:project:roles"]
); );
token.accessToken = account.access_token; 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 // Pin token.sub to the OIDC subject. Auth.js v5 otherwise puts a
// freshly generated UUID in token.sub on initial sign-in, // freshly generated UUID in token.sub on initial sign-in,
// ignoring what profile() returns for `id`. That UUID then // ignoring what profile() returns for `id`. That UUID then
@@ -80,10 +117,19 @@ export const authConfig: NextAuthConfig = {
async session({ session, token }) { async session({ session, token }) {
const roles = (token.roles as Role[]) ?? []; const roles = (token.roles as Role[]) ?? [];
const orgName = (token.orgName as string) ?? ""; 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 = { const sessionUser: SessionUser = {
id: token.sub!, id: token.sub!,
name: session.user?.name ?? "", name: tokenName || session.user?.name || "",
email: session.user?.email ?? "", email: tokenEmail || session.user?.email || "",
orgId: token.orgId as string, orgId: token.orgId as string,
orgName, orgName,
roles, roles,
@@ -96,6 +142,14 @@ export const authConfig: NextAuthConfig = {
isPersonal: isPersonalOrgName(orgName), isPersonal: isPersonalOrgName(orgName),
}; };
(session as any).platformUser = sessionUser; (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; return session;
}, },
}, },

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

@@ -528,3 +528,113 @@ export async function registerCustomer(params: {
throw err; 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

@@ -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",
@@ -479,28 +480,36 @@
"billingTitle": "Abrechnung", "billingTitle": "Abrechnung",
"billingDescription": "Adresse, MWST-Nummer und Rechnungs-E-Mail für alle Ihre Tenants.", "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.", "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": { "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 +751,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 +783,23 @@
"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."
},
"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

@@ -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",
@@ -479,28 +480,36 @@
"billingTitle": "Billing", "billingTitle": "Billing",
"billingDescription": "Address, VAT number, and invoice email used for all your tenants.", "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.", "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": { "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 +751,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 +783,23 @@
"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."
},
"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

@@ -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",
@@ -479,28 +480,36 @@
"billingTitle": "Facturation", "billingTitle": "Facturation",
"billingDescription": "Adresse, numéro de TVA et e-mail de facturation utilisés pour tous vos locataires.", "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.", "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": { "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 +751,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 +783,23 @@
"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."
},
"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

@@ -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",
@@ -479,28 +480,36 @@
"billingTitle": "Fatturazione", "billingTitle": "Fatturazione",
"billingDescription": "Indirizzo, numero di IVA ed e-mail di fatturazione usati per tutti i tuoi tenant.", "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.", "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": { "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 +751,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 +783,23 @@
"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."
},
"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 { 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;