Compare commits

..

No commits in common. "1cef3a8d1d246c86ed4ecd329bc1d15d378ff1dc" and "37706f61c425f18867cf4508cadb441677d43e48" have entirely different histories.

29 changed files with 199 additions and 1648 deletions

View File

@ -42,9 +42,6 @@ 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,9 +47,6 @@ 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,7 +9,6 @@ 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

@ -1,197 +0,0 @@
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,17 +381,6 @@ 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

@ -1,108 +0,0 @@
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

@ -1,41 +0,0 @@
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,16 +14,8 @@ jest.unstable_mockModule('../../../../utils.js', () => ({
distributeUpdate: jest.fn().mockResolvedValue(undefined),
}));
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 { upsertLocalPolicyFromRemote, resolveListingPolicy, persistMarketplaceMapping, idOf, updateAccountPolicy } =
await import('../accountPolicies.js');
const { makeRequest } = await import('../shared.js');
const { buildPaymentPolicy } = await import('../paymentPolicies.js');
const { buildReturnPolicy } = await import('../returnPolicies.js');
@ -246,87 +238,6 @@ 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,24 +185,4 @@ 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,8 +32,6 @@ const {
toEbayCondition,
upsertInventoryItem,
inventoryItemPutBody,
shouldSkipInventoryPut,
shouldSkipOfferPut,
} = await import('../listingVarients.js');
describe('eBay varient quantity', () => {
@ -503,64 +501,3 @@ 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,31 +222,4 @@ 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,13 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
import { 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',
@ -18,12 +11,9 @@ jest.unstable_mockModule('../shared.js', () => ({
}));
const { makeRequest } = await import('../shared.js');
const {
buildWebhookChallengeResponse,
verifyNotificationSignature,
ensureWebhookSubscriptions,
resolveNotificationAlertEmail,
} = await import('../notifications.js');
const { buildWebhookChallengeResponse, verifyNotificationSignature } = await import(
'../notifications.js'
);
describe('eBay notification challenge', () => {
it('hashes challengeCode + verificationToken + endpoint', () => {
@ -78,155 +68,3 @@ 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,5 +1,4 @@
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';
@ -34,23 +33,11 @@ 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, syncHash } = {}
{ externalReference, stateType, message } = {}
) {
if (!model || !doc) return null;
const marketplaceId = idOf(marketplace);
@ -75,11 +62,6 @@ 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,7 +2,6 @@ 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,
@ -12,7 +11,6 @@ import {
isDefaultAccountPolicy,
mappingExternalReference,
persistMarketplaceMapping,
shouldSkipMappedPolicySync,
updateAccountPolicy,
upsertLocalPolicyFromRemote,
} from './accountPolicies.js';
@ -294,44 +292,11 @@ 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',
@ -401,7 +366,6 @@ 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,5 +1,4 @@
import { downloadFile, BUCKETS } from '../../../database/ceph.js';
import { canReuseMarketplaceImageUrls, imageFilesHash } from '../syncFingerprint.js';
import { getMediaApiBaseUrl, makeRequest, logger } from './shared.js';
function fileId(file) {
@ -120,34 +119,10 @@ export async function filesToImageUrls(marketplace, files = []) {
return urls;
}
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);
return filesToImageUrls(marketplace, files);
}
if (varient?.imageUrls?.length) return varient.imageUrls;
if (listing?.imageUrls?.length) return listing.imageUrls;
@ -155,21 +130,18 @@ export async function resolveListingImageUrls(marketplace, listing, varient) {
}
export async function attachImageUrlsToListingAndVarients(marketplace, listing, varients = []) {
const listingFiles = listing?.listingImages || [];
const listingImageUrls = await resolveImageUrlsForOwner(marketplace, listing, listingFiles);
const listingWithUrls = withImageSyncFields(listing, listingFiles, listingImageUrls);
const listingImageUrls = await resolveListingImageUrls(marketplace, listing);
const listingWithUrls = listingImageUrls.length
? { ...listing, imageUrls: listingImageUrls }
: listing;
const varientsWithUrls = [];
for (const varient of varients) {
if (varient?.listingImages?.length) {
const urls = await resolveImageUrlsForOwner(marketplace, varient, varient.listingImages);
varientsWithUrls.push(withImageSyncFields(varient, varient.listingImages, urls));
const urls = await filesToImageUrls(marketplace, varient.listingImages);
varientsWithUrls.push(urls.length ? { ...varient, imageUrls: urls } : varient);
} else if (listingImageUrls.length) {
varientsWithUrls.push({
...varient,
imageUrls: listingImageUrls,
marketplaceImageUrls: listingImageUrls,
});
varientsWithUrls.push({ ...varient, imageUrls: listingImageUrls });
} else {
varientsWithUrls.push(varient);
}

View File

@ -6,7 +6,6 @@ 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';
@ -336,59 +335,6 @@ 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');
@ -401,10 +347,6 @@ 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',
@ -413,7 +355,6 @@ export async function upsertInventoryItem(marketplace, varient, listing) {
});
logger.debug('inventoryItem', inventoryItem);
logger.debug('result', result);
return result;
}
export function resolveOfferListingId(offer) {
@ -503,12 +444,6 @@ 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,11 +16,6 @@ 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));
@ -56,26 +51,9 @@ 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,
@ -162,13 +140,6 @@ 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}"`
@ -178,37 +149,20 @@ async function syncListing(marketplace, listing, varients, actionLabel) {
listingWithContext,
varientsWithImages[0]
);
return listingSyncResult(published, marketplace, imageState);
return listingSyncResult(published, marketplace);
}
const published = await syncGroupedListing(marketplace, listingWithContext, varientsWithImages);
return listingSyncResult(published, marketplace, imageState);
return listingSyncResult(published, marketplace);
}
function listingSyncResult(published, marketplace, imageState = {}) {
if (!published) {
return { url: '', ...imageState };
}
if (typeof published === 'string') {
return { url: published, ...imageState };
}
function listingSyncResult(published, marketplace) {
if (!published) return { url: '' };
if (typeof published === 'string') return { url: published };
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,
})),
};
}
@ -224,13 +178,6 @@ 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,13 +1,9 @@
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',
@ -26,7 +22,7 @@ function getVerificationToken(marketplace) {
}
function getWebhookEndpoint(marketplace) {
return marketplace.config?.webhookUrl || buildMarketplaceWebhookUrl(marketplace) || '';
return marketplace.config?.webhookUrl || '';
}
export function buildWebhookChallengeResponse(marketplace, { challengeCode, endpoint } = {}) {
@ -46,36 +42,8 @@ export function buildWebhookChallengeResponse(marketplace, { challengeCode, endp
return { challengeResponse };
}
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,
});
async function getApplicationToken(marketplace) {
logger.debug('eBay application token request', getMarketplaceDebugContext(marketplace));
const startedAt = Date.now();
const response = await fetch(`${getApiBaseUrl(marketplace)}/identity/v1/oauth2/token`, {
@ -86,7 +54,7 @@ async function getApplicationToken(marketplace, scope = APPLICATION_SCOPE) {
},
body: new URLSearchParams({
grant_type: 'client_credentials',
scope,
scope: APPLICATION_SCOPE,
}).toString(),
});
const data = await response.json();
@ -96,7 +64,6 @@ async function getApplicationToken(marketplace, scope = APPLICATION_SCOPE) {
status: response.status,
durationMs,
...getMarketplaceDebugContext(marketplace),
scope,
response: formatDebugPayload(data),
});
throw new Error(data.error_description || data.error || 'Failed to mint eBay application token');
@ -104,43 +71,60 @@ async function getApplicationToken(marketplace, scope = APPLICATION_SCOPE) {
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, applicationScope } = {}
) {
const plain = toPlainMarketplace(marketplace);
const requestMarketplace = {
...plain,
name: marketplace?.name || plain.name,
config: { ...(plain.config || {}) },
};
if (useApplicationToken) {
requestMarketplace.config.accessToken = await getApplicationToken(
requestMarketplace,
applicationScope || APPLICATION_SCOPE
);
async function notificationRequest(marketplace, { method = 'GET', path, body, useApplicationToken = false }) {
if (!useApplicationToken) {
return makeRequest({ marketplace, method, path, body, acceptableStatuses: [404] });
}
return makeRequest({
marketplace: requestMarketplace,
method,
path,
body,
acceptableStatuses: [404],
marketplaceHeaders: false,
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)
);
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 data;
}
function topicIsHandled(topicId = '') {
@ -148,127 +132,36 @@ 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) {
throw new Error(
'Marketplace webhook URL and verification token are required to subscribe to eBay notifications'
logger.warn(
`Skipping eBay webhook subscription for "${marketplace.name}" (missing webhookUrl or verificationToken)`
);
return { skipped: true };
}
await ensureNotificationConfig(marketplace);
const destinations = await notificationRequest(marketplace, {
path: `${NOTIFICATION_BASE}/destination`,
});
let destination = findDestinationByEndpoint(destinations, endpoint);
let destination = (destinations?.destinations || []).find(
(item) => item.deliveryConfig?.endpoint === endpoint
);
if (!destination) {
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,
},
destination = await notificationRequest(marketplace, {
method: 'POST',
path: `${NOTIFICATION_BASE}/destination`,
body: {
name: `FarmControl ${marketplace.name}`.slice(0, 200),
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,5 +1,4 @@
import { paymentPolicyModel } from '../../../database/schemas/finance/paymentpolicy.schema.js';
import { hashSyncPayload } from '../syncFingerprint.js';
import { makeRequest, logger } from './shared.js';
import {
buildCategoryTypes,
@ -7,23 +6,10 @@ 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),
@ -65,14 +51,6 @@ 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',
@ -114,7 +92,6 @@ 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,5 +1,4 @@
import { returnPolicyModel } from '../../../database/schemas/sales/returnpolicy.schema.js';
import { hashSyncPayload } from '../syncFingerprint.js';
import { makeRequest, logger } from './shared.js';
import {
buildCategoryTypes,
@ -7,7 +6,6 @@ import {
getEbayMarketplaceId,
mappingExternalReference,
persistMarketplaceMapping,
shouldSkipMappedPolicySync,
updateAccountPolicy,
upsertLocalPolicyFromRemote,
} from './accountPolicies.js';
@ -18,22 +16,6 @@ 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;
@ -114,14 +96,6 @@ 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',
@ -163,7 +137,6 @@ 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,10 +1,8 @@
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';
@ -58,21 +56,12 @@ 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',
@ -86,7 +75,6 @@ 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,7 +153,6 @@ export async function makeRequest({
rawBody = false,
contentType,
baseUrl,
marketplaceHeaders = true,
} = {}) {
const { accessToken } = marketplace.config || {};
if (!accessToken) {
@ -172,13 +171,11 @@ export async function makeRequest({
const headers = {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Accept-Language': getAcceptLanguage(marketplace),
...extraHeaders,
};
if (marketplaceHeaders) {
headers['Accept-Language'] = getAcceptLanguage(marketplace);
headers['X-EBAY-C-MARKETPLACE-ID'] = getEbayMarketplaceId(marketplace);
}
headers['X-EBAY-C-MARKETPLACE-ID'] = getEbayMarketplaceId(marketplace);
const fetchOptions = {
method,
@ -194,9 +191,7 @@ export async function makeRequest({
fetchOptions.body = body;
} else {
fetchOptions.headers['Content-Type'] = 'application/json';
if (marketplaceHeaders) {
fetchOptions.headers['Content-Language'] = getAcceptLanguage(marketplace);
}
fetchOptions.headers['Content-Language'] = getAcceptLanguage(marketplace);
fetchOptions.body = JSON.stringify(body);
}
}

View File

@ -1,141 +0,0 @@
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

@ -1,32 +0,0 @@
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,14 +23,6 @@ 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;
@ -76,16 +68,6 @@ 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);
}
@ -99,12 +81,6 @@ 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);
}
@ -402,27 +378,13 @@ export async function syncTaxRateOutbound(marketplace, user, policyId) {
});
}
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);
}
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);
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
const provider = getProvider(authenticatedMarketplace);
if (typeof provider.ensureWebhookSubscriptions !== 'function') {
return { skipped: true };
}
return provider.ensureWebhookSubscriptions(authenticatedMarketplace);
}
export async function debugMarketplaceGet(marketplace, user, path, params = {}) {
@ -524,76 +486,6 @@ 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) {
@ -601,11 +493,6 @@ 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}`)
@ -614,19 +501,32 @@ 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) {
await persistListingSyncMetadata({
listingId: listingData._id,
listing: fullListing,
varients,
result,
user,
stateType: previousState === 'active' ? 'active' : 'draft',
});
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(() => {});
}
}
logger.info(`Background createListing complete for marketplace "${marketplace.name}"`);
@ -648,18 +548,6 @@ 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}`)
@ -668,19 +556,37 @@ 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) {
await persistListingSyncMetadata({
listingId: listingData._id,
listing: fullListing,
varients,
result,
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,
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}"`);
@ -689,12 +595,7 @@ export async function updateListing(marketplace, user, listingData) {
`Background updateListing failed for marketplace "${marketplace.name}": ${err.message}`
);
if (listingData._id) {
await setListingState(
listingData._id,
listingStateAfterSync(previousState),
user,
err.message
).catch(() => {});
await setListingState(listingData._id, 'active', user, err.message).catch(() => {});
}
throw err;
}
@ -762,12 +663,18 @@ 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 = [];
@ -777,54 +684,49 @@ 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
);
await persistListingSyncMetadata({
listingId: listing._id,
listing,
varients: listingVarients,
result: {
...result,
url: result?.url || listing.url,
externalReference: result?.externalReference || listing.externalReference,
},
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,
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}`);
@ -1010,17 +912,18 @@ export async function publishListingOffers({
});
}
await persistListingSyncMetadata({
listingId,
listing,
varients: allVarients,
result: {
url: publishedUrl,
externalReference: publishedListingId,
},
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,
user,
stateType: 'active',
applyVarientState: false,
});
logger.info(`Background publishListing complete for listing ${listingId}`);
} catch (err) {
@ -1188,10 +1091,9 @@ export async function runJob(action, payload = {}) {
const marketplace = await loadMarketplace(payload.marketplaceId);
return syncTaxRateOutbound(marketplace, user, payload.policyId);
}
case 'ensureWebhookSubscriptions':
case 'syncWebhooks': {
case 'ensureWebhookSubscriptions': {
const marketplace = await loadMarketplace(payload.marketplaceId);
return syncWebhooks(marketplace, user);
return ensureWebhookSubscriptions(marketplace, user);
}
case 'pushShipmentFulfillment': {
const marketplace = await loadMarketplace(payload.marketplaceId);

View File

@ -54,7 +54,6 @@ import {
syncMarketplacePaymentPoliciesRouteHandler,
syncMarketplaceReturnPoliciesRouteHandler,
syncMarketplaceTaxRatesRouteHandler,
syncMarketplaceWebhooksRouteHandler,
marketplaceWebhookRouteHandler,
marketplaceWebhookChallengeRouteHandler,
subscribeMarketplaceWebhooksRouteHandler,
@ -223,15 +222,6 @@ 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,10 +61,8 @@ jest.unstable_mockModule('log4js', () => ({
},
}));
const { publishListingRouteHandler, unpublishListingRouteHandler, newListingRouteHandler } =
await import('../listings.js');
const { checkStates, editObject, newObject } = await import('../../../database/database.js');
const { createListing } = await import('../../../integrations/marketplace.js');
const { publishListingRouteHandler, unpublishListingRouteHandler } = await import('../listings.js');
const { checkStates, editObject } = await import('../../../database/database.js');
describe('listing marketplace publish/unpublish', () => {
let req;
@ -177,41 +175,3 @@ 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,6 +261,11 @@ 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,6 +1,5 @@
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 {
@ -451,38 +450,6 @@ 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;
@ -529,7 +496,6 @@ 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 {
@ -556,7 +522,7 @@ export const subscribeMarketplaceWebhooksRouteHandler = async (req, res) => {
return res.status(marketplace.code).send(marketplace);
}
try {
const result = await marketplaceIntegration.syncWebhooks(marketplace, req.user, {
const result = await marketplaceIntegration.ensureWebhookSubscriptions(marketplace, req.user, {
wait: true,
});
res.send({ success: true, ...result });