Compare commits

...

2 Commits

Author SHA1 Message Date
1cef3a8d1d Implement syncWebhooks functionality and refactor ensureWebhookSubscriptions
All checks were successful
farmcontrol/farmcontrol-api/pipeline/head This commit looks good
This commit introduces the `syncWebhooks` function to streamline the process of synchronizing webhook configurations for marketplaces. It enhances the existing `ensureWebhookSubscriptions` function to utilize `syncWebhooks`, ensuring that webhook URLs and verification tokens are correctly set and updated. Additionally, a new module for managing webhook configurations is added, which includes utility functions for building webhook URLs and generating verification tokens. Tests are also included to validate the new functionality and its integration with existing systems, improving the overall reliability of webhook management.
2026-08-29 23:02:13 +01:00
61d29a8f7e 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.
2026-08-29 22:34:11 +01:00
29 changed files with 1647 additions and 198 deletions

View File

@ -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,

View File

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

View File

@ -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,

View File

@ -0,0 +1,197 @@
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' }, app: { urlApi: 'https://api.example.com' } },
}));
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', () => ({}));
const ensureWebhookSubscriptions = jest.fn();
jest.unstable_mockModule('../marketplaces/ebay/index.js', () => ({
updateItem,
createItem: jest.fn(),
ensureAuthenticatedMarketplace: jest.fn(async (marketplace) => marketplace),
ensureWebhookSubscriptions,
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, syncWebhooks } = 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),
}),
})
);
});
});
describe('syncWebhooks', () => {
const user = { _id: 'user-1' };
beforeEach(() => {
jest.clearAllMocks();
editObject.mockImplementation(async ({ updateData }) => ({
_id: 'mp-1',
name: 'eBay UK',
provider: 'ebay',
...updateData,
}));
});
it('persists webhookUrl and verificationToken then subscribes', async () => {
ensureWebhookSubscriptions.mockResolvedValue({ destinationId: 'dest-1', created: ['ORDER'] });
const result = await syncWebhooks(
{ _id: 'mp-1', name: 'eBay UK', provider: 'ebay', config: {} },
user
);
expect(editObject).toHaveBeenCalledWith(
expect.objectContaining({
updateData: expect.objectContaining({
config: expect.objectContaining({
webhookUrl: 'https://api.example.com/marketplaces/mp-1/hook',
verificationToken: expect.stringMatching(/^[a-f0-9]{64}$/),
}),
}),
})
);
expect(ensureWebhookSubscriptions).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
destinationId: 'dest-1',
created: ['ORDER'],
webhookUrl: 'https://api.example.com/marketplaces/mp-1/hook',
});
});
it('does not persist when webhook config is already present', async () => {
ensureWebhookSubscriptions.mockResolvedValue({ destinationId: 'dest-1', created: [] });
const marketplace = {
_id: 'mp-1',
name: 'eBay UK',
provider: 'ebay',
config: {
webhookUrl: 'https://custom.example/hook',
verificationToken: 'existing-token-32-characters-long!',
},
};
const result = await syncWebhooks(marketplace, user);
expect(editObject).not.toHaveBeenCalled();
expect(ensureWebhookSubscriptions).toHaveBeenCalledWith(marketplace);
expect(result.webhookUrl).toBe('https://custom.example/hook');
});
});

View File

