Ctrl K
ComunidadGuías…

channels.js

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

Archivo original ↗Preguntar a KiBot
'use strict';
const crypto = require('crypto');
// Provider-agnostic sales-channel sync. Each provider fetches orders and returns
// them normalized to the db.upsertOrder() shape so order/customer/item insertion
// is reused. Uses global fetch (Node >= 22, see package.json engines).
//
// Wix: API key + Site ID (no OAuth) — works anywhere, incl. the worktree.
// eBay/Etsy: OAuth 2.0 — added with their auth flows (stubs throw clearly here).

const WIX_BASE = 'https://www.wixapis.com';
// Etsy Open API v3 (OAuth2 with PKCE — public app, keystring is the client_id, no
// secret). Token host is api.etsy.com; data endpoints live under openapi.etsy.com.
const ETSY_CONNECT_URL = 'https://www.etsy.com/oauth/connect';
const ETSY_TOKEN_URL = 'https://api.etsy.com/v3/public/oauth/token';
const ETSY_API = 'https://openapi.etsy.com/v3/application';
const ETSY_SCOPES = 'transactions_r transactions_w';
// eBay Sell Fulfillment API (OAuth2 authorization-code; redirect_uri in the flow is
// the RuName, not the literal URL — we store the RuName in the site_id column).
const EBAY_AUTHORIZE_URL = 'https://auth.ebay.com/oauth2/authorize';
const EBAY_TOKEN_URL = 'https://api.ebay.com/identity/v1/oauth2/token';
const EBAY_API = 'https://api.ebay.com/sell/fulfillment/v1';
const EBAY_SCOPES = 'https://api.ebay.com/oauth/api_scope/sell.fulfillment';
const EBAY_MARKETPLACE = 'EBAY_DE';
// Cache the Etsy shop id per access token — avoids an extra /users/{id}/shops call
// on every sync page and every write-back (Etsy's rate limit is tight, ~10/s).
const _etsyShopCache = new Map();

// Every outbound provider call gets a hard timeout: without it a stalled request
// hangs for minutes and (for a label buy) wedges 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) });
}

function _wixStatus(o) {
  const s = String(o.status || '').toUpperCase();
  if (s === 'CANCELED' || s === 'CANCELLED') return 'cancelled';
  const f = String(o.fulfillmentStatus || '').toUpperCase();
  if (f === 'FULFILLED') return 'shipped';
  return 'new';
}

// Wix aggregates orders from connected channels; channelInfo.type carries the
// true origin (WEB / EBAY / ETSY / AMAZON / POS …). Attribute the order to it so
// the hub shows the real channel — a single Wix key then covers all of them.
function _wixOriginChannel(o) {
  const t = ((o.channelInfo && (o.channelInfo.type || o.channelInfo.channelType)) || '').toUpperCase();
  if (t === 'EBAY') return 'ebay';
  if (t === 'ETSY') return 'etsy';
  return 'wix';
}

// Map one Wix eCommerce order (ecom/v1) to the upsertOrder shape. Field paths are
// best-effort against the documented API; verify against a real order and adjust.
function _normalizeWix(o) {
  const rec = o.recipientInfo || {};
  const contact = rec.contactDetails || (o.billingInfo && o.billingInfo.contactDetails) || {};
  const addr =
    rec.address ||
    (rec.shippingDestination && rec.shippingDestination.address) ||
    (o.billingInfo && o.billingInfo.address) ||
    {};
  const name = [contact.firstName, contact.lastName].filter(Boolean).join(' ') || null;
  const total =
    o.priceSummary && o.priceSummary.total && o.priceSummary.total.amount != null
      ? parseFloat(o.priceSummary.total.amount)
      : null;
  let street = addr.streetAddress ? addr.streetAddress.name : addr.addressLine1 || addr.addressLine || null;
  let house = addr.streetAddress && addr.streetAddress.number ? addr.streetAddress.number : null;
  // Wix fills streetAddress.number on only a minority of orders; the rest carry
  // the number inside the street line. Use the shared splitter so Wix, Etsy and
  // eBay all resolve house numbers identically.
  if (street && !house) {
    const sp = _splitHouse(street);
    street = sp.street;
    house = sp.house;
  }
  const items = (o.lineItems || []).map((li) => ({
    channelSku:
      (li.physicalProperties && li.physicalProperties.sku) ||
      (li.catalogReference && li.catalogReference.catalogItemId) ||
      null,
    listingId: (li.catalogReference && li.catalogReference.catalogItemId) || null,
    title: (li.productName && (li.productName.original || li.productName.translated)) || li.itemName || null,
    qty: li.quantity || 1,
    unitPrice: li.price && li.price.amount != null ? parseFloat(li.price.amount) : null
  }));
  return {
    channel: _wixOriginChannel(o),
    channelOrderId: o.number != null ? String(o.number) : String(o.id),
    status: _wixStatus(o),
    orderDate: o.createdDate || o._createdDate || o.dateCreated || null,
    customerName: name,
    customerEmail: (o.buyerInfo && o.buyerInfo.email) || contact.email || null,
    shipCountry: addr.country || null,
    totalAmount: total,
    currency: o.currency || (o.priceSummary && o.priceSummary.total && o.priceSummary.total.currency) || null,
    shipName: name,
    shipCompany: contact.company || null,
    shipStreet: street,
    shipHouse: house,
    shipAddress2: addr.addressLine2 || null,
    shipCity: addr.city || null,
    shipPostal: addr.postalCode || null,
    shipPhone: contact.phone || null,
    raw: o,
    items
  };
}

async function _wixSearch(cfg, cursorPaging) {
  const headers = {
    Authorization: cfg.apiKey,
    'wix-site-id': cfg.siteId,
    'Content-Type': 'application/json'
  };
  // API-key calls to Wix usually also require the account id. For Wix we store it
  // in the (otherwise-unused) client_id column.
  if (cfg.clientId) headers['wix-account-id'] = cfg.clientId;
  const res = await tfetch(WIX_BASE + '/ecom/v1/orders/search', {
    method: 'POST',
    headers,
    body: JSON.stringify({ search: { cursorPaging } })
  });
  const text = await res.text();
  if (!res.ok) throw new Error('Wix HTTP ' + res.status);
  return text ? JSON.parse(text) : {};
}

