Add innergemeinschaftliche-Lieferung VAT exemption for cross-border B2B

A validated EU business buyer (Österreich, the one cross-border option
this checkout offers) gets the sale zero-rated per §4 Nr. 1b UStG —
but only after a live VIES lookup confirms the VAT ID is actually
registered right now, never from format-validity alone (real
compliance risk otherwise). VIES unreachable fails closed: normal VAT
applies, no guessed exemption.

- lib/vies.ts: calls the EU's public VIES REST API.
- lib/vatExemption.ts: de-grosses item/shipping prices and computes
  the exempt totals; also picks the actual destination country
  (shipping override when set, billing otherwise).
- api/checkout/validate-vat: on-blur live check for instant feedback;
  api/checkout/route.ts re-runs the same check server-side at submit
  as the actual source of truth, and re-prices every line net-of-VAT
  when exempt.
- CheckoutContent.tsx: VIES status + a live exempt-totals preview;
  BestellbestaetigungContent.tsx mirrors it from the persisted
  snapshot. Both blur-validate every other checkout field now too
  (immediate inline errors, not just on submit).
- vatExempt/vatIdValidatedAt threaded through orderServer.ts,
  customerAuth.ts, orderEmail.ts, and both invoice-download routes so
  the invoice PDF and its e-invoice XML (companion payload-repo
  commit) reflect the exemption correctly wherever it's rendered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-23 19:10:01 +00:00
