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