19f6559c29
VIES answers HTTP 200 even when it couldn't actually perform the check
(actionSucceed: false, e.g. MS_UNAVAILABLE — Germany's own national
gateway does this fairly regularly). checkVatIdViaVies() only ever read
data.valid, which is absent on that response shape, so it silently
read as valid: false — a real, currently-registered German VAT ID
(reported: DE351362947) looked rejected. Worse, /api/checkout/validate-
vat then wrapped even a correctly-returned ok:false as { ok: true,
valid: false }, which the client reads as "invalid" rather than
"unavailable" — the actual bug the user hit, compounding the vies.ts
gap. Both are fixed now: an unconfirmable check surfaces to the client
as ok:false, which CheckoutContent.tsx's handleVatIdBlur already
correctly renders as "USt-IdNr.-Prüfung derzeit nicht möglich"
instead of a rejection. Same fix applied to the payload backend's own
copy of vies.ts (company-settings' VAT check).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
59 lines
3.1 KiB
TypeScript
59 lines
3.1 KiB
TypeScript
// 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: { actionSucceed?: boolean; valid?: boolean; name?: string; address?: string; errorWrappers?: { error?: string }[] } = await res.json();
|
|
// VIES answers 200 even when it couldn't actually perform the check —
|
|
// `actionSucceed: false` (e.g. `MS_UNAVAILABLE`, the member state's own
|
|
// national gateway being temporarily down — Germany's in particular is
|
|
// known to do this) means "couldn't confirm", not "confirmed invalid".
|
|
// Without this check a `MS_UNAVAILABLE` response fell through to
|
|
// `Boolean(data.valid)` on a body that has no `valid` field at all,
|
|
// silently reading as `valid: false` — a real, currently-registered VAT
|
|
// ID would then look rejected instead of "VIES unavailable, try again".
|
|
if (data.actionSucceed === false) {
|
|
const reason = data.errorWrappers?.[0]?.error ?? "VIES konnte die Anfrage nicht bearbeiten.";
|
|
return { ok: false, reason: `VIES: ${reason}` };
|
|
}
|
|
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." };
|
|
}
|
|
}
|