const wix = {
  async testConnection(cfg) {
    if (!cfg.apiKey || !cfg.siteId) throw new Error('API-Key + Site-ID erforderlich');
    const j = await _wixSearch(cfg, { limit: 1 });
    return { ok: true, account: cfg.siteId, sample: (j.orders || []).length };
  },
  async fetchOrders(cfg, { cursor } = {}) {
    if (!cfg.apiKey || !cfg.siteId) throw new Error('API-Key + Site-ID erforderlich');
    const j = await _wixSearch(cfg, cursor ? { cursor } : { limit: 50 });
    // Import only Wix-website (WEB) orders — eBay/Etsy are pulled from their own
    // APIs directly, so we skip Wix's (now-stale) aggregated copies of them.
    const orders = (j.orders || []).map(_normalizeWix).filter((o) => o.channel === 'wix');
    const nextCursor = (j.metadata && j.metadata.cursors && j.metadata.cursors.next) || null;
    return { orders, nextCursor };
  },
  // Push the Sendungsnummer back onto the Wix order (creates a fulfillment with
  // tracking). Same API key — no OAuth. Best-effort; the caller records the
  // outcome and never fails the label purchase over a write-back error.
  async pushTracking(cfg, { raw, trackingNumber, trackingUrl, carrier }) {
    if (!cfg.apiKey || !cfg.siteId) throw new Error('Wix nicht konfiguriert');
    const wixOrderId = raw && raw.id;
    if (!wixOrderId) throw new Error('Wix-Order-ID fehlt');
    const lineItems = (raw.lineItems || []).map((li) => ({ id: li.id, quantity: li.quantity || 1 }));
    const headers = {
      Authorization: cfg.apiKey,
      'wix-site-id': cfg.siteId,
      'Content-Type': 'application/json'
    };
    if (cfg.clientId) headers['wix-account-id'] = cfg.clientId;
    const body = {
      fulfillment: {
        lineItems,
        trackingInfo: {
          trackingNumber: trackingNumber || '',
          shippingProvider: carrier || 'other',
          trackingLink: trackingUrl || undefined
        }
      }
    };
    const res = await tfetch(
      WIX_BASE + '/ecom/v1/fulfillments/orders/' + encodeURIComponent(wixOrderId) + '/create-fulfillment',
      { method: 'POST', headers, body: JSON.stringify(body) }
    );
    if (!res.ok) throw new Error('Wix fulfillment HTTP ' + res.status);
    return { ok: true };
  }
};

// ── Shared helpers (eBay + Etsy) ─────────────────────────────────────────────
function _form(obj) {
  return Object.entries(obj)
    .filter(([, v]) => v != null && v !== '')
    .map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v))
    .join('&');
}
function _b64url(buf) {
  return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
// PKCE pair for Etsy: a 43-char verifier and its S256 challenge.
function pkcePair() {
  const verifier = _b64url(crypto.randomBytes(32));
  const challenge = _b64url(crypto.createHash('sha256').update(verifier).digest());
  return { verifier, challenge };
}
function _expiryIso(expiresInSec) {
  // OAuth token lifetime (seconds). A missing or non-numeric value must NOT be
  // treated as 0 — that stamps the token as already-expired, forcing a refresh
  // before every single call (and `new Date(NaN)` would throw). Fall back to 1h.
  const n = Number(expiresInSec);
  const secs = Number.isFinite(n) && n > 0 ? n : 3600;
  return new Date(Date.now() + secs * 1000).toISOString();
}
async function _json(res, label) {
  const text = await res.text();
  if (!res.ok) throw new Error(label + ' HTTP ' + res.status);
  return text ? JSON.parse(text) : {};
}
// Channels rarely send a separate house number; it arrives embedded in the street
// line. German-style addresses put it last ("Musterweg 18"), while French, Dutch,
// Belgian and Irish ones put it first ("12 Rue des Exemples") — and we ship EU-wide,
// so both occur. Try trailing first (the local convention and the common case),
// then leading. A range ("18-20") or a letter suffix ("18a") counts as one house
// number. Anything matching neither is left alone rather than guessed at: a wrong
// house number prints a wrong label, and the shipping guard would rather stop and
// ask than ship to an address we invented. Shared by all three channels.
const _HOUSE_NUM = '\\d+\\s*(?:[-/]\\s*\\d+)?\\s*[a-zA-Z]?';
function _splitHouse(street) {
  if (!street) return { street: street || null, house: null };
  const s = String(street).trim();
  const trailing = s.match(new RegExp('^(.*\\S)\\s+(' + _HOUSE_NUM + ')$'));
  if (trailing) return { street: trailing[1], house: trailing[2].replace(/\s+/g, '') };
  // Leading form: the remainder must start with a non-digit, so "12 3rd Ave" is
  // left for a human rather than split at the wrong number.
  const leading = s.match(new RegExp('^(' + _HOUSE_NUM + ')\\s+(\\D.*\\S)$'));
  if (leading) return { street: leading[2], house: leading[1].replace(/\s+/g, '') };
  return { street, house: null };
}

// ── OAuth: authorize URL + code exchange (driven by the server routes) ────────
function buildAuthorizeUrl(channel, cfg, { redirectUri, state, codeChallenge }) {
  if (channel === 'etsy') {
    if (!cfg.clientId) throw new Error('Etsy Keystring fehlt');
    return (
      ETSY_CONNECT_URL +
      '?' +
      _form({
        response_type: 'code',
        client_id: cfg.clientId,
        redirect_uri: redirectUri,
        scope: ETSY_SCOPES,
        state,
        code_challenge: codeChallenge,
        code_challenge_method: 'S256'
      })
    );
  }
  if (channel === 'ebay') {
    if (!cfg.clientId) throw new Error('eBay App-ID fehlt');
    if (!cfg.siteId) throw new Error('eBay RuName fehlt');
    return (
      EBAY_AUTHORIZE_URL +
      '?' +
      _form({
        client_id: cfg.clientId,
        response_type: 'code',
        redirect_uri: cfg.siteId, // eBay RuName, stored in the site_id column
        scope: EBAY_SCOPES,
        state
      })
    );
  }
  throw new Error('OAuth not supported for channel: ' + channel);
}
async function exchangeCode(channel, cfg, { code, redirectUri, codeVerifier }) {
  if (channel === 'etsy') {
    const res = await tfetch(ETSY_TOKEN_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: _form({
        grant_type: 'authorization_code',
        client_id: cfg.clientId,
        redirect_uri: redirectUri,
        code,
        code_verifier: codeVerifier
      })
    });
    const j = await _json(res, 'Etsy token');
    if (!j.access_token) throw new Error('Etsy token: kein access_token in der Antwort');
    return { accessToken: j.access_token, refreshToken: j.refresh_token, tokenExpires: _expiryIso(j.expires_in) };
  }
  if (channel === 'ebay') {
    const basic = Buffer.from((cfg.clientId || '') + ':' + (cfg.clientSecret || '')).toString('base64');
    const res = await tfetch(EBAY_TOKEN_URL, {
      method: 'POST',
      headers: { Authorization: 'Basic ' + basic, 'Content-Type': 'application/x-www-form-urlencoded' },
      body: _form({ grant_type: 'authorization_code', code, redirect_uri: cfg.siteId })
    });
    const j = await _json(res, 'eBay token');
    if (!j.access_token) throw new Error('eBay token: kein access_token in der Antwort');
    return { accessToken: j.access_token, refreshToken: j.refresh_token, tokenExpires: _expiryIso(j.expires_in) };
  }
  throw new Error('OAuth not supported for channel: ' + channel);
}

