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