import { syncProductCategory } from './categories.js'; import { syncListingPolicies } from './listingPolicies.js'; import { buildVarientEntry, fetchOffers, fromEbayCondition, resolveListingDescription, resolveOfferState, syncOfferAndMaybePublish, upsertInventoryItem, withdrawOfferById, } from './listingVarients.js'; import { makeRequest, logger } from './shared.js'; import { getEbayItemUrl, parseEbayItemId } from './itemUrl.js'; import { marketplaceSku } from '../ids.js'; import { buildGroupVariesBy } from './variationAspects.js'; import { toEbayHtmlDescription } from './description.js'; import { attachImageUrlsToListingAndVarients } from './images.js'; import { listingImageSyncHash, listingImagesUnchanged, payloadsEqual, } from '../syncFingerprint.js'; function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } export function buildInventoryItemGroupBody(listing, varients) { const groupKey = listing._reference; const variantSKUs = varients.map((v) => marketplaceSku(v)).filter(Boolean); const variation = buildGroupVariesBy(varients); const body = { title: listing.title || groupKey, variantSKUs, description: toEbayHtmlDescription(resolveListingDescription(listing)), variesBy: variation.variesBy, }; if (variation.aspects) { body.aspects = variation.aspects; } if (listing.imageUrls?.length) { body.imageUrls = listing.imageUrls; const firstSpecName = variation.variesBy?.specifications?.[0]?.name; if (firstSpecName) { body.variesBy = { ...body.variesBy, aspectsImageVariesBy: [firstSpecName], }; } } return body; } function comparableInventoryGroup(group) { if (!group) return null; return { title: group.title || '', variantSKUs: group.variantSKUs || [], description: group.description || '', variesBy: group.variesBy || null, aspects: group.aspects || null, imageUrls: group.imageUrls || null, }; } async function createOrReplaceGroup(marketplace, listing, varients) { const groupKey = listing._reference; const body = buildInventoryItemGroupBody(listing, varients); const existing = await safeFetchInventoryItemGroup(marketplace, groupKey); if (existing && payloadsEqual(comparableInventoryGroup(existing), comparableInventoryGroup(body))) { logger.debug(`Inventory item group "${groupKey}" unchanged — skipping eBay PUT`); return; } await makeRequest({ marketplace, method: 'PUT', path: `/sell/inventory/v1/inventory_item_group/${encodeURIComponent(groupKey)}`, body, }); } async function deleteGroup(marketplace, groupKey) { try { await makeRequest({ marketplace, method: 'DELETE', path: `/sell/inventory/v1/inventory_item_group/${encodeURIComponent(groupKey)}`, acceptableStatuses: [404], }); } catch (err) { logger.warn(`Failed to delete inventory item group "${groupKey}": ${err.message}`); } } async function syncSingleVarientListing(marketplace, listing, varient) { await upsertInventoryItem(marketplace, varient, listing); // If this listing used to be grouped, remove the stale group before treating it as a standalone item. if (listing._reference) { const existingGroup = await safeFetchInventoryItemGroup(marketplace, listing._reference); if (existingGroup) { await deleteGroup(marketplace, listing._reference); } } return syncOfferAndMaybePublish(marketplace, listing, varient); } async function syncGroupedListing(marketplace, listing, varients) { logger.info( `Syncing eBay inventory item group "${listing._reference}" with ${varients.length} varient(s)` ); for (const varient of varients) { await upsertInventoryItem(marketplace, varient, listing); } // Brief delay so eBay can resolve the new inventory item SKUs before creating the group. await sleep(1000); await createOrReplaceGroup(marketplace, listing, varients); let firstPublished = { url: '' }; for (const varient of varients) { try { const published = await syncOfferAndMaybePublish(marketplace, listing, varient); if (published?.url && !firstPublished.url) firstPublished = published; } catch (err) { logger.warn(`Failed to create offer for varient ${varient._reference}: ${err.message}`); } } return firstPublished; } async function syncListing(marketplace, listing, varients, actionLabel) { const ref = listing._reference; if (!ref) { throw new Error(`Listing must have a _reference to ${actionLabel} on eBay`); } const validVarients = (varients || []).filter((varient) => varient?._reference); if (validVarients.length === 0) { throw new Error( `Listing must have at least one varient with a _reference to ${actionLabel} on eBay` ); } const { listing: listingWithImages, varients: varientsWithImages } = await attachImageUrlsToListingAndVarients(marketplace, listing, validVarients); const category = await syncProductCategory(marketplace, listingWithImages, varientsWithImages); const listingPolicies = await syncListingPolicies(marketplace, listingWithImages); const listingWithContext = { ...listingWithImages, ...(category ? { categoryId: category.categoryId } : {}), ...listingPolicies, }; const imageState = imageSyncState( listing, validVarients, listingWithImages, varientsWithImages ); if (varientsWithImages.length === 1) { logger.info( `Syncing standalone eBay inventory item "${varientsWithImages[0]._reference}" for listing "${ref}"` ); const published = await syncSingleVarientListing( marketplace, listingWithContext, varientsWithImages[0] ); return listingSyncResult(published, marketplace, imageState); } const published = await syncGroupedListing(marketplace, listingWithContext, varientsWithImages); return listingSyncResult(published, marketplace, imageState); } function listingSyncResult(published, marketplace, imageState = {}) { if (!published) { return { url: '', ...imageState }; } if (typeof published === 'string') { return { url: published, ...imageState }; } const listingId = parseEbayItemId(published.listingId || published.externalReference); return { url: published.url || getEbayItemUrl(marketplace, listingId), ...(listingId ? { externalReference: listingId } : {}), ...imageState, }; } function imageSyncState(listing, varients, listingWithImages, varientsWithImages) { return { syncImageHash: listingImageSyncHash(listing, varients), marketplaceImageUrls: listingWithImages?.imageUrls || listingWithImages?.marketplaceImageUrls || [], varients: (varientsWithImages || []).map((varient) => ({ _id: varient._id, marketplaceImageUrls: varient.imageUrls || varient.marketplaceImageUrls || [], syncImageHash: varient.syncImageHash, })), }; } export async function createItem(marketplace, listing, varients) { return syncListing(marketplace, listing, varients, 'create'); } export async function updateItem(marketplace, listing, varients) { return syncListing(marketplace, listing, varients, 'update'); } export async function syncListingImages(marketplace, listing, varients = []) { const validVarients = (varients || []).filter((varient) => varient?._reference); if (validVarients.length === 0) return null; if (listingImagesUnchanged(listing, validVarients) && listing.marketplaceImageUrls?.length) { logger.debug( `Listing "${listing._reference}" images unchanged — skipping eBay image sync` ); return { listing, varients: validVarients, skipped: true }; } const { listing: listingWithImages, varients: varientsWithImages } = await attachImageUrlsToListingAndVarients(marketplace, listing, validVarients); for (const varient of varientsWithImages) { await upsertInventoryItem(marketplace, varient, listingWithImages); } return { listing: listingWithImages, varients: varientsWithImages }; } export async function deleteItem(marketplace, listing, varients = []) { const skus = varients.map((v) => marketplaceSku(v)).filter(Boolean); for (const sku of skus) { const offers = await fetchOffers(marketplace, sku); for (const offer of offers) { if (!offer?.offerId) continue; try { await withdrawOfferById(marketplace, offer.offerId); } catch (err) { logger.warn(`Failed to withdraw offer ${offer.offerId} for SKU ${sku}: ${err.message}`); } } try { await makeRequest({ marketplace, method: 'DELETE', path: `/sell/inventory/v1/inventory_item/${encodeURIComponent(sku)}`, acceptableStatuses: [404], }); } catch (err) { logger.warn(`Failed to delete eBay inventory item "${sku}": ${err.message}`); } } const ref = listing._reference; if (ref) { logger.info(`Deleting eBay inventory item group "${ref}"`); await deleteGroup(marketplace, ref); } } // --- Sync helpers (inbound from eBay) --- async function fetchAllInventoryItems(marketplace) { const items = []; let offset = 0; const limit = 100; do { const data = await makeRequest({ marketplace, path: '/sell/inventory/v1/inventory_item', params: { limit, offset }, }); if (data?.inventoryItems?.length) { items.push(...data.inventoryItems); } if (!data?.inventoryItems?.length || items.length >= (data.total || 0)) { break; } offset += limit; } while (true); return items; } async function safeFetchInventoryItemGroup(marketplace, groupKey) { try { return await makeRequest({ marketplace, path: `/sell/inventory/v1/inventory_item_group/${encodeURIComponent(groupKey)}`, acceptableStatuses: [404], }); } catch (err) { logger.warn(`Failed to fetch inventory item group "${groupKey}": ${err.message}`); return null; } } export async function syncItems(marketplace) { logger.info(`Syncing inventory from eBay marketplace: ${marketplace.name}`); const inventoryItems = await fetchAllInventoryItems(marketplace); const itemsBySku = new Map(); const groupKeysSet = new Set(); const groupedSkus = new Set(); for (const item of inventoryItems) { itemsBySku.set(item.sku, item); if (item.inventoryItemGroupKeys?.length) { for (const key of item.inventoryItemGroupKeys) { groupKeysSet.add(key); } } } const results = []; for (const groupKey of groupKeysSet) { const group = await safeFetchInventoryItemGroup(marketplace, groupKey); if (!group) continue; const variantSkus = group.variantSKUs || []; for (const sku of variantSkus) { groupedSkus.add(sku); } const variantItems = []; for (const sku of variantSkus) { const item = itemsBySku.get(sku); if (item) { try { const offers = await fetchOffers(marketplace, sku); variantItems.push({ ...item, _offers: offers }); } catch (err) { logger.warn(`Failed to fetch offers for group variant SKU ${sku}: ${err.message}`); variantItems.push({ ...item, _offers: [] }); } } } results.push({ _type: 'group', _groupKey: groupKey, _group: group, _variants: variantItems, }); } for (const item of inventoryItems) { if (groupedSkus.has(item.sku)) continue; try { const offers = await fetchOffers(marketplace, item.sku); results.push({ _type: 'single', ...item, _offers: offers, }); } catch (err) { logger.warn(`Failed to fetch offers for SKU ${item.sku}: ${err.message}`); results.push({ _type: 'single', ...item, _offers: [], }); } } logger.info( `Fetched ${results.length} listing(s) from eBay (${groupKeysSet.size} group(s), ${results.length - groupKeysSet.size} standalone)` ); return results; } export function mapProductToListing(ebayItem, marketplace) { if (ebayItem._type === 'group') { const group = ebayItem._group; const variants = ebayItem._variants || []; const allOffers = variants.flatMap((v) => v._offers || []); const stateType = resolveOfferState(allOffers); const firstPublishedOffer = allOffers.find((o) => o?.listingId || o?.listing?.listingId); const publishedListingId = parseEbayItemId( firstPublishedOffer?.listingId || firstPublishedOffer?.listing?.listingId ); const listingId = publishedListingId || ebayItem._groupKey; const url = getEbayItemUrl(marketplace, publishedListingId); const firstOffer = allOffers[0]; const price = firstOffer?.pricingSummary?.price?.value ? parseFloat(firstOffer.pricingSummary.price.value) : undefined; const currency = firstOffer?.pricingSummary?.price?.currency || undefined; const varients = variants.map((v) => buildVarientEntry(v, v._offers || [])); return { externalReference: listingId, title: group.title || ebayItem._groupKey, description: group.description, condition: fromEbayCondition(variants[0]?.condition), state: { type: stateType }, price, currency, url, varients, }; } const offer = ebayItem._offers?.[0]; const price = offer?.pricingSummary?.price?.value ? parseFloat(offer.pricingSummary.price.value) : undefined; const currency = offer?.pricingSummary?.price?.currency || undefined; const stateType = resolveOfferState(ebayItem._offers || []); const publishedListingId = parseEbayItemId(offer?.listingId || offer?.listing?.listingId); const listingId = publishedListingId || ebayItem.sku; const url = getEbayItemUrl(marketplace, publishedListingId); const varients = [buildVarientEntry(ebayItem, ebayItem._offers || [])]; return { externalReference: listingId, title: ebayItem.product?.title || ebayItem.sku, description: ebayItem.product?.description, condition: fromEbayCondition(ebayItem.condition), state: { type: stateType }, price, currency, url, varients, }; }