// ── Etsy (Open API v3, OAuth2 PKCE) ──────────────────────────────────────────
function _etsyHeaders(cfg, extra) {
  return Object.assign({ 'x-api-key': cfg.clientId, Authorization: 'Bearer ' + cfg.accessToken }, extra || {});
}
// The access token is "{user_id}.{token}"; the prefix is the Etsy user id.
function _etsyUserId(cfg) {
  const at = cfg.accessToken || '';
  const dot = at.indexOf('.');
  return dot > 0 ? at.slice(0, dot) : null;
}
async function _etsyShopId(cfg) {
  const uid = _etsyUserId(cfg);
  if (!uid) throw new Error('Etsy nicht verbunden');
  const cached = _etsyShopCache.get(cfg.accessToken);
  if (cached) return cached;
  const res = await tfetch(ETSY_API + '/users/' + encodeURIComponent(uid) + '/shops', { headers: _etsyHeaders(cfg) });
  const j = await _json(res, 'Etsy shop');
  const shop = Array.isArray(j.results) ? j.results[0] : j;
  const shopId = shop && (shop.shop_id || shop.shopId);
  if (!shopId) throw new Error('Etsy Shop-ID nicht gefunden');
  if (_etsyShopCache.size > 50) _etsyShopCache.clear();
  _etsyShopCache.set(cfg.accessToken, shopId);
  return shopId;
}
// Etsy money is { amount, divisor, currency_code } (amount is in minor units).
function _etsyMoney(m) {
  if (!m || m.amount == null) return null;
  return Math.round((m.amount / (m.divisor || 100)) * 100) / 100;
}
// Best-effort Sendcloud-carrier → Etsy carrier_name. Verify against Etsy's
// getShippingCarriers for your shipping country and tune as needed.
function _etsyCarrier(carrier) {
  const c = String(carrier || '').toLowerCase();
  if (c.includes('dhl')) return 'dhl-germany';
  if (c.includes('dpd')) return 'dpd-de';
  if (c.includes('hermes')) return 'hermes-de';
  if (c.includes('gls')) return 'gls-de';
  if (c.includes('ups')) return 'ups';
  if (c.includes('post')) return 'deutsche-post';
  return c || 'other';
}
function _normalizeEtsy(r) {
  const sp = _splitHouse(r.first_line || null);
  const created = r.created_timestamp != null ? r.created_timestamp : r.create_timestamp;
  const items = (r.transactions || []).map((t) => ({
    channelSku: t.sku || (t.product_data && t.product_data.sku) || null,
    listingId: t.listing_id != null ? String(t.listing_id) : null,
    title: t.title || null,
    qty: t.quantity || 1,
    unitPrice: _etsyMoney(t.price)
  }));
  return {
    channel: 'etsy',
    channelOrderId: String(r.receipt_id),
    status: r.is_shipped ? 'shipped' : 'new',
    orderDate: created != null ? new Date(created * 1000).toISOString() : null,
    customerName: r.name || null,
    customerEmail: r.buyer_email || null,
    // Stable Etsy buyer id → dedup key when the email is absent (see upsertCustomerFromOrder).
    buyerHandle: r.buyer_user_id != null ? String(r.buyer_user_id) : null,
    shipCountry: r.country_iso || null,
    totalAmount: _etsyMoney(r.grandtotal),
    currency: (r.grandtotal && r.grandtotal.currency_code) || null,
    shipName: r.name || null,
    shipStreet: sp.street,
    shipHouse: sp.house,
    shipAddress2: r.second_line || null,
    shipCity: r.city || null,
    shipPostal: r.zip || null,
    raw: r,
    items
  };
}
const etsy = {
  async testConnection(cfg) {
    if (!cfg.clientId) throw new Error('Etsy Keystring fehlt');
    if (!cfg.accessToken) throw new Error('Etsy nicht verbunden — bitte „Mit Etsy verbinden" klicken');
    const shopId = await _etsyShopId(cfg);
    return { ok: true, account: 'Shop ' + shopId };
  },
  // Open (paid, unshipped) receipts → upsertOrder shape. Pages via offset cursor.
  async fetchOrders(cfg, { cursor } = {}) {
    if (!cfg.accessToken) throw new Error('Etsy nicht verbunden');
    const shopId = await _etsyShopId(cfg);
    const limit = 100;
    const offset = cursor ? parseInt(cursor, 10) || 0 : 0;
    const url =
      ETSY_API +
      '/shops/' +
      encodeURIComponent(shopId) +
      '/receipts?was_paid=true&was_shipped=false&limit=' +
      limit +
      '&offset=' +
      offset;
    const j = await _json(await tfetch(url, { headers: _etsyHeaders(cfg) }), 'Etsy receipts');
    const results = j.results || [];
    const orders = results.map(_normalizeEtsy);
    const got = offset + results.length;
    // Page by Etsy's reported total, not page-fullness: a short page that is still
    // below `count` must not stop paging early (which would silently drop orders).
    // results.length > 0 guards against an infinite loop on an unexpected empty page.
    const nextCursor = j.count != null && got < j.count && results.length > 0 ? String(got) : null;
    return { orders, nextCursor };
  },
  // Write the Sendungsnummer back via createReceiptShipment. Best-effort.
  async pushTracking(cfg, { raw, trackingNumber, carrier }) {
    if (!cfg.accessToken) throw new Error('Etsy nicht verbunden');
    const receiptId = raw && raw.receipt_id;
    if (!receiptId) throw new Error('Etsy Receipt-ID fehlt');
    const shopId = await _etsyShopId(cfg);
    const res = await tfetch(
      ETSY_API + '/shops/' + encodeURIComponent(shopId) + '/receipts/' + encodeURIComponent(receiptId) + '/tracking',
      {
        method: 'POST',
        headers: _etsyHeaders(cfg, { 'Content-Type': 'application/x-www-form-urlencoded' }),
        body: _form({ tracking_code: trackingNumber || '', carrier_name: _etsyCarrier(carrier) })
      }
    );
    await _json(res, 'Etsy tracking');
    return { ok: true };
  },
  async refreshAccessToken(cfg) {
    const res = await tfetch(ETSY_TOKEN_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: _form({ grant_type: 'refresh_token', client_id: cfg.clientId, refresh_token: cfg.refreshToken })
    });
    const j = await _json(res, 'Etsy refresh');
    // A 2xx body without an access_token must not be persisted — it would wipe the
    // working token and brick every later call. Surface it as an error instead.
    if (!j.access_token) throw new Error('Etsy refresh: kein access_token in der Antwort');
    // Etsy rotates the refresh token; keep the stored one if the response omits it
    // (a null/empty value would otherwise brick all future refreshes).
    return {
      accessToken: j.access_token,
      refreshToken: j.refresh_token || cfg.refreshToken,
      tokenExpires: _expiryIso(j.expires_in)
    };
  }
};

