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:
@@ -34,6 +34,9 @@ export async function GET(request: Request, { params }: { params: Promise<{ orde
|
||||
correctionInvoiceIssuedAt: order.correctionInvoiceIssuedAt,
|
||||
customerFirstName: order.customerFirstName,
|
||||
customerLastName: order.customerLastName,
|
||||
companyName: order.companyName,
|
||||
vatId: order.vatId,
|
||||
vatExempt: order.vatExempt,
|
||||
deliveryMethod: order.deliveryMethod,
|
||||
street: order.street,
|
||||
packstationNumber: order.packstationNumber,
|
||||
|
||||
@@ -31,6 +31,9 @@ export async function GET(request: Request, { params }: { params: Promise<{ orde
|
||||
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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type { CartItem } from "../../lib/cart";
|
||||
import { useProducts } from "../../lib/products";
|
||||
import { computeCartTotals, effectivePrice, effectiveTaxRate } from "../../lib/cartTotals";
|
||||
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
|
||||
import { computeExemptTotals } from "../../lib/vatExemption";
|
||||
import { formatPrice, formatDate } from "../../lib/format";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { CheckoutSteps } from "../../components/CheckoutSteps";
|
||||
@@ -31,7 +32,8 @@ function parseOrderSnapshot(raw: string): OrderSnapshot | null {
|
||||
typeof data.shippingCost !== "number" ||
|
||||
typeof data.paymentMethodTitle !== "string" ||
|
||||
(data.discountCode !== null && typeof data.discountCode !== "string") ||
|
||||
typeof data.discountAmount !== "number"
|
||||
typeof data.discountAmount !== "number" ||
|
||||
typeof data.vatExempt !== "boolean"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -100,20 +102,42 @@ export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate:
|
||||
.map((entry) => ({ entry, product: products.find((p) => p.id === entry.id) }))
|
||||
.filter((row): row is { entry: CartItem; product: NonNullable<(typeof row)["product"]> } => Boolean(row.product));
|
||||
|
||||
// Displays the *persisted* discount from the snapshot, not a fresh
|
||||
// re-derivation — the purchase already happened, this page is a
|
||||
// Displays the *persisted* discount/shippingCost from the snapshot, not
|
||||
// a fresh re-derivation — the purchase already happened, this page is a
|
||||
// receipt, not a live cart, so it doesn't re-validate the code at all.
|
||||
const { subtotal, totalSavings, total } = computeCartTotals(items, order.shippingCost, {
|
||||
// order.shippingCost is already the actual (possibly de-grossed, if
|
||||
// vatExempt) figure charged at checkout — see api/checkout/route.ts's
|
||||
// own response. `subtotal`/`taxBreakdown` below still need their own
|
||||
// exempt branch, though: computeCartTotals/computeTaxBreakdown build
|
||||
// `subtotal` from each item's *current catalog* gross price via
|
||||
// effectivePrice(), which for an exempt order was never what was
|
||||
// actually charged (the catalog price includes VAT; the exempt order
|
||||
// paid the de-grossed net price instead).
|
||||
const { subtotal: catalogSubtotal, totalSavings, total: catalogTotal } = computeCartTotals(items, order.shippingCost, {
|
||||
type: "fixed",
|
||||
value: order.discountAmount,
|
||||
});
|
||||
const exemptTotals = order.vatExempt
|
||||
? computeExemptTotals(
|
||||
items.map(({ entry, product }) => ({
|
||||
quantity: entry.qty,
|
||||
grossUnitPrice: effectivePrice(entry, product),
|
||||
taxRatePercent: effectiveTaxRate(product, defaultTaxRate),
|
||||
})),
|
||||
order.shippingCost,
|
||||
defaultTaxRate,
|
||||
order.discountAmount,
|
||||
)
|
||||
: null;
|
||||
const subtotal = exemptTotals?.subtotal ?? catalogSubtotal;
|
||||
const total = exemptTotals?.total ?? catalogTotal;
|
||||
const taxBreakdown = computeTaxBreakdown(
|
||||
items.map(({ entry, product }) => ({
|
||||
quantity: entry.qty,
|
||||
unitPrice: effectivePrice(entry, product),
|
||||
taxRatePercent: effectiveTaxRate(product, defaultTaxRate),
|
||||
})),
|
||||
subtotal,
|
||||
catalogSubtotal,
|
||||
order.discountAmount,
|
||||
order.shippingCost,
|
||||
);
|
||||
@@ -257,7 +281,11 @@ export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate:
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-h-small text-text-primary">{formatPrice(total)}</span>
|
||||
</div>
|
||||
<VatBreakdown groups={taxBreakdown} />
|
||||
{order.vatExempt ? (
|
||||
<p className="text-label text-text-muted">Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)</p>
|
||||
) : (
|
||||
<VatBreakdown groups={taxBreakdown} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,8 @@ import { CheckoutSteps } from "../../components/CheckoutSteps";
|
||||
import { ORDER_KEY, type OrderSnapshot } from "../../lib/order";
|
||||
import { dispatchAuthChanged } from "../../lib/auth";
|
||||
import { readCheckoutDraft, writeCheckoutDraft, clearCheckoutDraft } from "../../lib/checkoutDraft";
|
||||
import { normalizeVatId, isValidVatId } from "../../lib/vatId";
|
||||
import { computeExemptTotals, destinationCountry, isExemptionEligibleCountry } from "../../lib/vatExemption";
|
||||
import type { ShippingMethod, PaymentMethod, TrustBadge, ShippingSettings } from "../../lib/payload";
|
||||
import type { CustomerProfile } from "../../lib/customerAuth";
|
||||
|
||||
@@ -33,18 +35,60 @@ function plzPattern(country: string): string {
|
||||
return `\\d{${digits}}`;
|
||||
}
|
||||
|
||||
// Same rules as the pattern/required attributes each field already
|
||||
// carries (and what Orders.ts/Customers.ts re-enforce server-side) — this
|
||||
// is the plausibility check surfaced immediately on blur, not a second
|
||||
// source of truth. Returns "" for valid, an error message otherwise.
|
||||
function validateRequired(label: string, value: string): string {
|
||||
return value.trim() ? "" : `${label} ist erforderlich.`;
|
||||
}
|
||||
|
||||
function validateEmailFormat(value: string): string {
|
||||
if (!value.trim()) return "E-Mail-Adresse ist erforderlich.";
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? "" : "Bitte eine gültige E-Mail-Adresse angeben.";
|
||||
}
|
||||
|
||||
function validateZip(value: string, country: string): string {
|
||||
if (!value.trim()) return "PLZ ist erforderlich.";
|
||||
const digits = PLZ_DIGITS[country] ?? 4;
|
||||
return new RegExp(`^\\d{${digits}}$`).test(value) ? "" : `PLZ muss aus ${digits} Ziffern bestehen.`;
|
||||
}
|
||||
|
||||
function validatePackstationNumber(value: string): string {
|
||||
if (!value.trim()) return "Packstationsnummer ist erforderlich.";
|
||||
return /^\d{1,3}$/.test(value) ? "" : "Packstationsnummer muss aus 1 bis 3 Ziffern bestehen.";
|
||||
}
|
||||
|
||||
function validatePostNumber(value: string): string {
|
||||
if (!value.trim()) return "Postnummer ist erforderlich.";
|
||||
return /^\d{6,10}$/.test(value) ? "" : "Postnummer muss aus 6 bis 10 Ziffern bestehen.";
|
||||
}
|
||||
|
||||
// Optional field — "" (valid) whenever empty, only format-checked once
|
||||
// something's actually been typed, same "never required by the other"
|
||||
// reasoning as the checkout body's own companyName/vatId handling.
|
||||
function validateVatIdFormat(value: string): string {
|
||||
if (!value.trim()) return "";
|
||||
return isValidVatId(normalizeVatId(value)) ? "" : "Ungültiges USt-IdNr.-Format (z. B. DE123456789).";
|
||||
}
|
||||
|
||||
function FormField({
|
||||
label,
|
||||
wrapperClassName = "flex-1 min-w-0",
|
||||
error,
|
||||
...props
|
||||
}: { label: string; wrapperClassName?: string } & React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
}: { label: string; wrapperClassName?: string; error?: string } & React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<label className={`flex flex-col gap-2 items-start ${wrapperClassName}`}>
|
||||
<span className="text-label text-text-muted">{label}</span>
|
||||
<input
|
||||
{...props}
|
||||
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
|
||||
aria-invalid={error ? true : undefined}
|
||||
className={`w-full border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors ${
|
||||
error ? "border-red-600" : "border-border"
|
||||
}`}
|
||||
/>
|
||||
{error && <span className="text-label text-red-600">{error}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -91,6 +135,25 @@ export function CheckoutContent({
|
||||
const [loginPassword, setLoginPassword] = useState("");
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [loggingIn, setLoggingIn] = useState(false);
|
||||
// Per-field inline validation, populated on blur (see each field's own
|
||||
// onBlur below) — surfaces the same plausibility checks the pattern/
|
||||
// required attributes already declare, immediately instead of only at
|
||||
// submit time (native browser validation still applies too, as a
|
||||
// fallback for fields somehow never blurred, e.g. autofill).
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
|
||||
function setFieldError(name: string, message: string) {
|
||||
setFieldErrors((prev) => {
|
||||
if (!message) {
|
||||
if (!(name in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[name];
|
||||
return next;
|
||||
}
|
||||
if (prev[name] === message) return prev;
|
||||
return { ...prev, [name]: message };
|
||||
});
|
||||
}
|
||||
|
||||
// Address-card fields — controlled (unlike before) so they can be
|
||||
// persisted via lib/checkoutDraft.ts and restored after navigating away
|
||||
@@ -130,6 +193,14 @@ export function CheckoutContent({
|
||||
const [shippingCity, setShippingCity] = useState("");
|
||||
const [shippingCountry, setShippingCountry] = useState("Deutschland");
|
||||
const [newsletterOptIn, setNewsletterOptIn] = useState(false);
|
||||
// Live VIES status for the USt-IdNr. field — only meaningful once the
|
||||
// goods' destination (shipping override country when set, billing
|
||||
// country otherwise) is Österreich, the one EU-cross-border option this
|
||||
// checkout offers (see lib/vatExemption.ts). "valid" is what actually
|
||||
// drives the exempt-totals preview below; api/checkout/route.ts re-runs
|
||||
// this exact same VIES check server-side at submit time regardless —
|
||||
// this state is a preview, never the source of truth.
|
||||
const [vatIdViesStatus, setVatIdViesStatus] = useState<"idle" | "checking" | "valid" | "invalid" | "unavailable">("idle");
|
||||
// Flips true only after the hydration effect's setState calls have
|
||||
// actually landed in a render — gates the write-back effect below so it
|
||||
// never fires with the pre-hydration defaults first and briefly
|
||||
@@ -250,6 +321,59 @@ export function CheckoutContent({
|
||||
shipping,
|
||||
);
|
||||
|
||||
// Live preview only — api/checkout/route.ts re-runs the same VIES check
|
||||
// server-side at submit time and is the actual source of truth (see
|
||||
// lib/vatExemption.ts). Destination is the shipping override's country
|
||||
// when set, the billing country otherwise — the exemption depends on
|
||||
// where the goods actually move to, not necessarily the invoice address.
|
||||
const buyerDestinationCountry = destinationCountry(country, hasDifferentShippingAddress, shippingCountry);
|
||||
const vatExemptPreview = vatIdViesStatus === "valid" && isExemptionEligibleCountry(buyerDestinationCountry);
|
||||
const exemptTotalsPreview = vatExemptPreview
|
||||
? computeExemptTotals(
|
||||
items.map(({ entry, product }) => ({
|
||||
quantity: entry.qty,
|
||||
grossUnitPrice: effectivePrice(entry, product),
|
||||
taxRatePercent: effectiveTaxRate(product, defaultTaxRate),
|
||||
})),
|
||||
shipping,
|
||||
defaultTaxRate,
|
||||
discountAmount,
|
||||
)
|
||||
: null;
|
||||
const displaySubtotal = exemptTotalsPreview?.subtotal ?? subtotal;
|
||||
const displayShipping = exemptTotalsPreview?.shippingCost ?? shipping;
|
||||
const displayTotal = exemptTotalsPreview?.total ?? total;
|
||||
|
||||
// USt-IdNr. blur — format-checks first (always), then a live VIES lookup
|
||||
// only once the destination actually qualifies (Österreich) — no point
|
||||
// hitting the EU's API for a Deutschland/Schweiz order, where this
|
||||
// exemption never applies regardless of what VIES says.
|
||||
async function handleVatIdBlur(e: React.FocusEvent<HTMLInputElement>) {
|
||||
const value = e.target.value;
|
||||
const formatError = validateVatIdFormat(value);
|
||||
setFieldError("vatId", formatError);
|
||||
if (!value.trim() || formatError || !isExemptionEligibleCountry(destinationCountry(country, hasDifferentShippingAddress, shippingCountry))) {
|
||||
setVatIdViesStatus("idle");
|
||||
return;
|
||||
}
|
||||
setVatIdViesStatus("checking");
|
||||
try {
|
||||
const res = await fetch("/api/checkout/validate-vat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ vatId: value }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
setVatIdViesStatus("unavailable");
|
||||
return;
|
||||
}
|
||||
setVatIdViesStatus(data.valid ? "valid" : "invalid");
|
||||
} catch {
|
||||
setVatIdViesStatus("unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
// Logs into an existing account inline, without leaving /checkout —
|
||||
// router.refresh() re-runs the page's Server Component, which re-reads
|
||||
// the now-set session cookie and passes the resolved customerEmail back
|
||||
@@ -293,6 +417,7 @@ export function CheckoutContent({
|
||||
// in case", only once actually relevant.
|
||||
async function handleEmailBlur(e: React.FocusEvent<HTMLInputElement>) {
|
||||
const email = e.target.value.trim();
|
||||
setFieldError("email", validateEmailFormat(email));
|
||||
if (!email || customerEmail || showLogin) return;
|
||||
try {
|
||||
const res = await fetch("/api/account/check-email", {
|
||||
@@ -387,6 +512,7 @@ export function CheckoutContent({
|
||||
paymentMethodTitle: data.paymentMethodTitle,
|
||||
discountCode: data.discountCode,
|
||||
discountAmount: data.discountAmount,
|
||||
vatExempt: Boolean(data.vatExempt),
|
||||
};
|
||||
try {
|
||||
window.sessionStorage.setItem(ORDER_KEY, JSON.stringify(snapshot));
|
||||
@@ -528,8 +654,30 @@ export function CheckoutContent({
|
||||
1. Rechnungsadresse
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<FormField label="Vorname" name="firstName" type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} placeholder="Max" autoComplete="given-name" required />
|
||||
<FormField label="Nachname" name="lastName" type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} placeholder="Mustermann" autoComplete="family-name" required />
|
||||
<FormField
|
||||
label="Vorname"
|
||||
name="firstName"
|
||||
type="text"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
onBlur={(e) => setFieldError("firstName", validateRequired("Vorname", e.target.value))}
|
||||
error={fieldErrors.firstName}
|
||||
placeholder="Max"
|
||||
autoComplete="given-name"
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Nachname"
|
||||
name="lastName"
|
||||
type="text"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
onBlur={(e) => setFieldError("lastName", validateRequired("Nachname", e.target.value))}
|
||||
error={fieldErrors.lastName}
|
||||
placeholder="Mustermann"
|
||||
autoComplete="family-name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{/* Optional B2B fields — both independently optional (see
|
||||
Orders.ts's own comment: a sole proprietor might give a VAT
|
||||
@@ -545,17 +693,39 @@ export function CheckoutContent({
|
||||
placeholder="Muster GmbH"
|
||||
autoComplete="organization"
|
||||
/>
|
||||
<FormField
|
||||
label="USt-IdNr. (optional)"
|
||||
name="vatId"
|
||||
type="text"
|
||||
value={vatId}
|
||||
onChange={(e) => setVatId(e.target.value)}
|
||||
placeholder="DE123456789"
|
||||
autoComplete="off"
|
||||
pattern="[A-Za-z]{2}[A-Za-z0-9]{2,12}"
|
||||
title="EU-Format: 2 Buchstaben Länderpräfix + bis zu 12 alphanumerische Zeichen, z. B. DE123456789."
|
||||
/>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
||||
<FormField
|
||||
label="USt-IdNr. (optional)"
|
||||
name="vatId"
|
||||
type="text"
|
||||
value={vatId}
|
||||
onChange={(e) => {
|
||||
setVatId(e.target.value);
|
||||
setVatIdViesStatus("idle");
|
||||
}}
|
||||
onBlur={handleVatIdBlur}
|
||||
error={fieldErrors.vatId}
|
||||
placeholder="DE123456789"
|
||||
autoComplete="off"
|
||||
pattern="[A-Za-z]{2}[A-Za-z0-9]{2,12}"
|
||||
title="EU-Format: 2 Buchstaben Länderpräfix + bis zu 12 alphanumerische Zeichen, z. B. DE123456789."
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
{/* Only shown once the destination actually qualifies
|
||||
(Österreich) — a "checking..."/status message for a
|
||||
Deutschland/Schweiz order would be meaningless noise,
|
||||
the exemption never applies there regardless. */}
|
||||
{isExemptionEligibleCountry(buyerDestinationCountry) && (
|
||||
<p className="text-label text-text-muted">
|
||||
{vatIdViesStatus === "checking" && "USt-IdNr. wird geprüft…"}
|
||||
{vatIdViesStatus === "valid" && (
|
||||
<span className="text-success">✓ Bestätigt — Lieferung wird steuerfrei berechnet.</span>
|
||||
)}
|
||||
{vatIdViesStatus === "invalid" && "USt-IdNr. konnte nicht bestätigt werden — reguläre MwSt. wird berechnet."}
|
||||
{vatIdViesStatus === "unavailable" && "Prüfung derzeit nicht möglich — reguläre MwSt. wird berechnet."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* w-[calc(50%-0.5rem)] at sm: — exactly matches Vorname's
|
||||
actual rendered width in the 2-col row above (each half of
|
||||
@@ -570,6 +740,7 @@ export function CheckoutContent({
|
||||
autoComplete="email"
|
||||
required
|
||||
onBlur={handleEmailBlur}
|
||||
error={fieldErrors.email}
|
||||
wrapperClassName="w-full sm:w-[calc(50%-0.5rem)] sm:flex-none min-w-0"
|
||||
/>
|
||||
{/* Only needed for the inline-registration path — an existing
|
||||
@@ -584,6 +755,17 @@ export function CheckoutContent({
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
onBlur={(e) =>
|
||||
setFieldError(
|
||||
"password",
|
||||
!e.target.value
|
||||
? "Passwort ist erforderlich."
|
||||
: e.target.value.length < 8
|
||||
? "Passwort muss mindestens 8 Zeichen lang sein."
|
||||
: "",
|
||||
)
|
||||
}
|
||||
error={fieldErrors.password}
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
<p className="text-label text-text-muted">
|
||||
@@ -602,6 +784,8 @@ export function CheckoutContent({
|
||||
type="text"
|
||||
value={street}
|
||||
onChange={(e) => setStreet(e.target.value)}
|
||||
onBlur={(e) => setFieldError("street", validateRequired("Straße und Hausnummer", e.target.value))}
|
||||
error={fieldErrors.street}
|
||||
placeholder="Musterstraße 1"
|
||||
autoComplete="street-address"
|
||||
required
|
||||
@@ -614,6 +798,8 @@ export function CheckoutContent({
|
||||
type="text"
|
||||
value={zip}
|
||||
onChange={(e) => setZip(e.target.value)}
|
||||
onBlur={(e) => setFieldError("zip", validateZip(e.target.value, country))}
|
||||
error={fieldErrors.zip}
|
||||
placeholder="10115"
|
||||
autoComplete="postal-code"
|
||||
inputMode="numeric"
|
||||
@@ -621,7 +807,18 @@ export function CheckoutContent({
|
||||
title={`PLZ muss aus ${PLZ_DIGITS[country] ?? 4} Ziffern bestehen.`}
|
||||
required
|
||||
/>
|
||||
<FormField label="Ort" name="city" type="text" value={city} onChange={(e) => setCity(e.target.value)} placeholder="Berlin" autoComplete="address-level2" required />
|
||||
<FormField
|
||||
label="Ort"
|
||||
name="city"
|
||||
type="text"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
onBlur={(e) => setFieldError("city", validateRequired("Ort", e.target.value))}
|
||||
error={fieldErrors.city}
|
||||
placeholder="Berlin"
|
||||
autoComplete="address-level2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<label className="flex flex-col gap-2 items-start w-full">
|
||||
<span className="text-label text-text-muted">Land</span>
|
||||
@@ -659,6 +856,8 @@ export function CheckoutContent({
|
||||
type="text"
|
||||
value={shippingFirstName}
|
||||
onChange={(e) => setShippingFirstName(e.target.value)}
|
||||
onBlur={(e) => setFieldError("shippingFirstName", validateRequired("Vorname", e.target.value))}
|
||||
error={fieldErrors.shippingFirstName}
|
||||
placeholder="Max"
|
||||
autoComplete="off"
|
||||
required
|
||||
@@ -668,6 +867,8 @@ export function CheckoutContent({
|
||||
type="text"
|
||||
value={shippingLastName}
|
||||
onChange={(e) => setShippingLastName(e.target.value)}
|
||||
onBlur={(e) => setFieldError("shippingLastName", validateRequired("Nachname", e.target.value))}
|
||||
error={fieldErrors.shippingLastName}
|
||||
placeholder="Mustermann"
|
||||
autoComplete="off"
|
||||
required
|
||||
@@ -708,6 +909,8 @@ export function CheckoutContent({
|
||||
type="text"
|
||||
value={shippingStreet}
|
||||
onChange={(e) => setShippingStreet(e.target.value)}
|
||||
onBlur={(e) => setFieldError("shippingStreet", validateRequired("Straße und Hausnummer", e.target.value))}
|
||||
error={fieldErrors.shippingStreet}
|
||||
placeholder="Musterstraße 1"
|
||||
autoComplete="off"
|
||||
required
|
||||
@@ -720,6 +923,8 @@ export function CheckoutContent({
|
||||
type="text"
|
||||
value={shippingPackstationNumber}
|
||||
onChange={(e) => setShippingPackstationNumber(e.target.value)}
|
||||
onBlur={(e) => setFieldError("shippingPackstationNumber", validatePackstationNumber(e.target.value))}
|
||||
error={fieldErrors.shippingPackstationNumber}
|
||||
inputMode="numeric"
|
||||
placeholder="123"
|
||||
autoComplete="off"
|
||||
@@ -733,6 +938,8 @@ export function CheckoutContent({
|
||||
type="text"
|
||||
value={shippingPostNumber}
|
||||
onChange={(e) => setShippingPostNumber(e.target.value)}
|
||||
onBlur={(e) => setFieldError("shippingPostNumber", validatePostNumber(e.target.value))}
|
||||
error={fieldErrors.shippingPostNumber}
|
||||
inputMode="numeric"
|
||||
placeholder="1234567890"
|
||||
autoComplete="off"
|
||||
@@ -749,6 +956,8 @@ export function CheckoutContent({
|
||||
type="text"
|
||||
value={shippingZip}
|
||||
onChange={(e) => setShippingZip(e.target.value)}
|
||||
onBlur={(e) => setFieldError("shippingZip", validateZip(e.target.value, shippingCountry))}
|
||||
error={fieldErrors.shippingZip}
|
||||
placeholder="10115"
|
||||
autoComplete="off"
|
||||
inputMode="numeric"
|
||||
@@ -761,6 +970,8 @@ export function CheckoutContent({
|
||||
type="text"
|
||||
value={shippingCity}
|
||||
onChange={(e) => setShippingCity(e.target.value)}
|
||||
onBlur={(e) => setFieldError("shippingCity", validateRequired("Ort", e.target.value))}
|
||||
error={fieldErrors.shippingCity}
|
||||
placeholder="Berlin"
|
||||
autoComplete="off"
|
||||
required
|
||||
@@ -927,7 +1138,7 @@ export function CheckoutContent({
|
||||
<div className="flex items-center w-full">
|
||||
<span className="text-body-sm text-text-primary">Zwischensumme</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">{formatPrice(subtotal)}</span>
|
||||
<span className="text-body-sm text-text-primary">{formatPrice(displaySubtotal)}</span>
|
||||
</div>
|
||||
|
||||
{totalSavings > 0 && (
|
||||
@@ -963,7 +1174,7 @@ export function CheckoutContent({
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">
|
||||
{shipping === 0 ? "Kostenlos" : formatPrice(shipping)}
|
||||
{displayShipping === 0 ? "Kostenlos" : formatPrice(displayShipping)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
@@ -987,9 +1198,13 @@ export function CheckoutContent({
|
||||
Gesamtsumme
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-h-small text-text-primary">{formatPrice(total)}</span>
|
||||
<span className="font-bold text-h-small text-text-primary">{formatPrice(displayTotal)}</span>
|
||||
</div>
|
||||
<VatBreakdown groups={taxBreakdown} />
|
||||
{vatExemptPreview ? (
|
||||
<p className="text-label text-text-muted">Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)</p>
|
||||
) : (
|
||||
<VatBreakdown groups={taxBreakdown} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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." };
|
||||
}
|
||||
}
|
||||
Generated
+1
-1
@@ -359,7 +359,7 @@
|
||||
},
|
||||
"node_modules/@einfach-produktiv/invoicing": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "git+https://git.mk360.de/Marco/einfach-produktiv-invoicing.git#ceedb4be05465a34d5a8051c8f30d578be1fd7af",
|
||||
"resolved": "git+https://git.mk360.de/Marco/einfach-produktiv-invoicing.git#942b8a86322b165a09e7b91af686b6c3538989f4",
|
||||
"dependencies": {
|
||||
"@e-invoice-eu/core": "^3.1.1"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user