From 6802636d1d7964ba8173a4a483e70a50ca656d12 Mon Sep 17 00:00:00 2001 From: Marco Date: Thu, 23 Jul 2026 19:10:01 +0000 Subject: [PATCH] Add innergemeinschaftliche-Lieferung VAT exemption for cross-border B2B MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../[orderNumber]/correction-invoice/route.ts | 3 + .../orders/[orderNumber]/invoice/route.ts | 3 + app/api/checkout/route.ts | 67 ++++- app/api/checkout/validate-vat/route.ts | 30 +++ .../components/BestellbestaetigungContent.tsx | 40 ++- app/checkout/components/CheckoutContent.tsx | 255 ++++++++++++++++-- app/lib/customerAuth.ts | 4 + app/lib/order.ts | 5 + app/lib/orderEmail.ts | 6 + app/lib/orderServer.ts | 6 + app/lib/vatExemption.ts | 54 ++++ app/lib/vies.ts | 46 ++++ package-lock.json | 2 +- 13 files changed, 488 insertions(+), 33 deletions(-) create mode 100644 app/api/checkout/validate-vat/route.ts create mode 100644 app/lib/vatExemption.ts create mode 100644 app/lib/vies.ts diff --git a/app/api/account/orders/[orderNumber]/correction-invoice/route.ts b/app/api/account/orders/[orderNumber]/correction-invoice/route.ts index e8d4351..7012494 100644 --- a/app/api/account/orders/[orderNumber]/correction-invoice/route.ts +++ b/app/api/account/orders/[orderNumber]/correction-invoice/route.ts @@ -34,6 +34,9 @@ export async function GET(request: Request, { params }: { params: Promise<{ orde correctionInvoiceIssuedAt: order.correctionInvoiceIssuedAt, customerFirstName: order.customerFirstName, customerLastName: order.customerLastName, + companyName: order.companyName, + vatId: order.vatId, + vatExempt: order.vatExempt, deliveryMethod: order.deliveryMethod, street: order.street, packstationNumber: order.packstationNumber, diff --git a/app/api/account/orders/[orderNumber]/invoice/route.ts b/app/api/account/orders/[orderNumber]/invoice/route.ts index 4955948..eb22bde 100644 --- a/app/api/account/orders/[orderNumber]/invoice/route.ts +++ b/app/api/account/orders/[orderNumber]/invoice/route.ts @@ -31,6 +31,9 @@ export async function GET(request: Request, { params }: { params: Promise<{ orde invoiceIssuedAt: order.invoiceIssuedAt, customerFirstName: order.customerFirstName, customerLastName: order.customerLastName, + companyName: order.companyName, + vatId: order.vatId, + vatExempt: order.vatExempt, deliveryMethod: order.deliveryMethod, street: order.street, packstationNumber: order.packstationNumber, diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts index f03708d..7d752e0 100644 --- a/app/api/checkout/route.ts +++ b/app/api/checkout/route.ts @@ -9,6 +9,8 @@ import { describeBundleContents } from "../../lib/bundleContents"; import { sendCriticalAlert } from "../../lib/alertAdmin"; import { sendOrderConfirmationEmail } from "../../lib/orderEmail"; import { normalizeVatId, isValidVatId } from "../../lib/vatId"; +import { checkVatIdViaVies } from "../../lib/vies"; +import { computeExemptTotals, destinationCountry, isExemptionEligibleCountry } from "../../lib/vatExemption"; // Plain float arithmetic on money (quantity × unitPrice summed across // lines, a percent discount, subtracting/adding those together) drifts @@ -206,7 +208,54 @@ export async function POST(request: Request) { validation.doc.type === "percent" ? (subtotal * validation.doc.value) / 100 : Math.min(validation.doc.value, subtotal), ); } - const total = roundMoney(Math.max(0, subtotal - discountAmount) + shippingCost); + + // 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. + let vatExempt = false; + let vatIdValidatedAt: string | null = null; + const buyerDestinationCountry = destinationCountry(body.country, Boolean(body.hasDifferentShippingAddress), body.shippingCountry); + if (normalizedVatId && isExemptionEligibleCountry(buyerDestinationCountry)) { + const viesResult = await checkVatIdViaVies(normalizedVatId); + if (viesResult.ok && viesResult.valid) { + vatExempt = true; + vatIdValidatedAt = new Date().toISOString(); + } + } + + if (vatExempt) { + // Re-price every line net of VAT (0% now applies) instead of the + // catalog's normal VAT-inclusive price — the whole point of the + // exemption is that the buyer pays less, not that this shop quietly + // keeps the VAT portion as extra margin. items/subtotal/shippingCost + // below are overwritten with the de-grossed figures actually charged + // and actually persisted on the order/invoice. + for (const item of items) { + item.unitPrice = roundMoney(item.unitPrice / (1 + item.taxRatePercent / 100)); + item.taxRatePercent = 0; + } + } + const exemptTotals = vatExempt + ? computeExemptTotals( + items.map((i) => ({ quantity: i.quantity, grossUnitPrice: i.unitPrice, taxRatePercent: 0 })), + shippingCost, + defaultTaxRate, + discountAmount, + ) + : null; + // Note: exemptTotals recomputes `subtotal` from the already-degrossed + // `items` above (taxRatePercent 0 there means computeExemptTotals's own + // degross() step is a no-op on them) — it exists mainly to degross + // `shippingCost` the same way, and to keep both figures derived through + // one shared function rather than duplicating the arithmetic here. + const finalSubtotal = exemptTotals?.subtotal ?? subtotal; + const finalShippingCost = exemptTotals?.shippingCost ?? shippingCost; + const total = roundMoney(Math.max(0, finalSubtotal - discountAmount) + finalShippingCost); const order = await createOrder({ customerId: customer.id, @@ -215,6 +264,8 @@ export async function POST(request: Request) { customerEmail: body.email, companyName: body.companyName || undefined, vatId: normalizedVatId, + vatExempt, + vatIdValidatedAt, deliveryMethod: body.deliveryMethod, street: body.street, packstationNumber: body.packstationNumber, @@ -234,8 +285,8 @@ export async function POST(request: Request) { shippingCountry: body.shippingCountry, newsletterOptIn: Boolean(body.newsletterOptIn), items, - subtotal, - shippingCost, + subtotal: finalSubtotal, + shippingCost: finalShippingCost, shippingMethodTitle: shippingMethod.title, paymentMethodTitle: paymentMethod.title, discountCode: body.discountCode || null, @@ -271,6 +322,9 @@ export async function POST(request: Request) { invoiceIssuedAt: order.invoiceIssuedAt, customerFirstName: body.firstName, customerLastName: body.lastName, + companyName: body.companyName || undefined, + vatId: normalizedVatId, + vatExempt, deliveryMethod: body.deliveryMethod, street: body.street, packstationNumber: body.packstationNumber, @@ -298,8 +352,8 @@ export async function POST(request: Request) { bundleContents: i.bundleContents, variantName: i.variantName, })), - subtotal, - shippingCost, + subtotal: finalSubtotal, + shippingCost: finalShippingCost, discountAmount, discountCode: body.discountCode || null, total, @@ -317,9 +371,10 @@ export async function POST(request: Request) { ok: true, orderNumber: order.orderNumber, orderDateIso: order.createdAt, - shippingCost, + shippingCost: finalShippingCost, paymentMethodTitle: paymentMethod.title, discountCode: body.discountCode || null, discountAmount, + vatExempt, }); } diff --git a/app/api/checkout/validate-vat/route.ts b/app/api/checkout/validate-vat/route.ts new file mode 100644 index 0000000..96fdc45 --- /dev/null +++ b/app/api/checkout/validate-vat/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { normalizeVatId, isValidVatId } from "../../../lib/vatId"; +import { checkVatIdViaVies } from "../../../lib/vies"; + +// Called from CheckoutContent.tsx on the USt-IdNr. field's blur, whenever +// the billing country is Österreich — the only cross-border-EU option this +// checkout offers besides Deutschland (domestic, exemption never applies) +// and Schweiz (non-EU export, a different exemption entirely, out of +// scope here). Gives the shopper immediate feedback on whether their VAT +// ID actually qualifies for the innergemeinschaftliche-Lieferung +// exemption, before they even submit — api/checkout/route.ts re-runs this +// exact same check server-side at submit time regardless (never trusts +// this response), since a VIES result could theoretically change between +// blur and submit. +export async function POST(request: Request) { + const body = await request.json().catch(() => null); + const vatId = typeof body?.vatId === "string" ? body.vatId : ""; + if (!vatId) return NextResponse.json({ ok: false, reason: "USt-IdNr. fehlt." }, { status: 400 }); + + const normalized = normalizeVatId(vatId); + if (!isValidVatId(normalized)) { + return NextResponse.json({ ok: true, valid: false, reason: "Ungültiges USt-IdNr.-Format." }); + } + + const result = await checkVatIdViaVies(normalized); + if (!result.ok) { + return NextResponse.json({ ok: true, valid: false, reason: `USt-IdNr.-Prüfung derzeit nicht möglich (${result.reason}).` }); + } + return NextResponse.json({ ok: true, valid: result.valid, name: result.name }); +} diff --git a/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx b/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx index 73a6268..3acc46a 100644 --- a/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx +++ b/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx @@ -7,6 +7,7 @@ import type { CartItem } from "../../lib/cart"; import { useProducts } from "../../lib/products"; import { computeCartTotals, effectivePrice, effectiveTaxRate } from "../../lib/cartTotals"; import { computeTaxBreakdown } from "@einfach-produktiv/invoicing"; +import { computeExemptTotals } from "../../lib/vatExemption"; import { formatPrice, formatDate } from "../../lib/format"; import { Reveal } from "../../components/Reveal"; import { CheckoutSteps } from "../../components/CheckoutSteps"; @@ -31,7 +32,8 @@ function parseOrderSnapshot(raw: string): OrderSnapshot | null { typeof data.shippingCost !== "number" || typeof data.paymentMethodTitle !== "string" || (data.discountCode !== null && typeof data.discountCode !== "string") || - typeof data.discountAmount !== "number" + typeof data.discountAmount !== "number" || + typeof data.vatExempt !== "boolean" ) { return null; } @@ -100,20 +102,42 @@ export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate: .map((entry) => ({ entry, product: products.find((p) => p.id === entry.id) })) .filter((row): row is { entry: CartItem; product: NonNullable<(typeof row)["product"]> } => Boolean(row.product)); - // Displays the *persisted* discount from the snapshot, not a fresh - // re-derivation — the purchase already happened, this page is a + // Displays the *persisted* discount/shippingCost from the snapshot, not + // a fresh re-derivation — the purchase already happened, this page is a // receipt, not a live cart, so it doesn't re-validate the code at all. - const { subtotal, totalSavings, total } = computeCartTotals(items, order.shippingCost, { + // order.shippingCost is already the actual (possibly de-grossed, if + // vatExempt) figure charged at checkout — see api/checkout/route.ts's + // own response. `subtotal`/`taxBreakdown` below still need their own + // exempt branch, though: computeCartTotals/computeTaxBreakdown build + // `subtotal` from each item's *current catalog* gross price via + // effectivePrice(), which for an exempt order was never what was + // actually charged (the catalog price includes VAT; the exempt order + // paid the de-grossed net price instead). + const { subtotal: catalogSubtotal, totalSavings, total: catalogTotal } = computeCartTotals(items, order.shippingCost, { type: "fixed", value: order.discountAmount, }); + const exemptTotals = order.vatExempt + ? computeExemptTotals( + items.map(({ entry, product }) => ({ + quantity: entry.qty, + grossUnitPrice: effectivePrice(entry, product), + taxRatePercent: effectiveTaxRate(product, defaultTaxRate), + })), + order.shippingCost, + defaultTaxRate, + order.discountAmount, + ) + : null; + const subtotal = exemptTotals?.subtotal ?? catalogSubtotal; + const total = exemptTotals?.total ?? catalogTotal; const taxBreakdown = computeTaxBreakdown( items.map(({ entry, product }) => ({ quantity: entry.qty, unitPrice: effectivePrice(entry, product), taxRatePercent: effectiveTaxRate(product, defaultTaxRate), })), - subtotal, + catalogSubtotal, order.discountAmount, order.shippingCost, ); @@ -257,7 +281,11 @@ export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate: {formatPrice(total)} - + {order.vatExempt ? ( +

Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)

+ ) : ( + + )} diff --git a/app/checkout/components/CheckoutContent.tsx b/app/checkout/components/CheckoutContent.tsx index 04b1663..bdaac0c 100644 --- a/app/checkout/components/CheckoutContent.tsx +++ b/app/checkout/components/CheckoutContent.tsx @@ -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) { +}: { label: string; wrapperClassName?: string; error?: string } & React.InputHTMLAttributes) { return ( ); } @@ -91,6 +135,25 @@ export function CheckoutContent({ const [loginPassword, setLoginPassword] = useState(""); const [loginError, setLoginError] = useState(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>({}); + + 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) { + 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) { 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

- setFirstName(e.target.value)} placeholder="Max" autoComplete="given-name" required /> - setLastName(e.target.value)} placeholder="Mustermann" autoComplete="family-name" required /> + setFirstName(e.target.value)} + onBlur={(e) => setFieldError("firstName", validateRequired("Vorname", e.target.value))} + error={fieldErrors.firstName} + placeholder="Max" + autoComplete="given-name" + required + /> + setLastName(e.target.value)} + onBlur={(e) => setFieldError("lastName", validateRequired("Nachname", e.target.value))} + error={fieldErrors.lastName} + placeholder="Mustermann" + autoComplete="family-name" + required + />
{/* 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" /> - 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." - /> +
+ { + 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) && ( +

+ {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."} +

+ )} +
{/* 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" />

@@ -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 /> - setCity(e.target.value)} placeholder="Berlin" autoComplete="address-level2" required /> + setCity(e.target.value)} + onBlur={(e) => setFieldError("city", validateRequired("Ort", e.target.value))} + error={fieldErrors.city} + placeholder="Berlin" + autoComplete="address-level2" + required + />

Zwischensumme - {formatPrice(subtotal)} + {formatPrice(displaySubtotal)}
{totalSavings > 0 && ( @@ -963,7 +1174,7 @@ export function CheckoutContent({
- {shipping === 0 ? "Kostenlos" : formatPrice(shipping)} + {displayShipping === 0 ? "Kostenlos" : formatPrice(displayShipping)}

@@ -987,9 +1198,13 @@ export function CheckoutContent({ Gesamtsumme - {formatPrice(total)} + {formatPrice(displayTotal)} - + {vatExemptPreview ? ( +

Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)

+ ) : ( + + )} diff --git a/app/lib/customerAuth.ts b/app/lib/customerAuth.ts index 7708215..2ba2bd5 100644 --- a/app/lib/customerAuth.ts +++ b/app/lib/customerAuth.ts @@ -452,6 +452,10 @@ export type CustomerOrderDetail = CustomerOrder & { customerFirstName: string; customerLastName: string; customerEmail: string; + companyName: string | null; + vatId: string | null; + vatExempt: boolean; + vatIdValidatedAt: string | null; deliveryMethod: "address" | "packstation"; street: string | null; packstationNumber: string | null; diff --git a/app/lib/order.ts b/app/lib/order.ts index a7e1da2..197292e 100644 --- a/app/lib/order.ts +++ b/app/lib/order.ts @@ -19,4 +19,9 @@ export type OrderSnapshot = { * null/0 when no discount was ever applied. */ discountCode: string | null; discountAmount: number; + /** Decided server-side at checkout (live VIES check, see api/checkout/ + * route.ts) — /bestellbestaetigung needs this to know whether to show + * the exempt (net, de-grossed) totals instead of the normal VAT- + * inclusive catalog prices it would otherwise re-derive live. */ + vatExempt: boolean; }; diff --git a/app/lib/orderEmail.ts b/app/lib/orderEmail.ts index 22a8a1d..6a1e0fc 100644 --- a/app/lib/orderEmail.ts +++ b/app/lib/orderEmail.ts @@ -13,6 +13,9 @@ export type OrderConfirmationEmailData = OrderConfirmationData & { invoiceIssuedAt: string; customerFirstName: string; customerLastName: string; + companyName?: string | null; + vatId?: string | null; + vatExempt?: boolean; deliveryMethod: "address" | "packstation"; street?: string | null; packstationNumber?: string | null; @@ -68,6 +71,9 @@ export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailDa invoiceIssuedAt: order.invoiceIssuedAt, customerFirstName: order.customerFirstName, customerLastName: order.customerLastName, + companyName: order.companyName, + vatId: order.vatId, + vatExempt: order.vatExempt, deliveryMethod: order.deliveryMethod, street: order.street, packstationNumber: order.packstationNumber, diff --git a/app/lib/orderServer.ts b/app/lib/orderServer.ts index 1735e90..c0d8dc1 100644 --- a/app/lib/orderServer.ts +++ b/app/lib/orderServer.ts @@ -38,6 +38,10 @@ export type CreateOrderInput = { // are independently optional. companyName?: string; vatId?: string; + // Decided server-side in api/checkout/route.ts (a live VIES check at the + // moment of purchase, never guessed) — see Orders.ts's own comment. + vatExempt: boolean; + vatIdValidatedAt: string | null; deliveryMethod: "address" | "packstation"; street?: string; packstationNumber?: string; @@ -93,6 +97,8 @@ export async function createOrder(input: CreateOrderInput): Promise sum + i.quantity * degross(i.grossUnitPrice, i.taxRatePercent), 0)); + const shippingCost = roundMoney(degross(shippingCostGross, defaultTaxRate)); + const total = roundMoney(Math.max(0, subtotal - discountAmount) + shippingCost); + return { subtotal, shippingCost, total }; +} + +// The destination the goods actually ship to, not necessarily the billing +// address — the exemption depends on where the goods physically move to, +// which is the shipping override's country when one is set (see Orders.ts's +// own hasDifferentShippingAddress comment), the billing country otherwise. +export function destinationCountry(country: string, hasDifferentShippingAddress: boolean, shippingCountry: string | null | undefined): string { + return hasDifferentShippingAddress && shippingCountry ? shippingCountry : country; +} + +// Only Österreich is a real candidate today — this checkout offers exactly +// three countries (Deutschland/Österreich/Schweiz, see CheckoutContent.tsx's +// own PLZ_DIGITS), and Deutschland (domestic) / Schweiz (non-EU export, a +// different exemption entirely) never qualify for this specific one. +export function isExemptionEligibleCountry(country: string): boolean { + return country === "Österreich"; +} diff --git a/app/lib/vies.ts b/app/lib/vies.ts new file mode 100644 index 0000000..7b3b7c9 --- /dev/null +++ b/app/lib/vies.ts @@ -0,0 +1,46 @@ +// Server-only — calls the European Commission's public VIES REST API to +// confirm an EU VAT ID is actually registered, not just correctly +// formatted (see lib/vatId.ts's own comment: format alone is never +// enough to zero-rate an invoice). Confirmed live and working against +// the real endpoint 2026-07-23 (POST {countryCode, vatNumber} → +// {valid: boolean, ...}) — this is the Commission's own documented REST +// API, not a guess. +const VIES_URL = "https://ec.europa.eu/taxation_customs/vies/rest-api/check-vat-number"; + +export type ViesCheckResult = + | { ok: true; valid: boolean; name: string | null; address: string | null } + | { ok: false; reason: string }; + +// `vatNumber` must NOT include the country prefix (VIES wants it split +// out) — callers pass the full "DE123456789"-shaped id and this function +// does the splitting, since every call site already has the normalized +// full id (see lib/vatId.ts's normalizeVatId()) rather than the two parts +// separately. +export async function checkVatIdViaVies(vatId: string): Promise { + const countryCode = vatId.slice(0, 2); + const vatNumber = vatId.slice(2); + if (!countryCode || !vatNumber) return { ok: false, reason: "Ungültiges USt-IdNr.-Format." }; + + try { + // 8s timeout — VIES is a shared EU-wide government service with no + // uptime SLA to this shop; a slow/unreachable response must not hang + // checkout indefinitely. Callers treat `ok: false` as "couldn't + // confirm" and fail closed (no exemption), never as "confirmed invalid". + const res = await fetch(VIES_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ countryCode, vatNumber }), + signal: AbortSignal.timeout(8000), + }); + if (!res.ok) return { ok: false, reason: `VIES antwortete mit ${res.status}` }; + const data: { valid?: boolean; name?: string; address?: string } = await res.json(); + return { + ok: true, + valid: Boolean(data.valid), + name: data.name && data.name !== "---" ? data.name : null, + address: data.address && data.address !== "---" ? data.address : null, + }; + } catch (err) { + return { ok: false, reason: err instanceof Error ? err.message : "VIES ist gerade nicht erreichbar." }; + } +} diff --git a/package-lock.json b/package-lock.json index 7a61c57..48a0314 100644 --- a/package-lock.json +++ b/package-lock.json @@ -359,7 +359,7 @@ }, "node_modules/@einfach-produktiv/invoicing": { "version": "0.1.0", - "resolved": "git+https://git.mk360.de/Marco/einfach-produktiv-invoicing.git#ceedb4be05465a34d5a8051c8f30d578be1fd7af", + "resolved": "git+https://git.mk360.de/Marco/einfach-produktiv-invoicing.git#942b8a86322b165a09e7b91af686b6c3538989f4", "dependencies": { "@e-invoice-eu/core": "^3.1.1" },