shipping.js
Rama main · réplica de lectura del repositorio original.
'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 };