@ -381,6 +381,17 @@ export function ensureWebhookSubscriptions(marketplace, user, { wait = false } =
);
}
export function syncWebhooks(marketplace, user, { wait = true } = {}) {
return sendJob(
'syncWebhooks',
{
...marketplacePayload(marketplace),
...userPayload(user),
},
{ wait }
);
}
export function pushMarketplaceShipmentFulfillment(marketplace, user, shipment) {
return sendJob('pushShipmentFulfillment', {
...marketplacePayload(marketplace),

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

@ -0,0 +1,41 @@
import { describe, expect, it, jest } from '@jest/globals';
jest.unstable_mockModule('../../../config.js', () => ({
default: { app: { urlApi: 'https://api.example.com/' }, server: { logLevel: 'error' } },
}));
const { buildMarketplaceWebhookUrl, webhookConfigUpdates, generateWebhookVerificationToken } =
await import('../webhookConfig.js');
describe('webhookConfig', () => {
it('builds the public hook URL from urlApi and marketplace id', () => {
expect(buildMarketplaceWebhookUrl({ _id: 'mkt-1' })).toBe(
'https://api.example.com/marketplaces/mkt-1/hook'
);
});
it('returns an empty URL when the marketplace id is missing', () => {
expect(buildMarketplaceWebhookUrl({})).toBe('');
});
it('generates a 64-character verification token', () => {
expect(generateWebhookVerificationToken()).toMatch(/^[a-f0-9]{64}$/);
});
it('fills missing webhookUrl and verificationToken', () => {
const updates = webhookConfigUpdates({ _id: 'mkt-1', config: {} });
expect(updates.webhookUrl).toBe('https://api.example.com/marketplaces/mkt-1/hook');
expect(updates.verificationToken).toMatch(/^[a-f0-9]{64}$/);
});
it('does not overwrite existing webhook fields', () => {
const updates = webhookConfigUpdates({
_id: 'mkt-1',
config: {
webhookUrl: 'https://custom.example/hook',
verificationToken: 'existing-token-32-characters-long!',
},
});
expect(updates).toEqual({});
});
});

View File

@ -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', () => {

View File

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

View File

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

View File

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

View File

@ -1,6 +1,13 @@
import { describe, expect, it, jest } from '@jest/globals';
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
import crypto from 'crypto';
jest.unstable_mockModule('../../../../config.js', () => ({
default: {
app: { urlApi: 'https://api.example.com' },
server: { logLevel: 'error' },
smtp: { from: 'FarmControl <noreply@farmcontrol.app>' },
},
}));
jest.unstable_mockModule('../shared.js', () => ({
makeRequest: jest.fn(),
getApiBaseUrl: () => 'https://api.sandbox.ebay.com',
@ -11,9 +18,12 @@ jest.unstable_mockModule('../shared.js', () => ({
}));
const { makeRequest } = await import('../shared.js');
const { buildWebhookChallengeResponse, verifyNotificationSignature } = await import(
'../notifications.js'
);
const {
buildWebhookChallengeResponse,
verifyNotificationSignature,
ensureWebhookSubscriptions,
resolveNotificationAlertEmail,
} = await import('../notifications.js');
describe('eBay notification challenge', () => {
it('hashes challengeCode + verificationToken + endpoint', () => {
@ -68,3 +78,155 @@ describe('eBay notification signature', () => {
).toBe(false);
});
});
describe('eBay webhook subscriptions', () => {
const marketplace = {
name: 'eBay',
config: {
verificationToken: 'verify-token-32-characters-long!!',
webhookUrl: 'https://example.com/marketplaces/1/hook',
accessToken: 'user-token',
},
};
const originalFetch = globalThis.fetch;
beforeEach(() => {
makeRequest.mockReset();
globalThis.fetch = jest.fn(async (url, options) => {
if (String(url).includes('/identity/v1/oauth2/token')) {
const body = String(options?.body || '');
expect(body).toContain('grant_type=client_credentials');
expect(decodeURIComponent(body)).toContain('https://api.ebay.com/oauth/api_scope');
return {
ok: true,
status: 200,
json: async () => ({ access_token: 'app-token', expires_in: 7200 }),
};
}
throw new Error(`unexpected fetch ${url}`);
});
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('throws when webhook URL and verification token are missing', async () => {
await expect(ensureWebhookSubscriptions({ name: 'eBay', config: {} })).rejects.toThrow(
/webhook URL and verification token/
);
});
it('resolves the alert email from SMTP from', () => {
expect(resolveNotificationAlertEmail({})).toBe('noreply@farmcontrol.app');
expect(
resolveNotificationAlertEmail({ config: { notificationAlertEmail: 'ops@example.com' } })
).toBe('ops@example.com');
});
it('creates notification config before creating a destination', async () => {
makeRequest.mockImplementation(async ({ method = 'GET', path }) => {
if (path.endsWith('/config') && method === 'GET') return null;
if (path.endsWith('/config') && method === 'PUT') return null;
if (path.endsWith('/destination') && method === 'GET') return { destinations: [] };
if (path.endsWith('/destination') && method === 'POST') return { destinationId: 'dest-1' };
if (path.endsWith('/topic')) {
return {
topics: [
{
topicId: 'ORDER',
status: 'ENABLED',
supportedPayloads: [{ schemaVersion: '1.0' }],
},
],
};
}
if (path.endsWith('/subscription') && method === 'GET') return { subscriptions: [] };
if (path.endsWith('/subscription') && method === 'POST') return {};
return null;
});
const result = await ensureWebhookSubscriptions(marketplace);
expect(makeRequest).toHaveBeenCalledWith(
expect.objectContaining({
method: 'PUT',
path: '/commerce/notification/v1/config',
body: { alertEmail: 'noreply@farmcontrol.app' },
marketplaceHeaders: false,
marketplace: expect.objectContaining({
name: 'eBay',
config: expect.objectContaining({ accessToken: 'app-token' }),
}),
})
);
expect(makeRequest).toHaveBeenCalledWith(
expect.objectContaining({
method: 'POST',
path: '/commerce/notification/v1/destination',
})
);
expect(result).toMatchObject({ destinationId: 'dest-1', created: ['ORDER'] });
});
it('continues destination setup when eBay /config PUT returns 500', async () => {
makeRequest.mockImplementation(async ({ method = 'GET', path }) => {
if (path.endsWith('/config') && method === 'GET') return null;
if (path.endsWith('/config') && method === 'PUT') {
throw new Error('eBay API error (500): There was a problem with an eBay internal system');
}
if (path.endsWith('/destination') && method === 'GET') return { destinations: [] };
if (path.endsWith('/destination') && method === 'POST') return { destinationId: 'dest-1' };
if (path.endsWith('/topic')) return { topics: [] };
if (path.endsWith('/subscription')) return { subscriptions: [] };
return null;
});
const result = await ensureWebhookSubscriptions(marketplace);
expect(result).toMatchObject({ destinationId: 'dest-1', created: [] });
});
it('explains how to set /config when destination create is rejected', async () => {
makeRequest.mockImplementation(async ({ method = 'GET', path }) => {
if (path.endsWith('/config') && method === 'GET') return null;
if (path.endsWith('/config') && method === 'PUT') {
throw new Error('eBay API error (500): internal');
}
if (path.endsWith('/destination') && method === 'GET') return { destinations: [] };
if (path.endsWith('/destination') && method === 'POST') {
throw new Error('eBay API error (409): Please provide configurations required for notifications');
}
return null;
});
await expect(ensureWebhookSubscriptions(marketplace)).rejects.toThrow(/Developer Portal/);
});
it('does not replace an existing alert email', async () => {
makeRequest.mockImplementation(async ({ method = 'GET', path }) => {
if (path.endsWith('/config')) return { alertEmail: 'existing@example.com' };
if (path.endsWith('/destination') && method === 'GET') {
return {
destinations: [
{
destinationId: 'dest-1',
deliveryConfig: { endpoint: marketplace.config.webhookUrl },
},
],
};
}
if (path.endsWith('/topic')) return { topics: [] };
if (path.endsWith('/subscription')) return { subscriptions: [] };
return null;
});
await ensureWebhookSubscriptions(marketplace);
expect(makeRequest).not.toHaveBeenCalledWith(
expect.objectContaining({
method: 'PUT',
path: '/commerce/notification/v1/config',
})
);
});
});

View File

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

View File

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

View File

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

View File

@ -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',

View File

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

View File

@ -1,9 +1,13 @@
import crypto from 'crypto';
import config from '../../../config.js';
import { getApiBaseUrl, getBasicAuthHeader, makeRequest, logger, formatDebugPayload, getMarketplaceDebugContext } from './shared.js';
import { buildMarketplaceWebhookUrl } from '../webhookConfig.js';
const NOTIFICATION_BASE = '/commerce/notification/v1';
const DEFAULT_APP_SCOPE = 'https://api.ebay.com/oauth/api_scope';
const APPLICATION_SCOPE =
'https://api.ebay.com/oauth/api_scope/commerce.notification.subscription';
const applicationTokenCache = new Map();
const HANDLED_TOPIC_MATCHERS = [
'MARKETPLACE_ACCOUNT_DELETION',
@ -22,7 +26,7 @@ function getVerificationToken(marketplace) {
}
function getWebhookEndpoint(marketplace) {
return marketplace.config?.webhookUrl || '';
return marketplace.config?.webhookUrl || buildMarketplaceWebhookUrl(marketplace) || '';
}
export function buildWebhookChallengeResponse(marketplace, { challengeCode, endpoint } = {}) {
@ -42,8 +46,36 @@ export function buildWebhookChallengeResponse(marketplace, { challengeCode, endp
return { challengeResponse };
}
async function getApplicationToken(marketplace) {
logger.debug('eBay application token request', getMarketplaceDebugContext(marketplace));
function toPlainMarketplace(marketplace) {
if (!marketplace) {
return {};
}
if (typeof marketplace.toObject === 'function') {
return marketplace.toObject();
}
if (typeof marketplace.toJSON === 'function') {
return marketplace.toJSON();
}
return { ...marketplace };
}
function applicationTokenCacheKey(marketplace, scope) {
const clientId = marketplace?.config?.clientId || '';
const sandbox = marketplace?.config?.sandbox ? 'sandbox' : 'prod';
return `${sandbox}:${clientId}:${scope}`;
}
async function getApplicationToken(marketplace, scope = APPLICATION_SCOPE) {
const cacheKey = applicationTokenCacheKey(marketplace, scope);
const cached = applicationTokenCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now() + 30_000) {
return cached.accessToken;
}
logger.debug('eBay application token request', {
...getMarketplaceDebugContext(marketplace),
scope,
});
const startedAt = Date.now();
const response = await fetch(`${getApiBaseUrl(marketplace)}/identity/v1/oauth2/token`, {
@ -54,7 +86,7 @@ async function getApplicationToken(marketplace) {
},
body: new URLSearchParams({
grant_type: 'client_credentials',
scope: APPLICATION_SCOPE,
scope,
}).toString(),
});
const data = await response.json();
@ -64,6 +96,7 @@ async function getApplicationToken(marketplace) {
status: response.status,
durationMs,
...getMarketplaceDebugContext(marketplace),
scope,
response: formatDebugPayload(data),
});
throw new Error(data.error_description || data.error || 'Failed to mint eBay application token');
@ -71,60 +104,43 @@ async function getApplicationToken(marketplace) {
logger.debug(`eBay application token request succeeded (${durationMs}ms)`, {
...getMarketplaceDebugContext(marketplace),
scope,
expiresIn: data.expires_in,
tokenType: data.token_type,
});
applicationTokenCache.set(cacheKey, {
accessToken: data.access_token,
expiresAt: Date.now() + Number(data.expires_in || 7200) * 1000,
});
return data.access_token;
}
async function notificationRequest(marketplace, { method = 'GET', path, body, useApplicationToken = false }) {
if (!useApplicationToken) {
return makeRequest({ marketplace, method, path, body, acceptableStatuses: [404] });
}
async function notificationRequest(
marketplace,
{ method = 'GET', path, body, useApplicationToken = false, applicationScope } = {}
) {
const plain = toPlainMarketplace(marketplace);
const requestMarketplace = {
...plain,
name: marketplace?.name || plain.name,
config: { ...(plain.config || {}) },
};
const token = await getApplicationToken(marketplace);
const url = `${getApiBaseUrl(marketplace)}${path}`;
const startedAt = Date.now();
logger.debug(`eBay notification API ${method} ${path}`, {
...getMarketplaceDebugContext(marketplace),
useApplicationToken: true,
body: body ? formatDebugPayload(body) : undefined,
});
const response = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
...(body ? { 'Content-Type': 'application/json' } : {}),
},
...(body && method !== 'GET' ? { body: JSON.stringify(body) } : {}),
});
const durationMs = Date.now() - startedAt;
if (response.status === 204 || response.status === 404) {
logger.debug(
`eBay notification API ${method} ${path} -> ${response.status} (${durationMs}ms)`,
getMarketplaceDebugContext(marketplace)
if (useApplicationToken) {
requestMarketplace.config.accessToken = await getApplicationToken(
requestMarketplace,
applicationScope || APPLICATION_SCOPE
);
return response.status === 404 ? null : null;
}
const data = await response.json().catch(() => null);
if (!response.ok) {
const message = data?.errors?.[0]?.message || data?.error_description || response.statusText;
logger.error(`eBay notification API error (${response.status}): ${message}`, {
path,
durationMs,
...getMarketplaceDebugContext(marketplace),
response: data ? formatDebugPayload(data) : undefined,
});
throw new Error(`eBay notification API error (${response.status}): ${message}`);
}
logger.debug(`eBay notification API ${method} ${path} -> ${response.status} (${durationMs}ms)`, {
...getMarketplaceDebugContext(marketplace),
response: data ? formatDebugPayload(data) : undefined,
return makeRequest({
marketplace: requestMarketplace,
method,
path,
body,
acceptableStatuses: [404],
marketplaceHeaders: false,
});
return data;
}
function topicIsHandled(topicId = '') {
@ -132,36 +148,127 @@ function topicIsHandled(topicId = '') {
return HANDLED_TOPIC_MATCHERS.some((matcher) => id.includes(matcher));
}
function parseEmailAddress(value) {
const text = String(value || '').trim();
if (!text) {
return '';
}
const bracket = text.match(/<([^>]+)>/);
const email = (bracket?.[1] || text).trim();
return email.includes('@') ? email : '';
}
export function resolveNotificationAlertEmail(marketplace) {
return (
parseEmailAddress(marketplace?.config?.notificationAlertEmail) ||
parseEmailAddress(config.smtp?.from) ||
'noreply@farmcontrol.app'
);
}
const CONFIG_PORTAL_HINT =
'Set the alert email in the eBay Developer Portal (Application Keys → Alerts & Notifications), then retry Sync Webhooks.';
function isMissingNotificationConfigError(err) {
return /195003|configurations required for notifications/i.test(err?.message || '');
}
async function putNotificationConfig(marketplace, alertEmail) {
try {
await notificationRequest(marketplace, {
method: 'PUT',
path: `${NOTIFICATION_BASE}/config`,
useApplicationToken: true,
applicationScope: DEFAULT_APP_SCOPE,
body: { alertEmail },
});
return true;
} catch (err) {
logger.warn(`eBay notification config PUT with application token failed: ${err.message}`);
}
try {
await notificationRequest(marketplace, {
method: 'PUT',
path: `${NOTIFICATION_BASE}/config`,
body: { alertEmail },
});
return true;
} catch (err) {
logger.warn(`eBay notification config PUT with user token failed: ${err.message}`);
return false;
}
}
export async function ensureNotificationConfig(marketplace) {
const existing = await notificationRequest(marketplace, {
path: `${NOTIFICATION_BASE}/config`,
useApplicationToken: true,
applicationScope: DEFAULT_APP_SCOPE,
});
if (existing?.alertEmail) {
return existing;
}
const alertEmail = resolveNotificationAlertEmail(marketplace);
const updated = await putNotificationConfig(marketplace, alertEmail);
if (updated) {
logger.info(`eBay notification alert email set for "${marketplace.name}"`);
return { alertEmail };
}
logger.warn(
`Continuing webhook sync for "${marketplace.name}" without a stored eBay /config. ${CONFIG_PORTAL_HINT}`
);
return { alertEmail, skipped: true };
}
function findDestinationByEndpoint(destinations, endpoint) {
return (destinations?.destinations || []).find((item) => item.deliveryConfig?.endpoint === endpoint);
}
export async function ensureWebhookSubscriptions(marketplace) {
const endpoint = getWebhookEndpoint(marketplace);
const verificationToken = getVerificationToken(marketplace);
if (!endpoint || !verificationToken) {
logger.warn(
`Skipping eBay webhook subscription for "${marketplace.name}" (missing webhookUrl or verificationToken)`
throw new Error(
'Marketplace webhook URL and verification token are required to subscribe to eBay notifications'
);
return { skipped: true };
}
await ensureNotificationConfig(marketplace);
const destinations = await notificationRequest(marketplace, {
path: `${NOTIFICATION_BASE}/destination`,
});
let destination = (destinations?.destinations || []).find(
(item) => item.deliveryConfig?.endpoint === endpoint
);
let destination = findDestinationByEndpoint(destinations, endpoint);
if (!destination) {
destination = await notificationRequest(marketplace, {
method: 'POST',
path: `${NOTIFICATION_BASE}/destination`,
body: {
name: `FarmControl ${marketplace.name}`.slice(0, 200),
status: 'ENABLED',
deliveryConfig: {
endpoint,
verificationToken,
try {
destination = await notificationRequest(marketplace, {
method: 'POST',
path: `${NOTIFICATION_BASE}/destination`,
body: {
name: `FarmControl ${marketplace.name || ''}`.replace(/\s+/g, ' ').trim().slice(0, 64),
status: 'ENABLED',
deliveryConfig: {
endpoint,
verificationToken,
},
},
},
});
});
} catch (err) {
if (isMissingNotificationConfigError(err)) {
throw new Error(`${err.message}. ${CONFIG_PORTAL_HINT}`);
}
throw err;
}
if (!destination?.destinationId && !destination?.id) {
const refreshed = await notificationRequest(marketplace, {
path: `${NOTIFICATION_BASE}/destination`,
});
destination = findDestinationByEndpoint(refreshed, endpoint) || destination;
}
}
const destinationId = destination?.destinationId || destination?.id;

View File

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

View File

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

View File

@ -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}"`

View File

@ -153,6 +153,7 @@ export async function makeRequest({
rawBody = false,
contentType,
baseUrl,
marketplaceHeaders = true,
} = {}) {
const { accessToken } = marketplace.config || {};
if (!accessToken) {
@ -171,11 +172,13 @@ export async function makeRequest({
const headers = {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Accept-Language': getAcceptLanguage(marketplace),
...extraHeaders,
};
headers['X-EBAY-C-MARKETPLACE-ID'] = getEbayMarketplaceId(marketplace);
if (marketplaceHeaders) {
headers['Accept-Language'] = getAcceptLanguage(marketplace);
headers['X-EBAY-C-MARKETPLACE-ID'] = getEbayMarketplaceId(marketplace);
}
const fetchOptions = {
method,
@ -191,7 +194,9 @@ export async function makeRequest({
fetchOptions.body = body;
} else {
fetchOptions.headers['Content-Type'] = 'application/json';
fetchOptions.headers['Content-Language'] = getAcceptLanguage(marketplace);
if (marketplaceHeaders) {
fetchOptions.headers['Content-Language'] = getAcceptLanguage(marketplace);
}
fetchOptions.body = JSON.stringify(body);
}
}

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

@ -0,0 +1,32 @@
import crypto from 'crypto';
import config from '../../config.js';
export function buildMarketplaceWebhookUrl(marketplace) {
const base = String(config.app?.urlApi || '').replace(/\/$/, '');
if (!base || !marketplace?._id) {
return '';
}
return `${base}/marketplaces/${marketplace._id}/hook`;
}
export function generateWebhookVerificationToken() {
return crypto.randomBytes(32).toString('hex');
}
export function webhookConfigUpdates(marketplace) {
const existing = marketplace?.config || {};
const updates = {};
if (!existing.webhookUrl) {
const webhookUrl = buildMarketplaceWebhookUrl(marketplace);
if (webhookUrl) {
updates.webhookUrl = webhookUrl;
}
}
if (!existing.verificationToken) {
updates.verificationToken = generateWebhookVerificationToken();
}
return updates;
}

View File

@ -23,6 +23,14 @@ import {
applyWebhookAction,
pushShipmentFulfillment,
} from './marketplaceSync.js';
import {
imageFilesHash,
isUnsyncedDraftListing,
listingImagesUnchanged,
listingSyncHash,
listingSyncUnchanged,
} from './marketplaces/syncFingerprint.js';
import { webhookConfigUpdates } from './marketplaces/webhookConfig.js';
const logger = log4js.getLogger('Marketplace Worker');
logger.level = config.server.logLevel;
@ -68,6 +76,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 +99,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);
}
@ -378,13 +402,27 @@ export async function syncTaxRateOutbound(marketplace, user, policyId) {
});
}
export async function ensureWebhookSubscriptions(marketplace, user) {
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
const provider = getProvider(authenticatedMarketplace);
if (typeof provider.ensureWebhookSubscriptions !== 'function') {
return { skipped: true };
export async function syncWebhooks(marketplace, user) {
const configUpdates = webhookConfigUpdates(marketplace);
let readyMarketplace = marketplace;
if (Object.keys(configUpdates).length > 0) {
readyMarketplace = await persistMarketplaceUpdate(marketplace, configUpdates, {}, user);
}
return provider.ensureWebhookSubscriptions(authenticatedMarketplace);
const authenticatedMarketplace = await ensureMarketplaceAuth(readyMarketplace, user);
const provider = getProvider(authenticatedMarketplace);
const webhookUrl = authenticatedMarketplace.config?.webhookUrl;
if (typeof provider.ensureWebhookSubscriptions !== 'function') {
return { skipped: true, webhookUrl };
}
return {
webhookUrl,
...(await provider.ensureWebhookSubscriptions(authenticatedMarketplace)),
};
}
export async function ensureWebhookSubscriptions(marketplace, user) {
return syncWebhooks(marketplace, user);
}
export async function debugMarketplaceGet(marketplace, user, path, params = {}) {
@ -486,6 +524,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 +601,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 +614,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 +648,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 +668,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 +689,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 +762,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 +777,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 +1010,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) {
@ -1091,9 +1188,10 @@ export async function runJob(action, payload = {}) {
const marketplace = await loadMarketplace(payload.marketplaceId);
return syncTaxRateOutbound(marketplace, user, payload.policyId);
}
case 'ensureWebhookSubscriptions': {
case 'ensureWebhookSubscriptions':
case 'syncWebhooks': {
const marketplace = await loadMarketplace(payload.marketplaceId);
return ensureWebhookSubscriptions(marketplace, user);
return syncWebhooks(marketplace, user);
}
case 'pushShipmentFulfillment': {
const marketplace = await loadMarketplace(payload.marketplaceId);

View File

@ -54,6 +54,7 @@ import {
syncMarketplacePaymentPoliciesRouteHandler,
syncMarketplaceReturnPoliciesRouteHandler,
syncMarketplaceTaxRatesRouteHandler,
syncMarketplaceWebhooksRouteHandler,
marketplaceWebhookRouteHandler,
marketplaceWebhookChallengeRouteHandler,
subscribeMarketplaceWebhooksRouteHandler,
@ -222,6 +223,15 @@ router.post(
}
);
router.post(
'/:id/sync/webhooks',
isAuthenticated,
checkPermissions('marketplace', 'sync'),
async (req, res) => {
syncMarketplaceWebhooksRouteHandler(req, res);
}
);
router.get('/:id/hook', async (req, res) => {
marketplaceWebhookChallengeRouteHandler(req, res);
});

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import config from '../../config.js';
import { marketplaceModel } from '../../database/schemas/sales/marketplace.schema.js';
import { buildMarketplaceWebhookUrl } from '../../integrations/marketplaces/webhookConfig.js';
import log4js from 'log4js';
import mongoose from 'mongoose';
import {
@ -450,6 +451,38 @@ export const syncMarketplaceOrdersRouteHandler = async (req, res) => {
res.send({ success: true, message: 'Order sync started' });
};
export const syncMarketplaceWebhooksRouteHandler = async (req, res) => {
const id = req.params.id;
const marketplace = await getObject({ model: marketplaceModel, id });
if (marketplace?.error) {
logger.warn('Marketplace not found for webhook sync.');
return res.status(marketplace.code).send(marketplace);
}
if (!marketplace.active) {
return res.status(400).send({ error: 'Marketplace is not active.', code: 400 });
}
if (!marketplaceIntegration.hasIntegration(marketplace.provider)) {
return res.status(400).send({
error: `No integration available for provider: ${marketplace.provider}`,
code: 400,
});
}
try {
const result = await marketplaceIntegration.syncWebhooks(marketplace, req.user, {
wait: true,
});
logger.info(`Webhook sync completed for marketplace ${marketplace.name}`);
res.send({ success: true, message: 'Webhook sync completed', ...result });
} catch (err) {
logger.error(`Error syncing webhooks for marketplace ${marketplace.name}:`, err.message);
res.status(400).send({ error: err.message, code: 400 });
}
};
export const marketplaceWebhookRouteHandler = async (req, res) => {
const id = req.params.id;
@ -496,6 +529,7 @@ export const marketplaceWebhookChallengeRouteHandler = async (req, res) => {
const challengeCode = req.query.challenge_code || req.query.challengeCode;
const endpoint =
marketplace.config?.webhookUrl ||
buildMarketplaceWebhookUrl(marketplace) ||
`${req.protocol}://${req.get('host')}/marketplaces/${id}/hook`;
try {
@ -522,7 +556,7 @@ export const subscribeMarketplaceWebhooksRouteHandler = async (req, res) => {
return res.status(marketplace.code).send(marketplace);
}
try {
const result = await marketplaceIntegration.ensureWebhookSubscriptions(marketplace, req.user, {
const result = await marketplaceIntegration.syncWebhooks(marketplace, req.user, {
wait: true,
});
res.send({ success: true, ...result });