Add audit owner resolution and actor display name functions
Some checks failed
farmcontrol/farmcontrol-api/pipeline/head There was a failure building this commit

This commit introduces a new module, `auditOwner.js`, which includes functions for resolving audit owner details and generating display names for actors. The `resolveAuditOwner` function determines the owner type based on the actor's object type, defaulting to 'user' if not specified. The `actorDisplayName` function formats the display name based on the actor's properties, enhancing the clarity of audit logs. Additionally, the `AUDIT_OWNER_TYPES` constant is exported for use in other modules. Updates to existing files incorporate these new functions for improved audit logging and notification handling.
This commit is contained in:
Tom Butcher 2026-08-24 02:49:38 +01:00
parent 8974d1e9da
commit 5ff93be27c
50 changed files with 3017 additions and 275 deletions

22
src/auditOwner.js Normal file
View File

@ -0,0 +1,22 @@
const AUDIT_OWNER_TYPES = ['user', 'printer', 'host', 'marketplace'];
export function resolveAuditOwner(actor) {
const ownerType = AUDIT_OWNER_TYPES.includes(actor?._objectType)
? actor._objectType
: 'user';
return {
owner: actor?._id,
ownerType,
};
}
export function actorDisplayName(actor) {
if (actor?._objectType === 'marketplace' || actor?._objectType === 'host') {
return actor.name || actor._objectType;
}
const firstName = actor?.firstName ?? 'unknown';
const lastName = actor?.lastName ?? '';
return `${firstName} ${lastName}`.trim();
}
export { AUDIT_OWNER_TYPES };

View File

@ -0,0 +1,103 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import mongoose from 'mongoose';
jest.unstable_mockModule('../../database.js', () => ({
aggregateRollups: jest.fn(),
aggregateRollupsHistory: jest.fn(),
editObject: jest.fn(),
getObject: jest.fn(),
}));
jest.unstable_mockModule('../../utils.js', () => ({
generateId: jest.fn(() => () => 'test-id'),
}));
const { aggregateRollups, editObject } = await import('../../database.js');
const { listingModel } = await import('../sales/listing.schema.js');
const { listingVarientModel } = await import('../sales/listingvarient.schema.js');
const { productSkuModel } = await import('../management/productsku.schema.js');
const { productStockModel } = await import('../inventory/productstock.schema.js');
const listingId = new mongoose.Types.ObjectId();
const productId = new mongoose.Types.ObjectId();
const productSkuId = new mongoose.Types.ObjectId();
const stockLocationId = new mongoose.Types.ObjectId();
const varientId = new mongoose.Types.ObjectId();
describe('listingVarient.recalculate', () => {
beforeEach(() => {
aggregateRollups.mockReset();
editObject.mockReset();
});
it('sums sibling listing varient stock quantities onto the listing', async () => {
aggregateRollups.mockResolvedValue({ stockQuantity: { sum: 12 } });
editObject.mockResolvedValue({});
await listingVarientModel.recalculate({ listing: listingId, stockQuantity: 4 }, 'user-1');
expect(aggregateRollups).toHaveBeenCalledWith(
expect.objectContaining({
model: listingVarientModel,
baseFilter: { listing: listingId },
})
);
expect(editObject).toHaveBeenCalledWith({
model: listingModel,
id: listingId,
updateData: { stockQuantity: 12 },
user: 'user-1',
recalculate: false,
});
});
});
describe('productStock.recalculate', () => {
beforeEach(() => {
aggregateRollups.mockReset();
editObject.mockReset();
jest.restoreAllMocks();
});
it('writes the sku/location total onto matching listing varients', async () => {
aggregateRollups.mockResolvedValue({ stockQuantity: { sum: 9 } });
editObject.mockResolvedValue({});
jest.spyOn(productSkuModel, 'findById').mockReturnValue({
select: () => ({ lean: async () => ({ product: productId }) }),
});
jest.spyOn(listingVarientModel, 'find').mockReturnValue({
populate: () => ({
lean: async () => [
{
_id: varientId,
product: productId,
productSku: productSkuId,
stockQuantity: 0,
listing: { product: productId, stockLocation: stockLocationId },
},
],
}),
});
await productStockModel.recalculate(
{ productSku: productSkuId, stockLocation: stockLocationId, currentQuantity: 9 },
'user-1'
);
expect(aggregateRollups).toHaveBeenCalledWith(
expect.objectContaining({
model: productStockModel,
baseFilter: {
productSku: productSkuId,
stockLocation: stockLocationId,
},
})
);
expect(editObject).toHaveBeenCalledWith({
model: listingVarientModel,
id: varientId,
updateData: { stockQuantity: 9 },
user: 'user-1',
});
});
});

View File

