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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user