From 61d29a8f7ee414000812bd649a231a48ab817001 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sat, 29 Aug 2026 22:34:11 +0100 Subject: [PATCH] Add syncHash and syncImageHash fields to listing and variant schemas; implement sync fingerprinting for marketplace integration This commit introduces new fields `syncHash` and `syncImageHash` to the `listing` and `listingVariant` schemas, enhancing the tracking of synchronization states. Additionally, a new module for sync fingerprinting is added, which includes functions to generate hashes for listings and their variants. The marketplace integration logic is updated to utilize these hashes for determining if updates are necessary, improving efficiency in syncing operations. Tests are also added to ensure the correctness of the new functionality and its integration with existing systems. --- src/database/schemas/sales/listing.schema.js | 3 + .../schemas/sales/listingvarient.schema.js | 3 + .../sales/marketplaceMapping.schema.js | 1 + .../__tests__/marketplaceworker.test.js | 135 +++++++++ .../__tests__/syncFingerprint.test.js | 108 +++++++ .../ebay/__tests__/accountPolicies.test.js | 93 +++++- .../ebay/__tests__/images.test.js | 20 ++ .../ebay/__tests__/listingVarients.test.js | 63 ++++ .../ebay/__tests__/listings.test.js | 27 ++ .../marketplaces/ebay/accountPolicies.js | 20 +- .../marketplaces/ebay/fulfillmentPolicies.js | 36 +++ src/integrations/marketplaces/ebay/images.js | 46 ++- .../marketplaces/ebay/listingVarients.js | 65 +++++ .../marketplaces/ebay/listings.js | 63 +++- .../marketplaces/ebay/paymentPolicies.js | 23 ++ .../marketplaces/ebay/returnPolicies.js | 27 ++ .../marketplaces/ebay/salesTax.js | 14 +- .../marketplaces/syncFingerprint.js | 141 +++++++++ src/integrations/marketplaceworker.js | 268 ++++++++++++------ src/services/sales/__tests__/listings.test.js | 44 ++- src/services/sales/listings.js | 5 - 21 files changed, 1087 insertions(+), 118 deletions(-) create mode 100644 src/integrations/__tests__/marketplaceworker.test.js create mode 100644 src/integrations/marketplaces/__tests__/syncFingerprint.test.js create mode 100644 src/integrations/marketplaces/syncFingerprint.js 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); };