Add shared VIES/VAT-ID/PLZ/carrier-tracking modules (v0.3.0)
Validate e-invoices / mustang (push) Successful in 23s
Validate e-invoices / mustang (push) Successful in 23s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+5
-1
@@ -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": {
|
||||
|
||||
@@ -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<Carrier, string> = {
|
||||
dhl: "DHL",
|
||||
dpd: "DPD",
|
||||
hermes: "Hermes",
|
||||
ups: "UPS",
|
||||
gls: "GLS",
|
||||
other: "Sonstiger Versanddienstleister",
|
||||
};
|
||||
|
||||
const CARRIER_TRACKING_URL: Partial<Record<Carrier, (trackingNumber: string) => 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+19
@@ -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}}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+58
@@ -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<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 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." };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user