From 2b4b2eb91fa78d2a5e7e9e0fdb87f9947e32c41f Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 31 Jul 2026 15:32:55 +0000 Subject: [PATCH] Add shared VIES/VAT-ID/PLZ/carrier-tracking modules (v0.3.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unifies logic that was hand-duplicated (and, for PLZ, actually inconsistent) between the backend and frontend repos — both explicitly flagged this drift risk in their own code comments. vies.ts stays behind a separate ./vies subpath (server-only by convention, unlike the client-safe main barrel). Co-Authored-By: Claude Sonnet 5 --- README.md | 6 +++++ package.json | 6 ++++- src/carrierTracking.ts | 32 +++++++++++++++++++++++ src/index.ts | 11 ++++++++ src/plz.ts | 19 ++++++++++++++ src/vatId.ts | 16 ++++++++++++ src/vies.ts | 58 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 src/carrierTracking.ts create mode 100644 src/plz.ts create mode 100644 src/vatId.ts create mode 100644 src/vies.ts diff --git a/README.md b/README.md index 2f61699..a5a5499 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,12 @@ Ships raw TypeScript/TSX source (no build step) via `main`/`types` pointing stra - `fonts.ts` — registers the embedded Liberation Sans font (`src/assets/fonts/`) once for both PDF modules — see the 2026-07-28 changelog entry above. - `seller.ts` — the shared `InvoiceSeller` type both document types render in their footer. - `einvoice/` — the ZUGFeRD/Factur-X layer (see "E-invoicing" below). +- `vies.ts` — `checkVatIdViaVies()`, a live check against the EU Commission's VIES API (server-only — import from `@einfach-produktiv/invoicing/vies`, not the main barrel). +- `vatId.ts` — `normalizeVatId()`/`isValidVatId()`, EU VAT-ID format validation (client- and server-safe). +- `plz.ts` — `isValidPlz()`/`plzInputPattern()`, postal-code digit-count validation keyed by a caller-supplied digit count per country. +- `carrierTracking.ts` — `CARRIER_LABELS`/`buildTrackingUrl()`, shipping-carrier tracking-link generation. + +These four were unified from what used to be hand-duplicated, independently-drifting copies in both consuming repos (each had its own `vies.ts`/`vatId.ts`/PLZ-regex/`tracking.ts`) — see each consumer's own README for where they're used. The small in-memory rate limiter each repo also has (`rateLimit.ts`) is deliberately left duplicated — small enough (~15 lines) that a shared dependency isn't worth the coupling. ## E-invoicing diff --git a/package.json b/package.json index b0c8998..8a8ab9f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@einfach-produktiv/invoicing", - "version": "0.2.9", + "version": "0.3.0", "private": true, "description": "Shared invoice / correction-invoice (Stornorechnung, Gutschrift) PDF generation and VAT-breakdown math, consumed as a git dependency by both the einfach-produktiv frontend and the payload backend — not published to npm.", "type": "module", @@ -14,6 +14,10 @@ "./einvoice": { "types": "./src/einvoice/index.ts", "default": "./src/einvoice/index.ts" + }, + "./vies": { + "types": "./src/vies.ts", + "default": "./src/vies.ts" } }, "scripts": { diff --git a/src/carrierTracking.ts b/src/carrierTracking.ts new file mode 100644 index 0000000..24d4fe4 --- /dev/null +++ b/src/carrierTracking.ts @@ -0,0 +1,32 @@ +// Carrier is a closed set (not free text) specifically so a tracking URL +// can be generated from `carrier` + `trackingNumber`, not typed in by hand +// alongside the number. If a real carrier API integration auto-populates +// `trackingNumber` instead of an admin typing it in, this URL-generation +// logic doesn't need to change at all — only *who* sets `trackingNumber` +// changes, not this file. +export const CARRIERS = ["dhl", "dpd", "hermes", "ups", "gls", "other"] as const; +export type Carrier = (typeof CARRIERS)[number]; + +export const CARRIER_LABELS: Record = { + dhl: "DHL", + dpd: "DPD", + hermes: "Hermes", + ups: "UPS", + gls: "GLS", + other: "Sonstiger Versanddienstleister", +}; + +const CARRIER_TRACKING_URL: Partial string>> = { + dhl: (n) => `https://www.dhl.de/de/privatkunden/dhl-sendungsverfolgung.html?piececode=${encodeURIComponent(n)}`, + dpd: (n) => `https://tracking.dpd.de/status/de_DE/parcel/${encodeURIComponent(n)}`, + hermes: (n) => `https://www.myhermes.de/empfangen/sendungsverfolgung/sendungsinformation/#${encodeURIComponent(n)}`, + ups: (n) => `https://www.ups.com/track?loc=de_DE&tracknum=${encodeURIComponent(n)}`, + gls: (n) => `https://www.gls-pakete.de/sendungsverfolgung?trackingNumber=${encodeURIComponent(n)}`, + // 'other' has no known URL pattern — trackingUrl is null, the frontend/ + // email just shows the raw number as text instead of a link. +}; + +export function buildTrackingUrl(carrier: string | null | undefined, trackingNumber: string | null | undefined): string | null { + if (!carrier || !trackingNumber) return null; + return CARRIER_TRACKING_URL[carrier as Carrier]?.(trackingNumber) ?? null; +} diff --git a/src/index.ts b/src/index.ts index 92b1dd7..b812e9d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,9 @@ export { computeTaxBreakdown, type TaxBreakdownLine, type TaxBreakdownGroup } from "./taxBreakdown"; export { formatPrice, formatDate } from "./formatters"; export type { InvoiceSeller } from "./seller"; +export { normalizeVatId, isValidVatId } from "./vatId"; +export { isValidPlz, plzInputPattern } from "./plz"; +export { CARRIERS, CARRIER_LABELS, buildTrackingUrl, type Carrier } from "./carrierTracking"; export { InvoiceDocument, renderInvoicePdf, @@ -15,6 +18,14 @@ export { type CorrectionInvoiceOrder, } from "./correctionInvoicePdf"; +// checkVatIdViaVies is deliberately NOT re-exported from here, even though +// it has no server-only imports of its own — it's documented as +// server-only by convention (a live VIES check should never run from the +// browser: no need to expose the call pattern client-side, and this main +// barrel is imported by Client Components, see the E-invoice comment +// below for the exact bundle-breaking failure mode that pattern guards +// against). Import from "@einfach-produktiv/invoicing/vies" instead. + // E-invoice generation (renderInvoiceEInvoice/renderCorrectionInvoiceEInvoice) // is deliberately NOT re-exported from here — @e-invoice-eu/core pulls in // Node-only dependencies (tmp/tmp-promise, used for its LibreOffice diff --git a/src/plz.ts b/src/plz.ts new file mode 100644 index 0000000..164c625 --- /dev/null +++ b/src/plz.ts @@ -0,0 +1,19 @@ +// Postal-code (PLZ) digit-count validation, keyed by country — pure +// function, no fetching of its own. Callers supply how many digits a +// valid postal code has in a given country (both consuming repos already +// have this: the Payload backend's ShippingCountries.plzDigits field is +// the single source of truth, fetched independently by each app) — this +// only does the actual comparison, so it stays a plain value-in/bool-out +// function usable from both a Payload field `validate()` and a browser +// blur-handler. +export function isValidPlz(value: string, digits: number): boolean { + const pattern = new RegExp(`^\\d{${digits}}$`); + return pattern.test(value); +} + +// Builds the HTML `pattern` attribute string for a native input, e.g. +// "\d{5}" for Germany — same digit count `isValidPlz` checks against, so +// browser-native validation and this package's own check never disagree. +export function plzInputPattern(digits: number): string { + return `\\d{${digits}}`; +} diff --git a/src/vatId.ts b/src/vatId.ts new file mode 100644 index 0000000..a6613e7 --- /dev/null +++ b/src/vatId.ts @@ -0,0 +1,16 @@ +// EU VAT-ID (USt-IdNr.) format check — 2-letter country prefix + up to 12 +// alphanumeric characters (the EU-wide format across every member state). +// Format-only; confirming the id is actually *registered* needs a live +// check against vies.ts. Used both client- and server-side (checkout's +// instant client-side pattern + server-side re-validation, same "never +// trust the client" reasoning as every other checkout field), so this has +// no server-only imports. +const VAT_ID_PATTERN = /^[A-Z]{2}[A-Z0-9]{2,12}$/; + +export function normalizeVatId(value: string): string { + return value.toUpperCase().trim(); +} + +export function isValidVatId(value: string): boolean { + return VAT_ID_PATTERN.test(value); +} diff --git a/src/vies.ts b/src/vies.ts new file mode 100644 index 0000000..cac2cdf --- /dev/null +++ b/src/vies.ts @@ -0,0 +1,58 @@ +// 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 vatId.ts's own comment: format alone is never enough to +// zero-rate an invoice). Confirmed live and working against the real +// endpoint (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 vatId.ts's normalizeVatId()) rather than the two parts +// separately. +export async function checkVatIdViaVies(vatId: string): Promise { + 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 any one consumer; a slow/unreachable response must not + // hang a checkout or admin save indefinitely. Callers treat `ok: false` + // as "couldn't confirm" and should fail closed (no exemption granted), + // 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) 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." }; + } +}