parent e48107470a
commit 6802636d1d
13 changed files with 488 additions and 33 deletions
+235 -20
View File
@@ -17,6 +17,8 @@ import { CheckoutSteps } from "../../components/CheckoutSteps";
import { ORDER_KEY, type OrderSnapshot } from "../../lib/order";
import { dispatchAuthChanged } from "../../lib/auth";
import { readCheckoutDraft, writeCheckoutDraft, clearCheckoutDraft } from "../../lib/checkoutDraft";
import { normalizeVatId, isValidVatId } from "../../lib/vatId";
import { computeExemptTotals, destinationCountry, isExemptionEligibleCountry } from "../../lib/vatExemption";
import type { ShippingMethod, PaymentMethod, TrustBadge, ShippingSettings } from "../../lib/payload";
import type { CustomerProfile } from "../../lib/customerAuth";
@@ -33,18 +35,60 @@ function plzPattern(country: string): string {
return `\\d{${digits}}`;
}
// Same rules as the pattern/required attributes each field already
// carries (and what Orders.ts/Customers.ts re-enforce server-side) — this
// is the plausibility check surfaced immediately on blur, not a second
// source of truth. Returns "" for valid, an error message otherwise.
function validateRequired(label: string, value: string): string {
return value.trim() ? "" : `${label} ist erforderlich.`;
}
function validateEmailFormat(value: string): string {
if (!value.trim()) return "E-Mail-Adresse ist erforderlich.";
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? "" : "Bitte eine gültige E-Mail-Adresse angeben.";
}
function validateZip(value: string, country: string): string {
if (!value.trim()) return "PLZ ist erforderlich.";
const digits = PLZ_DIGITS[country] ?? 4;
return new RegExp(`^\\d{${digits}}$`).test(value) ? "" : `PLZ muss aus ${digits} Ziffern bestehen.`;
}
function validatePackstationNumber(value: string): string {
if (!value.trim()) return "Packstationsnummer ist erforderlich.";
return /^\d{1,3}$/.test(value) ? "" : "Packstationsnummer muss aus 1 bis 3 Ziffern bestehen.";
}
function validatePostNumber(value: string): string {
if (!value.trim()) return "Postnummer ist erforderlich.";
return /^\d{6,10}$/.test(value) ? "" : "Postnummer muss aus 6 bis 10 Ziffern bestehen.";
}
// Optional field — "" (valid) whenever empty, only format-checked once
// something's actually been typed, same "never required by the other"
// reasoning as the checkout body's own companyName/vatId handling.
function validateVatIdFormat(value: string): string {
if (!value.trim()) return "";
return isValidVatId(normalizeVatId(value)) ? "" : "Ungültiges USt-IdNr.-Format (z. B. DE123456789).";
}
function FormField({
label,
wrapperClassName = "flex-1 min-w-0",
error,
...props
}: { label: string; wrapperClassName?: string } & React.InputHTMLAttributes<HTMLInputElement>) {
}: { label: string; wrapperClassName?: string; error?: string } & React.InputHTMLAttributes<HTMLInputElement>) {
return (
<label className={`flex flex-col gap-2 items-start ${wrapperClassName}`}>
<span className="text-label text-text-muted">{label}</span>
<input
{...props}
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
aria-invalid={error ? true : undefined}
className={`w-full border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors ${
error ? "border-red-600" : "border-border"
}`}
/>
{error && <span className="text-label text-red-600">{error}</span>}
</label>
);
}
@@ -91,6 +135,25 @@ export function CheckoutContent({
const [loginPassword, setLoginPassword] = useState("");
const [loginError, setLoginError] = useState<string | null>(null);
const [loggingIn, setLoggingIn] = useState(false);
// Per-field inline validation, populated on blur (see each field's own
// onBlur below) — surfaces the same plausibility checks the pattern/
// required attributes already declare, immediately instead of only at
// submit time (native browser validation still applies too, as a
// fallback for fields somehow never blurred, e.g. autofill).
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
function setFieldError(name: string, message: string) {
setFieldErrors((prev) => {
if (!message) {
if (!(name in prev)) return prev;
const next = { ...prev };
delete next[name];
return next;
}
if (prev[name] === message) return prev;
return { ...prev, [name]: message };
});
}
// Address-card fields — controlled (unlike before) so they can be
// persisted via lib/checkoutDraft.ts and restored after navigating away
@@ -130,6 +193,14 @@ export function CheckoutContent({
const [shippingCity, setShippingCity] = useState("");
const [shippingCountry, setShippingCountry] = useState("Deutschland");
const [newsletterOptIn, setNewsletterOptIn] = useState(false);
// Live VIES status for the USt-IdNr. field — only meaningful once the
// goods' destination (shipping override country when set, billing
// country otherwise) is Österreich, the one EU-cross-border option this
// checkout offers (see lib/vatExemption.ts). "valid" is what actually
// drives the exempt-totals preview below; api/checkout/route.ts re-runs
// this exact same VIES check server-side at submit time regardless —
// this state is a preview, never the source of truth.
const [vatIdViesStatus, setVatIdViesStatus] = useState<"idle" | "checking" | "valid" | "invalid" | "unavailable">("idle");
// Flips true only after the hydration effect's setState calls have
// actually landed in a render — gates the write-back effect below so it
// never fires with the pre-hydration defaults first and briefly
@@ -250,6 +321,59 @@ export function CheckoutContent({
shipping,
);
// Live preview only — api/checkout/route.ts re-runs the same VIES check
// server-side at submit time and is the actual source of truth (see
// lib/vatExemption.ts). Destination is the shipping override's country
// when set, the billing country otherwise — the exemption depends on
// where the goods actually move to, not necessarily the invoice address.
const buyerDestinationCountry = destinationCountry(country, hasDifferentShippingAddress, shippingCountry);
const vatExemptPreview = vatIdViesStatus === "valid" && isExemptionEligibleCountry(buyerDestinationCountry);
const exemptTotalsPreview = vatExemptPreview
? computeExemptTotals(
items.map(({ entry, product }) => ({
quantity: entry.qty,
grossUnitPrice: effectivePrice(entry, product),
taxRatePercent: effectiveTaxRate(product, defaultTaxRate),
})),
shipping,
defaultTaxRate,
discountAmount,
)
: null;
const displaySubtotal = exemptTotalsPreview?.subtotal ?? subtotal;
const displayShipping = exemptTotalsPreview?.shippingCost ?? shipping;
const displayTotal = exemptTotalsPreview?.total ?? total;
// USt-IdNr. blur — format-checks first (always), then a live VIES lookup
// only once the destination actually qualifies (Österreich) — no point
// hitting the EU's API for a Deutschland/Schweiz order, where this
// exemption never applies regardless of what VIES says.
async function handleVatIdBlur(e: React.FocusEvent<HTMLInputElement>) {
const value = e.target.value;
const formatError = validateVatIdFormat(value);
setFieldError("vatId", formatError);
if (!value.trim() || formatError || !isExemptionEligibleCountry(destinationCountry(country, hasDifferentShippingAddress, shippingCountry))) {
setVatIdViesStatus("idle");
return;
}
setVatIdViesStatus("checking");
try {
const res = await fetch("/api/checkout/validate-vat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ vatId: value }),
});
const data = await res.json();
if (!data.ok) {
setVatIdViesStatus("unavailable");
return;
}
setVatIdViesStatus(data.valid ? "valid" : "invalid");
} catch {
setVatIdViesStatus("unavailable");
}
}
// Logs into an existing account inline, without leaving /checkout —
// router.refresh() re-runs the page's Server Component, which re-reads
// the now-set session cookie and passes the resolved customerEmail back
@@ -293,6 +417,7 @@ export function CheckoutContent({
// in case", only once actually relevant.
async function handleEmailBlur(e: React.FocusEvent<HTMLInputElement>) {
const email = e.target.value.trim();
setFieldError("email", validateEmailFormat(email));
if (!email || customerEmail || showLogin) return;
try {
const res = await fetch("/api/account/check-email", {
@@ -387,6 +512,7 @@ export function CheckoutContent({
paymentMethodTitle: data.paymentMethodTitle,
discountCode: data.discountCode,
discountAmount: data.discountAmount,
vatExempt: Boolean(data.vatExempt),
};
try {
window.sessionStorage.setItem(ORDER_KEY, JSON.stringify(snapshot));
@@ -528,8 +654,30 @@ export function CheckoutContent({
1. Rechnungsadresse
</p>
<div className="flex flex-col sm:flex-row gap-4 w-full">
<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 />
<FormField
label="Vorname"
name="firstName"
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
onBlur={(e) => setFieldError("firstName", validateRequired("Vorname", e.target.value))}
error={fieldErrors.firstName}
placeholder="Max"
autoComplete="given-name"
required
/>
<FormField
label="Nachname"
name="lastName"
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
onBlur={(e) => setFieldError("lastName", validateRequired("Nachname", e.target.value))}
error={fieldErrors.lastName}
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
@@ -545,17 +693,39 @@ export function CheckoutContent({
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 className="flex-1 min-w-0 flex flex-col gap-1">
<FormField
label="USt-IdNr. (optional)"
name="vatId"
type="text"
value={vatId}
onChange={(e) => {
setVatId(e.target.value);
setVatIdViesStatus("idle");
}}
onBlur={handleVatIdBlur}
error={fieldErrors.vatId}
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."
wrapperClassName="w-full"
/>
{/* Only shown once the destination actually qualifies
(Österreich) — a "checking..."/status message for a
Deutschland/Schweiz order would be meaningless noise,
the exemption never applies there regardless. */}
{isExemptionEligibleCountry(buyerDestinationCountry) && (
<p className="text-label text-text-muted">
{vatIdViesStatus === "checking" && "USt-IdNr. wird geprüft…"}
{vatIdViesStatus === "valid" && (
<span className="text-success"> Bestätigt Lieferung wird steuerfrei berechnet.</span>
)}
{vatIdViesStatus === "invalid" && "USt-IdNr. konnte nicht bestätigt werden — reguläre MwSt. wird berechnet."}
{vatIdViesStatus === "unavailable" && "Prüfung derzeit nicht möglich — reguläre MwSt. wird berechnet."}
</p>
)}
</div>
</div>
{/* w-[calc(50%-0.5rem)] at sm: — exactly matches Vorname's
actual rendered width in the 2-col row above (each half of
@@ -570,6 +740,7 @@ export function CheckoutContent({
autoComplete="email"
required
onBlur={handleEmailBlur}
error={fieldErrors.email}
wrapperClassName="w-full sm:w-[calc(50%-0.5rem)] sm:flex-none min-w-0"
/>
{/* Only needed for the inline-registration path — an existing
@@ -584,6 +755,17 @@ export function CheckoutContent({
autoComplete="new-password"
required
minLength={8}
onBlur={(e) =>
setFieldError(
"password",
!e.target.value
? "Passwort ist erforderlich."
: e.target.value.length < 8
? "Passwort muss mindestens 8 Zeichen lang sein."
: "",
)
}
error={fieldErrors.password}
wrapperClassName="w-full"
/>
<p className="text-label text-text-muted">
@@ -602,6 +784,8 @@ export function CheckoutContent({
type="text"
value={street}
onChange={(e) => setStreet(e.target.value)}
onBlur={(e) => setFieldError("street", validateRequired("Straße und Hausnummer", e.target.value))}
error={fieldErrors.street}
placeholder="Musterstraße 1"
autoComplete="street-address"
required
@@ -614,6 +798,8 @@ export function CheckoutContent({
type="text"
value={zip}
onChange={(e) => setZip(e.target.value)}
onBlur={(e) => setFieldError("zip", validateZip(e.target.value, country))}
error={fieldErrors.zip}
placeholder="10115"
autoComplete="postal-code"
inputMode="numeric"
@@ -621,7 +807,18 @@ export function CheckoutContent({
title={`PLZ muss aus ${PLZ_DIGITS[country] ?? 4} Ziffern bestehen.`}
required
/>
<FormField label="Ort" name="city" type="text" value={city} onChange={(e) => setCity(e.target.value)} placeholder="Berlin" autoComplete="address-level2" required />
<FormField
label="Ort"
name="city"
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
onBlur={(e) => setFieldError("city", validateRequired("Ort", e.target.value))}
error={fieldErrors.city}
placeholder="Berlin"
autoComplete="address-level2"
required
/>
</div>
<label className="flex flex-col gap-2 items-start w-full">
<span className="text-label text-text-muted">Land</span>
@@ -659,6 +856,8 @@ export function CheckoutContent({
type="text"
value={shippingFirstName}
onChange={(e) => setShippingFirstName(e.target.value)}
onBlur={(e) => setFieldError("shippingFirstName", validateRequired("Vorname", e.target.value))}
error={fieldErrors.shippingFirstName}
placeholder="Max"
autoComplete="off"
required
@@ -668,6 +867,8 @@ export function CheckoutContent({
type="text"
value={shippingLastName}
onChange={(e) => setShippingLastName(e.target.value)}
onBlur={(e) => setFieldError("shippingLastName", validateRequired("Nachname", e.target.value))}
error={fieldErrors.shippingLastName}
placeholder="Mustermann"
autoComplete="off"
required
@@ -708,6 +909,8 @@ export function CheckoutContent({
type="text"
value={shippingStreet}
onChange={(e) => setShippingStreet(e.target.value)}
onBlur={(e) => setFieldError("shippingStreet", validateRequired("Straße und Hausnummer", e.target.value))}
error={fieldErrors.shippingStreet}
placeholder="Musterstraße 1"
autoComplete="off"
required
@@ -720,6 +923,8 @@ export function CheckoutContent({
type="text"
value={shippingPackstationNumber}
onChange={(e) => setShippingPackstationNumber(e.target.value)}
onBlur={(e) => setFieldError("shippingPackstationNumber", validatePackstationNumber(e.target.value))}
error={fieldErrors.shippingPackstationNumber}
inputMode="numeric"
placeholder="123"
autoComplete="off"
@@ -733,6 +938,8 @@ export function CheckoutContent({
type="text"
value={shippingPostNumber}
onChange={(e) => setShippingPostNumber(e.target.value)}
onBlur={(e) => setFieldError("shippingPostNumber", validatePostNumber(e.target.value))}
error={fieldErrors.shippingPostNumber}
inputMode="numeric"
placeholder="1234567890"
autoComplete="off"
@@ -749,6 +956,8 @@ export function CheckoutContent({
type="text"
value={shippingZip}
onChange={(e) => setShippingZip(e.target.value)}
onBlur={(e) => setFieldError("shippingZip", validateZip(e.target.value, shippingCountry))}
error={fieldErrors.shippingZip}
placeholder="10115"
autoComplete="off"
inputMode="numeric"
@@ -761,6 +970,8 @@ export function CheckoutContent({
type="text"
value={shippingCity}
onChange={(e) => setShippingCity(e.target.value)}
onBlur={(e) => setFieldError("shippingCity", validateRequired("Ort", e.target.value))}
error={fieldErrors.shippingCity}
placeholder="Berlin"
autoComplete="off"
required
@@ -927,7 +1138,7 @@ export function CheckoutContent({
<div className="flex items-center w-full">
<span className="text-body-sm text-text-primary">Zwischensumme</span>
<span className="flex-1" />
<span className="text-body-sm text-text-primary">{formatPrice(subtotal)}</span>
<span className="text-body-sm text-text-primary">{formatPrice(displaySubtotal)}</span>
</div>
{totalSavings > 0 && (
@@ -963,7 +1174,7 @@ export function CheckoutContent({
</span>
<span className="flex-1" />
<span className="text-body-sm text-text-primary">
{shipping === 0 ? "Kostenlos" : formatPrice(shipping)}
{displayShipping === 0 ? "Kostenlos" : formatPrice(displayShipping)}
</span>
</div>
<p className="text-label text-text-muted">
@@ -987,9 +1198,13 @@ export function CheckoutContent({
Gesamtsumme
</span>
<span className="flex-1" />
<span className="font-bold text-h-small text-text-primary">{formatPrice(total)}</span>
<span className="font-bold text-h-small text-text-primary">{formatPrice(displayTotal)}</span>
</div>
<VatBreakdown groups={taxBreakdown} />
{vatExemptPreview ? (
<p className="text-label text-text-muted">Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)</p>
) : (
<VatBreakdown groups={taxBreakdown} />
)}
</div>
</div>