@ -63,6 +63,9 @@ const orderItemSchema = new Schema(
invoicedQuantityRemaining: { type: Number, required: false, default: 0 },
timestamp: { type: Date, default: Date.now },
shipment: { type: Schema.Types.ObjectId, ref: 'shipment', required: false },
listing: { type: Schema.Types.ObjectId, ref: 'listing', required: false },
listingVarient: { type: Schema.Types.ObjectId, ref: 'listingVarient', required: false },
externalReference: { type: String, required: false },
orderedAt: { type: Date, required: false },
receivedAt: { type: Date, required: false },
},
@ -70,6 +73,7 @@ const orderItemSchema = new Schema(
);
orderItemSchema.index({ name: 'text', itemType: 'text', orderType: 'text' });
orderItemSchema.index({ order: 1, externalReference: 1 }, { unique: true, sparse: true });
const rollupConfigs = [
{
@ -132,7 +136,13 @@ orderItemSchema.statics.recalculate = async function (orderItem, user) {
cached: true,
});
if (sku) {
const parentId = sku.part?._id || sku.part || sku.product?._id || sku.product || sku.filament?._id || sku.filament;
const parentId =
sku.part?._id ||
sku.part ||
sku.product?._id ||
sku.product ||
sku.filament?._id ||
sku.filament;
if (syncAmount === 'itemCost') {
if (sku.overrideCost && sku.cost != null) {
effectiveItemAmount = sku.cost;

View File

@ -1,7 +1,7 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
const partStockUsageSchema = new Schema({
partStock: { type: Schema.Types.ObjectId, ref: 'partStock', required: false },
@ -9,6 +9,12 @@ const partStockUsageSchema = new Schema({
quantity: { type: Number, required: true },
});
const toId = (value) => {
if (value == null) return null;
if (typeof value === 'object' && value._id) return String(value._id);
return String(value);
};
// Define the main productStock schema - tracks assembled products consisting of part stocks
const productStockSchema = new Schema(
{
@ -76,6 +82,64 @@ productStockSchema.statics.history = async function (from, to) {
return results;
};
productStockSchema.statics.recalculate = async function (productStock, user) {
const productSkuId = toId(productStock?.productSku);
const stockLocationId = toId(productStock?.stockLocation);
if (!productSkuId || !stockLocationId) {
return;
}
let productId = toId(productStock?.productSku?.product);
if (!productId) {
const productSku = await mongoose.model('productSku').findById(productSkuId).select('product').lean();
productId = toId(productSku?.product);
}
if (!productId) {
return;
}
const rollupResults = await aggregateRollups({
model: this,
baseFilter: {
productSku: new mongoose.Types.ObjectId(productSkuId),
stockLocation: new mongoose.Types.ObjectId(stockLocationId),
},
rollupConfigs: [
{
name: 'stockQuantity',
rollups: [{ name: 'stockQuantity', property: 'currentQuantity', operation: 'sum' }],
},
],
});
const stockQuantity = rollupResults.stockQuantity?.sum || 0;
const listingVarientModel = mongoose.model('listingVarient');
const varients = await listingVarientModel
.find({ productSku: productSkuId })
.populate('listing')
.lean();
for (const varient of varients) {
const varientProductId =
toId(varient.product) || toId(varient.listing?.product);
const varientLocationId = toId(varient.listing?.stockLocation);
if (varientProductId !== productId || varientLocationId !== stockLocationId) {
continue;
}
if (varient.stockQuantity === stockQuantity) {
await listingVarientModel.recalculate(varient, user);
continue;
}
await editObject({
model: listingVarientModel,
id: varient._id,
updateData: { stockQuantity },
user,
});
}
};
// Add virtual id getter
productStockSchema.virtual('id').get(function () {
return this._id;

View File

@ -13,6 +13,7 @@ const shipmentSchema = new Schema(
order: { type: Schema.Types.ObjectId, refPath: 'orderType', required: true },
courierService: { type: Schema.Types.ObjectId, ref: 'courierService', required: false },
trackingNumber: { type: String, required: false },
externalReference: { type: String, required: false },
amount: { type: Number, required: true },
amountWithTax: { type: Number, required: true },
taxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
@ -35,6 +36,7 @@ const shipmentSchema = new Schema(
);
shipmentSchema.index({ trackingNumber: 'text', orderType: 'text' });
shipmentSchema.index({ order: 1, externalReference: 1 }, { unique: true, sparse: true });
shipmentSchema.statics.recalculate = async function (shipment, user) {
if (shipment.orderType !== 'purchaseOrder' && shipment.orderType !== 'salesOrder') {

View File

@ -190,7 +190,7 @@ const recalculateParentStock = async (parentType, parentId, user) => {
buildParentUpdateData(parentType, parentStock, events)
),
user,
recalculate: false,
recalculate: parentType === 'productStock',
});
};

View File

@ -30,7 +30,7 @@ const auditLogSchema = new Schema(
ownerType: {
type: String,
required: true,
enum: ['user', 'printer', 'host'],
enum: ['user', 'printer', 'host', 'marketplace'],
},
},
{ timestamps: true }

View File

@ -14,7 +14,14 @@ const courierSchema = new mongoose.Schema(
{ timestamps: true }
);
courierSchema.index({ name: 'text', website: 'text', email: 'text', phone: 'text', contact: 'text', country: 'text' });
courierSchema.index({
name: 'text',
website: 'text',
email: 'text',
phone: 'text',
contact: 'text',
country: 'text',
});
courierSchema.virtual('id').get(function () {
return this._id;

View File

@ -2,6 +2,14 @@ import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
const marketplaceMappingSchema = new mongoose.Schema(
{
marketplace: { type: Schema.Types.ObjectId, ref: 'marketplace', required: true },
externalReference: { type: String, required: false },
},
{ _id: true }
);
const courierServiceSchema = new mongoose.Schema(
{
_reference: { type: String, default: () => generateId()() },
@ -18,6 +26,7 @@ const courierServiceSchema = new mongoose.Schema(
additionalCostWithTax: { required: false, type: Number },
shippingCurrency: { required: true, type: String, default: 'GBP' },
international: { required: true, type: Boolean, default: false },
marketplaces: { type: [marketplaceMappingSchema], default: [] },
},
{ timestamps: true }
);

View File

@ -48,6 +48,7 @@ import { salesOrderModel } from './sales/salesorder.schema.js';
import { marketplaceModel } from './sales/marketplace.schema.js';
import { listingModel } from './sales/listing.schema.js';
import { listingVarientModel } from './sales/listingvarient.schema.js';
import { marketplaceEventModel } from './sales/marketplaceevent.schema.js';
import { paymentModel } from './finance/payment.schema.js';
// Map prefixes to models and id fields
@ -396,6 +397,13 @@ export const models = {
label: 'Listing Varient',
referenceField: '_reference',
},
MKE: {
model: marketplaceEventModel,
idField: '_id',
type: 'marketplaceEvent',
label: 'Marketplace Event',
referenceField: '_reference',
},
PAY: {
model: paymentModel,
idField: '_id',

View File

@ -16,6 +16,7 @@ const clientSchema = new mongoose.Schema(
_reference: { type: String, default: () => generateId()() },
name: { required: true, type: String },
marketplace: { type: mongoose.Schema.Types.ObjectId, ref: 'marketplace', required: false },
externalReference: { type: String, required: false },
email: { required: false, type: String },
phone: { required: false, type: String },
country: { required: false, type: String },
@ -27,6 +28,7 @@ const clientSchema = new mongoose.Schema(
);
clientSchema.index({ name: 'text', email: 'text', phone: 'text', country: 'text', tags: 'text' });
clientSchema.index({ marketplace: 1, externalReference: 1 }, { unique: true, sparse: true });
clientSchema.virtual('id').get(function () {
return this._id;

View File

@ -19,15 +19,43 @@ const listingSchema = new Schema(
message: { type: String, required: false },
},
url: { type: String, required: false },
description: { type: String, required: false },
externalReference: { type: String, required: false },
price: { type: Number, required: false },
currency: { type: String, required: false },
lastSyncedAt: { type: Date, required: false },
stockQuantity: { type: Number, required: false, default: 0 },
condition: {
type: String,
enum: [
'new',
'likeNew',
'newOther',
'newWithDefects',
'manufacturerRefurbished',
'certifiedRefurbished',
'excellentRefurbished',
'veryGoodRefurbished',
'goodRefurbished',
'sellerRefurbished',
'usedExcellent',
'usedVeryGood',
'usedGood',
'usedAcceptable',
'forPartsOrNotWorking',
'preOwnedExcellent',
'preOwnedFair',
],
default: 'new',
required: false,
},
courierServices: [{ type: Schema.Types.ObjectId, ref: 'courierService', required: true }],
},
{ timestamps: true }
);
listingSchema.index({ title: 'text', url: 'text' });
listingSchema.index({ marketplace: 1, externalReference: 1 }, { unique: true, sparse: true });
listingSchema.virtual('id').get(function () {
return this._id;

View File

@ -1,5 +1,6 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
import { aggregateRollups, editObject } from '../../database.js';
const { Schema } = mongoose;
const listingVarientSchema = new Schema(
@ -16,16 +17,19 @@ const listingVarientSchema = new Schema(
},
message: { type: String, required: false },
},
externalReference: { type: String, required: false },
price: { type: Number, required: false },
currency: { type: String, required: false },
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
priceWithTax: { type: Number, required: false },
lastSyncedAt: { type: Date, required: false },
stockQuantity: { type: Number, required: false, default: 0 },
},
{ timestamps: true }
);
listingVarientSchema.index({ currency: 'text', 'state.type': 'text' });
listingVarientSchema.index({ listing: 1, externalReference: 1 }, { unique: true, sparse: true });
listingVarientSchema.virtual('id').get(function () {
return this._id;
@ -42,4 +46,32 @@ listingVarientSchema.set('toJSON', {
},
});
listingVarientSchema.statics.recalculate = async function (listingVarient, user) {
const listingId = listingVarient?.listing?._id || listingVarient?.listing;
if (!listingId) {
return;
}
const rollupResults = await aggregateRollups({
model: this,
baseFilter: { listing: new mongoose.Types.ObjectId(listingId) },
rollupConfigs: [
{
name: 'stockQuantity',
rollups: [{ name: 'stockQuantity', property: 'stockQuantity', operation: 'sum' }],
},
],
});
await editObject({
model: mongoose.model('listing'),
id: listingId,
updateData: {
stockQuantity: rollupResults.stockQuantity?.sum || 0,
},
user,
recalculate: false,
});
};
export const listingVarientModel = mongoose.model('listingVarient', listingVarientSchema);

View File

@ -17,13 +17,16 @@ const marketplaceSchema = new mongoose.Schema(
state: {
type: {
type: String,
enum: ['active', 'inactive', 'suspended', 'ready', 'offline', 'syncing'],
enum: ['active', 'inactive', 'suspended', 'ready', 'offline', 'syncing', 'disconnected'],
default: 'offline',
},
message: { type: String, required: false },
},
// Provider-specific API configuration (flexible for eBay, Etsy, TikTok Shop)
config: { type: mongoose.Schema.Types.Mixed, default: {} },
eBay: {
availableShippingServices: { type: [String], default: [] },
},
},
{ timestamps: true }
);

View File

@ -0,0 +1,31 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const marketplaceEventSchema = new mongoose.Schema(
{
_reference: { type: String, default: () => generateId()() },
marketplace: { type: mongoose.Schema.Types.ObjectId, ref: 'marketplace', required: true },
externalReference: { type: String, required: true },
topic: { type: String, required: true },
status: {
type: String,
required: true,
enum: ['received', 'processed', 'failed'],
default: 'received',
},
processedAt: { type: Date, required: false },
error: { type: String, required: false },
},
{ timestamps: true }
);
marketplaceEventSchema.index({ marketplace: 1, externalReference: 1 }, { unique: true });
marketplaceEventSchema.index({ topic: 'text', status: 'text' });
marketplaceEventSchema.virtual('id').get(function () {
return this._id;
});
marketplaceEventSchema.set('toJSON', { virtuals: true });
export const marketplaceEventModel = mongoose.model('marketplaceEvent', marketplaceEventSchema);

View File

@ -1,11 +1,7 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
import {
aggregateRollups,
aggregateRollupsHistory,
editObject,
} from '../../database.js';
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
const salesOrderSchema = new Schema(
{
@ -19,6 +15,7 @@ const salesOrderSchema = new Schema(
timestamp: { type: Date, default: Date.now },
client: { type: Schema.Types.ObjectId, ref: 'client', required: true },
marketplace: { type: Schema.Types.ObjectId, ref: 'marketplace', required: false },
externalReference: { type: String, required: false },
state: {
type: { type: String, required: true, default: 'draft' },
},
@ -31,6 +28,7 @@ const salesOrderSchema = new Schema(
);
salesOrderSchema.index({ 'state.type': 'text' });
salesOrderSchema.index({ marketplace: 1, externalReference: 1 }, { unique: true, sparse: true });
const rollupConfigs = [
{

View File

@ -2,10 +2,11 @@ import { customAlphabet } from 'nanoid';
import mongoose from 'mongoose';
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
export const generateId = () => {
const createId = () => {
// 10 characters
return customAlphabet(ALPHABET, 12);
};
export const generateId = createId;
/** Check if a value is a string that looks like a MongoDB ObjectId (24 hex chars). */
export function isObjectIdString(value) {

View File

@ -145,7 +145,16 @@ async function initializeApp() {
// Configure middleware
app.use(cors(corsOptions));
app.use(bodyParser.json({ type: 'application/json', strict: false, limit: '50mb' }));
app.use(
bodyParser.json({
type: 'application/json',
strict: false,
limit: '50mb',
verify: (req, res, buf) => {
req.rawBody = buf.toString('utf8');
},
})
);
app.use(express.json());
app.use(populateUserMiddleware);

View File

@ -0,0 +1,142 @@
import { describe, expect, it, jest, beforeEach } from '@jest/globals';
jest.unstable_mockModule('../../database/database.js', () => ({
newObject: jest.fn(),
editObject: jest.fn(),
}));
jest.unstable_mockModule('../../database/schemas/sales/client.schema.js', () => ({
clientModel: { findOne: jest.fn() },
}));
jest.unstable_mockModule('../../database/schemas/sales/salesorder.schema.js', () => ({
salesOrderModel: { findOne: jest.fn(), findById: jest.fn() },
}));
jest.unstable_mockModule('../../database/schemas/sales/listing.schema.js', () => ({
listingModel: { findOne: jest.fn() },
}));
jest.unstable_mockModule('../../database/schemas/sales/listingvarient.schema.js', () => ({
listingVarientModel: { findOne: jest.fn() },
}));
jest.unstable_mockModule('../../database/schemas/sales/marketplace.schema.js', () => ({
marketplaceModel: {},
}));
jest.unstable_mockModule('../../database/schemas/inventory/orderitem.schema.js', () => ({
orderItemModel: { findOne: jest.fn(), find: jest.fn() },
}));
jest.unstable_mockModule('../../database/schemas/inventory/shipment.schema.js', () => ({
shipmentModel: { findOne: jest.fn() },
}));
jest.unstable_mockModule('../../database/schemas/sales/marketplaceevent.schema.js', () => ({
marketplaceEventModel: { findOne: jest.fn() },
}));
jest.unstable_mockModule('../../database/schemas/management/productsku.schema.js', () => ({
productSkuModel: { findOne: jest.fn() },
}));
jest.unstable_mockModule('../../database/schemas/management/product.schema.js', () => ({
productModel: { findById: jest.fn() },
}));
jest.unstable_mockModule('log4js', () => ({
default: { getLogger: () => ({ level: 'off', warn: jest.fn(), info: jest.fn(), debug: jest.fn() }) },
}));
jest.unstable_mockModule('../../config.js', () => ({
default: { server: { logLevel: 'off' } },
}));
const { newObject } = await import('../../database/database.js');
const { clientModel } = await import('../../database/schemas/sales/client.schema.js');
const { salesOrderModel } = await import('../../database/schemas/sales/salesorder.schema.js');
const { orderItemModel } = await import('../../database/schemas/inventory/orderitem.schema.js');
const { marketplaceEventModel } = await import(
'../../database/schemas/sales/marketplaceevent.schema.js'
);
const { upsertExternalOrder, applyWebhookAction } = await import('../marketplaceSync.js');
const { resolveAuditOwner } = await import('../../auditOwner.js');
describe('marketplace sync upserts', () => {
const marketplace = { _id: 'mkt-1', name: 'eBay UK', config: {} };
const actor = { _id: 'mkt-1', name: 'eBay UK', _objectType: 'marketplace' };
beforeEach(() => {
jest.clearAllMocks();
});
it('creates sales orders by externalReference with marketplace actor', async () => {
clientModel.findOne.mockResolvedValue(null);
salesOrderModel.findOne.mockResolvedValue(null);
orderItemModel.findOne.mockResolvedValue(null);
newObject.mockImplementation(async ({ newData }) => ({
_id: 'created-1',
...newData,
}));
const provider = {
mapOrderToSalesOrder: () => ({
externalReference: 'ebay-order-1',
state: { type: 'confirmed' },
totalAmount: 10,
totalAmountWithTax: 12,
shippingAmount: 2,
shippingAmountWithTax: 2,
grandTotalAmount: 14,
totalTaxAmount: 2,
}),
mapBuyerToClient: () => ({
name: 'Jane',
email: 'jane@example.com',
externalReference: 'jane-ebay',
}),
mapOrderLineItems: () => [
{
externalReference: 'line-1',
name: 'Widget',
sku: 'SKU-1',
quantity: 1,
itemAmount: 10,
totalAmount: 10,
totalAmountWithTax: 12,
},
],
};
const result = await upsertExternalOrder(
marketplace,
provider,
{ orderId: 'ebay-order-1' },
actor
);
expect(result.action).toBe('created');
expect(newObject).toHaveBeenCalledWith(
expect.objectContaining({
user: actor,
newData: expect.objectContaining({ externalReference: 'ebay-order-1' }),
})
);
});
it('skips duplicate webhook events', async () => {
marketplaceEventModel.findOne.mockResolvedValue({
_id: 'evt-1',
status: 'processed',
});
const classified = await applyWebhookAction(
marketplace,
{},
{ action: 'orderUpdate', orderId: 'o1', notificationId: 'n1' },
actor
);
expect(classified.duplicate).toBe(true);
expect(newObject).not.toHaveBeenCalled();
});
});
describe('audit owner resolution', () => {
it('uses marketplace ownerType for marketplace actors', () => {
expect(resolveAuditOwner({ _id: 'm1', _objectType: 'marketplace' })).toEqual({
owner: 'm1',
ownerType: 'marketplace',
});
});
it('defaults to user', () => {
expect(resolveAuditOwner({ _id: 'u1' })).toEqual({ owner: 'u1', ownerType: 'user' });
});
});

View File

@ -0,0 +1,482 @@
import { clientModel } from '../database/schemas/sales/client.schema.js';
import { salesOrderModel } from '../database/schemas/sales/salesorder.schema.js';
import { listingModel } from '../database/schemas/sales/listing.schema.js';
import { listingVarientModel } from '../database/schemas/sales/listingvarient.schema.js';
import { marketplaceModel } from '../database/schemas/sales/marketplace.schema.js';
import { orderItemModel } from '../database/schemas/inventory/orderitem.schema.js';
import { shipmentModel } from '../database/schemas/inventory/shipment.schema.js';
import { marketplaceEventModel } from '../database/schemas/sales/marketplaceevent.schema.js';
import { productSkuModel } from '../database/schemas/management/productsku.schema.js';
import { productModel } from '../database/schemas/management/product.schema.js';
import { editObject, newObject } from '../database/database.js';
import { marketplaceActor, marketplaceSku } from './marketplaces/ids.js';
import config from '../config.js';
import log4js from 'log4js';
const logger = log4js.getLogger('Marketplace Sync');
logger.level = config.server.logLevel;
function externalIdOf(mapped = {}) {
return mapped.externalReference || mapped.externalId;
}
function stateType(mapped) {
return mapped?.state?.type || mapped?.state;
}
async function upsertClient(marketplace, clientData, actor) {
if (!clientData) return null;
const marketplaceId = marketplace._id;
let client = null;
const externalReference = externalIdOf(clientData);
if (externalReference) {
client = await clientModel.findOne({ marketplace: marketplaceId, externalReference });
}
if (!client && clientData.email) {
client = await clientModel.findOne({ marketplace: marketplaceId, email: clientData.email });
}
if (!client && clientData.name) {
client = await clientModel.findOne({ marketplace: marketplaceId, name: clientData.name });
}
const payload = {
...clientData,
marketplace: marketplaceId,
active: true,
...(externalReference ? { externalReference } : {}),
};
delete payload.externalId;
if (client) {
return editObject({
model: clientModel,
id: client._id,
updateData: payload,
user: actor,
});
}
return newObject({
model: clientModel,
newData: payload,
user: actor,
});
}
async function upsertOrderItems(salesOrder, externalOrder, provider, actor) {
if (typeof provider.mapOrderLineItems !== 'function') return;
const lines = provider.mapOrderLineItems(externalOrder) || [];
for (const line of lines) {
const lineRef = externalIdOf(line);
if (!lineRef) continue;
let listingVarient = null;
if (line.sku) {
listingVarient = await listingVarientModel.findOne({
$or: [{ externalReference: line.sku }, { _reference: line.sku }],
});
}
const payload = {
orderType: 'salesOrder',
order: salesOrder._id,
name: line.name || line.sku || 'Marketplace item',
itemType: line.itemType || 'product',
quantity: line.quantity || 1,
itemAmount: line.itemAmount ?? 0,
totalAmount: line.totalAmount ?? 0,
totalAmountWithTax: line.totalAmountWithTax ?? line.totalAmount ?? 0,
externalReference: lineRef,
listing: listingVarient?.listing,
listingVarient: listingVarient?._id,
item: listingVarient?.product,
sku: listingVarient?.productSku,
state: { type: stateType(salesOrder) === 'cancelled' ? 'cancelled' : 'ordered' },
};
const existing = await orderItemModel.findOne({
order: salesOrder._id,
externalReference: lineRef,
});
if (existing) {
await editObject({
model: orderItemModel,
id: existing._id,
updateData: payload,
user: actor,
});
} else {
await newObject({
model: orderItemModel,
newData: payload,
user: actor,
});
}
}
}
async function upsertOrderShipments(salesOrder, externalOrder, provider, actor) {
if (typeof provider.mapOrderShipments !== 'function') return;
const shipments = provider.mapOrderShipments(externalOrder) || [];
for (const mapped of shipments) {
const shipmentRef = externalIdOf(mapped);
if (!shipmentRef) continue;
const payload = {
orderType: 'salesOrder',
order: salesOrder._id,
trackingNumber: mapped.trackingNumber || '',
amount: mapped.amount ?? 0,
amountWithTax: mapped.amountWithTax ?? mapped.amount ?? 0,
externalReference: shipmentRef,
state: mapped.state || { type: 'planned' },
shippedAt: mapped.shippedAt,
};
const existing = await shipmentModel.findOne({
order: salesOrder._id,
externalReference: shipmentRef,
});
if (existing) {
await editObject({
model: shipmentModel,
id: existing._id,
updateData: payload,
user: actor,
});
} else {
await newObject({
model: shipmentModel,
newData: payload,
user: actor,
});
}
}
}
export async function upsertExternalOrder(marketplace, provider, externalOrder, actor) {
const mapped = provider.mapOrderToSalesOrder(externalOrder);
const clientData = provider.mapBuyerToClient(externalOrder);
const externalReference = externalIdOf(mapped);
if (!externalReference) {
throw new Error('Mapped sales order is missing externalReference');
}
const client = await upsertClient(marketplace, clientData, actor);
const existingOrder = await salesOrderModel.findOne({
marketplace: marketplace._id,
externalReference,
});
const lifecycle = {};
const type = mapped.state?.type;
if (type === 'confirmed' || type === 'shipped' || type === 'delivered') {
lifecycle.confirmedAt = existingOrder?.confirmedAt || new Date();
}
if (type === 'cancelled') {
lifecycle.cancelledAt = existingOrder?.cancelledAt || new Date();
}
const orderPayload = {
client: client?._id,
marketplace: marketplace._id,
externalReference,
state: mapped.state,
totalAmount: mapped.totalAmount,
totalAmountWithTax: mapped.totalAmountWithTax,
shippingAmount: mapped.shippingAmount,
shippingAmountWithTax: mapped.shippingAmountWithTax,
grandTotalAmount: mapped.grandTotalAmount,
totalTaxAmount: mapped.totalTaxAmount,
...lifecycle,
};
let salesOrder = existingOrder;
if (existingOrder) {
salesOrder = await editObject({
model: salesOrderModel,
id: existingOrder._id,
updateData: orderPayload,
user: actor,
});
} else {
salesOrder = await newObject({
model: salesOrderModel,
newData: orderPayload,
user: actor,
});
}
await upsertOrderItems(salesOrder, externalOrder, provider, actor);
await upsertOrderShipments(salesOrder, externalOrder, provider, actor);
return { salesOrder, action: existingOrder ? 'updated' : 'created', externalReference };
}
async function matchProductSku(mapped) {
const skuCodes = [
mapped.externalReference,
...(mapped.varients || []).map((v) => v.externalReference || v._reference),
].filter(Boolean);
for (const code of skuCodes) {
const sku = await productSkuModel.findOne({
$or: [{ _reference: code }, { barcode: code }],
});
if (sku) return sku;
}
return null;
}
async function upsertInboundListing(marketplace, mapped, actor) {
const externalReference = externalIdOf(mapped);
if (!externalReference) {
logger.warn('Skipping inbound listing without externalReference');
return { action: 'skipped' };
}
const defaults = marketplace.config?.defaults || {};
const productSku = await matchProductSku(mapped);
const product = productSku
? await productModel.findById(productSku.product).lean()
: null;
const existing = await listingModel.findOne({
marketplace: marketplace._id,
externalReference,
});
const payload = {
title: mapped.title,
url: mapped.url,
price: mapped.price,
currency: mapped.currency,
description: mapped.description,
externalReference,
lastSyncedAt: new Date(),
state: mapped.state || { type: 'active' },
marketplace: marketplace._id,
};
if (product) {
payload.product = product._id;
payload.vendor = product.vendor;
}
let listing = existing;
if (existing) {
listing = await editObject({
model: listingModel,
id: existing._id,
updateData: payload,
user: actor,
});
} else {
const vendor = payload.vendor || defaults.vendor;
const stockLocation = defaults.stockLocation;
const courierServices = defaults.courierServices || [];
if (!vendor || !stockLocation) {
logger.warn(
`Skipping inbound listing ${externalReference}: marketplace defaults.vendor and defaults.stockLocation are required to create listings`
);
return { action: 'skipped', externalReference };
}
listing = await newObject({
model: listingModel,
newData: {
...payload,
vendor,
stockLocation,
courierServices,
},
user: actor,
});
}
for (const mappedVarient of mapped.varients || []) {
const varientRef = mappedVarient.externalReference || mappedVarient._reference;
if (!varientRef) continue;
const varientPayload = {
listing: listing._id,
externalReference: varientRef,
price: mappedVarient.price,
currency: mappedVarient.currency,
state: mappedVarient.state || mapped.state || { type: 'active' },
lastSyncedAt: new Date(),
product: product?._id || listing.product,
productSku: productSku?._id,
};
const existingVarient = await listingVarientModel.findOne({
listing: listing._id,
externalReference: varientRef,
});
if (existingVarient) {
await editObject({
model: listingVarientModel,
id: existingVarient._id,
updateData: varientPayload,
user: actor,
});
} else {
await newObject({
model: listingVarientModel,
newData: varientPayload,
user: actor,
});
}
}
return { action: existing ? 'updated' : 'created', listing };
}
export async function importExternalItems(marketplace, provider, actor) {
if (typeof provider.syncItems !== 'function' || typeof provider.mapProductToListing !== 'function') {
return [];
}
const externalItems = await provider.syncItems(marketplace);
const results = [];
for (const item of externalItems || []) {
try {
const mapped = provider.mapProductToListing(item);
const result = await upsertInboundListing(marketplace, mapped, actor);
results.push(result);
} catch (err) {
logger.warn(`Failed to import marketplace listing: ${err.message}`);
results.push({ action: 'error', error: err.message });
}
}
return results;
}
export async function recordMarketplaceEvent(marketplace, classified, actor) {
const externalReference =
classified.notificationId ||
`${classified.action}:${classified.orderId || classified.itemId || classified.topic || Date.now()}`;
const existing = await marketplaceEventModel.findOne({
marketplace: marketplace._id,
externalReference,
});
if (existing) {
return existing;
}
return newObject({
model: marketplaceEventModel,
newData: {
marketplace: marketplace._id,
externalReference,
topic: classified.topic || classified.action || 'unknown',
status: 'received',
},
user: actor,
});
}
export async function markMarketplaceEvent(event, status, actor, error) {
if (!event?._id) return;
await editObject({
model: marketplaceEventModel,
id: event._id,
updateData: {
status,
processedAt: status === 'processed' ? new Date() : event.processedAt,
error: error || undefined,
},
user: actor,
});
}
export async function applyWebhookAction(marketplace, provider, classified, actor) {
const event = await recordMarketplaceEvent(marketplace, classified, actor);
if (event.status === 'processed') {
return { ...classified, duplicate: true };
}
try {
switch (classified.action) {
case 'orderCreate':
case 'orderUpdate':
case 'orderCancel': {
if (classified.orderId && typeof provider.getOrder === 'function') {
const order = await provider.getOrder(marketplace, classified.orderId);
await upsertExternalOrder(marketplace, provider, order, actor);
}
break;
}
case 'productUpdate':
case 'productEnded': {
await importExternalItems(marketplace, provider, actor);
break;
}
case 'accountDeletion': {
if (classified.userId) {
const client = await clientModel.findOne({
marketplace: marketplace._id,
$or: [{ externalReference: classified.userId }, { name: classified.userId }],
});
if (client) {
await editObject({
model: clientModel,
id: client._id,
updateData: {
name: 'Deleted marketplace user',
email: '',
phone: '',
address: {},
},
user: actor,
});
}
}
break;
}
case 'disconnect': {
await editObject({
model: marketplaceModel,
id: marketplace._id,
updateData: { connected: false, state: { type: 'disconnected' } },
user: actor,
});
break;
}
default:
break;
}
await markMarketplaceEvent(event, 'processed', actor);
} catch (err) {
await markMarketplaceEvent(event, 'failed', actor, err.message);
throw err;
}
return classified;
}
export async function pushShipmentFulfillment(marketplace, provider, shipment, actor) {
if (typeof provider.createShippingFulfillment !== 'function') {
return;
}
const orderId = shipment.order?._id || shipment.order;
const salesOrder = await salesOrderModel.findById(orderId).lean();
if (!salesOrder?.externalReference) {
return;
}
const orderItems = await orderItemModel
.find({ order: salesOrder._id, shipment: shipment._id, orderType: 'salesOrder' })
.lean();
await provider.createShippingFulfillment(marketplace, {
orderId: salesOrder.externalReference,
trackingNumber: shipment.trackingNumber,
shippingCarrierCode: shipment.courierService?._reference || shipment.courierService?.code,
lineItems: orderItems.map((item) => ({
lineItemId: item.externalReference,
quantity: item.quantity,
})),
});
if (shipment._id && !shipment.externalReference) {
await editObject({
model: shipmentModel,
id: shipment._id,
updateData: { lastSyncedAt: new Date() },
user: actor,
});
}
}
export { marketplaceActor, marketplaceSku };

View File

@ -0,0 +1,15 @@
import { describe, expect, it } from '@jest/globals';
import { marketplaceSku, marketplaceActor } from '../ids.js';
describe('marketplace ids', () => {
it('prefers externalReference over _reference', () => {
expect(marketplaceSku({ externalReference: 'ebay-sku', _reference: 'LVR-1' })).toBe('ebay-sku');
expect(marketplaceSku({ _reference: 'LVR-1' })).toBe('LVR-1');
});
it('marks marketplace actors for audit logs', () => {
const actor = marketplaceActor({ _id: 'm1', name: 'eBay UK' });
expect(actor._objectType).toBe('marketplace');
expect(actor.name).toBe('eBay UK');
});
});

View File

@ -0,0 +1,115 @@
import { describe, expect, it } from '@jest/globals';
import {
buildCategoryTypes,
buildFulfillmentPolicy,
isDefaultFulfillmentPolicy,
validateCourierServices,
} from '../fulfillmentPolicies.js';
const marketplace = { _id: 'mp1', config: { marketplaceId: 'EBAY_GB' } };
describe('eBay fulfillment policies', () => {
it('requires an eBay shipping service code for the listing marketplace', () => {
expect(() =>
validateCourierServices(
[{ name: 'Second Class', active: true, marketplaces: [] }],
{ _reference: 'LST-1' },
marketplace
)
).toThrow(
'Courier service "Second Class" requires an eBay shipping service code before it can be used on an eBay listing.'
);
});
it('uses the marketplace mapping externalReference as the shipping service code', () => {
const services = [
{
name: 'Second Class',
active: true,
marketplaces: [
{
marketplace: { _id: 'mp1' },
externalReference: 'UK_RoyalMailSecondClassStandard',
},
],
cost: 2.5,
shippingCurrency: 'GBP',
},
];
const policy = buildFulfillmentPolicy(
{ _reference: 'LST-1', currency: 'GBP' },
marketplace,
services
);
expect(policy.shippingOptions[0].shippingServices[0].shippingServiceCode).toBe(
'UK_RoyalMailSecondClassStandard'
);
expect(policy.shippingOptions[0].shippingServices[0].shippingCarrierCode).toBeUndefined();
expect(policy.categoryTypes).toEqual([{ name: 'ALL_EXCLUDING_MOTORS_VEHICLES' }]);
expect(policy.categoryTypes[0].default).toBeUndefined();
});
it('omits categoryTypes.default on create so the Account API does not receive the deprecated field', () => {
expect(buildCategoryTypes()).toEqual([{ name: 'ALL_EXCLUDING_MOTORS_VEHICLES' }]);
});
it('sends categoryTypes.default=true when the existing policy is the account default', () => {
const policy = buildFulfillmentPolicy(
{ _reference: 'LST-1', currency: 'GBP' },
marketplace,
[
{
name: 'Second Class',
active: true,
marketplaces: [
{
marketplace: { _id: 'mp1' },
externalReference: 'UK_RoyalMailSecondClassStandard',
},
],
cost: 2.5,
shippingCurrency: 'GBP',
},
],
{
fulfillmentPolicyId: '6246152000',
categoryTypes: [{ name: 'ALL_EXCLUDING_MOTORS_VEHICLES', default: true }],
}
);
expect(policy.categoryTypes).toEqual([
{ name: 'ALL_EXCLUDING_MOTORS_VEHICLES', default: true },
]);
});
it('never sends categoryTypes.default=false, even when GET reports false', () => {
expect(
buildCategoryTypes({
fulfillmentPolicyId: '6246152000',
categoryTypes: [{ name: 'ALL_EXCLUDING_MOTORS_VEHICLES', default: false }],
})
).toEqual([{ name: 'ALL_EXCLUDING_MOTORS_VEHICLES' }]);
});
it('treats the only fulfillment policy as the account default', () => {
expect(
isDefaultFulfillmentPolicy(
{
fulfillmentPolicyId: '6246152000',
categoryTypes: [{ name: 'ALL_EXCLUDING_MOTORS_VEHICLES', default: false }],
},
[{ fulfillmentPolicyId: '6246152000' }]
)
).toBe(true);
expect(
buildCategoryTypes(
{
fulfillmentPolicyId: '6246152000',
categoryTypes: [{ name: 'ALL_EXCLUDING_MOTORS_VEHICLES', default: false }],
},
[{ fulfillmentPolicyId: '6246152000' }]
)
).toEqual([{ name: 'ALL_EXCLUDING_MOTORS_VEHICLES', default: true }]);
});
});

View File

@ -0,0 +1,111 @@
import { describe, expect, it, jest } from '@jest/globals';
jest.unstable_mockModule('../../../../database/schemas/inventory/stocklocation.schema.js', () => ({
stockLocationModel: { findById: jest.fn() },
}));
jest.unstable_mockModule('../../../../database/schemas/inventory/productstock.schema.js', () => ({
productStockModel: { find: jest.fn() },
}));
jest.unstable_mockModule('../countryCodes.js', () => ({
FARMCONTROL_GB_SUBDIVISION_STATE: {},
resolveEbayCountry: jest.fn(),
}));
jest.unstable_mockModule('../categories.js', () => ({ syncProductCategory: jest.fn() }));
jest.unstable_mockModule('../fulfillmentPolicies.js', () => ({ syncFulfillmentPolicy: jest.fn() }));
jest.unstable_mockModule('../shared.js', () => ({
makeRequest: jest.fn(),
logger: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
}));
jest.unstable_mockModule('../../ids.js', () => ({
marketplaceSku: (record) => record?.externalReference || record?._reference,
marketplaceActor: (marketplace) => ({ ...marketplace, _objectType: 'marketplace' }),
}));
const { productStockModel } =
await import('../../../../database/schemas/inventory/productstock.schema.js');
const { makeRequest } = await import('../shared.js');
const {
fromEbayCondition,
resolveListingDescription,
resolveVarientQuantity,
toEbayCondition,
upsertInventoryItem,
} = await import('../listingVarients.js');
describe('eBay varient quantity', () => {
it('sums productStock.currentQuantity for the listing stock location', async () => {
productStockModel.find.mockReturnValue({
lean: async () => [{ currentQuantity: 3 }, { currentQuantity: 5 }],
});
const quantity = await resolveVarientQuantity(
{ productSku: 'sku-1' },
{ stockLocation: 'loc-1' }
);
expect(quantity).toBe(8);
expect(productStockModel.find).toHaveBeenCalledWith({
productSku: 'sku-1',
stockLocation: 'loc-1',
});
});
it('falls back to varient.inventory when no product SKU is linked', async () => {
const quantity = await resolveVarientQuantity({ inventory: 4 }, {});
expect(quantity).toBe(4);
});
});
describe('eBay listing description', () => {
it('prefers listing.description', () => {
expect(resolveListingDescription({ description: 'Custom copy', title: 'Widget' })).toBe(
'Custom copy'
);
});
it('falls back to title when description is empty', () => {
expect(resolveListingDescription({ title: 'Widget' })).toBe('Widget');
});
it('falls back to product SKU description', () => {
expect(resolveListingDescription({}, { productSku: { description: 'SKU copy' } })).toBe(
'SKU copy'
);
});
});
describe('eBay listing condition', () => {
it('converts camelCase listing conditions to eBay ConditionEnum values', () => {
expect(toEbayCondition('new')).toBe('NEW');
expect(toEbayCondition('likeNew')).toBe('LIKE_NEW');
expect(toEbayCondition('forPartsOrNotWorking')).toBe('FOR_PARTS_OR_NOT_WORKING');
expect(toEbayCondition('USED_GOOD')).toBe('USED_GOOD');
expect(toEbayCondition()).toBe('NEW');
});
it('converts eBay ConditionEnum values back to camelCase', () => {
expect(fromEbayCondition('NEW')).toBe('new');
expect(fromEbayCondition('LIKE_NEW')).toBe('likeNew');
expect(fromEbayCondition('FOR_PARTS_OR_NOT_WORKING')).toBe('forPartsOrNotWorking');
});
});
describe('upsertInventoryItem description', () => {
it('always sends product.description and eBay condition to eBay', async () => {
makeRequest.mockResolvedValue({});
await upsertInventoryItem(
{ config: { accessToken: 'token' } },
{ _reference: 'SKU-1' },
{ title: 'Widget', condition: 'usedGood' }
);
expect(makeRequest).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
condition: 'USED_GOOD',
product: expect.objectContaining({
title: 'Widget',
description: 'Widget',
}),
}),
})
);
});
});

View File

@ -0,0 +1,76 @@
import { describe, expect, it, jest } from '@jest/globals';
jest.unstable_mockModule('../listingVarients.js', () => ({
buildVarientEntry: (item, offers) => ({
_reference: item.sku,
externalReference: item.sku,
price: offers?.[0]?.pricingSummary?.price?.value
? parseFloat(offers[0].pricingSummary.price.value)
: undefined,
currency: offers?.[0]?.pricingSummary?.price?.currency,
state: { type: offers?.[0]?.status === 'PUBLISHED' ? 'active' : 'draft' },
}),
fetchOffers: jest.fn(),
fromEbayCondition: (condition) =>
condition
? condition.toLowerCase().replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
: undefined,
resolveListingDescription: (listing) => listing?.description || listing?.title || '',
resolveOfferState: (offers = []) =>
offers.some((o) => o?.status === 'PUBLISHED') ? 'active' : 'draft',
syncOfferAndMaybePublish: jest.fn(),
upsertInventoryItem: jest.fn(),
withdrawOfferById: jest.fn(),
}));
jest.unstable_mockModule('../../ids.js', () => ({
marketplaceSku: (record) => record?.externalReference || record?._reference,
marketplaceActor: (marketplace) => ({ ...marketplace, _objectType: 'marketplace' }),
}));
jest.unstable_mockModule('../shared.js', () => ({
makeRequest: jest.fn(),
logger: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
}));
jest.unstable_mockModule('../categories.js', () => ({ syncProductCategory: jest.fn() }));
jest.unstable_mockModule('../fulfillmentPolicies.js', () => ({ syncFulfillmentPolicy: jest.fn() }));
const { mapProductToListing } = await import('../listings.js');
describe('eBay listing mappers', () => {
it('maps grouped inventory to externalReference instead of overwriting _reference', () => {
const mapped = mapProductToListing({
_type: 'group',
_groupKey: 'group-1',
_group: { title: 'Widget', description: 'A widget' },
_variants: [
{
sku: 'SKU-RED',
_offers: [
{
listingId: '123456',
status: 'PUBLISHED',
pricingSummary: { price: { value: '9.99', currency: 'GBP' } },
},
],
},
],
});
expect(mapped.externalReference).toBe('123456');
expect(mapped.title).toBe('Widget');
expect(mapped.description).toBe('A widget');
expect(mapped.varients[0].externalReference).toBe('SKU-RED');
expect(mapped._reference).toBeUndefined();
});
it('maps standalone inventory items', () => {
const mapped = mapProductToListing({
sku: 'SKU-BLUE',
condition: 'LIKE_NEW',
product: { title: 'Blue widget', description: 'Blue' },
_offers: [{ listingId: '999', status: 'PUBLISHED', pricingSummary: { price: { value: '4.00', currency: 'GBP' } } }],
});
expect(mapped.externalReference).toBe('999');
expect(mapped.varients[0].externalReference).toBe('SKU-BLUE');
expect(mapped.condition).toBe('likeNew');
});
});

View File

@ -0,0 +1,70 @@
import { describe, expect, it, jest } from '@jest/globals';
import crypto from 'crypto';
jest.unstable_mockModule('../shared.js', () => ({
makeRequest: jest.fn(),
getApiBaseUrl: () => 'https://api.sandbox.ebay.com',
getBasicAuthHeader: () => 'Basic abc',
formatDebugPayload: (value) => value,
getMarketplaceDebugContext: () => ({}),
logger: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
}));
const { makeRequest } = await import('../shared.js');
const { buildWebhookChallengeResponse, verifyNotificationSignature } = await import(
'../notifications.js'
);
describe('eBay notification challenge', () => {
it('hashes challengeCode + verificationToken + endpoint', () => {
const marketplace = {
config: {
verificationToken: 'verify-token-32-characters-long!!',
webhookUrl: 'https://example.com/marketplaces/1/hook',
},
};
const challengeCode = 'abc123';
const result = buildWebhookChallengeResponse(marketplace, {
challengeCode,
endpoint: marketplace.config.webhookUrl,
});
const expected = crypto
.createHash('sha256')
.update(challengeCode + marketplace.config.verificationToken + marketplace.config.webhookUrl)
.digest('hex');
expect(result.challengeResponse).toBe(expected);
});
it('throws when challenge inputs are missing', () => {
expect(() => buildWebhookChallengeResponse({ config: {} }, { challengeCode: 'x' })).toThrow(
/challenge/
);
});
});
describe('eBay notification signature', () => {
it('verifies ECDSA signatures with a mocked public key', async () => {
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
const payload = '{"notification":{"notificationId":"n1"}}';
const signature = crypto.createSign('SHA256').update(payload).end().sign(privateKey, 'base64');
const header = Buffer.from(
JSON.stringify({ kid: 'kid-1', signature, digest: 'SHA256' })
).toString('base64');
makeRequest.mockResolvedValue({
key: publicKey.export({ type: 'spki', format: 'pem' }),
});
const valid = await verifyNotificationSignature(
{ config: { accessToken: 'token' } },
payload,
header
);
expect(valid).toBe(true);
});
it('rejects missing signature headers', async () => {
expect(
await verifyNotificationSignature({ config: { accessToken: 'token' } }, '{}', '')
).toBe(false);
});
});

View File

@ -0,0 +1,118 @@
import { describe, expect, it } from '@jest/globals';
import {
mapOrderStatus,
mapOrderToSalesOrder,
mapBuyerToClient,
mapOrderLineItems,
mapOrderShipments,
handleWebhook,
} from '../orders.js';
const ebayOrder = {
orderId: '12-34567-89012',
orderFulfillmentStatus: 'NOT_STARTED',
pricingSummary: {
priceSubtotal: { value: '10.00' },
deliveryCost: { value: '2.50' },
tax: { value: '1.20' },
total: { value: '13.70' },
},
buyer: {
username: 'ebay-buyer',
buyerRegistrationAddress: { email: 'buyer@example.com' },
},
fulfillmentStartInstructions: [
{
shippingStep: {
shipTo: {
fullName: 'Jane Buyer',
primaryPhone: { phoneNumber: '012345' },
contactAddress: {
addressLine1: '1 High St',
city: 'London',
postalCode: 'SW1A 1AA',
countryCode: 'GB',
},
},
},
},
],
lineItems: [
{
lineItemId: 'line-1',
sku: 'SKU-RED',
title: 'Red widget',
quantity: 2,
lineItemCost: { value: '5.00' },
taxes: [{ amount: { value: '1.00' } }],
},
],
shippingFulfillments: [
{
fulfillmentId: 'ful-1',
shipmentTrackingNumber: 'TRACK123',
shippedDate: '2026-08-01T10:00:00.000Z',
lineItems: [{ lineItemId: 'line-1' }],
},
],
};
describe('eBay order mappers', () => {
it('maps sales order totals and externalReference', () => {
const mapped = mapOrderToSalesOrder(ebayOrder);
expect(mapped.externalReference).toBe('12-34567-89012');
expect(mapped.totalAmount).toBe(10);
expect(mapped.shippingAmount).toBe(2.5);
expect(mapped.totalTaxAmount).toBe(1.2);
expect(mapped.state.type).toBe('draft');
});
it('maps cancelled orders', () => {
expect(
mapOrderStatus({ cancelStatus: { cancelState: 'CANCELED' } })
).toBe('cancelled');
});
it('maps buyer to client with marketplace buyer id', () => {
const client = mapBuyerToClient(ebayOrder);
expect(client.externalReference).toBe('ebay-buyer');
expect(client.email).toBe('buyer@example.com');
expect(client.address.city).toBe('London');
});
it('maps line items', () => {
const lines = mapOrderLineItems(ebayOrder);
expect(lines).toHaveLength(1);
expect(lines[0]).toMatchObject({
externalReference: 'line-1',
sku: 'SKU-RED',
quantity: 2,
itemAmount: 5,
totalAmount: 10,
totalAmountWithTax: 11,
});
});
it('maps shipments from fulfillments', () => {
const shipments = mapOrderShipments(ebayOrder);
expect(shipments[0]).toMatchObject({
externalReference: 'ful-1',
trackingNumber: 'TRACK123',
});
expect(shipments[0].state.type).toBe('shipped');
});
it('classifies commerce notification topics', async () => {
const sold = await handleWebhook(
{ name: 'eBay' },
{ metadata: { topic: 'ORDER_CONFIRMATION' }, notification: { data: { orderId: 'o1' }, notificationId: 'n1' } }
);
expect(sold).toMatchObject({ action: 'orderUpdate', orderId: 'o1', notificationId: 'n1' });
const deletion = await handleWebhook(
{ name: 'eBay' },
{ topic: 'MARKETPLACE_ACCOUNT_DELETION', data: { userId: 'u1' } }
);
expect(deletion.action).toBe('accountDeletion');
});
});

View File

@ -0,0 +1,36 @@
import { describe, expect, it } from '@jest/globals';
import { parseShippingServiceDetails } from '../shippingServices.js';
const sampleXml = `
<GeteBayDetailsResponse xmlns="urn:ebay:apis:eBLBaseComponents">
<Ack>Success</Ack>
<ShippingServiceDetails>
<Description>Royal Mail 1st Class</Description>
<ShippingService>UK_RoyalMailFirstClassStandard</ShippingService>
<ValidForSellingFlow>true</ValidForSellingFlow>
</ShippingServiceDetails>
<ShippingServiceDetails>
<Description>Deprecated</Description>
<ShippingService>UK_OldService</ShippingService>
<ValidForSellingFlow>false</ValidForSellingFlow>
</ShippingServiceDetails>
<ShippingServiceDetails>
<Description>Royal Mail 2nd Class</Description>
<ShippingService>UK_RoyalMailSecondClassStandard</ShippingService>
<ValidForSellingFlow>true</ValidForSellingFlow>
</ShippingServiceDetails>
</GeteBayDetailsResponse>
`;
describe('eBay shipping service details', () => {
it('extracts selling-flow shipping service codes', () => {
expect(parseShippingServiceDetails(sampleXml)).toEqual([
'UK_RoyalMailFirstClassStandard',
'UK_RoyalMailSecondClassStandard',
]);
});
it('returns an empty list when no details are present', () => {
expect(parseShippingServiceDetails('<Ack>Success</Ack>')).toEqual([]);
});
});

View File

@ -8,12 +8,21 @@ import {
isAccessTokenExpired,
getRequiredAuthConfig,
getBasicAuthHeader,
formatDebugPayload,
getMarketplaceDebugContext,
logger,
} from './shared.js';
const TOKEN_PATH = '/identity/v1/oauth2/token';
async function mintToken(marketplace, body) {
logger.debug('eBay token request', {
...getMarketplaceDebugContext(marketplace),
grantType: body.grant_type,
scope: body.scope,
});
const startedAt = Date.now();
const response = await fetch(`${getApiBaseUrl(marketplace)}${TOKEN_PATH}`, {
method: 'POST',
headers: {
@ -24,12 +33,26 @@ async function mintToken(marketplace, body) {
});
const data = await response.json();
const durationMs = Date.now() - startedAt;
if (!response.ok || data.error) {
const message = data.error_description || data.error || response.statusText;
logger.error(`eBay token request failed: ${message}`);
logger.error(`eBay token request failed: ${message}`, {
status: response.status,
durationMs,
...getMarketplaceDebugContext(marketplace),
response: formatDebugPayload(data),
});
throw new Error(`eBay token request failed: ${message}`);
}
logger.debug(`eBay token request succeeded (${durationMs}ms)`, {
...getMarketplaceDebugContext(marketplace),
grantType: body.grant_type,
expiresIn: data.expires_in,
tokenType: data.token_type,
hasRefreshToken: !!data.refresh_token,
});
return data;
}
@ -124,9 +147,18 @@ export async function refreshAuth(marketplace) {
export async function ensureAuthenticatedMarketplace(marketplace) {
if (!isAccessTokenExpired(marketplace)) {
logger.debug(`eBay access token still valid for "${marketplace.name}"`, {
...getMarketplaceDebugContext(marketplace),
expiresAt: marketplace.config?.accessTokenExpiresAt,
});
return { marketplace };
}
logger.debug(`eBay access token expired for "${marketplace.name}", refreshing`, {
...getMarketplaceDebugContext(marketplace),
expiresAt: marketplace.config?.accessTokenExpiresAt,
});
const authResult = await refreshAuth(marketplace);
return {
marketplace: {

View File

@ -3,6 +3,7 @@ import { courierServiceModel } from '../../../database/schemas/management/courie
import { makeRequest, logger } from './shared.js';
const SELLING_POLICY_PROGRAM = 'SELLING_POLICY_MANAGEMENT';
const FULFILLMENT_CATEGORY_TYPE = 'ALL_EXCLUDING_MOTORS_VEHICLES';
async function fetchOptedInPrograms(marketplace) {
const result = await makeRequest({
@ -42,11 +43,7 @@ function isPopulatedCourierService(service) {
service &&
typeof service === 'object' &&
!(service instanceof mongoose.Types.ObjectId) &&
service.name != null &&
service.courier &&
typeof service.courier === 'object' &&
!(service.courier instanceof mongoose.Types.ObjectId) &&
service.courier._reference
service.name != null
);
}
@ -62,14 +59,45 @@ async function resolveCourierServices(listing) {
const serviceIds = serviceRefs.map((service) => service?._id || service).filter(Boolean);
const services = await courierServiceModel
.find({ _id: { $in: serviceIds } })
.populate('courier')
.populate(['courier', 'marketplaces.marketplace'])
.lean();
const servicesById = new Map(services.map((service) => [String(service._id), service]));
return serviceIds.map((id) => servicesById.get(String(id))).filter(Boolean);
}
function validateCourierServices(services, listing) {
function idOf(value) {
if (value == null) return '';
if (typeof value === 'object') return String(value._id || '');
return String(value);
}
export function getCourierServiceMarketplaceMappings(service) {
if (Array.isArray(service?.marketplaces) && service.marketplaces.length) {
return service.marketplaces;
}
if (service?.marketplace) {
return [
{
marketplace: service.marketplace,
externalReference: service.externalReference,
},
];
}
return [];
}
export function getCourierServiceShippingCode(service, marketplace) {
const marketplaceId = idOf(marketplace);
const mappings = getCourierServiceMarketplaceMappings(service);
if (!marketplaceId) {
return mappings[0]?.externalReference || service?.externalReference || '';
}
const mapping = mappings.find((entry) => idOf(entry?.marketplace) === marketplaceId);
return mapping?.externalReference || '';
}
export function validateCourierServices(services, listing, marketplace) {
const activeServices = services.filter((service) => service.active !== false);
if (!activeServices.length) {
throw new Error(
@ -78,14 +106,9 @@ function validateCourierServices(services, listing) {
}
for (const service of activeServices) {
if (!service._reference) {
if (!getCourierServiceShippingCode(service, marketplace)) {
throw new Error(
`Courier service "${service.name}" requires a reference containing its eBay shipping service code.`
);
}
if (!service.courier?._reference) {
throw new Error(
`The courier for service "${service.name}" requires a reference containing its eBay shipping carrier code.`
`Courier service "${service.name}" requires an eBay shipping service code before it can be used on an eBay listing.`
);
}
}
@ -101,13 +124,12 @@ function validateCourierServices(services, listing) {
return activeServices;
}
function buildShippingService(service, index, defaultCurrency) {
function buildShippingService(service, index, defaultCurrency, marketplace) {
const cost = Number(service.costWithTax ?? service.cost ?? 0);
const additionalCost = Number(service.additionalCostWithTax ?? service.additionalCost ?? 0);
const shippingService = {
sortOrder: index + 1,
shippingCarrierCode: service.courier._reference,
shippingServiceCode: service._reference,
shippingServiceCode: getCourierServiceShippingCode(service, marketplace),
buyerResponsibleForShipping: false,
freeShipping: cost === 0 && additionalCost === 0,
shippingCost: {
@ -126,25 +148,71 @@ function buildShippingService(service, index, defaultCurrency) {
return shippingService;
}
function buildShippingOption(optionType, services, defaultCurrency) {
function buildShippingOption(optionType, services, defaultCurrency, marketplace) {
if (!services.length) return null;
return {
optionType,
costType: 'FLAT_RATE',
shippingServices: services.map((service, index) =>
buildShippingService(service, index, defaultCurrency)
buildShippingService(service, index, defaultCurrency, marketplace)
),
};
}
function buildFulfillmentPolicy(listing, marketplace, services) {
export function isDefaultFulfillmentPolicy(existingPolicy, allPolicies = []) {
if (!existingPolicy?.fulfillmentPolicyId) {
return false;
}
const existingId = String(existingPolicy.fulfillmentPolicyId);
const sources = [existingPolicy, ...allPolicies];
if (
sources.some(
(policy) =>
String(policy?.fulfillmentPolicyId) === existingId &&
(policy.categoryTypes || []).some((type) => type?.default === true)
)
) {
return true;
}
const uniqueIds = [
...new Set(
allPolicies
.filter((policy) => policy?.fulfillmentPolicyId)
.map((policy) => String(policy.fulfillmentPolicyId))
),
];
return uniqueIds.length === 1 && uniqueIds[0] === existingId;
}
export function buildCategoryTypes(existingPolicy, allPolicies = []) {
const categoryType = { name: FULFILLMENT_CATEGORY_TYPE };
// Never send default: false. eBay's GET can report false for the account-default
// policy, and updateFulfillmentPolicy then rejects it as changing default status
// (20403). Keep the default flag only when this policy is (or must remain) default.
if (isDefaultFulfillmentPolicy(existingPolicy, allPolicies)) {
categoryType.default = true;
}
return [categoryType];
}
export function buildFulfillmentPolicy(
listing,
marketplace,
services,
existingPolicy,
allPolicies = []
) {
const marketplaceId = marketplace.config?.marketplaceId || 'EBAY_GB';
const defaultCurrency = marketplace.config?.currency || listing.currency || 'GBP';
const domesticServices = services.filter((service) => !service.international);
const internationalServices = services.filter((service) => service.international);
const shippingOptions = [
buildShippingOption('DOMESTIC', domesticServices, defaultCurrency),
buildShippingOption('INTERNATIONAL', internationalServices, defaultCurrency),
buildShippingOption('DOMESTIC', domesticServices, defaultCurrency, marketplace),
buildShippingOption('INTERNATIONAL', internationalServices, defaultCurrency, marketplace),
].filter(Boolean);
const deliveryTime = Math.max(0, ...services.map((service) => Number(service.deliveryTime ?? 1)));
@ -152,7 +220,7 @@ function buildFulfillmentPolicy(listing, marketplace, services) {
name: `FarmControl ${listing._reference}`.slice(0, 64),
description: `Managed by FarmControl for listing ${listing._reference}`.slice(0, 250),
marketplaceId,
categoryTypes: [{ name: 'ALL_EXCLUDING_MOTORS_VEHICLES' }],
categoryTypes: buildCategoryTypes(existingPolicy, allPolicies),
handlingTime: { value: deliveryTime, unit: 'DAY' },
localPickup: false,
globalShipping: false,
@ -162,18 +230,61 @@ function buildFulfillmentPolicy(listing, marketplace, services) {
};
}
function getMarketplaceId(marketplace) {
return marketplace.config?.marketplaceId || 'EBAY_GB';
}
async function fetchFulfillmentPolicyByName(marketplace, name) {
return makeRequest({
marketplace,
path: '/sell/account/v1/fulfillment_policy/get_by_policy_name',
params: {
marketplace_id: marketplace.config?.marketplaceId || 'EBAY_GB',
marketplace_id: getMarketplaceId(marketplace),
name,
},
acceptableStatuses: [404],
});
}
async function fetchFulfillmentPolicies(marketplace) {
const result = await makeRequest({
marketplace,
path: '/sell/account/v1/fulfillment_policy',
params: { marketplace_id: getMarketplaceId(marketplace) },
acceptableStatuses: [404],
});
return result?.fulfillmentPolicies || [];
}
function isDefaultStatusError(err) {
return /changing the default status/i.test(err?.message || '');
}
async function updateFulfillmentPolicy(marketplace, fulfillmentPolicyId, policy) {
const path = `/sell/account/v1/fulfillment_policy/${encodeURIComponent(fulfillmentPolicyId)}`;
try {
await makeRequest({ marketplace, method: 'PUT', path, body: policy });
return;
} catch (err) {
if (policy.categoryTypes?.[0]?.default === true || !isDefaultStatusError(err)) {
throw err;
}
logger.debug(
`Retrying fulfillment policy ${fulfillmentPolicyId} with categoryTypes.default=true`
);
await makeRequest({
marketplace,
method: 'PUT',
path,
body: {
...policy,
categoryTypes: [{ name: FULFILLMENT_CATEGORY_TYPE, default: true }],
},
});
}
}
export async function syncFulfillmentPolicy(marketplace, listing) {
await ensureSellingPolicyManagement(marketplace);
@ -181,19 +292,26 @@ export async function syncFulfillmentPolicy(marketplace, listing) {
return { fulfillmentPolicyId: String(listing.fulfillmentPolicyId) };
}
const services = validateCourierServices(await resolveCourierServices(listing), listing);
const policy = buildFulfillmentPolicy(listing, marketplace, services);
const existingPolicy = await fetchFulfillmentPolicyByName(marketplace, policy.name);
const services = validateCourierServices(
await resolveCourierServices(listing),
listing,
marketplace
);
const policyName = `FarmControl ${listing._reference}`.slice(0, 64);
const [existingPolicy, allPolicies] = await Promise.all([
fetchFulfillmentPolicyByName(marketplace, policyName),
fetchFulfillmentPolicies(marketplace),
]);
const policy = buildFulfillmentPolicy(
listing,
marketplace,
services,
existingPolicy,
allPolicies
);
if (existingPolicy?.fulfillmentPolicyId) {
await makeRequest({
marketplace,
method: 'PUT',
path: `/sell/account/v1/fulfillment_policy/${encodeURIComponent(
existingPolicy.fulfillmentPolicyId
)}`,
body: policy,
});
await updateFulfillmentPolicy(marketplace, existingPolicy.fulfillmentPolicyId, policy);
logger.info(
`Updated eBay fulfillment policy "${policy.name}" (${existingPolicy.fulfillmentPolicyId})`
);

View File

@ -26,5 +26,19 @@ export {
mapOrderStatus,
mapOrderToSalesOrder,
mapBuyerToClient,
mapOrderLineItems,
mapOrderShipments,
getOrder,
createShippingFulfillment,
handleWebhook,
} from './orders.js';
export {
buildWebhookChallengeResponse,
ensureWebhookSubscriptions,
verifyNotificationSignature,
canVerifyNotificationSignature,
} from './notifications.js';
export { makeRequest as debugGet } from './shared.js';
export { syncMarketplaceMetadata } from './shippingServices.js';

View File

@ -1,9 +1,11 @@
import mongoose from 'mongoose';
import { stockLocationModel } from '../../../database/schemas/inventory/stocklocation.schema.js';
import { productStockModel } from '../../../database/schemas/inventory/productstock.schema.js';
import { FARMCONTROL_GB_SUBDIVISION_STATE, resolveEbayCountry } from './countryCodes.js';
import { syncProductCategory } from './categories.js';
import { syncFulfillmentPolicy } from './fulfillmentPolicies.js';
import { makeRequest, logger } from './shared.js';
import { marketplaceSku } from '../ids.js';
const WAREHOUSE_ADDRESS_DEFAULTS = {
GB: { city: 'London', stateOrProvince: 'England', postalCode: 'SW1A 1AA' },
@ -22,6 +24,23 @@ const LISTING_STATUS_MAP = {
UNPUBLISHED: 'draft',
};
export function toEbayCondition(condition) {
if (!condition || typeof condition !== 'string') {
return 'NEW';
}
if (condition.includes('_') || condition === condition.toUpperCase()) {
return condition.toUpperCase();
}
return condition.replace(/[A-Z]/g, (letter) => `_${letter}`).toUpperCase();
}
export function fromEbayCondition(condition) {
if (!condition || typeof condition !== 'string') {
return undefined;
}
return condition.toLowerCase().replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
}
function isPopulatedStockLocation(stockLocation) {
if (!stockLocation || typeof stockLocation !== 'object') return false;
if (stockLocation instanceof mongoose.Types.ObjectId) return false;
@ -175,23 +194,72 @@ function applyMarketplaceOfferDefaults(
if (Object.keys(listingPolicies).length) offer.listingPolicies = listingPolicies;
}
function mapVarientToInventoryItem(varient, listing) {
export function resolveListingDescription(listing, varient) {
const candidates = [
listing?.description,
varient?.productSku?.description,
listing?.title,
listing?.product?.name,
marketplaceSku(varient),
listing?._reference,
];
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim()) {
return candidate.trim();
}
}
return 'No description provided.';
}
function inventoryItemPutBody(existing, listing, sku, varient) {
const product = { ...(existing?.product || {}) };
product.title = product.title || listing?.title || sku;
product.description = resolveListingDescription(listing, varient);
const body = { product };
if (existing?.availability) body.availability = existing.availability;
body.condition = toEbayCondition(listing?.condition || existing?.condition);
if (existing?.conditionDescription) body.conditionDescription = existing.conditionDescription;
if (existing?.packageWeightAndSize) body.packageWeightAndSize = existing.packageWeightAndSize;
return body;
}
async function ensureInventoryItemDescription(marketplace, sku, listing, varient) {
const existing = await makeRequest({
marketplace,
path: `/sell/inventory/v1/inventory_item/${encodeURIComponent(sku)}`,
acceptableStatuses: [404],
});
if (!existing) return;
await makeRequest({
marketplace,
method: 'PUT',
path: `/sell/inventory/v1/inventory_item/${encodeURIComponent(sku)}`,
body: inventoryItemPutBody(existing, listing, sku, varient),
});
}
function mapVarientToInventoryItem(varient, listing, quantity = 0) {
const item = {
product: { title: listing.title || varient._reference || '' },
condition: toEbayCondition(listing?.condition),
product: {
title: listing.title || marketplaceSku(varient) || '',
description: resolveListingDescription(listing, varient),
},
availability: {
shipToLocationAvailability: { quantity: varient.inventory ?? 0 },
shipToLocationAvailability: { quantity },
},
};
if (listing.description) item.product.description = listing.description;
if (listing.imageUrls?.length) item.product.imageUrls = listing.imageUrls;
return item;
}
function mapVarientToOffer(varient, listing, marketplace, merchantLocationKey) {
const offer = {
sku: varient._reference,
sku: marketplaceSku(varient),
marketplaceId: marketplace.config?.marketplaceId || 'EBAY_GB',
format: 'FIXED_PRICE',
listingDescription: resolveListingDescription(listing, varient),
};
applyMarketplaceOfferDefaults(
offer,
@ -213,12 +281,28 @@ function mapVarientToOffer(varient, listing, marketplace, merchantLocationKey) {
return offer;
}
export async function resolveVarientQuantity(varient, listing) {
const skuId = varient.productSku?._id || varient.productSku;
if (!skuId) {
return Number(varient.inventory) || 0;
}
const locationId = listing?.stockLocation?._id || listing?.stockLocation;
const filter = { productSku: skuId };
if (locationId) {
filter.stockLocation = locationId;
}
const stocks = await productStockModel.find(filter).lean();
return stocks.reduce((sum, stock) => sum + (Number(stock.currentQuantity) || 0), 0);
}
export async function upsertInventoryItem(marketplace, varient, listing) {
const inventoryItem = mapVarientToInventoryItem(varient, listing);
const sku = marketplaceSku(varient);
const quantity = await resolveVarientQuantity(varient, listing);
const inventoryItem = mapVarientToInventoryItem(varient, listing, quantity);
const result = await makeRequest({
marketplace,
method: 'PUT',
path: `/sell/inventory/v1/inventory_item/${encodeURIComponent(varient._reference)}`,
path: `/sell/inventory/v1/inventory_item/${encodeURIComponent(sku)}`,
body: inventoryItem,
});
logger.debug('inventoryItem', inventoryItem);
@ -242,12 +326,15 @@ export async function fetchOffers(marketplace, sku) {
async function upsertOrCreateOffer(marketplace, varient, listing) {
const merchantLocationKey = await ensureMerchantLocation(marketplace, listing);
const offers = await fetchOffers(marketplace, varient._reference);
const offers = await fetchOffers(marketplace, marketplaceSku(varient));
const existingOffer = offers[0];
const price = varient.price ?? listing.price;
if (existingOffer?.offerId) {
const offerUpdate = { merchantLocationKey };
const offerUpdate = {
merchantLocationKey,
listingDescription: resolveListingDescription(listing, varient),
};
if (price != null) {
offerUpdate.pricingSummary = {
price: {
@ -309,15 +396,18 @@ export async function syncOfferAndMaybePublish(marketplace, listing, varient) {
try {
const publishResult = await publishOfferById(marketplace, offerResult.offerId);
if (publishResult?.listingId) {
return `https://www.ebay.com/itm/${publishResult.listingId}`;
return {
url: `https://www.ebay.com/itm/${publishResult.listingId}`,
listingId: publishResult.listingId,
};
}
} catch (err) {
logger.warn(
`Created offer but failed to publish for varient ${varient._reference}: ${err.message}`
`Created offer but failed to publish for varient ${marketplaceSku(varient)}: ${err.message}`
);
}
}
return '';
return { url: '', listingId: offerResult?.listingId };
}
export async function publishOfferForSku(marketplace, sku, listing) {
@ -344,6 +434,9 @@ export async function publishOfferForSku(marketplace, sku, listing) {
);
}
const listingDescription = resolveListingDescription(listing, { _reference: sku });
await ensureInventoryItemDescription(marketplace, sku, listing, { _reference: sku });
await makeRequest({
marketplace,
method: 'PUT',
@ -352,6 +445,7 @@ export async function publishOfferForSku(marketplace, sku, listing) {
...existingOffer,
merchantLocationKey,
categoryId: String(category.categoryId),
listingDescription,
listingPolicies: {
...(existingOffer.listingPolicies || {}),
fulfillmentPolicyId: fulfillmentPolicy.fulfillmentPolicyId,
@ -387,6 +481,7 @@ export function buildVarientEntry(item, offers) {
const offer = offers?.[0];
return {
_reference: item.sku,
externalReference: item.sku,
price: offer?.pricingSummary?.price?.value
? parseFloat(offer.pricingSummary.price.value)
: undefined,

View File

@ -3,11 +3,15 @@ import { syncFulfillmentPolicy } from './fulfillmentPolicies.js';
import {
buildVarientEntry,
fetchOffers,
fromEbayCondition,
resolveListingDescription,
resolveOfferState,
syncOfferAndMaybePublish,
upsertInventoryItem,
withdrawOfferById,
} from './listingVarients.js';
import { makeRequest, logger } from './shared.js';
import { marketplaceSku } from '../ids.js';
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
@ -15,17 +19,14 @@ function sleep(ms) {
async function createOrReplaceGroup(marketplace, listing, varients) {
const groupKey = listing._reference;
const variantSKUs = varients.map((v) => v._reference).filter(Boolean);
const variantSKUs = varients.map((v) => marketplaceSku(v)).filter(Boolean);
const body = {
title: listing.title || groupKey,
variantSKUs,
description: resolveListingDescription(listing),
};
if (listing.description) {
body.description = listing.description;
}
if (listing.imageUrls?.length) {
body.imageUrls = listing.imageUrls;
}
@ -78,17 +79,17 @@ async function syncGroupedListing(marketplace, listing, varients) {
await sleep(1000);
await createOrReplaceGroup(marketplace, listing, varients);
let firstPublishedUrl = '';
let firstPublished = { url: '' };
for (const varient of varients) {
try {
const publishedUrl = await syncOfferAndMaybePublish(marketplace, listing, varient);
if (publishedUrl && !firstPublishedUrl) firstPublishedUrl = publishedUrl;
const published = await syncOfferAndMaybePublish(marketplace, listing, varient);
if (published?.url && !firstPublished.url) firstPublished = published;
} catch (err) {
logger.warn(`Failed to create offer for varient ${varient._reference}: ${err.message}`);
}
}
return firstPublishedUrl;
return firstPublished;
}
async function syncListing(marketplace, listing, varients, actionLabel) {
@ -116,12 +117,25 @@ async function syncListing(marketplace, listing, varients, actionLabel) {
logger.info(
`Syncing standalone eBay inventory item "${validVarients[0]._reference}" for listing "${ref}"`
);
const url = await syncSingleVarientListing(marketplace, listingWithContext, validVarients[0]);
return { url };
const published = await syncSingleVarientListing(
marketplace,
listingWithContext,
validVarients[0]
);
return listingSyncResult(published);
}
const url = await syncGroupedListing(marketplace, listingWithContext, validVarients);
return { url };
const published = await syncGroupedListing(marketplace, listingWithContext, validVarients);
return listingSyncResult(published);
}
function listingSyncResult(published) {
if (!published) return { url: '' };
if (typeof published === 'string') return { url: published };
return {
url: published.url || '',
...(published.listingId ? { externalReference: published.listingId } : {}),
};
}
export async function createItem(marketplace, listing, varients) {
@ -132,13 +146,38 @@ export async function updateItem(marketplace, listing, varients) {
return syncListing(marketplace, listing, varients, 'update');
}
export async function deleteItem(marketplace, listing) {
const ref = listing._reference;
if (!ref) return;
export async function deleteItem(marketplace, listing, varients = []) {
const skus = varients.map((v) => marketplaceSku(v)).filter(Boolean);
for (const sku of skus) {
const offers = await fetchOffers(marketplace, sku);
for (const offer of offers) {
if (!offer?.offerId) continue;
try {
await withdrawOfferById(marketplace, offer.offerId);
} catch (err) {
logger.warn(`Failed to withdraw offer ${offer.offerId} for SKU ${sku}: ${err.message}`);
}
}
try {
await makeRequest({
marketplace,
method: 'DELETE',
path: `/sell/inventory/v1/inventory_item/${encodeURIComponent(sku)}`,
acceptableStatuses: [404],
});
} catch (err) {
logger.warn(`Failed to delete eBay inventory item "${sku}": ${err.message}`);
}
}
const ref = listing._reference;
if (ref) {
logger.info(`Deleting eBay inventory item group "${ref}"`);
await deleteGroup(marketplace, ref);
}
}
// --- Sync helpers (inbound from eBay) ---
@ -278,8 +317,10 @@ export function mapProductToListing(ebayItem) {
const varients = variants.map((v) => buildVarientEntry(v, v._offers || []));
return {
_reference: ebayItem._groupKey,
externalReference: firstPublishedOffer?.listingId || ebayItem._groupKey,
title: group.title || ebayItem._groupKey,
description: group.description,
condition: fromEbayCondition(variants[0]?.condition),
state: { type: stateType },
price,
currency,
@ -301,8 +342,10 @@ export function mapProductToListing(ebayItem) {
const varients = [buildVarientEntry(ebayItem, ebayItem._offers || [])];
return {
_reference: ebayItem.sku,
externalReference: offer?.listingId || ebayItem.sku,
title: ebayItem.product?.title || ebayItem.sku,
description: ebayItem.product?.description,
condition: fromEbayCondition(ebayItem.condition),
state: { type: stateType },
price,
currency,

View File

@ -0,0 +1,251 @@
import crypto from 'crypto';
import { getApiBaseUrl, getBasicAuthHeader, makeRequest, logger, formatDebugPayload, getMarketplaceDebugContext } from './shared.js';
const NOTIFICATION_BASE = '/commerce/notification/v1';
const APPLICATION_SCOPE =
'https://api.ebay.com/oauth/api_scope/commerce.notification.subscription';
const HANDLED_TOPIC_MATCHERS = [
'MARKETPLACE_ACCOUNT_DELETION',
'AUTHORIZATION_REVOCATION',
'ITEM_AVAILABILITY',
'ITEM_PRICE_REVISION',
'ORDER',
];
function getVerificationToken(marketplace) {
return (
marketplace.config?.verificationToken ||
marketplace.config?.verificationToken ||
''
);
}
function getWebhookEndpoint(marketplace) {
return marketplace.config?.webhookUrl || '';
}
export function buildWebhookChallengeResponse(marketplace, { challengeCode, endpoint } = {}) {
const verificationToken = getVerificationToken(marketplace);
const destination = endpoint || getWebhookEndpoint(marketplace);
if (!challengeCode || !verificationToken || !destination) {
throw new Error(
'eBay webhook challenge requires challengeCode, verificationToken, and endpoint'
);
}
const challengeResponse = crypto
.createHash('sha256')
.update(challengeCode + verificationToken + destination)
.digest('hex');
return { challengeResponse };
}
async function getApplicationToken(marketplace) {
logger.debug('eBay application token request', getMarketplaceDebugContext(marketplace));
const startedAt = Date.now();
const response = await fetch(`${getApiBaseUrl(marketplace)}/identity/v1/oauth2/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: getBasicAuthHeader(marketplace),
},
body: new URLSearchParams({
grant_type: 'client_credentials',
scope: APPLICATION_SCOPE,
}).toString(),
});
const data = await response.json();
const durationMs = Date.now() - startedAt;
if (!response.ok || data.error) {
logger.error('eBay application token request failed', {
status: response.status,
durationMs,
...getMarketplaceDebugContext(marketplace),
response: formatDebugPayload(data),
});
throw new Error(data.error_description || data.error || 'Failed to mint eBay application token');
}
logger.debug(`eBay application token request succeeded (${durationMs}ms)`, {
...getMarketplaceDebugContext(marketplace),
expiresIn: data.expires_in,
tokenType: data.token_type,
});
return data.access_token;
}
async function notificationRequest(marketplace, { method = 'GET', path, body, useApplicationToken = false }) {
if (!useApplicationToken) {
return makeRequest({ marketplace, method, path, body, acceptableStatuses: [404] });
}
const token = await getApplicationToken(marketplace);
const url = `${getApiBaseUrl(marketplace)}${path}`;
const startedAt = Date.now();
logger.debug(`eBay notification API ${method} ${path}`, {
...getMarketplaceDebugContext(marketplace),
useApplicationToken: true,
body: body ? formatDebugPayload(body) : undefined,
});
const response = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
...(body ? { 'Content-Type': 'application/json' } : {}),
},
...(body && method !== 'GET' ? { body: JSON.stringify(body) } : {}),
});
const durationMs = Date.now() - startedAt;
if (response.status === 204 || response.status === 404) {
logger.debug(
`eBay notification API ${method} ${path} -> ${response.status} (${durationMs}ms)`,
getMarketplaceDebugContext(marketplace)
);
return response.status === 404 ? null : null;
}
const data = await response.json().catch(() => null);
if (!response.ok) {
const message = data?.errors?.[0]?.message || data?.error_description || response.statusText;
logger.error(`eBay notification API error (${response.status}): ${message}`, {
path,
durationMs,
...getMarketplaceDebugContext(marketplace),
response: data ? formatDebugPayload(data) : undefined,
});
throw new Error(`eBay notification API error (${response.status}): ${message}`);
}
logger.debug(`eBay notification API ${method} ${path} -> ${response.status} (${durationMs}ms)`, {
...getMarketplaceDebugContext(marketplace),
response: data ? formatDebugPayload(data) : undefined,
});
return data;
}
function topicIsHandled(topicId = '') {
const id = String(topicId).toUpperCase();
return HANDLED_TOPIC_MATCHERS.some((matcher) => id.includes(matcher));
}
export async function ensureWebhookSubscriptions(marketplace) {
const endpoint = getWebhookEndpoint(marketplace);
const verificationToken = getVerificationToken(marketplace);
if (!endpoint || !verificationToken) {
logger.warn(
`Skipping eBay webhook subscription for "${marketplace.name}" (missing webhookUrl or verificationToken)`
);
return { skipped: true };
}
const destinations = await notificationRequest(marketplace, {
path: `${NOTIFICATION_BASE}/destination`,
});
let destination = (destinations?.destinations || []).find(
(item) => item.deliveryConfig?.endpoint === endpoint
);
if (!destination) {
destination = await notificationRequest(marketplace, {
method: 'POST',
path: `${NOTIFICATION_BASE}/destination`,
body: {
name: `FarmControl ${marketplace.name}`.slice(0, 200),
status: 'ENABLED',
deliveryConfig: {
endpoint,
verificationToken,
},
},
});
}
const destinationId = destination?.destinationId || destination?.id;
if (!destinationId) {
throw new Error('Failed to create or resolve eBay notification destination');
}
const topics = await notificationRequest(marketplace, { path: `${NOTIFICATION_BASE}/topic` });
const subscriptions = await notificationRequest(marketplace, {
path: `${NOTIFICATION_BASE}/subscription`,
});
const existingTopicIds = new Set(
(subscriptions?.subscriptions || []).map((item) => item.topicId)
);
const created = [];
for (const topic of topics?.topics || []) {
if (topic.status && String(topic.status).toUpperCase() !== 'ENABLED') continue;
if (!topicIsHandled(topic.topicId)) continue;
if (existingTopicIds.has(topic.topicId)) continue;
const useApplicationToken = String(topic.scope || '').toUpperCase() === 'APPLICATION';
try {
await notificationRequest(marketplace, {
method: 'POST',
path: `${NOTIFICATION_BASE}/subscription`,
useApplicationToken,
body: {
topicId: topic.topicId,
status: 'ENABLED',
destinationId,
payload: { format: 'JSON', schemaVersion: topic.supportedPayloads?.[0]?.schemaVersion || '1.0' },
},
});
created.push(topic.topicId);
} catch (err) {
logger.warn(`Failed to subscribe to eBay topic ${topic.topicId}: ${err.message}`);
}
}
logger.info(
`eBay webhook destination ${destinationId} ready for "${marketplace.name}" (${created.length} new subscription(s))`
);
return { destinationId, created };
}
async function getPublicKey(marketplace, kid) {
return makeRequest({
marketplace,
path: `${NOTIFICATION_BASE}/public_key/${encodeURIComponent(kid)}`,
});
}
export async function verifyNotificationSignature(marketplace, rawBody, signatureHeader) {
if (!signatureHeader) {
return false;
}
try {
const decodedJson = Buffer.from(signatureHeader, 'base64').toString('utf8');
const decoded = JSON.parse(decodedJson);
const kid = decoded.kid;
const signature = decoded.signature;
if (!kid || !signature) {
return false;
}
const publicKey = await getPublicKey(marketplace, kid);
const pem = publicKey?.key;
if (!pem) {
return false;
}
const digest = (decoded.digest || publicKey.digest || 'SHA256').replace('-', '');
const verifier = crypto.createVerify(digest);
verifier.update(rawBody);
verifier.end();
return verifier.verify(pem, signature, 'base64');
} catch (err) {
logger.warn(`eBay notification signature verification failed: ${err.message}`);
return false;
}
}
export function canVerifyNotificationSignature(marketplace) {
return !!marketplace.config?.accessToken;
}

View File

@ -90,6 +90,7 @@ export function mapOrderToSalesOrder(ebayOrder) {
const grandTotal = parseFloat(pricingSummary.total?.value || 0);
return {
externalReference: ebayOrder.orderId,
externalId: ebayOrder.orderId,
state: { type: mapOrderStatus(ebayOrder) },
totalAmount,
@ -112,6 +113,7 @@ export function mapBuyerToClient(ebayOrder) {
name: fullName,
email: buyer.buyerRegistrationAddress?.email || '',
phone: address.primaryPhone?.phoneNumber || '',
externalReference: buyer.username || '',
address: {
addressLine1: contactAddress.addressLine1 || '',
addressLine2: contactAddress.addressLine2 || '',
@ -123,34 +125,131 @@ export function mapBuyerToClient(ebayOrder) {
};
}
export async function getOrder(marketplace, orderId) {
const order = await makeRequest({
marketplace,
path: `/sell/fulfillment/v1/order/${encodeURIComponent(orderId)}`,
});
const fulfillments = await makeRequest({
marketplace,
path: `/sell/fulfillment/v1/order/${encodeURIComponent(orderId)}/shipping_fulfillment`,
acceptableStatuses: [404],
});
return {
...order,
shippingFulfillments: fulfillments?.fulfillments || [],
};
}
export function mapOrderLineItems(ebayOrder) {
return (ebayOrder.lineItems || []).map((line) => {
const unit = parseFloat(line.lineItemCost?.value || 0);
const tax = (line.taxes || []).reduce(
(sum, taxLine) => sum + parseFloat(taxLine.amount?.value || 0),
0
);
const quantity = line.quantity || 1;
return {
externalReference: line.lineItemId,
sku: line.sku || '',
name: line.title || line.sku || 'Marketplace item',
quantity,
itemAmount: unit,
totalAmount: unit * quantity,
totalAmountWithTax: unit * quantity + tax,
itemType: 'product',
};
});
}
export function mapOrderShipments(ebayOrder) {
const shippingAmount = parseFloat(ebayOrder.pricingSummary?.deliveryCost?.value || 0);
const fulfillments = ebayOrder.shippingFulfillments || [];
if (!fulfillments.length) {
return [];
}
const splitAmount = shippingAmount / fulfillments.length;
return fulfillments.map((fulfillment) => ({
externalReference: fulfillment.fulfillmentId,
trackingNumber: fulfillment.shipmentTrackingNumber || '',
amount: splitAmount,
amountWithTax: splitAmount,
state: { type: fulfillment.shippedDate ? 'shipped' : 'planned' },
shippedAt: fulfillment.shippedDate ? new Date(fulfillment.shippedDate) : undefined,
lineItemIds: (fulfillment.lineItems || []).map((line) => line.lineItemId).filter(Boolean),
}));
}
export async function createShippingFulfillment(marketplace, { orderId, lineItems, trackingNumber, shippingCarrierCode }) {
if (!orderId) {
throw new Error('orderId is required to create an eBay shipping fulfillment');
}
if (!trackingNumber) {
throw new Error('trackingNumber is required to create an eBay shipping fulfillment');
}
return makeRequest({
marketplace,
method: 'POST',
path: `/sell/fulfillment/v1/order/${encodeURIComponent(orderId)}/shipping_fulfillment`,
body: {
lineItems: (lineItems || []).map((line) => ({
lineItemId: line.lineItemId || line.externalReference,
quantity: line.quantity || 1,
})),
shippingCarrierCode: shippingCarrierCode || 'Other',
trackingNumber,
},
});
}
export async function handleWebhook(marketplace, event) {
const { topic, data } = event;
const topic =
event?.metadata?.topic ||
event?.topic ||
event?.notification?.topic ||
'';
const data = event?.notification?.data || event?.data || {};
const notificationId =
event?.notification?.notificationId || event?.notificationId || '';
logger.info(`eBay webhook received: ${topic} for marketplace ${marketplace.name}`);
if (topic?.startsWith('marketplace.account_deletion')) {
return { action: 'accountDeletion', userId: data?.userId };
const orderId = data.orderId || data.order?.orderId || data.legacyOrderId;
const sku = data.sku || data.itemId || data.listingId;
const normalized = String(topic).toUpperCase();
if (normalized.includes('ACCOUNT_DELETION') || normalized.includes('MARKETPLACE_ACCOUNT_DELETION')) {
return {
action: 'accountDeletion',
userId: data.userId || data.username,
notificationId,
topic,
};
}
switch (topic) {
case 'item.sold':
return { action: 'orderCreate', orderId: data?.orderId };
if (normalized.includes('AUTHORIZATION_REVOCATION')) {
return { action: 'disconnect', notificationId, topic };
}
case 'item.created':
case 'item.updated':
return { action: 'productUpdate', itemId: data?.itemId };
if (normalized.includes('ORDER') || normalized === 'ITEM.SOLD' || normalized === 'ORDER.FULFILLMENT') {
return {
action: orderId && topic.toLowerCase().includes('cancel') ? 'orderCancel' : 'orderUpdate',
orderId,
notificationId,
topic,
};
}
case 'item.ended':
return { action: 'productEnded', itemId: data?.itemId };
if (
normalized.includes('ITEM') ||
normalized.includes('AVAILABILITY') ||
normalized.includes('PRICE')
) {
return { action: 'productUpdate', itemId: sku, sku, notificationId, topic };
}
case 'order.cancelled':
return { action: 'orderCancel', orderId: data?.orderId };
case 'order.fulfillment':
return { action: 'orderUpdate', orderId: data?.orderId };
default:
logger.debug(`Unhandled eBay webhook topic: ${topic}`);
return { action: 'unknown', topic };
}
return { action: 'unknown', topic, notificationId };
}

View File

@ -4,6 +4,35 @@ import log4js from 'log4js';
const logger = log4js.getLogger('eBay');
logger.level = config.server.logLevel;
const DEBUG_PAYLOAD_MAX_LENGTH = 4000;
function formatDebugPayload(value, { maxLength = DEBUG_PAYLOAD_MAX_LENGTH } = {}) {
if (value === null || value === undefined) {
return value;
}
let text;
try {
text = typeof value === 'string' ? value : JSON.stringify(value);
} catch {
text = String(value);
}
if (text.length <= maxLength) {
return text;
}
return `${text.slice(0, maxLength)}... [truncated ${text.length - maxLength} chars]`;
}
function getMarketplaceDebugContext(marketplace) {
return {
marketplace: marketplace?.name,
marketplaceId: marketplace?.config?.marketplaceId,
sandbox: marketplace?.config?.sandbox ?? false,
};
}
const SANDBOX_API_URL = 'https://api.sandbox.ebay.com';
const PRODUCTION_API_URL = 'https://api.ebay.com';
const SANDBOX_AUTH_URL = 'https://auth.sandbox.ebay.com';
@ -14,6 +43,7 @@ const DEFAULT_SCOPES = [
'https://api.ebay.com/oauth/api_scope/sell.inventory',
'https://api.ebay.com/oauth/api_scope/sell.fulfillment',
'https://api.ebay.com/oauth/api_scope/sell.account',
'https://api.ebay.com/oauth/api_scope/commerce.notification.subscription',
];
const MARKETPLACE_LANGUAGE_MAP = {
EBAY_US: 'en-US',
@ -144,11 +174,21 @@ export async function makeRequest({
fetchOptions.body = JSON.stringify(body);
}
logger.debug(`eBay API ${method} ${path}`);
const startedAt = Date.now();
logger.debug(`eBay API ${method} ${path}`, {
...getMarketplaceDebugContext(marketplace),
params: Object.keys(params).length ? params : undefined,
body: body ? formatDebugPayload(body) : undefined,
acceptableStatuses: acceptableStatuses.length ? acceptableStatuses : undefined,
});
const response = await fetch(url, fetchOptions);
const durationMs = Date.now() - startedAt;
if (response.status === 204) {
logger.debug(`eBay API ${method} ${path} -> 204 No Content (${durationMs}ms)`, {
...getMarketplaceDebugContext(marketplace),
});
return null;
}
@ -163,19 +203,40 @@ export async function makeRequest({
}
if (!response.ok && acceptableStatuses.includes(response.status)) {
logger.debug(
`eBay API ${method} ${path} -> ${response.status} (acceptable) (${durationMs}ms)`,
{
...getMarketplaceDebugContext(marketplace),
response: data ? formatDebugPayload(data) : undefined,
}
);
return null;
}
if (!response.ok) {
const message =
(typeof data === 'object' && (data?.errors?.[0]?.message || data?.error_description)) ||
(typeof data === 'object' &&
(data?.errors?.[0]?.longMessage ||
data?.errors?.[0]?.message ||
data?.error_description)) ||
(typeof data === 'string' && data) ||
response.statusText;
logger.error(`eBay API error: ${message}`, { status: response.status, path });
logger.error(`eBay API error: ${message}`, {
status: response.status,
path,
durationMs,
...getMarketplaceDebugContext(marketplace),
response: data ? formatDebugPayload(data) : undefined,
});
throw new Error(`eBay API error (${response.status}): ${message}`);
}
logger.debug(`eBay API ${method} ${path} -> ${response.status} (${durationMs}ms)`, {
...getMarketplaceDebugContext(marketplace),
response: data ? formatDebugPayload(data) : undefined,
});
return data;
}
export { logger };
export { logger, formatDebugPayload, getMarketplaceDebugContext };

View File

@ -0,0 +1,144 @@
import { logger, getMarketplaceDebugContext } from './shared.js';
const TRADING_COMPATIBILITY_LEVEL = '1399';
const TRADING_SANDBOX_URL = 'https://api.sandbox.ebay.com/ws/api.dll';
const TRADING_PRODUCTION_URL = 'https://api.ebay.com/ws/api.dll';
const MARKETPLACE_SITE_IDS = {
EBAY_US: '0',
EBAY_ENCA: '2',
EBAY_GB: '3',
EBAY_AU: '15',
EBAY_AT: '16',
EBAY_FRBE: '23',
EBAY_FR: '71',
EBAY_DE: '77',
EBAY_IT: '101',
EBAY_NLBE: '123',
EBAY_NL: '146',
EBAY_ES: '186',
EBAY_CH: '193',
EBAY_HK: '201',
EBAY_IN: '203',
EBAY_IE: '205',
EBAY_MY: '207',
EBAY_PH: '211',
EBAY_PL: '212',
EBAY_SG: '216',
};
function getTradingApiUrl(marketplace) {
return marketplace.config?.sandbox ? TRADING_SANDBOX_URL : TRADING_PRODUCTION_URL;
}
function getSiteId(marketplace) {
const marketplaceId = marketplace.config?.marketplaceId || 'EBAY_GB';
return MARKETPLACE_SITE_IDS[marketplaceId] || MARKETPLACE_SITE_IDS.EBAY_GB;
}
export function parseShippingServiceDetails(xml = '') {
const blocks = xml.match(/<ShippingServiceDetails>[\s\S]*?<\/ShippingServiceDetails>/g) || [];
const services = [];
const seen = new Set();
for (const block of blocks) {
const validMatch = block.match(/<ValidForSellingFlow>([^<]+)<\/ValidForSellingFlow>/i);
if (validMatch && String(validMatch[1]).toLowerCase() !== 'true') {
continue;
}
const serviceMatch = block.match(/<ShippingService>([^<]+)<\/ShippingService>/i);
const code = serviceMatch?.[1]?.trim();
if (!code || seen.has(code)) continue;
seen.add(code);
services.push(code);
}
return services.sort((a, b) => a.localeCompare(b));
}
function buildGeteBayDetailsRequest() {
return `<?xml version="1.0" encoding="utf-8"?>
<GeteBayDetailsRequest xmlns="urn:ebay:apis:eBLBaseComponents">
<ErrorLanguage>en_GB</ErrorLanguage>
<WarningLevel>High</WarningLevel>
<DetailName>ShippingServiceDetails</DetailName>
</GeteBayDetailsRequest>`;
}
export async function fetchEbayShippingServices(marketplace) {
const accessToken = marketplace.config?.accessToken;
if (!accessToken) {
throw new Error(
'eBay marketplace is not authenticated. Complete marketplace authorization first.'
);
}
const url = getTradingApiUrl(marketplace);
const siteId = getSiteId(marketplace);
const startedAt = Date.now();
logger.debug('eBay Trading API GeteBayDetails request', {
...getMarketplaceDebugContext(marketplace),
siteId,
url,
});
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'text/xml',
'X-EBAY-API-CALL-NAME': 'GeteBayDetails',
'X-EBAY-API-SITEID': siteId,
'X-EBAY-API-COMPATIBILITY-LEVEL': TRADING_COMPATIBILITY_LEVEL,
'X-EBAY-API-IAF-TOKEN': accessToken,
},
body: buildGeteBayDetailsRequest(),
});
const xml = await response.text();
const durationMs = Date.now() - startedAt;
if (!response.ok) {
logger.error(`GeteBayDetails failed (${response.status})`, {
durationMs,
...getMarketplaceDebugContext(marketplace),
siteId,
});
throw new Error(`eBay GeteBayDetails error (${response.status})`);
}
const ackMatch = xml.match(/<Ack>([^<]+)<\/Ack>/);
const ack = ackMatch?.[1];
if (ack && ack !== 'Success' && ack !== 'Warning') {
const message =
xml.match(/<LongMessage>([^<]+)<\/LongMessage>/)?.[1] ||
xml.match(/<ShortMessage>([^<]+)<\/ShortMessage>/)?.[1] ||
ack;
logger.error(`GeteBayDetails failed: ${message}`, {
ack,
durationMs,
...getMarketplaceDebugContext(marketplace),
siteId,
});
throw new Error(`eBay GeteBayDetails failed: ${message}`);
}
const services = parseShippingServiceDetails(xml);
logger.debug(`GeteBayDetails succeeded (${durationMs}ms)`, {
...getMarketplaceDebugContext(marketplace),
siteId,
serviceCount: services.length,
});
logger.info(
`Fetched ${services.length} eBay shipping service(s) for marketplace "${marketplace.name}"`
);
return services;
}
export async function syncMarketplaceMetadata(marketplace) {
const availableShippingServices = await fetchEbayShippingServices(marketplace);
return {
eBay: {
...(marketplace.eBay && typeof marketplace.eBay === 'object' ? marketplace.eBay : {}),
availableShippingServices,
},
};
}

View File

@ -0,0 +1,9 @@
export function marketplaceSku(record) {
return record?.externalReference || record?._reference;
}
export function marketplaceActor(marketplace) {
const plain =
typeof marketplace?.toObject === 'function' ? marketplace.toObject() : { ...marketplace };
return { ...plain, _objectType: 'marketplace' };
}

View File

@ -429,8 +429,8 @@ function mapListingToProduct(listing) {
};
}
if (listing.externalId) {
sku.seller_sku = listing.externalId;
if (listing.externalReference || listing.externalId) {
sku.seller_sku = listing.externalReference || listing.externalId;
}
product.skus = [sku];
@ -452,15 +452,16 @@ export async function createItem(marketplace, listing) {
const productId = result?.product_id || result?.id;
return {
externalReference: productId,
externalId: productId,
url: '',
};
}
export async function updateItem(marketplace, listing) {
const productId = listing.externalId;
const productId = listing.externalReference || listing.externalId;
if (!productId) {
throw new Error('Listing must have an externalId to update on TikTok Shop');
throw new Error('Listing must have an externalReference to update on TikTok Shop');
}
logger.info(`Updating TikTok Shop product: ${productId}`);
@ -473,7 +474,7 @@ export async function updateItem(marketplace, listing) {
body: productBody,
});
return { externalId: productId };
return { externalReference: productId, externalId: productId };
}
export async function syncItems(marketplace) {
@ -537,6 +538,7 @@ export function mapOrderToSalesOrder(tiktokOrder) {
const totalTax = parseFloat(paymentInfo.tax || 0);
return {
externalReference: tiktokOrder.id,
externalId: tiktokOrder.id,
state: { type: mapOrderStatus(tiktokOrder.status) },
totalAmount,
@ -579,6 +581,7 @@ export function mapProductToListing(tiktokProduct) {
: undefined;
return {
_reference: sku.id || sku.seller_sku || '',
externalReference: sku.seller_sku || sku.id || '',
price: skuPrice,
currency: sku.price?.currency || currency,
state: { type: PRODUCT_STATUS_MAP[tiktokProduct.status] || 'draft' },
@ -586,6 +589,7 @@ export function mapProductToListing(tiktokProduct) {
});
return {
externalReference: tiktokProduct.id,
_reference: tiktokProduct.id,
title: tiktokProduct.title || '',
state: { type: PRODUCT_STATUS_MAP[tiktokProduct.status] || 'draft' },
@ -602,6 +606,7 @@ export function mapBuyerToClient(tiktokOrder) {
const address = tiktokOrder.recipient_address || {};
return {
externalReference: buyer.open_id || buyer.username || '',
name: buyer.name || address.name || 'Unknown',
email: buyer.email || '',
phone: buyer.phone_number || address.phone_number || '',
@ -616,6 +621,42 @@ export function mapBuyerToClient(tiktokOrder) {
};
}
export async function getOrder(marketplace, orderId) {
return fetchOrderDetail(marketplace, orderId);
}
export function mapOrderLineItems(tiktokOrder) {
return (tiktokOrder.line_items || []).map((line) => {
const quantity = Number(line.quantity || 1);
const unit = parseFloat(line.sale_price || line.original_price || 0);
return {
externalReference: String(line.id || line.sku_id || ''),
sku: line.seller_sku || line.sku_id || '',
name: line.product_name || line.sku_name || 'Marketplace item',
quantity,
itemAmount: unit,
totalAmount: unit * quantity,
totalAmountWithTax: unit * quantity,
itemType: 'product',
};
});
}
export function mapOrderShipments(tiktokOrder) {
const packages = tiktokOrder.packages || [];
const shippingAmount = parseFloat(tiktokOrder.payment?.shipping_fee || 0);
const split = packages.length ? shippingAmount / packages.length : 0;
return packages
.filter((pkg) => pkg.id || pkg.tracking_number)
.map((pkg) => ({
externalReference: String(pkg.id || pkg.tracking_number),
trackingNumber: pkg.tracking_number || '',
amount: split,
amountWithTax: split,
state: { type: pkg.tracking_number ? 'shipped' : 'planned' },
}));
}
export async function handleWebhook(marketplace, event) {
const { type, data } = event;

View File

@ -1,13 +1,19 @@
import config from '../config.js';
import log4js from 'log4js';
import { clientModel } from '../database/schemas/sales/client.schema.js';
import { salesOrderModel } from '../database/schemas/sales/salesorder.schema.js';
import { listingModel } from '../database/schemas/sales/listing.schema.js';
import { listingVarientModel } from '../database/schemas/sales/listingvarient.schema.js';
import { marketplaceModel } from '../database/schemas/sales/marketplace.schema.js';
import { editObject, newObject } from '../database/database.js';
import { editObject } from '../database/database.js';
import * as tiktokShop from './marketplaces/tiktokShop.js';
import * as ebay from './marketplaces/ebay/index.js';
import {
marketplaceActor,
marketplaceSku,
upsertExternalOrder,
importExternalItems,
applyWebhookAction,
pushShipmentFulfillment,
} from './marketplaceSync.js';
const logger = log4js.getLogger('Marketplace Worker');
logger.level = config.server.logLevel;
@ -128,8 +134,17 @@ export async function exchangeAuthorizationCode(marketplace, user, { code, state
user
);
let marketplaceWithAuth = updatedMarketplace;
try {
marketplaceWithAuth = await syncMarketplaceMetadata(marketplaceWithAuth, user);
} catch (err) {
logger.warn(
`Failed to sync marketplace metadata after authorization for "${marketplace.name}": ${err.message}`
);
}
return {
marketplace: updatedMarketplace,
marketplace: marketplaceWithAuth,
...(authResult?.data ? { data: authResult.data } : {}),
};
}
@ -156,10 +171,17 @@ export async function refreshMarketplaceAuth(marketplace, user) {
export function canVerifyWebhookSignature(marketplace) {
const provider = getProvider(marketplace);
if (typeof provider.verifyWebhookSignature !== 'function') {
if (
typeof provider.verifyWebhookSignature !== 'function' &&
typeof provider.verifyNotificationSignature !== 'function'
) {
return false;
}
if (typeof provider.canVerifyNotificationSignature === 'function') {
return provider.canVerifyNotificationSignature(marketplace);
}
if (typeof provider.canVerifyWebhookSignature === 'function') {
return provider.canVerifyWebhookSignature(marketplace);
}
@ -169,6 +191,9 @@ export function canVerifyWebhookSignature(marketplace) {
export function verifyWebhookSignature(marketplace, rawBody, signature) {
const provider = getProvider(marketplace);
if (typeof provider.verifyNotificationSignature === 'function') {
return provider.verifyNotificationSignature(marketplace, rawBody, signature);
}
if (!provider.verifyWebhookSignature) {
logger.warn(`Provider ${marketplace.provider} does not support webhook signature verification`);
return true;
@ -177,9 +202,67 @@ export function verifyWebhookSignature(marketplace, rawBody, signature) {
return provider.verifyWebhookSignature(marketplace, rawBody, signature);
}
export async function handleWebhook(marketplace, event) {
export async function handleWebhook(marketplace, event, { rawBody, signature } = {}) {
const provider = getProvider(marketplace);
return provider.handleWebhook(marketplace, event);
const actor = marketplaceActor(marketplace);
if (signature && (await canVerifyWebhookSignature(marketplace))) {
const valid = await verifyWebhookSignature(marketplace, rawBody || JSON.stringify(event), signature);
if (!valid) {
const error = new Error('Invalid webhook signature');
error.status = 401;
throw error;
}
}
const classified = await provider.handleWebhook(marketplace, event);
return applyWebhookAction(marketplace, provider, classified, actor);
}
export function buildWebhookChallengeResponse(marketplace, query) {
const provider = getProvider(marketplace);
if (typeof provider.buildWebhookChallengeResponse !== 'function') {
return null;
}
return provider.buildWebhookChallengeResponse(marketplace, query);
}
export async function syncMarketplaceMetadata(marketplace, user) {
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
const provider = getProvider(authenticatedMarketplace);
if (typeof provider.syncMarketplaceMetadata !== 'function') {
return authenticatedMarketplace;
}
const metadataUpdates = await provider.syncMarketplaceMetadata(authenticatedMarketplace);
if (!metadataUpdates || !Object.keys(metadataUpdates).length) {
return authenticatedMarketplace;
}
return persistMarketplaceUpdate(authenticatedMarketplace, {}, metadataUpdates, user);
}
export async function ensureWebhookSubscriptions(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 = {}) {
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
const provider = getProvider(authenticatedMarketplace);
if (typeof provider.debugGet !== 'function') {
throw new Error(`Provider ${marketplace.provider} does not support debug GET`);
}
if (!String(path).startsWith('/sell/') && !String(path).startsWith('/commerce/')) {
throw new Error('Debug proxy only allows /sell and /commerce paths');
}
return provider.debugGet({
marketplace: authenticatedMarketplace,
path,
params,
});
}
async function setListingState(listingId, stateType, user, message) {
@ -262,13 +345,18 @@ export function createListing(marketplace, user, listingData) {
if (listingData._id) {
const updateData = { lastSyncedAt: new Date(), state: { type: 'active' } };
if (result?.url) updateData.url = result.url;
if (result?.externalReference) updateData.externalReference = result.externalReference;
await editObject({ model: listingModel, id: listingData._id, updateData, user });
for (const varient of varients) {
const varientUpdate = { lastSyncedAt: new Date(), state: { type: 'active' } };
if (!varient.externalReference && marketplaceSku(varient)) {
varientUpdate.externalReference = marketplaceSku(varient);
}
await editObject({
model: listingVarientModel,
id: varient._id,
updateData: { lastSyncedAt: new Date(), state: { type: 'active' } },
updateData: varientUpdate,
user,
}).catch(() => {});
}
@ -312,21 +400,28 @@ export function updateListing(marketplace, user, listingData) {
logger.info(
`Updating listing on marketplace "${marketplace.name}" (${marketplace.provider})`
);
await provider.updateItem(authenticatedMarketplace, fullListing, varients);
const result = await provider.updateItem(authenticatedMarketplace, fullListing, varients);
if (listingData._id) {
const updateData = { state: { type: 'active' }, lastSyncedAt: new Date() };
if (result?.url) updateData.url = result.url;
if (result?.externalReference) updateData.externalReference = result.externalReference;
await editObject({
model: listingModel,
id: listingData._id,
updateData: { state: { type: 'active' }, lastSyncedAt: new Date() },
updateData,
user,
});
for (const varient of varients) {
const varientUpdate = { lastSyncedAt: new Date(), state: { type: 'active' } };
if (!varient.externalReference && marketplaceSku(varient)) {
varientUpdate.externalReference = marketplaceSku(varient);
}
await editObject({
model: listingVarientModel,
id: varient._id,
updateData: { lastSyncedAt: new Date(), state: { type: 'active' } },
updateData: varientUpdate,
user,
}).catch(() => {});
}
@ -359,7 +454,10 @@ export function deleteListing(marketplace, user, listingData) {
logger.info(
`Deleting listing from marketplace "${marketplace.name}" (${marketplace.provider})`
);
await provider.deleteItem(authenticatedMarketplace, listingData);
const varients = listingData._id
? await fetchListingVarients(listingData._id)
: [];
await provider.deleteItem(authenticatedMarketplace, listingData, varients);
logger.info(`Background deleteListing complete for marketplace "${marketplace.name}"`);
} catch (err) {
logger.error(
@ -380,10 +478,29 @@ export function syncItems(marketplace, user) {
try {
const provider = getProvider(marketplace);
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
const actor = marketplaceActor(authenticatedMarketplace);
logger.info(
`Starting item sync for marketplace "${marketplace.name}" (${marketplace.provider})`
);
if (typeof provider.syncMarketplaceMetadata === 'function') {
try {
const metadataUpdates = await provider.syncMarketplaceMetadata(authenticatedMarketplace);
if (metadataUpdates && Object.keys(metadataUpdates).length) {
Object.assign(
authenticatedMarketplace,
await persistMarketplaceUpdate(authenticatedMarketplace, {}, metadataUpdates, user)
);
}
} catch (err) {
logger.warn(
`Failed to sync marketplace metadata for "${marketplace.name}": ${err.message}`
);
}
}
await importExternalItems(authenticatedMarketplace, provider, actor);
const existingListings = await listingModel
.find({
marketplace: authenticatedMarketplace._id,
@ -428,6 +545,9 @@ export function syncItems(marketplace, user) {
if (result?.url) {
listingUpdateData.url = result.url;
}
if (result?.externalReference) {
listingUpdateData.externalReference = result.externalReference;
}
await editObject({
model: listingModel,
@ -443,6 +563,9 @@ export function syncItems(marketplace, user) {
updateData: {
lastSyncedAt: new Date(),
state: varient.state || { type: 'draft' },
...(!varient.externalReference && marketplaceSku(varient)
? { externalReference: marketplaceSku(varient) }
: {}),
},
user,
}).catch(() => {});
@ -490,6 +613,7 @@ export function syncOrders(marketplace, user, { startTime, endTime } = {}) {
try {
const provider = getProvider(marketplace);
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
const actor = marketplaceActor(authenticatedMarketplace);
logger.info(
`Starting order sync for marketplace "${marketplace.name}" (${marketplace.provider})`
);
@ -502,75 +626,24 @@ export function syncOrders(marketplace, user, { startTime, endTime } = {}) {
for (const externalOrder of externalOrders) {
try {
const mapped = provider.mapOrderToSalesOrder(externalOrder);
const clientData = provider.mapBuyerToClient(externalOrder);
let client = await clientModel.findOne({
name: clientData.name,
marketplace: authenticatedMarketplace._id,
});
if (!client) {
client = await newObject({
model: clientModel,
newData: {
...clientData,
marketplace: authenticatedMarketplace._id,
active: true,
},
user,
});
logger.debug(`Created client "${clientData.name}" from marketplace order`);
}
const existingOrder = await salesOrderModel.findOne({
marketplace: authenticatedMarketplace._id,
_reference: mapped.externalId,
});
if (existingOrder) {
await editObject({
model: salesOrderModel,
id: existingOrder._id,
updateData: {
state: mapped.state,
totalAmount: mapped.totalAmount,
totalAmountWithTax: mapped.totalAmountWithTax,
shippingAmount: mapped.shippingAmount,
shippingAmountWithTax: mapped.shippingAmountWithTax,
grandTotalAmount: mapped.grandTotalAmount,
totalTaxAmount: mapped.totalTaxAmount,
updatedAt: new Date(),
},
user,
});
const result = await upsertExternalOrder(
authenticatedMarketplace,
provider,
externalOrder,
actor
);
results.push({
externalId: mapped.externalId,
action: 'updated',
id: existingOrder._id,
externalReference: result.externalReference,
action: result.action,
id: result.salesOrder?._id,
});
} else {
const salesOrder = await newObject({
model: salesOrderModel,
newData: {
_reference: mapped.externalId,
client: client._id,
marketplace: authenticatedMarketplace._id,
state: mapped.state,
totalAmount: mapped.totalAmount,
totalAmountWithTax: mapped.totalAmountWithTax,
shippingAmount: mapped.shippingAmount,
shippingAmountWithTax: mapped.shippingAmountWithTax,
grandTotalAmount: mapped.grandTotalAmount,
totalTaxAmount: mapped.totalTaxAmount,
},
user,
});
results.push({ externalId: mapped.externalId, action: 'created', id: salesOrder._id });
}
} catch (err) {
logger.warn(`Failed to process order: ${err.message}`);
results.push({ externalId: externalOrder.id, action: 'error', error: err.message });
results.push({
externalId: externalOrder.id || externalOrder.orderId,
action: 'error',
error: err.message,
});
}
}
@ -589,3 +662,16 @@ export function syncOrders(marketplace, user, { startTime, endTime } = {}) {
work();
}
export async function pushMarketplaceShipmentFulfillment(marketplace, user, shipment) {
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
const provider = getProvider(authenticatedMarketplace);
return pushShipmentFulfillment(
authenticatedMarketplace,
provider,
shipment,
marketplaceActor(authenticatedMarketplace)
);
}
export { marketplaceSku, marketplaceActor };

View File

@ -80,6 +80,20 @@ const isAuthenticated = async (req, res, next) => {
}
}
// CURL/sandbox helper: when MARKETPLACE_DEBUG_TOKEN is set, Bearer that token
// impersonates the oldest Mongo user. Disabled unless the env var is present.
if (process.env.MARKETPLACE_DEBUG_TOKEN && authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7);
if (token === process.env.MARKETPLACE_DEBUG_TOKEN) {
const user = await userModel.findOne({}).sort({ createdAt: 1 }).lean();
if (user) {
req.user = { ...user, _objectType: 'user' };
req.session = { user, debug: true };
return next();
}
}
}
const hostId = req.headers['x-host-id'];
const authCode = req.headers['x-auth-code'];
if (hostId && authCode) {

View File

@ -15,6 +15,7 @@ const listAllowedFilters = [
'deliveryTime',
'cost',
'costWithTax',
'marketplaces.marketplace',
'createdAt',
'updatedAt',
'_reference',
@ -41,6 +42,7 @@ const propertiesAllowedFilters = [
'deliveryTime',
'cost',
'costWithTax',
'marketplaces.marketplace',
];
import {
listCourierServicesRouteHandler,

View File

@ -12,6 +12,7 @@ const listAllowedFilters = [
'vendor._id',
'stockLocation',
'stockLocation._id',
'stockQuantity',
'marketplace',
'marketplace._id',
'courierServices',
@ -25,6 +26,7 @@ const listAllowedSorters = [
'title',
'vendor',
'state',
'stockQuantity',
'price',
'lastSyncedAt',
'createdAt',
@ -35,6 +37,7 @@ const propertiesAllowedFilters = [
'product',
'vendor',
'stockLocation',
'stockQuantity',
'marketplace',
'courierServices',
'state',

View File

@ -12,18 +12,20 @@ const listAllowedFilters = [
'product._id',
'productSku',
'productSku._id',
'stockQuantity',
'state',
'state.type',
'createdAt',
'updatedAt',
'_reference',
];
const listAllowedSorters = ['state', 'price', 'lastSyncedAt', 'createdAt', 'updatedAt', '_id'];
const listAllowedSorters = ['state', 'stockQuantity', 'price', 'lastSyncedAt', 'createdAt', 'updatedAt', '_id'];
const propertiesAllowedFilters = [
'listing',
'listing._id',
'product',
'productSku',
'stockQuantity',
'state',
'state.type',
'createdAt',

View File

@ -26,7 +26,15 @@ const listAllowedSorters = [
'updatedAt',
'_id',
];
const propertiesAllowedFilters = ['name', 'provider', 'active', 'connected', 'state.type', 'createdAt', 'updatedAt'];
const propertiesAllowedFilters = [
'name',
'provider',
'active',
'connected',
'state.type',
'createdAt',
'updatedAt',
];
import {
listMarketplacesRouteHandler,
getMarketplaceRouteHandler,
@ -39,22 +47,39 @@ import {
getMarketplaceAuthUrlRouteHandler,
exchangeMarketplaceAuthCodeRouteHandler,
refreshMarketplaceAuthRouteHandler,
syncMarketplaceRouteHandler,
syncMarketplaceItemsRouteHandler,
syncMarketplaceOrdersRouteHandler,
marketplaceWebhookRouteHandler,
marketplaceWebhookChallengeRouteHandler,
subscribeMarketplaceWebhooksRouteHandler,
debugEbayRouteHandler,
searchMarketplacesRouteHandler,
getMarketplacePropertyValuesRouteHandler,
getMarketplaceNeighborsRouteHandler
getMarketplaceNeighborsRouteHandler,
} from '../../services/sales/marketplaces.js';
router.get('/', isAuthenticated, async (req, res) => {
const { page, limit, property, search, sortProperty, sortOrder } = req.query;
const filter = await getFilter(req.query, listAllowedFilters);
listMarketplacesRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder);
listMarketplacesRouteHandler(
req,
res,
page,
limit,
property,
filter,
search,
getSort(sortProperty, listAllowedSorters),
sortOrder
);
});
router.get('/properties', checkPermissions('marketplace', 'list'), isAuthenticated, async (req, res) => {
router.get(
'/properties',
checkPermissions('marketplace', 'list'),
isAuthenticated,
async (req, res) => {
let properties = convertPropertiesString(req.query.properties);
const filter = await getFilter(req.query, propertiesAllowedFilters, false);
var masterFilter = {};
@ -62,17 +87,27 @@ router.get('/properties', checkPermissions('marketplace', 'list'), isAuthenticat
masterFilter = JSON.parse(req.query.masterFilter);
}
listMarketplacesByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
});
}
);
router.get('/values', checkPermissions('marketplace', 'list'), isAuthenticated, async (req, res) => {
router.get(
'/values',
checkPermissions('marketplace', 'list'),
isAuthenticated,
async (req, res) => {
const { property } = req.query;
getMarketplacePropertyValuesRouteHandler(req, res, property);
});
router.get('/search', checkPermissions('marketplace', 'list'), isAuthenticated, async (req, res) => {
}
);
router.get(
'/search',
checkPermissions('marketplace', 'list'),
isAuthenticated,
async (req, res) => {
const { search } = req.query;
searchMarketplacesRouteHandler(req, res, search);
});
}
);
router.post('/', isAuthenticated, checkPermissions('marketplace', 'new'), async (req, res) => {
newMarketplaceRouteHandler(req, res);
@ -90,7 +125,10 @@ router.get('/:id/auth/url', isAuthenticated, async (req, res) => {
getMarketplaceAuthUrlRouteHandler(req, res);
});
router.post('/:id/auth/exchange', isAuthenticated, async (req, res, next) => {
router.post(
'/:id/auth/exchange',
isAuthenticated,
async (req, res, next) => {
try {
const allowed =
(await hasPermission(req.user, 'marketplace', 'connect')) ||
@ -102,31 +140,92 @@ router.post('/:id/auth/exchange', isAuthenticated, async (req, res, next) => {
} catch (err) {
return next(err);
}
}, async (req, res) => {
},
async (req, res) => {
exchangeMarketplaceAuthCodeRouteHandler(req, res);
});
}
);
router.post('/:id/auth/refresh', isAuthenticated, checkPermissions('marketplace', 'refreshToken'), async (req, res) => {
router.post(
'/:id/auth/refresh',
isAuthenticated,
checkPermissions('marketplace', 'refreshToken'),
async (req, res) => {
refreshMarketplaceAuthRouteHandler(req, res);
});
}
);
router.post('/:id/sync/items', isAuthenticated, checkPermissions('marketplace', 'syncListings'), async (req, res) => {
router.post(
'/:id/sync',
isAuthenticated,
checkPermissions('marketplace', 'sync'),
async (req, res) => {
syncMarketplaceRouteHandler(req, res);
}
);
router.post(
'/:id/sync/items',
isAuthenticated,
checkPermissions('marketplace', 'sync'),
async (req, res) => {
syncMarketplaceItemsRouteHandler(req, res);
});
}
);
router.post('/:id/sync/orders', isAuthenticated, checkPermissions('marketplace', 'syncOrders'), async (req, res) => {
router.post(
'/:id/sync/orders',
isAuthenticated,
checkPermissions('marketplace', 'sync'),
async (req, res) => {
syncMarketplaceOrdersRouteHandler(req, res);
}
);
router.get('/:id/hook', async (req, res) => {
marketplaceWebhookChallengeRouteHandler(req, res);
});
router.get('/:id/ebay-challenge', async (req, res) => {
marketplaceWebhookChallengeRouteHandler(req, res);
});
// Webhook endpoint — no auth, provider verifies via signature
router.post('/:id/hook', async (req, res) => {
marketplaceWebhookRouteHandler(req, res);
});
router.post('/:id/ebay-webhook', async (req, res) => {
marketplaceWebhookRouteHandler(req, res);
});
router.post(
'/:id/webhooks/subscribe',
isAuthenticated,
checkPermissions('marketplace', 'connect'),
async (req, res) => {
subscribeMarketplaceWebhooksRouteHandler(req, res);
}
);
router.get('/:id/debug-ebay', isAuthenticated, async (req, res) => {
debugEbayRouteHandler(req, res);
});
router.get('/:id/debug/ebay', isAuthenticated, async (req, res) => {
debugEbayRouteHandler(req, res);
});
router.get('/neighbors', isAuthenticated, async (req, res) => {
const { property, search, sortProperty, sortOrder, id } = req.query;
const filter = await getFilter(req.query, listAllowedFilters);
getMarketplaceNeighborsRouteHandler(req, res, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder, id);
getMarketplaceNeighborsRouteHandler(
req,
res,
property,
filter,
search,
getSort(sortProperty, listAllowedSorters),
sortOrder,
id
);
});
router.get('/:id', isAuthenticated, checkPermissions('marketplace', 'info'), async (req, res) => {

View File

@ -20,6 +20,9 @@ import {
const logger = log4js.getLogger('Shipments');
logger.level = config.server.logLevel;
import { orderItemModel } from '../../database/schemas/inventory/orderitem.schema.js';
import { salesOrderModel } from '../../database/schemas/sales/salesorder.schema.js';
import { marketplaceModel } from '../../database/schemas/sales/marketplace.schema.js';
import * as marketplaceIntegration from '../../integrations/marketplaceworker.js';
export const listShipmentsRouteHandler = async (
req,
@ -316,6 +319,30 @@ export const shipShipmentRouteHandler = async (req, res) => {
}
logger.debug(`Shipped shipment with ID: ${id}`);
res.send(result);
const orderId = result.order?._id || result.order;
if (result.orderType === 'salesOrder' && orderId) {
salesOrderModel
.findById(orderId)
.lean()
.then(async (salesOrder) => {
if (!salesOrder?.marketplace) return;
const marketplace = await marketplaceModel.findById(salesOrder.marketplace);
if (!marketplace) return;
const shipment = await shipmentModel
.findById(id)
.populate('courierService')
.lean();
await marketplaceIntegration.pushMarketplaceShipmentFulfillment(
marketplace,
req.user,
shipment
);
})
.catch((err) => {
logger.warn(`Failed to push marketplace fulfillment for shipment ${id}: ${err.message}`);
});
}
return;
};

View File

@ -18,6 +18,8 @@ import {
const logger = log4js.getLogger('CourierServices');
logger.level = config.server.logLevel;
const COURIER_SERVICE_POPULATE = ['courier', 'costTaxRate', 'marketplaces.marketplace'];
export const listCourierServicesRouteHandler = async (
req,
res,
@ -38,7 +40,7 @@ export const listCourierServicesRouteHandler = async (
search,
sort,
order,
populate: ['courier', 'costTaxRate'],
populate: COURIER_SERVICE_POPULATE,
});
if (result?.error) {
@ -63,7 +65,7 @@ export const listCourierServicesByPropertiesRouteHandler = async (
properties,
filter,
masterFilter,
populate: ['courier', 'costTaxRate'],
populate: COURIER_SERVICE_POPULATE,
});
if (result?.error) {
@ -88,6 +90,7 @@ export const searchCourierServicesRouteHandler = async (req, res, search) => {
const result = await searchObjects({
model: courierServiceModel,
search,
populate: COURIER_SERVICE_POPULATE,
});
res.send(result);
};
@ -97,7 +100,7 @@ export const getCourierServiceRouteHandler = async (req, res) => {
const result = await getObject({
model: courierServiceModel,
id,
populate: ['courier', 'costTaxRate'],
populate: COURIER_SERVICE_POPULATE,
});
if (result?.error) {
logger.warn(`Courier service not found with supplied id.`);
@ -128,14 +131,14 @@ export const editCourierServiceRouteHandler = async (req, res) => {
additionalCostWithTax: req.body?.additionalCostWithTax,
shippingCurrency: req.body?.shippingCurrency,
international: req.body?.international,
marketplaces: req.body?.marketplaces,
};
// Create audit log before updating
const result = await editObject({
model: courierServiceModel,
id,
updateData,
user: req.user,
populate: ['courier', 'costTaxRate'],
populate: COURIER_SERVICE_POPULATE,
});
if (result.error) {
@ -166,6 +169,7 @@ export const newCourierServiceRouteHandler = async (req, res) => {
additionalCostWithTax: req.body?.additionalCostWithTax,
shippingCurrency: req.body?.shippingCurrency,
international: req.body?.international,
marketplaces: req.body?.marketplaces,
};
const result = await newObject({
model: courierServiceModel,

View File

@ -25,6 +25,7 @@ import {
deleteListing as deleteExternalListing,
publishMarketplaceOfferForSku,
withdrawMarketplaceOfferForSku,
marketplaceSku,
} from '../../integrations/marketplaceworker.js';
const logger = log4js.getLogger('Listings');
@ -159,7 +160,9 @@ export const editListingRouteHandler = async (req, res) => {
stockLocation: req.body.stockLocation,
marketplace: req.body.marketplace,
title: req.body.title,
description: req.body.description,
url: req.body.url,
condition: req.body.condition,
courierServices: req.body.courierServices,
};
const result = await editObject({
@ -193,8 +196,10 @@ export const newListingRouteHandler = async (req, res) => {
stockLocation: req.body.stockLocation,
marketplace: req.body.marketplace,
title: req.body.title,
description: req.body.description,
state: req.body.state || { type: 'draft' },
url: req.body.url,
condition: req.body.condition,
courierServices: req.body.courierServices,
};
const result = await newObject({
@ -370,7 +375,7 @@ export const publishListingRouteHandler = async (req, res) => {
const apiResult = await publishMarketplaceOfferForSku(
marketplace,
req.user,
v._reference,
marketplaceSku(v),
listing
);
await editObject({
@ -464,7 +469,7 @@ export const unpublishListingRouteHandler = async (req, res) => {
try {
for (const v of toUnpublish) {
await withdrawMarketplaceOfferForSku(marketplace, req.user, v._reference);
await withdrawMarketplaceOfferForSku(marketplace, req.user, marketplaceSku(v));
await editObject({
model: listingVarientModel,
id: v._id,
@ -531,4 +536,3 @@ export const getListingNeighborsRouteHandler = async (
logger.debug(`Retrieved listing neighbors for ID: ${id}`);
res.send(result);
};

View File

@ -21,6 +21,7 @@ import {
hasIntegration,
publishMarketplaceOfferForSku,
withdrawMarketplaceOfferForSku,
marketplaceSku,
} from '../../integrations/marketplaceworker.js';
const logger = log4js.getLogger('ListingVarients');
@ -306,7 +307,7 @@ export const publishListingVarientRouteHandler = async (req, res) => {
const apiResult = await publishMarketplaceOfferForSku(
marketplace,
req.user,
doc._reference,
marketplaceSku(doc),
doc.listing
);
@ -415,7 +416,7 @@ export const unpublishListingVarientRouteHandler = async (req, res) => {
}
try {
await withdrawMarketplaceOfferForSku(marketplace, req.user, doc._reference);
await withdrawMarketplaceOfferForSku(marketplace, req.user, marketplaceSku(doc));
await editObject({
model: listingVarientModel,

View File

@ -257,6 +257,9 @@ export const exchangeMarketplaceAuthCodeRouteHandler = async (req, res) => {
});
logger.info(`Marketplace authorization completed for ${marketplace.name}`);
marketplaceIntegration.ensureWebhookSubscriptions(result.marketplace || marketplace, req.user).catch((err) => {
logger.warn(`Failed to subscribe marketplace webhooks for ${marketplace.name}: ${err.message}`);
});
res.send({ success: true, ...result });
} catch (err) {
logger.error('Error exchanging marketplace authorization code:', err.message);
@ -289,6 +292,39 @@ export const refreshMarketplaceAuthRouteHandler = async (req, res) => {
}
};
export const syncMarketplaceRouteHandler = async (req, res) => {
const id = req.params.id;
const marketplace = await getObject({ model: marketplaceModel, id });
if (marketplace?.error) {
logger.warn('Marketplace not found for 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 syncedMarketplace = await marketplaceIntegration.syncMarketplaceMetadata(
marketplace,
req.user
);
logger.info(`Marketplace metadata synced for ${marketplace.name}`);
res.send({ success: true, marketplace: syncedMarketplace });
} catch (err) {
logger.error(`Error syncing marketplace ${marketplace.name}:`, err.message);
res.status(400).send({ error: err.message, code: 400 });
}
};
export const syncMarketplaceItemsRouteHandler = async (req, res) => {
const id = req.params.id;
@ -361,27 +397,88 @@ export const marketplaceWebhookRouteHandler = async (req, res) => {
}
const signature =
req.headers['x-tts-signature'] ||
req.headers['x-ebay-signature'] ||
req.headers['x-tts-signature'] ||
req.headers['x-signature'] ||
'';
const rawBody = JSON.stringify(req.body);
if (signature && marketplaceIntegration.canVerifyWebhookSignature(marketplace)) {
const valid = marketplaceIntegration.verifyWebhookSignature(marketplace, rawBody, signature);
if (!valid) {
logger.warn(`Invalid webhook signature for marketplace ${marketplace.name}`);
return res.status(401).send({ error: 'Invalid signature.', code: 401 });
}
}
const rawBody = req.rawBody || JSON.stringify(req.body);
try {
const result = await marketplaceIntegration.handleWebhook(marketplace, req.body);
const result = await marketplaceIntegration.handleWebhook(marketplace, req.body, {
rawBody,
signature,
});
logger.info(`Webhook processed for marketplace ${marketplace.name}: ${result.action}`);
res.send({ success: true, ...result });
} catch (err) {
logger.error('Error processing marketplace webhook:', err.message);
res.status(500).send({ error: err.message, code: 500 });
res.status(err.status || 500).send({ error: err.message, code: err.status || 500 });
}
};
export const marketplaceWebhookChallengeRouteHandler = async (req, res) => {
const id = req.params.id;
const marketplace = await getObject({ model: marketplaceModel, id });
if (marketplace?.error) {
return res.status(404).send({ error: 'Marketplace not found.', code: 404 });
}
const challengeCode = req.query.challenge_code || req.query.challengeCode;
const endpoint =
marketplace.config?.webhookUrl ||
`${req.protocol}://${req.get('host')}/marketplaces/${id}/hook`;
try {
const result = marketplaceIntegration.buildWebhookChallengeResponse(marketplace, {
challengeCode,
endpoint,
});
if (!result) {
return res.status(400).send({ error: 'Provider does not support webhook challenges.', code: 400 });
}
res.send(result);
} catch (err) {
logger.error('Error handling webhook challenge:', err.message);
res.status(400).send({ error: err.message, code: 400 });
}
};
export const subscribeMarketplaceWebhooksRouteHandler = async (req, res) => {
const id = req.params.id;
const marketplace = await getObject({ model: marketplaceModel, id });
if (marketplace?.error) {
return res.status(marketplace.code).send(marketplace);
}
try {
const result = await marketplaceIntegration.ensureWebhookSubscriptions(marketplace, req.user);
res.send({ success: true, ...result });
} catch (err) {
logger.error('Error subscribing marketplace webhooks:', err.message);
res.status(400).send({ error: err.message, code: 400 });
}
};
export const debugEbayRouteHandler = async (req, res) => {
const id = req.params.id;
const marketplace = await getObject({ model: marketplaceModel, id });
if (marketplace?.error) {
return res.status(marketplace.code).send(marketplace);
}
if (marketplace.provider !== 'ebay') {
return res.status(400).send({ error: 'Debug eBay proxy is only available for eBay marketplaces.', code: 400 });
}
if (marketplace.config?.sandbox !== true) {
return res.status(400).send({ error: 'eBay debug proxy is sandbox-only.', code: 400 });
}
try {
const path = req.query.path || '/sell/inventory/v1/inventory_item';
const data = await marketplaceIntegration.debugMarketplaceGet(marketplace, req.user, path, {
limit: req.query.limit || 1,
});
res.send({ success: true, data });
} catch (err) {
logger.error('eBay debug GET failed:', err.message);
res.status(400).send({ error: err.message, code: 400 });
}
};
export const getMarketplaceNeighborsRouteHandler = async (

View File

@ -16,6 +16,7 @@ import { Worker } from 'worker_threads';
import path from 'path';
import { fileURLToPath } from 'url';
import { diffJson } from 'diff';
import { resolveAuditOwner, actorDisplayName } from './auditOwner.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@ -1064,7 +1065,7 @@ function getChangedValues(oldObj, newObj, old = false) {
return changes;
}
const AUDIT_EXCLUDED_MODELS = ['notification', 'userNotifier'];
const AUDIT_EXCLUDED_MODELS = ['notification', 'userNotifier', 'marketplaceEvent'];
const SENSITIVE_KEYS = ['secret'];
const DISTRIBUTE_KEYS = {
@ -1101,8 +1102,7 @@ async function newAuditLog(newValue, parentId, parentType, user) {
},
parent: parentId,
parentType,
owner: user._id,
ownerType: 'user',
...resolveAuditOwner(user),
operation: 'new',
});
@ -1130,8 +1130,7 @@ async function editAuditLog(oldValue, newValue, parentId, parentType, user) {
},
parent: parentId,
parentType,
owner: user._id,
ownerType: 'user',
...resolveAuditOwner(user),
operation: 'edit',
});
@ -1153,7 +1152,7 @@ async function editNotification(oldValue, newValue, parentId, parentType, user)
await notfiyObjectUserNotifiers(
parentId,
parentType,
`${objectName} edited by ${user?.firstName ?? 'unknown'} ${user?.lastName ?? ''}`,
`${objectName} edited by ${actorDisplayName(user)}`,
`The ${parentType} ${parentId} has been updated.`,
'editObject',
{
@ -1163,8 +1162,8 @@ async function editNotification(oldValue, newValue, parentId, parentType, user)
object: { _id: String(parentId ?? '') },
user: {
_id: String(user?._id ?? ''),
firstName: user.firstName,
lastName: user.lastName,
firstName: user?.firstName ?? user?.name ?? '',
lastName: user?.lastName ?? '',
},
}
);
@ -1179,8 +1178,7 @@ async function deleteAuditLog(deleteValue, parentId, parentType, user) {
},
parent: parentId,
parentType,
owner: user._id,
ownerType: 'user',
...resolveAuditOwner(user),
operation: 'delete',
});
@ -1195,14 +1193,18 @@ async function deleteNotification(object, parentId, parentType, user) {
await notfiyObjectUserNotifiers(
parentId,
parentType,
`${objectName} deleted by ${user?.firstName ?? 'unknown'} ${user?.lastName ?? ''}`,
`${objectName} deleted by ${actorDisplayName(user)}`,
`The ${parentType} ${parentId} has been deleted.`,
'deleteObject',
{
object: omitSensitive(object),
objectType: parentType,
object: { _id: parentId },
user: { _id: user._id, firstName: user.firstName, lastName: user.lastName },
user: {
_id: user?._id,
firstName: user?.firstName ?? user?.name ?? '',
lastName: user?.lastName ?? '',
},
}
);
}
@ -1213,7 +1215,7 @@ async function newNoteNotification(note, user) {
await notfiyObjectUserNotifiers(
note.parent,
note.parentType,
`New note added to ${objectName.toLowerCase()} by ${user?.firstName ?? 'unknown'} ${user?.lastName ?? ''}`,
`New note added to ${objectName.toLowerCase()} by ${actorDisplayName(user)}`,
`A new note has been created.`,
'newNote',
{
@ -1233,8 +1235,7 @@ async function subscribeAuditLog(parentId, parentType, user) {
const auditLog = new auditLogModel({
parent: parentId,
parentType,
owner: user._id,
ownerType: 'user',
...resolveAuditOwner(user),
operation: 'subscribe',
});
await auditLog.save();
@ -1246,8 +1247,7 @@ async function unsubscribeAuditLog(parentId, parentType, user) {
const auditLog = new auditLogModel({
parent: parentId,
parentType,
owner: user._id,
ownerType: 'user',
...resolveAuditOwner(user),
operation: 'unsubscribe',
});
await auditLog.save();
@ -1811,4 +1811,6 @@ export {
jsonToCacheKey,
subscribeAuditLog,
unsubscribeAuditLog,
resolveAuditOwner,
actorDisplayName,
};