'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 };