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, };