/** * eBay listings adapter — Sell Inventory API (REST) + Trading API fallback for legacy listings. * * Credentials (same shape the Laravel EbayTokenRepository uses): c_ordersource.marketplace_token = * JSON { access_token, refresh_token, expires_in, obtained_at, … }, marketplace_key / _secret = the * app's client id / secret (needed to refresh), ebay_environment = production | sandbox. A refreshed * token is written back to the order source so Laravel and the ERP share it. * * eBay structure: an INVENTORY ITEM is keyed by SKU; variations of one listing are an INVENTORY ITEM * GROUP (each variation = own SKU); an OFFER (offerId) ties one SKU to one marketplace with price, * quantity and policies; publishing an offer creates the LISTING (listingId = the public item id, * shared by all variation offers of a group). * ListingKey offerId (legacy items: "item:" or "item::") * ExternalProductId listingId / ItemID ExternalInventoryId SKU * MetaJson { marketplaceId, listingId, groupKey, variantTitle, imageUrl, legacy, storefrontUrl, adminUrl } * import all inventory items → their offers (one row per offer) + GetMyeBaySelling for listings * that were never migrated to the Inventory API (read + price/qty via Trading API) * price/qty bulkUpdatePriceQuantity (offers) · ReviseInventoryStatus (legacy) * delist withdrawOffer / EndFixedPriceItem · relist publishOffer / RelistFixedPriceItem (new ItemID) * create createOrReplaceInventoryItem + createOffer (first fulfillment/payment/return policy, * first inventory location, category suggestion for the title) + publishOffer * eBay has no per-second price throttle like Amazon → the adapter is `immediate`: routes flush right away. */ import { XMLParser, XMLBuilder } from 'fast-xml-parser' import type { AdapterCtx, ListingAdapter, ImportResult, PushItem, PushResult, CompetitionResult, RemoteListing } from './types' interface EbayConn { apiUrl: string; accessToken: string; marketplaceId: string; siteId: number; currency: string; storefront: string } const SCOPES = 'https://api.ebay.com/oauth/api_scope https://api.ebay.com/oauth/api_scope/sell.inventory https://api.ebay.com/oauth/api_scope/sell.inventory.readonly https://api.ebay.com/oauth/api_scope/sell.account https://api.ebay.com/oauth/api_scope/sell.account.readonly https://api.ebay.com/oauth/api_scope/sell.fulfillment https://api.ebay.com/oauth/api_scope/sell.fulfillment.readonly https://api.ebay.com/oauth/api_scope/sell.marketing https://api.ebay.com/oauth/api_scope/sell.marketing.readonly https://api.ebay.com/oauth/api_scope/sell.analytics.readonly https://api.ebay.com/oauth/api_scope/sell.finances https://api.ebay.com/oauth/api_scope/sell.payment.dispute https://api.ebay.com/oauth/api_scope/commerce.identity.readonly' const ENDPOINTS = { production: { api: 'https://api.ebay.com', token: 'https://api.ebay.com/identity/v1/oauth2/token' }, sandbox: { api: 'https://api.sandbox.ebay.com', token: 'https://api.sandbox.ebay.com/identity/v1/oauth2/token' } } // marketplace → Trading site id, currency, storefront host const MARKETS: Record = { EBAY_DE: { siteId: 77, currency: 'EUR', host: 'www.ebay.de', categoryTree: '77' }, EBAY_AT: { siteId: 16, currency: 'EUR', host: 'www.ebay.at', categoryTree: '16' }, EBAY_CH: { siteId: 193, currency: 'CHF', host: 'www.ebay.ch', categoryTree: '193' }, EBAY_GB: { siteId: 3, currency: 'GBP', host: 'www.ebay.co.uk', categoryTree: '3' }, EBAY_US: { siteId: 0, currency: 'USD', host: 'www.ebay.com', categoryTree: '0' }, EBAY_FR: { siteId: 71, currency: 'EUR', host: 'www.ebay.fr', categoryTree: '71' }, EBAY_IT: { siteId: 101, currency: 'EUR', host: 'www.ebay.it', categoryTree: '101' }, EBAY_ES: { siteId: 186, currency: 'EUR', host: 'www.ebay.es', categoryTree: '186' }, EBAY_NL: { siteId: 146, currency: 'EUR', host: 'www.ebay.nl', categoryTree: '146' } } const TRADING_COMPAT = '1301' const connCache = new Map() const importCache = new Map() const IMPORT_CACHE_MS = 5 * 60 * 1000 const errMsg = (e: any): string => { const d = e?.data?.errors?.[0] return String(d ? `${d.message || ''}${d.longMessage ? ' — ' + d.longMessage : ''}`.trim() : (e?.data?.error_description || e?.message || e)).slice(0, 1900) } const isTransient = (e: any): boolean => { const st = Number(e?.status || e?.statusCode || 0) return st === 429 || st >= 500 || /timed out|ECONNRESET|fetch failed/i.test(String(e?.message || '')) } const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)) const xmlParser = new XMLParser({ ignoreAttributes: true, parseTagValue: false, isArray: (name) => ['Item', 'Variation', 'NameValueList', 'Value', 'Errors', 'InventoryStatus', 'PictureURL'].includes(name) }) const xmlBuilder = new XMLBuilder({ ignoreAttributes: true, suppressEmptyNode: true }) export const ebayMarketplaceOf = (orderSource: any): string => { const explicit = String(orderSource?.ebay_marketplace_id || orderSource?.Ebay_Marketplace_ID || '').toUpperCase() if (explicit && MARKETS[explicit]) return explicit const desc = String(orderSource?.Description || '') + ' ' + String(orderSource?.marketplace_url || '') const m = desc.match(/ebay\.(co\.uk|com\.au|[a-z]{2})/i) const tld = (m?.[1] || 'de').toLowerCase() return ({ de: 'EBAY_DE', at: 'EBAY_AT', ch: 'EBAY_CH', 'co.uk': 'EBAY_GB', com: 'EBAY_US', fr: 'EBAY_FR', it: 'EBAY_IT', es: 'EBAY_ES', nl: 'EBAY_NL' } as Record)[tld] || 'EBAY_DE' } /** Access token from the stored JSON, refreshed (and written back to c_ordersource) when expired. */ const connect = async (ctx: AdapterCtx): Promise => { const c = connCache.get(ctx.orderSourceId) if (c && Date.now() - c.at < 20 * 60 * 1000) return c.conn const os = ctx.orderSource const envKey = String(os?.ebay_environment || 'production').toLowerCase() === 'sandbox' ? 'sandbox' : 'production' const ep = ENDPOINTS[envKey] let tokens: any = null try { tokens = typeof os?.marketplace_token === 'string' ? JSON.parse(os.marketplace_token) : os?.marketplace_token } catch { tokens = null } if (!tokens?.access_token && !tokens?.refresh_token) throw Object.assign(new Error('eBay order source is not connected (marketplace_token holds no OAuth tokens) — connect it via OAuth first'), { status: 400 }) const obtained = tokens?.obtained_at ? new Date(tokens.obtained_at).getTime() : 0 const expiresIn = Number(tokens?.expires_in || 7200) * 1000 let accessToken: string = tokens?.access_token || '' if (!accessToken || !obtained || Date.now() - obtained >= expiresIn - 5 * 60 * 1000) { const clientId = os?.marketplace_key, clientSecret = os?.marketplace_secret if (!clientId || !clientSecret) throw Object.assign(new Error('eBay access token expired and the order source has no app client id / secret (marketplace_key / marketplace_secret) to refresh it'), { status: 400 }) if (!tokens?.refresh_token) throw Object.assign(new Error('eBay access token expired and no refresh token is stored — reconnect via OAuth'), { status: 400 }) const res: any = await $fetch(ep.token, { method: 'POST', retry: 0, timeout: 30_000, headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: 'Basic ' + Buffer.from(`${clientId}:${clientSecret}`).toString('base64') }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: tokens.refresh_token, scope: SCOPES }).toString() } as any) if (!res?.access_token) throw new Error('eBay token refresh answered without access_token') accessToken = res.access_token const updated = JSON.stringify({ ...tokens, access_token: accessToken, token_type: 'User Access Token', expires_in: res.expires_in || 7200, obtained_at: new Date().toISOString() }) try { await ctx.fetch(`models/c_ordersource/${ctx.orderSourceId}`, 'PUT', { marketplace_token: updated }) } catch (e: any) { ctx.log(`refreshed eBay token could not be stored on the order source: ${e?.message || e}`) } os.marketplace_token = updated } const marketplaceId = ebayMarketplaceOf(os) const mk = MARKETS[marketplaceId] || MARKETS.EBAY_DE! const conn: EbayConn = { apiUrl: ep.api, accessToken, marketplaceId, siteId: mk.siteId, currency: mk.currency, storefront: `https://${mk.host}` } connCache.set(ctx.orderSourceId, { at: Date.now(), conn }) return conn } const rest = async (conn: EbayConn, method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, body?: any, attempt = 0): Promise => { try { return await $fetch(`${conn.apiUrl}${path}`, { method, body, retry: 0, timeout: 60_000, // Content-Language must be a plain locale eBay accepts (de-DE is rejected on the Inventory API → "en-US") headers: { Authorization: `Bearer ${conn.accessToken}`, Accept: 'application/json', 'Content-Type': 'application/json', 'Content-Language': 'en-US', 'Accept-Language': 'en-US', 'X-EBAY-C-MARKETPLACE-ID': conn.marketplaceId } } as any) } catch (e: any) { if (attempt < 3 && isTransient(e)) { await sleep(1000 * (attempt + 1)); return rest(conn, method, path, body, attempt + 1) } throw e } } /** Trading API call (XML) with the same OAuth user token (IAF header). */ const trading = async (conn: EbayConn, callName: string, request: any): Promise => { const xml = `<${callName}Request xmlns="urn:ebay:apis:eBLBaseComponents">${xmlBuilder.build(request)}` const text: string = await $fetch(`${conn.apiUrl}/ws/api.dll`, { method: 'POST', body: xml, retry: 0, timeout: 90_000, responseType: 'text', headers: { 'Content-Type': 'text/xml', 'X-EBAY-API-IAF-TOKEN': conn.accessToken, 'X-EBAY-API-CALL-NAME': callName, 'X-EBAY-API-SITEID': String(conn.siteId), 'X-EBAY-API-COMPATIBILITY-LEVEL': TRADING_COMPAT } } as any) const parsed = xmlParser.parse(text) const res = parsed?.[`${callName}Response`] || {} if (res.Ack === 'Failure' || (res.Ack === 'PartialFailure' && !res.ItemID)) { const errs: any[] = res.Errors || [] throw Object.assign(new Error(errs.map(e => `${e.ShortMessage || ''}${e.LongMessage ? ' — ' + e.LongMessage : ''}`).join('; ') || `${callName} failed`), { data: res }) } return res } const asArray = (v: any): any[] => (v == null ? [] : Array.isArray(v) ? v : [v]) const pool = async (items: T[], size: number, fn: (t: T) => Promise): Promise => { const out: R[] = new Array(items.length) let i = 0 await Promise.all(Array.from({ length: Math.min(size, items.length) }, async () => { while (i < items.length) { const k = i++; out[k] = await fn(items[k]!) } })) return out } const variantTitleOf = (aspects: any): string => { if (!aspects || typeof aspects !== 'object') return '' return Object.entries(aspects).filter(([k]) => /size|gr[öo]ße|colou?r|farbe|variante|variation|style|material|menge|pack/i.test(k)).map(([, v]) => asArray(v).join('/')).filter(Boolean).join(' · ') } const itemUrl = (conn: EbayConn, listingId: string | null) => (listingId ? `${conn.storefront}/itm/${listingId}` : null) const editUrl = (conn: EbayConn, listingId: string | null) => (listingId ? `${conn.storefront}/sl/sell?mode=ReviseItem&itemId=${listingId}` : null) export const ebayAdapter: ListingAdapter = { key: 'ebay', immediate: true, async importRemote(ctx, opts = {}): Promise { const cached = importCache.get(ctx.orderSourceId) if (!opts.forceRefresh && cached && Date.now() - cached.at < IMPORT_CACHE_MS) return { pending: false, rows: cached.rows, generatedAt: cached.at, fromCache: true } const conn = await connect(ctx) const rows: RemoteListing[] = [] const seenListingIds = new Set() // 1. Inventory API: items → offers const items: any[] = [] for (let offset = 0; offset < 100_000; offset += 100) { const res: any = await rest(conn, 'GET', `/sell/inventory/v1/inventory_item?limit=100&offset=${offset}`) items.push(...(res?.inventoryItems || [])) if (!res?.next || (res?.inventoryItems || []).length < 100) break } const offersBySku = await pool(items, 4, async (it) => { try { const r: any = await rest(conn, 'GET', `/sell/inventory/v1/offer?sku=${encodeURIComponent(it.sku)}&marketplace_id=${conn.marketplaceId}&limit=20`); return (r?.offers || []) as any[] } catch (e: any) { if (Number(e?.status) === 404) return []; throw e } }) items.forEach((it, i) => { for (const o of offersBySku[i] || []) { const listingId = o?.listing?.listingId || null if (listingId) seenListingIds.add(String(listingId)) const published = o.status === 'PUBLISHED' const live = published && (!o?.listing?.listingStatus || o.listing.listingStatus === 'ACTIVE') rows.push({ listingKey: String(o.offerId), sellerSku: String(it.sku || ''), externalProductId: listingId, externalInventoryId: String(it.sku || ''), title: String(it?.product?.title || o?.listingDescription?.slice(0, 80) || it.sku), price: o?.pricingSummary?.price?.value != null ? Number(o.pricingSummary.price.value) : null, currency: o?.pricingSummary?.price?.currency || conn.currency, qty: o?.availableQuantity != null ? Number(o.availableQuantity) : (it?.availability?.shipToLocationAvailability?.quantity ?? null), fulfillmentChannel: 'DEFAULT', status: live ? 'Active' : (published ? String(o?.listing?.listingStatus || 'Inactive').charAt(0) + String(o?.listing?.listingStatus || 'Inactive').slice(1).toLowerCase() : 'Unpublished'), dateListed: null, meta: { marketplaceId: conn.marketplaceId, listingId, offerStatus: o.status, listingStatus: o?.listing?.listingStatus || null, format: o.format, groupKey: (it.groupIds || [])[0] || null, variantTitle: variantTitleOf(it?.product?.aspects), imageUrl: (it?.product?.imageUrls || [])[0] || null, categoryId: o.categoryId || null, merchantLocationKey: o.merchantLocationKey || null, legacy: false, storefrontUrl: itemUrl(conn, listingId), adminUrl: editUrl(conn, listingId) } }) } }) // 2. Legacy listings (created in Seller Hub / Trading API, never migrated): GetMyeBaySelling ActiveList try { for (let page = 1; page <= 50; page++) { const res = await trading(conn, 'GetMyeBaySelling', { ActiveList: { Include: true, Pagination: { EntriesPerPage: 200, PageNumber: page }, Sort: 'TimeLeft' }, DetailLevel: 'ReturnAll', OutputSelector: ['ActiveList.ItemArray.Item.ItemID', 'ActiveList.ItemArray.Item.Title', 'ActiveList.ItemArray.Item.SKU', 'ActiveList.ItemArray.Item.Quantity', 'ActiveList.ItemArray.Item.QuantityAvailable', 'ActiveList.ItemArray.Item.SellingStatus', 'ActiveList.ItemArray.Item.BuyItNowPrice', 'ActiveList.ItemArray.Item.ListingDetails', 'ActiveList.ItemArray.Item.PictureDetails', 'ActiveList.ItemArray.Item.Variations', 'ActiveList.ItemArray.Item.ListingType', 'ActiveList.PaginationResult'] }) const list: any[] = res?.ActiveList?.ItemArray?.Item || [] for (const it of list) { const id = String(it.ItemID || '') if (!id || seenListingIds.has(id)) continue const start = it?.ListingDetails?.StartTime || null const pic = asArray(it?.PictureDetails?.PictureURL)[0] || it?.PictureDetails?.GalleryURL || null const price = it?.SellingStatus?.CurrentPrice ?? it?.BuyItNowPrice ?? null const variations: any[] = it?.Variations?.Variation || [] if (variations.length) { for (const v of variations) { const specifics = (v?.VariationSpecifics?.NameValueList || []).map((n: any) => `${n.Name}: ${asArray(n.Value).join('/')}`).join(' · ') const sku = String(v.SKU || '') rows.push({ listingKey: `item:${id}:${sku || specifics}`, sellerSku: sku, externalProductId: id, externalInventoryId: sku || null, title: `${it.Title} – ${specifics}`, price: v?.StartPrice != null ? Number(v.StartPrice) : (price != null ? Number(price) : null), currency: conn.currency, qty: v?.Quantity != null ? Math.max(0, Number(v.Quantity) - Number(v?.SellingStatus?.QuantitySold || 0)) : null, fulfillmentChannel: 'DEFAULT', status: 'Active', dateListed: start, meta: { marketplaceId: conn.marketplaceId, listingId: id, legacy: true, variantTitle: specifics, variationSpecifics: v?.VariationSpecifics?.NameValueList || [], imageUrl: asArray(v?.VariationPictures?.Pictures?.PictureURL)[0] || pic, storefrontUrl: itemUrl(conn, id), adminUrl: editUrl(conn, id) } }) } } else { rows.push({ listingKey: `item:${id}`, sellerSku: String(it.SKU || ''), externalProductId: id, externalInventoryId: it.SKU ? String(it.SKU) : null, title: String(it.Title || id), price: price != null ? Number(price) : null, currency: conn.currency, qty: it?.QuantityAvailable != null ? Number(it.QuantityAvailable) : (it?.Quantity != null ? Math.max(0, Number(it.Quantity) - Number(it?.SellingStatus?.QuantitySold || 0)) : null), fulfillmentChannel: 'DEFAULT', status: 'Active', dateListed: start, meta: { marketplaceId: conn.marketplaceId, listingId: id, legacy: true, variantTitle: '', imageUrl: pic, listingType: it.ListingType || null, storefrontUrl: itemUrl(conn, id), adminUrl: editUrl(conn, id) } }) } seenListingIds.add(id) } const total = Number(res?.ActiveList?.PaginationResult?.TotalNumberOfPages || 1) if (page >= total || !list.length) break } } catch (e: any) { ctx.log(`legacy listings (Trading API) not read: ${errMsg(e)}`) } ctx.log(`ebay import: ${rows.length} row(s) — ${items.length} inventory item(s), ${rows.filter(r => r.meta?.legacy).length} legacy`) importCache.set(ctx.orderSourceId, { at: Date.now(), rows }) return { pending: false, rows, generatedAt: Date.now(), fromCache: false } }, async push(ctx, items): Promise { if (!items.length) return [] const conn = await connect(ctx) const out: PushResult[] = [] for (const item of items) { const l = item.listing const base = { listingId: l.id, changeIds: item.changes.map(c => c.id), deliveryMode: 'patch' as const } const warnings: string[] = [] if (item.minPrice != null || item.maxPrice != null) warnings.push('min/max price are not supported on eBay (ignored)') try { if (item.op === 'delete') { out.push({ ...base, status: 'error', error: 'Deleting an eBay listing from here is not supported — end it (delist) or remove it in Seller Hub' }); continue } if (item.op === 'create') { out.push(await createOffer(ctx, conn, item)); continue } const legacy = !!l.meta?.legacy || String(l.listingKey).startsWith('item:') let qty: number | null | undefined = item.qty if (item.op === 'delist') qty = 0 if (item.op === 'relist') qty = item.qty ?? l.qty ?? 0 const price = item.price != null && item.op !== 'delist' ? Number(item.price) : null if (!legacy) { const offerId = String(l.listingKey) if (item.op === 'delist') { await rest(conn, 'POST', `/sell/inventory/v1/offer/${encodeURIComponent(offerId)}/withdraw`, {}) } else { if (price != null || qty != null) { const req: any = { offers: [{ offerId, ...(qty != null ? { availableQuantity: Math.max(0, Math.trunc(qty)) } : {}), ...(price != null ? { price: { currency: l.currency || conn.currency, value: price.toFixed(2) } } : {}) }], sku: l.sellerSku } if (qty != null) req.shipToLocationAvailability = { quantity: Math.max(0, Math.trunc(qty)) } const res: any = await rest(conn, 'POST', '/sell/inventory/v1/bulk_update_price_quantity', { requests: [req] }) const r = (res?.responses || [])[0] if (r && Number(r.statusCode) >= 300) throw new Error((r.errors || []).map((e: any) => e.message).join('; ') || `bulk update answered ${r.statusCode}`) } if (item.op === 'relist') { const pub: any = await rest(conn, 'POST', `/sell/inventory/v1/offer/${encodeURIComponent(offerId)}/publish`, {}) if (pub?.listingId) { out.push({ ...base, status: 'sent', confirmed: true, submissionId: String(pub.listingId), warnings, remote: { externalProductId: String(pub.listingId), meta: { ...(l.meta || {}), listingId: String(pub.listingId), storefrontUrl: itemUrl(conn, String(pub.listingId)), adminUrl: editUrl(conn, String(pub.listingId)) } } }); continue } } } out.push({ ...base, status: 'sent', confirmed: true, submissionId: null, warnings }) continue } // legacy listing → Trading API const itemId = String(l.externalProductId || l.meta?.listingId || '') if (!itemId) throw new Error('Legacy eBay listing has no ItemID (ExternalProductId) — re-import it') if (item.op === 'delist') { await trading(conn, 'EndFixedPriceItem', { ItemID: itemId, EndingReason: 'NotAvailable' }) out.push({ ...base, status: 'sent', confirmed: true, submissionId: itemId, warnings }) continue } if (item.op === 'relist') { const res = await trading(conn, 'RelistFixedPriceItem', { Item: { ItemID: itemId, ...(price != null ? { StartPrice: price.toFixed(2) } : {}), ...(qty != null ? { Quantity: Math.max(0, Math.trunc(qty)) } : {}) } }) const newId = String(res?.ItemID || itemId) out.push({ ...base, status: 'sent', confirmed: true, submissionId: newId, warnings, remote: { listingKey: String(l.listingKey).replace(itemId, newId), externalProductId: newId, meta: { ...(l.meta || {}), listingId: newId, storefrontUrl: itemUrl(conn, newId), adminUrl: editUrl(conn, newId) } } }) continue } const status: any = { ItemID: itemId } if (l.sellerSku) status.SKU = l.sellerSku else if ((l.meta?.variationSpecifics || []).length) status.VariationSpecifics = { NameValueList: l.meta.variationSpecifics } if (price != null) status.StartPrice = price.toFixed(2) if (qty != null) status.Quantity = Math.max(0, Math.trunc(qty)) await trading(conn, 'ReviseInventoryStatus', { InventoryStatus: status }) out.push({ ...base, status: 'sent', confirmed: true, submissionId: itemId, warnings }) } catch (e: any) { out.push({ ...base, status: 'error', error: errMsg(e), transient: isTransient(e) }) } } return out }, async fetchCompetition(_ctx, listings): Promise { return listings.map(l => ({ listingId: l.id, buyBoxStatus: 'unknown', buyBoxPrice: null, lowestOtherPrice: null, competitorCount: null, feesEstimate: null, feesJson: null, error: 'not available on eBay' })) }, validateCreate(_ctx, product) { if (!String(product.value || '').trim()) return 'Product has no product number (Value) — eBay needs it as the SKU' return null } } /** "List products": inventory item + offer (default policies / location / suggested category) + publish. */ const createOffer = async (ctx: AdapterCtx, conn: EbayConn, item: PushItem): Promise => { const l = item.listing const base = { listingId: l.id, changeIds: item.changes.map(c => c.id), deliveryMode: 'patch' as const } const warnings: string[] = [] const price = item.price ?? l.price if (price == null) return { ...base, status: 'error', error: 'price missing' } const sku = l.sellerSku const qty = Math.max(0, Math.trunc(Number(item.qty ?? l.qty ?? 0))) const [ful, pay, ret, loc] = await Promise.all([ rest(conn, 'GET', `/sell/account/v1/fulfillment_policy?marketplace_id=${conn.marketplaceId}`).catch(() => null), rest(conn, 'GET', `/sell/account/v1/payment_policy?marketplace_id=${conn.marketplaceId}`).catch(() => null), rest(conn, 'GET', `/sell/account/v1/return_policy?marketplace_id=${conn.marketplaceId}`).catch(() => null), rest(conn, 'GET', '/sell/inventory/v1/location?limit=20').catch(() => null) ]) const policies = { fulfillmentPolicyId: ful?.fulfillmentPolicies?.[0]?.fulfillmentPolicyId, paymentPolicyId: pay?.paymentPolicies?.[0]?.paymentPolicyId, returnPolicyId: ret?.returnPolicies?.[0]?.returnPolicyId } const merchantLocationKey = loc?.locations?.[0]?.merchantLocationKey if (!policies.fulfillmentPolicyId || !policies.paymentPolicyId || !policies.returnPolicyId) return { ...base, status: 'error', error: 'eBay business policies (fulfillment / payment / return) missing for this marketplace — create them in Seller Hub first' } if (!merchantLocationKey) return { ...base, status: 'error', error: 'No eBay inventory location — create one in Seller Hub (or via the Inventory API) first' } const tree = MARKETS[conn.marketplaceId]?.categoryTree || '77' const sug: any = await rest(conn, 'GET', `/commerce/taxonomy/v1/category_tree/${tree}/get_category_suggestions?q=${encodeURIComponent(l.name.slice(0, 80))}`).catch(() => null) const categoryId = sug?.categorySuggestions?.[0]?.category?.categoryId if (!categoryId) return { ...base, status: 'error', error: `No eBay category suggestion for "${l.name}" — list it manually once, then import` } await rest(conn, 'PUT', `/sell/inventory/v1/inventory_item/${encodeURIComponent(sku)}`, { availability: { shipToLocationAvailability: { quantity: qty } }, condition: 'NEW', product: { title: l.name.slice(0, 80), description: l.description || l.name, ...(l.meta?.imageUrl ? { imageUrls: [l.meta.imageUrl] } : {}) } }) const offer: any = await rest(conn, 'POST', '/sell/inventory/v1/offer', { sku, marketplaceId: conn.marketplaceId, format: 'FIXED_PRICE', availableQuantity: qty, categoryId, merchantLocationKey, listingDescription: l.description || l.name, listingPolicies: policies, pricingSummary: { price: { currency: conn.currency, value: Number(price).toFixed(2) } } }) const offerId = String(offer?.offerId || '') if (!offerId) return { ...base, status: 'error', error: 'createOffer answered without offerId' } let listingId: string | null = null try { const pub: any = await rest(conn, 'POST', `/sell/inventory/v1/offer/${offerId}/publish`, {}); listingId = pub?.listingId ? String(pub.listingId) : null; for (const w of (pub?.warnings || [])) warnings.push(String(w.message || '')) } catch (e: any) { warnings.push(`offer ${offerId} created but not published: ${errMsg(e)}`) } ctx.log(`created eBay offer ${offerId}${listingId ? ` / listing ${listingId}` : ''} for ${sku}`) return { ...base, status: 'sent', confirmed: true, submissionId: offerId, warnings, remote: { listingKey: offerId, externalProductId: listingId, externalInventoryId: sku, meta: { ...(l.meta || {}), marketplaceId: conn.marketplaceId, listingId, legacy: false, categoryId, merchantLocationKey, storefrontUrl: itemUrl(conn, listingId), adminUrl: editUrl(conn, listingId) } } } }