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.
This commit is contained in:
Tom Butcher 2026-08-29 22:34:11 +01:00
parent 37706f61c4
commit 61d29a8f7e
21 changed files with 1087 additions and 118 deletions

View File

@ -42,6 +42,9 @@ const listingSchema = new Schema(
price: { type: Number, required: false }, price: { type: Number, required: false },
currency: { type: String, required: false }, currency: { type: String, required: false },
lastSyncedAt: { type: Date, 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 }, stockQuantity: { type: Number, required: false, default: 0 },
condition: { condition: {
type: String, type: String,

View File

@ -47,6 +47,9 @@ const listingVarientSchema = new Schema(
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false }, priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
priceWithTax: { type: Number, required: false }, priceWithTax: { type: Number, required: false },
lastSyncedAt: { type: Date, 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 }], listingImages: [{ type: Schema.Types.ObjectId, ref: 'file', required: false }],
stockQuantity: { type: Number, required: false, default: 0 }, stockQuantity: { type: Number, required: false, default: 0 },
}, },

View File

@ -9,6 +9,7 @@ export function marketplaceSyncMappingSchema() {
{ {
marketplace: { type: Schema.Types.ObjectId, ref: 'marketplace', required: true }, marketplace: { type: Schema.Types.ObjectId, ref: 'marketplace', required: true },
externalReference: { type: String, required: false }, externalReference: { type: String, required: false },
syncHash: { type: String, required: false },
state: { state: {
type: { type: {
type: String, type: String,

View File

@ -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),
}),
})
);
});
});

View File

@ -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);
});
});

View File

@ -14,8 +14,16 @@ jest.unstable_mockModule('../../../../utils.js', () => ({
distributeUpdate: jest.fn().mockResolvedValue(undefined), distributeUpdate: jest.fn().mockResolvedValue(undefined),
})); }));
const { upsertLocalPolicyFromRemote, resolveListingPolicy, persistMarketplaceMapping, idOf, updateAccountPolicy } = const {
await import('../accountPolicies.js'); 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 { makeRequest } = await import('../shared.js');
const { buildPaymentPolicy } = await import('../paymentPolicies.js'); const { buildPaymentPolicy } = await import('../paymentPolicies.js');
const { buildReturnPolicy } = await import('../returnPolicies.js'); const { buildReturnPolicy } = await import('../returnPolicies.js');
@ -238,6 +246,87 @@ describe('persistMarketplaceMapping', () => {
expect(mappings[0].state.type).toBe('syncing'); expect(mappings[0].state.type).toBe('syncing');
expect(idOf(mappings[0].marketplace)).toBe('507f1f77bcf86cd799439011'); 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', () => { describe('updateAccountPolicy', () => {

View File

@ -185,4 +185,24 @@ describe('attachImageUrlsToListingAndVarients', () => {
expect(result.varients[0].imageUrls).toEqual(['https://i.ebayimg.com/red.jpg']); expect(result.varients[0].imageUrls).toEqual(['https://i.ebayimg.com/red.jpg']);
expect(downloadFile).toHaveBeenCalledTimes(2); 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();
});
}); });

View File

@ -32,6 +32,8 @@ const {
toEbayCondition, toEbayCondition,
upsertInventoryItem, upsertInventoryItem,
inventoryItemPutBody, inventoryItemPutBody,
shouldSkipInventoryPut,
shouldSkipOfferPut,
} = await import('../listingVarients.js'); } = await import('../listingVarients.js');
describe('eBay varient quantity', () => { 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);
});
});

View File

@ -222,4 +222,31 @@ describe('syncListingImages', () => {
expect(syncOfferAndMaybePublish).not.toHaveBeenCalled(); expect(syncOfferAndMaybePublish).not.toHaveBeenCalled();
expect(result).toEqual({ listing: listingWithImages, varients: varientsWithImages }); 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,
})
);
});
}); });

View File