// ── eBay (Sell Fulfillment API, OAuth2 authorization-code) ───────────────────
function _ebayHeaders(cfg, extra) {
  return Object.assign(
    { Authorization: 'Bearer ' + cfg.accessToken, 'X-EBAY-C-MARKETPLACE-ID': EBAY_MARKETPLACE },
    extra || {}
  );
}
// Best-effort Sendcloud-carrier → eBay shippingCarrierCode (uppercase enum).
function _ebayCarrier(carrier) {
  const c = String(carrier || '').toLowerCase();
  if (c.includes('dhl')) return 'DHL';
  if (c.includes('dpd')) return 'DPD';
  if (c.includes('hermes')) return 'HERMES';
  if (c.includes('gls')) return 'GLS';
  if (c.includes('ups')) return 'UPS';
  if (c.includes('post')) return 'DEUTSCHE_POST';
  return String(carrier || '').toUpperCase() || 'OTHER';
}
function _normalizeEbay(o) {
  const fsi = (o.fulfillmentStartInstructions && o.fulfillmentStartInstructions[0]) || {};
  const shipTo = (fsi.shippingStep && fsi.shippingStep.shipTo) || {};
  const addr = shipTo.contactAddress || {};
  const total = o.pricingSummary && o.pricingSummary.total;
  const sp = _splitHouse(addr.addressLine1 || null);
  const items = (o.lineItems || []).map((li) => ({
    channelSku: li.sku || null,
    listingId: li.legacyItemId != null ? String(li.legacyItemId) : null,
    title: li.title || null,
    qty: li.quantity || 1,
    unitPrice: li.lineItemCost && li.lineItemCost.value != null ? parseFloat(li.lineItemCost.value) : null
  }));
  return {
    channel: 'ebay',
    channelOrderId: String(o.orderId),
    // We only ever fetch unshipped orders (NOT_STARTED|IN_PROGRESS).
    status: 'new',
    orderDate: o.creationDate || null,
    customerName: shipTo.fullName || (o.buyer && o.buyer.username) || null,
    customerEmail: shipTo.email || null,
    // eBay usually masks the buyer email; the username is the stable dedup key
    // (see upsertCustomerFromOrder), otherwise every sync creates a new customer.
    buyerHandle: (o.buyer && o.buyer.username) || null,
    shipCountry: addr.countryCode || null,
    totalAmount: total && total.value != null ? parseFloat(total.value) : null,
    currency: (total && total.currency) || null,
    shipName: shipTo.fullName || null,
    shipStreet: sp.street,
    shipHouse: sp.house,
    shipAddress2: addr.addressLine2 || null,
    shipCity: addr.city || null,
    shipPostal: addr.postalCode || null,
    shipPhone: (shipTo.primaryPhone && shipTo.primaryPhone.phoneNumber) || null,
    raw: o,
    items
  };
}
const ebay = {
  async testConnection(cfg) {
    if (!cfg.clientId || !cfg.clientSecret) throw new Error('eBay App-ID + Cert-ID fehlen');
    if (!cfg.siteId) throw new Error('eBay RuName fehlt');
    if (!cfg.accessToken) throw new Error('eBay nicht verbunden — bitte „Mit eBay verbinden" klicken');
    const j = await _json(await tfetch(EBAY_API + '/order?limit=1', { headers: _ebayHeaders(cfg) }), 'eBay test');
    return { ok: true, account: 'Bestellungen: ' + (j.total != null ? j.total : (j.orders || []).length) };
  },
  // Unfulfilled orders → upsertOrder shape. Pages via offset cursor.
  async fetchOrders(cfg, { cursor } = {}) {
    if (!cfg.accessToken) throw new Error('eBay nicht verbunden');
    const limit = 50;
    const filter = encodeURIComponent('orderfulfillmentstatus:{NOT_STARTED|IN_PROGRESS}');
    let url = EBAY_API + '/order?filter=' + filter + '&limit=' + limit;
    if (cursor) url += '&offset=' + encodeURIComponent(cursor);
    const j = await _json(await tfetch(url, { headers: _ebayHeaders(cfg) }), 'eBay orders');
    const orders = (j.orders || []).map(_normalizeEbay);
    let nextCursor = null;
    const off = j.offset != null ? j.offset : cursor ? parseInt(cursor, 10) || 0 : 0;
    if (j.total != null && off + (j.limit || limit) < j.total) nextCursor = String(off + (j.limit || limit));
    return { orders, nextCursor };
  },
  // Write the Sendungsnummer back via createShippingFulfillment. Best-effort.
  async pushTracking(cfg, { raw, order, trackingNumber, carrier }) {
    if (!cfg.accessToken) throw new Error('eBay nicht verbunden');
    const orderId = (raw && raw.orderId) || (order && order.channelOrderId);
    if (!orderId) throw new Error('eBay Order-ID fehlt');
    const lineItems = ((raw && raw.lineItems) || []).map((li) => ({
      lineItemId: li.lineItemId,
      quantity: li.quantity || 1
    }));
    const body = {
      lineItems,
      shippedDate: new Date().toISOString(),
      shippingCarrierCode: _ebayCarrier(carrier),
      shipmentTrackingNumber: trackingNumber || ''
    };
    const res = await tfetch(EBAY_API + '/order/' + encodeURIComponent(orderId) + '/shipping_fulfillment', {
      method: 'POST',
      headers: _ebayHeaders(cfg, { 'Content-Type': 'application/json' }),
      body: JSON.stringify(body)
    });
    await _json(res, 'eBay fulfillment'); // 201 Created, empty body
    return { ok: true };
  },
  async refreshAccessToken(cfg) {
    const basic = Buffer.from((cfg.clientId || '') + ':' + (cfg.clientSecret || '')).toString('base64');
    const res = await tfetch(EBAY_TOKEN_URL, {
      method: 'POST',
      headers: { Authorization: 'Basic ' + basic, 'Content-Type': 'application/x-www-form-urlencoded' },
      body: _form({ grant_type: 'refresh_token', refresh_token: cfg.refreshToken, scope: EBAY_SCOPES })
    });
    const j = await _json(res, 'eBay refresh');
    if (!j.access_token) throw new Error('eBay refresh: kein access_token in der Antwort');
    // The refresh response renews the access token only; keep the existing refresh token.
    return { accessToken: j.access_token, tokenExpires: _expiryIso(j.expires_in) };
  }
};

