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 },
|
price: { type: Number, required: false },
|
||||||
currency: { type: String, required: false },
|
currency: { type: String, required: false },
|
||||||
lastSyncedAt: { type: Date, required: false },
|
lastSyncedAt: { type: Date, required: false },
|
||||||
syncHash: { type: String, required: false },
|
|
||||||
syncImageHash: { type: String, required: false },
|
|
||||||
marketplaceImageUrls: [{ type: String, required: false }],
|
|
||||||
stockQuantity: { type: Number, required: false, default: 0 },
|
stockQuantity: { type: Number, required: false, default: 0 },
|
||||||
condition: {
|
condition: {
|
||||||
type: String,
|
type: String,
|
||||||
|
|||||||
@ -47,9 +47,6 @@ const listingVarientSchema = new Schema(
|
|||||||
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||||
priceWithTax: { type: Number, required: false },
|
priceWithTax: { type: Number, required: false },
|
||||||
lastSyncedAt: { type: Date, required: false },
|
lastSyncedAt: { type: Date, required: false },
|
||||||
syncHash: { type: String, required: false },
|
|
||||||
syncImageHash: { type: String, required: false },
|
|
||||||
marketplaceImageUrls: [{ type: String, required: false }],
|
|
||||||
listingImages: [{ type: Schema.Types.ObjectId, ref: 'file', required: false }],
|
listingImages: [{ type: Schema.Types.ObjectId, ref: 'file', required: false }],
|
||||||
stockQuantity: { type: Number, required: false, default: 0 },
|
stockQuantity: { type: Number, required: false, default: 0 },
|
||||||
},
|
},
|
||||||
|
|||||||
@ -9,7 +9,6 @@ export function marketplaceSyncMappingSchema() {
|
|||||||
{
|
{
|
||||||
marketplace: { type: Schema.Types.ObjectId, ref: 'marketplace', required: true },
|
marketplace: { type: Schema.Types.ObjectId, ref: 'marketplace', required: true },
|
||||||
externalReference: { type: String, required: false },
|
externalReference: { type: String, required: false },
|
||||||
syncHash: { type: String, required: false },
|
|
||||||
state: {
|
state: {
|
||||||
type: {
|
type: {
|
||||||
type: String,
|
type: String,
|
||||||
|
|||||||
@ -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) {
|
export function pushMarketplaceShipmentFulfillment(marketplace, user, shipment) {
|
||||||
return sendJob('pushShipmentFulfillment', {
|
return sendJob('pushShipmentFulfillment', {
|
||||||
...marketplacePayload(marketplace),
|
...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),
|
distributeUpdate: jest.fn().mockResolvedValue(undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const {
|
const { upsertLocalPolicyFromRemote, resolveListingPolicy, persistMarketplaceMapping, idOf, updateAccountPolicy } =
|
||||||
upsertLocalPolicyFromRemote,
|
await import('../accountPolicies.js');
|
||||||
resolveListingPolicy,
|
|
||||||
persistMarketplaceMapping,
|
|
||||||
idOf,
|
|
||||||
updateAccountPolicy,
|
|
||||||
shouldSkipMappedPolicySync,
|
|
||||||
} = await import('../accountPolicies.js');
|
|
||||||
const { paymentPolicyFingerprint, ensurePaymentPolicySynced } = await import('../paymentPolicies.js');
|
|
||||||
const { hashSyncPayload } = await import('../../syncFingerprint.js');
|
|
||||||
const { makeRequest } = await import('../shared.js');
|
const { makeRequest } = await import('../shared.js');
|
||||||
const { buildPaymentPolicy } = await import('../paymentPolicies.js');
|
const { buildPaymentPolicy } = await import('../paymentPolicies.js');
|
||||||
const { buildReturnPolicy } = await import('../returnPolicies.js');
|
const { buildReturnPolicy } = await import('../returnPolicies.js');
|
||||||
@ -246,87 +238,6 @@ describe('persistMarketplaceMapping', () => {
|
|||||||
expect(mappings[0].state.type).toBe('syncing');
|
expect(mappings[0].state.type).toBe('syncing');
|
||||||
expect(idOf(mappings[0].marketplace)).toBe('507f1f77bcf86cd799439011');
|
expect(idOf(mappings[0].marketplace)).toBe('507f1f77bcf86cd799439011');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('persists a syncHash when provided', async () => {
|
|
||||||
const existing = {
|
|
||||||
_id: 'ppl-1',
|
|
||||||
marketplaces: [{ _id: 'map-1', marketplace: 'mp1', externalReference: 'pay-1' }],
|
|
||||||
};
|
|
||||||
const model = createModelMock({ existing });
|
|
||||||
await persistMarketplaceMapping(model, existing, marketplace, {
|
|
||||||
stateType: 'ready',
|
|
||||||
syncHash: 'abc123',
|
|
||||||
});
|
|
||||||
expect(model.updateOne.mock.calls[0][1].$set.marketplaces[0].syncHash).toBe('abc123');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('mapped policy sync skip', () => {
|
|
||||||
it('skips when the stored mapping hash matches the fingerprint', () => {
|
|
||||||
const fingerprint = paymentPolicyFingerprint(
|
|
||||||
{ name: 'Immediate Pay', immediatePay: true },
|
|
||||||
marketplace
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
shouldSkipMappedPolicySync(
|
|
||||||
{
|
|
||||||
marketplaces: [
|
|
||||||
{
|
|
||||||
marketplace: marketplace._id,
|
|
||||||
externalReference: 'pay-1',
|
|
||||||
syncHash: hashSyncPayload(fingerprint),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
marketplace,
|
|
||||||
fingerprint
|
|
||||||
)
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not skip when the fingerprint changes', () => {
|
|
||||||
const fingerprint = paymentPolicyFingerprint(
|
|
||||||
{ name: 'Immediate Pay', immediatePay: true },
|
|
||||||
marketplace
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
shouldSkipMappedPolicySync(
|
|
||||||
{
|
|
||||||
marketplaces: [
|
|
||||||
{
|
|
||||||
marketplace: marketplace._id,
|
|
||||||
externalReference: 'pay-1',
|
|
||||||
syncHash: hashSyncPayload(fingerprint),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
marketplace,
|
|
||||||
{ ...fingerprint, name: 'Renamed' }
|
|
||||||
)
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not call eBay when ensurePaymentPolicySynced finds an unchanged mapping', async () => {
|
|
||||||
makeRequest.mockReset();
|
|
||||||
const policy = {
|
|
||||||
name: 'Immediate Pay',
|
|
||||||
immediatePay: true,
|
|
||||||
marketplaces: [
|
|
||||||
{
|
|
||||||
marketplace: marketplace._id,
|
|
||||||
externalReference: 'pay-1',
|
|
||||||
syncHash: hashSyncPayload(
|
|
||||||
paymentPolicyFingerprint({ name: 'Immediate Pay', immediatePay: true }, marketplace)
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(ensurePaymentPolicySynced(marketplace, policy)).resolves.toEqual({
|
|
||||||
paymentPolicyId: 'pay-1',
|
|
||||||
});
|
|
||||||
expect(makeRequest).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('updateAccountPolicy', () => {
|
describe('updateAccountPolicy', () => {
|
||||||
|
|||||||
@ -185,24 +185,4 @@ describe('attachImageUrlsToListingAndVarients', () => {
|
|||||||
expect(result.varients[0].imageUrls).toEqual(['https://i.ebayimg.com/red.jpg']);
|
expect(result.varients[0].imageUrls).toEqual(['https://i.ebayimg.com/red.jpg']);
|
||||||
expect(downloadFile).toHaveBeenCalledTimes(2);
|
expect(downloadFile).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reuses stored marketplace image URLs when the image hash matches', async () => {
|
|
||||||
const { imageFilesHash } = await import('../../syncFingerprint.js');
|
|
||||||
const files = [{ _id: 'file-1', extension: '.jpg', type: 'image/jpeg' }];
|
|
||||||
const result = await attachImageUrlsToListingAndVarients(
|
|
||||||
marketplace,
|
|
||||||
{
|
|
||||||
_reference: 'LST-1',
|
|
||||||
listingImages: files,
|
|
||||||
syncImageHash: imageFilesHash(files),
|
|
||||||
marketplaceImageUrls: ['https://i.ebayimg.com/cached.jpg'],
|
|
||||||
},
|
|
||||||
[{ _reference: 'SKU-RED' }]
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.listing.imageUrls).toEqual(['https://i.ebayimg.com/cached.jpg']);
|
|
||||||
expect(result.varients[0].imageUrls).toEqual(['https://i.ebayimg.com/cached.jpg']);
|
|
||||||
expect(downloadFile).not.toHaveBeenCalled();
|
|
||||||
expect(makeRequest).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -32,8 +32,6 @@ const {
|
|||||||
toEbayCondition,
|
toEbayCondition,
|
||||||
upsertInventoryItem,
|
upsertInventoryItem,
|
||||||
inventoryItemPutBody,
|
inventoryItemPutBody,
|
||||||
shouldSkipInventoryPut,
|
|
||||||
shouldSkipOfferPut,
|
|
||||||
} = await import('../listingVarients.js');
|
} = await import('../listingVarients.js');
|
||||||
|
|
||||||
describe('eBay varient quantity', () => {
|
describe('eBay varient quantity', () => {
|
||||||
@ -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(syncOfferAndMaybePublish).not.toHaveBeenCalled();
|
||||||
expect(result).toEqual({ listing: listingWithImages, varients: varientsWithImages });
|
expect(result).toEqual({ listing: listingWithImages, varients: varientsWithImages });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips image upload when the stored image hash is unchanged', async () => {
|
|
||||||
attachImageUrlsToListingAndVarients.mockClear();
|
|
||||||
upsertInventoryItem.mockClear();
|
|
||||||
const { imageFilesHash } = await import('../../syncFingerprint.js');
|
|
||||||
const listingImages = [{ _id: 'file-1' }];
|
|
||||||
const listing = {
|
|
||||||
_reference: 'LST-1',
|
|
||||||
title: 'Widget',
|
|
||||||
listingImages,
|
|
||||||
marketplaceImageUrls: ['https://i.ebayimg.com/cached.jpg'],
|
|
||||||
syncImageHash: imageFilesHash(listingImages),
|
|
||||||
};
|
|
||||||
const varients = [{ _reference: 'SKU-RED' }];
|
|
||||||
|
|
||||||
const result = await syncListingImages({ name: 'eBay UK' }, listing, varients);
|
|
||||||
|
|
||||||
expect(attachImageUrlsToListingAndVarients).not.toHaveBeenCalled();
|
|
||||||
expect(upsertInventoryItem).not.toHaveBeenCalled();
|
|
||||||
expect(result).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
skipped: true,
|
|
||||||
listing,
|
|
||||||
varients,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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';
|
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', () => ({
|
jest.unstable_mockModule('../shared.js', () => ({
|
||||||
makeRequest: jest.fn(),
|
makeRequest: jest.fn(),
|
||||||
getApiBaseUrl: () => 'https://api.sandbox.ebay.com',
|
getApiBaseUrl: () => 'https://api.sandbox.ebay.com',
|
||||||
@ -18,12 +11,9 @@ jest.unstable_mockModule('../shared.js', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const { makeRequest } = await import('../shared.js');
|
const { makeRequest } = await import('../shared.js');
|
||||||
const {
|
const { buildWebhookChallengeResponse, verifyNotificationSignature } = await import(
|
||||||
buildWebhookChallengeResponse,
|
'../notifications.js'
|
||||||
verifyNotificationSignature,
|
);
|
||||||
ensureWebhookSubscriptions,
|
|
||||||
resolveNotificationAlertEmail,
|
|
||||||
} = await import('../notifications.js');
|
|
||||||
|
|
||||||
describe('eBay notification challenge', () => {
|
describe('eBay notification challenge', () => {
|
||||||
it('hashes challengeCode + verificationToken + endpoint', () => {
|
it('hashes challengeCode + verificationToken + endpoint', () => {
|
||||||
@ -78,155 +68,3 @@ describe('eBay notification signature', () => {
|
|||||||
).toBe(false);
|
).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 { makeRequest, logger, getEbayMarketplaceId } from './shared.js';
|
||||||
import { hashSyncPayload } from '../syncFingerprint.js';
|
|
||||||
|
|
||||||
const SELLING_POLICY_PROGRAM = 'SELLING_POLICY_MANAGEMENT';
|
const SELLING_POLICY_PROGRAM = 'SELLING_POLICY_MANAGEMENT';
|
||||||
export const POLICY_CATEGORY_TYPE = 'ALL_EXCLUDING_MOTORS_VEHICLES';
|
export const POLICY_CATEGORY_TYPE = 'ALL_EXCLUDING_MOTORS_VEHICLES';
|
||||||
@ -34,23 +33,11 @@ export function mappingExternalReference(doc, marketplace) {
|
|||||||
return getMarketplaceMapping(doc, marketplace)?.externalReference || '';
|
return getMarketplaceMapping(doc, marketplace)?.externalReference || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mappingSyncHash(doc, marketplace) {
|
|
||||||
return getMarketplaceMapping(doc, marketplace)?.syncHash || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldSkipMappedPolicySync(doc, marketplace, fingerprint) {
|
|
||||||
const mapping = getMarketplaceMapping(doc, marketplace);
|
|
||||||
if (!mapping?.externalReference || !mapping?.syncHash || fingerprint == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return mapping.syncHash === hashSyncPayload(fingerprint);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function persistMarketplaceMapping(
|
export async function persistMarketplaceMapping(
|
||||||
model,
|
model,
|
||||||
doc,
|
doc,
|
||||||
marketplace,
|
marketplace,
|
||||||
{ externalReference, stateType, message, syncHash } = {}
|
{ externalReference, stateType, message } = {}
|
||||||
) {
|
) {
|
||||||
if (!model || !doc) return null;
|
if (!model || !doc) return null;
|
||||||
const marketplaceId = idOf(marketplace);
|
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;
|
if (index >= 0) mappings[index] = next;
|
||||||
else mappings.push(next);
|
else mappings.push(next);
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import mongoose from 'mongoose';
|
|||||||
import { courierServiceModel } from '../../../database/schemas/management/courierservice.schema.js';
|
import { courierServiceModel } from '../../../database/schemas/management/courierservice.schema.js';
|
||||||
import { fulfillmentPolicyModel } from '../../../database/schemas/sales/fulfillmentpolicy.schema.js';
|
import { fulfillmentPolicyModel } from '../../../database/schemas/sales/fulfillmentpolicy.schema.js';
|
||||||
import { makeRequest, logger } from './shared.js';
|
import { makeRequest, logger } from './shared.js';
|
||||||
import { hashSyncPayload } from '../syncFingerprint.js';
|
|
||||||
import {
|
import {
|
||||||
POLICY_CATEGORY_TYPE,
|
POLICY_CATEGORY_TYPE,
|
||||||
buildCategoryTypes as buildSharedCategoryTypes,
|
buildCategoryTypes as buildSharedCategoryTypes,
|
||||||
@ -12,7 +11,6 @@ import {
|
|||||||
isDefaultAccountPolicy,
|
isDefaultAccountPolicy,
|
||||||
mappingExternalReference,
|
mappingExternalReference,
|
||||||
persistMarketplaceMapping,
|
persistMarketplaceMapping,
|
||||||
shouldSkipMappedPolicySync,
|
|
||||||
updateAccountPolicy,
|
updateAccountPolicy,
|
||||||
upsertLocalPolicyFromRemote,
|
upsertLocalPolicyFromRemote,
|
||||||
} from './accountPolicies.js';
|
} from './accountPolicies.js';
|
||||||
@ -294,44 +292,11 @@ async function resolvePolicyCourierServices(policy) {
|
|||||||
return serviceIds.map((id) => servicesById.get(String(id))).filter(Boolean);
|
return serviceIds.map((id) => servicesById.get(String(id))).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fulfillmentPolicySyncFingerprint(policy, marketplace) {
|
|
||||||
const services = (policy?.courierServices || []).map((service) => {
|
|
||||||
if (!service || typeof service !== 'object') {
|
|
||||||
return { id: idOf(service) };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: idOf(service),
|
|
||||||
code: getCourierServiceShippingCode(service, marketplace) || '',
|
|
||||||
cost: service.cost ?? null,
|
|
||||||
shippingCurrency: service.shippingCurrency || '',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
name: policy?.name || '',
|
|
||||||
description: policy?.description || '',
|
|
||||||
handlingTime: policy?.handlingTime ?? 1,
|
|
||||||
localPickup: policy?.localPickup === true,
|
|
||||||
globalShipping: policy?.globalShipping === true,
|
|
||||||
freightShipping: policy?.freightShipping === true,
|
|
||||||
pickupDropOff: policy?.pickupDropOff === true,
|
|
||||||
courierServices: services,
|
|
||||||
marketplaceId: getEbayMarketplaceId(marketplace),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureFulfillmentPolicySynced(marketplace, policy) {
|
export async function ensureFulfillmentPolicySynced(marketplace, policy) {
|
||||||
if (!policy) {
|
if (!policy) {
|
||||||
throw new Error('A fulfillment policy is required before publishing to eBay.');
|
throw new Error('A fulfillment policy is required before publishing to eBay.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const fingerprint = fulfillmentPolicySyncFingerprint(policy, marketplace);
|
|
||||||
const syncHash = hashSyncPayload(fingerprint);
|
|
||||||
const existingId = mappingExternalReference(policy, marketplace);
|
|
||||||
if (shouldSkipMappedPolicySync(policy, marketplace, fingerprint)) {
|
|
||||||
logger.debug(`Fulfillment policy "${policy.name}" unchanged — skipping eBay sync`);
|
|
||||||
return { fulfillmentPolicyId: String(existingId) };
|
|
||||||
}
|
|
||||||
|
|
||||||
await ensureSellingPolicyManagement(marketplace);
|
await ensureSellingPolicyManagement(marketplace);
|
||||||
await persistMarketplaceMapping(fulfillmentPolicyModel, policy, marketplace, {
|
await persistMarketplaceMapping(fulfillmentPolicyModel, policy, marketplace, {
|
||||||
stateType: 'syncing',
|
stateType: 'syncing',
|
||||||
@ -401,7 +366,6 @@ export async function ensureFulfillmentPolicySynced(marketplace, policy) {
|
|||||||
await persistMarketplaceMapping(fulfillmentPolicyModel, policy, marketplace, {
|
await persistMarketplaceMapping(fulfillmentPolicyModel, policy, marketplace, {
|
||||||
externalReference: String(fulfillmentPolicyId),
|
externalReference: String(fulfillmentPolicyId),
|
||||||
stateType: 'ready',
|
stateType: 'ready',
|
||||||
syncHash,
|
|
||||||
});
|
});
|
||||||
logger.info(`Synced eBay fulfillment policy "${payload.name}" (${fulfillmentPolicyId})`);
|
logger.info(`Synced eBay fulfillment policy "${payload.name}" (${fulfillmentPolicyId})`);
|
||||||
return { fulfillmentPolicyId: String(fulfillmentPolicyId) };
|
return { fulfillmentPolicyId: String(fulfillmentPolicyId) };
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { downloadFile, BUCKETS } from '../../../database/ceph.js';
|
import { downloadFile, BUCKETS } from '../../../database/ceph.js';
|
||||||
import { canReuseMarketplaceImageUrls, imageFilesHash } from '../syncFingerprint.js';
|
|
||||||
import { getMediaApiBaseUrl, makeRequest, logger } from './shared.js';
|
import { getMediaApiBaseUrl, makeRequest, logger } from './shared.js';
|
||||||
|
|
||||||
function fileId(file) {
|
function fileId(file) {
|
||||||
@ -120,34 +119,10 @@ export async function filesToImageUrls(marketplace, files = []) {
|
|||||||
return urls;
|
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) {
|
export async function resolveListingImageUrls(marketplace, listing, varient) {
|
||||||
const files = getListingImageFiles(listing, varient);
|
const files = getListingImageFiles(listing, varient);
|
||||||
const owner = varient?.listingImages?.length ? varient : listing;
|
|
||||||
if (files.length) {
|
if (files.length) {
|
||||||
return resolveImageUrlsForOwner(marketplace, owner, files);
|
return filesToImageUrls(marketplace, files);
|
||||||
}
|
}
|
||||||
if (varient?.imageUrls?.length) return varient.imageUrls;
|
if (varient?.imageUrls?.length) return varient.imageUrls;
|
||||||
if (listing?.imageUrls?.length) return listing.imageUrls;
|
if (listing?.imageUrls?.length) return listing.imageUrls;
|
||||||
@ -155,21 +130,18 @@ export async function resolveListingImageUrls(marketplace, listing, varient) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function attachImageUrlsToListingAndVarients(marketplace, listing, varients = []) {
|
export async function attachImageUrlsToListingAndVarients(marketplace, listing, varients = []) {
|
||||||
const listingFiles = listing?.listingImages || [];
|
const listingImageUrls = await resolveListingImageUrls(marketplace, listing);
|
||||||
const listingImageUrls = await resolveImageUrlsForOwner(marketplace, listing, listingFiles);
|
const listingWithUrls = listingImageUrls.length
|
||||||
const listingWithUrls = withImageSyncFields(listing, listingFiles, listingImageUrls);
|
? { ...listing, imageUrls: listingImageUrls }
|
||||||
|
: listing;
|
||||||
|
|
||||||
const varientsWithUrls = [];
|
const varientsWithUrls = [];
|
||||||
for (const varient of varients) {
|
for (const varient of varients) {
|
||||||
if (varient?.listingImages?.length) {
|
if (varient?.listingImages?.length) {
|
||||||
const urls = await resolveImageUrlsForOwner(marketplace, varient, varient.listingImages);
|
const urls = await filesToImageUrls(marketplace, varient.listingImages);
|
||||||
varientsWithUrls.push(withImageSyncFields(varient, varient.listingImages, urls));
|
varientsWithUrls.push(urls.length ? { ...varient, imageUrls: urls } : varient);
|
||||||
} else if (listingImageUrls.length) {
|
} else if (listingImageUrls.length) {
|
||||||
varientsWithUrls.push({
|
varientsWithUrls.push({ ...varient, imageUrls: listingImageUrls });
|
||||||
...varient,
|
|
||||||
imageUrls: listingImageUrls,
|
|
||||||
marketplaceImageUrls: listingImageUrls,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
varientsWithUrls.push(varient);
|
varientsWithUrls.push(varient);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import { syncListingPolicies } from './listingPolicies.js';
|
|||||||
import { makeRequest, logger } from './shared.js';
|
import { makeRequest, logger } from './shared.js';
|
||||||
import { getEbayItemUrl, parseEbayItemId } from './itemUrl.js';
|
import { getEbayItemUrl, parseEbayItemId } from './itemUrl.js';
|
||||||
import { marketplaceSku } from '../ids.js';
|
import { marketplaceSku } from '../ids.js';
|
||||||
import { payloadsEqual } from '../syncFingerprint.js';
|
|
||||||
import { fromEbayProductAspects, toEbayProductAspects } from './variationAspects.js';
|
import { fromEbayProductAspects, toEbayProductAspects } from './variationAspects.js';
|
||||||
import { toEbayHtmlDescription, toEbayPlainDescription } from './description.js';
|
import { toEbayHtmlDescription, toEbayPlainDescription } from './description.js';
|
||||||
|
|
||||||
@ -336,59 +335,6 @@ export function resolveVarientQuantity(varient) {
|
|||||||
return Math.max(0, Number(varient?.stockQuantity) || 0);
|
return Math.max(0, Number(varient?.stockQuantity) || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function comparableInventoryItem(item) {
|
|
||||||
if (!item) return null;
|
|
||||||
const shipTo = item.availability?.shipToLocationAvailability || {};
|
|
||||||
const availability = {
|
|
||||||
shipToLocationAvailability: {
|
|
||||||
quantity: shipTo.quantity,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
if (Array.isArray(shipTo.availabilityDistributions) && shipTo.availabilityDistributions.length) {
|
|
||||||
availability.shipToLocationAvailability.availabilityDistributions =
|
|
||||||
shipTo.availabilityDistributions;
|
|
||||||
}
|
|
||||||
if (item.availability?.pickupAtLocationAvailability?.length) {
|
|
||||||
availability.pickupAtLocationAvailability = item.availability.pickupAtLocationAvailability;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
product: item.product || {},
|
|
||||||
availability,
|
|
||||||
condition: item.condition,
|
|
||||||
...(item.conditionDescription ? { conditionDescription: item.conditionDescription } : {}),
|
|
||||||
...(item.conditionDescriptors?.length
|
|
||||||
? { conditionDescriptors: item.conditionDescriptors }
|
|
||||||
: {}),
|
|
||||||
...(item.packageWeightAndSize ? { packageWeightAndSize: item.packageWeightAndSize } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldSkipInventoryPut(existing, next) {
|
|
||||||
return Boolean(
|
|
||||||
existing && next && payloadsEqual(comparableInventoryItem(existing), comparableInventoryItem(next))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function comparableOffer(offer) {
|
|
||||||
if (!offer) return null;
|
|
||||||
return {
|
|
||||||
merchantLocationKey: offer.merchantLocationKey || null,
|
|
||||||
listingDescription: offer.listingDescription || '',
|
|
||||||
availableQuantity: offer.availableQuantity,
|
|
||||||
pricingSummary: offer.pricingSummary || null,
|
|
||||||
categoryId: offer.categoryId != null ? String(offer.categoryId) : null,
|
|
||||||
listingPolicies: offer.listingPolicies || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldSkipOfferPut(existingOffer, nextBody) {
|
|
||||||
return Boolean(
|
|
||||||
existingOffer &&
|
|
||||||
nextBody &&
|
|
||||||
payloadsEqual(comparableOffer(existingOffer), comparableOffer(nextBody))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function upsertInventoryItem(marketplace, varient, listing) {
|
export async function upsertInventoryItem(marketplace, varient, listing) {
|
||||||
const sku = marketplaceSku(varient);
|
const sku = marketplaceSku(varient);
|
||||||
if (!sku) throw new Error('SKU is required to upsert an eBay inventory item');
|
if (!sku) throw new Error('SKU is required to upsert an eBay inventory item');
|
||||||
@ -401,10 +347,6 @@ export async function upsertInventoryItem(marketplace, varient, listing) {
|
|||||||
const inventoryItem = existing
|
const inventoryItem = existing
|
||||||
? inventoryItemPutBody(existing, listing, sku, varient, quantity)
|
? inventoryItemPutBody(existing, listing, sku, varient, quantity)
|
||||||
: mapVarientToInventoryItem(varient, listing, quantity);
|
: mapVarientToInventoryItem(varient, listing, quantity);
|
||||||
if (shouldSkipInventoryPut(existing, inventoryItem)) {
|
|
||||||
logger.debug(`Inventory item ${sku} unchanged — skipping eBay PUT`);
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
const result = await makeRequest({
|
const result = await makeRequest({
|
||||||
marketplace,
|
marketplace,
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@ -413,7 +355,6 @@ export async function upsertInventoryItem(marketplace, varient, listing) {
|
|||||||
});
|
});
|
||||||
logger.debug('inventoryItem', inventoryItem);
|
logger.debug('inventoryItem', inventoryItem);
|
||||||
logger.debug('result', result);
|
logger.debug('result', result);
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveOfferListingId(offer) {
|
export function resolveOfferListingId(offer) {
|
||||||
@ -503,12 +444,6 @@ async function upsertOrCreateOffer(marketplace, varient, listing) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const body = { ...existingOffer, ...offerUpdate };
|
const body = { ...existingOffer, ...offerUpdate };
|
||||||
if (shouldSkipOfferPut(existingOffer, body)) {
|
|
||||||
logger.debug(
|
|
||||||
`Offer ${existingOffer.offerId} unchanged — skipping eBay PUT`
|
|
||||||
);
|
|
||||||
return existingOffer;
|
|
||||||
}
|
|
||||||
await makeRequest({
|
await makeRequest({
|
||||||
marketplace,
|
marketplace,
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
@ -16,11 +16,6 @@ import { marketplaceSku } from '../ids.js';
|
|||||||
import { buildGroupVariesBy } from './variationAspects.js';
|
import { buildGroupVariesBy } from './variationAspects.js';
|
||||||
import { toEbayHtmlDescription } from './description.js';
|
import { toEbayHtmlDescription } from './description.js';
|
||||||
import { attachImageUrlsToListingAndVarients } from './images.js';
|
import { attachImageUrlsToListingAndVarients } from './images.js';
|
||||||
import {
|
|
||||||
listingImageSyncHash,
|
|
||||||
listingImagesUnchanged,
|
|
||||||
payloadsEqual,
|
|
||||||
} from '../syncFingerprint.js';
|
|
||||||
|
|
||||||
function sleep(ms) {
|
function sleep(ms) {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
@ -56,26 +51,9 @@ export function buildInventoryItemGroupBody(listing, varients) {
|
|||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
function comparableInventoryGroup(group) {
|
|
||||||
if (!group) return null;
|
|
||||||
return {
|
|
||||||
title: group.title || '',
|
|
||||||
variantSKUs: group.variantSKUs || [],
|
|
||||||
description: group.description || '',
|
|
||||||
variesBy: group.variesBy || null,
|
|
||||||
aspects: group.aspects || null,
|
|
||||||
imageUrls: group.imageUrls || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createOrReplaceGroup(marketplace, listing, varients) {
|
async function createOrReplaceGroup(marketplace, listing, varients) {
|
||||||
const groupKey = listing._reference;
|
const groupKey = listing._reference;
|
||||||
const body = buildInventoryItemGroupBody(listing, varients);
|
const body = buildInventoryItemGroupBody(listing, varients);
|
||||||
const existing = await safeFetchInventoryItemGroup(marketplace, groupKey);
|
|
||||||
if (existing && payloadsEqual(comparableInventoryGroup(existing), comparableInventoryGroup(body))) {
|
|
||||||
logger.debug(`Inventory item group "${groupKey}" unchanged — skipping eBay PUT`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await makeRequest({
|
await makeRequest({
|
||||||
marketplace,
|
marketplace,
|
||||||
@ -162,13 +140,6 @@ async function syncListing(marketplace, listing, varients, actionLabel) {
|
|||||||
...listingPolicies,
|
...listingPolicies,
|
||||||
};
|
};
|
||||||
|
|
||||||
const imageState = imageSyncState(
|
|
||||||
listing,
|
|
||||||
validVarients,
|
|
||||||
listingWithImages,
|
|
||||||
varientsWithImages
|
|
||||||
);
|
|
||||||
|
|
||||||
if (varientsWithImages.length === 1) {
|
if (varientsWithImages.length === 1) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`Syncing standalone eBay inventory item "${varientsWithImages[0]._reference}" for listing "${ref}"`
|
`Syncing standalone eBay inventory item "${varientsWithImages[0]._reference}" for listing "${ref}"`
|
||||||
@ -178,37 +149,20 @@ async function syncListing(marketplace, listing, varients, actionLabel) {
|
|||||||
listingWithContext,
|
listingWithContext,
|
||||||
varientsWithImages[0]
|
varientsWithImages[0]
|
||||||
);
|
);
|
||||||
return listingSyncResult(published, marketplace, imageState);
|
return listingSyncResult(published, marketplace);
|
||||||
}
|
}
|
||||||
|
|
||||||
const published = await syncGroupedListing(marketplace, listingWithContext, varientsWithImages);
|
const published = await syncGroupedListing(marketplace, listingWithContext, varientsWithImages);
|
||||||
return listingSyncResult(published, marketplace, imageState);
|
return listingSyncResult(published, marketplace);
|
||||||
}
|
}
|
||||||
|
|
||||||
function listingSyncResult(published, marketplace, imageState = {}) {
|
function listingSyncResult(published, marketplace) {
|
||||||
if (!published) {
|
if (!published) return { url: '' };
|
||||||
return { url: '', ...imageState };
|
if (typeof published === 'string') return { url: published };
|
||||||
}
|
|
||||||
if (typeof published === 'string') {
|
|
||||||
return { url: published, ...imageState };
|
|
||||||
}
|
|
||||||
const listingId = parseEbayItemId(published.listingId || published.externalReference);
|
const listingId = parseEbayItemId(published.listingId || published.externalReference);
|
||||||
return {
|
return {
|
||||||
url: published.url || getEbayItemUrl(marketplace, listingId),
|
url: published.url || getEbayItemUrl(marketplace, listingId),
|
||||||
...(listingId ? { externalReference: listingId } : {}),
|
...(listingId ? { externalReference: listingId } : {}),
|
||||||
...imageState,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function imageSyncState(listing, varients, listingWithImages, varientsWithImages) {
|
|
||||||
return {
|
|
||||||
syncImageHash: listingImageSyncHash(listing, varients),
|
|
||||||
marketplaceImageUrls: listingWithImages?.imageUrls || listingWithImages?.marketplaceImageUrls || [],
|
|
||||||
varients: (varientsWithImages || []).map((varient) => ({
|
|
||||||
_id: varient._id,
|
|
||||||
marketplaceImageUrls: varient.imageUrls || varient.marketplaceImageUrls || [],
|
|
||||||
syncImageHash: varient.syncImageHash,
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -224,13 +178,6 @@ export async function syncListingImages(marketplace, listing, varients = []) {
|
|||||||
const validVarients = (varients || []).filter((varient) => varient?._reference);
|
const validVarients = (varients || []).filter((varient) => varient?._reference);
|
||||||
if (validVarients.length === 0) return null;
|
if (validVarients.length === 0) return null;
|
||||||
|
|
||||||
if (listingImagesUnchanged(listing, validVarients) && listing.marketplaceImageUrls?.length) {
|
|
||||||
logger.debug(
|
|
||||||
`Listing "${listing._reference}" images unchanged — skipping eBay image sync`
|
|
||||||
);
|
|
||||||
return { listing, varients: validVarients, skipped: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const { listing: listingWithImages, varients: varientsWithImages } =
|
const { listing: listingWithImages, varients: varientsWithImages } =
|
||||||
await attachImageUrlsToListingAndVarients(marketplace, listing, validVarients);
|
await attachImageUrlsToListingAndVarients(marketplace, listing, validVarients);
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,9 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import config from '../../../config.js';
|
|
||||||
import { getApiBaseUrl, getBasicAuthHeader, makeRequest, logger, formatDebugPayload, getMarketplaceDebugContext } from './shared.js';
|
import { getApiBaseUrl, getBasicAuthHeader, makeRequest, logger, formatDebugPayload, getMarketplaceDebugContext } from './shared.js';
|
||||||
import { buildMarketplaceWebhookUrl } from '../webhookConfig.js';
|
|
||||||
|
|
||||||
const NOTIFICATION_BASE = '/commerce/notification/v1';
|
const NOTIFICATION_BASE = '/commerce/notification/v1';
|
||||||
const DEFAULT_APP_SCOPE = 'https://api.ebay.com/oauth/api_scope';
|
|
||||||
const APPLICATION_SCOPE =
|
const APPLICATION_SCOPE =
|
||||||
'https://api.ebay.com/oauth/api_scope/commerce.notification.subscription';
|
'https://api.ebay.com/oauth/api_scope/commerce.notification.subscription';
|
||||||
const applicationTokenCache = new Map();
|
|
||||||
|
|
||||||
const HANDLED_TOPIC_MATCHERS = [
|
const HANDLED_TOPIC_MATCHERS = [
|
||||||
'MARKETPLACE_ACCOUNT_DELETION',
|
'MARKETPLACE_ACCOUNT_DELETION',
|
||||||
@ -26,7 +22,7 @@ function getVerificationToken(marketplace) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getWebhookEndpoint(marketplace) {
|
function getWebhookEndpoint(marketplace) {
|
||||||
return marketplace.config?.webhookUrl || buildMarketplaceWebhookUrl(marketplace) || '';
|
return marketplace.config?.webhookUrl || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildWebhookChallengeResponse(marketplace, { challengeCode, endpoint } = {}) {
|
export function buildWebhookChallengeResponse(marketplace, { challengeCode, endpoint } = {}) {
|
||||||
@ -46,36 +42,8 @@ export function buildWebhookChallengeResponse(marketplace, { challengeCode, endp
|
|||||||
return { challengeResponse };
|
return { challengeResponse };
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPlainMarketplace(marketplace) {
|
async function getApplicationToken(marketplace) {
|
||||||
if (!marketplace) {
|
logger.debug('eBay application token request', getMarketplaceDebugContext(marketplace));
|
||||||
return {};
|
|
||||||
}
|
|
||||||
if (typeof marketplace.toObject === 'function') {
|
|
||||||
return marketplace.toObject();
|
|
||||||
}
|
|
||||||
if (typeof marketplace.toJSON === 'function') {
|
|
||||||
return marketplace.toJSON();
|
|
||||||
}
|
|
||||||
return { ...marketplace };
|
|
||||||
}
|
|
||||||
|
|
||||||
function applicationTokenCacheKey(marketplace, scope) {
|
|
||||||
const clientId = marketplace?.config?.clientId || '';
|
|
||||||
const sandbox = marketplace?.config?.sandbox ? 'sandbox' : 'prod';
|
|
||||||
return `${sandbox}:${clientId}:${scope}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getApplicationToken(marketplace, scope = APPLICATION_SCOPE) {
|
|
||||||
const cacheKey = applicationTokenCacheKey(marketplace, scope);
|
|
||||||
const cached = applicationTokenCache.get(cacheKey);
|
|
||||||
if (cached && cached.expiresAt > Date.now() + 30_000) {
|
|
||||||
return cached.accessToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('eBay application token request', {
|
|
||||||
...getMarketplaceDebugContext(marketplace),
|
|
||||||
scope,
|
|
||||||
});
|
|
||||||
|
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const response = await fetch(`${getApiBaseUrl(marketplace)}/identity/v1/oauth2/token`, {
|
const response = await fetch(`${getApiBaseUrl(marketplace)}/identity/v1/oauth2/token`, {
|
||||||
@ -86,7 +54,7 @@ async function getApplicationToken(marketplace, scope = APPLICATION_SCOPE) {
|
|||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
grant_type: 'client_credentials',
|
grant_type: 'client_credentials',
|
||||||
scope,
|
scope: APPLICATION_SCOPE,
|
||||||
}).toString(),
|
}).toString(),
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@ -96,7 +64,6 @@ async function getApplicationToken(marketplace, scope = APPLICATION_SCOPE) {
|
|||||||
status: response.status,
|
status: response.status,
|
||||||
durationMs,
|
durationMs,
|
||||||
...getMarketplaceDebugContext(marketplace),
|
...getMarketplaceDebugContext(marketplace),
|
||||||
scope,
|
|
||||||
response: formatDebugPayload(data),
|
response: formatDebugPayload(data),
|
||||||
});
|
});
|
||||||
throw new Error(data.error_description || data.error || 'Failed to mint eBay application token');
|
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)`, {
|
logger.debug(`eBay application token request succeeded (${durationMs}ms)`, {
|
||||||
...getMarketplaceDebugContext(marketplace),
|
...getMarketplaceDebugContext(marketplace),
|
||||||
scope,
|
|
||||||
expiresIn: data.expires_in,
|
expiresIn: data.expires_in,
|
||||||
tokenType: data.token_type,
|
tokenType: data.token_type,
|
||||||
});
|
});
|
||||||
applicationTokenCache.set(cacheKey, {
|
|
||||||
accessToken: data.access_token,
|
|
||||||
expiresAt: Date.now() + Number(data.expires_in || 7200) * 1000,
|
|
||||||
});
|
|
||||||
return data.access_token;
|
return data.access_token;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function notificationRequest(
|
async function notificationRequest(marketplace, { method = 'GET', path, body, useApplicationToken = false }) {
|
||||||
marketplace,
|
if (!useApplicationToken) {
|
||||||
{ method = 'GET', path, body, useApplicationToken = false, applicationScope } = {}
|
return makeRequest({ marketplace, method, path, body, acceptableStatuses: [404] });
|
||||||
) {
|
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return makeRequest({
|
const token = await getApplicationToken(marketplace);
|
||||||
marketplace: requestMarketplace,
|
const url = `${getApiBaseUrl(marketplace)}${path}`;
|
||||||
method,
|
const startedAt = Date.now();
|
||||||
path,
|
logger.debug(`eBay notification API ${method} ${path}`, {
|
||||||
body,
|
...getMarketplaceDebugContext(marketplace),
|
||||||
acceptableStatuses: [404],
|
useApplicationToken: true,
|
||||||
marketplaceHeaders: false,
|
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 = '') {
|
function topicIsHandled(topicId = '') {
|
||||||
@ -148,108 +132,29 @@ function topicIsHandled(topicId = '') {
|
|||||||
return HANDLED_TOPIC_MATCHERS.some((matcher) => id.includes(matcher));
|
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) {
|
export async function ensureWebhookSubscriptions(marketplace) {
|
||||||
const endpoint = getWebhookEndpoint(marketplace);
|
const endpoint = getWebhookEndpoint(marketplace);
|
||||||
const verificationToken = getVerificationToken(marketplace);
|
const verificationToken = getVerificationToken(marketplace);
|
||||||
if (!endpoint || !verificationToken) {
|
if (!endpoint || !verificationToken) {
|
||||||
throw new Error(
|
logger.warn(
|
||||||
'Marketplace webhook URL and verification token are required to subscribe to eBay notifications'
|
`Skipping eBay webhook subscription for "${marketplace.name}" (missing webhookUrl or verificationToken)`
|
||||||
);
|
);
|
||||||
|
return { skipped: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureNotificationConfig(marketplace);
|
|
||||||
|
|
||||||
const destinations = await notificationRequest(marketplace, {
|
const destinations = await notificationRequest(marketplace, {
|
||||||
path: `${NOTIFICATION_BASE}/destination`,
|
path: `${NOTIFICATION_BASE}/destination`,
|
||||||
});
|
});
|
||||||
let destination = findDestinationByEndpoint(destinations, endpoint);
|
let destination = (destinations?.destinations || []).find(
|
||||||
|
(item) => item.deliveryConfig?.endpoint === endpoint
|
||||||
|
);
|
||||||
|
|
||||||
if (!destination) {
|
if (!destination) {
|
||||||
try {
|
|
||||||
destination = await notificationRequest(marketplace, {
|
destination = await notificationRequest(marketplace, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: `${NOTIFICATION_BASE}/destination`,
|
path: `${NOTIFICATION_BASE}/destination`,
|
||||||
body: {
|
body: {
|
||||||
name: `FarmControl ${marketplace.name || ''}`.replace(/\s+/g, ' ').trim().slice(0, 64),
|
name: `FarmControl ${marketplace.name}`.slice(0, 200),
|
||||||
status: 'ENABLED',
|
status: 'ENABLED',
|
||||||
deliveryConfig: {
|
deliveryConfig: {
|
||||||
endpoint,
|
endpoint,
|
||||||
@ -257,18 +162,6 @@ export async function ensureWebhookSubscriptions(marketplace) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} 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;
|
const destinationId = destination?.destinationId || destination?.id;
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { paymentPolicyModel } from '../../../database/schemas/finance/paymentpolicy.schema.js';
|
import { paymentPolicyModel } from '../../../database/schemas/finance/paymentpolicy.schema.js';
|
||||||
import { hashSyncPayload } from '../syncFingerprint.js';
|
|
||||||
import { makeRequest, logger } from './shared.js';
|
import { makeRequest, logger } from './shared.js';
|
||||||
import {
|
import {
|
||||||
buildCategoryTypes,
|
buildCategoryTypes,
|
||||||
@ -7,23 +6,10 @@ import {
|
|||||||
getEbayMarketplaceId,
|
getEbayMarketplaceId,
|
||||||
mappingExternalReference,
|
mappingExternalReference,
|
||||||
persistMarketplaceMapping,
|
persistMarketplaceMapping,
|
||||||
shouldSkipMappedPolicySync,
|
|
||||||
updateAccountPolicy,
|
updateAccountPolicy,
|
||||||
upsertLocalPolicyFromRemote,
|
upsertLocalPolicyFromRemote,
|
||||||
} from './accountPolicies.js';
|
} from './accountPolicies.js';
|
||||||
|
|
||||||
export function paymentPolicyFingerprint(policy, marketplace) {
|
|
||||||
return {
|
|
||||||
name: String(policy?.name || '').slice(0, 64),
|
|
||||||
description: policy?.description ? String(policy.description).slice(0, 250) : '',
|
|
||||||
paymentInstructions: policy?.paymentInstructions
|
|
||||||
? String(policy.paymentInstructions).slice(0, 1000)
|
|
||||||
: '',
|
|
||||||
immediatePay: policy?.immediatePay !== false,
|
|
||||||
marketplaceId: getEbayMarketplaceId(marketplace),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildPaymentPolicy(policy, marketplace, existingPolicy, allPolicies = []) {
|
export function buildPaymentPolicy(policy, marketplace, existingPolicy, allPolicies = []) {
|
||||||
const payload = {
|
const payload = {
|
||||||
name: String(policy?.name || '').slice(0, 64),
|
name: String(policy?.name || '').slice(0, 64),
|
||||||
@ -65,14 +51,6 @@ export async function ensurePaymentPolicySynced(marketplace, policy) {
|
|||||||
throw new Error('A payment policy is required before publishing to eBay.');
|
throw new Error('A payment policy is required before publishing to eBay.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const fingerprint = paymentPolicyFingerprint(policy, marketplace);
|
|
||||||
const syncHash = hashSyncPayload(fingerprint);
|
|
||||||
const existingId = mappingExternalReference(policy, marketplace);
|
|
||||||
if (shouldSkipMappedPolicySync(policy, marketplace, fingerprint)) {
|
|
||||||
logger.debug(`Payment policy "${policy.name}" unchanged — skipping eBay sync`);
|
|
||||||
return { paymentPolicyId: String(existingId) };
|
|
||||||
}
|
|
||||||
|
|
||||||
await ensureSellingPolicyManagement(marketplace);
|
await ensureSellingPolicyManagement(marketplace);
|
||||||
await persistMarketplaceMapping(paymentPolicyModel, policy, marketplace, {
|
await persistMarketplaceMapping(paymentPolicyModel, policy, marketplace, {
|
||||||
stateType: 'syncing',
|
stateType: 'syncing',
|
||||||
@ -114,7 +92,6 @@ export async function ensurePaymentPolicySynced(marketplace, policy) {
|
|||||||
await persistMarketplaceMapping(paymentPolicyModel, policy, marketplace, {
|
await persistMarketplaceMapping(paymentPolicyModel, policy, marketplace, {
|
||||||
externalReference: String(paymentPolicyId),
|
externalReference: String(paymentPolicyId),
|
||||||
stateType: 'ready',
|
stateType: 'ready',
|
||||||
syncHash,
|
|
||||||
});
|
});
|
||||||
logger.info(`Synced eBay payment policy "${payload.name}" (${paymentPolicyId})`);
|
logger.info(`Synced eBay payment policy "${payload.name}" (${paymentPolicyId})`);
|
||||||
return { paymentPolicyId: String(paymentPolicyId) };
|
return { paymentPolicyId: String(paymentPolicyId) };
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { returnPolicyModel } from '../../../database/schemas/sales/returnpolicy.schema.js';
|
import { returnPolicyModel } from '../../../database/schemas/sales/returnpolicy.schema.js';
|
||||||
import { hashSyncPayload } from '../syncFingerprint.js';
|
|
||||||
import { makeRequest, logger } from './shared.js';
|
import { makeRequest, logger } from './shared.js';
|
||||||
import {
|
import {
|
||||||
buildCategoryTypes,
|
buildCategoryTypes,
|
||||||
@ -7,7 +6,6 @@ import {
|
|||||||
getEbayMarketplaceId,
|
getEbayMarketplaceId,
|
||||||
mappingExternalReference,
|
mappingExternalReference,
|
||||||
persistMarketplaceMapping,
|
persistMarketplaceMapping,
|
||||||
shouldSkipMappedPolicySync,
|
|
||||||
updateAccountPolicy,
|
updateAccountPolicy,
|
||||||
upsertLocalPolicyFromRemote,
|
upsertLocalPolicyFromRemote,
|
||||||
} from './accountPolicies.js';
|
} 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_TO_REFUND = { MONEY_BACK: 'moneyBack', MERCHANDISE_CREDIT: 'merchandiseCredit' };
|
||||||
const EBAY_RETURN_PERIOD_DAYS = [14, 30, 60];
|
const EBAY_RETURN_PERIOD_DAYS = [14, 30, 60];
|
||||||
|
|
||||||
export function returnPolicyFingerprint(policy, marketplace) {
|
|
||||||
return {
|
|
||||||
name: String(policy?.name || '').slice(0, 64),
|
|
||||||
description: policy?.description ? String(policy.description).slice(0, 250) : '',
|
|
||||||
returnsAccepted: policy?.returnsAccepted !== false,
|
|
||||||
returnPeriodDays: policy?.returnPeriodDays ?? 30,
|
|
||||||
returnShippingCostPayer: policy?.returnShippingCostPayer || 'buyer',
|
|
||||||
refundMethod: policy?.refundMethod || 'moneyBack',
|
|
||||||
returnInstructions: policy?.returnInstructions || '',
|
|
||||||
internationalReturnsAccepted: policy?.internationalReturnsAccepted ?? null,
|
|
||||||
internationalReturnPeriodDays: policy?.internationalReturnPeriodDays ?? null,
|
|
||||||
internationalReturnShippingCostPayer: policy?.internationalReturnShippingCostPayer || '',
|
|
||||||
marketplaceId: getEbayMarketplaceId(marketplace),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function snapReturnPeriodDays(days, fallback = 30) {
|
export function snapReturnPeriodDays(days, fallback = 30) {
|
||||||
const value = Number(days);
|
const value = Number(days);
|
||||||
if (!Number.isFinite(value) || value <= 0) return fallback;
|
if (!Number.isFinite(value) || value <= 0) return fallback;
|
||||||
@ -114,14 +96,6 @@ export async function ensureReturnPolicySynced(marketplace, policy) {
|
|||||||
throw new Error('A return policy is required before publishing to eBay.');
|
throw new Error('A return policy is required before publishing to eBay.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const fingerprint = returnPolicyFingerprint(policy, marketplace);
|
|
||||||
const syncHash = hashSyncPayload(fingerprint);
|
|
||||||
const existingId = mappingExternalReference(policy, marketplace);
|
|
||||||
if (shouldSkipMappedPolicySync(policy, marketplace, fingerprint)) {
|
|
||||||
logger.debug(`Return policy "${policy.name}" unchanged — skipping eBay sync`);
|
|
||||||
return { returnPolicyId: String(existingId) };
|
|
||||||
}
|
|
||||||
|
|
||||||
await ensureSellingPolicyManagement(marketplace);
|
await ensureSellingPolicyManagement(marketplace);
|
||||||
await persistMarketplaceMapping(returnPolicyModel, policy, marketplace, {
|
await persistMarketplaceMapping(returnPolicyModel, policy, marketplace, {
|
||||||
stateType: 'syncing',
|
stateType: 'syncing',
|
||||||
@ -163,7 +137,6 @@ export async function ensureReturnPolicySynced(marketplace, policy) {
|
|||||||
await persistMarketplaceMapping(returnPolicyModel, policy, marketplace, {
|
await persistMarketplaceMapping(returnPolicyModel, policy, marketplace, {
|
||||||
externalReference: String(returnPolicyId),
|
externalReference: String(returnPolicyId),
|
||||||
stateType: 'ready',
|
stateType: 'ready',
|
||||||
syncHash,
|
|
||||||
});
|
});
|
||||||
logger.info(`Synced eBay return policy "${payload.name}" (${returnPolicyId})`);
|
logger.info(`Synced eBay return policy "${payload.name}" (${returnPolicyId})`);
|
||||||
return { returnPolicyId: String(returnPolicyId) };
|
return { returnPolicyId: String(returnPolicyId) };
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
import { taxRateModel } from '../../../database/schemas/management/taxrate.schema.js';
|
import { taxRateModel } from '../../../database/schemas/management/taxrate.schema.js';
|
||||||
import { hashSyncPayload } from '../syncFingerprint.js';
|
|
||||||
import { makeRequest, logger } from './shared.js';
|
import { makeRequest, logger } from './shared.js';
|
||||||
import {
|
import {
|
||||||
idOf,
|
idOf,
|
||||||
persistMarketplaceMapping,
|
persistMarketplaceMapping,
|
||||||
shouldSkipMappedPolicySync,
|
|
||||||
upsertLocalPolicyFromRemote,
|
upsertLocalPolicyFromRemote,
|
||||||
} from './accountPolicies.js';
|
} from './accountPolicies.js';
|
||||||
|
|
||||||
@ -58,21 +56,12 @@ export async function ensureTaxRateSynced(marketplace, taxRate) {
|
|||||||
return { skipped: true };
|
return { skipped: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const entry = buildSalesTaxEntry(taxRate);
|
|
||||||
const fingerprint = entry;
|
|
||||||
const syncHash = hashSyncPayload(fingerprint);
|
|
||||||
if (shouldSkipMappedPolicySync(taxRate, marketplace, fingerprint)) {
|
|
||||||
logger.debug(
|
|
||||||
`Sales tax ${taxExternalReference(entry.countryCode, entry.jurisdictionId)} unchanged — skipping eBay sync`
|
|
||||||
);
|
|
||||||
return { externalReference: taxExternalReference(entry.countryCode, entry.jurisdictionId) };
|
|
||||||
}
|
|
||||||
|
|
||||||
await persistMarketplaceMapping(taxRateModel, taxRate, marketplace, {
|
await persistMarketplaceMapping(taxRateModel, taxRate, marketplace, {
|
||||||
stateType: 'syncing',
|
stateType: 'syncing',
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const entry = buildSalesTaxEntry(taxRate);
|
||||||
await makeRequest({
|
await makeRequest({
|
||||||
marketplace,
|
marketplace,
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@ -86,7 +75,6 @@ export async function ensureTaxRateSynced(marketplace, taxRate) {
|
|||||||
await persistMarketplaceMapping(taxRateModel, taxRate, marketplace, {
|
await persistMarketplaceMapping(taxRateModel, taxRate, marketplace, {
|
||||||
externalReference,
|
externalReference,
|
||||||
stateType: 'ready',
|
stateType: 'ready',
|
||||||
syncHash,
|
|
||||||
});
|
});
|
||||||
logger.info(
|
logger.info(
|
||||||
`Synced eBay sales tax ${externalReference} for tax rate "${taxRate.name || taxRate._reference}"`
|
`Synced eBay sales tax ${externalReference} for tax rate "${taxRate.name || taxRate._reference}"`
|
||||||
|
|||||||
@ -153,7 +153,6 @@ export async function makeRequest({
|
|||||||
rawBody = false,
|
rawBody = false,
|
||||||
contentType,
|
contentType,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
marketplaceHeaders = true,
|
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const { accessToken } = marketplace.config || {};
|
const { accessToken } = marketplace.config || {};
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
@ -172,13 +171,11 @@ export async function makeRequest({
|
|||||||
const headers = {
|
const headers = {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
|
'Accept-Language': getAcceptLanguage(marketplace),
|
||||||
...extraHeaders,
|
...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 = {
|
const fetchOptions = {
|
||||||
method,
|
method,
|
||||||
@ -194,9 +191,7 @@ export async function makeRequest({
|
|||||||
fetchOptions.body = body;
|
fetchOptions.body = body;
|
||||||
} else {
|
} else {
|
||||||
fetchOptions.headers['Content-Type'] = 'application/json';
|
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);
|
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,
|
applyWebhookAction,
|
||||||
pushShipmentFulfillment,
|
pushShipmentFulfillment,
|
||||||
} from './marketplaceSync.js';
|
} 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');
|
const logger = log4js.getLogger('Marketplace Worker');
|
||||||
logger.level = config.server.logLevel;
|
logger.level = config.server.logLevel;
|
||||||
@ -76,16 +68,6 @@ export async function ensureMarketplaceListingInventory(marketplace, user, listi
|
|||||||
if (!fullListing) throw new Error('Listing not found');
|
if (!fullListing) throw new Error('Listing not found');
|
||||||
const listingVarients =
|
const listingVarients =
|
||||||
varients.length > 0 ? varients : listing?._id ? await fetchListingVarients(listing._id) : [];
|
varients.length > 0 ? varients : listing?._id ? await fetchListingVarients(listing._id) : [];
|
||||||
if (listingSyncUnchanged(fullListing, listingVarients)) {
|
|
||||||
logger.info(
|
|
||||||
`Listing "${fullListing._reference}" inventory unchanged — skipping eBay inventory sync`
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
skipped: true,
|
|
||||||
url: fullListing.url,
|
|
||||||
externalReference: fullListing.externalReference,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return provider.updateItem(authenticatedMarketplace, fullListing, listingVarients);
|
return provider.updateItem(authenticatedMarketplace, fullListing, listingVarients);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,12 +81,6 @@ export async function syncMarketplaceListingImages(marketplace, user, listing, v
|
|||||||
if (!fullListing) throw new Error('Listing not found');
|
if (!fullListing) throw new Error('Listing not found');
|
||||||
const listingVarients =
|
const listingVarients =
|
||||||
varients.length > 0 ? varients : listing?._id ? await fetchListingVarients(listing._id) : [];
|
varients.length > 0 ? varients : listing?._id ? await fetchListingVarients(listing._id) : [];
|
||||||
if (listingImagesUnchanged(fullListing, listingVarients)) {
|
|
||||||
logger.debug(
|
|
||||||
`Listing "${fullListing._reference}" images unchanged — skipping eBay image sync`
|
|
||||||
);
|
|
||||||
return { skipped: true, listing: fullListing, varients: listingVarients };
|
|
||||||
}
|
|
||||||
return provider.syncListingImages(authenticatedMarketplace, fullListing, listingVarients);
|
return provider.syncListingImages(authenticatedMarketplace, fullListing, listingVarients);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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) {
|
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 = {}) {
|
export async function debugMarketplaceGet(marketplace, user, path, params = {}) {
|
||||||
@ -524,76 +486,6 @@ async function fetchListingVarients(listingId) {
|
|||||||
return listingVarientModel.find({ listing: listingId }).populate('listingImages').lean();
|
return listingVarientModel.find({ listing: listingId }).populate('listingImages').lean();
|
||||||
}
|
}
|
||||||
|
|
||||||
function listingStateAfterSync(previousStateType) {
|
|
||||||
if (!previousStateType || previousStateType === 'syncing') return 'active';
|
|
||||||
if (previousStateType === 'draft') return 'draft';
|
|
||||||
return previousStateType;
|
|
||||||
}
|
|
||||||
|
|
||||||
function listingSyncUpdateData(listing, varients, result, stateType) {
|
|
||||||
const updateData = {
|
|
||||||
lastSyncedAt: new Date(),
|
|
||||||
syncHash: listingSyncHash(listing, varients),
|
|
||||||
syncImageHash: imageFilesHash(listing.listingImages),
|
|
||||||
};
|
|
||||||
if (stateType) updateData.state = { type: stateType };
|
|
||||||
if (result?.url) updateData.url = result.url;
|
|
||||||
if (result?.externalReference) updateData.externalReference = result.externalReference;
|
|
||||||
const imageUrls = result?.marketplaceImageUrls?.length
|
|
||||||
? result.marketplaceImageUrls
|
|
||||||
: listing.marketplaceImageUrls;
|
|
||||||
if (imageUrls?.length) updateData.marketplaceImageUrls = imageUrls;
|
|
||||||
return updateData;
|
|
||||||
}
|
|
||||||
|
|
||||||
function varientSyncUpdateData(varient, result, stateType) {
|
|
||||||
const fromResult = result?.varients?.find((item) => String(item._id) === String(varient._id));
|
|
||||||
const updateData = {
|
|
||||||
lastSyncedAt: new Date(),
|
|
||||||
syncHash: listingSyncHash({ title: varient._reference }, [varient]),
|
|
||||||
syncImageHash: imageFilesHash(varient.listingImages),
|
|
||||||
};
|
|
||||||
if (stateType) updateData.state = { type: stateType };
|
|
||||||
if (!varient.externalReference && marketplaceSku(varient)) {
|
|
||||||
updateData.externalReference = marketplaceSku(varient);
|
|
||||||
}
|
|
||||||
const imageUrls = fromResult?.marketplaceImageUrls?.length
|
|
||||||
? fromResult.marketplaceImageUrls
|
|
||||||
: varient.marketplaceImageUrls;
|
|
||||||
if (imageUrls?.length) updateData.marketplaceImageUrls = imageUrls;
|
|
||||||
return updateData;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function persistListingSyncMetadata({
|
|
||||||
listingId,
|
|
||||||
listing,
|
|
||||||
varients,
|
|
||||||
result,
|
|
||||||
user,
|
|
||||||
stateType,
|
|
||||||
applyVarientState = true,
|
|
||||||
}) {
|
|
||||||
await editObject({
|
|
||||||
model: listingModel,
|
|
||||||
id: listingId,
|
|
||||||
updateData: listingSyncUpdateData(listing, varients, result, stateType),
|
|
||||||
user,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const varient of varients) {
|
|
||||||
await editObject({
|
|
||||||
model: listingVarientModel,
|
|
||||||
id: varient._id,
|
|
||||||
updateData: varientSyncUpdateData(
|
|
||||||
varient,
|
|
||||||
result,
|
|
||||||
applyVarientState ? stateType : undefined
|
|
||||||
),
|
|
||||||
user,
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createListing(marketplace, user, listingData) {
|
export async function createListing(marketplace, user, listingData) {
|
||||||
const provider = getProvider(marketplace);
|
const provider = getProvider(marketplace);
|
||||||
if (!provider.createItem) {
|
if (!provider.createItem) {
|
||||||
@ -601,11 +493,6 @@ export async function createListing(marketplace, user, listingData) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullListing = listingData._id ? await fetchFullListing(listingData._id) : listingData;
|
|
||||||
if (!fullListing) throw new Error('Listing not found');
|
|
||||||
const varients = listingData._id ? await fetchListingVarients(listingData._id) : [];
|
|
||||||
const previousState = fullListing.state?.type || 'draft';
|
|
||||||
|
|
||||||
if (listingData._id) {
|
if (listingData._id) {
|
||||||
await setListingState(listingData._id, 'syncing', user).catch((err) =>
|
await setListingState(listingData._id, 'syncing', user).catch((err) =>
|
||||||
logger.warn(`Failed to set listing syncing state: ${err.message}`)
|
logger.warn(`Failed to set listing syncing state: ${err.message}`)
|
||||||
@ -614,19 +501,32 @@ export async function createListing(marketplace, user, listingData) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
|
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
|
||||||
|
const fullListing = listingData._id ? await fetchFullListing(listingData._id) : listingData;
|
||||||
|
if (!fullListing) throw new Error('Listing not found');
|
||||||
|
|
||||||
|
const varients = listingData._id ? await fetchListingVarients(listingData._id) : [];
|
||||||
|
|
||||||
logger.info(`Creating listing on marketplace "${marketplace.name}" (${marketplace.provider})`);
|
logger.info(`Creating listing on marketplace "${marketplace.name}" (${marketplace.provider})`);
|
||||||
const result = await provider.createItem(authenticatedMarketplace, fullListing, varients);
|
const result = await provider.createItem(authenticatedMarketplace, fullListing, varients);
|
||||||
|
|
||||||
if (listingData._id) {
|
if (listingData._id) {
|
||||||
await persistListingSyncMetadata({
|
const updateData = { lastSyncedAt: new Date(), state: { type: 'active' } };
|
||||||
listingId: listingData._id,
|
if (result?.url) updateData.url = result.url;
|
||||||
listing: fullListing,
|
if (result?.externalReference) updateData.externalReference = result.externalReference;
|
||||||
varients,
|
await editObject({ model: listingModel, id: listingData._id, updateData, user });
|
||||||
result,
|
|
||||||
|
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,
|
user,
|
||||||
stateType: previousState === 'active' ? 'active' : 'draft',
|
}).catch(() => {});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Background createListing complete for marketplace "${marketplace.name}"`);
|
logger.info(`Background createListing complete for marketplace "${marketplace.name}"`);
|
||||||
@ -648,18 +548,6 @@ export async function updateListing(marketplace, user, listingData) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullListing = listingData._id ? await fetchFullListing(listingData._id) : listingData;
|
|
||||||
if (!fullListing) throw new Error('Listing not found');
|
|
||||||
const varients = listingData._id ? await fetchListingVarients(listingData._id) : [];
|
|
||||||
const previousState = fullListing.state?.type || 'active';
|
|
||||||
|
|
||||||
if (listingSyncUnchanged(fullListing, varients)) {
|
|
||||||
logger.info(
|
|
||||||
`Listing "${fullListing._reference}" unchanged — skipping marketplace update`
|
|
||||||
);
|
|
||||||
return { skipped: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (listingData._id) {
|
if (listingData._id) {
|
||||||
await setListingState(listingData._id, 'syncing', user).catch((err) =>
|
await setListingState(listingData._id, 'syncing', user).catch((err) =>
|
||||||
logger.warn(`Failed to set listing syncing state: ${err.message}`)
|
logger.warn(`Failed to set listing syncing state: ${err.message}`)
|
||||||
@ -668,19 +556,37 @@ export async function updateListing(marketplace, user, listingData) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
|
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
|
||||||
|
const fullListing = listingData._id ? await fetchFullListing(listingData._id) : listingData;
|
||||||
|
if (!fullListing) throw new Error('Listing not found');
|
||||||
|
|
||||||
|
const varients = listingData._id ? await fetchListingVarients(listingData._id) : [];
|
||||||
|
|
||||||
logger.info(`Updating listing on marketplace "${marketplace.name}" (${marketplace.provider})`);
|
logger.info(`Updating listing on marketplace "${marketplace.name}" (${marketplace.provider})`);
|
||||||
const result = await provider.updateItem(authenticatedMarketplace, fullListing, varients);
|
const result = await provider.updateItem(authenticatedMarketplace, fullListing, varients);
|
||||||
|
|
||||||
if (listingData._id) {
|
if (listingData._id) {
|
||||||
await persistListingSyncMetadata({
|
const updateData = { state: { type: 'active' }, lastSyncedAt: new Date() };
|
||||||
listingId: listingData._id,
|
if (result?.url) updateData.url = result.url;
|
||||||
listing: fullListing,
|
if (result?.externalReference) updateData.externalReference = result.externalReference;
|
||||||
varients,
|
await editObject({
|
||||||
result,
|
model: listingModel,
|
||||||
|
id: listingData._id,
|
||||||
|
updateData,
|
||||||
user,
|
user,
|
||||||
stateType: listingStateAfterSync(previousState),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const varient of varients) {
|
||||||
|
const varientUpdate = { lastSyncedAt: new Date(), state: { type: 'active' } };
|
||||||
|
if (!varient.externalReference && marketplaceSku(varient)) {
|
||||||
|
varientUpdate.externalReference = marketplaceSku(varient);
|
||||||
|
}
|
||||||
|
await editObject({
|
||||||
|
model: listingVarientModel,
|
||||||
|
id: varient._id,
|
||||||
|
updateData: varientUpdate,
|
||||||
|
user,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Background updateListing complete for marketplace "${marketplace.name}"`);
|
logger.info(`Background updateListing complete for marketplace "${marketplace.name}"`);
|
||||||
@ -689,12 +595,7 @@ export async function updateListing(marketplace, user, listingData) {
|
|||||||
`Background updateListing failed for marketplace "${marketplace.name}": ${err.message}`
|
`Background updateListing failed for marketplace "${marketplace.name}": ${err.message}`
|
||||||
);
|
);
|
||||||
if (listingData._id) {
|
if (listingData._id) {
|
||||||
await setListingState(
|
await setListingState(listingData._id, 'active', user, err.message).catch(() => {});
|
||||||
listingData._id,
|
|
||||||
listingStateAfterSync(previousState),
|
|
||||||
user,
|
|
||||||
err.message
|
|
||||||
).catch(() => {});
|
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@ -762,12 +663,18 @@ export async function syncItems(marketplace, user) {
|
|||||||
'state.type': { $ne: 'deleted' },
|
'state.type': { $ne: 'deleted' },
|
||||||
})
|
})
|
||||||
.lean();
|
.lean();
|
||||||
|
for (const listing of existingListings) {
|
||||||
|
await setListingState(listing._id, 'syncing', user).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
const existingVarients = await listingVarientModel
|
const existingVarients = await listingVarientModel
|
||||||
.find({
|
.find({
|
||||||
listing: { $in: existingListings.map((l) => l._id) },
|
listing: { $in: existingListings.map((l) => l._id) },
|
||||||
})
|
})
|
||||||
.lean();
|
.lean();
|
||||||
|
for (const varient of existingVarients) {
|
||||||
|
await setListingVarientState(varient._id, 'syncing', user).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
@ -777,53 +684,48 @@ export async function syncItems(marketplace, user) {
|
|||||||
(varient) => String(varient.listing) === String(listing._id)
|
(varient) => String(varient.listing) === String(listing._id)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isUnsyncedDraftListing(listing)) {
|
|
||||||
results.push({
|
|
||||||
_reference: listing._reference,
|
|
||||||
action: 'skipped',
|
|
||||||
reason: 'draft',
|
|
||||||
id: listing._id,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (listingSyncUnchanged(listing, listingVarients)) {
|
|
||||||
results.push({
|
|
||||||
_reference: listing._reference,
|
|
||||||
action: 'skipped',
|
|
||||||
reason: 'unchanged',
|
|
||||||
id: listing._id,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!listingVarients.length) {
|
if (!listingVarients.length) {
|
||||||
throw new Error('Listing has no varients to sync');
|
throw new Error('Listing has no varients to sync');
|
||||||
}
|
}
|
||||||
|
|
||||||
await setListingState(listing._id, 'syncing', user).catch(() => {});
|
|
||||||
for (const varient of listingVarients) {
|
|
||||||
await setListingVarientState(varient._id, 'syncing', user).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await provider.updateItem(
|
const result = await provider.updateItem(
|
||||||
authenticatedMarketplace,
|
authenticatedMarketplace,
|
||||||
listing,
|
listing,
|
||||||
listingVarients
|
listingVarients
|
||||||
);
|
);
|
||||||
|
|
||||||
await persistListingSyncMetadata({
|
const listingUpdateData = {
|
||||||
listingId: listing._id,
|
lastSyncedAt: new Date(),
|
||||||
listing,
|
state: listing.state || { type: 'draft' },
|
||||||
varients: listingVarients,
|
};
|
||||||
result: {
|
if (result?.url) {
|
||||||
...result,
|
listingUpdateData.url = result.url;
|
||||||
url: result?.url || listing.url,
|
}
|
||||||
externalReference: result?.externalReference || listing.externalReference,
|
if (result?.externalReference) {
|
||||||
|
listingUpdateData.externalReference = result.externalReference;
|
||||||
|
}
|
||||||
|
|
||||||
|
await editObject({
|
||||||
|
model: listingModel,
|
||||||
|
id: listing._id,
|
||||||
|
updateData: listingUpdateData,
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
user,
|
||||||
stateType: listing.state?.type || 'draft',
|
}).catch(() => {});
|
||||||
});
|
}
|
||||||
|
|
||||||
results.push({ _reference: listing._reference, action: 'synced', id: listing._id });
|
results.push({ _reference: listing._reference, action: 'synced', id: listing._id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -1010,17 +912,18 @@ export async function publishListingOffers({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await persistListingSyncMetadata({
|
const listingUpdate = {
|
||||||
listingId,
|
updatedAt: new Date(),
|
||||||
listing,
|
state: { type: 'active' },
|
||||||
varients: allVarients,
|
lastSyncedAt: new Date(),
|
||||||
result: {
|
};
|
||||||
url: publishedUrl,
|
if (publishedListingId) listingUpdate.externalReference = publishedListingId;
|
||||||
externalReference: publishedListingId,
|
if (publishedUrl) listingUpdate.url = publishedUrl;
|
||||||
},
|
await editObject({
|
||||||
|
model: listingModel,
|
||||||
|
id: listingId,
|
||||||
|
updateData: listingUpdate,
|
||||||
user,
|
user,
|
||||||
stateType: 'active',
|
|
||||||
applyVarientState: false,
|
|
||||||
});
|
});
|
||||||
logger.info(`Background publishListing complete for listing ${listingId}`);
|
logger.info(`Background publishListing complete for listing ${listingId}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -1188,10 +1091,9 @@ export async function runJob(action, payload = {}) {
|
|||||||
const marketplace = await loadMarketplace(payload.marketplaceId);
|
const marketplace = await loadMarketplace(payload.marketplaceId);
|
||||||
return syncTaxRateOutbound(marketplace, user, payload.policyId);
|
return syncTaxRateOutbound(marketplace, user, payload.policyId);
|
||||||
}
|
}
|
||||||
case 'ensureWebhookSubscriptions':
|
case 'ensureWebhookSubscriptions': {
|
||||||
case 'syncWebhooks': {
|
|
||||||
const marketplace = await loadMarketplace(payload.marketplaceId);
|
const marketplace = await loadMarketplace(payload.marketplaceId);
|
||||||
return syncWebhooks(marketplace, user);
|
return ensureWebhookSubscriptions(marketplace, user);
|
||||||
}
|
}
|
||||||
case 'pushShipmentFulfillment': {
|
case 'pushShipmentFulfillment': {
|
||||||
const marketplace = await loadMarketplace(payload.marketplaceId);
|
const marketplace = await loadMarketplace(payload.marketplaceId);
|
||||||
|
|||||||
@ -54,7 +54,6 @@ import {
|
|||||||
syncMarketplacePaymentPoliciesRouteHandler,
|
syncMarketplacePaymentPoliciesRouteHandler,
|
||||||
syncMarketplaceReturnPoliciesRouteHandler,
|
syncMarketplaceReturnPoliciesRouteHandler,
|
||||||
syncMarketplaceTaxRatesRouteHandler,
|
syncMarketplaceTaxRatesRouteHandler,
|
||||||
syncMarketplaceWebhooksRouteHandler,
|
|
||||||
marketplaceWebhookRouteHandler,
|
marketplaceWebhookRouteHandler,
|
||||||
marketplaceWebhookChallengeRouteHandler,
|
marketplaceWebhookChallengeRouteHandler,
|
||||||
subscribeMarketplaceWebhooksRouteHandler,
|
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) => {
|
router.get('/:id/hook', async (req, res) => {
|
||||||
marketplaceWebhookChallengeRouteHandler(req, res);
|
marketplaceWebhookChallengeRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -61,10 +61,8 @@ jest.unstable_mockModule('log4js', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { publishListingRouteHandler, unpublishListingRouteHandler, newListingRouteHandler } =
|
const { publishListingRouteHandler, unpublishListingRouteHandler } = await import('../listings.js');
|
||||||
await import('../listings.js');
|
const { checkStates, editObject } = await import('../../../database/database.js');
|
||||||
const { checkStates, editObject, newObject } = await import('../../../database/database.js');
|
|
||||||
const { createListing } = await import('../../../integrations/marketplace.js');
|
|
||||||
|
|
||||||
describe('listing marketplace publish/unpublish', () => {
|
describe('listing marketplace publish/unpublish', () => {
|
||||||
let req;
|
let req;
|
||||||
@ -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);
|
return res.status(result.code).send(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newMarketplaceId = result.marketplace?._id || result.marketplace;
|
||||||
|
if (newMarketplaceId) {
|
||||||
|
pushToMarketplace(newMarketplaceId, { _id: result._id }, req.user, { isNew: true });
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug(`New listing with ID: ${result._id}`);
|
logger.debug(`New listing with ID: ${result._id}`);
|
||||||
res.send(result);
|
res.send(result);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import config from '../../config.js';
|
import config from '../../config.js';
|
||||||
import { marketplaceModel } from '../../database/schemas/sales/marketplace.schema.js';
|
import { marketplaceModel } from '../../database/schemas/sales/marketplace.schema.js';
|
||||||
import { buildMarketplaceWebhookUrl } from '../../integrations/marketplaces/webhookConfig.js';
|
|
||||||
import log4js from 'log4js';
|
import log4js from 'log4js';
|
||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
import {
|
import {
|
||||||
@ -451,38 +450,6 @@ export const syncMarketplaceOrdersRouteHandler = async (req, res) => {
|
|||||||
res.send({ success: true, message: 'Order sync started' });
|
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) => {
|
export const marketplaceWebhookRouteHandler = async (req, res) => {
|
||||||
const id = req.params.id;
|
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 challengeCode = req.query.challenge_code || req.query.challengeCode;
|
||||||
const endpoint =
|
const endpoint =
|
||||||
marketplace.config?.webhookUrl ||
|
marketplace.config?.webhookUrl ||
|
||||||
buildMarketplaceWebhookUrl(marketplace) ||
|
|
||||||
`${req.protocol}://${req.get('host')}/marketplaces/${id}/hook`;
|
`${req.protocol}://${req.get('host')}/marketplaces/${id}/hook`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -556,7 +522,7 @@ export const subscribeMarketplaceWebhooksRouteHandler = async (req, res) => {
|
|||||||
return res.status(marketplace.code).send(marketplace);
|
return res.status(marketplace.code).send(marketplace);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await marketplaceIntegration.syncWebhooks(marketplace, req.user, {
|
const result = await marketplaceIntegration.ensureWebhookSubscriptions(marketplace, req.user, {
|
||||||
wait: true,
|
wait: true,
|
||||||
});
|
});
|
||||||
res.send({ success: true, ...result });
|
res.send({ success: true, ...result });
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user