Ctrl K
ComunidadGuías…

shipping.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
'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 };