// ── Billbee ──────────────────────────────────────────────────────────────────
// https://api.billbee.io/api/v1 (spec: https://app.billbee.io/swagger/docs/v1)
//
// Billbee is not a shop. It is the merchant's own order hub: it already holds the
// orders of every channel they sell on, and it already pushes stock out to all of
// them. That makes this provider the odd one out twice over.
//
// **Orders arrive already merged**, each naming the shop it came from. They are
// all imported under the channel 'billbee' rather than re-attributed to
// 'etsy'/'ebay' the way _wixOriginChannel does, and that is deliberate: orders
// dedupe on (channel, channelOrderId), and Billbee's order id has nothing to do
// with eBay's. Re-attributing would file the same sale twice — once from each
// side — instead of merging the two. So when Billbee is on, the direct connection
// for every shop it covers belongs off. The shop name still travels in raw_json.
//
// Stock it leaves alone, with one exception. Billbee is the commercial side of
// this business and owns what may be sold; the lab's job with a Billbee order is
// to make what it says. The exception is liquid-culture syringes: there the lab
// is the only place that knows how many exist, so it reports the count for the
// SKUs someone has explicitly tied to a species (setStock, driven by
// syringe-stock.js). Beyond that it writes back a Sendungsnummer for a label
// bought in Meistertracker, and Wix orders Billbee cannot fetch itself.
//
// Credentials reuse the existing columns, so there is no migration: api_key holds
// the app's X-Billbee-Api-Key, client_id the Billbee login, client_secret the API
// password from the Billbee account settings. The key has to be requested from
// Billbee and the API switched on in the account — neither is something this code
// can do, and both fail as a plain 401 here.
const BILLBEE_API = 'https://api.billbee.io/api/v1';
// Billbee throttles per endpoint: 2 calls/second for one API key + user, answered
// with 429 + Retry-After. The sync loop pages faster than that, so the calls are
// spaced here instead of the limit being discovered on a live account.
const BILLBEE_MIN_GAP_MS = 550;
const BILLBEE_PAGE_SIZE = 250; // the documented maximum
// How far back a sync looks. Without a floor every poll would walk the entire
// order history of every connected channel — and the sync route stops after 20
// pages regardless, so the walk would never reach today's orders.
const BILLBEE_WINDOW_DAYS = 30;

// One queue for every Billbee call in this process, spaced by BILLBEE_MIN_GAP_MS.
// Per endpoint would be closer to what Billbee actually limits, but the sync is
// the only thing here that pages, the Sendungsnummer goes back one call at a
// time, and one queue cannot be got wrong.
let _billbeeQueue = Promise.resolve();
let _billbeeLast = 0;
function _billbeeGate(fn) {
  const run = _billbeeQueue.then(async () => {
    const wait = BILLBEE_MIN_GAP_MS - (Date.now() - _billbeeLast);
    if (wait > 0) await new Promise((r) => setTimeout(r, wait));
    try {
      return await fn();
    } finally {
      _billbeeLast = Date.now();
    }
  });
  // The chain has to survive a rejection: chaining on `run` itself would leave
  // every later call queued behind a rejected promise and never run again.
  _billbeeQueue = run.then(
    () => {},
    () => {}
  );
  return run;
}

/** The one credential shape Billbee cannot be given. Returns a reason, or null. */
function billbeeCredentialProblem(cfg) {
  if (cfg && String(cfg.clientId || '').includes(':')) return 'Billbee-Benutzer darf keinen Doppelpunkt enthalten';
  if (cfg && String(cfg.clientSecret || '').includes(':'))
    return 'Billbee API-Passwort darf keinen Doppelpunkt enthalten';
  return null;
}
function _billbeeHeaders(cfg, hasBody) {
  const h = {
    'X-Billbee-Api-Key': cfg.apiKey,
    Authorization: 'Basic ' + Buffer.from(cfg.clientId + ':' + cfg.clientSecret).toString('base64'),
    Accept: 'application/json'
  };
  if (hasBody) h['Content-Type'] = 'application/json';
  return h;
}

async function _billbeeFetch(cfg, pathAndQuery, opts = {}, label = 'Billbee') {
  if (!cfg.apiKey) throw new Error('Billbee API-Key fehlt');
  if (!cfg.clientId || !cfg.clientSecret) throw new Error('Billbee Benutzer + API-Passwort erforderlich');
  // Basic auth splits user from password on the first colon, so a colon in the
  // login breaks the header outright — and Billbee's own documentation says a
  // colon in the API password breaks the connection at their end too. Both would
  // otherwise arrive as a plain 401, which reads as "the key was never approved"
  // and sends somebody off requesting a new one.
  const badCredential = billbeeCredentialProblem(cfg);
  if (badCredential) throw new Error(badCredential);
  const url = BILLBEE_API + pathAndQuery;
  const init = { ...opts, headers: _billbeeHeaders(cfg, !!opts.body) };
  let res = await _billbeeGate(() => tfetch(url, init));
  if (res.status === 429) {
    // Retry once, honouring Retry-After. Capped, because this wait sits inside the
    // caller's own request: a minute of sleeping would look like a hung server.
    const asked = parseInt(res.headers.get('retry-after') || '', 10);
    const after = Math.min(Number.isFinite(asked) && asked >= 0 ? asked : 2, 10);
    await new Promise((r) => setTimeout(r, after * 1000));
    res = await _billbeeGate(() => tfetch(url, init));
  }
  return _json(res, label);
}

// Billbee's carrier ids are account-independent but the spec does not list them,
// so they are read once per process from the API instead of being hard-coded from
// a guess. A miss is not an error: the Sendungsnummer is what matters, and the
// carrier name still travels in the comment.
let _billbeeCarriers = null;
async function _billbeeCarrierId(cfg, carrier) {
  const want = String(carrier || '')
    .trim()
    .toLowerCase();
  if (!want) return null;
  if (!_billbeeCarriers) {
    let j;
    try {
      j = await _billbeeFetch(cfg, '/enums/shippingcarriers', {}, 'Billbee Versanddienstleister');
    } catch {
      // Not cached: a transient failure must not disable carrier ids for the
      // lifetime of the server.
      return null;
    }
    const data = (j && j.Data) || j || {};
    // The endpoint is typed as a bare object in the spec. Accept both shapes it
    // can take — a list of {Id, Name} and an id → name map — rather than pick one.
    const rows = Array.isArray(data)
      ? data.map((e) => ({ id: Number(e.Id != null ? e.Id : e.id), name: String(e.Name || e.name || '') }))
      : Object.entries(data).map(([k, v]) => ({ id: Number(k), name: String(v == null ? '' : v) }));
    _billbeeCarriers = rows.filter((r) => Number.isFinite(r.id) && r.name);
  }
  const exact = _billbeeCarriers.find((c) => c.name.toLowerCase() === want);
  if (exact) return exact.id;
  // Otherwise the *longest* name that overlaps, not the first one in Billbee's
  // list: for 'deutsche post', a list holding both "Post" and "Deutsche Post"
  // used to answer with whichever came first, so the shipment could be filed
  // under the wrong carrier and the buyer got a tracking link that never resolves.
  let best = null;
  for (const c of _billbeeCarriers) {
    const n = c.name.toLowerCase();
    if (!want.includes(n) && !n.includes(want)) continue;
    if (!best || n.length > best.name.length) best = { id: c.id, name: n };
  }
  return best ? best.id : null;
}

// Billbee's OrderStateEnum: 4 = shipped, 6 = deleted, 7 = closed, 8 = cancelled.
// ShippedAt is checked alongside the number and is what makes a wrong one
// harmless — a shipped order carries the timestamp whatever its state id says.
function _billbeeStatus(o) {
  const s = Number(o.State);
  if (s === 6 || s === 8) return 'cancelled';
  if (o.ShippedAt || s === 4 || s === 7) return 'shipped';
  return 'new';
}