@ -1,4 +1,5 @@
import { makeRequest, logger, getEbayMarketplaceId } from './shared.js'; import { makeRequest, logger, getEbayMarketplaceId } from './shared.js';
import { hashSyncPayload } from '../syncFingerprint.js';
const SELLING_POLICY_PROGRAM = 'SELLING_POLICY_MANAGEMENT'; const SELLING_POLICY_PROGRAM = 'SELLING_POLICY_MANAGEMENT';
export const POLICY_CATEGORY_TYPE = 'ALL_EXCLUDING_MOTORS_VEHICLES'; export const POLICY_CATEGORY_TYPE = 'ALL_EXCLUDING_MOTORS_VEHICLES';
@ -33,11 +34,23 @@ export function mappingExternalReference(doc, marketplace) {
return getMarketplaceMapping(doc, marketplace)?.externalReference || ''; 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( export async function persistMarketplaceMapping(
model, model,
doc, doc,
marketplace, marketplace,
{ externalReference, stateType, message } = {} { externalReference, stateType, message, syncHash } = {}
) { ) {
if (!model || !doc) return null; if (!model || !doc) return null;
const marketplaceId = idOf(marketplace); 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; if (index >= 0) mappings[index] = next;
else mappings.push(next); else mappings.push(next);

View File

@ -2,6 +2,7 @@ import mongoose from 'mongoose';
import { courierServiceModel } from '../../../database/schemas/management/courierservice.schema.js'; import { courierServiceModel } from '../../../database/schemas/management/courierservice.schema.js';
import { fulfillmentPolicyModel } from '../../../database/schemas/sales/fulfillmentpolicy.schema.js'; import { fulfillmentPolicyModel } from '../../../database/schemas/sales/fulfillmentpolicy.schema.js';
import { makeRequest, logger } from './shared.js'; import { makeRequest, logger } from './shared.js';
import { hashSyncPayload } from '../syncFingerprint.js';
import { import {
POLICY_CATEGORY_TYPE, POLICY_CATEGORY_TYPE,
buildCategoryTypes as buildSharedCategoryTypes, buildCategoryTypes as buildSharedCategoryTypes,
@ -11,6 +12,7 @@ import {
isDefaultAccountPolicy, isDefaultAccountPolicy,
mappingExternalReference, mappingExternalReference,
persistMarketplaceMapping, persistMarketplaceMapping,
shouldSkipMappedPolicySync,
updateAccountPolicy, updateAccountPolicy,
upsertLocalPolicyFromRemote, upsertLocalPolicyFromRemote,
} from './accountPolicies.js'; } from './accountPolicies.js';
@ -292,11 +294,44 @@ async function resolvePolicyCourierServices(policy) {
return serviceIds.map((id) => servicesById.get(String(id))).filter(Boolean); 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) { export async function ensureFulfillmentPolicySynced(marketplace, policy) {
if (!policy) { if (!policy) {
throw new Error('A fulfillment policy is required before publishing to eBay.'); 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 ensureSellingPolicyManagement(marketplace);
await persistMarketplaceMapping(fulfillmentPolicyModel, policy, marketplace, { await persistMarketplaceMapping(fulfillmentPolicyModel, policy, marketplace, {
stateType: 'syncing', stateType: 'syncing',
@ -366,6 +401,7 @@ export async function ensureFulfillmentPolicySynced(marketplace, policy) {
await persistMarketplaceMapping(fulfillmentPolicyModel, policy, marketplace, { await persistMarketplaceMapping(fulfillmentPolicyModel, policy, marketplace, {
externalReference: String(fulfillmentPolicyId), externalReference: String(fulfillmentPolicyId),
stateType: 'ready', stateType: 'ready',
syncHash,
}); });
logger.info(`Synced eBay fulfillment policy "${payload.name}" (${fulfillmentPolicyId})`); logger.info(`Synced eBay fulfillment policy "${payload.name}" (${fulfillmentPolicyId})`);
return { fulfillmentPolicyId: String(fulfillmentPolicyId) }; return { fulfillmentPolicyId: String(fulfillmentPolicyId) };

View File

@ -1,4 +1,5 @@
import { downloadFile, BUCKETS } from '../../../database/ceph.js'; import { downloadFile, BUCKETS } from '../../../database/ceph.js';
import { canReuseMarketplaceImageUrls, imageFilesHash } from '../syncFingerprint.js';
import { getMediaApiBaseUrl, makeRequest, logger } from './shared.js'; import { getMediaApiBaseUrl, makeRequest, logger } from './shared.js';
function fileId(file) { function fileId(file) {
@ -119,29 +120,56 @@ export async function filesToImageUrls(marketplace, files = []) {
return urls; return urls;
} }
export async function resolveListingImageUrls(marketplace, listing, varient) { async function resolveImageUrlsForOwner(marketplace, owner, files) {
const files = getListingImageFiles(listing, varient); 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) { if (files.length) {
return filesToImageUrls(marketplace, files); 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 (varient?.imageUrls?.length) return varient.imageUrls;
if (listing?.imageUrls?.length) return listing.imageUrls; if (listing?.imageUrls?.length) return listing.imageUrls;
return []; return [];
} }
export async function attachImageUrlsToListingAndVarients(marketplace, listing, varients = []) { export async function attachImageUrlsToListingAndVarients(marketplace, listing, varients = []) {
const listingImageUrls = await resolveListingImageUrls(marketplace, listing); const listingFiles = listing?.listingImages || [];
const listingWithUrls = listingImageUrls.length const listingImageUrls = await resolveImageUrlsForOwner(marketplace, listing, listingFiles);
? { ...listing, imageUrls: listingImageUrls } const listingWithUrls = withImageSyncFields(listing, listingFiles, listingImageUrls);
: listing;
const varientsWithUrls = []; const varientsWithUrls = [];
for (const varient of varients) { for (const varient of varients) {
if (varient?.listingImages?.length) { if (varient?.listingImages?.length) {
const urls = await filesToImageUrls(marketplace, varient.listingImages); const urls = await resolveImageUrlsForOwner(marketplace, varient, varient.listingImages);
varientsWithUrls.push(urls.length ? { ...varient, imageUrls: urls } : varient); varientsWithUrls.push(withImageSyncFields(varient, varient.listingImages, urls));
} else if (listingImageUrls.length) { } else if (listingImageUrls.length) {
varientsWithUrls.push({ ...varient, imageUrls: listingImageUrls }); varientsWithUrls.push({
...varient,
imageUrls: listingImageUrls,
marketplaceImageUrls: listingImageUrls,
});
} else { } else {
varientsWithUrls.push(varient); varientsWithUrls.push(varient);
} }

View File

@ -6,6 +6,7 @@ import { syncListingPolicies } from './listingPolicies.js';
import { makeRequest, logger } from './shared.js'; import { makeRequest, logger } from './shared.js';
import { getEbayItemUrl, parseEbayItemId } from './itemUrl.js'; import { getEbayItemUrl, parseEbayItemId } from './itemUrl.js';
import { marketplaceSku } from '../ids.js'; import { marketplaceSku } from '../ids.js';
import { payloadsEqual } from '../syncFingerprint.js';
import { fromEbayProductAspects, toEbayProductAspects } from './variationAspects.js'; import { fromEbayProductAspects, toEbayProductAspects } from './variationAspects.js';
import { toEbayHtmlDescription, toEbayPlainDescription } from './description.js'; import { toEbayHtmlDescription, toEbayPlainDescription } from './description.js';
@ -335,6 +336,59 @@ export function resolveVarientQuantity(varient) {
return Math.max(0, Number(varient?.stockQuantity) || 0); 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) { export async function upsertInventoryItem(marketplace, varient, listing) {
const sku = marketplaceSku(varient); const sku = marketplaceSku(varient);
if (!sku) throw new Error('SKU is required to upsert an eBay inventory item'); 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 const inventoryItem = existing
? inventoryItemPutBody(existing, listing, sku, varient, quantity) ? inventoryItemPutBody(existing, listing, sku, varient, quantity)
: mapVarientToInventoryItem(varient, listing, quantity); : mapVarientToInventoryItem(varient, listing, quantity);
if (shouldSkipInventoryPut(existing, inventoryItem)) {
logger.debug(`Inventory item ${sku} unchanged — skipping eBay PUT`);
return existing;
}
const result = await makeRequest({ const result = await makeRequest({
marketplace, marketplace,
method: 'PUT', method: 'PUT',
@ -355,6 +413,7 @@ export async function upsertInventoryItem(marketplace, varient, listing) {
}); });
logger.debug('inventoryItem', inventoryItem); logger.debug('inventoryItem', inventoryItem);
logger.debug('result', result); logger.debug('result', result);
return result;
} }
export function resolveOfferListingId(offer) { export function resolveOfferListingId(offer) {
@ -444,6 +503,12 @@ async function upsertOrCreateOffer(marketplace, varient, listing) {
}; };
} }
const body = { ...existingOffer, ...offerUpdate }; const body = { ...existingOffer, ...offerUpdate };
if (shouldSkipOfferPut(existingOffer, body)) {
logger.debug(
`Offer ${existingOffer.offerId} unchanged — skipping eBay PUT`
);
return existingOffer;
}
await makeRequest({ await makeRequest({
marketplace, marketplace,
method: 'PUT', method: 'PUT',

View File

@ -16,6 +16,11 @@ import { marketplaceSku } from '../ids.js';
import { buildGroupVariesBy } from './variationAspects.js'; import { buildGroupVariesBy } from './variationAspects.js';
import { toEbayHtmlDescription } from './description.js'; import { toEbayHtmlDescription } from './description.js';
import { attachImageUrlsToListingAndVarients } from './images.js'; import { attachImageUrlsToListingAndVarients } from './images.js';
import {
listingImageSyncHash,
listingImagesUnchanged,
payloadsEqual,
} from '../syncFingerprint.js';
function sleep(ms) { function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
@ -51,9 +56,26 @@ export function buildInventoryItemGroupBody(listing, varients) {
return body; 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) { async function createOrReplaceGroup(marketplace, listing, varients) {
const groupKey = listing._reference; const groupKey = listing._reference;
const body = buildInventoryItemGroupBody(listing, varients); 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({ await makeRequest({
marketplace, marketplace,
@ -140,6 +162,13 @@ async function syncListing(marketplace, listing, varients, actionLabel) {
...listingPolicies, ...listingPolicies,
}; };
const imageState = imageSyncState(
listing,
validVarients,
listingWithImages,
varientsWithImages
);
if (varientsWithImages.length === 1) { if (varientsWithImages.length === 1) {
logger.info( logger.info(
`Syncing standalone eBay inventory item "${varientsWithImages[0]._reference}" for listing "${ref}"` `Syncing standalone eBay inventory item "${varientsWithImages[0]._reference}" for listing "${ref}"`
@ -149,20 +178,37 @@ async function syncListing(marketplace, listing, varients, actionLabel) {
listingWithContext, listingWithContext,
varientsWithImages[0] varientsWithImages[0]
); );
return listingSyncResult(published, marketplace); return listingSyncResult(published, marketplace, imageState);
} }
const published = await syncGroupedListing(marketplace, listingWithContext, varientsWithImages); const published = await syncGroupedListing(marketplace, listingWithContext, varientsWithImages);
return listingSyncResult(published, marketplace); return listingSyncResult(published, marketplace, imageState);
} }
function listingSyncResult(published, marketplace) { function listingSyncResult(published, marketplace, imageState = {}) {
if (!published) return { url: '' }; if (!published) {
if (typeof published === 'string') return { url: published }; return { url: '', ...imageState };
}
if (typeof published === 'string') {
return { url: published, ...imageState };
}
const listingId = parseEbayItemId(published.listingId || published.externalReference); const listingId = parseEbayItemId(published.listingId || published.externalReference);
return { return {
url: published.url || getEbayItemUrl(marketplace, listingId), url: published.url || getEbayItemUrl(marketplace, listingId),
...(listingId ? { externalReference: 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); const validVarients = (varients || []).filter((varient) => varient?._reference);
if (validVarients.length === 0) return null; 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 } = const { listing: listingWithImages, varients: varientsWithImages } =
await attachImageUrlsToListingAndVarients(marketplace, listing, validVarients); await attachImageUrlsToListingAndVarients(marketplace, listing, validVarients);

View File

@ -1,4 +1,5 @@
import { paymentPolicyModel } from '../../../database/schemas/finance/paymentpolicy.schema.js'; import { paymentPolicyModel } from '../../../database/schemas/finance/paymentpolicy.schema.js';
import { hashSyncPayload } from '../syncFingerprint.js';
import { makeRequest, logger } from './shared.js'; import { makeRequest, logger } from './shared.js';
import { import {
buildCategoryTypes, buildCategoryTypes,
@ -6,10 +7,23 @@ import {
getEbayMarketplaceId, getEbayMarketplaceId,
mappingExternalReference, mappingExternalReference,
persistMarketplaceMapping, persistMarketplaceMapping,
shouldSkipMappedPolicySync,
updateAccountPolicy, updateAccountPolicy,
upsertLocalPolicyFromRemote, upsertLocalPolicyFromRemote,
} from './accountPolicies.js'; } 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 = []) { export function buildPaymentPolicy(policy, marketplace, existingPolicy, allPolicies = []) {
const payload = { const payload = {
name: String(policy?.name || '').slice(0, 64), 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.'); 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 ensureSellingPolicyManagement(marketplace);
await persistMarketplaceMapping(paymentPolicyModel, policy, marketplace, { await persistMarketplaceMapping(paymentPolicyModel, policy, marketplace, {
stateType: 'syncing', stateType: 'syncing',
@ -92,6 +114,7 @@ export async function ensurePaymentPolicySynced(marketplace, policy) {
await persistMarketplaceMapping(paymentPolicyModel, policy, marketplace, { await persistMarketplaceMapping(paymentPolicyModel, policy, marketplace, {
externalReference: String(paymentPolicyId), externalReference: String(paymentPolicyId),
stateType: 'ready', stateType: 'ready',
syncHash,
}); });
logger.info(`Synced eBay payment policy "${payload.name}" (${paymentPolicyId})`); logger.info(`Synced eBay payment policy "${payload.name}" (${paymentPolicyId})`);
return { paymentPolicyId: String(paymentPolicyId) }; return { paymentPolicyId: String(paymentPolicyId) };

View File

@ -1,4 +1,5 @@
import { returnPolicyModel } from '../../../database/schemas/sales/returnpolicy.schema.js'; import { returnPolicyModel } from '../../../database/schemas/sales/returnpolicy.schema.js';
import { hashSyncPayload } from '../syncFingerprint.js';
import { makeRequest, logger } from './shared.js'; import { makeRequest, logger } from './shared.js';
import { import {
buildCategoryTypes, buildCategoryTypes,
@ -6,6 +7,7 @@ import {
getEbayMarketplaceId, getEbayMarketplaceId,
mappingExternalReference, mappingExternalReference,
persistMarketplaceMapping, persistMarketplaceMapping,
shouldSkipMappedPolicySync,
updateAccountPolicy, updateAccountPolicy,
upsertLocalPolicyFromRemote, upsertLocalPolicyFromRemote,
} from './accountPolicies.js'; } 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_TO_REFUND = { MONEY_BACK: 'moneyBack', MERCHANDISE_CREDIT: 'merchandiseCredit' };
const EBAY_RETURN_PERIOD_DAYS = [14, 30, 60]; 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) { export function snapReturnPeriodDays(days, fallback = 30) {
const value = Number(days); const value = Number(days);
if (!Number.isFinite(value) || value <= 0) return fallback; 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.'); 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 ensureSellingPolicyManagement(marketplace);
await persistMarketplaceMapping(returnPolicyModel, policy, marketplace, { await persistMarketplaceMapping(returnPolicyModel, policy, marketplace, {
stateType: 'syncing', stateType: 'syncing',
@ -137,6 +163,7 @@ export async function ensureReturnPolicySynced(marketplace, policy) {
await persistMarketplaceMapping(returnPolicyModel, policy, marketplace, { await persistMarketplaceMapping(returnPolicyModel, policy, marketplace, {
externalReference: String(returnPolicyId), externalReference: String(returnPolicyId),
stateType: 'ready', stateType: 'ready',
syncHash,
}); });
logger.info(`Synced eBay return policy "${payload.name}" (${returnPolicyId})`); logger.info(`Synced eBay return policy "${payload.name}" (${returnPolicyId})`);
return { returnPolicyId: String(returnPolicyId) }; return { returnPolicyId: String(returnPolicyId) };

View File

@ -1,8 +1,10 @@
import { taxRateModel } from '../../../database/schemas/management/taxrate.schema.js'; import { taxRateModel } from '../../../database/schemas/management/taxrate.schema.js';
import { hashSyncPayload } from '../syncFingerprint.js';
import { makeRequest, logger } from './shared.js'; import { makeRequest, logger } from './shared.js';
import { import {
idOf, idOf,
persistMarketplaceMapping, persistMarketplaceMapping,
shouldSkipMappedPolicySync,
upsertLocalPolicyFromRemote, upsertLocalPolicyFromRemote,
} from './accountPolicies.js'; } from './accountPolicies.js';
@ -56,12 +58,21 @@ export async function ensureTaxRateSynced(marketplace, taxRate) {
return { skipped: true }; 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, { await persistMarketplaceMapping(taxRateModel, taxRate, marketplace, {
stateType: 'syncing', stateType: 'syncing',
}); });
try { try {
const entry = buildSalesTaxEntry(taxRate);
await makeRequest({ await makeRequest({
marketplace, marketplace,
method: 'PUT', method: 'PUT',
@ -75,6 +86,7 @@ export async function ensureTaxRateSynced(marketplace, taxRate) {
await persistMarketplaceMapping(taxRateModel, taxRate, marketplace, { await persistMarketplaceMapping(taxRateModel, taxRate, marketplace, {
externalReference, externalReference,
stateType: 'ready', stateType: 'ready',
syncHash,
}); });
logger.info( logger.info(
`Synced eBay sales tax ${externalReference} for tax rate "${taxRate.name || taxRate._reference}"` `Synced eBay sales tax ${externalReference} for tax rate "${taxRate.name || taxRate._reference}"`

View File

@ -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
);
}

View File

@ -23,6 +23,13 @@ import {
applyWebhookAction, applyWebhookAction,
pushShipmentFulfillment, pushShipmentFulfillment,
} from './marketplaceSync.js'; } from './marketplaceSync.js';
import {
imageFilesHash,
isUnsyncedDraftListing,
listingImagesUnchanged,
listingSyncHash,
listingSyncUnchanged,
} from './marketplaces/syncFingerprint.js';
const logger = log4js.getLogger('Marketplace Worker'); const logger = log4js.getLogger('Marketplace Worker');
logger.level = config.server.logLevel; logger.level = config.server.logLevel;
@ -68,6 +75,16 @@ export async function ensureMarketplaceListingInventory(marketplace, user, listi
if (!fullListing) throw new Error('Listing not found'); if (!fullListing) throw new Error('Listing not found');
const listingVarients = const listingVarients =
varients.length > 0 ? varients : listing?._id ? await fetchListingVarients(listing._id) : []; 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); 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'); if (!fullListing) throw new Error('Listing not found');
const listingVarients = const listingVarients =
varients.length > 0 ? varients : listing?._id ? await fetchListingVarients(listing._id) : []; 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); return provider.syncListingImages(authenticatedMarketplace, fullListing, listingVarients);
} }
@ -486,6 +509,76 @@ async function fetchListingVarients(listingId) {
return listingVarientModel.find({ listing: listingId }).populate('listingImages').lean(); 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) { export async function createListing(marketplace, user, listingData) {
const provider = getProvider(marketplace); const provider = getProvider(marketplace);
if (!provider.createItem) { if (!provider.createItem) {
@ -493,6 +586,11 @@ export async function createListing(marketplace, user, listingData) {
return; 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) { if (listingData._id) {
await setListingState(listingData._id, 'syncing', user).catch((err) => await setListingState(listingData._id, 'syncing', user).catch((err) =>
logger.warn(`Failed to set listing syncing state: ${err.message}`) logger.warn(`Failed to set listing syncing state: ${err.message}`)
@ -501,32 +599,19 @@ export async function createListing(marketplace, user, listingData) {
try { try {
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user); 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})`); logger.info(`Creating listing on marketplace "${marketplace.name}" (${marketplace.provider})`);
const result = await provider.createItem(authenticatedMarketplace, fullListing, varients); const result = await provider.createItem(authenticatedMarketplace, fullListing, varients);
if (listingData._id) { if (listingData._id) {
const updateData = { lastSyncedAt: new Date(), state: { type: 'active' } }; await persistListingSyncMetadata({
if (result?.url) updateData.url = result.url; listingId: listingData._id,
if (result?.externalReference) updateData.externalReference = result.externalReference; listing: fullListing,
await editObject({ model: listingModel, id: listingData._id, updateData, user }); varients,
result,
for (const varient of varients) { user,
const varientUpdate = { lastSyncedAt: new Date(), state: { type: 'active' } }; stateType: previousState === 'active' ? 'active' : 'draft',
if (!varient.externalReference && marketplaceSku(varient)) { });
varientUpdate.externalReference = marketplaceSku(varient);
}
await editObject({
model: listingVarientModel,
id: varient._id,
updateData: varientUpdate,
user,
}).catch(() => {});
}
} }
logger.info(`Background createListing complete for marketplace "${marketplace.name}"`); logger.info(`Background createListing complete for marketplace "${marketplace.name}"`);
@ -548,6 +633,18 @@ export async function updateListing(marketplace, user, listingData) {
return; 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) { if (listingData._id) {
await setListingState(listingData._id, 'syncing', user).catch((err) => await setListingState(listingData._id, 'syncing', user).catch((err) =>
logger.warn(`Failed to set listing syncing state: ${err.message}`) logger.warn(`Failed to set listing syncing state: ${err.message}`)
@ -556,37 +653,19 @@ export async function updateListing(marketplace, user, listingData) {
try { try {
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user); 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})`); logger.info(`Updating listing on marketplace "${marketplace.name}" (${marketplace.provider})`);
const result = await provider.updateItem(authenticatedMarketplace, fullListing, varients); const result = await provider.updateItem(authenticatedMarketplace, fullListing, varients);
if (listingData._id) { if (listingData._id) {
const updateData = { state: { type: 'active' }, lastSyncedAt: new Date() }; await persistListingSyncMetadata({
if (result?.url) updateData.url = result.url; listingId: listingData._id,
if (result?.externalReference) updateData.externalReference = result.externalReference; listing: fullListing,
await editObject({ varients,
model: listingModel, result,
id: listingData._id,
updateData,
user, 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}"`); 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}` `Background updateListing failed for marketplace "${marketplace.name}": ${err.message}`
); );
if (listingData._id) { if (listingData._id) {
await setListingState(listingData._id, 'active', user, err.message).catch(() => {}); await setListingState(
listingData._id,
listingStateAfterSync(previousState),
user,
err.message
).catch(() => {});
} }
throw err; throw err;
} }
@ -663,18 +747,12 @@ export async function syncItems(marketplace, user) {
'state.type': { $ne: 'deleted' }, 'state.type': { $ne: 'deleted' },
}) })
.lean(); .lean();
for (const listing of existingListings) {
await setListingState(listing._id, 'syncing', user).catch(() => {});
}
const existingVarients = await listingVarientModel const existingVarients = await listingVarientModel
.find({ .find({
listing: { $in: existingListings.map((l) => l._id) }, listing: { $in: existingListings.map((l) => l._id) },
}) })
.lean(); .lean();
for (const varient of existingVarients) {
await setListingVarientState(varient._id, 'syncing', user).catch(() => {});
}
const results = []; const results = [];
@ -684,49 +762,54 @@ export async function syncItems(marketplace, user) {
(varient) => String(varient.listing) === String(listing._id) (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) { if (!listingVarients.length) {
throw new Error('Listing has no varients to sync'); 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( const result = await provider.updateItem(
authenticatedMarketplace, authenticatedMarketplace,
listing, listing,
listingVarients listingVarients
); );
const listingUpdateData = { await persistListingSyncMetadata({
lastSyncedAt: new Date(), listingId: listing._id,
state: listing.state || { type: 'draft' }, listing,
}; varients: listingVarients,
if (result?.url) { result: {
listingUpdateData.url = result.url; ...result,
} url: result?.url || listing.url,
if (result?.externalReference) { externalReference: result?.externalReference || listing.externalReference,
listingUpdateData.externalReference = result.externalReference; },
}
await editObject({
model: listingModel,
id: listing._id,
updateData: listingUpdateData,
user, 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 }); results.push({ _reference: listing._reference, action: 'synced', id: listing._id });
} catch (err) { } catch (err) {
logger.warn(`Failed to sync listing ${listing._reference}: ${err.message}`); logger.warn(`Failed to sync listing ${listing._reference}: ${err.message}`);
@ -912,18 +995,17 @@ export async function publishListingOffers({
}); });
} }
const listingUpdate = { await persistListingSyncMetadata({
updatedAt: new Date(), listingId,
state: { type: 'active' }, listing,
lastSyncedAt: new Date(), varients: allVarients,
}; result: {
if (publishedListingId) listingUpdate.externalReference = publishedListingId; url: publishedUrl,
if (publishedUrl) listingUpdate.url = publishedUrl; externalReference: publishedListingId,
await editObject({ },
model: listingModel,
id: listingId,
updateData: listingUpdate,
user, user,
stateType: 'active',
applyVarientState: false,
}); });
logger.info(`Background publishListing complete for listing ${listingId}`); logger.info(`Background publishListing complete for listing ${listingId}`);
} catch (err) { } catch (err) {

View File

@ -61,8 +61,10 @@ jest.unstable_mockModule('log4js', () => ({
}, },
})); }));
const { publishListingRouteHandler, unpublishListingRouteHandler } = await import('../listings.js'); const { publishListingRouteHandler, unpublishListingRouteHandler, newListingRouteHandler } =
const { checkStates, editObject } = await import('../../../database/database.js'); 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', () => { describe('listing marketplace publish/unpublish', () => {
let req; 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' },
})
);
});
});

View File

@ -261,11 +261,6 @@ export const newListingRouteHandler = async (req, res) => {
return res.status(result.code).send(result); 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}`); logger.debug(`New listing with ID: ${result._id}`);
res.send(result); res.send(result);
}; };