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