Replace hand-duplicated VIES/VAT-ID/PLZ/tracking logic with shared package

Deletes app/lib/vies.ts, vatId.ts, tracking.ts in favor of the newly unified @einfach-produktiv/invoicing modules — fixes the actual PLZ inconsistency (this repo already validated per-country digit counts; the backend hardcoded German-only) rather than just deduplicating code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-31 15:41:01 +00:00
parent 230b8ebaad
commit b6a826dc46
10 changed files with 15 additions and 113 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { getSessionCustomer, updateCustomerProfile } from "../../../lib/customerAuth";
import { normalizeVatId, isValidVatId } from "../../../lib/vatId";
import { normalizeVatId, isValidVatId } from "@einfach-produktiv/invoicing";
export async function GET() {
const session = await getSessionCustomer();
+2 -2
View File
@@ -8,8 +8,8 @@ import { fetchProductsBySlug } from "../../lib/productsServer";
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 { normalizeVatId, isValidVatId } from "@einfach-produktiv/invoicing";
import { checkVatIdViaVies } from "@einfach-produktiv/invoicing/vies";
import { computeExemptTotals, destinationCountry, isExemptionEligibleCountry } from "../../lib/vatExemption";
import { upsertNewsletterContact } from "../../lib/brevo";
import { paymentProvider, isPaymentTestMode } from "../../lib/payments";
+2 -2
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { normalizeVatId, isValidVatId } from "../../../lib/vatId";
import { checkVatIdViaVies } from "../../../lib/vies";
import { normalizeVatId, isValidVatId } from "@einfach-produktiv/invoicing";
import { checkVatIdViaVies } from "@einfach-produktiv/invoicing/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
+3 -4
View File
@@ -20,7 +20,7 @@ import { ORDER_KEY, PENDING_ORDER_KEY, type OrderSnapshot } from "../../lib/orde
import { PaymentStep } from "./PaymentStep";
import { dispatchAuthChanged } from "../../lib/auth";
import { readCheckoutDraft, writeCheckoutDraft, clearCheckoutDraft } from "../../lib/checkoutDraft";
import { normalizeVatId, isValidVatId } from "../../lib/vatId";
import { normalizeVatId, isValidVatId, isValidPlz, plzInputPattern } from "@einfach-produktiv/invoicing";
import { computeExemptTotals, destinationCountry, isExemptionEligibleCountry } from "../../lib/vatExemption";
import { validateEmailFormat } from "../../lib/email";
import type { ShippingMethod, ShippingCountry, PaymentMethod, TrustBadge, ShippingSettings } from "../../lib/payload";
@@ -37,8 +37,7 @@ import type { CustomerProfile } from "../../lib/customerAuth";
// here. `?? 4` only matters if a country somehow isn't in the map at all
// (shouldn't happen — the <select> options are built from the same list).
function plzPattern(country: string, plzDigitsMap: Record<string, number>): string {
const digits = plzDigitsMap[country] ?? 4;
return `\\d{${digits}}`;
return plzInputPattern(plzDigitsMap[country] ?? 4);
}
// Same rules as the pattern/required attributes each field already
@@ -52,7 +51,7 @@ function validateRequired(label: string, value: string): string {
function validateZip(value: string, country: string, plzDigitsMap: Record<string, number>): string {
if (!value.trim()) return "PLZ ist erforderlich.";
const digits = plzDigitsMap[country] ?? 4;
return new RegExp(`^\\d{${digits}}$`).test(value) ? "" : `PLZ muss aus ${digits} Ziffern bestehen.`;
return isValidPlz(value, digits) ? "" : `PLZ muss aus ${digits} Ziffern bestehen.`;
}
function validatePackstationNumber(value: string): string {
@@ -9,7 +9,7 @@ import { formatPrice, formatDate } from "../../../lib/format";
import { getSessionCustomer, getCustomerOrderDetail, customerOrderAction } from "../../../lib/customerAuth";
import { getProductImagesByIds, getPaymentMethods, groupPaymentMethodsForCheckout, getMediaUrlById } from "../../../lib/payload";
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
import { buildTrackingUrl, CARRIER_LABELS } from "../../../lib/tracking";
import { buildTrackingUrl, CARRIER_LABELS, type Carrier } from "@einfach-produktiv/invoicing";
import { OrderActionButton } from "./components/OrderActionButton";
import { SwitchPaymentButton } from "./components/SwitchPaymentButton";
import { OrderStatusBadge } from "../../components/OrderStatusBadge";
@@ -92,7 +92,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
{order.trackingNumber && (
<div className="flex flex-col gap-1 w-full">
<p className="text-label text-text-muted">Sendungsverfolgung{order.carrier ? ` (${CARRIER_LABELS[order.carrier] ?? order.carrier})` : ""}</p>
<p className="text-label text-text-muted">Sendungsverfolgung{order.carrier ? ` (${CARRIER_LABELS[order.carrier as Carrier] ?? order.carrier})` : ""}</p>
{(() => {
const trackingUrl = buildTrackingUrl(order.carrier, order.trackingNumber);
return trackingUrl ? (
+3 -1
View File
@@ -1,6 +1,8 @@
// Thin client for the backend's DHL custom endpoints (src/lib/endpoints/
// dhlValidatePostNumber.ts, dhlAutocompleteAddress.ts) — own copy per repo,
// same "no shared package yet" convention as app/lib/tracking.ts.
// unlike vies.ts/vatId.ts/tracking.ts (now shared via @einfach-produktiv/invoicing),
// since this proxies to Payload endpoints specific to this repo's own DHL wiring,
// not portable logic.
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
const TENANT_SLUG = "einfach-produktiv";
-26
View File
@@ -1,26 +0,0 @@
// Mirrors the Payload backend's own src/lib/tracking.ts — same carrier
// set/labels/URL patterns, kept in sync by hand (two separate
// deployments, no shared package). Used to render a clickable tracking
// link on /konto/bestellungen/[orderNumber]; the backend's copy builds
// the same link for the order-shipped email.
export const CARRIER_LABELS: Record<string, string> = {
dhl: "DHL",
dpd: "DPD",
hermes: "Hermes",
ups: "UPS",
gls: "GLS",
other: "Sonstiger Versanddienstleister",
};
const CARRIER_TRACKING_URL: Record<string, (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)}`,
};
export function buildTrackingUrl(carrier: string | null | undefined, trackingNumber: string | null | undefined): string | null {
if (!carrier || !trackingNumber) return null;
return CARRIER_TRACKING_URL[carrier]?.(trackingNumber) ?? null;
}
-15
View File
@@ -1,15 +0,0 @@
// Mirrors the backend's own USt-IdNr. validation exactly (Orders.ts/
// Customers.ts/CompanySettings.ts in the Payload repo) — kept as a plain
// client+server-safe helper here since this repo's frontend needs the same
// check twice (checkout's instant client-side pattern + api/checkout's own
// server-side re-validation, same "never trust the client" reasoning as
// every other checkout field).
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
View File
@@ -1,58 +0,0 @@
// 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." };
}
}