diff --git a/src/database/schemas/sales/listing.schema.js b/src/database/schemas/sales/listing.schema.js index a110f27..7e6039b 100644 --- a/src/database/schemas/sales/listing.schema.js +++ b/src/database/schemas/sales/listing.schema.js @@ -42,6 +42,9 @@ const listingSchema = new Schema( price: { type: Number, required: false }, currency: { type: String, required: false }, lastSyncedAt: { type: Date, required: false }, + syncHash: { type: String, required: false }, + syncImageHash: { type: String, required: false }, + marketplaceImageUrls: [{ type: String, required: false }], stockQuantity: { type: Number, required: false, default: 0 }, condition: { type: String, diff --git a/src/database/schemas/sales/listingvarient.schema.js b/src/database/schemas/sales/listingvarient.schema.js index a8072fc..975ecba 100644 --- a/src/database/schemas/sales/listingvarient.schema.js +++ b/src/database/schemas/sales/listingvarient.schema.js @@ -47,6 +47,9 @@ const listingVarientSchema = new Schema( priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false }, priceWithTax: { type: Number, required: false }, lastSyncedAt: { type: Date, required: false }, + syncHash: { type: String, required: false }, + syncImageHash: { type: String, required: false }, + marketplaceImageUrls: [{ type: String, required: false }], listingImages: [{ type: Schema.Types.ObjectId, ref: 'file', required: false }], stockQuantity: { type: Number, required: false, default: 0 }, }, diff --git a/src/database/schemas/sales/marketplaceMapping.schema.js b/src/database/schemas/sales/marketplaceMapping.schema.js index 1326d4e..92043ff 100644 --- a/src/database/schemas/sales/marketplaceMapping.schema.js +++ b/src/database/schemas/sales/marketplaceMapping.schema.js @@ -9,6 +9,7 @@ export function marketplaceSyncMappingSchema() { { marketplace: { type: Schema.Types.ObjectId, ref: 'marketplace', required: true }, externalReference: { type: String, required: false }, + syncHash: { type: String, required: false }, state: { type: { type: String, diff --git a/src/integrations/__tests__/marketplaceworker.test.js b/src/integrations/__tests__/marketplaceworker.test.js new file mode 100644 index 0000000..f0ba12a --- /dev/null +++ b/src/integrations/__tests__/marketplaceworker.test.js @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { listingSyncHash } from '../marketplaces/syncFingerprint.js'; + +const editObject = jest.fn(async ({ updateData }) => updateData); +const updateItem = jest.fn(); +const listingFindById = jest.fn(); +const listingVarientFind = jest.fn(); + +jest.unstable_mockModule('../../config.js', () => ({ + default: { server: { logLevel: 'error' } }, +})); +jest.unstable_mockModule('log4js', () => ({ + default: { + getLogger: () => ({ + level: 'error', + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), + }, +})); +jest.unstable_mockModule('../../database/database.js', () => ({ + editObject, +})); +jest.unstable_mockModule('../../database/mongo.js', () => ({ dbConnect: jest.fn() })); +jest.unstable_mockModule('../../database/redis.js', () => ({ redisServer: {} })); +jest.unstable_mockModule('../../database/nats.js', () => ({ natsServer: {} })); +jest.unstable_mockModule('../../database/schemas/sales/listing.schema.js', () => ({ + listingModel: { findById: listingFindById }, +})); +jest.unstable_mockModule('../../database/schemas/sales/listingvarient.schema.js', () => ({ + listingVarientModel: { find: listingVarientFind }, +})); +jest.unstable_mockModule('../../database/schemas/sales/marketplace.schema.js', () => ({ + marketplaceModel: { findById: jest.fn() }, +})); +jest.unstable_mockModule('../../database/schemas/inventory/shipment.schema.js', () => ({ + shipmentModel: {}, +})); +jest.unstable_mockModule('../../database/schemas/finance/paymentpolicy.schema.js', () => ({ + paymentPolicyModel: {}, +})); +jest.unstable_mockModule('../../database/schemas/sales/returnpolicy.schema.js', () => ({ + returnPolicyModel: {}, +})); +jest.unstable_mockModule('../../database/schemas/sales/fulfillmentpolicy.schema.js', () => ({ + fulfillmentPolicyModel: {}, +})); +jest.unstable_mockModule('../../database/schemas/management/taxrate.schema.js', () => ({ + taxRateModel: {}, +})); +jest.unstable_mockModule('../marketplaces/ebay/accountPolicies.js', () => ({ + persistMarketplaceMapping: jest.fn(), +})); +jest.unstable_mockModule('../marketplaces/tiktokShop.js', () => ({})); +jest.unstable_mockModule('../marketplaces/ebay/index.js', () => ({ + updateItem, + createItem: jest.fn(), + ensureAuthenticatedMarketplace: jest.fn(async (marketplace) => marketplace), + publishOfferForSku: jest.fn(), + syncListingImages: jest.fn(), +})); +jest.unstable_mockModule('../marketplaceSync.js', () => ({ + marketplaceActor: (marketplace) => marketplace, + marketplaceSku: (record) => record?.externalReference || record?._reference, + upsertExternalOrder: jest.fn(), + importExternalItems: jest.fn(), + applyWebhookAction: jest.fn(), + pushShipmentFulfillment: jest.fn(), +})); + +const { updateListing } = await import('../marketplaceworker.js'); + +function leanDoc(value) { + return { + populate: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue(value), + }; +} + +describe('updateListing fingerprint skip', () => { + const marketplace = { _id: 'mp-1', name: 'eBay UK', provider: 'ebay', active: true }; + const listing = { + _id: 'lst-1', + _reference: 'LST-1', + title: 'Widget', + description: 'A widget', + condition: 'new', + state: { type: 'active' }, + marketplace, + }; + const varients = [{ _id: 'var-1', _reference: 'SKU-1', price: 9.99, stockQuantity: 2 }]; + + beforeEach(() => { + jest.clearAllMocks(); + listingFindById.mockReturnValue(leanDoc(listing)); + listingVarientFind.mockReturnValue(leanDoc(varients)); + }); + + it('skips provider.updateItem and does not set syncing when the hash matches', async () => { + listingFindById.mockReturnValue( + leanDoc({ ...listing, syncHash: listingSyncHash(listing, varients) }) + ); + + await expect(updateListing(marketplace, { _id: 'user-1' }, { _id: 'lst-1' })).resolves.toEqual({ + skipped: true, + }); + expect(updateItem).not.toHaveBeenCalled(); + expect(editObject).not.toHaveBeenCalled(); + }); + + it('calls provider.updateItem when the hash is missing or stale', async () => { + updateItem.mockResolvedValue({ url: 'https://www.ebay.co.uk/itm/1' }); + + await updateListing(marketplace, { _id: 'user-1' }, { _id: 'lst-1' }); + + expect(updateItem).toHaveBeenCalledTimes(1); + expect(editObject).toHaveBeenCalledWith( + expect.objectContaining({ + updateData: expect.objectContaining({ + state: { type: 'syncing' }, + }), + }) + ); + expect(editObject).toHaveBeenCalledWith( + expect.objectContaining({ + updateData: expect.objectContaining({ + state: { type: 'active' }, + syncHash: listingSyncHash(listing, varients), + }), + }) + ); + }); +}); diff --git a/src/integrations/marketplaces/__tests__/syncFingerprint.test.js b/src/integrations/marketplaces/__tests__/syncFingerprint.test.js new file mode 100644 index 0000000..0be89e9 --- /dev/null +++ b/src/integrations/marketplaces/__tests__/syncFingerprint.test.js @@ -0,0 +1,108 @@ +import { describe, expect, it } from '@jest/globals'; +import { + hashSyncPayload, + imageFilesHash, + isUnsyncedDraftListing, + listingImageSyncHash, + listingImagesUnchanged, + listingSyncFingerprint, + listingSyncHash, + listingSyncUnchanged, + payloadsEqual, +} from '../syncFingerprint.js'; + +const listing = { + title: 'Widget', + description: 'A widget', + condition: 'new', + stockLocation: { _id: 'loc-1' }, + courierServices: [{ _id: 'cs-2' }, { _id: 'cs-1' }], + fulfillmentPolicy: 'ful-1', + paymentPolicy: { _id: 'pay-1' }, + returnPolicy: { _id: 'ret-1' }, + listingImages: [{ _id: 'img-1' }], + updatedAt: new Date('2026-01-01'), + lastSyncedAt: new Date('2026-01-02'), + state: { type: 'active' }, +}; + +const variants = [ + { + _reference: 'SKU-RED', + price: 9.99, + currency: 'GBP', + stockQuantity: 3, + aspects: [ + { name: 'Size', value: 'L' }, + { name: 'Color', value: 'Red' }, + ], + listingImages: [], + }, +]; + +describe('syncFingerprint', () => { + it('hashes the same listing payload consistently and ignores timestamps and state', () => { + const first = listingSyncHash(listing, variants); + const second = listingSyncHash( + { ...listing, updatedAt: new Date(), lastSyncedAt: new Date(), state: { type: 'syncing' } }, + variants + ); + expect(first).toBe(second); + expect(listingSyncFingerprint(listing, variants).v).toBe(1); + }); + + it('changes the hash when price, images, or policy ids change', () => { + const baseline = listingSyncHash(listing, variants); + expect( + listingSyncHash(listing, [{ ...variants[0], price: 12.5 }]) + ).not.toBe(baseline); + expect( + listingSyncHash({ ...listing, listingImages: [{ _id: 'img-2' }] }, variants) + ).not.toBe(baseline); + expect( + listingSyncHash({ ...listing, paymentPolicy: 'pay-2' }, variants) + ).not.toBe(baseline); + }); + + it('treats a stored syncHash match as unchanged', () => { + const syncHash = listingSyncHash(listing, variants); + expect(listingSyncUnchanged({ ...listing, syncHash }, variants)).toBe(true); + expect(listingSyncUnchanged({ ...listing, syncHash: 'nope' }, variants)).toBe(false); + expect(listingSyncUnchanged(listing, variants)).toBe(false); + }); + + it('identifies unsynced draft listings', () => { + expect(isUnsyncedDraftListing({ state: { type: 'draft' } })).toBe(true); + expect( + isUnsyncedDraftListing({ state: { type: 'draft' }, externalReference: '123' }) + ).toBe(false); + expect(isUnsyncedDraftListing({ state: { type: 'active' } })).toBe(false); + }); + + it('hashes image file ids independently of listing copy', () => { + const imageHash = listingImageSyncHash(listing, variants); + expect( + listingImageSyncHash({ ...listing, title: 'Renamed' }, variants) + ).toBe(imageHash); + expect( + listingImageSyncHash({ ...listing, listingImages: [{ _id: 'img-2' }] }, variants) + ).not.toBe(imageHash); + expect( + listingImagesUnchanged( + { + ...listing, + syncImageHash: imageFilesHash(listing.listingImages), + marketplaceImageUrls: ['https://i.ebayimg.com/1.jpg'], + }, + variants + ) + ).toBe(true); + expect(imageFilesHash([{ _id: 'img-1' }])).toBe(imageFilesHash(['img-1'])); + }); + + it('compares payloads by canonical hash', () => { + expect(payloadsEqual({ b: 1, a: 2 }, { a: 2, b: 1 })).toBe(true); + expect(payloadsEqual({ a: 1 }, { a: 2 })).toBe(false); + expect(hashSyncPayload({ a: 1 })).toHaveLength(64); + }); +}); diff --git a/src/integrations/marketplaces/ebay/__tests__/accountPolicies.test.js b/src/integrations/marketplaces/ebay/__tests__/accountPolicies.test.js index eacfdd7..fa95abb 100644 --- a/src/integrations/marketplaces/ebay/__tests__/accountPolicies.test.js +++ b/src/integrations/marketplaces/ebay/__tests__/accountPolicies.test.js @@ -14,8 +14,16 @@ jest.unstable_mockModule('../../../../utils.js', () => ({ distributeUpdate: jest.fn().mockResolvedValue(undefined), })); -const { upsertLocalPolicyFromRemote, resolveListingPolicy, persistMarketplaceMapping, idOf, updateAccountPolicy } = - await import('../accountPolicies.js'); +const { + upsertLocalPolicyFromRemote, + resolveListingPolicy, + persistMarketplaceMapping, + idOf, + updateAccountPolicy, + shouldSkipMappedPolicySync, +} = await import('../accountPolicies.js'); +const { paymentPolicyFingerprint, ensurePaymentPolicySynced } = await import('../paymentPolicies.js'); +const { hashSyncPayload } = await import('../../syncFingerprint.js'); const { makeRequest } = await import('../shared.js'); const { buildPaymentPolicy } = await import('../paymentPolicies.js'); const { buildReturnPolicy } = await import('../returnPolicies.js'); @@ -238,6 +246,87 @@ describe('persistMarketplaceMapping', () => { expect(mappings[0].state.type).toBe('syncing'); expect(idOf(mappings[0].marketplace)).toBe('507f1f77bcf86cd799439011'); }); + + it('persists a syncHash when provided', async () => { + const existing = { + _id: 'ppl-1', + marketplaces: [{ _id: 'map-1', marketplace: 'mp1', externalReference: 'pay-1' }], + }; + const model = createModelMock({ existing }); + await persistMarketplaceMapping(model, existing, marketplace, { + stateType: 'ready', + syncHash: 'abc123', + }); + expect(model.updateOne.mock.calls[0][1].$set.marketplaces[0].syncHash).toBe('abc123'); + }); +}); + +describe('mapped policy sync skip', () => { + it('skips when the stored mapping hash matches the fingerprint', () => { + const fingerprint = paymentPolicyFingerprint( + { name: 'Immediate Pay', immediatePay: true }, + marketplace + ); + expect( + shouldSkipMappedPolicySync( + { + marketplaces: [ + { + marketplace: marketplace._id, + externalReference: 'pay-1', + syncHash: hashSyncPayload(fingerprint), + }, + ], + }, + marketplace, + fingerprint + ) + ).toBe(true); + }); + + it('does not skip when the fingerprint changes', () => { + const fingerprint = paymentPolicyFingerprint( + { name: 'Immediate Pay', immediatePay: true }, + marketplace + ); + expect( + shouldSkipMappedPolicySync( + { + marketplaces: [ + { + marketplace: marketplace._id, + externalReference: 'pay-1', + syncHash: hashSyncPayload(fingerprint), + }, + ], + }, + marketplace, + { ...fingerprint, name: 'Renamed' } + ) + ).toBe(false); + }); + + it('does not call eBay when ensurePaymentPolicySynced finds an unchanged mapping', async () => { + makeRequest.mockReset(); + const policy = { + name: 'Immediate Pay', + immediatePay: true, + marketplaces: [ + { + marketplace: marketplace._id, + externalReference: 'pay-1', + syncHash: hashSyncPayload( + paymentPolicyFingerprint({ name: 'Immediate Pay', immediatePay: true }, marketplace) + ), + }, + ], + }; + + await expect(ensurePaymentPolicySynced(marketplace, policy)).resolves.toEqual({ + paymentPolicyId: 'pay-1', + }); + expect(makeRequest).not.toHaveBeenCalled(); + }); }); describe('updateAccountPolicy', () => { diff --git a/src/integrations/marketplaces/ebay/__tests__/images.test.js b/src/integrations/marketplaces/ebay/__tests__/images.test.js index ff1efcb..5ff1d9b 100644 --- a/src/integrations/marketplaces/ebay/__tests__/images.test.js +++ b/src/integrations/marketplaces/ebay/__tests__/images.test.js @@ -185,4 +185,24 @@ describe('attachImageUrlsToListingAndVarients', () => { expect(result.varients[0].imageUrls).toEqual(['https://i.ebayimg.com/red.jpg']); expect(downloadFile).toHaveBeenCalledTimes(2); }); + + it('reuses stored marketplace image URLs when the image hash matches', async () => { + const { imageFilesHash } = await import('../../syncFingerprint.js'); + const files = [{ _id: 'file-1', extension: '.jpg', type: 'image/jpeg' }]; + const result = await attachImageUrlsToListingAndVarients( + marketplace, + { + _reference: 'LST-1', + listingImages: files, + syncImageHash: imageFilesHash(files), + marketplaceImageUrls: ['https://i.ebayimg.com/cached.jpg'], + }, + [{ _reference: 'SKU-RED' }] + ); + + expect(result.listing.imageUrls).toEqual(['https://i.ebayimg.com/cached.jpg']); + expect(result.varients[0].imageUrls).toEqual(['https://i.ebayimg.com/cached.jpg']); + expect(downloadFile).not.toHaveBeenCalled(); + expect(makeRequest).not.toHaveBeenCalled(); + }); }); diff --git a/src/integrations/marketplaces/ebay/__tests__/listingVarients.test.js b/src/integrations/marketplaces/ebay/__tests__/listingVarients.test.js index e00f6d9..bf9d1af 100644 --- a/src/integrations/marketplaces/ebay/__tests__/listingVarients.test.js +++ b/src/integrations/marketplaces/ebay/__tests__/listingVarients.test.js @@ -32,6 +32,8 @@ const { toEbayCondition, upsertInventoryItem, inventoryItemPutBody, + shouldSkipInventoryPut, + shouldSkipOfferPut, } = await import('../listingVarients.js'); describe('eBay varient quantity', () => { @@ -501,3 +503,64 @@ describe('publishOfferForSku', () => { ); }); }); + +describe('eBay unchanged payload skips', () => { + beforeEach(() => { + makeRequest.mockReset(); + }); + + it('skips inventory PUT when the constructed body matches the existing item', async () => { + const existing = { + sku: 'SKU-1', + locale: 'en_GB', + condition: 'NEW', + product: { + title: 'Widget', + description: 'A widget', + }, + availability: { + shipToLocationAvailability: { quantity: 4, allocationByFormat: { fixedPrice: 4 } }, + }, + }; + const next = inventoryItemPutBody( + existing, + { title: 'Widget', description: 'A widget', condition: 'new' }, + 'SKU-1', + { _reference: 'SKU-1' }, + 4 + ); + expect(shouldSkipInventoryPut(existing, next)).toBe(true); + + makeRequest.mockResolvedValueOnce(existing); + await upsertInventoryItem( + { config: { accessToken: 'token' } }, + { _reference: 'SKU-1', stockQuantity: 4 }, + { title: 'Widget', description: 'A widget', condition: 'new' } + ); + expect(makeRequest).toHaveBeenCalledTimes(1); + expect(makeRequest).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'PUT' })); + }); + + it('skips offer PUT when update fields match the existing offer', () => { + const existing = { + offerId: 'offer-1', + merchantLocationKey: 'fc-loc', + listingDescription: 'A widget', + availableQuantity: 4, + pricingSummary: { price: { value: '9.99', currency: 'GBP' } }, + categoryId: '123', + listingPolicies: { fulfillmentPolicyId: 'ful-1' }, + }; + const next = { + ...existing, + merchantLocationKey: 'fc-loc', + listingDescription: 'A widget', + availableQuantity: 4, + pricingSummary: { price: { value: '9.99', currency: 'GBP' } }, + categoryId: '123', + listingPolicies: { fulfillmentPolicyId: 'ful-1' }, + }; + expect(shouldSkipOfferPut(existing, next)).toBe(true); + expect(shouldSkipOfferPut(existing, { ...next, availableQuantity: 8 })).toBe(false); + }); +}); diff --git a/src/integrations/marketplaces/ebay/__tests__/listings.test.js b/src/integrations/marketplaces/ebay/__tests__/listings.test.js index ba714e7..13ac2e6 100644 --- a/src/integrations/marketplaces/ebay/__tests__/listings.test.js +++ b/src/integrations/marketplaces/ebay/__tests__/listings.test.js @@ -222,4 +222,31 @@ describe('syncListingImages', () => { expect(syncOfferAndMaybePublish).not.toHaveBeenCalled(); expect(result).toEqual({ listing: listingWithImages, varients: varientsWithImages }); }); + + it('skips image upload when the stored image hash is unchanged', async () => { + attachImageUrlsToListingAndVarients.mockClear(); + upsertInventoryItem.mockClear(); + const { imageFilesHash } = await import('../../syncFingerprint.js'); + const listingImages = [{ _id: 'file-1' }]; + const listing = { + _reference: 'LST-1', + title: 'Widget', + listingImages, + marketplaceImageUrls: ['https://i.ebayimg.com/cached.jpg'], + syncImageHash: imageFilesHash(listingImages), + }; + const varients = [{ _reference: 'SKU-RED' }]; + + const result = await syncListingImages({ name: 'eBay UK' }, listing, varients); + + expect(attachImageUrlsToListingAndVarients).not.toHaveBeenCalled(); + expect(upsertInventoryItem).not.toHaveBeenCalled(); + expect(result).toEqual( + expect.objectContaining({ + skipped: true, + listing, + varients, + }) + ); + }); }); diff --git a/src/integrations/marketplaces/ebay/accountPolicies.js b/src/integrations/marketplaces/ebay/accountPolicies.js index 7acd674..d580d9e 100644 --- a/src/integrations/marketplaces/ebay/accountPolicies.js +++ b/src/integrations/marketplaces/ebay/accountPolicies.js @@ -1,4 +1,5 @@ import { makeRequest, logger, getEbayMarketplaceId } from './shared.js'; +import { hashSyncPayload } from '../syncFingerprint.js'; const SELLING_POLICY_PROGRAM = 'SELLING_POLICY_MANAGEMENT'; export const POLICY_CATEGORY_TYPE = 'ALL_EXCLUDING_MOTORS_VEHICLES'; @@ -33,11 +34,23 @@ export function mappingExternalReference(doc, marketplace) { return getMarketplaceMapping(doc, marketplace)?.externalReference || ''; } +export function mappingSyncHash(doc, marketplace) { + return getMarketplaceMapping(doc, marketplace)?.syncHash || ''; +} + +export function shouldSkipMappedPolicySync(doc, marketplace, fingerprint) { + const mapping = getMarketplaceMapping(doc, marketplace); + if (!mapping?.externalReference || !mapping?.syncHash || fingerprint == null) { + return false; + } + return mapping.syncHash === hashSyncPayload(fingerprint); +} + export async function persistMarketplaceMapping( model, doc, marketplace, - { externalReference, stateType, message } = {} + { externalReference, stateType, message, syncHash } = {} ) { if (!model || !doc) return null; const marketplaceId = idOf(marketplace); @@ -62,6 +75,11 @@ export async function persistMarketplaceMapping( : {}), }, }; + if (syncHash !== undefined) { + next.syncHash = syncHash; + } else if (previous.syncHash) { + next.syncHash = previous.syncHash; + } if (index >= 0) mappings[index] = next; else mappings.push(next); diff --git a/src/integrations/marketplaces/ebay/fulfillmentPolicies.js b/src/integrations/marketplaces/ebay/fulfillmentPolicies.js index 10d9d9c..f5d2800 100644 --- a/src/integrations/marketplaces/ebay/fulfillmentPolicies.js +++ b/src/integrations/marketplaces/ebay/fulfillmentPolicies.js @@ -2,6 +2,7 @@ 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 { hashSyncPayload } from '../syncFingerprint.js'; import { POLICY_CATEGORY_TYPE, buildCategoryTypes as buildSharedCategoryTypes, @@ -11,6 +12,7 @@ import { isDefaultAccountPolicy, mappingExternalReference, persistMarketplaceMapping, + shouldSkipMappedPolicySync, updateAccountPolicy, upsertLocalPolicyFromRemote, } from './accountPolicies.js'; @@ -292,11 +294,44 @@ async function resolvePolicyCourierServices(policy) { return serviceIds.map((id) => servicesById.get(String(id))).filter(Boolean); } +export function fulfillmentPolicySyncFingerprint(policy, marketplace) { + const services = (policy?.courierServices || []).map((service) => { + if (!service || typeof service !== 'object') { + return { id: idOf(service) }; + } + return { + id: idOf(service), + code: getCourierServiceShippingCode(service, marketplace) || '', + cost: service.cost ?? null, + shippingCurrency: service.shippingCurrency || '', + }; + }); + return { + name: policy?.name || '', + description: policy?.description || '', + handlingTime: policy?.handlingTime ?? 1, + localPickup: policy?.localPickup === true, + globalShipping: policy?.globalShipping === true, + freightShipping: policy?.freightShipping === true, + pickupDropOff: policy?.pickupDropOff === true, + courierServices: services, + marketplaceId: getEbayMarketplaceId(marketplace), + }; +} + export async function ensureFulfillmentPolicySynced(marketplace, policy) { if (!policy) { throw new Error('A fulfillment policy is required before publishing to eBay.'); } + const fingerprint = fulfillmentPolicySyncFingerprint(policy, marketplace); + const syncHash = hashSyncPayload(fingerprint); + const existingId = mappingExternalReference(policy, marketplace); + if (shouldSkipMappedPolicySync(policy, marketplace, fingerprint)) { + logger.debug(`Fulfillment policy "${policy.name}" unchanged — skipping eBay sync`); + return { fulfillmentPolicyId: String(existingId) }; + } + await ensureSellingPolicyManagement(marketplace); await persistMarketplaceMapping(fulfillmentPolicyModel, policy, marketplace, { stateType: 'syncing', @@ -366,6 +401,7 @@ export async function ensureFulfillmentPolicySynced(marketplace, policy) { await persistMarketplaceMapping(fulfillmentPolicyModel, policy, marketplace, { externalReference: String(fulfillmentPolicyId), stateType: 'ready', + syncHash, }); logger.info(`Synced eBay fulfillment policy "${payload.name}" (${fulfillmentPolicyId})`); return { fulfillmentPolicyId: String(fulfillmentPolicyId) }; diff --git a/src/integrations/marketplaces/ebay/images.js b/src/integrations/marketplaces/ebay/images.js index 0672fb5..1502025 100644 --- a/src/integrations/marketplaces/ebay/images.js +++ b/src/integrations/marketplaces/ebay/images.js @@ -1,4 +1,5 @@ import { downloadFile, BUCKETS } from '../../../database/ceph.js'; +import { canReuseMarketplaceImageUrls, imageFilesHash } from '../syncFingerprint.js'; import { getMediaApiBaseUrl, makeRequest, logger } from './shared.js'; function fileId(file) { @@ -119,29 +120,56 @@ export async function filesToImageUrls(marketplace, files = []) { return urls; } -export async function resolveListingImageUrls(marketplace, listing, varient) { - const files = getListingImageFiles(listing, varient); +async function resolveImageUrlsForOwner(marketplace, owner, files) { + if (canReuseMarketplaceImageUrls(owner, files)) { + logger.debug( + `Reusing ${owner.marketplaceImageUrls.length} stored marketplace image URL(s) — skipping eBay Media upload` + ); + return owner.marketplaceImageUrls; + } if (files.length) { return filesToImageUrls(marketplace, files); } + if (owner?.imageUrls?.length) return owner.imageUrls; + return []; +} + +function withImageSyncFields(owner, files, urls) { + return { + ...owner, + ...(urls.length ? { imageUrls: urls } : {}), + syncImageHash: imageFilesHash(files), + marketplaceImageUrls: urls, + }; +} + +export async function resolveListingImageUrls(marketplace, listing, varient) { + const files = getListingImageFiles(listing, varient); + const owner = varient?.listingImages?.length ? varient : listing; + if (files.length) { + return resolveImageUrlsForOwner(marketplace, owner, files); + } if (varient?.imageUrls?.length) return varient.imageUrls; if (listing?.imageUrls?.length) return listing.imageUrls; return []; } export async function attachImageUrlsToListingAndVarients(marketplace, listing, varients = []) { - const listingImageUrls = await resolveListingImageUrls(marketplace, listing); - const listingWithUrls = listingImageUrls.length - ? { ...listing, imageUrls: listingImageUrls } - : listing; + const listingFiles = listing?.listingImages || []; + const listingImageUrls = await resolveImageUrlsForOwner(marketplace, listing, listingFiles); + const listingWithUrls = withImageSyncFields(listing, listingFiles, listingImageUrls); const varientsWithUrls = []; for (const varient of varients) { if (varient?.listingImages?.length) { - const urls = await filesToImageUrls(marketplace, varient.listingImages); - varientsWithUrls.push(urls.length ? { ...varient, imageUrls: urls } : varient); + const urls = await resolveImageUrlsForOwner(marketplace, varient, varient.listingImages); + varientsWithUrls.push(withImageSyncFields(varient, varient.listingImages, urls)); } else if (listingImageUrls.length) { - varientsWithUrls.push({ ...varient, imageUrls: listingImageUrls }); + varientsWithUrls.push({ + ...varient, + imageUrls: listingImageUrls, + marketplaceImageUrls: listingImageUrls, + }); } else { varientsWithUrls.push(varient); } diff --git a/src/integrations/marketplaces/ebay/listingVarients.js b/src/integrations/marketplaces/ebay/listingVarients.js index 12b95aa..5053677 100644 --- a/src/integrations/marketplaces/ebay/listingVarients.js +++ b/src/integrations/marketplaces/ebay/listingVarients.js @@ -6,6 +6,7 @@ import { syncListingPolicies } from './listingPolicies.js'; import { makeRequest, logger } from './shared.js'; import { getEbayItemUrl, parseEbayItemId } from './itemUrl.js'; import { marketplaceSku } from '../ids.js'; +import { payloadsEqual } from '../syncFingerprint.js'; import { fromEbayProductAspects, toEbayProductAspects } from './variationAspects.js'; import { toEbayHtmlDescription, toEbayPlainDescription } from './description.js'; @@ -335,6 +336,59 @@ export function resolveVarientQuantity(varient) { return Math.max(0, Number(varient?.stockQuantity) || 0); } +export function comparableInventoryItem(item) { + if (!item) return null; + const shipTo = item.availability?.shipToLocationAvailability || {}; + const availability = { + shipToLocationAvailability: { + quantity: shipTo.quantity, + }, + }; + if (Array.isArray(shipTo.availabilityDistributions) && shipTo.availabilityDistributions.length) { + availability.shipToLocationAvailability.availabilityDistributions = + shipTo.availabilityDistributions; + } + if (item.availability?.pickupAtLocationAvailability?.length) { + availability.pickupAtLocationAvailability = item.availability.pickupAtLocationAvailability; + } + return { + product: item.product || {}, + availability, + condition: item.condition, + ...(item.conditionDescription ? { conditionDescription: item.conditionDescription } : {}), + ...(item.conditionDescriptors?.length + ? { conditionDescriptors: item.conditionDescriptors } + : {}), + ...(item.packageWeightAndSize ? { packageWeightAndSize: item.packageWeightAndSize } : {}), + }; +} + +export function shouldSkipInventoryPut(existing, next) { + return Boolean( + existing && next && payloadsEqual(comparableInventoryItem(existing), comparableInventoryItem(next)) + ); +} + +export function comparableOffer(offer) { + if (!offer) return null; + return { + merchantLocationKey: offer.merchantLocationKey || null, + listingDescription: offer.listingDescription || '', + availableQuantity: offer.availableQuantity, + pricingSummary: offer.pricingSummary || null, + categoryId: offer.categoryId != null ? String(offer.categoryId) : null, + listingPolicies: offer.listingPolicies || null, + }; +} + +export function shouldSkipOfferPut(existingOffer, nextBody) { + return Boolean( + existingOffer && + nextBody && + payloadsEqual(comparableOffer(existingOffer), comparableOffer(nextBody)) + ); +} + export async function upsertInventoryItem(marketplace, varient, listing) { const sku = marketplaceSku(varient); if (!sku) throw new Error('SKU is required to upsert an eBay inventory item'); @@ -347,6 +401,10 @@ export async function upsertInventoryItem(marketplace, varient, listing) { const inventoryItem = existing ? inventoryItemPutBody(existing, listing, sku, varient, quantity) : mapVarientToInventoryItem(varient, listing, quantity); + if (shouldSkipInventoryPut(existing, inventoryItem)) { + logger.debug(`Inventory item ${sku} unchanged — skipping eBay PUT`); + return existing; + } const result = await makeRequest({ marketplace, method: 'PUT', @@ -355,6 +413,7 @@ export async function upsertInventoryItem(marketplace, varient, listing) { }); logger.debug('inventoryItem', inventoryItem); logger.debug('result', result); + return result; } export function resolveOfferListingId(offer) { @@ -444,6 +503,12 @@ async function upsertOrCreateOffer(marketplace, varient, listing) { }; } const body = { ...existingOffer, ...offerUpdate }; + if (shouldSkipOfferPut(existingOffer, body)) { + logger.debug( + `Offer ${existingOffer.offerId} unchanged — skipping eBay PUT` + ); + return existingOffer; + } await makeRequest({ marketplace, method: 'PUT', diff --git a/src/integrations/marketplaces/ebay/listings.js b/src/integrations/marketplaces/ebay/listings.js index 7866ecd..32e3c24 100644 --- a/src/integrations/marketplaces/ebay/listings.js +++ b/src/integrations/marketplaces/ebay/listings.js @@ -16,6 +16,11 @@ 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)); @@ -51,9 +56,26 @@ export function buildInventoryItemGroupBody(listing, varients) { 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, @@ -140,6 +162,13 @@ async function syncListing(marketplace, listing, varients, actionLabel) { ...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}"` @@ -149,20 +178,37 @@ async function syncListing(marketplace, listing, varients, actionLabel) { listingWithContext, varientsWithImages[0] ); - return listingSyncResult(published, marketplace); + return listingSyncResult(published, marketplace, imageState); } const published = await syncGroupedListing(marketplace, listingWithContext, varientsWithImages); - return listingSyncResult(published, marketplace); + return listingSyncResult(published, marketplace, imageState); } -function listingSyncResult(published, marketplace) { - if (!published) return { url: '' }; - if (typeof published === 'string') return { url: published }; +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, + })), }; } @@ -178,6 +224,13 @@ 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); diff --git a/src/integrations/marketplaces/ebay/paymentPolicies.js b/src/integrations/marketplaces/ebay/paymentPolicies.js index 46bdf3e..c096e0e 100644 --- a/src/integrations/marketplaces/ebay/paymentPolicies.js +++ b/src/integrations/marketplaces/ebay/paymentPolicies.js @@ -1,4 +1,5 @@ import { paymentPolicyModel } from '../../../database/schemas/finance/paymentpolicy.schema.js'; +import { hashSyncPayload } from '../syncFingerprint.js'; import { makeRequest, logger } from './shared.js'; import { buildCategoryTypes, @@ -6,10 +7,23 @@ import { getEbayMarketplaceId, mappingExternalReference, persistMarketplaceMapping, + shouldSkipMappedPolicySync, updateAccountPolicy, upsertLocalPolicyFromRemote, } from './accountPolicies.js'; +export function paymentPolicyFingerprint(policy, marketplace) { + return { + name: String(policy?.name || '').slice(0, 64), + description: policy?.description ? String(policy.description).slice(0, 250) : '', + paymentInstructions: policy?.paymentInstructions + ? String(policy.paymentInstructions).slice(0, 1000) + : '', + immediatePay: policy?.immediatePay !== false, + marketplaceId: getEbayMarketplaceId(marketplace), + }; +} + export function buildPaymentPolicy(policy, marketplace, existingPolicy, allPolicies = []) { const payload = { name: String(policy?.name || '').slice(0, 64), @@ -51,6 +65,14 @@ export async function ensurePaymentPolicySynced(marketplace, policy) { throw new Error('A payment policy is required before publishing to eBay.'); } + const fingerprint = paymentPolicyFingerprint(policy, marketplace); + const syncHash = hashSyncPayload(fingerprint); + const existingId = mappingExternalReference(policy, marketplace); + if (shouldSkipMappedPolicySync(policy, marketplace, fingerprint)) { + logger.debug(`Payment policy "${policy.name}" unchanged — skipping eBay sync`); + return { paymentPolicyId: String(existingId) }; + } + await ensureSellingPolicyManagement(marketplace); await persistMarketplaceMapping(paymentPolicyModel, policy, marketplace, { stateType: 'syncing', @@ -92,6 +114,7 @@ export async function ensurePaymentPolicySynced(marketplace, policy) { await persistMarketplaceMapping(paymentPolicyModel, policy, marketplace, { externalReference: String(paymentPolicyId), stateType: 'ready', + syncHash, }); logger.info(`Synced eBay payment policy "${payload.name}" (${paymentPolicyId})`); return { paymentPolicyId: String(paymentPolicyId) }; diff --git a/src/integrations/marketplaces/ebay/returnPolicies.js b/src/integrations/marketplaces/ebay/returnPolicies.js index dd1085b..1dacac2 100644 --- a/src/integrations/marketplaces/ebay/returnPolicies.js +++ b/src/integrations/marketplaces/ebay/returnPolicies.js @@ -1,4 +1,5 @@ import { returnPolicyModel } from '../../../database/schemas/sales/returnpolicy.schema.js'; +import { hashSyncPayload } from '../syncFingerprint.js'; import { makeRequest, logger } from './shared.js'; import { buildCategoryTypes, @@ -6,6 +7,7 @@ import { getEbayMarketplaceId, mappingExternalReference, persistMarketplaceMapping, + shouldSkipMappedPolicySync, updateAccountPolicy, upsertLocalPolicyFromRemote, } from './accountPolicies.js'; @@ -16,6 +18,22 @@ const REFUND_TO_EBAY = { moneyBack: 'MONEY_BACK', merchandiseCredit: 'MERCHANDIS const EBAY_TO_REFUND = { MONEY_BACK: 'moneyBack', MERCHANDISE_CREDIT: 'merchandiseCredit' }; const EBAY_RETURN_PERIOD_DAYS = [14, 30, 60]; +export function returnPolicyFingerprint(policy, marketplace) { + return { + name: String(policy?.name || '').slice(0, 64), + description: policy?.description ? String(policy.description).slice(0, 250) : '', + returnsAccepted: policy?.returnsAccepted !== false, + returnPeriodDays: policy?.returnPeriodDays ?? 30, + returnShippingCostPayer: policy?.returnShippingCostPayer || 'buyer', + refundMethod: policy?.refundMethod || 'moneyBack', + returnInstructions: policy?.returnInstructions || '', + internationalReturnsAccepted: policy?.internationalReturnsAccepted ?? null, + internationalReturnPeriodDays: policy?.internationalReturnPeriodDays ?? null, + internationalReturnShippingCostPayer: policy?.internationalReturnShippingCostPayer || '', + marketplaceId: getEbayMarketplaceId(marketplace), + }; +} + export function snapReturnPeriodDays(days, fallback = 30) { const value = Number(days); if (!Number.isFinite(value) || value <= 0) return fallback; @@ -96,6 +114,14 @@ export async function ensureReturnPolicySynced(marketplace, policy) { throw new Error('A return policy is required before publishing to eBay.'); } + const fingerprint = returnPolicyFingerprint(policy, marketplace); + const syncHash = hashSyncPayload(fingerprint); + const existingId = mappingExternalReference(policy, marketplace); + if (shouldSkipMappedPolicySync(policy, marketplace, fingerprint)) { + logger.debug(`Return policy "${policy.name}" unchanged — skipping eBay sync`); + return { returnPolicyId: String(existingId) }; + } + await ensureSellingPolicyManagement(marketplace); await persistMarketplaceMapping(returnPolicyModel, policy, marketplace, { stateType: 'syncing', @@ -137,6 +163,7 @@ export async function ensureReturnPolicySynced(marketplace, policy) { await persistMarketplaceMapping(returnPolicyModel, policy, marketplace, { externalReference: String(returnPolicyId), stateType: 'ready', + syncHash, }); logger.info(`Synced eBay return policy "${payload.name}" (${returnPolicyId})`); return { returnPolicyId: String(returnPolicyId) }; diff --git a/src/integrations/marketplaces/ebay/salesTax.js b/src/integrations/marketplaces/ebay/salesTax.js index fa4b8ce..2ed79c0 100644 --- a/src/integrations/marketplaces/ebay/salesTax.js +++ b/src/integrations/marketplaces/ebay/salesTax.js @@ -1,8 +1,10 @@ import { taxRateModel } from '../../../database/schemas/management/taxrate.schema.js'; +import { hashSyncPayload } from '../syncFingerprint.js'; import { makeRequest, logger } from './shared.js'; import { idOf, persistMarketplaceMapping, + shouldSkipMappedPolicySync, upsertLocalPolicyFromRemote, } from './accountPolicies.js'; @@ -56,12 +58,21 @@ export async function ensureTaxRateSynced(marketplace, taxRate) { return { skipped: true }; } + const entry = buildSalesTaxEntry(taxRate); + const fingerprint = entry; + const syncHash = hashSyncPayload(fingerprint); + if (shouldSkipMappedPolicySync(taxRate, marketplace, fingerprint)) { + logger.debug( + `Sales tax ${taxExternalReference(entry.countryCode, entry.jurisdictionId)} unchanged — skipping eBay sync` + ); + return { externalReference: taxExternalReference(entry.countryCode, entry.jurisdictionId) }; + } + await persistMarketplaceMapping(taxRateModel, taxRate, marketplace, { stateType: 'syncing', }); try { - const entry = buildSalesTaxEntry(taxRate); await makeRequest({ marketplace, method: 'PUT', @@ -75,6 +86,7 @@ export async function ensureTaxRateSynced(marketplace, taxRate) { await persistMarketplaceMapping(taxRateModel, taxRate, marketplace, { externalReference, stateType: 'ready', + syncHash, }); logger.info( `Synced eBay sales tax ${externalReference} for tax rate "${taxRate.name || taxRate._reference}"` diff --git a/src/integrations/marketplaces/syncFingerprint.js b/src/integrations/marketplaces/syncFingerprint.js new file mode 100644 index 0000000..2b08e4f --- /dev/null +++ b/src/integrations/marketplaces/syncFingerprint.js @@ -0,0 +1,141 @@ +import crypto from 'crypto'; +import canonicalize from 'canonical-json'; + +export const LISTING_SYNC_HASH_VERSION = 1; + +function refId(value) { + if (value == null || value === '') return null; + if (typeof value === 'object') { + if (value._id != null) return String(value._id); + if (value.id != null && value.id !== value) return String(value.id); + if (typeof value.toHexString === 'function') return value.toHexString(); + if (value._bsontype === 'ObjectId') return String(value); + } + return String(value); +} + +function fileIds(files) { + if (!Array.isArray(files)) return []; + return files.map((file) => refId(file)).filter(Boolean); +} + +function toPlain(value) { + if (value === undefined) return undefined; + if (value === null) return null; + if (typeof value === 'object' && typeof value.toHexString === 'function') { + return value.toHexString(); + } + if (typeof value === 'object' && value._bsontype === 'ObjectId') { + return String(value); + } + if (value instanceof Date) return value.toISOString(); + if (Array.isArray(value)) return value.map((item) => toPlain(item)); + if (typeof value === 'object') { + const out = {}; + for (const key of Object.keys(value).sort()) { + const next = toPlain(value[key]); + if (next !== undefined) out[key] = next; + } + return out; + } + return value; +} + +export function hashSyncPayload(payload) { + const normalized = canonicalize(toPlain(payload)); + return crypto.createHash('sha256').update(normalized).digest('hex'); +} + +export function payloadsEqual(left, right) { + return hashSyncPayload(left) === hashSyncPayload(right); +} + +export function listingSyncFingerprint(listing, variants = []) { + return { + v: LISTING_SYNC_HASH_VERSION, + title: listing?.title || '', + description: listing?.description || '', + condition: listing?.condition || '', + stockLocation: refId(listing?.stockLocation), + courierServices: (listing?.courierServices || []).map(refId).filter(Boolean).sort(), + fulfillmentPolicy: refId(listing?.fulfillmentPolicy), + paymentPolicy: refId(listing?.paymentPolicy), + returnPolicy: refId(listing?.returnPolicy), + listingImages: fileIds(listing?.listingImages), + variants: [...(variants || [])] + .map((variant) => ({ + sku: variant?.externalReference || variant?._reference || '', + price: variant?.price ?? listing?.price ?? null, + currency: variant?.currency || listing?.currency || '', + aspects: [...(variant?.aspects || [])] + .map((aspect) => ({ name: aspect?.name || '', value: aspect?.value || '' })) + .sort( + (left, right) => + left.name.localeCompare(right.name) || left.value.localeCompare(right.value) + ), + listingImages: fileIds(variant?.listingImages), + stockQuantity: Number(variant?.stockQuantity) || 0, + })) + .sort((left, right) => left.sku.localeCompare(right.sku)), + }; +} + +export function listingSyncHash(listing, variants = []) { + return hashSyncPayload(listingSyncFingerprint(listing, variants)); +} + +export function listingSyncUnchanged(listing, variants = []) { + return Boolean(listing?.syncHash && listing.syncHash === listingSyncHash(listing, variants)); +} + +export function isUnsyncedDraftListing(listing) { + return listing?.state?.type === 'draft' && !listing?.externalReference; +} + +export function imageFilesHash(files) { + return hashSyncPayload(fileIds(files)); +} + +export function listingImageSyncHash(listing, variants = []) { + return hashSyncPayload({ + listingImages: fileIds(listing?.listingImages), + variants: [...(variants || [])] + .map((variant) => ({ + sku: variant?.externalReference || variant?._reference || '', + listingImages: fileIds(variant?.listingImages), + })) + .sort((left, right) => left.sku.localeCompare(right.sku)), + }); +} + +export function listingImagesUnchanged(listing, variants = []) { + const listingFiles = listing?.listingImages || []; + if (listingFiles.length && !canReuseMarketplaceImageUrls(listing, listingFiles)) { + return false; + } + for (const variant of variants || []) { + if (variant?.listingImages?.length && !canReuseMarketplaceImageUrls(variant, variant.listingImages)) { + return false; + } + } + if (listingFiles.length) { + return canReuseMarketplaceImageUrls(listing, listingFiles); + } + const variantWithImages = (variants || []).find((variant) => variant?.listingImages?.length); + if (variantWithImages) { + return canReuseMarketplaceImageUrls(variantWithImages, variantWithImages.listingImages); + } + return Boolean(listing?.syncImageHash || listing?.marketplaceImageUrls?.length); +} + +export function canReuseMarketplaceImageUrls(owner, files) { + const urls = owner?.marketplaceImageUrls; + return Boolean( + Array.isArray(files) && + files.length && + owner?.syncImageHash && + owner.syncImageHash === imageFilesHash(files) && + Array.isArray(urls) && + urls.length + ); +} diff --git a/src/integrations/marketplaceworker.js b/src/integrations/marketplaceworker.js index 7ee8b72..74f5e00 100644 --- a/src/integrations/marketplaceworker.js +++ b/src/integrations/marketplaceworker.js @@ -23,6 +23,13 @@ import { applyWebhookAction, pushShipmentFulfillment, } from './marketplaceSync.js'; +import { + imageFilesHash, + isUnsyncedDraftListing, + listingImagesUnchanged, + listingSyncHash, + listingSyncUnchanged, +} from './marketplaces/syncFingerprint.js'; const logger = log4js.getLogger('Marketplace Worker'); logger.level = config.server.logLevel; @@ -68,6 +75,16 @@ export async function ensureMarketplaceListingInventory(marketplace, user, listi if (!fullListing) throw new Error('Listing not found'); const listingVarients = varients.length > 0 ? varients : listing?._id ? await fetchListingVarients(listing._id) : []; + if (listingSyncUnchanged(fullListing, listingVarients)) { + logger.info( + `Listing "${fullListing._reference}" inventory unchanged — skipping eBay inventory sync` + ); + return { + skipped: true, + url: fullListing.url, + externalReference: fullListing.externalReference, + }; + } return provider.updateItem(authenticatedMarketplace, fullListing, listingVarients); } @@ -81,6 +98,12 @@ export async function syncMarketplaceListingImages(marketplace, user, listing, v if (!fullListing) throw new Error('Listing not found'); const listingVarients = varients.length > 0 ? varients : listing?._id ? await fetchListingVarients(listing._id) : []; + if (listingImagesUnchanged(fullListing, listingVarients)) { + logger.debug( + `Listing "${fullListing._reference}" images unchanged — skipping eBay image sync` + ); + return { skipped: true, listing: fullListing, varients: listingVarients }; + } return provider.syncListingImages(authenticatedMarketplace, fullListing, listingVarients); } @@ -486,6 +509,76 @@ async function fetchListingVarients(listingId) { return listingVarientModel.find({ listing: listingId }).populate('listingImages').lean(); } +function listingStateAfterSync(previousStateType) { + if (!previousStateType || previousStateType === 'syncing') return 'active'; + if (previousStateType === 'draft') return 'draft'; + return previousStateType; +} + +function listingSyncUpdateData(listing, varients, result, stateType) { + const updateData = { + lastSyncedAt: new Date(), + syncHash: listingSyncHash(listing, varients), + syncImageHash: imageFilesHash(listing.listingImages), + }; + if (stateType) updateData.state = { type: stateType }; + if (result?.url) updateData.url = result.url; + if (result?.externalReference) updateData.externalReference = result.externalReference; + const imageUrls = result?.marketplaceImageUrls?.length + ? result.marketplaceImageUrls + : listing.marketplaceImageUrls; + if (imageUrls?.length) updateData.marketplaceImageUrls = imageUrls; + return updateData; +} + +function varientSyncUpdateData(varient, result, stateType) { + const fromResult = result?.varients?.find((item) => String(item._id) === String(varient._id)); + const updateData = { + lastSyncedAt: new Date(), + syncHash: listingSyncHash({ title: varient._reference }, [varient]), + syncImageHash: imageFilesHash(varient.listingImages), + }; + if (stateType) updateData.state = { type: stateType }; + if (!varient.externalReference && marketplaceSku(varient)) { + updateData.externalReference = marketplaceSku(varient); + } + const imageUrls = fromResult?.marketplaceImageUrls?.length + ? fromResult.marketplaceImageUrls + : varient.marketplaceImageUrls; + if (imageUrls?.length) updateData.marketplaceImageUrls = imageUrls; + return updateData; +} + +async function persistListingSyncMetadata({ + listingId, + listing, + varients, + result, + user, + stateType, + applyVarientState = true, +}) { + await editObject({ + model: listingModel, + id: listingId, + updateData: listingSyncUpdateData(listing, varients, result, stateType), + user, + }); + + for (const varient of varients) { + await editObject({ + model: listingVarientModel, + id: varient._id, + updateData: varientSyncUpdateData( + varient, + result, + applyVarientState ? stateType : undefined + ), + user, + }).catch(() => {}); + } +} + export async function createListing(marketplace, user, listingData) { const provider = getProvider(marketplace); if (!provider.createItem) { @@ -493,6 +586,11 @@ export async function createListing(marketplace, user, listingData) { return; } + const fullListing = listingData._id ? await fetchFullListing(listingData._id) : listingData; + if (!fullListing) throw new Error('Listing not found'); + const varients = listingData._id ? await fetchListingVarients(listingData._id) : []; + const previousState = fullListing.state?.type || 'draft'; + if (listingData._id) { await setListingState(listingData._id, 'syncing', user).catch((err) => logger.warn(`Failed to set listing syncing state: ${err.message}`) @@ -501,32 +599,19 @@ export async function createListing(marketplace, user, listingData) { try { const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user); - const fullListing = listingData._id ? await fetchFullListing(listingData._id) : listingData; - if (!fullListing) throw new Error('Listing not found'); - - const varients = listingData._id ? await fetchListingVarients(listingData._id) : []; logger.info(`Creating listing on marketplace "${marketplace.name}" (${marketplace.provider})`); const result = await provider.createItem(authenticatedMarketplace, fullListing, varients); if (listingData._id) { - const updateData = { lastSyncedAt: new Date(), state: { type: 'active' } }; - if (result?.url) updateData.url = result.url; - if (result?.externalReference) updateData.externalReference = result.externalReference; - await editObject({ model: listingModel, id: listingData._id, updateData, user }); - - for (const varient of varients) { - const varientUpdate = { lastSyncedAt: new Date(), state: { type: 'active' } }; - if (!varient.externalReference && marketplaceSku(varient)) { - varientUpdate.externalReference = marketplaceSku(varient); - } - await editObject({ - model: listingVarientModel, - id: varient._id, - updateData: varientUpdate, - user, - }).catch(() => {}); - } + await persistListingSyncMetadata({ + listingId: listingData._id, + listing: fullListing, + varients, + result, + user, + stateType: previousState === 'active' ? 'active' : 'draft', + }); } logger.info(`Background createListing complete for marketplace "${marketplace.name}"`); @@ -548,6 +633,18 @@ export async function updateListing(marketplace, user, listingData) { return; } + const fullListing = listingData._id ? await fetchFullListing(listingData._id) : listingData; + if (!fullListing) throw new Error('Listing not found'); + const varients = listingData._id ? await fetchListingVarients(listingData._id) : []; + const previousState = fullListing.state?.type || 'active'; + + if (listingSyncUnchanged(fullListing, varients)) { + logger.info( + `Listing "${fullListing._reference}" unchanged — skipping marketplace update` + ); + return { skipped: true }; + } + if (listingData._id) { await setListingState(listingData._id, 'syncing', user).catch((err) => logger.warn(`Failed to set listing syncing state: ${err.message}`) @@ -556,37 +653,19 @@ export async function updateListing(marketplace, user, listingData) { try { const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user); - const fullListing = listingData._id ? await fetchFullListing(listingData._id) : listingData; - if (!fullListing) throw new Error('Listing not found'); - - const varients = listingData._id ? await fetchListingVarients(listingData._id) : []; logger.info(`Updating listing on marketplace "${marketplace.name}" (${marketplace.provider})`); const result = await provider.updateItem(authenticatedMarketplace, fullListing, varients); if (listingData._id) { - const updateData = { state: { type: 'active' }, lastSyncedAt: new Date() }; - if (result?.url) updateData.url = result.url; - if (result?.externalReference) updateData.externalReference = result.externalReference; - await editObject({ - model: listingModel, - id: listingData._id, - updateData, + await persistListingSyncMetadata({ + listingId: listingData._id, + listing: fullListing, + varients, + result, user, + stateType: listingStateAfterSync(previousState), }); - - for (const varient of varients) { - const varientUpdate = { lastSyncedAt: new Date(), state: { type: 'active' } }; - if (!varient.externalReference && marketplaceSku(varient)) { - varientUpdate.externalReference = marketplaceSku(varient); - } - await editObject({ - model: listingVarientModel, - id: varient._id, - updateData: varientUpdate, - user, - }).catch(() => {}); - } } logger.info(`Background updateListing complete for marketplace "${marketplace.name}"`); @@ -595,7 +674,12 @@ export async function updateListing(marketplace, user, listingData) { `Background updateListing failed for marketplace "${marketplace.name}": ${err.message}` ); if (listingData._id) { - await setListingState(listingData._id, 'active', user, err.message).catch(() => {}); + await setListingState( + listingData._id, + listingStateAfterSync(previousState), + user, + err.message + ).catch(() => {}); } throw err; } @@ -663,18 +747,12 @@ export async function syncItems(marketplace, user) { 'state.type': { $ne: 'deleted' }, }) .lean(); - for (const listing of existingListings) { - await setListingState(listing._id, 'syncing', user).catch(() => {}); - } const existingVarients = await listingVarientModel .find({ listing: { $in: existingListings.map((l) => l._id) }, }) .lean(); - for (const varient of existingVarients) { - await setListingVarientState(varient._id, 'syncing', user).catch(() => {}); - } const results = []; @@ -684,49 +762,54 @@ export async function syncItems(marketplace, user) { (varient) => String(varient.listing) === String(listing._id) ); + if (isUnsyncedDraftListing(listing)) { + results.push({ + _reference: listing._reference, + action: 'skipped', + reason: 'draft', + id: listing._id, + }); + continue; + } + + if (listingSyncUnchanged(listing, listingVarients)) { + results.push({ + _reference: listing._reference, + action: 'skipped', + reason: 'unchanged', + id: listing._id, + }); + continue; + } + if (!listingVarients.length) { throw new Error('Listing has no varients to sync'); } + await setListingState(listing._id, 'syncing', user).catch(() => {}); + for (const varient of listingVarients) { + await setListingVarientState(varient._id, 'syncing', user).catch(() => {}); + } + const result = await provider.updateItem( authenticatedMarketplace, listing, listingVarients ); - const listingUpdateData = { - lastSyncedAt: new Date(), - state: listing.state || { type: 'draft' }, - }; - if (result?.url) { - listingUpdateData.url = result.url; - } - if (result?.externalReference) { - listingUpdateData.externalReference = result.externalReference; - } - - await editObject({ - model: listingModel, - id: listing._id, - updateData: listingUpdateData, + await persistListingSyncMetadata({ + listingId: listing._id, + listing, + varients: listingVarients, + result: { + ...result, + url: result?.url || listing.url, + externalReference: result?.externalReference || listing.externalReference, + }, user, + stateType: listing.state?.type || 'draft', }); - for (const varient of listingVarients) { - await editObject({ - model: listingVarientModel, - id: varient._id, - updateData: { - lastSyncedAt: new Date(), - state: varient.state || { type: 'draft' }, - ...(!varient.externalReference && marketplaceSku(varient) - ? { externalReference: marketplaceSku(varient) } - : {}), - }, - user, - }).catch(() => {}); - } - results.push({ _reference: listing._reference, action: 'synced', id: listing._id }); } catch (err) { logger.warn(`Failed to sync listing ${listing._reference}: ${err.message}`); @@ -912,18 +995,17 @@ export async function publishListingOffers({ }); } - const listingUpdate = { - updatedAt: new Date(), - state: { type: 'active' }, - lastSyncedAt: new Date(), - }; - if (publishedListingId) listingUpdate.externalReference = publishedListingId; - if (publishedUrl) listingUpdate.url = publishedUrl; - await editObject({ - model: listingModel, - id: listingId, - updateData: listingUpdate, + await persistListingSyncMetadata({ + listingId, + listing, + varients: allVarients, + result: { + url: publishedUrl, + externalReference: publishedListingId, + }, user, + stateType: 'active', + applyVarientState: false, }); logger.info(`Background publishListing complete for listing ${listingId}`); } catch (err) { diff --git a/src/services/sales/__tests__/listings.test.js b/src/services/sales/__tests__/listings.test.js index d36f2c9..e568635 100644 --- a/src/services/sales/__tests__/listings.test.js +++ b/src/services/sales/__tests__/listings.test.js @@ -61,8 +61,10 @@ jest.unstable_mockModule('log4js', () => ({ }, })); -const { publishListingRouteHandler, unpublishListingRouteHandler } = await import('../listings.js'); -const { checkStates, editObject } = await import('../../../database/database.js'); +const { publishListingRouteHandler, unpublishListingRouteHandler, newListingRouteHandler } = + await import('../listings.js'); +const { checkStates, editObject, newObject } = await import('../../../database/database.js'); +const { createListing } = await import('../../../integrations/marketplace.js'); describe('listing marketplace publish/unpublish', () => { let req; @@ -175,3 +177,41 @@ describe('listing marketplace publish/unpublish', () => { ); }); }); + +describe('new listing create', () => { + it('creates a draft listing without syncing to the marketplace', async () => { + const listingId = '507f1f77bcf86cd799439011'; + const marketplaceId = '507f1f77bcf86cd799439013'; + newObject.mockResolvedValue({ + _id: listingId, + marketplace: marketplaceId, + state: { type: 'draft' }, + title: 'Draft listing', + }); + const req = { + body: { + title: 'Draft listing', + marketplace: marketplaceId, + vendor: 'vendor-1', + stockLocation: 'loc-1', + courierServices: ['cs-1'], + }, + user: { _id: 'user-1' }, + }; + const res = { + send: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + + await newListingRouteHandler(req, res); + + expect(newObject).toHaveBeenCalled(); + expect(createListing).not.toHaveBeenCalled(); + expect(res.send).toHaveBeenCalledWith( + expect.objectContaining({ + _id: listingId, + state: { type: 'draft' }, + }) + ); + }); +}); diff --git a/src/services/sales/listings.js b/src/services/sales/listings.js index 53de105..ed62471 100644 --- a/src/services/sales/listings.js +++ b/src/services/sales/listings.js @@ -261,11 +261,6 @@ export const newListingRouteHandler = async (req, res) => { return res.status(result.code).send(result); } - const newMarketplaceId = result.marketplace?._id || result.marketplace; - if (newMarketplaceId) { - pushToMarketplace(newMarketplaceId, { _id: result._id }, req.user, { isNew: true }); - } - logger.debug(`New listing with ID: ${result._id}`); res.send(result); };