'use strict'; // Provider-agnostic shipping adapter. Sendcloud is the first implementation; // add more providers behind getProvider() (e.g. shipcloud) without touching the // server routes. Uses global fetch (Node >= 22, see package.json engines). // // SAFETY: buyLabel() creates a *billable* label at the carrier. It is only ever // called from POST /api/ship/label, which the user triggers explicitly. const SENDCLOUD_BASE = 'https://panel.sendcloud.sc/api/v2'; // EU-only shipping: a destination is resolved to an ISO-3166 alpha-2 code and // checked against this EU-27 allowlist, so a non-EU address is refused before a // billable label is ever bought. // // NOTE: this is a hardcoded business policy, not a product rule. A lab outside // the EU — Switzerland, the UK, the US — cannot buy a label at all, because its // own home market is rejected here before Sendcloud is even asked. The right // home for it is shipping_config (which already carries provider, sender address // and default method); until then, forks shipping elsewhere must edit this set. const _EU_ISO2 = new Set([ 'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE' ]); // Common country names (DE/EN) → ISO-3166 alpha-2, so a channel that sends a full // name (notably Wix) still resolves. Non-EU names are listed too, only so the // EU-only guard can reject them with a clear message instead of "unknown country". const _COUNTRY_ISO2 = { germany: 'DE', deutschland: 'DE', austria: 'AT', österreich: 'AT', oesterreich: 'AT', france: 'FR', frankreich: 'FR', italy: 'IT', italien: 'IT', spain: 'ES', spanien: 'ES', portugal: 'PT', netherlands: 'NL', niederlande: 'NL', 'the netherlands': 'NL', holland: 'NL', belgium: 'BE', belgien: 'BE', belgique: 'BE', luxembourg: 'LU', luxemburg: 'LU', denmark: 'DK', dänemark: 'DK', daenemark: 'DK', sweden: 'SE', schweden: 'SE', finland: 'FI', finnland: 'FI', poland: 'PL', polen: 'PL', 'czech republic': 'CZ', czechia: 'CZ', tschechien: 'CZ', slovakia: 'SK', slowakei: 'SK', slovenia: 'SI', slowenien: 'SI', hungary: 'HU', ungarn: 'HU', croatia: 'HR', kroatien: 'HR', romania: 'RO', rumänien: 'RO', rumaenien: 'RO', bulgaria: 'BG', bulgarien: 'BG', greece: 'GR', griechenland: 'GR', ireland: 'IE', irland: 'IE', estonia: 'EE', estland: 'EE', latvia: 'LV', lettland: 'LV', lithuania: 'LT', litauen: 'LT', cyprus: 'CY', zypern: 'CY', malta: 'MT', // Non-EU — listed only so the EU-only guard rejects them clearly (we do not ship here). switzerland: 'CH', schweiz: 'CH', suisse: 'CH', 'united kingdom': 'GB', 'great britain': 'GB', uk: 'GB', england: 'GB', grossbritannien: 'GB', großbritannien: 'GB', norway: 'NO', norwegen: 'NO', 'united states': 'US', 'united states of america': 'US', usa: 'US', canada: 'CA', kanada: 'CA' }; // Resolve a destination to an EU ISO-3166 alpha-2 code, or throw. EU-only, so a // non-EU destination (UK, Switzerland, …) is refused here rather than buying a // label that should never go out. function _iso2Country(v) { const s = String(v == null ? '' : v).trim(); if (!s) throw new Error('Zielland fehlt — bitte Lieferland angeben (ISO-Ländercode, z. B. DE).'); const code = /^[A-Za-z]{2}$/.test(s) ? s.toUpperCase() : _COUNTRY_ISO2[s.toLowerCase()]; if (!code) throw new Error('Unbekanntes Zielland: "' + s + '" — bitte ISO-Ländercode verwenden (z. B. DE).'); if (!_EU_ISO2.has(code)) { throw new Error('Versand nur innerhalb der EU — "' + s + '" (' + code + ') ist ausgeschlossen.'); } return code; } // Buying a label is billable and irreversible-ish: Sendcloud accepts a parcel with // a blank house number or an empty street and happily charges for it, and the // carrier then cannot deliver it. Everything the address needs is checked here, // before the call goes out, so an incomplete order stops with a message naming the // missing field instead of costing postage. The buy-label dialog can send an // `address` override, so the fix is one screen away. function _requireAddress(order) { const missing = []; if (!String(order.shipName || order.customerName || '').trim()) missing.push('Name'); if (!String(order.shipStreet || '').trim()) missing.push('Straße'); if (!String(order.shipHouse || '').trim()) missing.push('Hausnummer'); if (!String(order.shipPostal || '').trim()) missing.push('PLZ'); if (!String(order.shipCity || '').trim()) missing.push('Ort'); if (missing.length) { throw new Error( 'Adresse unvollständig — es fehlt: ' + missing.join(', ') + '. Bitte im Versand-Dialog ergänzen und erneut kaufen.' ); } } function scAuth(cfg) { return 'Basic ' + Buffer.from((cfg.publicKey || '') + ':' + (cfg.secretKey || '')).toString('base64'); } // Every outbound Sendcloud call gets a hard timeout: without it a stalled request // hangs for minutes and a label buy would wedge the caller's in-flight lock. // AbortSignal.timeout aborts the fetch, surfacing as a normal caught error. const FETCH_TIMEOUT_MS = 15000; function tfetch(url, opts) { return globalThis.fetch(url, { ...opts, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); } async function scFetch(cfg, method, path, body) { const res = await tfetch(SENDCLOUD_BASE + path, { method, headers: { Authorization: scAuth(cfg), 'Content-Type': 'application/json', Accept: 'application/json' }, body: body ? JSON.stringify(body) : undefined }); const text = await res.text(); let json = null; try { json = text ? JSON.parse(text) : null; } catch (e) { /* non-JSON body */ } if (!res.ok) { const e = (json && json.error) || {}; const msg = e.message || (typeof e === 'string' ? e : '') || 'Sendcloud HTTP ' + res.status; const err = new Error(msg); err.status = res.status; throw err; } return json; } const sendcloud = { async testConnection(cfg) { const j = await scFetch(cfg, 'GET', '/user'); const u = (j && j.user) || {}; return { ok: true, account: u.username || u.company_name || 'ok' }; }, async listMethods(cfg, { toCountry = 'DE', fromCountry = 'DE', weightG } = {}) { const path = '/shipping_methods?to_country=' + encodeURIComponent(toCountry) + '&from_country=' + encodeURIComponent(fromCountry); const j = await scFetch(cfg, 'GET', path); let arr = (j && j.shipping_methods) || []; if (weightG) { const w = weightG / 1000; arr = arr.filter((m) => { const lo = m.min_weight != null ? parseFloat(m.min_weight) : 0; const hi = m.max_weight != null ? parseFloat(m.max_weight) : 1e9; return w >= lo && w <= hi; }); } return arr.map((m) => ({ id: m.id, name: m.name, carrier: m.carrier, minWeight: m.min_weight, maxWeight: m.max_weight })); }, async buyLabel(cfg, { order, methodId, weightG, requestLabel }) { // Destination policy first: for a country we refuse outright, "we don't ship // there" is a clearer answer than "your address is incomplete". Completeness // is only worth checking for a destination we would actually serve. const country = _iso2Country(order.shipCountry); _requireAddress(order); const weightKg = ((weightG || cfg.defaultWeightG || 1000) / 1000).toFixed(3); const parcel = { name: order.shipName || order.customerName || 'Customer', company_name: order.shipCompany || '', address: order.shipStreet || '', house_number: order.shipHouse || '', address_2: order.shipAddress2 || '', city: order.shipCity || '', postal_code: order.shipPostal || '', country, telephone: order.shipPhone || '', email: order.customerEmail || '', order_number: order.channel ? order.channel + '-' + (order.channelOrderId || order.id) : String(order.id || ''), weight: weightKg, // request_label:false = announce only (no billable label) — used for test mode. request_label: requestLabel !== false, shipment: { id: Number(methodId) } }; if (cfg.senderAddressId) parcel.sender_address = Number(cfg.senderAddressId); const j = await scFetch(cfg, 'POST', '/parcels', { parcel }); const p = (j && j.parcel) || {}; const label = p.label || {}; // label_printer = single PDF sized for label printers (~A6 / 100x150); // normal_printer = array of A4 PDF URLs. Prefer the label-printer size. const labelUrl = label.label_printer || (Array.isArray(label.normal_printer) && label.normal_printer[0]) || null; return { providerParcelId: p.id != null ? String(p.id) : null, carrier: (p.carrier && (p.carrier.code || p.carrier.name)) || null, methodName: (p.shipment && p.shipment.name) || null, trackingNumber: p.tracking_number || null, trackingUrl: p.tracking_url || null, labelUrl, labelFormat: label.label_printer ? 'pdf_a6' : 'pdf_a4', status: requestLabel === false ? 'announced' : 'created' }; }, async fetchLabelPdf(cfg, url) { // labelUrl comes from an earlier Sendcloud response; only ever send the Basic // credentials to Sendcloud's own host (new URL() also rejects a malformed url // before any auth header goes out). if (new URL(url).host !== 'panel.sendcloud.sc') { throw new Error('Label-URL nicht von Sendcloud — Anfrage abgelehnt.'); } const res = await tfetch(url, { headers: { Authorization: scAuth(cfg) } }); if (!res.ok) throw new Error('label fetch HTTP ' + res.status); return Buffer.from(await res.arrayBuffer()); } }; function getProvider(cfg) { const p = (cfg && cfg.provider) || 'sendcloud'; if (p === 'sendcloud') return sendcloud; throw new Error('unknown shipping provider: ' + p); } module.exports = { getProvider, sendcloud, _scFetch: scFetch, SENDCLOUD_BASE };