Compare commits
No commits in common. "1cef3a8d1d246c86ed4ecd329bc1d15d378ff1dc" and "37706f61c425f18867cf4508cadb441677d43e48" have entirely different histories.
1cef3a8d1d
...
37706f61c4
@ -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,
|
||||
|
||||
@ -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 },
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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),
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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({});
|
||||
});
|
||||
});
|
||||
@ -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', () => {
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) };
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) };
|
||||
|
||||
@ -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) };
|
||||
|
||||
@ -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}"`
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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' },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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 });
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user