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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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." };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user