diff --git a/src/auditOwner.js b/src/auditOwner.js
new file mode 100644
index 0000000..3447aa1
--- /dev/null
+++ b/src/auditOwner.js
@@ -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 };
diff --git a/src/database/schemas/__tests__/stockQuantity.recalculate.test.js b/src/database/schemas/__tests__/stockQuantity.recalculate.test.js
new file mode 100644
index 0000000..43f6955
--- /dev/null
+++ b/src/database/schemas/__tests__/stockQuantity.recalculate.test.js
@@ -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',
+ });
+ });
+});
diff --git a/src/database/schemas/inventory/orderitem.schema.js b/src/database/schemas/inventory/orderitem.schema.js
index 2a45017..1a932f6 100644
--- a/src/database/schemas/inventory/orderitem.schema.js
+++ b/src/database/schemas/inventory/orderitem.schema.js
@@ -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;
diff --git a/src/database/schemas/inventory/productstock.schema.js b/src/database/schemas/inventory/productstock.schema.js
index 7476496..48dd9bd 100644
--- a/src/database/schemas/inventory/productstock.schema.js
+++ b/src/database/schemas/inventory/productstock.schema.js
@@ -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;
diff --git a/src/database/schemas/inventory/shipment.schema.js b/src/database/schemas/inventory/shipment.schema.js
index 7a889b0..95698b8 100644
--- a/src/database/schemas/inventory/shipment.schema.js
+++ b/src/database/schemas/inventory/shipment.schema.js
@@ -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') {
diff --git a/src/database/schemas/inventory/stockevent.schema.js b/src/database/schemas/inventory/stockevent.schema.js
index 8809575..bebc78f 100644
--- a/src/database/schemas/inventory/stockevent.schema.js
+++ b/src/database/schemas/inventory/stockevent.schema.js
@@ -190,7 +190,7 @@ const recalculateParentStock = async (parentType, parentId, user) => {
buildParentUpdateData(parentType, parentStock, events)
),
user,
- recalculate: false,
+ recalculate: parentType === 'productStock',
});
};
diff --git a/src/database/schemas/management/auditlog.schema.js b/src/database/schemas/management/auditlog.schema.js
index 96f9e16..1ac2bcf 100644
--- a/src/database/schemas/management/auditlog.schema.js
+++ b/src/database/schemas/management/auditlog.schema.js
@@ -30,7 +30,7 @@ const auditLogSchema = new Schema(
ownerType: {
type: String,
required: true,
- enum: ['user', 'printer', 'host'],
+ enum: ['user', 'printer', 'host', 'marketplace'],
},
},
{ timestamps: true }
diff --git a/src/database/schemas/management/courier.schema.js b/src/database/schemas/management/courier.schema.js
index 2d68188..92c8d10 100644
--- a/src/database/schemas/management/courier.schema.js
+++ b/src/database/schemas/management/courier.schema.js
@@ -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;
diff --git a/src/database/schemas/management/courierservice.schema.js b/src/database/schemas/management/courierservice.schema.js
index cbb24a0..73e990a 100644
--- a/src/database/schemas/management/courierservice.schema.js
+++ b/src/database/schemas/management/courierservice.schema.js
@@ -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 }
);
diff --git a/src/database/schemas/models.js b/src/database/schemas/models.js
index e4276e6..a72289f 100644
--- a/src/database/schemas/models.js
+++ b/src/database/schemas/models.js
@@ -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',
diff --git a/src/database/schemas/sales/client.schema.js b/src/database/schemas/sales/client.schema.js
index 52dbb62..0fcfce9 100644
--- a/src/database/schemas/sales/client.schema.js
+++ b/src/database/schemas/sales/client.schema.js
@@ -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;
diff --git a/src/database/schemas/sales/listing.schema.js b/src/database/schemas/sales/listing.schema.js
index c86c983..4a7a548 100644
--- a/src/database/schemas/sales/listing.schema.js
+++ b/src/database/schemas/sales/listing.schema.js
@@ -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;
diff --git a/src/database/schemas/sales/listingvarient.schema.js b/src/database/schemas/sales/listingvarient.schema.js
index ffec816..113f7dc 100644
--- a/src/database/schemas/sales/listingvarient.schema.js
+++ b/src/database/schemas/sales/listingvarient.schema.js
@@ -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);
diff --git a/src/database/schemas/sales/marketplace.schema.js b/src/database/schemas/sales/marketplace.schema.js
index 2b8dcdc..954d79d 100644
--- a/src/database/schemas/sales/marketplace.schema.js
+++ b/src/database/schemas/sales/marketplace.schema.js
@@ -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 }
);
diff --git a/src/database/schemas/sales/marketplaceevent.schema.js b/src/database/schemas/sales/marketplaceevent.schema.js
new file mode 100644
index 0000000..8baee58
--- /dev/null
+++ b/src/database/schemas/sales/marketplaceevent.schema.js
@@ -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);
diff --git a/src/database/schemas/sales/salesorder.schema.js b/src/database/schemas/sales/salesorder.schema.js
index 8db9258..bd0632e 100644
--- a/src/database/schemas/sales/salesorder.schema.js
+++ b/src/database/schemas/sales/salesorder.schema.js
@@ -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 = [
{
diff --git a/src/database/utils.js b/src/database/utils.js
index d79fe58..920c084 100644
--- a/src/database/utils.js
+++ b/src/database/utils.js
@@ -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) {
diff --git a/src/index.js b/src/index.js
index 3e31bab..c5ae333 100644
--- a/src/index.js
+++ b/src/index.js
@@ -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);
diff --git a/src/integrations/__tests__/marketplaceSync.test.js b/src/integrations/__tests__/marketplaceSync.test.js
new file mode 100644
index 0000000..75d3c40
--- /dev/null
+++ b/src/integrations/__tests__/marketplaceSync.test.js
@@ -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' });
+ });
+});
diff --git a/src/integrations/marketplaceSync.js b/src/integrations/marketplaceSync.js
new file mode 100644
index 0000000..1a47375
--- /dev/null
+++ b/src/integrations/marketplaceSync.js
@@ -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 };
diff --git a/src/integrations/marketplaces/__tests__/ids.test.js b/src/integrations/marketplaces/__tests__/ids.test.js
new file mode 100644
index 0000000..b70cd93
--- /dev/null
+++ b/src/integrations/marketplaces/__tests__/ids.test.js
@@ -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');
+ });
+});
diff --git a/src/integrations/marketplaces/ebay/__tests__/fulfillmentPolicies.test.js b/src/integrations/marketplaces/ebay/__tests__/fulfillmentPolicies.test.js
new file mode 100644
index 0000000..068e50f
--- /dev/null
+++ b/src/integrations/marketplaces/ebay/__tests__/fulfillmentPolicies.test.js
@@ -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 }]);
+ });
+});
diff --git a/src/integrations/marketplaces/ebay/__tests__/listingVarients.test.js b/src/integrations/marketplaces/ebay/__tests__/listingVarients.test.js
new file mode 100644
index 0000000..2755a81
--- /dev/null
+++ b/src/integrations/marketplaces/ebay/__tests__/listingVarients.test.js
@@ -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',
+ }),
+ }),
+ })
+ );
+ });
+});
diff --git a/src/integrations/marketplaces/ebay/__tests__/listings.test.js b/src/integrations/marketplaces/ebay/__tests__/listings.test.js
new file mode 100644
index 0000000..0e29b5d
--- /dev/null
+++ b/src/integrations/marketplaces/ebay/__tests__/listings.test.js
@@ -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');
+ });
+});
diff --git a/src/integrations/marketplaces/ebay/__tests__/notifications.test.js b/src/integrations/marketplaces/ebay/__tests__/notifications.test.js
new file mode 100644
index 0000000..74a1d8d
--- /dev/null
+++ b/src/integrations/marketplaces/ebay/__tests__/notifications.test.js
@@ -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);
+ });
+});
diff --git a/src/integrations/marketplaces/ebay/__tests__/orders.test.js b/src/integrations/marketplaces/ebay/__tests__/orders.test.js
new file mode 100644
index 0000000..77b7355
--- /dev/null
+++ b/src/integrations/marketplaces/ebay/__tests__/orders.test.js
@@ -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');
+ });
+});
diff --git a/src/integrations/marketplaces/ebay/__tests__/shippingServices.test.js b/src/integrations/marketplaces/ebay/__tests__/shippingServices.test.js
new file mode 100644
index 0000000..dde134e
--- /dev/null
+++ b/src/integrations/marketplaces/ebay/__tests__/shippingServices.test.js
@@ -0,0 +1,36 @@
+import { describe, expect, it } from '@jest/globals';
+import { parseShippingServiceDetails } from '../shippingServices.js';
+
+const sampleXml = `
+
+ Success
+
+ Royal Mail 1st Class
+ UK_RoyalMailFirstClassStandard
+ true
+
+
+ Deprecated
+ UK_OldService
+ false
+
+
+ Royal Mail 2nd Class
+ UK_RoyalMailSecondClassStandard
+ true
+
+
+`;
+
+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('Success')).toEqual([]);
+ });
+});
diff --git a/src/integrations/marketplaces/ebay/auth.js b/src/integrations/marketplaces/ebay/auth.js
index d5a7f15..5f690eb 100644
--- a/src/integrations/marketplaces/ebay/auth.js
+++ b/src/integrations/marketplaces/ebay/auth.js
@@ -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: {
diff --git a/src/integrations/marketplaces/ebay/fulfillmentPolicies.js b/src/integrations/marketplaces/ebay/fulfillmentPolicies.js
index c2fea76..b11150a 100644
--- a/src/integrations/marketplaces/ebay/fulfillmentPolicies.js
+++ b/src/integrations/marketplaces/ebay/fulfillmentPolicies.js
@@ -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})`
);
diff --git a/src/integrations/marketplaces/ebay/index.js b/src/integrations/marketplaces/ebay/index.js
index b08d571..610a3b2 100644
--- a/src/integrations/marketplaces/ebay/index.js
+++ b/src/integrations/marketplaces/ebay/index.js
@@ -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';
diff --git a/src/integrations/marketplaces/ebay/listingVarients.js b/src/integrations/marketplaces/ebay/listingVarients.js
index 42e962f..085607d 100644
--- a/src/integrations/marketplaces/ebay/listingVarients.js
+++ b/src/integrations/marketplaces/ebay/listingVarients.js
@@ -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,
diff --git a/src/integrations/marketplaces/ebay/listings.js b/src/integrations/marketplaces/ebay/listings.js
index c668cd7..6819929 100644
--- a/src/integrations/marketplaces/ebay/listings.js
+++ b/src/integrations/marketplaces/ebay/listings.js
@@ -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,12 +146,37 @@ 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);
- logger.info(`Deleting eBay inventory item group "${ref}"`);
- await deleteGroup(marketplace, ref);
+ 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,
diff --git a/src/integrations/marketplaces/ebay/notifications.js b/src/integrations/marketplaces/ebay/notifications.js
new file mode 100644
index 0000000..0f7c5ec
--- /dev/null
+++ b/src/integrations/marketplaces/ebay/notifications.js
@@ -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;
+}
diff --git a/src/integrations/marketplaces/ebay/orders.js b/src/integrations/marketplaces/ebay/orders.js
index 87fee25..2569e97 100644
--- a/src/integrations/marketplaces/ebay/orders.js
+++ b/src/integrations/marketplaces/ebay/orders.js
@@ -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 };
-
- case 'item.created':
- case 'item.updated':
- return { action: 'productUpdate', itemId: data?.itemId };
-
- case 'item.ended':
- return { action: 'productEnded', itemId: data?.itemId };
-
- 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 };
+ if (normalized.includes('AUTHORIZATION_REVOCATION')) {
+ return { action: 'disconnect', notificationId, topic };
}
+
+ if (normalized.includes('ORDER') || normalized === 'ITEM.SOLD' || normalized === 'ORDER.FULFILLMENT') {
+ return {
+ action: orderId && topic.toLowerCase().includes('cancel') ? 'orderCancel' : 'orderUpdate',
+ orderId,
+ notificationId,
+ topic,
+ };
+ }
+
+ if (
+ normalized.includes('ITEM') ||
+ normalized.includes('AVAILABILITY') ||
+ normalized.includes('PRICE')
+ ) {
+ return { action: 'productUpdate', itemId: sku, sku, notificationId, topic };
+ }
+
+ logger.debug(`Unhandled eBay webhook topic: ${topic}`);
+ return { action: 'unknown', topic, notificationId };
}
diff --git a/src/integrations/marketplaces/ebay/shared.js b/src/integrations/marketplaces/ebay/shared.js
index a2d6f7b..145d332 100644
--- a/src/integrations/marketplaces/ebay/shared.js
+++ b/src/integrations/marketplaces/ebay/shared.js
@@ -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 };
diff --git a/src/integrations/marketplaces/ebay/shippingServices.js b/src/integrations/marketplaces/ebay/shippingServices.js
new file mode 100644
index 0000000..79711ae
--- /dev/null
+++ b/src/integrations/marketplaces/ebay/shippingServices.js
@@ -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(/[\s\S]*?<\/ShippingServiceDetails>/g) || [];
+ const services = [];
+ const seen = new Set();
+
+ for (const block of blocks) {
+ const validMatch = block.match(/([^<]+)<\/ValidForSellingFlow>/i);
+ if (validMatch && String(validMatch[1]).toLowerCase() !== 'true') {
+ continue;
+ }
+ const serviceMatch = block.match(/([^<]+)<\/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 `
+
+ en_GB
+ High
+ ShippingServiceDetails
+`;
+}
+
+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>/);
+ const ack = ackMatch?.[1];
+ if (ack && ack !== 'Success' && ack !== 'Warning') {
+ const message =
+ xml.match(/([^<]+)<\/LongMessage>/)?.[1] ||
+ xml.match(/([^<]+)<\/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,
+ },
+ };
+}
diff --git a/src/integrations/marketplaces/ids.js b/src/integrations/marketplaces/ids.js
new file mode 100644
index 0000000..51c1ded
--- /dev/null
+++ b/src/integrations/marketplaces/ids.js
@@ -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' };
+}
diff --git a/src/integrations/marketplaces/tiktokShop.js b/src/integrations/marketplaces/tiktokShop.js
index 72ef277..46251af 100644
--- a/src/integrations/marketplaces/tiktokShop.js
+++ b/src/integrations/marketplaces/tiktokShop.js
@@ -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;
diff --git a/src/integrations/marketplaceworker.js b/src/integrations/marketplaceworker.js
index 9aeb690..5f91b97 100644
--- a/src/integrations/marketplaceworker.js
+++ b/src/integrations/marketplaceworker.js
@@ -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,
+ const result = await upsertExternalOrder(
+ authenticatedMarketplace,
+ provider,
+ externalOrder,
+ actor
+ );
+ results.push({
+ externalReference: result.externalReference,
+ action: result.action,
+ id: result.salesOrder?._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,
- });
- results.push({
- externalId: mapped.externalId,
- action: 'updated',
- id: existingOrder._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 };
diff --git a/src/keycloak.js b/src/keycloak.js
index ee395d6..fe19014 100644
--- a/src/keycloak.js
+++ b/src/keycloak.js
@@ -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) {
diff --git a/src/routes/management/courierservice.js b/src/routes/management/courierservice.js
index 0113672..1af5c43 100644
--- a/src/routes/management/courierservice.js
+++ b/src/routes/management/courierservice.js
@@ -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,
diff --git a/src/routes/sales/listings.js b/src/routes/sales/listings.js
index 0f65f60..7cdbb38 100644
--- a/src/routes/sales/listings.js
+++ b/src/routes/sales/listings.js
@@ -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',
diff --git a/src/routes/sales/listingvarients.js b/src/routes/sales/listingvarients.js
index da15fbe..8ef37d8 100644
--- a/src/routes/sales/listingvarients.js
+++ b/src/routes/sales/listingvarients.js
@@ -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',
diff --git a/src/routes/sales/marketplaces.js b/src/routes/sales/marketplaces.js
index fc073f2..10f3816 100644
--- a/src/routes/sales/marketplaces.js
+++ b/src/routes/sales/marketplaces.js
@@ -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,40 +47,67 @@ 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);
+ const filter = await getFilter(req.query, listAllowedFilters);
+ listMarketplacesRouteHandler(
+ req,
+ res,
+ page,
+ limit,
+ property,
+ filter,
+ search,
+ getSort(sortProperty, listAllowedSorters),
+ sortOrder
+ );
});
-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 = {};
- if (req.query.masterFilter) {
- masterFilter = JSON.parse(req.query.masterFilter);
+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 = {};
+ if (req.query.masterFilter) {
+ masterFilter = JSON.parse(req.query.masterFilter);
+ }
+ listMarketplacesByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
}
- listMarketplacesByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
-});
-
-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) => {
- const { search } = req.query;
- searchMarketplacesRouteHandler(req, res, search);
-});
+);
+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) => {
+ const { search } = req.query;
+ searchMarketplacesRouteHandler(req, res, search);
+ }
+);
router.post('/', isAuthenticated, checkPermissions('marketplace', 'new'), async (req, res) => {
newMarketplaceRouteHandler(req, res);
@@ -90,43 +125,107 @@ router.get('/:id/auth/url', isAuthenticated, async (req, res) => {
getMarketplaceAuthUrlRouteHandler(req, res);
});
-router.post('/:id/auth/exchange', isAuthenticated, async (req, res, next) => {
- try {
- const allowed =
- (await hasPermission(req.user, 'marketplace', 'connect')) ||
- (await hasPermission(req.user, 'marketplace', 'reconnect'));
- if (!allowed) {
- return res.status(403).json({ error: 'Forbidden', code: 'FORBIDDEN' });
+router.post(
+ '/:id/auth/exchange',
+ isAuthenticated,
+ async (req, res, next) => {
+ try {
+ const allowed =
+ (await hasPermission(req.user, 'marketplace', 'connect')) ||
+ (await hasPermission(req.user, 'marketplace', 'reconnect'));
+ if (!allowed) {
+ return res.status(403).json({ error: 'Forbidden', code: 'FORBIDDEN' });
+ }
+ return next();
+ } catch (err) {
+ return next(err);
}
- return next();
- } catch (err) {
- return next(err);
+ },
+ async (req, res) => {
+ exchangeMarketplaceAuthCodeRouteHandler(req, res);
}
-}, async (req, res) => {
- exchangeMarketplaceAuthCodeRouteHandler(req, res);
-});
+);
-router.post('/:id/auth/refresh', isAuthenticated, checkPermissions('marketplace', 'refreshToken'), async (req, res) => {
- refreshMarketplaceAuthRouteHandler(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) => {
- syncMarketplaceItemsRouteHandler(req, res);
-});
+router.post(
+ '/:id/sync',
+ isAuthenticated,
+ checkPermissions('marketplace', 'sync'),
+ async (req, res) => {
+ syncMarketplaceRouteHandler(req, res);
+ }
+);
-router.post('/:id/sync/orders', isAuthenticated, checkPermissions('marketplace', 'syncOrders'), async (req, res) => {
- syncMarketplaceOrdersRouteHandler(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', '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) => {
diff --git a/src/services/inventory/shipments.js b/src/services/inventory/shipments.js
index 0ec2291..2cbb384 100644
--- a/src/services/inventory/shipments.js
+++ b/src/services/inventory/shipments.js
@@ -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;
};
diff --git a/src/services/management/courierservice.js b/src/services/management/courierservice.js
index 79557b6..1a82db6 100644
--- a/src/services/management/courierservice.js
+++ b/src/services/management/courierservice.js
@@ -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,
diff --git a/src/services/sales/listings.js b/src/services/sales/listings.js
index 5a5b1ca..855e43e 100644
--- a/src/services/sales/listings.js
+++ b/src/services/sales/listings.js
@@ -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);
};
-
diff --git a/src/services/sales/listingvarients.js b/src/services/sales/listingvarients.js
index e4b012c..07de971 100644
--- a/src/services/sales/listingvarients.js
+++ b/src/services/sales/listingvarients.js
@@ -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,
diff --git a/src/services/sales/marketplaces.js b/src/services/sales/marketplaces.js
index 7440f81..fe5eefd 100644
--- a/src/services/sales/marketplaces.js
+++ b/src/services/sales/marketplaces.js
@@ -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 (
diff --git a/src/utils.js b/src/utils.js
index 43baa8a..2ce56c3 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -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,
};