/**
 * What a lab is allowed to learn from a Billbee order.
 *
 * Billbee's copy is the commercial record: buyer name, e-mail, telephone,
 * delivery address. A lab needs none of it — its job with an order is to make
 * what the order says — and this server runs on a farm's own machine behind a
 * home line, so the safest place for a customer's address is the one that
 * already has it and is built to hold it.
 *
 * So the import keeps the order's identity, its state, its money and its
 * articles, and drops the person. `raw_json` is filtered too: storing the whole
 * order there would have moved the address rather than left it.
 */
function _billbeeLabRecord(o) {
  const buyer = o.Buyer || {};
  return {
    BillBeeOrderId: o.BillBeeOrderId,
    OrderNumber: o.OrderNumber || null,
    // Where it came from, in the order Billbee fills the three: the shop's own
    // name, the account it arrived on, then the bare platform. All three name a
    // marketplace, none of them names a person.
    ShopName: buyer.BillbeeShopName || o.ApiAccountName || buyer.Platform || null,
    State: o.State != null ? o.State : null,
    CreatedAt: o.CreatedAt || null,
    ShippedAt: o.ShippedAt || null,
    Currency: o.Currency || null,
    // The link back into Billbee, for the human who wants the rest of the order:
    // it lives there, one click away, and does not have to live here.
    WebUrl: o.WebUrl || null,
    // What the packing list needs on top. Paid decides whether an order is
    // packed at all; the tags are the merchant's own words, set by Billbee
    // rules (ZUSAMMENFASSEN marks orders to go out in one parcel).
    PayedAt: o.PayedAt || null,
    Tags: Array.isArray(o.Tags) ? o.Tags.map((x) => String(x == null ? '' : x).trim()).filter(Boolean) : [],
    CustomerKey: _billbeeCustomerKey(o),
    // Whether a label has been bought, and when. Billbee's timed rules buy it
    // and set the order to "Versendet" before anyone has packed it, so this is
    // information for the packer, not a sign the parcel is gone. Only the count
    // and the time are kept, not the tracking numbers.
    LabelCount: Array.isArray(o.ShippingIds) ? o.ShippingIds.length : 0,
    LabelAt: _billbeeFirstLabelAt(o)
  };
}

/**
 * Who the parcel is for, as the packing list names it: the delivery address's
 * first and last name, else its company, else Billbee's customer name, else the
 * marketplace's buyer name. Trimmed, never more than one line.
 */
function _billbeeRecipientName(o) {
  const ship = o.ShippingAddress || {};
  const clean = (v) =>
    String(v == null ? '' : v)
      .replace(/\s+/g, ' ')
      .trim();
  const person = clean([ship.FirstName, ship.LastName].filter(Boolean).join(' '));
  const name =
    person || clean(ship.Company) || clean(o.Customer && o.Customer.Name) || clean(o.Buyer && o.Buyer.FullName);
  return name ? name.slice(0, 80) : null;
}

function _billbeeFirstLabelAt(o) {
  if (!Array.isArray(o.ShippingIds)) return null;
  const times = o.ShippingIds.map((s) => s && s.Created)
    .filter((t) => t && !Number.isNaN(new Date(t).getTime()))
    .sort();
  return times.length ? times[0] : null;
}

/**
 * Which orders belong to the same buyer, without saying who the buyer is.
 *
 * Two orders from one person go out in one parcel, so the packing list has to
 * see that they share a buyer. It does not have to see the buyer: a one-way hash
 * of Billbee's own customer id answers "same or not" and nothing else. The id is
 * hashed rather than kept because it is a handle into the commercial record, and
 * this database is not where that record lives (see _billbeeLabRecord).
 */
function _billbeeCustomerKey(o) {
  const c = o.Customer || {};
  const buyer = o.Buyer || {};
  const id =
    c.Id != null && c.Id !== ''
      ? 'c:' + c.Id
      : o.CustomerId != null && o.CustomerId !== ''
        ? 'c:' + o.CustomerId
        : buyer.Id != null && buyer.Id !== ''
          ? 'b:' + (buyer.Platform || '') + ':' + buyer.Id
          : null;
  if (!id) return null;
  return crypto
    .createHash('sha256')
    .update('mt-billbee-customer|' + id)
    .digest('hex')
    .slice(0, 16);
}

function _normalizeBillbee(o) {
  const ship = o.ShippingAddress || {};
  const items = (o.OrderItems || [])
    // Coupons and discounts arrive as positions like any other. They are not
    // things to make, and importing them parks a "Gutschein" line in the mapping
    // screen for ever, waiting to be bound to a product that cannot exist.
    .filter((li) => li && !li.IsCoupon)
    .map((li) => {
      const p = li.Product || {};
      // A missing quantity means one. Number(null) is 0, so the null case has to
      // be excluded before the finite check, or a position without a quantity
      // would import as zero.
      const qty = li.Quantity != null && Number.isFinite(Number(li.Quantity)) ? Number(li.Quantity) : 1;
      return {
        channelSku: p.SKU || p.SkuOrId || li.InvoiceSKU || null,
        listingId: p.Id != null ? String(p.Id) : null,
        title: p.Title || null,
        qty,
        // Billbee reports the position total (unit price × quantity); the hub
        // stores the unit price.
        unitPrice: li.TotalPrice != null && qty ? Math.round((Number(li.TotalPrice) / qty) * 10000) / 10000 : null
      };
    });
  // TotalCost is documented as the total *without* shipping, unlike the other
  // three channels' totals, so the shipping cost is added back — otherwise the
  // hub under-reports every Billbee order by its postage.
  //
  // ⚠️ That reading is the API documentation's, and it cannot be checked from
  // here; several Billbee clients treat TotalCost as the whole order. Read the
  // wrong way it would *over*-report every order by its postage instead, and
  // nothing about the number would look wrong. So a paid order is believed over
  // the arithmetic: PaidAmount is what the buyer actually handed over, and it is
  // right whichever way the documentation is meant.
  //
  // Rounded because it is money and this is the one channel where two figures are
  // added: 0.10 + 0.70 lands on 0.7999999999999999 in binary floating point.
  const summed =
    o.TotalCost != null ? Number(o.TotalCost) + (o.ShippingCost != null ? Number(o.ShippingCost) : 0) : null;
  const paid = o.PayedAt && o.PaidAmount != null ? Number(o.PaidAmount) : null;
  const chosen = paid != null && paid > 0 ? paid : summed;
  const total = chosen != null && Number.isFinite(chosen) ? Math.round(chosen * 100) / 100 : null;
  // A credit note (Billbee files it as a second order, number suffixed "-GS")
  // carries only negative positions and stays in state "ordered" for ever.
  // Nothing in it goes out, so it is closed on arrival; left open it would sit
  // in Eingang and the mapping screen with nobody able to clear it.
  const creditNote = items.length > 0 && items.every((i) => i.qty < 0);
  return {
    channel: 'billbee',
    // The *internal* Billbee id — not Id or OrderNumber, which belong to the
    // marketplace the order came from. It is the only key that is unique across
    // channels, and it is the one /orders/{id}/shipment takes.
    channelOrderId: o.BillBeeOrderId != null ? String(o.BillBeeOrderId) : null,
    status: creditNote ? 'cancelled' : _billbeeStatus(o),
    orderDate: o.CreatedAt || null,
    // The recipient's name, and nothing else of the person: the packing list
    // leads with it so the team can see which parcel is whose. No e-mail, no
    // telephone, no address, and no customer record built from the name — see
    // _billbeeLabRecord and noCustomerRecord below. The country stays: it
    // identifies nobody and it answers "how much of this goes abroad".
    customerName: _billbeeRecipientName(o),
    noCustomerRecord: true,
    shipCountry: ship.CountryISO2 || null,
    totalAmount: total != null && Number.isFinite(total) ? total : null,
    currency: o.Currency || null,
    shipWeightG:
      o.ShipWeightKg != null && Number.isFinite(Number(o.ShipWeightKg))
        ? Math.round(Number(o.ShipWeightKg) * 1000)
        : null,
    raw: _billbeeLabRecord(o),
    items
  };
}

