Add innergemeinschaftliche-Lieferung VAT exemption for cross-border B2B

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

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

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