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