// Which Billbee shop account a pushed order is filed under. Resolved from the
// API and cached for the process, the same way carrier ids are: the id is
// account-specific, so hard-coding it would tie this code to one installation.
//
// Matched on the platform first and the name only as a fallback, because the name
// is whatever the merchant typed when they added the connection.
let _billbeeShops = null;
async function _billbeeWixShopId(cfg) {
  if (!_billbeeShops) {
    let j;
    try {
      j = await _billbeeFetch(cfg, '/shopaccounts?page=1&pageSize=50', {}, 'Billbee Shopkonten');
    } catch {
      // Not cached — a transient failure must not disable shop ids for the life of
      // the server. Without an id the order still files, just without a shop.
      return null;
    }
    _billbeeShops = ((j && j.Data) || [])
      .map((s) => ({
        id: Number(s && s.Id),
        name: String((s && s.Name) || ''),
        platform: String((s && (s.Platform || s.Type)) || '')
      }))
      .filter((s) => Number.isFinite(s.id));
  }
  const byPlatform = _billbeeShops.find((s) => s.platform.toLowerCase() === 'wix');
  if (byPlatform) return byPlatform.id;
  const byName = _billbeeShops.find((s) => s.name.toLowerCase().includes('wix'));
  return byName ? byName.id : null;
}

// Billbee's OrderStateEnum, the write direction of _billbeeStatus. 'new' does not
// map to "paid": the hub's three statuses say nothing about money, and filing an
// unpaid order as paid would put it straight into Billbee's "bezahlte Aufträge,
// die versendet werden können" list. So payment is read from the channel's own
// record instead, and anything unproven stays at 2 (confirmed).
function _billbeeStateFor(order) {
  if (order.status === 'cancelled') return 8;
  if (order.status === 'shipped') return 4;
  const paid = String((order.raw && (order.raw.paymentStatus || order.raw.PaymentStatus)) || '').toUpperCase();
  return paid === 'PAID' || paid === 'FULLY_PAID' ? 3 : 2;
}

/**
 * Map one hub order onto Billbee's Order model for POST /orders.
 *
 * Unlike every other Billbee function here this one *writes* a customer's name
 * and address. That is the point rather than an oversight: the hub holds them
 * only to print a label, Billbee is the commercial system that issues the invoice
 * and owns the customer record, and an order pushed without an address is an
 * order nobody can ship.
 *
 * ⚠️ Billbee uses one Order model in both directions, so the property names here
 * are the ones _normalizeBillbee already reads back. The write direction cannot
 * be checked without a live API key, so verify this payload against the first
 * real order and adjust — the same caveat _normalizeWix carries.
 */
function _billbeeOrderPayload(order) {
  const name = order.shipName || order.customerName || '';
  const sp = name.indexOf(' ');
  const firstName = sp > 0 ? name.slice(0, sp) : name;
  const lastName = sp > 0 ? name.slice(sp + 1) : '';
  const address = {
    FirstName: firstName || null,
    LastName: lastName || null,
    Company: order.shipCompany || null,
    Street: order.shipStreet || null,
    HouseNumber: order.shipHouse || null,
    Line2: order.shipAddress2 || null,
    City: order.shipCity || null,
    Zip: order.shipPostal || null,
    CountryISO2: order.shipCountry || null,
    Phone: order.shipPhone || null,
    Email: order.customerEmail || null
  };
  const items = (order.items || []).map((li) => {
    const qty = Number(li.qty) || 1;
    const unit = li.unitPrice != null && Number.isFinite(Number(li.unitPrice)) ? Number(li.unitPrice) : 0;
    return {
      // Billbee matches an incoming position to an existing article by SKU — the
      // same rule by which a rename can mint duplicates. So the SKU has to
      // travel even when the title does not.
      Product: { Title: li.title || null, SKU: li.channelSku || null },
      Quantity: qty,
      // Billbee reads TotalPrice as the position total, not the unit price. The
      // hub stores the unit price, so it is multiplied back out here — the exact
      // inverse of what _normalizeBillbee does on the way in.
      TotalPrice: Math.round(unit * qty * 100) / 100
    };
  });
  const state = _billbeeStateFor(order);
  const payload = {
    OrderNumber: String(order.channelOrderId),
    CreatedAt: order.orderDate || null,
    State: state,
    Currency: order.currency || 'EUR',
    TotalCost: order.totalAmount != null ? Number(order.totalAmount) : null,
    Customer: { Name: order.customerName || name || null, Email: order.customerEmail || null },
    InvoiceAddress: address,
    ShippingAddress: address,
    OrderItems: items
  };
  // Only claimed when it is actually known — see _billbeeStateFor.
  if (state === 3 && order.totalAmount != null) {
    payload.PaidAmount = Number(order.totalAmount);
    payload.PayedAt = order.orderDate || null;
  }
  return payload;
}

