import mongoose from 'mongoose'; import { courierServiceModel } from '../../../database/schemas/management/courierservice.schema.js'; import { fulfillmentPolicyModel } from '../../../database/schemas/sales/fulfillmentpolicy.schema.js'; import { makeRequest, logger } from './shared.js'; import { POLICY_CATEGORY_TYPE, buildCategoryTypes as buildSharedCategoryTypes, ensureSellingPolicyManagement, getEbayMarketplaceId, idOf, isDefaultAccountPolicy, mappingExternalReference, persistMarketplaceMapping, updateAccountPolicy, upsertLocalPolicyFromRemote, } from './accountPolicies.js'; const FULFILLMENT_CATEGORY_TYPE = POLICY_CATEGORY_TYPE; function isPopulatedCourierService(service) { return ( service && typeof service === 'object' && !(service instanceof mongoose.Types.ObjectId) && service.name != null ); } async function resolveCourierServices(listing) { const serviceRefs = listing?.courierServices || []; if (!serviceRefs.length) return []; const populatedServices = serviceRefs.filter(isPopulatedCourierService); if (populatedServices.length === serviceRefs.length) { return populatedServices; } const serviceIds = serviceRefs.map((service) => service?._id || service).filter(Boolean); const services = await courierServiceModel .find({ _id: { $in: serviceIds } }) .populate(['courier', 'marketplaces.marketplace']) .lean(); const servicesById = new Map(services.map((service) => [String(service._id), service])); return serviceIds.map((id) => servicesById.get(String(id))).filter(Boolean); } export function getCourierServiceMarketplaceMappings(service) { if (Array.isArray(service?.marketplaces) && service.marketplaces.length) { return service.marketplaces; } if (service?.marketplace) { return [ { marketplace: service.marketplace, externalReference: service.externalReference, }, ]; } return []; } export function getCourierServiceShippingCode(service, marketplace) { const marketplaceId = idOf(marketplace); const mappings = getCourierServiceMarketplaceMappings(service); if (!marketplaceId) { return mappings[0]?.externalReference || service?.externalReference || ''; } const mapping = mappings.find((entry) => idOf(entry?.marketplace) === marketplaceId); return mapping?.externalReference || ''; } export function validateCourierServices(services, listing, marketplace) { const activeServices = services.filter((service) => service.active !== false); if (!activeServices.length) { throw new Error( `Listing "${listing._reference}" must have at least one active courier service before syncing with eBay.` ); } for (const service of activeServices) { if (!getCourierServiceShippingCode(service, marketplace)) { throw new Error( `Courier service "${service.name}" requires an eBay shipping service code before it can be used on an eBay listing.` ); } } const domesticCount = activeServices.filter((service) => !service.international).length; const internationalCount = activeServices.length - domesticCount; if (domesticCount > 4 || internationalCount > 5) { throw new Error( 'eBay fulfillment policies support at most four domestic and five international courier services.' ); } return activeServices; } function buildShippingService(service, index, defaultCurrency, marketplace) { const cost = Number(service.costWithTax ?? service.cost ?? 0); const additionalCost = Number(service.additionalCostWithTax ?? service.additionalCost ?? 0); const shippingService = { sortOrder: index + 1, shippingServiceCode: getCourierServiceShippingCode(service, marketplace), buyerResponsibleForShipping: false, freeShipping: cost === 0 && additionalCost === 0, shippingCost: { value: String(cost), currency: service.shippingCurrency || defaultCurrency, }, }; if (service.additionalCostWithTax != null || service.additionalCost != null) { shippingService.additionalShippingCost = { value: String(additionalCost), currency: service.shippingCurrency || defaultCurrency, }; } return shippingService; } function buildShippingOption(optionType, services, defaultCurrency, marketplace) { if (!services.length) return null; return { optionType, costType: 'FLAT_RATE', shippingServices: services.map((service, index) => buildShippingService(service, index, defaultCurrency, marketplace) ), }; } export function isDefaultFulfillmentPolicy(existingPolicy, allPolicies = []) { return isDefaultAccountPolicy(existingPolicy, allPolicies, 'fulfillmentPolicyId'); } export function buildCategoryTypes(existingPolicy, allPolicies = []) { return buildSharedCategoryTypes(existingPolicy, allPolicies, 'fulfillmentPolicyId'); } export function buildFulfillmentPolicy( listing, marketplace, services, existingPolicy, allPolicies = [], overrides = {} ) { const marketplaceId = getEbayMarketplaceId(marketplace); const defaultCurrency = marketplace.config?.currency || listing.currency || 'GBP'; const domesticServices = services.filter((service) => !service.international); const internationalServices = services.filter((service) => service.international); const shippingOptions = [ buildShippingOption('DOMESTIC', domesticServices, defaultCurrency, marketplace), buildShippingOption('INTERNATIONAL', internationalServices, defaultCurrency, marketplace), ].filter(Boolean); const deliveryTime = overrides.handlingTime != null ? Number(overrides.handlingTime) : Math.max(0, ...services.map((service) => Number(service.deliveryTime ?? 1))); return { name: String(overrides.name || `FarmControl ${listing._reference || ''}`).slice(0, 64), description: String( overrides.description || `Managed by FarmControl for listing ${listing._reference || ''}` ).slice(0, 250), marketplaceId, categoryTypes: buildCategoryTypes(existingPolicy, allPolicies), handlingTime: { value: Number.isFinite(deliveryTime) ? deliveryTime : 1, unit: 'DAY' }, localPickup: overrides.localPickup === true, globalShipping: overrides.globalShipping === true, freightShipping: overrides.freightShipping === true, pickupDropOff: overrides.pickupDropOff === true, shippingOptions, }; } function moneyKey(amount) { if (amount == null || amount.value == null || amount.value === '') { return '0:'; } return `${Number(amount.value)}:${amount.currency || ''}`; } function shippingServiceFingerprint(service = {}) { return [ service.sortOrder || 1, service.shippingServiceCode || '', service.freeShipping ? '1' : '0', service.buyerResponsibleForShipping ? '1' : '0', moneyKey(service.shippingCost), moneyKey(service.additionalShippingCost), ].join('|'); } function shippingOptionFingerprint(option = {}) { const services = [...(option.shippingServices || [])] .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)) .map(shippingServiceFingerprint); return `${option.optionType || ''}|${option.costType || ''}|${services.join(',')}`; } export function fulfillmentPolicyFingerprint(policy) { const options = [...(policy?.shippingOptions || [])] .sort((a, b) => String(a?.optionType || '').localeCompare(String(b?.optionType || ''))) .map(shippingOptionFingerprint); const handling = policy?.handlingTime || {}; return [ policy?.marketplaceId || '', Number(handling.value) || 0, handling.unit || 'DAY', policy?.localPickup ? 1 : 0, policy?.globalShipping ? 1 : 0, policy?.freightShipping ? 1 : 0, policy?.pickupDropOff ? 1 : 0, options.join(';'), ].join('~'); } export function findMatchingFulfillmentPolicy(desiredPolicy, allPolicies = []) { const desiredFingerprint = fulfillmentPolicyFingerprint(desiredPolicy); return ( allPolicies.find( (policy) => policy?.fulfillmentPolicyId && fulfillmentPolicyFingerprint(policy) === desiredFingerprint ) || null ); } export function duplicateFulfillmentPolicyId(err) { const params = []; for (const ebayError of err?.ebayErrors || []) { if (Array.isArray(ebayError?.parameters)) params.push(...ebayError.parameters); } const match = params.find((param) => /^(DuplicateProfileId|Shipping Profile Id)$/i.test(param?.name || '') ); return match?.value ? String(match.value) : ''; } function getMarketplaceId(marketplace) { return getEbayMarketplaceId(marketplace); } async function fetchFulfillmentPolicyByName(marketplace, name) { return makeRequest({ marketplace, path: '/sell/account/v1/fulfillment_policy/get_by_policy_name', params: { marketplace_id: getMarketplaceId(marketplace), name, }, acceptableStatuses: [404], }); } async function fetchFulfillmentPolicies(marketplace) { const result = await makeRequest({ marketplace, path: '/sell/account/v1/fulfillment_policy', params: { marketplace_id: getMarketplaceId(marketplace) }, acceptableStatuses: [404], }); return result?.fulfillmentPolicies || []; } export { fetchFulfillmentPolicies }; async function updateFulfillmentPolicy(marketplace, fulfillmentPolicyId, policy) { await updateAccountPolicy( marketplace, `/sell/account/v1/fulfillment_policy/${encodeURIComponent(fulfillmentPolicyId)}`, policy ); } async function resolvePolicyCourierServices(policy) { const serviceRefs = policy?.courierServices || []; if (!serviceRefs.length) return []; const populatedServices = serviceRefs.filter(isPopulatedCourierService); if (populatedServices.length === serviceRefs.length) { return populatedServices; } const serviceIds = serviceRefs.map((service) => service?._id || service).filter(Boolean); const services = await courierServiceModel .find({ _id: { $in: serviceIds } }) .populate(['courier', 'marketplaces.marketplace']) .lean(); const servicesById = new Map(services.map((service) => [String(service._id), service])); return serviceIds.map((id) => servicesById.get(String(id))).filter(Boolean); } export async function ensureFulfillmentPolicySynced(marketplace, policy) { if (!policy) { throw new Error('A fulfillment policy is required before publishing to eBay.'); } await ensureSellingPolicyManagement(marketplace); await persistMarketplaceMapping(fulfillmentPolicyModel, policy, marketplace, { stateType: 'syncing', }); try { const services = validateCourierServices( await resolvePolicyCourierServices(policy), { _reference: policy._reference || policy.name }, marketplace ); const existingId = mappingExternalReference(policy, marketplace); const [existingByName, allPolicies] = await Promise.all([ existingId ? null : fetchFulfillmentPolicyByName(marketplace, String(policy.name).slice(0, 64)), fetchFulfillmentPolicies(marketplace), ]); const existingPolicy = (existingId && allPolicies.find((item) => String(item.fulfillmentPolicyId) === String(existingId))) || existingByName || null; const payload = buildFulfillmentPolicy( { _reference: policy._reference, currency: marketplace.config?.currency || 'GBP' }, marketplace, services, existingPolicy, allPolicies, { name: policy.name, description: policy.description, handlingTime: policy.handlingTime, localPickup: policy.localPickup, globalShipping: policy.globalShipping, freightShipping: policy.freightShipping, pickupDropOff: policy.pickupDropOff, } ); let fulfillmentPolicyId = existingId || existingPolicy?.fulfillmentPolicyId; if (fulfillmentPolicyId) { await updateFulfillmentPolicy(marketplace, fulfillmentPolicyId, payload); } else { const matchingPolicy = findMatchingFulfillmentPolicy(payload, allPolicies); if (matchingPolicy?.fulfillmentPolicyId) { fulfillmentPolicyId = matchingPolicy.fulfillmentPolicyId; } else { try { const created = await makeRequest({ marketplace, method: 'POST', path: '/sell/account/v1/fulfillment_policy', body: payload, }); fulfillmentPolicyId = created?.fulfillmentPolicyId; } catch (err) { const duplicateId = duplicateFulfillmentPolicyId(err); if (!duplicateId) throw err; fulfillmentPolicyId = duplicateId; } } } if (!fulfillmentPolicyId) { throw new Error(`eBay did not return an ID for fulfillment policy "${payload.name}"`); } await persistMarketplaceMapping(fulfillmentPolicyModel, policy, marketplace, { externalReference: String(fulfillmentPolicyId), stateType: 'ready', }); logger.info(`Synced eBay fulfillment policy "${payload.name}" (${fulfillmentPolicyId})`); return { fulfillmentPolicyId: String(fulfillmentPolicyId) }; } catch (err) { await persistMarketplaceMapping(fulfillmentPolicyModel, policy, marketplace, { stateType: 'failed', message: err.message, }); throw err; } } function collectRemoteShippingCodes(remote) { const codes = []; for (const option of remote?.shippingOptions || []) { for (const service of option.shippingServices || []) { if (service.shippingServiceCode) codes.push(String(service.shippingServiceCode)); } } return codes; } async function matchCourierServicesByShippingCodes(marketplace, codes) { if (!codes.length) return []; const services = await courierServiceModel .find({ 'marketplaces.marketplace': idOf(marketplace) }) .lean(); const matched = []; for (const code of codes) { const service = services.find((item) => (item.marketplaces || []).some( (entry) => idOf(entry.marketplace) === idOf(marketplace) && String(entry.externalReference) === String(code) ) ); if (service && !matched.some((item) => String(item._id) === String(service._id))) { matched.push(service); } } return matched.map((service) => service._id); } function mapRemoteFulfillmentPolicy(remote, courierServiceIds) { return { description: remote.description, handlingTime: Number(remote.handlingTime?.value) || 1, localPickup: Boolean(remote.localPickup), globalShipping: Boolean(remote.globalShipping), freightShipping: Boolean(remote.freightShipping), pickupDropOff: Boolean(remote.pickupDropOff), courierServices: courierServiceIds, }; } export async function syncFulfillmentPoliciesFromEbay(marketplace) { await ensureSellingPolicyManagement(marketplace); const remotes = await fetchFulfillmentPolicies(marketplace); const upserted = []; for (const remote of remotes) { const fulfillmentPolicyId = remote.fulfillmentPolicyId; if (!fulfillmentPolicyId || !remote.name) continue; const courierServiceIds = await matchCourierServicesByShippingCodes( marketplace, collectRemoteShippingCodes(remote) ); upserted.push( await upsertLocalPolicyFromRemote({ model: fulfillmentPolicyModel, marketplace, remoteId: fulfillmentPolicyId, name: remote.name, fields: mapRemoteFulfillmentPolicy(remote, courierServiceIds), }) ); } logger.info( `Imported ${upserted.length} eBay fulfillment polic${upserted.length === 1 ? 'y' : 'ies'}` ); return { count: upserted.length }; } export async function syncFulfillmentPolicy(marketplace, listing) { await ensureSellingPolicyManagement(marketplace); if (listing?.fulfillmentPolicyId) { return { fulfillmentPolicyId: String(listing.fulfillmentPolicyId) }; } const services = validateCourierServices( await resolveCourierServices(listing), listing, marketplace ); const policyName = `FarmControl ${listing._reference}`.slice(0, 64); const [existingPolicy, allPolicies] = await Promise.all([ fetchFulfillmentPolicyByName(marketplace, policyName), fetchFulfillmentPolicies(marketplace), ]); const policy = buildFulfillmentPolicy( listing, marketplace, services, existingPolicy, allPolicies ); if (existingPolicy?.fulfillmentPolicyId) { await updateFulfillmentPolicy(marketplace, existingPolicy.fulfillmentPolicyId, policy); logger.info( `Updated eBay fulfillment policy "${policy.name}" (${existingPolicy.fulfillmentPolicyId})` ); return { fulfillmentPolicyId: String(existingPolicy.fulfillmentPolicyId) }; } const matchingPolicy = findMatchingFulfillmentPolicy(policy, allPolicies); if (matchingPolicy?.fulfillmentPolicyId) { logger.info( `Reusing eBay fulfillment policy "${matchingPolicy.name}" (${matchingPolicy.fulfillmentPolicyId}) with matching shipping settings` ); return { fulfillmentPolicyId: String(matchingPolicy.fulfillmentPolicyId) }; } try { const result = await makeRequest({ marketplace, method: 'POST', path: '/sell/account/v1/fulfillment_policy', body: policy, }); if (!result?.fulfillmentPolicyId) { throw new Error(`eBay did not return an ID for fulfillment policy "${policy.name}"`); } logger.info(`Created eBay fulfillment policy "${policy.name}" (${result.fulfillmentPolicyId})`); return { fulfillmentPolicyId: String(result.fulfillmentPolicyId) }; } catch (err) { const duplicateId = duplicateFulfillmentPolicyId(err); if (duplicateId) { logger.info( `Reusing existing eBay fulfillment policy ${duplicateId} after duplicate-policy response` ); return { fulfillmentPolicyId: duplicateId }; } throw err; } }