Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 002867850d | |||
| eea027b3b0 | |||
| 522246e386 |
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ import { getOrgBilling, upsertOrgBilling } from "@/lib/db";
|
|||||||
|
|
||||||
const upsertSchema = z.object({
|
const upsertSchema = z.object({
|
||||||
companyName: z.string().trim().min(1).max(200),
|
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),
|
streetAddress: z.string().trim().min(1).max(200),
|
||||||
postalCode: z.string().trim().min(1).max(20),
|
postalCode: z.string().trim().min(1).max(20),
|
||||||
city: z.string().trim().min(1).max(100),
|
city: z.string().trim().min(1).max(100),
|
||||||
@@ -73,6 +77,7 @@ export async function PUT(request: Request) {
|
|||||||
const billing = await upsertOrgBilling({
|
const billing = await upsertOrgBilling({
|
||||||
zitadelOrgId: user.orgId,
|
zitadelOrgId: user.orgId,
|
||||||
companyName: data.companyName,
|
companyName: data.companyName,
|
||||||
|
contactName: data.contactName ?? null,
|
||||||
streetAddress: data.streetAddress,
|
streetAddress: data.streetAddress,
|
||||||
postalCode: data.postalCode,
|
postalCode: data.postalCode,
|
||||||
city: data.city,
|
city: data.city,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 || ""}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function BillingSettingsForm({ initial, isPersonal }: Props) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
companyName: initial?.companyName ?? "",
|
companyName: initial?.companyName ?? "",
|
||||||
|
contactName: initial?.contactName ?? "",
|
||||||
streetAddress: initial?.streetAddress ?? "",
|
streetAddress: initial?.streetAddress ?? "",
|
||||||
postalCode: initial?.postalCode ?? "",
|
postalCode: initial?.postalCode ?? "",
|
||||||
city: initial?.city ?? "",
|
city: initial?.city ?? "",
|
||||||
@@ -84,6 +85,10 @@ export function BillingSettingsForm({ initial, isPersonal }: Props) {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
companyName: form.companyName.trim(),
|
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(),
|
streetAddress: form.streetAddress.trim(),
|
||||||
postalCode: form.postalCode.trim(),
|
postalCode: form.postalCode.trim(),
|
||||||
city: form.city.trim(),
|
city: form.city.trim(),
|
||||||
@@ -124,6 +129,17 @@ export function BillingSettingsForm({ initial, isPersonal }: Props) {
|
|||||||
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
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>
|
||||||
|
{!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>
|
<Field label={t("streetAddressLabel")} required>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -504,7 +505,9 @@
|
|||||||
"invalidCountry": "Ländercode muss aus 2 Buchstaben bestehen (z.B. CH).",
|
"invalidCountry": "Ländercode muss aus 2 Buchstaben bestehen (z.B. CH).",
|
||||||
"invalidEmail": "Bitte eine gültige E-Mail-Adresse eingeben.",
|
"invalidEmail": "Bitte eine gültige E-Mail-Adresse eingeben.",
|
||||||
"fullNameLabel": "Vor- und Nachname",
|
"fullNameLabel": "Vor- und Nachname",
|
||||||
"subtitlePersonal": "Ihre Rechnungsadresse und Rechnungskontakt. Erforderlich, bevor Rechnungen ausgestellt werden können."
|
"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",
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -504,7 +505,9 @@
|
|||||||
"invalidCountry": "Country code must be 2 letters (e.g. CH).",
|
"invalidCountry": "Country code must be 2 letters (e.g. CH).",
|
||||||
"invalidEmail": "Please enter a valid email address.",
|
"invalidEmail": "Please enter a valid email address.",
|
||||||
"fullNameLabel": "Full name",
|
"fullNameLabel": "Full name",
|
||||||
"subtitlePersonal": "Your billing address and invoice contact. Required before invoices can be issued."
|
"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",
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -504,7 +505,9 @@
|
|||||||
"invalidCountry": "Le code pays doit comporter 2 lettres (p. ex. CH).",
|
"invalidCountry": "Le code pays doit comporter 2 lettres (p. ex. CH).",
|
||||||
"invalidEmail": "Veuillez saisir une adresse e-mail valide.",
|
"invalidEmail": "Veuillez saisir une adresse e-mail valide.",
|
||||||
"fullNameLabel": "Nom et prénom",
|
"fullNameLabel": "Nom et prénom",
|
||||||
"subtitlePersonal": "Votre adresse de facturation et votre contact. Requis avant l'émission de toute facture."
|
"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",
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -504,7 +505,9 @@
|
|||||||
"invalidCountry": "Il codice paese deve essere di 2 lettere (es. CH).",
|
"invalidCountry": "Il codice paese deve essere di 2 lettere (es. CH).",
|
||||||
"invalidEmail": "Inserisci un indirizzo e-mail valido.",
|
"invalidEmail": "Inserisci un indirizzo e-mail valido.",
|
||||||
"fullNameLabel": "Nome e cognome",
|
"fullNameLabel": "Nome e cognome",
|
||||||
"subtitlePersonal": "Il tuo indirizzo di fatturazione e contatto. Necessari prima che possano essere emesse fatture."
|
"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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user