/** * Client fuer die DHL Parcel DE Shipping API v2 ("Post & Parcel Germany", * developer.dhl.com) — erstellt Versandmarken (Labels) fuer nationale * DHL-Paketsendungen. * * Authentifizierung laut aktueller DHL-Dokumentation (Stand: Recherche * September 2026, developer.dhl.com/api-reference/parcel-de-shipping-post-parcel-germany-v2): * Zwei Faktoren sind noetig, nicht nur ein API-Key: * 1. Ein OAuth2-Access-Token ueber den ROPC-Grant (Resource Owner Password * Credentials) unter POST /parcel/de/account/auth/ropc/v1/token, mit * App-Client-ID/-Secret UND den Zugangsdaten des DHL-Geschaeftskunden- * portals (GKP-Benutzername/-Passwort) im Body. * 2. Der App-API-Key zusaetzlich als "dhl-api-key"-Header auf jedem * eigentlichen API-Aufruf (identifiziert die registrierte App/Quota). * DHL kuendigt an, das aeltere reine Basic-Auth-Schema mittelfristig * abzuschaffen — dieser Client verwendet daher bewusst den OAuth2/ROPC-Weg. * * Sandbox-Basis-URL: https://api-sandbox.dhl.com * Produktions-Basis-URL: https://api-eu.dhl.com * * WICHTIG: Die genauen Feldnamen/Endpunkte wurden anhand der oeffentlichen * API-Referenz und des zugehoerigen OpenAPI-Schemas recherchiert, konnten in * dieser Umgebung aber nicht gegen einen echten Sandbox-Account getestet * werden (keine DHL-Zugangsdaten vorhanden). Vor dem produktiven Einsatz: * einmal mit echten Sandbox-Zugangsdaten (siehe .env.example) gegen * https://developer.dhl.com testen und bei Abweichungen die Request-/ * Response-Shape unten anpassen — DHL liefert bei Validierungsfehlern * sprechende Fehlermeldungen, die per DhlApiError durchgereicht werden. */ // --------------------------------------------------------------------------- // Fehlerklassen // --------------------------------------------------------------------------- /** Wird geworfen, wenn eine/mehrere der benoetigten DHL_*-Umgebungsvariablen fehlen. */ export class DhlNotConfiguredError extends Error { constructor() { super( "DHL ist noch nicht konfiguriert. Bitte DHL_API_KEY, DHL_API_SECRET, DHL_GKP_USERNAME, " + "DHL_GKP_PASSWORD und DHL_ACCOUNT_NUMBER in der .env-Datei setzen (siehe .env.example)." ); this.name = "DhlNotConfiguredError"; } } /** Wird bei einem fehlgeschlagenen Aufruf der DHL-API geworfen (Auth, Validierung, Netzwerk, ...). */ export class DhlApiError extends Error { constructor(message: string) { super(message); this.name = "DhlApiError"; } } // --------------------------------------------------------------------------- // Konfiguration // --------------------------------------------------------------------------- // Ohne Timeout wuerde eine haengende (nicht fehlerhafte, sondern einfach nie // antwortende) DHL-API die aufrufende Admin-Server-Action unbegrenzt // blockieren und dabei den DHL_CLAIM_SENTINEL-Claim (siehe admin/actions/dhl.ts) // auf unbestimmte Zeit offen halten. const DHL_REQUEST_TIMEOUT_MS = 20_000; interface DhlConfig { apiKey: string; apiSecret: string; gkpUsername: string; gkpPassword: string; /** * "billingNumber" — 14-stellig: 10-stellige EKP (Kundennummer) + 2-stelliges * Verfahren + 2-stellige Teilnahme, z.B. "33333333330101" fuer V01PAK. * Wird vom DHL-Vertrieb im Rahmen des Geschaeftskundenvertrags vergeben. */ accountNumber: string; baseUrl: string; } function getDhlConfig(): DhlConfig { const apiKey = process.env.DHL_API_KEY; const apiSecret = process.env.DHL_API_SECRET; const gkpUsername = process.env.DHL_GKP_USERNAME; const gkpPassword = process.env.DHL_GKP_PASSWORD; const accountNumber = process.env.DHL_ACCOUNT_NUMBER; if (!apiKey || !apiSecret || !gkpUsername || !gkpPassword || !accountNumber) { throw new DhlNotConfiguredError(); } const environment = (process.env.DHL_ENVIRONMENT ?? "sandbox").trim().toLowerCase(); const baseUrl = environment === "production" ? "https://api-eu.dhl.com" : "https://api-sandbox.dhl.com"; return { apiKey, apiSecret, gkpUsername, gkpPassword, accountNumber, baseUrl }; } /** Fuer die bedingte Anzeige/Deaktivierung des "Versandmarke erstellen"-Buttons im Admin-UI. */ export function isDhlConfigured(): boolean { try { getDhlConfig(); return true; } catch { return false; } } // --------------------------------------------------------------------------- // OAuth2-Access-Token (ROPC), pro Serverprozess zwischengespeichert // --------------------------------------------------------------------------- interface CachedToken { accessToken: string; expiresAt: number; } // Modul-weiter In-Memory-Cache: analog zu lib/rate-limit.ts ausreichend fuer // den Single-Container-Betrieb dieser Seite. Bei horizontaler Skalierung // wuerde jede Instanz ihr eigenes Token ziehen, was fuer DHL unproblematisch // ist (kein geteilter Zustand noetig). let cachedToken: CachedToken | null = null; async function fetchAccessToken(config: DhlConfig): Promise { let response: Response; try { response = await fetch(`${config.baseUrl}/parcel/de/account/auth/ropc/v1/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, body: new URLSearchParams({ grant_type: "password", username: config.gkpUsername, password: config.gkpPassword, client_id: config.apiKey, client_secret: config.apiSecret, }), signal: AbortSignal.timeout(DHL_REQUEST_TIMEOUT_MS), }); } catch (error: unknown) { const message = error instanceof Error ? error.message : "Unbekannter Netzwerkfehler"; throw new DhlApiError(`DHL-Authentifizierung nicht erreichbar: ${message}`); } const payload = await safeReadJson(response); if (!response.ok) { throw new DhlApiError( `DHL-Authentifizierung fehlgeschlagen (HTTP ${response.status}): ${extractErrorDetail(payload) ?? "unbekannter Fehler"}` ); } const accessToken = typeof payload?.access_token === "string" ? payload.access_token : null; if (!accessToken) { throw new DhlApiError("DHL-Authentifizierung lieferte kein Access Token zurück."); } const expiresInSeconds = typeof payload?.expires_in === "number" ? payload.expires_in : 3600; // 60 Sekunden Sicherheitsabstand vor dem tatsaechlichen Ablauf, damit kein // knapp abgelaufenes Token noch fuer einen Request verwendet wird. cachedToken = { accessToken, expiresAt: Date.now() + Math.max(expiresInSeconds - 60, 30) * 1000 }; return accessToken; } async function getAccessToken(config: DhlConfig): Promise { if (cachedToken && cachedToken.expiresAt > Date.now()) return cachedToken.accessToken; return fetchAccessToken(config); } // --------------------------------------------------------------------------- // Adress-Mapping // --------------------------------------------------------------------------- // DHL erwartet Laendercodes nach ISO 3166-1 ALPHA-3, waehrend Order.country // in diesem Projekt ALPHA-2 speichert (siehe prisma/schema.prisma). Bewusst // nur die in einem deutschen Honig-Versandhandel realistischen Zielmaerkte // gelistet — ein nicht gelistetes Land liefert einen klaren Fehler statt // eines falsch geratenen Codes. const COUNTRY_ISO2_TO_ISO3: Record = { DE: "DEU", AT: "AUT", CH: "CHE", FR: "FRA", NL: "NLD", BE: "BEL", LU: "LUX", IT: "ITA", ES: "ESP", PL: "POL", DK: "DNK", CZ: "CZE", }; function toIso3Country(iso2: string): string { const code = COUNTRY_ISO2_TO_ISO3[iso2.trim().toUpperCase()]; if (!code) { throw new DhlApiError( `Land "${iso2}" wird vom DHL-Client aktuell nicht unterstützt (siehe COUNTRY_ISO2_TO_ISO3 in lib/dhl.ts).` ); } return code; } /** * Trennt eine deutsche Adresszeile ("Musterstraße 12a") in Straßenname und * Hausnummer fuer die getrennten DHL-Adressfelder (addressStreet/addressHouse). * Ohne erkennbare Hausnummer am Ende landet die komplette Zeile im * Straßenfeld — das Hausnummernfeld ist bei DHL optional, ein falscher * Trennversuch waere schlechter als gar keine Trennung. */ function splitStreet(street: string): { addressStreet: string; addressHouse?: string } { const trimmed = street.trim(); const match = trimmed.match(/^(.*\S)\s+(\d+\s*[a-zA-Z]?)$/); if (!match) return { addressStreet: trimmed }; return { addressStreet: match[1], addressHouse: match[2].replace(/\s+/g, "") }; } // --------------------------------------------------------------------------- // Versandmarke erstellen // --------------------------------------------------------------------------- export interface DhlAddressInput { firstName: string; lastName: string; street: string; zip: string; city: string; /** ISO 3166-1 ALPHA-2, z.B. "DE" — wie Order.country in prisma/schema.prisma. */ country: string; phone?: string | null; email?: string | null; /** * Nur bei Lieferung an eine DHL-Packstation gesetzt (Order.packstationNumber). * Ist sie vorhanden, verlangt DHL eine voellig andere Empfaengerstruktur — * siehe buildDhlConsignee() unten. */ packstationNumber?: string | null; /** DHL-Postnummer der empfangenden Person, 6-10 Ziffern (Order.postNumber). */ postNumber?: string | null; } export interface DhlShipperInput extends DhlAddressInput { companyName: string; } export interface DhlCreateShipmentInput { /** Wird als DHL-Referenz (refNo) mitgeschickt, z.B. die Wabenhain-Bestellnummer. */ reference: string; /** Gesamtgewicht der Sendung in Gramm (Summe aller Positionen inkl. Menge). */ weightGrams: number; shipper: DhlShipperInput; receiver: DhlAddressInput; } export interface DhlCreateShipmentResult { trackingNumber: string; /** Base64-kodiertes PDF-Label — wird aktuell nicht persistiert (siehe README/Bericht). */ labelPdfBase64: string; } // Deutsches Standard-Inlandspaket. Andere Produkte (z.B. Warenpost/Kleinpaket // fuer kleine, leichte Sendungen) sind bewusst nicht abgebildet — YAGNI, // bis der Bedarf konkret entsteht. const DHL_PRODUCT_NATIONAL_PARCEL = "V01PAK"; function buildDhlContact(address: DhlAddressInput, name1: string) { const { addressStreet, addressHouse } = splitStreet(address.street); return { name1, phone: address.phone || undefined, email: address.email || undefined, address: { addressStreet, addressHouse, postalCode: address.zip, city: address.city, country: toIso3Country(address.country), }, }; } /** * Empfaengerblock fuer die Shipping-API v2. * * DHL kennt fuer den Empfaenger zwei sich gegenseitig ausschliessende * Formen: eine normale Hausanschrift mit Strasse und Hausnummer, oder eine * Packstation. Letztere wird NICHT als Strasse uebermittelt — DHL erwartet * stattdessen "lockerID" (die Packstation-Nummer) zusammen mit der * "postNumber" der empfangenden Person; Strassen- und Hausnummernfelder * entfallen dabei ersatzlos. Eine Packstation als Strassentext zu schicken * ergaebe eine Marke auf eine Anschrift, die es so nicht gibt. */ function buildDhlConsignee(address: DhlAddressInput, name: string) { if (address.packstationNumber && address.postNumber) { return { name, lockerID: address.packstationNumber, postNumber: address.postNumber, postalCode: address.zip, city: address.city, country: toIso3Country(address.country), }; } return buildDhlContact(address, name); } export async function createDhlShipmentLabel( input: DhlCreateShipmentInput ): Promise { const config = getDhlConfig(); const accessToken = await getAccessToken(config); const requestBody = { shipments: [ { product: DHL_PRODUCT_NATIONAL_PARCEL, billingNumber: config.accountNumber, // DHL erlaubt max. 35 Zeichen fuer refNo. refNo: input.reference.slice(0, 35), shipper: buildDhlContact(input.shipper, input.shipper.companyName), consignee: buildDhlConsignee( input.receiver, `${input.receiver.firstName} ${input.receiver.lastName}`.trim() ), details: { weight: { uom: "g", value: Math.max(Math.round(input.weightGrams), 1) }, }, }, ], }; let response: Response; try { response = await fetch(`${config.baseUrl}/parcel/de/shipping/v2/orders`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: `Bearer ${accessToken}`, "dhl-api-key": config.apiKey, }, body: JSON.stringify(requestBody), signal: AbortSignal.timeout(DHL_REQUEST_TIMEOUT_MS), }); } catch (error: unknown) { const message = error instanceof Error ? error.message : "Unbekannter Netzwerkfehler"; throw new DhlApiError(`DHL-API nicht erreichbar: ${message}`); } const payload = await safeReadJson(response); if (!response.ok) { throw new DhlApiError( `DHL-Versandmarke konnte nicht erstellt werden (HTTP ${response.status}): ${extractErrorDetail(payload) ?? "unbekannter Fehler"}` ); } const item = Array.isArray(payload?.items) ? payload.items[0] : undefined; const validationError = Array.isArray(item?.validationMessages) ? item.validationMessages.find((entry: { validationState?: string }) => entry?.validationState === "Error") : undefined; if (validationError) { throw new DhlApiError(`DHL meldet einen Fehler in den Sendungsdaten: ${validationError.validationMessage ?? "unbekannt"}`); } const trackingNumber = typeof item?.shipmentNo === "string" ? item.shipmentNo : null; const labelPdfBase64 = typeof item?.label?.b64 === "string" ? item.label.b64 : null; if (!trackingNumber || !labelPdfBase64) { throw new DhlApiError("DHL-Antwort enthielt keine Sendungsnummer oder kein Label — bitte Sandbox-Response pruefen."); } return { trackingNumber, labelPdfBase64 }; } // --------------------------------------------------------------------------- // Hilfsfunktionen // --------------------------------------------------------------------------- async function safeReadJson(response: Response): Promise | undefined> { try { return await response.json(); } catch { return undefined; } } /** Extrahiert eine lesbare Fehlermeldung aus gaengigen DHL-Fehlerformaten (RFC7807-aehnlich). */ function extractErrorDetail(payload: Record | undefined): string | undefined { if (!payload) return undefined; if (typeof payload.detail === "string") return payload.detail; if (typeof payload.title === "string") return payload.title; if (typeof payload.error_description === "string") return payload.error_description; if (typeof payload.message === "string") return payload.message; const status = payload.status as { detail?: string; title?: string } | undefined; if (status?.detail) return status.detail; if (status?.title) return status.title; return undefined; }