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