import mongoose from 'mongoose'; import { stockLocationModel } from '../../../database/schemas/inventory/stocklocation.schema.js'; import { FARMCONTROL_GB_SUBDIVISION_STATE, resolveEbayCountry } from './countryCodes.js'; import { syncProductCategory } from './categories.js'; import { syncFulfillmentPolicy } from './fulfillmentPolicies.js'; import { makeRequest, logger } from './shared.js'; const WAREHOUSE_ADDRESS_DEFAULTS = { GB: { city: 'London', stateOrProvince: 'England', postalCode: 'SW1A 1AA' }, US: { city: 'New York', stateOrProvince: 'NY', postalCode: '10001' }, AU: { city: 'Sydney', stateOrProvince: 'NSW', postalCode: '2000' }, CA: { city: 'Toronto', stateOrProvince: 'ON', postalCode: 'M5H 2N2' }, DE: { city: 'Berlin', stateOrProvince: 'Berlin', postalCode: '10115' }, FR: { city: 'Paris', stateOrProvince: 'Île-de-France', postalCode: '75001' }, }; const LISTING_STATUS_MAP = { ACTIVE: 'active', OUT_OF_STOCK: 'inactive', ENDED: 'inactive', PUBLISHED: 'active', UNPUBLISHED: 'draft', }; function isPopulatedStockLocation(stockLocation) { if (!stockLocation || typeof stockLocation !== 'object') return false; if (stockLocation instanceof mongoose.Types.ObjectId) return false; return stockLocation.name != null || stockLocation.address != null; } async function ensureListingStockLocation(listing) { const stockLocationRef = listing?.stockLocation; if (!stockLocationRef || isPopulatedStockLocation(stockLocationRef)) { return listing; } const stockLocationId = stockLocationRef._id || stockLocationRef; const stockLocation = await stockLocationModel.findById(stockLocationId).lean(); if (!stockLocation) throw new Error('Listing stock location not found.'); return { ...listing, stockLocation }; } export function resolveStockLocationCountryCode(listing) { return resolveStockLocationEbayCountry(listing)?.ebayCountryCode ?? null; } export function resolveStockLocationEbayCountry(listing) { const stockLocation = listing?.stockLocation; if (!isPopulatedStockLocation(stockLocation)) return null; try { return resolveEbayCountry(stockLocation.address?.country); } catch { return null; } } function resolveMerchantLocationKey(listing) { const stockLocationId = listing?.stockLocation?._id || listing?.stockLocation; return stockLocationId ? `fc-${String(stockLocationId)}`.slice(0, 36) : null; } function buildEbayWarehouseAddress(addr, ebayCountryCode, farmControlCountryCode) { const address = { country: ebayCountryCode }; if (addr?.addressLine1) address.addressLine1 = addr.addressLine1; if (addr?.addressLine2) address.addressLine2 = addr.addressLine2; if (addr?.city) address.city = addr.city; if (addr?.state) address.stateOrProvince = addr.state; if (addr?.postcode) address.postalCode = addr.postcode; if ( !address.stateOrProvince && farmControlCountryCode && FARMCONTROL_GB_SUBDIVISION_STATE[farmControlCountryCode] ) { address.stateOrProvince = FARMCONTROL_GB_SUBDIVISION_STATE[farmControlCountryCode]; } if (!address.postalCode && !(address.city && address.stateOrProvince)) { const defaults = WAREHOUSE_ADDRESS_DEFAULTS[ebayCountryCode]; if (defaults) { Object.assign(address, defaults); if (farmControlCountryCode && FARMCONTROL_GB_SUBDIVISION_STATE[farmControlCountryCode]) { address.stateOrProvince = FARMCONTROL_GB_SUBDIVISION_STATE[farmControlCountryCode]; } } else { address.city = address.city || 'Unknown'; address.stateOrProvince = address.stateOrProvince || ebayCountryCode; address.postalCode = address.postalCode || '00000'; } } if (!address.country) { throw new Error('eBay warehouse address requires a country code.'); } return address; } function buildWarehouseLocationBody(listing, ebayCountryCode, farmControlCountryCode) { const stockLocation = listing.stockLocation; return { location: { address: buildEbayWarehouseAddress( stockLocation?.address || {}, ebayCountryCode, farmControlCountryCode ), }, locationTypes: ['WAREHOUSE'], name: stockLocation?.name || `FarmControl ${ebayCountryCode}`, }; } export async function ensureMerchantLocation(marketplace, listing) { const listingWithStockLocation = await ensureListingStockLocation(listing); const countryResolved = resolveEbayCountry( listingWithStockLocation.stockLocation?.address?.country ); if (!countryResolved) { throw new Error( 'Listing stock location address must include a country before syncing or publishing on eBay.' ); } const merchantLocationKey = resolveMerchantLocationKey(listingWithStockLocation); if (!merchantLocationKey) { throw new Error('Listing must have a stock location before syncing or publishing on eBay.'); } const locationBody = buildWarehouseLocationBody( listingWithStockLocation, countryResolved.ebayCountryCode, countryResolved.farmControlCountryCode ); listing.stockLocation = listingWithStockLocation.stockLocation; const locationPath = `/sell/inventory/v1/location/${encodeURIComponent(merchantLocationKey)}`; const existing = await makeRequest({ marketplace, path: locationPath, acceptableStatuses: [404], }); if (existing) { await makeRequest({ marketplace, method: 'POST', path: `${locationPath}/update_location_details`, body: { location: locationBody.location }, }); } else { await makeRequest({ marketplace, method: 'POST', path: locationPath, body: locationBody, }); } return merchantLocationKey; } function applyMarketplaceOfferDefaults( offer, marketplace, merchantLocationKey, fulfillmentPolicyId ) { offer.merchantLocationKey = merchantLocationKey; const config = marketplace.config || {}; const listingPolicies = {}; if (fulfillmentPolicyId || config.fulfillmentPolicyId) { listingPolicies.fulfillmentPolicyId = fulfillmentPolicyId || config.fulfillmentPolicyId; } if (config.paymentPolicyId) listingPolicies.paymentPolicyId = config.paymentPolicyId; if (config.returnPolicyId) listingPolicies.returnPolicyId = config.returnPolicyId; if (Object.keys(listingPolicies).length) offer.listingPolicies = listingPolicies; } function mapVarientToInventoryItem(varient, listing) { const item = { product: { title: listing.title || varient._reference || '' }, availability: { shipToLocationAvailability: { quantity: varient.inventory ?? 0 }, }, }; if (listing.description) item.product.description = listing.description; if (listing.imageUrls?.length) item.product.imageUrls = listing.imageUrls; return item; } function mapVarientToOffer(varient, listing, marketplace, merchantLocationKey) { const offer = { sku: varient._reference, marketplaceId: marketplace.config?.marketplaceId || 'EBAY_GB', format: 'FIXED_PRICE', }; applyMarketplaceOfferDefaults( offer, marketplace, merchantLocationKey, listing.fulfillmentPolicyId ); const price = varient.price ?? listing.price; if (price != null) { offer.pricingSummary = { price: { value: String(price), currency: varient.currency || listing.currency || 'GBP', }, }; } if (listing.categoryId) offer.categoryId = String(listing.categoryId); return offer; } export async function upsertInventoryItem(marketplace, varient, listing) { const inventoryItem = mapVarientToInventoryItem(varient, listing); const result = await makeRequest({ marketplace, method: 'PUT', path: `/sell/inventory/v1/inventory_item/${encodeURIComponent(varient._reference)}`, body: inventoryItem, }); logger.debug('inventoryItem', inventoryItem); logger.debug('result', result); } export async function fetchOffers(marketplace, sku) { try { const data = await makeRequest({ marketplace, path: '/sell/inventory/v1/offer', params: { sku, limit: 200 }, acceptableStatuses: [404], }); return data?.offers || []; } catch (err) { logger.debug(`No offers found for SKU ${sku}: ${err.message}`); return []; } } async function upsertOrCreateOffer(marketplace, varient, listing) { const merchantLocationKey = await ensureMerchantLocation(marketplace, listing); const offers = await fetchOffers(marketplace, varient._reference); const existingOffer = offers[0]; const price = varient.price ?? listing.price; if (existingOffer?.offerId) { const offerUpdate = { merchantLocationKey }; if (price != null) { offerUpdate.pricingSummary = { price: { value: String(price), currency: varient.currency || listing.currency || 'GBP', }, }; } if (listing.categoryId) offerUpdate.categoryId = String(listing.categoryId); if (listing.fulfillmentPolicyId) { offerUpdate.listingPolicies = { ...(existingOffer.listingPolicies || {}), fulfillmentPolicyId: String(listing.fulfillmentPolicyId), }; } const body = { ...existingOffer, ...offerUpdate }; await makeRequest({ marketplace, method: 'PUT', path: `/sell/inventory/v1/offer/${existingOffer.offerId}`, body, }); logger.debug('offerUpdate', body); return existingOffer; } const offerBody = mapVarientToOffer(varient, listing, marketplace, merchantLocationKey); const result = await makeRequest({ marketplace, method: 'POST', path: '/sell/inventory/v1/offer', body: offerBody, }); logger.debug('offerBody', offerBody); return result; } export async function publishOfferById(marketplace, offerId) { if (!offerId) throw new Error('offerId is required to publish an offer'); return makeRequest({ marketplace, method: 'POST', path: `/sell/inventory/v1/offer/${encodeURIComponent(offerId)}/publish`, }); } export async function withdrawOfferById(marketplace, offerId) { if (!offerId) throw new Error('offerId is required to withdraw an offer'); return makeRequest({ marketplace, method: 'POST', path: `/sell/inventory/v1/offer/${encodeURIComponent(offerId)}/withdraw`, }); } export async function syncOfferAndMaybePublish(marketplace, listing, varient) { const offerResult = await upsertOrCreateOffer(marketplace, varient, listing); if (offerResult?.offerId && listing.state?.type === 'active') { try { const publishResult = await publishOfferById(marketplace, offerResult.offerId); if (publishResult?.listingId) { return `https://www.ebay.com/itm/${publishResult.listingId}`; } } catch (err) { logger.warn( `Created offer but failed to publish for varient ${varient._reference}: ${err.message}` ); } } return ''; } export async function publishOfferForSku(marketplace, sku, listing) { if (!sku) throw new Error('SKU (_reference) is required to publish an offer'); if (!listing) { throw new Error( 'Listing is required to publish an eBay offer (stock location address is used for item location).' ); } const merchantLocationKey = await ensureMerchantLocation(marketplace, listing); const category = await syncProductCategory(marketplace, listing); const fulfillmentPolicy = await syncFulfillmentPolicy(marketplace, listing); if (!category?.categoryId) { throw new Error( `Listing "${listing._reference || sku}" must have a product with a product category before publishing on eBay.` ); } const existingOffer = (await fetchOffers(marketplace, sku))[0]; if (!existingOffer?.offerId) { throw new Error( `No eBay offer exists for SKU "${sku}". Create or sync the listing so an offer exists before publishing.` ); } await makeRequest({ marketplace, method: 'PUT', path: `/sell/inventory/v1/offer/${existingOffer.offerId}`, body: { ...existingOffer, merchantLocationKey, categoryId: String(category.categoryId), listingPolicies: { ...(existingOffer.listingPolicies || {}), fulfillmentPolicyId: fulfillmentPolicy.fulfillmentPolicyId, }, }, }); const publishResult = await publishOfferById(marketplace, existingOffer.offerId); return { offerId: existingOffer.offerId, listingId: publishResult?.listingId }; } export async function withdrawOfferForSku(marketplace, sku) { if (!sku) throw new Error('SKU (_reference) is required to withdraw an offer'); const existingOffer = (await fetchOffers(marketplace, sku))[0]; if (!existingOffer?.offerId) throw new Error(`No eBay offer exists for SKU "${sku}".`); await withdrawOfferById(marketplace, existingOffer.offerId); return { offerId: existingOffer.offerId }; } export function resolveOfferState(offers) { let stateType = 'draft'; for (const offer of offers) { if (offer?.status && LISTING_STATUS_MAP[offer.status]) { const mapped = LISTING_STATUS_MAP[offer.status]; if (mapped === 'active') return 'active'; if (mapped !== 'draft') stateType = mapped; } } return stateType; } export function buildVarientEntry(item, offers) { const offer = offers?.[0]; return { _reference: item.sku, price: offer?.pricingSummary?.price?.value ? parseFloat(offer.pricingSummary.price.value) : undefined, currency: offer?.pricingSummary?.price?.currency || undefined, state: { type: resolveOfferState(offers || []) }, }; }