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 (
{label}
+ {error && {error} }
);
}
@@ -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
+ />
Land
@@ -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({
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"
},