const billbee = {
  async testConnection(cfg) {
    const j = await _billbeeFetch(cfg, '/shopaccounts?page=1&pageSize=50', {}, 'Billbee Shopkonten');
    const shops = ((j && j.Data) || []).map((s) => s && s.Name).filter(Boolean);
    // The connected shops are the useful answer here: they are exactly the list of
    // channels that must now be switched off on this page.
    return { ok: true, account: shops.length ? shops.join(', ') : 'Billbee', sample: shops.length, shops };
  },
  // Orders of the last BILLBEE_WINDOW_DAYS days. The cursor is `page|since`, and
  // it carries the window start for a reason: recomputing "30 days ago" on every
  // page moves the filter underneath the paging. An order sitting on the boundary
  // then drops out between two requests, every later order shifts up one place,
  // and whichever order that pushes across the page break is not returned by that
  // sync at all — silent loss, not a delay, for an order never imported before.
  async fetchOrders(cfg, { cursor } = {}) {
    const [rawPage, rawSince] = String(cursor == null ? '' : cursor).split('|');
    const page = Math.max(1, parseInt(rawPage, 10) || 1);
    const since = rawSince || new Date(Date.now() - BILLBEE_WINDOW_DAYS * 86400000).toISOString();
    const q = _form({ page, pageSize: BILLBEE_PAGE_SIZE, minOrderDate: since });
    const j = await _billbeeFetch(cfg, '/orders?' + q, {}, 'Billbee Aufträge');
    const rows = (j && j.Data) || [];
    // An order without an internal id cannot be deduped or written back to, and
    // upsertOrder would throw on it mid-batch. Drop it here instead.
    const orders = rows.map(_normalizeBillbee).filter((o) => o.channelOrderId);
    const totalPages = (j && j.Paging && j.Paging.TotalPages) || 0;
    const nextCursor = rows.length && page < totalPages ? page + 1 + '|' + since : null;
    return { orders, nextCursor };
  },
  // Write the Sendungsnummer back onto the Billbee order. Best-effort, like the
  // other three: the caller records the outcome and never fails a bought label
  // over a write-back error.
  async pushTracking(cfg, { raw, trackingNumber, trackingUrl, carrier }) {
    const id = raw && raw.BillBeeOrderId;
    if (id == null) throw new Error('Billbee-Auftragsnummer fehlt');
    const body = { ShippingId: trackingNumber || '', ShipmentType: 0 };
    const carrierId = await _billbeeCarrierId(cfg, carrier);
    if (carrierId != null) body.CarrierId = carrierId;
    // The carrier name travels even when it mapped to an id: Billbee shows the
    // comment on the shipment, and an unmapped carrier would arrive nameless.
    const note = [carrier, trackingUrl].filter(Boolean).join(' · ');
    if (note) body.Comment = note;
    await _billbeeFetch(
      cfg,
      '/orders/' + encodeURIComponent(id) + '/shipment',
      { method: 'POST', body: JSON.stringify(body) },
      'Billbee Sendungsnummer'
    );
    return { ok: true };
  },
  /**
   * File one hub order *into* Billbee. The only call here that runs against the
   * grain of this provider, and it exists for exactly one channel.
   *
   * Billbee reads every other shop by API and needs nothing from this server. Wix
   * is the exception: Billbee's Wix connector parses order mails, and Wix's
   * current mail carries no order data, so Billbee can never see a Wix sale on its
   * own — see BILLBEE_SUPERSEDES in db.js. Meanwhile this server *can* read the
   * Wix API and already does. So the hub becomes the bridge.
   *
   * Only ever called for orders that came from the Wix API. Pushing an order that
   * was itself imported *from* Billbee would file it back as a second, unrelated
   * order — the caller guards on channel, and so does this.
   */
  async pushOrder(cfg, order) {
    if (!order || order.channel !== 'wix') throw new Error('Billbee-Push nur für Wix-Bestellungen');
    if (order.channelOrderId == null) throw new Error('Wix-Bestellnummer fehlt');
    const shopId = await _billbeeWixShopId(cfg);
    const q = shopId != null ? '?shopId=' + encodeURIComponent(shopId) : '';
    const j = await _billbeeFetch(
      cfg,
      '/orders' + q,
      { method: 'POST', body: JSON.stringify(_billbeeOrderPayload(order)) },
      'Billbee Auftrag anlegen'
    );
    const created = (j && j.Data) || j || {};
    return { ok: true, billbeeOrderId: created.BillBeeOrderId != null ? String(created.BillBeeOrderId) : null, shopId };
  },
  /**
   * Set one article's stock to what the lab physically holds.
   *
   * Absolute, because that is all Billbee takes: its spec says "You have to send
   * the absolute value for the current stock", and DeltaQuantity is documented as
   * "currently ignored". That suits the lab anyway — it counts what is on the
   * shelf, it does not know which syringe a shop sold.
   *
   * AutosubtractReservedAmount is what keeps a sale from being counted twice. A
   * sold syringe stays on the lab's shelf until it is shipped and scanned out, so
   * the lab's count still includes it; Billbee takes the unshipped orders off
   * before storing the number, and the shops see what is actually free.
   *
   * `resend` sets ForceSendStockToShops: Billbee passes a figure on to the shops
   * only when it changed, so a shop that was edited by hand — or set up after the
   * last change — keeps its own wrong number until the next sale. The "Jetzt
   * melden" button resends, and that is what puts it right.
   */
  async setStock(cfg, { sku, count, reason, resend = false }) {
    if (!sku) throw new Error('SKU fehlt');
    const n = Number(count);
    if (!Number.isInteger(n) || n < 0) throw new Error('Bestand muss eine ganze Zahl ab 0 sein');
    let j;
    try {
      j = await _billbeeFetch(
        cfg,
        '/products/updatestock',
        {
          method: 'POST',
          body: JSON.stringify({
            Sku: sku,
            NewQuantity: n,
            AutosubtractReservedAmount: true,
            ForceSendStockToShops: !!resend,
            Reason: reason || 'Meistertracker: Spritzen im Labor'
          })
        },
        'Billbee Bestand'
      );
    } catch (e) {
      // Billbee answers an unknown SKU with a bare 4xx. Said as what it almost
      // always is, so somebody checks the spelling rather than the API key.
      if (/HTTP 4(00|04)\b/.test(e.message || '')) {
        throw new Error('Billbee kennt die SKU ' + sku + ' nicht. Schreibweise in Billbee unter Artikel prüfen.');
      }
      throw e;
    }
    // A 200 can still carry a refusal in the envelope.
    if (j && (j.ErrorMessage || (j.ErrorCode && j.ErrorCode !== 0))) {
      throw new Error(
        'Billbee hat den Bestand für ' + sku + ' abgelehnt: ' + (j.ErrorMessage || 'Fehler ' + j.ErrorCode)
      );
    }
    const d = (j && j.Data) || {};
    return {
      ok: true,
      sku: d.SKU || sku,
      oldStock: d.OldStock != null ? Number(d.OldStock) : null,
      currentStock: d.CurrentStock != null ? Number(d.CurrentStock) : null,
      unfulfilled: d.UnfulfilledAmount != null ? Number(d.UnfulfilledAmount) : null
    };
  }
};

function getChannelProvider(channel) {
  if (channel === 'wix') return wix;
  if (channel === 'ebay') return ebay;
  if (channel === 'etsy') return etsy;
  if (channel === 'billbee') return billbee;
  throw new Error('unknown channel: ' + channel);
}

module.exports = {
  getChannelProvider,
  buildAuthorizeUrl,
  exchangeCode,
  pkcePair,
  wix,
  etsy,
  ebay,
  billbee,
  billbeeCredentialProblem,
  _normalizeWix,
  _normalizeEtsy,
  _normalizeEbay,
  _normalizeBillbee,
  _billbeeLabRecord,
  _billbeeStatus,
  _billbeeOrderPayload,
  _billbeeStateFor,
  _splitHouse,
  WIX_BASE
};