Add optional Firma/USt-IdNr. fields to checkout and profile

B2B checkout fields, split out from the e-invoicing migration and
picked back up now that it's shipped. Both fields are independently
optional, format-validated (shared regex in lib/vatId.ts, mirrored
server-side in api/checkout and api/account/profile), persisted in
the checkout draft, and saved as a customer profile default that
pre-fills future checkouts. Order/customer snapshot fields land in a
companion Payload backend commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-23 17:53:21 +00:00
parent 2d88fb86a1
commit e48107470a
8 changed files with 119 additions and 1 deletions
+10 -1
View File
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { getSessionCustomer, updateCustomerProfile } from "../../../lib/customerAuth";
import { normalizeVatId, isValidVatId } from "../../../lib/vatId";
export async function GET() {
const session = await getSessionCustomer();
@@ -12,7 +13,7 @@ export async function PATCH(request: Request) {
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
const body = await request.json().catch(() => null);
const { firstName, lastName, deliveryMethod, street, packstationNumber, postNumber, zip, city, country } = body ?? {};
const { firstName, lastName, deliveryMethod, street, packstationNumber, postNumber, zip, city, country, companyName, vatId } = body ?? {};
if (
typeof firstName !== "string" ||
!firstName ||
@@ -34,6 +35,12 @@ export async function PATCH(request: Request) {
if (deliveryMethod === "packstation" && (!packstationNumber || !postNumber)) {
return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer angeben." }, { status: 400 });
}
// Both independently optional (see Customers.ts's own comment) — only
// format-checked when actually provided, same as the backend field itself.
const normalizedVatId = typeof vatId === "string" && vatId ? normalizeVatId(vatId) : undefined;
if (normalizedVatId && !isValidVatId(normalizedVatId)) {
return NextResponse.json({ ok: false, reason: "Ungültiges USt-IdNr.-Format (z. B. DE123456789)." }, { status: 400 });
}
const result = await updateCustomerProfile(session.token, session.customer.id, {
firstName,
@@ -45,6 +52,8 @@ export async function PATCH(request: Request) {
zip,
city,
country,
companyName: typeof companyName === "string" && companyName ? companyName : undefined,
vatId: normalizedVatId,
});
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
}
+14
View File
@@ -8,6 +8,7 @@ import { fetchProductsBySlug } from "../../lib/productsServer";
import { describeBundleContents } from "../../lib/bundleContents";
import { sendCriticalAlert } from "../../lib/alertAdmin";
import { sendOrderConfirmationEmail } from "../../lib/orderEmail";
import { normalizeVatId, isValidVatId } from "../../lib/vatId";
// Plain float arithmetic on money (quantity × unitPrice summed across
// lines, a percent discount, subtracting/adding those together) drifts
@@ -30,6 +31,8 @@ type CheckoutBody = {
lastName: string;
email: string;
password?: string;
companyName?: string;
vatId?: string;
deliveryMethod: "address" | "packstation";
street?: string;
packstationNumber?: string;
@@ -85,6 +88,15 @@ export async function POST(request: Request) {
if (body.deliveryMethod === "packstation" && (!body.packstationNumber || !body.postNumber)) {
return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer angeben." }, { status: 400 });
}
// Optional — only format-checked when actually provided, same "never
// trust the client" reasoning as every other checkout field re-validated
// here. Normalized the same way Orders.ts's own field does (uppercase +
// trim), so the snapshot on the order matches what would've been
// accepted directly through the Payload admin.
const normalizedVatId = body.vatId ? normalizeVatId(body.vatId) : undefined;
if (normalizedVatId && !isValidVatId(normalizedVatId)) {
return NextResponse.json({ ok: false, reason: "Ungültiges USt-IdNr.-Format (z. B. DE123456789)." }, { status: 400 });
}
if (body.hasDifferentShippingAddress) {
if (!body.shippingFirstName || !body.shippingLastName || !body.shippingZip || !body.shippingCity || !body.shippingCountry) {
return NextResponse.json({ ok: false, reason: "Bitte alle Felder der Lieferadresse ausfüllen." }, { status: 400 });
@@ -201,6 +213,8 @@ export async function POST(request: Request) {
customerFirstName: body.firstName,
customerLastName: body.lastName,
customerEmail: body.email,
companyName: body.companyName || undefined,
vatId: normalizedVatId,
deliveryMethod: body.deliveryMethod,
street: body.street,
packstationNumber: body.packstationNumber,
@@ -102,6 +102,13 @@ export function CheckoutContent({
const [firstName, setFirstName] = useState(savedProfile?.firstName ?? "");
const [lastName, setLastName] = useState(savedProfile?.lastName ?? "");
const [email, setEmail] = useState(savedProfile?.email ?? customerEmail ?? "");
// Optional B2B fields — sit right next to Rechnungsadresse (not a
// separately gated "order as a business" toggle) since each is
// independently optional (see Customers.ts/Orders.ts's own comment on
// why neither implies the other). Prefilled from the saved profile, same
// as every other Card 1 field.
const [companyName, setCompanyName] = useState(savedProfile?.companyName ?? "");
const [vatId, setVatId] = useState(savedProfile?.vatId ?? "");
// Always a plain street address — a Packstation isn't a valid Rechnungs-
// adresse (an invoice needs a real postal address). Packstation is only
// ever offered on the separate, optional shipping-address override below.
@@ -143,6 +150,8 @@ export function CheckoutContent({
if (draft.firstName) setFirstName(draft.firstName);
if (draft.lastName) setLastName(draft.lastName);
if (draft.email) setEmail(draft.email);
if (draft.companyName) setCompanyName(draft.companyName);
if (draft.vatId) setVatId(draft.vatId);
if (draft.street) setStreet(draft.street);
if (draft.zip) setZip(draft.zip);
if (draft.city) setCity(draft.city);
@@ -171,6 +180,8 @@ export function CheckoutContent({
firstName,
lastName,
email,
companyName,
vatId,
street,
zip,
city,
@@ -194,6 +205,8 @@ export function CheckoutContent({
firstName,
lastName,
email,
companyName,
vatId,
street,
zip,
city,
@@ -317,6 +330,8 @@ export function CheckoutContent({
firstName,
lastName,
email,
companyName: companyName || undefined,
vatId: vatId || undefined,
// Deliberately still read from FormData, not state — password is the
// one address-card field that stays uncontrolled/unpersisted (see
// lib/checkoutDraft.ts's own comment on why).
@@ -516,6 +531,32 @@ export function CheckoutContent({
<FormField label="Vorname" name="firstName" type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} placeholder="Max" autoComplete="given-name" required />
<FormField label="Nachname" name="lastName" type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} placeholder="Mustermann" autoComplete="family-name" required />
</div>
{/* Optional B2B fields — both independently optional (see
Orders.ts's own comment: a sole proprietor might give a VAT
ID with no separate "company name", and vice versa), so
neither is required just because the other is filled in. */}
<div className="flex flex-col sm:flex-row gap-4 w-full">
<FormField
label="Firma (optional)"
name="companyName"
type="text"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
placeholder="Muster GmbH"
autoComplete="organization"
/>
<FormField
label="USt-IdNr. (optional)"
name="vatId"
type="text"
value={vatId}
onChange={(e) => setVatId(e.target.value)}
placeholder="DE123456789"
autoComplete="off"
pattern="[A-Za-z]{2}[A-Za-z0-9]{2,12}"
title="EU-Format: 2 Buchstaben Länderpräfix + bis zu 12 alphanumerische Zeichen, z. B. DE123456789."
/>
</div>
{/* w-[calc(50%-0.5rem)] at sm: — exactly matches Vorname's
actual rendered width in the 2-col row above (each half of
a gap-4 flex row), instead of stretching full-width. */}
@@ -45,6 +45,8 @@ export function ProfileForm({ profile }: { profile: CustomerProfile }) {
zip: String(form.get("zip") ?? ""),
city: String(form.get("city") ?? ""),
country: String(form.get("country") ?? ""),
companyName: String(form.get("companyName") ?? "") || undefined,
vatId: String(form.get("vatId") ?? "") || undefined,
};
try {
@@ -83,6 +85,22 @@ export function ProfileForm({ profile }: { profile: CustomerProfile }) {
<Field label="Nachname" name="lastName" type="text" defaultValue={profile.lastName} required />
</div>
{/* Optional B2B fields — prefills /checkout's own Firma/USt-IdNr.
fields, same "profile default, order keeps its own snapshot"
split as the address fields below (see Customers.ts). */}
<div className="flex flex-col sm:flex-row gap-4 w-full">
<Field label="Firma (optional)" name="companyName" type="text" defaultValue={profile.companyName ?? ""} />
<Field
label="USt-IdNr. (optional)"
name="vatId"
type="text"
defaultValue={profile.vatId ?? ""}
placeholder="DE123456789"
pattern="[A-Za-z]{2}[A-Za-z0-9]{2,12}"
title="EU-Format: 2 Buchstaben Länderpräfix + bis zu 12 alphanumerische Zeichen, z. B. DE123456789."
/>
</div>
<div className="w-full flex flex-col gap-2 items-start">
<span className="text-label text-text-muted">Lieferart</span>
<div className="flex w-full max-w-sm rounded-sm border border-border overflow-hidden">
+5
View File
@@ -14,6 +14,11 @@ export type CheckoutDraft = {
firstName: string;
lastName: string;
email: string;
// Optional B2B fields — see CheckoutContent.tsx's own comment on why
// they sit here (right next to the Rechnungsadresse fields, not a
// separate persisted concept).
companyName: string;
vatId: string;
// Rechnungsadresse is always a plain street address now — no
// deliveryMethod/packstationNumber/postNumber here, only on the
// shipping* override fields below (see CheckoutContent.tsx).
+10
View File
@@ -199,6 +199,10 @@ export type CustomerAddress = {
zip: string | null;
city: string | null;
country: string | null;
// Optional B2B profile default — see Customers.ts's own comment. Prefills
// /checkout's Firma/USt-IdNr. fields for a returning customer.
companyName: string | null;
vatId: string | null;
};
export type CustomerProfile = CustomerSummary & CustomerAddress;
@@ -217,6 +221,8 @@ type PayloadCustomerMe = {
zip: string | null;
city: string | null;
country: string | null;
companyName: string | null;
vatId: string | null;
cart: { product: number; productSlug: string; quantity: number; variantName: string | null }[] | null;
};
@@ -243,6 +249,8 @@ export async function getCustomerProfile(token: string): Promise<CustomerProfile
zip: u.zip,
city: u.city,
country: u.country,
companyName: u.companyName,
vatId: u.vatId,
};
}
@@ -259,6 +267,8 @@ export async function updateCustomerProfile(
zip: string;
city: string;
country: string;
companyName?: string;
vatId?: string;
},
): Promise<{ ok: true } | { ok: false; reason: string }> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/${customerId}`, {
+6
View File
@@ -34,6 +34,10 @@ export type CreateOrderInput = {
customerFirstName: string;
customerLastName: string;
customerEmail: string;
// Optional B2B snapshot fields — see Orders.ts's own comment on why both
// are independently optional.
companyName?: string;
vatId?: string;
deliveryMethod: "address" | "packstation";
street?: string;
packstationNumber?: string;
@@ -87,6 +91,8 @@ export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder
customerFirstName: input.customerFirstName,
customerLastName: input.customerLastName,
customerEmail: input.customerEmail,
companyName: input.companyName,
vatId: input.vatId,
deliveryMethod: input.deliveryMethod,
street: input.street,
packstationNumber: input.packstationNumber,
+15
View File
@@ -0,0 +1,15 @@
// Mirrors the backend's own USt-IdNr. validation exactly (Orders.ts/
// Customers.ts/CompanySettings.ts in the Payload repo) — kept as a plain
// client+server-safe helper here since this repo's frontend needs the same
// check twice (checkout's instant client-side pattern + api/checkout's own
// server-side re-validation, same "never trust the client" reasoning as
// every other checkout field).
const VAT_ID_PATTERN = /^[A-Z]{2}[A-Z0-9]{2,12}$/;
export function normalizeVatId(value: string): string {
return value.toUpperCase().trim();
}
export function isValidVatId(value: string): boolean {
return VAT_ID_PATTERN.test(value);
}