import type { EbayContext } from "./ebayFinancesApi" import { parseAmount } from "./ebayFinancesApi" import { buildSigningHeaders } from "./ebaySignature" /** * eBay Sell **Fulfillment** API helper (order listing) for the eBay Bestellabgleich * reconciliation page. * * NB: this is a DIFFERENT host from the Finances API. Finances lives on apiz.ebay.com * and MANDATES RFC 9421 digital signatures; the Fulfillment API lives on api.ebay.com * and getOrders/getOrder (read calls) do NOT require signatures. We therefore call it * UNSIGNED, but fall back to a signed retry if eBay ever answers 403 about a missing * signature — so the feature keeps working if eBay extends the mandate to reads. * * READ-ONLY: nothing here writes to iDempiere or eBay. */ export const EBAY_FULFILLMENT_BASE = { production: 'https://api.ebay.com', sandbox: 'https://api.sandbox.ebay.com' } const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)) const looksLikeSignatureError = (status: number, json: any, text: string): boolean => { if (status !== 403) return false const blob = (JSON.stringify(json || {}) + ' ' + (text || '')).toLowerCase() return blob.includes('signature') } // Generic Fulfillment-API GET. Tries unsigned first; on a 403 that mentions a // signature, retries ONCE with RFC 9421 signing headers. Throws an Error carrying // .status + the eBay message on any other non-2xx. export const ebayFulfillmentGet = async (ctx: EbayContext, path: string): Promise => { const base = ctx.environment === 'sandbox' ? EBAY_FULFILLMENT_BASE.sandbox : EBAY_FULFILLMENT_BASE.production const url = `${base}${path}` const doFetch = async (extraHeaders: Record) => { const response = await fetch(url, { method: 'GET', headers: { Authorization: `Bearer ${ctx.accessToken}`, 'Content-Type': 'application/json', // eBay requires a marketplace id header on Fulfillment calls. 'X-EBAY-C-MARKETPLACE-ID': 'EBAY_DE', ...extraHeaders } }) const text = await response.text() let json: any = {} try { json = text ? JSON.parse(text) : {} } catch { json = { raw: text } } return { response, json, text } } let { response, json, text } = await doFetch({}) // Signature-mandate fallback: retry once, signed. if (!response.ok && looksLikeSignatureError(response.status, json, text)) { try { const signHeaders = await buildSigningHeaders({ method: 'GET', url, clientId: ctx.clientId, clientSecret: ctx.clientSecret, environment: ctx.environment }) ;({ response, json, text } = await doFetch(signHeaders)) } catch { /* keep the original 403 below */ } } if (!response.ok) { const msg = json?.errors?.[0]?.message || json?.errors?.[0]?.longMessage || json?.error_description || text || response.statusText const err: any = new Error(`eBay API ${response.status}: ${msg}`) err.status = response.status err.ebay = json throw err } return json } export interface EbayOrderRef { ebayOrderId: string legacyOrderId: string creationDate: string orderFulfillmentStatus: string cancelState: string isCanceled: boolean status: string // display token consumed by the page's statusColor() total: number // gross order total (buyer-paid) currency: string } // Map eBay's fulfillment/cancel status onto the page's colour tokens // (mirrors the Amazon page's shipped/unshipped/pending/canceled vocabulary). const displayStatus = (fulfillmentStatus: string, isCanceled: boolean): string => { if (isCanceled) return 'canceled' switch (String(fulfillmentStatus || '').toUpperCase()) { case 'FULFILLED': return 'shipped' case 'IN_PROGRESS': return 'partiallyshipped' case 'NOT_STARTED': return 'unshipped' default: return 'pending' } } const normalizeOrder = (o: any): EbayOrderRef => { const cancelState = String(o?.cancelStatus?.cancelState || '') const isCanceled = cancelState.toUpperCase() === 'CANCELED' const fulfillment = String(o?.orderFulfillmentStatus || '') return { ebayOrderId: String(o?.orderId || ''), legacyOrderId: String(o?.legacyOrderId || ''), creationDate: o?.creationDate || '', orderFulfillmentStatus: fulfillment, cancelState, isCanceled, status: displayStatus(fulfillment, isCanceled), total: parseAmount(o?.pricingSummary?.total), currency: o?.pricingSummary?.total?.currency || 'EUR' } } // Pull EVERY eBay order created in [createdAfter, createdBefore) via the Fulfillment // API, paging by offset. eBay's getOrders is far less throttled than Amazon, but we // still cap pages + wall-clock so a huge full-year query can't time out the proxy. export const listAllEbayOrders = async ( ctx: EbayContext, opts: { createdAfter: string; createdBefore: string; maxPages?: number; budgetMs?: number; pageDelayMs?: number } ): Promise<{ orders: EbayOrderRef[]; truncated: boolean; throttled: boolean; pages: number }> => { const limit = 200 const maxPages = opts.maxPages ?? 40 // 40 * 200 = 8000 orders (matches the matcher cap) const budgetMs = opts.budgetMs ?? 45000 const pageDelayMs = opts.pageDelayMs ?? 0 const startedAt = Date.now() const orders: EbayOrderRef[] = [] let offset = 0 let pages = 0 let total = Number.POSITIVE_INFINITY let truncated = false let throttled = false // eBay getOrders date filter: creationdate:[start..end] (ISO 8601 UTC, ms precision). const filter = `creationdate:[${opts.createdAfter}..${opts.createdBefore}]` while (offset < total && pages < maxPages) { if (orders.length > 0 && Date.now() - startedAt > budgetMs) { truncated = true; break } const path = `/sell/fulfillment/v1/order?limit=${limit}&offset=${offset}&filter=${encodeURIComponent(filter)}` let json: any try { json = await ebayFulfillmentGet(ctx, path) } catch (e: any) { // Rate-limited → one short backoff, then retry the same page (within budget). if (Number(e?.status) === 429) { throttled = true if (orders.length > 0 && Date.now() - startedAt > budgetMs) { truncated = true; break } await sleep(2000) try { json = await ebayFulfillmentGet(ctx, path) } catch (e2: any) { if (orders.length > 0) { truncated = true; break } throw e2 } } else { // Nothing collected yet → surface the error; otherwise return the partial set. if (orders.length > 0) { truncated = true; break } throw e } } const batch = json?.orders || [] for (const o of batch) orders.push(normalizeOrder(o)) total = Number.isFinite(json?.total) ? Number(json.total) : (offset + batch.length) pages++ if (!batch.length) break offset += limit if (pageDelayMs && offset < total && pages < maxPages) await sleep(pageDelayMs) } if (offset < total && pages >= maxPages) truncated = true return { orders, truncated, throttled, pages } }