// DPD WebConnect helper (LoginService 2.0 + ShipmentService 4.5) // // LoginService is REST (JSON), ShipmentService is SOAP-only. // Token is cached for 1 business day per DPD policy; max 10 logins/account/day. // Shipment service is limited to 60 calls/minute and must be sequential. // // Credentials are resolved in this order: // 1. `credentials` argument — from C_OrderSource when DPD_USE_CUSTOM=true // (DPDURL, DPDDELISID, DPDPASSWORD, DPDCUSTOMERNUMBER, DPDDEPOT) // 2. runtimeConfig env vars (DPDURL, DPDDELISID, DPDPASSWORD, etc.) // — set in .env for the global default account // // If neither source provides delisId + password, dpdGetAuth throws. const TOKEN_COOKIE_PREFIX = 'logship_dpd_' const TOKEN_TTL_SECONDS = 23 * 60 * 60 // 23h — DPD says ~1 business day, refresh after 03:01 const DEFAULT_BASE_URL = 'https://public-ws.dpd.com/services' export interface DpdCredentials { baseUrl: string delisId: string password: string customerNumber: string depot?: string } export interface DpdAuth { authToken: string depot: string delisId: string customerUid: string } // ---------------------------------------------------------------------------- // Credential resolution // ---------------------------------------------------------------------------- export function resolveDpdCredentials(custom?: Partial | null): DpdCredentials { const config = useRuntimeConfig() const baseUrl = String(custom?.baseUrl || (config.api as any).dpdurl || DEFAULT_BASE_URL).replace(/\/+$/, '') const delisId = String(custom?.delisId || (config.api as any).dpddelisid || '') const password = String(custom?.password || (config.api as any).dpdpassword || '') const customerNumber = String(custom?.customerNumber || (config.api as any).dpdcustomernumber || '') const depot = String(custom?.depot || (config.api as any).dpddepot || '') if (!delisId || !password) { throw new Error( 'DPD credentials missing. Either set DPDDELISID + DPDPASSWORD in .env (global default), ' + 'or enable DPD_USE_CUSTOM on the C_OrderSource and populate DPDDELISID/DPDPASSWORD/DPDCUSTOMERNUMBER there.' ) } return { baseUrl, delisId, password, customerNumber, depot } } // ---------------------------------------------------------------------------- // XML helpers // ---------------------------------------------------------------------------- const XML_ESC: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } export function xmlEscape(v: any): string { if (v === null || v === undefined) return '' return String(v).replace(/[&<>"']/g, c => XML_ESC[c]) } // Tiny element extractor — DPD response shapes are well-defined, so a // regex-based parser is enough and avoids pulling in a heavy XML lib. // Matches the FIRST occurrence of ... (namespace-agnostic). export function xmlPick(xml: string, tag: string): string | null { const re = new RegExp(`<(?:[\\w-]+:)?${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/(?:[\\w-]+:)?${tag}>`, 'i') const m = xml.match(re) return m ? m[1] : null } // Match ALL occurrences (for parcelLabelNumber list on multi-parcel responses) export function xmlPickAll(xml: string, tag: string): string[] { const re = new RegExp(`<(?:[\\w-]+:)?${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/(?:[\\w-]+:)?${tag}>`, 'gi') const out: string[] = [] let m: RegExpExecArray | null while ((m = re.exec(xml)) !== null) out.push(m[1]) return out } // ---------------------------------------------------------------------------- // LoginService (REST) // ---------------------------------------------------------------------------- // LoginService is REST-callable but lives under /restservices/, while // ShipmentService is SOAP-only and lives under /services/. The configured // base URL is the SOAP root; rewrite to the REST root for login. function toRestBaseUrl(soapBaseUrl: string): string { if (/\/restservices(?:\/|$)/.test(soapBaseUrl)) return soapBaseUrl return soapBaseUrl.replace(/\/services(?=\/|$)/, '/restservices') } async function dpdLoginRest(creds: DpdCredentials): Promise { const url = `${toRestBaseUrl(creds.baseUrl)}/LoginService/V2_0/getAuth` const res: any = await $fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: { delisId: creds.delisId, password: creds.password, messageLanguage: 'de_DE' } }) const status = res?.status?.type if (status && status !== 'OK') { const code = res?.status?.code || 'LOGIN_FAIL' const msg = res?.status?.message || 'DPD login failed' const err: any = new Error(`DPD login error [${code}]: ${msg}`) err.dpdCode = code err.dpdMessage = msg throw err } const ret = res?.getAuthResponse?.return if (!ret?.authToken) { throw new Error('DPD login: no authToken in response') } return { authToken: String(ret.authToken), depot: String(ret.depot || creds.depot || ''), delisId: String(ret.delisId || creds.delisId), customerUid: String(ret.customerUid || creds.delisId) } } // ---------------------------------------------------------------------------- // Token caching (per delisId so multi-OrderSource setups don't clash) // ---------------------------------------------------------------------------- function tokenCookieKey(delisId: string) { return `${TOKEN_COOKIE_PREFIX}${delisId}` } function readCachedAuth(event: any, delisId: string): DpdAuth | null { const raw = getCookie(event, tokenCookieKey(delisId)) if (!raw) return null try { const parsed = JSON.parse(raw) if (!parsed?.authToken) return null return parsed } catch { return null } } function writeCachedAuth(event: any, auth: DpdAuth) { setCookie(event, tokenCookieKey(auth.delisId), JSON.stringify(auth), { maxAge: TOKEN_TTL_SECONDS, httpOnly: true, sameSite: 'lax' }) } function clearCachedAuth(event: any, delisId: string) { setCookie(event, tokenCookieKey(delisId), '', { maxAge: 0 }) } export async function dpdGetAuth(event: any, credentials?: Partial | null, forceRefresh = false): Promise<{ auth: DpdAuth; creds: DpdCredentials }> { const creds = resolveDpdCredentials(credentials) if (!forceRefresh) { const cached = readCachedAuth(event, creds.delisId) if (cached) return { auth: cached, creds } } const auth = await dpdLoginRest(creds) // Apply OrderSource-level depot override if configured if (creds.depot) auth.depot = creds.depot writeCachedAuth(event, auth) return { auth, creds } } // ---------------------------------------------------------------------------- // ShipmentService (SOAP) // ---------------------------------------------------------------------------- const SHIPMENT_NS = 'http://dpd.com/common/service/types/ShipmentService/4.4' const AUTH_NS = 'http://dpd.com/common/service/types/Authentication/2.0' const SOAP_NS = 'http://schemas.xmlsoap.org/soap/envelope/' function buildShipmentEnvelope(auth: DpdAuth, storeOrdersBodyInner: string): string { return ` ${xmlEscape(auth.delisId)} ${xmlEscape(auth.authToken)} de_DE ${storeOrdersBodyInner} ` } // Result shape from a successful storeOrders call. Mirrors the chunks of the // DHL response that downstream code already knows how to consume. export interface DpdShipmentResult { mpsId: string // DPD shipment-level id (~ DHL_Shipment_Code) parcelLabelNumbers: string[] // tracking numbers, one per parcel labelPdfBase64: string // first/primary label PDF (combined when splitByParcel=false) parcelLabels: Array<{ trackingNumber: string; pdfBase64: string }> // per-parcel labels when splitByParcel=true faults: Array<{ code: string; message: string }> raw: string // raw SOAP response for debugging } function parseFaults(xml: string): Array<{ code: string; message: string }> { const out: Array<{ code: string; message: string }> = [] // XY const blockRe = /<(?:[\w-]+:)?faults(?:\s[^>]*)?>([\s\S]*?)<\/(?:[\w-]+:)?faults>/gi let m: RegExpExecArray | null while ((m = blockRe.exec(xml)) !== null) { const inner = m[1] out.push({ code: xmlPick(inner, 'faultCode') || '', message: xmlPick(inner, 'message') || '' }) } return out } function parseSoapFault(xml: string): { code: string; message: string } | null { const faultBlock = xmlPick(xml, 'Fault') if (!faultBlock) return null return { code: xmlPick(faultBlock, 'faultcode') || xmlPick(faultBlock, 'errorCode') || 'SOAP_FAULT', message: xmlPick(faultBlock, 'faultstring') || xmlPick(faultBlock, 'errorMessage') || 'Unknown SOAP fault' } } function parseStoreOrdersResponse(xml: string): DpdShipmentResult { // In the response, the per-shipment block is named // (field name from storeOrdersResponseType, not the type name "shipmentResponse"). // Try the plural first, fall back to singular just in case. const shipmentResp = xmlPick(xml, 'shipmentResponses') || xmlPick(xml, 'shipmentResponse') || '' const mpsId = xmlPick(shipmentResp, 'mpsId') || '' const faults = parseFaults(shipmentResp) // Each parcel arrives in its own child of shipmentResponses const parcelInfoBlocks: string[] = [] const piRe = /<(?:[\w-]+:)?parcelInformation(?:\s[^>]*)?>([\s\S]*?)<\/(?:[\w-]+:)?parcelInformation>/gi let m: RegExpExecArray | null while ((m = piRe.exec(shipmentResp)) !== null) parcelInfoBlocks.push(m[1]) const parcelLabelNumbers: string[] = parcelInfoBlocks .map(b => xmlPick(b, 'parcelLabelNumber') || '') .filter(Boolean) // Per-parcel labels: when splitByParcel=true, each // carries its own . Capture all of them so the route // can return an array. const parcelLabels = parcelInfoBlocks.map(b => { const out = xmlPick(b, 'output') || '' return { trackingNumber: xmlPick(b, 'parcelLabelNumber') || '', pdfBase64: (xmlPick(out, 'content') || '').replace(/\s+/g, '') } }) // The combined label PDF lives on storeOrdersResponseType.output, sibling // of shipmentResponses (used when splitByParcel=false). const topOutput = xmlPick(xml, 'output') || '' const combinedPdf = (xmlPick(topOutput, 'content') || '').replace(/\s+/g, '') // Pick the "primary" label that older callers expect on labelPdfBase64: // combined if present, else the first per-parcel PDF. const labelPdfBase64 = combinedPdf || (parcelLabels[0]?.pdfBase64 || '') return { mpsId, parcelLabelNumbers, labelPdfBase64, parcelLabels, faults, raw: xml } } async function postSoap(creds: DpdCredentials, auth: DpdAuth, body: string): Promise { const url = `${creds.baseUrl}/ShipmentService/V4_4/` // Use raw fetch — $fetch tries to JSON-parse on success which would munge our XML const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'text/xml; charset=utf-8', Accept: 'text/xml', SOAPAction: '' }, body }) const text = await response.text() if (!response.ok) { // SOAP faults arrive as HTTP 500 with a soap:Fault body const fault = parseSoapFault(text) const err: any = new Error(fault ? `DPD SOAP fault [${fault.code}]: ${fault.message}` : `DPD HTTP ${response.status}`) err.statusCode = response.status err.dpdFault = fault err.dpdRaw = text throw err } // Even on 200 a soap:Fault could be present — check const fault = parseSoapFault(text) if (fault) { const err: any = new Error(`DPD SOAP fault [${fault.code}]: ${fault.message}`) err.dpdFault = fault err.dpdRaw = text throw err } return text } // Re-issue auth + retry once on LOGIN_5/LOGIN_6 (per DPD dev guidelines). async function storeOrdersWithRetry(event: any, creds: DpdCredentials, auth: DpdAuth, soapBody: string): Promise { try { return await postSoap(creds, auth, buildShipmentEnvelope(auth, soapBody)) } catch (err: any) { const code = err?.dpdFault?.code || '' const isAuthExpired = /LOGIN_5|LOGIN_6/i.test(code) || /authToken/i.test(err?.dpdFault?.message || '') if (!isAuthExpired) throw err clearCachedAuth(event, auth.delisId) const fresh = await dpdGetAuth(event, creds, true) return await postSoap(fresh.creds, fresh.auth, buildShipmentEnvelope(fresh.auth, soapBody)) } } /** * Send a storeOrders SOAP request. `storeOrdersBodyInner` is the XML * inside ... (printOptions + order(s)). */ export async function dpdStoreOrders( event: any, credentials: Partial | null, storeOrdersBodyInner: string ): Promise<{ result: DpdShipmentResult; auth: DpdAuth; creds: DpdCredentials }> { const { auth, creds } = await dpdGetAuth(event, credentials) const xml = await storeOrdersWithRetry(event, creds, auth, storeOrdersBodyInner) const result = parseStoreOrdersResponse(xml) return { result, auth, creds } } // ---------------------------------------------------------------------------- // Domain helpers used by the route layer // ---------------------------------------------------------------------------- // DPD wants weight in 10-gram units as integer: e.g. 3kg → 300 export function kgTo10GramUnits(kg: number | string): number { const v = parseFloat(String(kg).replace(',', '.')) if (!isFinite(v) || v <= 0) return 1 return Math.max(1, Math.round(v * 100)) } // Volume: LLLWWWHHH in cm without separators (e.g. 30cm x 20cm x 15cm → 030020015) export function buildVolumeString(lengthCm: number, widthCm: number, heightCm: number): string { const pad = (n: number) => String(Math.max(0, Math.round(n))).padStart(3, '0') return `${pad(lengthCm)}${pad(widthCm)}${pad(heightCm)}` } // ISO 3166-1 alpha-2 → 3-digit numeric (used by DPD V4_4 customsOrigin). // Covers the most common shipping origins; falls back to 276 (DE) when // the code isn't in the table. const ISO_ALPHA2_TO_NUMERIC: Record = { AD: 20, AE: 784, AF: 4, AG: 28, AL: 8, AM: 51, AO: 24, AR: 32, AT: 40, AU: 36, AZ: 31, BA: 70, BB: 52, BD: 50, BE: 56, BF: 854, BG: 100, BH: 48, BI: 108, BJ: 204, BN: 96, BO: 68, BR: 76, BS: 44, BT: 64, BW: 72, BY: 112, BZ: 84, CA: 124, CD: 180, CG: 178, CH: 756, CI: 384, CL: 152, CM: 120, CN: 156, CO: 170, CR: 188, CU: 192, CV: 132, CY: 196, CZ: 203, DE: 276, DJ: 262, DK: 208, DM: 212, DO: 214, DZ: 12, EC: 218, EE: 233, EG: 818, ER: 232, ES: 724, ET: 231, FI: 246, FJ: 242, FR: 250, GA: 266, GB: 826, GD: 308, GE: 268, GH: 288, GM: 270, GN: 324, GQ: 226, GR: 300, GT: 320, GW: 624, GY: 328, HK: 344, HN: 340, HR: 191, HT: 332, HU: 348, ID: 360, IE: 372, IL: 376, IN: 356, IQ: 368, IR: 364, IS: 352, IT: 380, JM: 388, JO: 400, JP: 392, KE: 404, KG: 417, KH: 116, KI: 296, KM: 174, KP: 408, KR: 410, KW: 414, KZ: 398, LA: 418, LB: 422, LC: 662, LI: 438, LK: 144, LR: 430, LS: 426, LT: 440, LU: 442, LV: 428, LY: 434, MA: 504, MC: 492, MD: 498, ME: 499, MG: 450, MH: 584, MK: 807, ML: 466, MM: 104, MN: 496, MR: 478, MT: 470, MU: 480, MV: 462, MW: 454, MX: 484, MY: 458, MZ: 508, NA: 516, NE: 562, NG: 566, NI: 558, NL: 528, NO: 578, NP: 524, NR: 520, NZ: 554, OM: 512, PA: 591, PE: 604, PG: 598, PH: 608, PK: 586, PL: 616, PT: 620, PW: 585, PY: 600, QA: 634, RO: 642, RS: 688, RU: 643, RW: 646, SA: 682, SB: 90, SC: 690, SD: 729, SE: 752, SG: 702, SI: 705, SK: 703, SL: 694, SM: 674, SN: 686, SO: 706, SR: 740, SS: 728, ST: 678, SV: 222, SY: 760, SZ: 748, TD: 148, TG: 768, TH: 764, TJ: 762, TL: 626, TM: 795, TN: 788, TO: 776, TR: 792, TT: 780, TV: 798, TW: 158, TZ: 834, UA: 804, UG: 800, US: 840, UY: 858, UZ: 860, VA: 336, VC: 670, VE: 862, VN: 704, VU: 548, WS: 882, YE: 887, ZA: 710, ZM: 894, ZW: 716 } export function iso2ToCustomsOrigin(alpha2: string | undefined | null): number { if (!alpha2) return 276 // DE fallback const code = String(alpha2).trim().toUpperCase() return ISO_ALPHA2_TO_NUMERIC[code] ?? 276 } // Convert dimension value to cm (matches DHL logic): // - No decimal point → value is in mm → divide by 10 // - Has decimal (. or ,) → value is in meters → multiply by 100 export function toCm(val: any): number { if (val === null || val === undefined || val === '') return 0 const raw = String(val).trim() if (raw === '' || raw === '0') return 0 const hasDecimal = raw.includes('.') || raw.includes(',') const numeric = parseFloat(raw.replace(',', '.')) if (isNaN(numeric) || numeric <= 0) return 0 const cm = hasDecimal ? numeric * 100 : numeric / 10 return parseFloat(cm.toFixed(2)) } // Look up DPD credential overrides on a C_OrderSource record. Returns null if // DPD_USE_CUSTOM is not enabled. Returns a per-field breakdown when enabled so // the route can give the user a precise diagnostic. export interface OrderSourceDpdExtraction { creds: Partial missing: string[] // names of required iDempiere columns that are empty } const isBlank = (v: any) => v === null || v === undefined || String(v).trim() === '' export function extractOrderSourceDpdCreds(orderSource: any): OrderSourceDpdExtraction | null { if (!orderSource) return null if (orderSource.DPD_USE_CUSTOM !== true && orderSource.DPD_USE_CUSTOM !== 'Y') return null const missing: string[] = [] if (isBlank(orderSource.DPDDELISID)) missing.push('DPDDELISID') if (isBlank(orderSource.DPDPASSWORD)) missing.push('DPDPASSWORD') // DPDURL, DPDCUSTOMERNUMBER, DPDDEPOT are optional (helper has sensible defaults) return { creds: { baseUrl: isBlank(orderSource.DPDURL) ? undefined : String(orderSource.DPDURL).trim(), delisId: isBlank(orderSource.DPDDELISID) ? undefined : String(orderSource.DPDDELISID).trim(), password: isBlank(orderSource.DPDPASSWORD) ? undefined : String(orderSource.DPDPASSWORD), customerNumber: isBlank(orderSource.DPDCUSTOMERNUMBER) ? undefined : String(orderSource.DPDCUSTOMERNUMBER).trim(), depot: isBlank(orderSource.DPDDEPOT) ? undefined : String(orderSource.DPDDEPOT).trim() }, missing } }