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:
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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." };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user