From 4d2e78dd2a9691f201296dc2fd1cee57b8f3533b Mon Sep 17 00:00:00 2001 From: Marco Date: Thu, 23 Jul 2026 19:37:29 +0000 Subject: [PATCH] =?UTF-8?q?Check=20VAT=20ID=20validity=20via=20VIES=20for?= =?UTF-8?q?=20any=20country,=20not=20just=20=C3=96sterreich?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VAT-ID validity and the exemption decision are separate questions. Previously VIES was only ever called when the destination already qualified for the cross-border exemption (Österreich), so a garbage VAT ID on a domestic order (e.g. "ED123456789" — not even a real country code) sailed through with no feedback at all, and a Deutschland/Schweiz customer got no confirmation their real VAT ID was valid either. Now VIES checks any format-valid VAT ID regardless of destination (data quality, same reasoning as company-settings' own check) — the exemption itself still only applies when the destination is also Österreich, a validated German VAT ID never zero-rates a domestic sale. The status message now always shows ("✓ USt-IdNr. bestätigt", plus the exemption note only when it actually applies) instead of staying hidden for non-Österreich orders. Also added maxLength to PLZ (per-country digit count) and USt-IdNr. (14) checkout fields — they had pattern validation but nothing stopping the browser from accepting more characters than could ever be valid. Co-Authored-By: Claude Sonnet 5 --- app/api/checkout/route.ts | 29 ++++++++----- app/checkout/components/CheckoutContent.tsx | 48 +++++++++++++-------- 2 files changed, 49 insertions(+), 28 deletions(-) diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts index 7d752e0..ee61c02 100644 --- a/app/api/checkout/route.ts +++ b/app/api/checkout/route.ts @@ -209,22 +209,31 @@ export async function POST(request: Request) { ); } - // Innergemeinschaftliche Lieferung (§4 Nr. 1b UStG) — only for the goods' - // actual destination (the shipping override's country when set, the - // billing country otherwise) being Österreich, the one EU-cross-border - // option this checkout offers, AND a VAT ID that VIES itself confirms is - // currently registered right now, at the moment of purchase — a merely - // format-valid id is never enough (see lib/vatExemption.ts's own - // comment). VIES being unreachable fails closed: normal VAT applies, - // never a guessed exemption. + // VAT-ID validity and the exemption decision are two separate questions. + // Validity (is this actually a currently-registered VAT ID at all) is + // checked via VIES for ANY country whenever one is given — worth + // recording regardless of destination, same "data quality" reasoning as + // company-settings.vatId's own VIES check on the backend; a merely + // format-valid id (e.g. "ED123456789" — "ED" isn't even a real country + // code) is never enough on its own. The exemption itself + // (innergemeinschaftliche Lieferung, §4 Nr. 1b UStG) additionally + // requires the goods' actual destination (the shipping override's + // country when set, the billing country otherwise) to be Österreich, + // the one EU-cross-border option this checkout offers — a validated + // *German* VAT ID never zero-rates a domestic sale, no matter how real + // it is. VIES being unreachable fails closed on the exemption: normal + // VAT applies, never a guessed exemption (vatIdValidatedAt just stays + // unset in that case too). let vatExempt = false; let vatIdValidatedAt: string | null = null; const buyerDestinationCountry = destinationCountry(body.country, Boolean(body.hasDifferentShippingAddress), body.shippingCountry); - if (normalizedVatId && isExemptionEligibleCountry(buyerDestinationCountry)) { + if (normalizedVatId) { const viesResult = await checkVatIdViaVies(normalizedVatId); if (viesResult.ok && viesResult.valid) { - vatExempt = true; vatIdValidatedAt = new Date().toISOString(); + if (isExemptionEligibleCountry(buyerDestinationCountry)) { + vatExempt = true; + } } } diff --git a/app/checkout/components/CheckoutContent.tsx b/app/checkout/components/CheckoutContent.tsx index bdaac0c..4121cbf 100644 --- a/app/checkout/components/CheckoutContent.tsx +++ b/app/checkout/components/CheckoutContent.tsx @@ -345,14 +345,19 @@ export function CheckoutContent({ 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. + // for ANY country, not just Österreich. Two genuinely separate concerns: + // whether this is a real, currently-registered VAT ID at all (data + // quality — worth knowing regardless of destination, same reasoning as + // company-settings.vatId's own VIES check on the backend) vs. whether + // *this transaction* qualifies for the cross-border exemption (a + // narrower legal question, still gated on isExemptionEligibleCountry() + // wherever vatExemptPreview/exemptTotalsPreview are computed below — a + // validated German VAT ID never zero-rates a domestic sale). async function handleVatIdBlur(e: React.FocusEvent) { const value = e.target.value; const formatError = validateVatIdFormat(value); setFieldError("vatId", formatError); - if (!value.trim() || formatError || !isExemptionEligibleCountry(destinationCountry(country, hasDifferentShippingAddress, shippingCountry))) { + if (!value.trim() || formatError) { setVatIdViesStatus("idle"); return; } @@ -708,23 +713,28 @@ export function CheckoutContent({ placeholder="DE123456789" autoComplete="off" pattern="[A-Za-z]{2}[A-Za-z0-9]{2,12}" + maxLength={14} 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) && ( -

- {vatIdViesStatus === "checking" && "USt-IdNr. wird geprüft…"} - {vatIdViesStatus === "valid" && ( - ✓ Bestätigt — Lieferung wird steuerfrei berechnet. - )} - {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."} -

- )} + {/* Shown for any country — validity is worth confirming + regardless of destination (data quality: is this VAT ID + even real). Only the "valid" message's wording differs + by destination: Österreich additionally gets the + exemption note, Deutschland/Schweiz just get a plain + confirmation, since the exemption never applies there + even for a genuinely valid VAT ID. */} +

+ {vatIdViesStatus === "checking" && "USt-IdNr. wird geprüft…"} + {vatIdViesStatus === "valid" && ( + + ✓ USt-IdNr. bestätigt + {isExemptionEligibleCountry(buyerDestinationCountry) ? " — Lieferung wird steuerfrei berechnet." : "."} + + )} + {vatIdViesStatus === "invalid" && "USt-IdNr. konnte nicht bestätigt werden."} + {vatIdViesStatus === "unavailable" && "USt-IdNr.-Prüfung derzeit nicht möglich."} +

{/* w-[calc(50%-0.5rem)] at sm: — exactly matches Vorname's @@ -804,6 +814,7 @@ export function CheckoutContent({ autoComplete="postal-code" inputMode="numeric" pattern={plzPattern(country)} + maxLength={PLZ_DIGITS[country] ?? 4} title={`PLZ muss aus ${PLZ_DIGITS[country] ?? 4} Ziffern bestehen.`} required /> @@ -962,6 +973,7 @@ export function CheckoutContent({ autoComplete="off" inputMode="numeric" pattern={plzPattern(shippingCountry)} + maxLength={PLZ_DIGITS[shippingCountry] ?? 4} title={`PLZ muss aus ${PLZ_DIGITS[shippingCountry] ?? 4} Ziffern bestehen.`} required />