Add audit owner resolution and actor display name functions
Some checks failed
farmcontrol/farmcontrol-api/pipeline/head There was a failure building this commit
Some checks failed
farmcontrol/farmcontrol-api/pipeline/head There was a failure building this commit
This commit introduces a new module, `auditOwner.js`, which includes functions for resolving audit owner details and generating display names for actors. The `resolveAuditOwner` function determines the owner type based on the actor's object type, defaulting to 'user' if not specified. The `actorDisplayName` function formats the display name based on the actor's properties, enhancing the clarity of audit logs. Additionally, the `AUDIT_OWNER_TYPES` constant is exported for use in other modules. Updates to existing files incorporate these new functions for improved audit logging and notification handling.
This commit is contained in:
parent
8974d1e9da
commit
5ff93be27c
22
src/auditOwner.js
Normal file
22
src/auditOwner.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
const AUDIT_OWNER_TYPES = ['user', 'printer', 'host', 'marketplace'];
|
||||||
|
|
||||||
|
export function resolveAuditOwner(actor) {
|
||||||
|
const ownerType = AUDIT_OWNER_TYPES.includes(actor?._objectType)
|
||||||
|
? actor._objectType
|
||||||
|
: 'user';
|
||||||
|
return {
|
||||||
|
owner: actor?._id,
|
||||||
|
ownerType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function actorDisplayName(actor) {
|
||||||
|
if (actor?._objectType === 'marketplace' || actor?._objectType === 'host') {
|
||||||
|
return actor.name || actor._objectType;
|
||||||
|
}
|
||||||
|
const firstName = actor?.firstName ?? 'unknown';
|
||||||
|
const lastName = actor?.lastName ?? '';
|
||||||
|
return `${firstName} ${lastName}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AUDIT_OWNER_TYPES };
|
||||||
103
src/database/schemas/__tests__/stockQuantity.recalculate.test.js
Normal file
103
src/database/schemas/__tests__/stockQuantity.recalculate.test.js
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
|
||||||
|
import mongoose from 'mongoose';
|
||||||
|
|
||||||
|
jest.unstable_mockModule('../../database.js', () => ({
|
||||||
|
aggregateRollups: jest.fn(),
|
||||||
|
aggregateRollupsHistory: jest.fn(),
|
||||||
|
editObject: jest.fn(),
|
||||||
|
getObject: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule('../../utils.js', () => ({
|
||||||
|
generateId: jest.fn(() => () => 'test-id'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { aggregateRollups, editObject } = await import('../../database.js');
|
||||||
|
const { listingModel } = await import('../sales/listing.schema.js');
|
||||||
|
const { listingVarientModel } = await import('../sales/listingvarient.schema.js');
|
||||||
|
const { productSkuModel } = await import('../management/productsku.schema.js');
|
||||||
|
const { productStockModel } = await import('../inventory/productstock.schema.js');
|
||||||
|
|
||||||
|
const listingId = new mongoose.Types.ObjectId();
|
||||||
|
const productId = new mongoose.Types.ObjectId();
|
||||||
|
const productSkuId = new mongoose.Types.ObjectId();
|
||||||
|
const stockLocationId = new mongoose.Types.ObjectId();
|
||||||
|
const varientId = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
describe('listingVarient.recalculate', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
aggregateRollups.mockReset();
|
||||||
|
editObject.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sums sibling listing varient stock quantities onto the listing', async () => {
|
||||||
|
aggregateRollups.mockResolvedValue({ stockQuantity: { sum: 12 } });
|
||||||
|
editObject.mockResolvedValue({});
|
||||||
|
|
||||||
|
await listingVarientModel.recalculate({ listing: listingId, stockQuantity: 4 }, 'user-1');
|
||||||
|
|
||||||
|
expect(aggregateRollups).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
model: listingVarientModel,
|
||||||
|
baseFilter: { listing: listingId },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(editObject).toHaveBeenCalledWith({
|
||||||
|
model: listingModel,
|
||||||
|
id: listingId,
|
||||||
|
updateData: { stockQuantity: 12 },
|
||||||
|
user: 'user-1',
|
||||||
|
recalculate: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('productStock.recalculate', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
aggregateRollups.mockReset();
|
||||||
|
editObject.mockReset();
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes the sku/location total onto matching listing varients', async () => {
|
||||||
|
aggregateRollups.mockResolvedValue({ stockQuantity: { sum: 9 } });
|
||||||
|
editObject.mockResolvedValue({});
|
||||||
|
jest.spyOn(productSkuModel, 'findById').mockReturnValue({
|
||||||
|
select: () => ({ lean: async () => ({ product: productId }) }),
|
||||||
|
});
|
||||||
|
jest.spyOn(listingVarientModel, 'find').mockReturnValue({
|
||||||
|
populate: () => ({
|
||||||
|
lean: async () => [
|
||||||
|
{
|
||||||
|
_id: varientId,
|
||||||
|
product: productId,
|
||||||
|
productSku: productSkuId,
|
||||||
|
stockQuantity: 0,
|
||||||
|
listing: { product: productId, stockLocation: stockLocationId },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await productStockModel.recalculate(
|
||||||
|
{ productSku: productSkuId, stockLocation: stockLocationId, currentQuantity: 9 },
|
||||||
|
'user-1'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(aggregateRollups).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
model: productStockModel,
|
||||||
|
baseFilter: {
|
||||||
|
productSku: productSkuId,
|
||||||
|
stockLocation: stockLocationId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(editObject).toHaveBeenCalledWith({
|
||||||
|
model: listingVarientModel,
|
||||||
|
id: varientId,
|
||||||
|
updateData: { stockQuantity: 9 },
|
||||||
|
user: 'user-1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -63,6 +63,9 @@ const orderItemSchema = new Schema(
|
|||||||
invoicedQuantityRemaining: { type: Number, required: false, default: 0 },
|
invoicedQuantityRemaining: { type: Number, required: false, default: 0 },
|
||||||
timestamp: { type: Date, default: Date.now },
|
timestamp: { type: Date, default: Date.now },
|
||||||
shipment: { type: Schema.Types.ObjectId, ref: 'shipment', required: false },
|
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 },
|
orderedAt: { type: Date, required: false },
|
||||||
receivedAt: { 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({ name: 'text', itemType: 'text', orderType: 'text' });
|
||||||
|
orderItemSchema.index({ order: 1, externalReference: 1 }, { unique: true, sparse: true });
|
||||||
|
|
||||||
const rollupConfigs = [
|
const rollupConfigs = [
|
||||||
{
|
{
|
||||||
@ -132,7 +136,13 @@ orderItemSchema.statics.recalculate = async function (orderItem, user) {
|
|||||||
cached: true,
|
cached: true,
|
||||||
});
|
});
|
||||||
if (sku) {
|
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 (syncAmount === 'itemCost') {
|
||||||
if (sku.overrideCost && sku.cost != null) {
|
if (sku.overrideCost && sku.cost != null) {
|
||||||
effectiveItemAmount = sku.cost;
|
effectiveItemAmount = sku.cost;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
import { generateId } from '../../utils.js';
|
import { generateId } from '../../utils.js';
|
||||||
const { Schema } = mongoose;
|
const { Schema } = mongoose;
|
||||||
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
|
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
|
||||||
|
|
||||||
const partStockUsageSchema = new Schema({
|
const partStockUsageSchema = new Schema({
|
||||||
partStock: { type: Schema.Types.ObjectId, ref: 'partStock', required: false },
|
partStock: { type: Schema.Types.ObjectId, ref: 'partStock', required: false },
|
||||||
@ -9,6 +9,12 @@ const partStockUsageSchema = new Schema({
|
|||||||
quantity: { type: Number, required: true },
|
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
|
// Define the main productStock schema - tracks assembled products consisting of part stocks
|
||||||
const productStockSchema = new Schema(
|
const productStockSchema = new Schema(
|
||||||
{
|
{
|
||||||
@ -76,6 +82,64 @@ productStockSchema.statics.history = async function (from, to) {
|
|||||||
return results;
|
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
|
// Add virtual id getter
|
||||||
productStockSchema.virtual('id').get(function () {
|
productStockSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
|
|||||||
@ -13,6 +13,7 @@ const shipmentSchema = new Schema(
|
|||||||
order: { type: Schema.Types.ObjectId, refPath: 'orderType', required: true },
|
order: { type: Schema.Types.ObjectId, refPath: 'orderType', required: true },
|
||||||
courierService: { type: Schema.Types.ObjectId, ref: 'courierService', required: false },
|
courierService: { type: Schema.Types.ObjectId, ref: 'courierService', required: false },
|
||||||
trackingNumber: { type: String, required: false },
|
trackingNumber: { type: String, required: false },
|
||||||
|
externalReference: { type: String, required: false },
|
||||||
amount: { type: Number, required: true },
|
amount: { type: Number, required: true },
|
||||||
amountWithTax: { type: Number, required: true },
|
amountWithTax: { type: Number, required: true },
|
||||||
taxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
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({ trackingNumber: 'text', orderType: 'text' });
|
||||||
|
shipmentSchema.index({ order: 1, externalReference: 1 }, { unique: true, sparse: true });
|
||||||
|
|
||||||
shipmentSchema.statics.recalculate = async function (shipment, user) {
|
shipmentSchema.statics.recalculate = async function (shipment, user) {
|
||||||
if (shipment.orderType !== 'purchaseOrder' && shipment.orderType !== 'salesOrder') {
|
if (shipment.orderType !== 'purchaseOrder' && shipment.orderType !== 'salesOrder') {
|
||||||
|
|||||||
@ -190,7 +190,7 @@ const recalculateParentStock = async (parentType, parentId, user) => {
|
|||||||
buildParentUpdateData(parentType, parentStock, events)
|
buildParentUpdateData(parentType, parentStock, events)
|
||||||
),
|
),
|
||||||
user,
|
user,
|
||||||
recalculate: false,
|
recalculate: parentType === 'productStock',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -30,7 +30,7 @@ const auditLogSchema = new Schema(
|
|||||||
ownerType: {
|
ownerType: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
enum: ['user', 'printer', 'host'],
|
enum: ['user', 'printer', 'host', 'marketplace'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
|
|||||||
@ -14,7 +14,14 @@ const courierSchema = new mongoose.Schema(
|
|||||||
{ timestamps: true }
|
{ 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 () {
|
courierSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
|
|||||||
@ -2,6 +2,14 @@ import mongoose from 'mongoose';
|
|||||||
import { generateId } from '../../utils.js';
|
import { generateId } from '../../utils.js';
|
||||||
const { Schema } = mongoose;
|
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(
|
const courierServiceSchema = new mongoose.Schema(
|
||||||
{
|
{
|
||||||
_reference: { type: String, default: () => generateId()() },
|
_reference: { type: String, default: () => generateId()() },
|
||||||
@ -18,6 +26,7 @@ const courierServiceSchema = new mongoose.Schema(
|
|||||||
additionalCostWithTax: { required: false, type: Number },
|
additionalCostWithTax: { required: false, type: Number },
|
||||||
shippingCurrency: { required: true, type: String, default: 'GBP' },
|
shippingCurrency: { required: true, type: String, default: 'GBP' },
|
||||||
international: { required: true, type: Boolean, default: false },
|
international: { required: true, type: Boolean, default: false },
|
||||||
|
marketplaces: { type: [marketplaceMappingSchema], default: [] },
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|||||||
@ -48,6 +48,7 @@ import { salesOrderModel } from './sales/salesorder.schema.js';
|
|||||||
import { marketplaceModel } from './sales/marketplace.schema.js';
|
import { marketplaceModel } from './sales/marketplace.schema.js';
|
||||||
import { listingModel } from './sales/listing.schema.js';
|
import { listingModel } from './sales/listing.schema.js';
|
||||||
import { listingVarientModel } from './sales/listingvarient.schema.js';
|
import { listingVarientModel } from './sales/listingvarient.schema.js';
|
||||||
|
import { marketplaceEventModel } from './sales/marketplaceevent.schema.js';
|
||||||
import { paymentModel } from './finance/payment.schema.js';
|
import { paymentModel } from './finance/payment.schema.js';
|
||||||
|
|
||||||
// Map prefixes to models and id fields
|
// Map prefixes to models and id fields
|
||||||
@ -396,6 +397,13 @@ export const models = {
|
|||||||
label: 'Listing Varient',
|
label: 'Listing Varient',
|
||||||
referenceField: '_reference',
|
referenceField: '_reference',
|
||||||
},
|
},
|
||||||
|
MKE: {
|
||||||
|
model: marketplaceEventModel,
|
||||||
|
idField: '_id',
|
||||||
|
type: 'marketplaceEvent',
|
||||||
|
label: 'Marketplace Event',
|
||||||
|
referenceField: '_reference',
|
||||||
|
},
|
||||||
PAY: {
|
PAY: {
|
||||||
model: paymentModel,
|
model: paymentModel,
|
||||||
idField: '_id',
|
idField: '_id',
|
||||||
|
|||||||
@ -16,6 +16,7 @@ const clientSchema = new mongoose.Schema(
|
|||||||
_reference: { type: String, default: () => generateId()() },
|
_reference: { type: String, default: () => generateId()() },
|
||||||
name: { required: true, type: String },
|
name: { required: true, type: String },
|
||||||
marketplace: { type: mongoose.Schema.Types.ObjectId, ref: 'marketplace', required: false },
|
marketplace: { type: mongoose.Schema.Types.ObjectId, ref: 'marketplace', required: false },
|
||||||
|
externalReference: { type: String, required: false },
|
||||||
email: { required: false, type: String },
|
email: { required: false, type: String },
|
||||||
phone: { required: false, type: String },
|
phone: { required: false, type: String },
|
||||||
country: { 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({ 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 () {
|
clientSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
|
|||||||
@ -19,15 +19,43 @@ const listingSchema = new Schema(
|
|||||||
message: { type: String, required: false },
|
message: { type: String, required: false },
|
||||||
},
|
},
|
||||||
url: { type: String, required: false },
|
url: { type: String, required: false },
|
||||||
|
description: { type: String, required: false },
|
||||||
|
externalReference: { type: String, required: false },
|
||||||
price: { type: Number, required: false },
|
price: { type: Number, required: false },
|
||||||
currency: { type: String, required: false },
|
currency: { type: String, required: false },
|
||||||
lastSyncedAt: { type: Date, required: false },
|
lastSyncedAt: { type: Date, required: false },
|
||||||
|
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 }],
|
courierServices: [{ type: Schema.Types.ObjectId, ref: 'courierService', required: true }],
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
listingSchema.index({ title: 'text', url: 'text' });
|
listingSchema.index({ title: 'text', url: 'text' });
|
||||||
|
listingSchema.index({ marketplace: 1, externalReference: 1 }, { unique: true, sparse: true });
|
||||||
|
|
||||||
listingSchema.virtual('id').get(function () {
|
listingSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
import { generateId } from '../../utils.js';
|
import { generateId } from '../../utils.js';
|
||||||
|
import { aggregateRollups, editObject } from '../../database.js';
|
||||||
const { Schema } = mongoose;
|
const { Schema } = mongoose;
|
||||||
|
|
||||||
const listingVarientSchema = new Schema(
|
const listingVarientSchema = new Schema(
|
||||||
@ -16,16 +17,19 @@ const listingVarientSchema = new Schema(
|
|||||||
},
|
},
|
||||||
message: { type: String, required: false },
|
message: { type: String, required: false },
|
||||||
},
|
},
|
||||||
|
externalReference: { type: String, required: false },
|
||||||
price: { type: Number, required: false },
|
price: { type: Number, required: false },
|
||||||
currency: { type: String, required: false },
|
currency: { type: String, required: false },
|
||||||
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||||
priceWithTax: { type: Number, required: false },
|
priceWithTax: { type: Number, required: false },
|
||||||
lastSyncedAt: { type: Date, required: false },
|
lastSyncedAt: { type: Date, required: false },
|
||||||
|
stockQuantity: { type: Number, required: false, default: 0 },
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
listingVarientSchema.index({ currency: 'text', 'state.type': 'text' });
|
listingVarientSchema.index({ currency: 'text', 'state.type': 'text' });
|
||||||
|
listingVarientSchema.index({ listing: 1, externalReference: 1 }, { unique: true, sparse: true });
|
||||||
|
|
||||||
listingVarientSchema.virtual('id').get(function () {
|
listingVarientSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
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);
|
export const listingVarientModel = mongoose.model('listingVarient', listingVarientSchema);
|
||||||
|
|||||||
@ -17,13 +17,16 @@ const marketplaceSchema = new mongoose.Schema(
|
|||||||
state: {
|
state: {
|
||||||
type: {
|
type: {
|
||||||
type: String,
|
type: String,
|
||||||
enum: ['active', 'inactive', 'suspended', 'ready', 'offline', 'syncing'],
|
enum: ['active', 'inactive', 'suspended', 'ready', 'offline', 'syncing', 'disconnected'],
|
||||||
default: 'offline',
|
default: 'offline',
|
||||||
},
|
},
|
||||||
message: { type: String, required: false },
|
message: { type: String, required: false },
|
||||||
},
|
},
|
||||||
// Provider-specific API configuration (flexible for eBay, Etsy, TikTok Shop)
|
// Provider-specific API configuration (flexible for eBay, Etsy, TikTok Shop)
|
||||||
config: { type: mongoose.Schema.Types.Mixed, default: {} },
|
config: { type: mongoose.Schema.Types.Mixed, default: {} },
|
||||||
|
eBay: {
|
||||||
|
availableShippingServices: { type: [String], default: [] },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|||||||
31
src/database/schemas/sales/marketplaceevent.schema.js
Normal file
31
src/database/schemas/sales/marketplaceevent.schema.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { generateId } from '../../utils.js';
|
||||||
|
|
||||||
|
const marketplaceEventSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
_reference: { type: String, default: () => generateId()() },
|
||||||
|
marketplace: { type: mongoose.Schema.Types.ObjectId, ref: 'marketplace', required: true },
|
||||||
|
externalReference: { type: String, required: true },
|
||||||
|
topic: { type: String, required: true },
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
enum: ['received', 'processed', 'failed'],
|
||||||
|
default: 'received',
|
||||||
|
},
|
||||||
|
processedAt: { type: Date, required: false },
|
||||||
|
error: { type: String, required: false },
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
marketplaceEventSchema.index({ marketplace: 1, externalReference: 1 }, { unique: true });
|
||||||
|
marketplaceEventSchema.index({ topic: 'text', status: 'text' });
|
||||||
|
|
||||||
|
marketplaceEventSchema.virtual('id').get(function () {
|
||||||
|
return this._id;
|
||||||
|
});
|
||||||
|
|
||||||
|
marketplaceEventSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
|
export const marketplaceEventModel = mongoose.model('marketplaceEvent', marketplaceEventSchema);
|
||||||
@ -1,11 +1,7 @@
|
|||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
import { generateId } from '../../utils.js';
|
import { generateId } from '../../utils.js';
|
||||||
const { Schema } = mongoose;
|
const { Schema } = mongoose;
|
||||||
import {
|
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
|
||||||
aggregateRollups,
|
|
||||||
aggregateRollupsHistory,
|
|
||||||
editObject,
|
|
||||||
} from '../../database.js';
|
|
||||||
|
|
||||||
const salesOrderSchema = new Schema(
|
const salesOrderSchema = new Schema(
|
||||||
{
|
{
|
||||||
@ -19,6 +15,7 @@ const salesOrderSchema = new Schema(
|
|||||||
timestamp: { type: Date, default: Date.now },
|
timestamp: { type: Date, default: Date.now },
|
||||||
client: { type: Schema.Types.ObjectId, ref: 'client', required: true },
|
client: { type: Schema.Types.ObjectId, ref: 'client', required: true },
|
||||||
marketplace: { type: Schema.Types.ObjectId, ref: 'marketplace', required: false },
|
marketplace: { type: Schema.Types.ObjectId, ref: 'marketplace', required: false },
|
||||||
|
externalReference: { type: String, required: false },
|
||||||
state: {
|
state: {
|
||||||
type: { type: String, required: true, default: 'draft' },
|
type: { type: String, required: true, default: 'draft' },
|
||||||
},
|
},
|
||||||
@ -31,6 +28,7 @@ const salesOrderSchema = new Schema(
|
|||||||
);
|
);
|
||||||
|
|
||||||
salesOrderSchema.index({ 'state.type': 'text' });
|
salesOrderSchema.index({ 'state.type': 'text' });
|
||||||
|
salesOrderSchema.index({ marketplace: 1, externalReference: 1 }, { unique: true, sparse: true });
|
||||||
|
|
||||||
const rollupConfigs = [
|
const rollupConfigs = [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -2,10 +2,11 @@ import { customAlphabet } from 'nanoid';
|
|||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
|
|
||||||
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
export const generateId = () => {
|
const createId = () => {
|
||||||
// 10 characters
|
// 10 characters
|
||||||
return customAlphabet(ALPHABET, 12);
|
return customAlphabet(ALPHABET, 12);
|
||||||
};
|
};
|
||||||
|
export const generateId = createId;
|
||||||
|
|
||||||
/** Check if a value is a string that looks like a MongoDB ObjectId (24 hex chars). */
|
/** Check if a value is a string that looks like a MongoDB ObjectId (24 hex chars). */
|
||||||
export function isObjectIdString(value) {
|
export function isObjectIdString(value) {
|
||||||
|
|||||||
11
src/index.js
11
src/index.js
@ -145,7 +145,16 @@ async function initializeApp() {
|
|||||||
|
|
||||||
// Configure middleware
|
// Configure middleware
|
||||||
app.use(cors(corsOptions));
|
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(express.json());
|
||||||
app.use(populateUserMiddleware);
|
app.use(populateUserMiddleware);
|
||||||
|
|
||||||
|
|||||||
142
src/integrations/__tests__/marketplaceSync.test.js
Normal file
142
src/integrations/__tests__/marketplaceSync.test.js
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { describe, expect, it, jest, beforeEach } from '@jest/globals';
|
||||||
|
|
||||||
|
jest.unstable_mockModule('../../database/database.js', () => ({
|
||||||
|
newObject: jest.fn(),
|
||||||
|
editObject: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.unstable_mockModule('../../database/schemas/sales/client.schema.js', () => ({
|
||||||
|
clientModel: { findOne: jest.fn() },
|
||||||
|
}));
|
||||||
|
jest.unstable_mockModule('../../database/schemas/sales/salesorder.schema.js', () => ({
|
||||||
|
salesOrderModel: { findOne: jest.fn(), findById: jest.fn() },
|
||||||
|
}));
|
||||||
|
jest.unstable_mockModule('../../database/schemas/sales/listing.schema.js', () => ({
|
||||||
|
listingModel: { findOne: jest.fn() },
|
||||||
|
}));
|
||||||
|
jest.unstable_mockModule('../../database/schemas/sales/listingvarient.schema.js', () => ({
|
||||||
|
listingVarientModel: { findOne: jest.fn() },
|
||||||
|
}));
|
||||||
|
jest.unstable_mockModule('../../database/schemas/sales/marketplace.schema.js', () => ({
|
||||||
|
marketplaceModel: {},
|
||||||
|
}));
|
||||||
|
jest.unstable_mockModule('../../database/schemas/inventory/orderitem.schema.js', () => ({
|
||||||
|
orderItemModel: { findOne: jest.fn(), find: jest.fn() },
|
||||||
|
}));
|
||||||
|
jest.unstable_mockModule('../../database/schemas/inventory/shipment.schema.js', () => ({
|
||||||
|
shipmentModel: { findOne: jest.fn() },
|
||||||
|
}));
|
||||||
|
jest.unstable_mockModule('../../database/schemas/sales/marketplaceevent.schema.js', () => ({
|
||||||
|
marketplaceEventModel: { findOne: jest.fn() },
|
||||||
|
}));
|
||||||
|
jest.unstable_mockModule('../../database/schemas/management/productsku.schema.js', () => ({
|
||||||
|
productSkuModel: { findOne: jest.fn() },
|
||||||
|
}));
|
||||||
|
jest.unstable_mockModule('../../database/schemas/management/product.schema.js', () => ({
|
||||||
|
productModel: { findById: jest.fn() },
|
||||||
|
}));
|
||||||
|
jest.unstable_mockModule('log4js', () => ({
|
||||||
|
default: { getLogger: () => ({ level: 'off', warn: jest.fn(), info: jest.fn(), debug: jest.fn() }) },
|
||||||
|
}));
|
||||||
|
jest.unstable_mockModule('../../config.js', () => ({
|
||||||
|
default: { server: { logLevel: 'off' } },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { newObject } = await import('../../database/database.js');
|
||||||
|
const { clientModel } = await import('../../database/schemas/sales/client.schema.js');
|
||||||
|
const { salesOrderModel } = await import('../../database/schemas/sales/salesorder.schema.js');
|
||||||
|
const { orderItemModel } = await import('../../database/schemas/inventory/orderitem.schema.js');
|
||||||
|
const { marketplaceEventModel } = await import(
|
||||||
|
'../../database/schemas/sales/marketplaceevent.schema.js'
|
||||||
|
);
|
||||||
|
const { upsertExternalOrder, applyWebhookAction } = await import('../marketplaceSync.js');
|
||||||
|
const { resolveAuditOwner } = await import('../../auditOwner.js');
|
||||||
|
|
||||||
|
describe('marketplace sync upserts', () => {
|
||||||
|
const marketplace = { _id: 'mkt-1', name: 'eBay UK', config: {} };
|
||||||
|
const actor = { _id: 'mkt-1', name: 'eBay UK', _objectType: 'marketplace' };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates sales orders by externalReference with marketplace actor', async () => {
|
||||||
|
clientModel.findOne.mockResolvedValue(null);
|
||||||
|
salesOrderModel.findOne.mockResolvedValue(null);
|
||||||
|
orderItemModel.findOne.mockResolvedValue(null);
|
||||||
|
newObject.mockImplementation(async ({ newData }) => ({
|
||||||
|
_id: 'created-1',
|
||||||
|
...newData,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const provider = {
|
||||||
|
mapOrderToSalesOrder: () => ({
|
||||||
|
externalReference: 'ebay-order-1',
|
||||||
|
state: { type: 'confirmed' },
|
||||||
|
totalAmount: 10,
|
||||||
|
totalAmountWithTax: 12,
|
||||||
|
shippingAmount: 2,
|
||||||
|
shippingAmountWithTax: 2,
|
||||||
|
grandTotalAmount: 14,
|
||||||
|
totalTaxAmount: 2,
|
||||||
|
}),
|
||||||
|
mapBuyerToClient: () => ({
|
||||||
|
name: 'Jane',
|
||||||
|
email: 'jane@example.com',
|
||||||
|
externalReference: 'jane-ebay',
|
||||||
|
}),
|
||||||
|
mapOrderLineItems: () => [
|
||||||
|
{
|
||||||
|
externalReference: 'line-1',
|
||||||
|
name: 'Widget',
|
||||||
|
sku: 'SKU-1',
|
||||||
|
quantity: 1,
|
||||||
|
itemAmount: 10,
|
||||||
|
totalAmount: 10,
|
||||||
|
totalAmountWithTax: 12,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await upsertExternalOrder(
|
||||||
|
marketplace,
|
||||||
|
provider,
|
||||||
|
{ orderId: 'ebay-order-1' },
|
||||||
|
actor
|
||||||
|
);
|
||||||
|
expect(result.action).toBe('created');
|
||||||
|
expect(newObject).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
user: actor,
|
||||||
|
newData: expect.objectContaining({ externalReference: 'ebay-order-1' }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips duplicate webhook events', async () => {
|
||||||
|
marketplaceEventModel.findOne.mockResolvedValue({
|
||||||
|
_id: 'evt-1',
|
||||||
|
status: 'processed',
|
||||||
|
});
|
||||||
|
const classified = await applyWebhookAction(
|
||||||
|
marketplace,
|
||||||
|
{},
|
||||||
|
{ action: 'orderUpdate', orderId: 'o1', notificationId: 'n1' },
|
||||||
|
actor
|
||||||
|
);
|
||||||
|
expect(classified.duplicate).toBe(true);
|
||||||
|
expect(newObject).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('audit owner resolution', () => {
|
||||||
|
it('uses marketplace ownerType for marketplace actors', () => {
|
||||||
|
expect(resolveAuditOwner({ _id: 'm1', _objectType: 'marketplace' })).toEqual({
|
||||||
|
owner: 'm1',
|
||||||
|
ownerType: 'marketplace',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to user', () => {
|
||||||
|
expect(resolveAuditOwner({ _id: 'u1' })).toEqual({ owner: 'u1', ownerType: 'user' });
|
||||||
|
});
|
||||||
|
});
|
||||||
482
src/integrations/marketplaceSync.js
Normal file
482
src/integrations/marketplaceSync.js
Normal file
@ -0,0 +1,482 @@
|
|||||||
|
import { clientModel } from '../database/schemas/sales/client.schema.js';
|
||||||
|
import { salesOrderModel } from '../database/schemas/sales/salesorder.schema.js';
|
||||||
|
import { listingModel } from '../database/schemas/sales/listing.schema.js';
|
||||||
|
import { listingVarientModel } from '../database/schemas/sales/listingvarient.schema.js';
|
||||||
|
import { marketplaceModel } from '../database/schemas/sales/marketplace.schema.js';
|
||||||
|
import { orderItemModel } from '../database/schemas/inventory/orderitem.schema.js';
|
||||||
|
import { shipmentModel } from '../database/schemas/inventory/shipment.schema.js';
|
||||||
|
import { marketplaceEventModel } from '../database/schemas/sales/marketplaceevent.schema.js';
|
||||||
|
import { productSkuModel } from '../database/schemas/management/productsku.schema.js';
|
||||||
|
import { productModel } from '../database/schemas/management/product.schema.js';
|
||||||
|
import { editObject, newObject } from '../database/database.js';
|
||||||
|
import { marketplaceActor, marketplaceSku } from './marketplaces/ids.js';
|
||||||
|
import config from '../config.js';
|
||||||
|
import log4js from 'log4js';
|
||||||
|
|
||||||
|
const logger = log4js.getLogger('Marketplace Sync');
|
||||||
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
|
function externalIdOf(mapped = {}) {
|
||||||
|
return mapped.externalReference || mapped.externalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateType(mapped) {
|
||||||
|
return mapped?.state?.type || mapped?.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertClient(marketplace, clientData, actor) {
|
||||||
|
if (!clientData) return null;
|
||||||
|
const marketplaceId = marketplace._id;
|
||||||
|
let client = null;
|
||||||
|
const externalReference = externalIdOf(clientData);
|
||||||
|
if (externalReference) {
|
||||||
|
client = await clientModel.findOne({ marketplace: marketplaceId, externalReference });
|
||||||
|
}
|
||||||
|
if (!client && clientData.email) {
|
||||||
|
client = await clientModel.findOne({ marketplace: marketplaceId, email: clientData.email });
|
||||||
|
}
|
||||||
|
if (!client && clientData.name) {
|
||||||
|
client = await clientModel.findOne({ marketplace: marketplaceId, name: clientData.name });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
...clientData,
|
||||||
|
marketplace: marketplaceId,
|
||||||
|
active: true,
|
||||||
|
...(externalReference ? { externalReference } : {}),
|
||||||
|
};
|
||||||
|
delete payload.externalId;
|
||||||
|
|
||||||
|
if (client) {
|
||||||
|
return editObject({
|
||||||
|
model: clientModel,
|
||||||
|
id: client._id,
|
||||||
|
updateData: payload,
|
||||||
|
user: actor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return newObject({
|
||||||
|
model: clientModel,
|
||||||
|
newData: payload,
|
||||||
|
user: actor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertOrderItems(salesOrder, externalOrder, provider, actor) {
|
||||||
|
if (typeof provider.mapOrderLineItems !== 'function') return;
|
||||||
|
const lines = provider.mapOrderLineItems(externalOrder) || [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const lineRef = externalIdOf(line);
|
||||||
|
if (!lineRef) continue;
|
||||||
|
|
||||||
|
let listingVarient = null;
|
||||||
|
if (line.sku) {
|
||||||
|
listingVarient = await listingVarientModel.findOne({
|
||||||
|
$or: [{ externalReference: line.sku }, { _reference: line.sku }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
orderType: 'salesOrder',
|
||||||
|
order: salesOrder._id,
|
||||||
|
name: line.name || line.sku || 'Marketplace item',
|
||||||
|
itemType: line.itemType || 'product',
|
||||||
|
quantity: line.quantity || 1,
|
||||||
|
itemAmount: line.itemAmount ?? 0,
|
||||||
|
totalAmount: line.totalAmount ?? 0,
|
||||||
|
totalAmountWithTax: line.totalAmountWithTax ?? line.totalAmount ?? 0,
|
||||||
|
externalReference: lineRef,
|
||||||
|
listing: listingVarient?.listing,
|
||||||
|
listingVarient: listingVarient?._id,
|
||||||
|
item: listingVarient?.product,
|
||||||
|
sku: listingVarient?.productSku,
|
||||||
|
state: { type: stateType(salesOrder) === 'cancelled' ? 'cancelled' : 'ordered' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const existing = await orderItemModel.findOne({
|
||||||
|
order: salesOrder._id,
|
||||||
|
externalReference: lineRef,
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
await editObject({
|
||||||
|
model: orderItemModel,
|
||||||
|
id: existing._id,
|
||||||
|
updateData: payload,
|
||||||
|
user: actor,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await newObject({
|
||||||
|
model: orderItemModel,
|
||||||
|
newData: payload,
|
||||||
|
user: actor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertOrderShipments(salesOrder, externalOrder, provider, actor) {
|
||||||
|
if (typeof provider.mapOrderShipments !== 'function') return;
|
||||||
|
const shipments = provider.mapOrderShipments(externalOrder) || [];
|
||||||
|
for (const mapped of shipments) {
|
||||||
|
const shipmentRef = externalIdOf(mapped);
|
||||||
|
if (!shipmentRef) continue;
|
||||||
|
const payload = {
|
||||||
|
orderType: 'salesOrder',
|
||||||
|
order: salesOrder._id,
|
||||||
|
trackingNumber: mapped.trackingNumber || '',
|
||||||
|
amount: mapped.amount ?? 0,
|
||||||
|
amountWithTax: mapped.amountWithTax ?? mapped.amount ?? 0,
|
||||||
|
externalReference: shipmentRef,
|
||||||
|
state: mapped.state || { type: 'planned' },
|
||||||
|
shippedAt: mapped.shippedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
const existing = await shipmentModel.findOne({
|
||||||
|
order: salesOrder._id,
|
||||||
|
externalReference: shipmentRef,
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
await editObject({
|
||||||
|
model: shipmentModel,
|
||||||
|
id: existing._id,
|
||||||
|
updateData: payload,
|
||||||
|
user: actor,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await newObject({
|
||||||
|
model: shipmentModel,
|
||||||
|
newData: payload,
|
||||||
|
user: actor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertExternalOrder(marketplace, provider, externalOrder, actor) {
|
||||||
|
const mapped = provider.mapOrderToSalesOrder(externalOrder);
|
||||||
|
const clientData = provider.mapBuyerToClient(externalOrder);
|
||||||
|
const externalReference = externalIdOf(mapped);
|
||||||
|
if (!externalReference) {
|
||||||
|
throw new Error('Mapped sales order is missing externalReference');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await upsertClient(marketplace, clientData, actor);
|
||||||
|
const existingOrder = await salesOrderModel.findOne({
|
||||||
|
marketplace: marketplace._id,
|
||||||
|
externalReference,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lifecycle = {};
|
||||||
|
const type = mapped.state?.type;
|
||||||
|
if (type === 'confirmed' || type === 'shipped' || type === 'delivered') {
|
||||||
|
lifecycle.confirmedAt = existingOrder?.confirmedAt || new Date();
|
||||||
|
}
|
||||||
|
if (type === 'cancelled') {
|
||||||
|
lifecycle.cancelledAt = existingOrder?.cancelledAt || new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderPayload = {
|
||||||
|
client: client?._id,
|
||||||
|
marketplace: marketplace._id,
|
||||||
|
externalReference,
|
||||||
|
state: mapped.state,
|
||||||
|
totalAmount: mapped.totalAmount,
|
||||||
|
totalAmountWithTax: mapped.totalAmountWithTax,
|
||||||
|
shippingAmount: mapped.shippingAmount,
|
||||||
|
shippingAmountWithTax: mapped.shippingAmountWithTax,
|
||||||
|
grandTotalAmount: mapped.grandTotalAmount,
|
||||||
|
totalTaxAmount: mapped.totalTaxAmount,
|
||||||
|
...lifecycle,
|
||||||
|
};
|
||||||
|
|
||||||
|
let salesOrder = existingOrder;
|
||||||
|
if (existingOrder) {
|
||||||
|
salesOrder = await editObject({
|
||||||
|
model: salesOrderModel,
|
||||||
|
id: existingOrder._id,
|
||||||
|
updateData: orderPayload,
|
||||||
|
user: actor,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
salesOrder = await newObject({
|
||||||
|
model: salesOrderModel,
|
||||||
|
newData: orderPayload,
|
||||||
|
user: actor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await upsertOrderItems(salesOrder, externalOrder, provider, actor);
|
||||||
|
await upsertOrderShipments(salesOrder, externalOrder, provider, actor);
|
||||||
|
return { salesOrder, action: existingOrder ? 'updated' : 'created', externalReference };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function matchProductSku(mapped) {
|
||||||
|
const skuCodes = [
|
||||||
|
mapped.externalReference,
|
||||||
|
...(mapped.varients || []).map((v) => v.externalReference || v._reference),
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
for (const code of skuCodes) {
|
||||||
|
const sku = await productSkuModel.findOne({
|
||||||
|
$or: [{ _reference: code }, { barcode: code }],
|
||||||
|
});
|
||||||
|
if (sku) return sku;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertInboundListing(marketplace, mapped, actor) {
|
||||||
|
const externalReference = externalIdOf(mapped);
|
||||||
|
if (!externalReference) {
|
||||||
|
logger.warn('Skipping inbound listing without externalReference');
|
||||||
|
return { action: 'skipped' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults = marketplace.config?.defaults || {};
|
||||||
|
const productSku = await matchProductSku(mapped);
|
||||||
|
const product = productSku
|
||||||
|
? await productModel.findById(productSku.product).lean()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const existing = await listingModel.findOne({
|
||||||
|
marketplace: marketplace._id,
|
||||||
|
externalReference,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
title: mapped.title,
|
||||||
|
url: mapped.url,
|
||||||
|
price: mapped.price,
|
||||||
|
currency: mapped.currency,
|
||||||
|
description: mapped.description,
|
||||||
|
externalReference,
|
||||||
|
lastSyncedAt: new Date(),
|
||||||
|
state: mapped.state || { type: 'active' },
|
||||||
|
marketplace: marketplace._id,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (product) {
|
||||||
|
payload.product = product._id;
|
||||||
|
payload.vendor = product.vendor;
|
||||||
|
}
|
||||||
|
|
||||||
|
let listing = existing;
|
||||||
|
if (existing) {
|
||||||
|
listing = await editObject({
|
||||||
|
model: listingModel,
|
||||||
|
id: existing._id,
|
||||||
|
updateData: payload,
|
||||||
|
user: actor,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const vendor = payload.vendor || defaults.vendor;
|
||||||
|
const stockLocation = defaults.stockLocation;
|
||||||
|
const courierServices = defaults.courierServices || [];
|
||||||
|
if (!vendor || !stockLocation) {
|
||||||
|
logger.warn(
|
||||||
|
`Skipping inbound listing ${externalReference}: marketplace defaults.vendor and defaults.stockLocation are required to create listings`
|
||||||
|
);
|
||||||
|
return { action: 'skipped', externalReference };
|
||||||
|
}
|
||||||
|
listing = await newObject({
|
||||||
|
model: listingModel,
|
||||||
|
newData: {
|
||||||
|
...payload,
|
||||||
|
vendor,
|
||||||
|
stockLocation,
|
||||||
|
courierServices,
|
||||||
|
},
|
||||||
|
user: actor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mappedVarient of mapped.varients || []) {
|
||||||
|
const varientRef = mappedVarient.externalReference || mappedVarient._reference;
|
||||||
|
if (!varientRef) continue;
|
||||||
|
const varientPayload = {
|
||||||
|
listing: listing._id,
|
||||||
|
externalReference: varientRef,
|
||||||
|
price: mappedVarient.price,
|
||||||
|
currency: mappedVarient.currency,
|
||||||
|
state: mappedVarient.state || mapped.state || { type: 'active' },
|
||||||
|
lastSyncedAt: new Date(),
|
||||||
|
product: product?._id || listing.product,
|
||||||
|
productSku: productSku?._id,
|
||||||
|
};
|
||||||
|
const existingVarient = await listingVarientModel.findOne({
|
||||||
|
listing: listing._id,
|
||||||
|
externalReference: varientRef,
|
||||||
|
});
|
||||||
|
if (existingVarient) {
|
||||||
|
await editObject({
|
||||||
|
model: listingVarientModel,
|
||||||
|
id: existingVarient._id,
|
||||||
|
updateData: varientPayload,
|
||||||
|
user: actor,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await newObject({
|
||||||
|
model: listingVarientModel,
|
||||||
|
newData: varientPayload,
|
||||||
|
user: actor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { action: existing ? 'updated' : 'created', listing };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importExternalItems(marketplace, provider, actor) {
|
||||||
|
if (typeof provider.syncItems !== 'function' || typeof provider.mapProductToListing !== 'function') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const externalItems = await provider.syncItems(marketplace);
|
||||||
|
const results = [];
|
||||||
|
for (const item of externalItems || []) {
|
||||||
|
try {
|
||||||
|
const mapped = provider.mapProductToListing(item);
|
||||||
|
const result = await upsertInboundListing(marketplace, mapped, actor);
|
||||||
|
results.push(result);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Failed to import marketplace listing: ${err.message}`);
|
||||||
|
results.push({ action: 'error', error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordMarketplaceEvent(marketplace, classified, actor) {
|
||||||
|
const externalReference =
|
||||||
|
classified.notificationId ||
|
||||||
|
`${classified.action}:${classified.orderId || classified.itemId || classified.topic || Date.now()}`;
|
||||||
|
const existing = await marketplaceEventModel.findOne({
|
||||||
|
marketplace: marketplace._id,
|
||||||
|
externalReference,
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
return newObject({
|
||||||
|
model: marketplaceEventModel,
|
||||||
|
newData: {
|
||||||
|
marketplace: marketplace._id,
|
||||||
|
externalReference,
|
||||||
|
topic: classified.topic || classified.action || 'unknown',
|
||||||
|
status: 'received',
|
||||||
|
},
|
||||||
|
user: actor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markMarketplaceEvent(event, status, actor, error) {
|
||||||
|
if (!event?._id) return;
|
||||||
|
await editObject({
|
||||||
|
model: marketplaceEventModel,
|
||||||
|
id: event._id,
|
||||||
|
updateData: {
|
||||||
|
status,
|
||||||
|
processedAt: status === 'processed' ? new Date() : event.processedAt,
|
||||||
|
error: error || undefined,
|
||||||
|
},
|
||||||
|
user: actor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyWebhookAction(marketplace, provider, classified, actor) {
|
||||||
|
const event = await recordMarketplaceEvent(marketplace, classified, actor);
|
||||||
|
if (event.status === 'processed') {
|
||||||
|
return { ...classified, duplicate: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (classified.action) {
|
||||||
|
case 'orderCreate':
|
||||||
|
case 'orderUpdate':
|
||||||
|
case 'orderCancel': {
|
||||||
|
if (classified.orderId && typeof provider.getOrder === 'function') {
|
||||||
|
const order = await provider.getOrder(marketplace, classified.orderId);
|
||||||
|
await upsertExternalOrder(marketplace, provider, order, actor);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'productUpdate':
|
||||||
|
case 'productEnded': {
|
||||||
|
await importExternalItems(marketplace, provider, actor);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'accountDeletion': {
|
||||||
|
if (classified.userId) {
|
||||||
|
const client = await clientModel.findOne({
|
||||||
|
marketplace: marketplace._id,
|
||||||
|
$or: [{ externalReference: classified.userId }, { name: classified.userId }],
|
||||||
|
});
|
||||||
|
if (client) {
|
||||||
|
await editObject({
|
||||||
|
model: clientModel,
|
||||||
|
id: client._id,
|
||||||
|
updateData: {
|
||||||
|
name: 'Deleted marketplace user',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
address: {},
|
||||||
|
},
|
||||||
|
user: actor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'disconnect': {
|
||||||
|
await editObject({
|
||||||
|
model: marketplaceModel,
|
||||||
|
id: marketplace._id,
|
||||||
|
updateData: { connected: false, state: { type: 'disconnected' } },
|
||||||
|
user: actor,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await markMarketplaceEvent(event, 'processed', actor);
|
||||||
|
} catch (err) {
|
||||||
|
await markMarketplaceEvent(event, 'failed', actor, err.message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return classified;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pushShipmentFulfillment(marketplace, provider, shipment, actor) {
|
||||||
|
if (typeof provider.createShippingFulfillment !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const orderId = shipment.order?._id || shipment.order;
|
||||||
|
const salesOrder = await salesOrderModel.findById(orderId).lean();
|
||||||
|
if (!salesOrder?.externalReference) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const orderItems = await orderItemModel
|
||||||
|
.find({ order: salesOrder._id, shipment: shipment._id, orderType: 'salesOrder' })
|
||||||
|
.lean();
|
||||||
|
await provider.createShippingFulfillment(marketplace, {
|
||||||
|
orderId: salesOrder.externalReference,
|
||||||
|
trackingNumber: shipment.trackingNumber,
|
||||||
|
shippingCarrierCode: shipment.courierService?._reference || shipment.courierService?.code,
|
||||||
|
lineItems: orderItems.map((item) => ({
|
||||||
|
lineItemId: item.externalReference,
|
||||||
|
quantity: item.quantity,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
if (shipment._id && !shipment.externalReference) {
|
||||||
|
await editObject({
|
||||||
|
model: shipmentModel,
|
||||||
|
id: shipment._id,
|
||||||
|
updateData: { lastSyncedAt: new Date() },
|
||||||
|
user: actor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { marketplaceActor, marketplaceSku };
|
||||||
15
src/integrations/marketplaces/__tests__/ids.test.js
Normal file
15
src/integrations/marketplaces/__tests__/ids.test.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { describe, expect, it } from '@jest/globals';
|
||||||
|
import { marketplaceSku, marketplaceActor } from '../ids.js';
|
||||||
|
|
||||||
|
describe('marketplace ids', () => {
|
||||||
|
it('prefers externalReference over _reference', () => {
|
||||||
|
expect(marketplaceSku({ externalReference: 'ebay-sku', _reference: 'LVR-1' })).toBe('ebay-sku');
|
||||||
|
expect(marketplaceSku({ _reference: 'LVR-1' })).toBe('LVR-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks marketplace actors for audit logs', () => {
|
||||||
|
const actor = marketplaceActor({ _id: 'm1', name: 'eBay UK' });
|
||||||
|
expect(actor._objectType).toBe('marketplace');
|
||||||
|
expect(actor.name).toBe('eBay UK');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
118
src/integrations/marketplaces/ebay/__tests__/orders.test.js
Normal file
118
src/integrations/marketplaces/ebay/__tests__/orders.test.js
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { describe, expect, it } from '@jest/globals';
|
||||||
|
import {
|
||||||
|
mapOrderStatus,
|
||||||
|
mapOrderToSalesOrder,
|
||||||
|
mapBuyerToClient,
|
||||||
|
mapOrderLineItems,
|
||||||
|
mapOrderShipments,
|
||||||
|
handleWebhook,
|
||||||
|
} from '../orders.js';
|
||||||
|
|
||||||
|
const ebayOrder = {
|
||||||
|
orderId: '12-34567-89012',
|
||||||
|
orderFulfillmentStatus: 'NOT_STARTED',
|
||||||
|
pricingSummary: {
|
||||||
|
priceSubtotal: { value: '10.00' },
|
||||||
|
deliveryCost: { value: '2.50' },
|
||||||
|
tax: { value: '1.20' },
|
||||||
|
total: { value: '13.70' },
|
||||||
|
},
|
||||||
|
buyer: {
|
||||||
|
username: 'ebay-buyer',
|
||||||
|
buyerRegistrationAddress: { email: 'buyer@example.com' },
|
||||||
|
},
|
||||||
|
fulfillmentStartInstructions: [
|
||||||
|
{
|
||||||
|
shippingStep: {
|
||||||
|
shipTo: {
|
||||||
|
fullName: 'Jane Buyer',
|
||||||
|
primaryPhone: { phoneNumber: '012345' },
|
||||||
|
contactAddress: {
|
||||||
|
addressLine1: '1 High St',
|
||||||
|
city: 'London',
|
||||||
|
postalCode: 'SW1A 1AA',
|
||||||
|
countryCode: 'GB',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
lineItems: [
|
||||||
|
{
|
||||||
|
lineItemId: 'line-1',
|
||||||
|
sku: 'SKU-RED',
|
||||||
|
title: 'Red widget',
|
||||||
|
quantity: 2,
|
||||||
|
lineItemCost: { value: '5.00' },
|
||||||
|
taxes: [{ amount: { value: '1.00' } }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
shippingFulfillments: [
|
||||||
|
{
|
||||||
|
fulfillmentId: 'ful-1',
|
||||||
|
shipmentTrackingNumber: 'TRACK123',
|
||||||
|
shippedDate: '2026-08-01T10:00:00.000Z',
|
||||||
|
lineItems: [{ lineItemId: 'line-1' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('eBay order mappers', () => {
|
||||||
|
it('maps sales order totals and externalReference', () => {
|
||||||
|
const mapped = mapOrderToSalesOrder(ebayOrder);
|
||||||
|
expect(mapped.externalReference).toBe('12-34567-89012');
|
||||||
|
expect(mapped.totalAmount).toBe(10);
|
||||||
|
expect(mapped.shippingAmount).toBe(2.5);
|
||||||
|
expect(mapped.totalTaxAmount).toBe(1.2);
|
||||||
|
expect(mapped.state.type).toBe('draft');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps cancelled orders', () => {
|
||||||
|
expect(
|
||||||
|
mapOrderStatus({ cancelStatus: { cancelState: 'CANCELED' } })
|
||||||
|
).toBe('cancelled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps buyer to client with marketplace buyer id', () => {
|
||||||
|
const client = mapBuyerToClient(ebayOrder);
|
||||||
|
expect(client.externalReference).toBe('ebay-buyer');
|
||||||
|
expect(client.email).toBe('buyer@example.com');
|
||||||
|
expect(client.address.city).toBe('London');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps line items', () => {
|
||||||
|
const lines = mapOrderLineItems(ebayOrder);
|
||||||
|
expect(lines).toHaveLength(1);
|
||||||
|
expect(lines[0]).toMatchObject({
|
||||||
|
externalReference: 'line-1',
|
||||||
|
sku: 'SKU-RED',
|
||||||
|
quantity: 2,
|
||||||
|
itemAmount: 5,
|
||||||
|
totalAmount: 10,
|
||||||
|
totalAmountWithTax: 11,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps shipments from fulfillments', () => {
|
||||||
|
const shipments = mapOrderShipments(ebayOrder);
|
||||||
|
expect(shipments[0]).toMatchObject({
|
||||||
|
externalReference: 'ful-1',
|
||||||
|
trackingNumber: 'TRACK123',
|
||||||
|
});
|
||||||
|
expect(shipments[0].state.type).toBe('shipped');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies commerce notification topics', async () => {
|
||||||
|
const sold = await handleWebhook(
|
||||||
|
{ name: 'eBay' },
|
||||||
|
{ metadata: { topic: 'ORDER_CONFIRMATION' }, notification: { data: { orderId: 'o1' }, notificationId: 'n1' } }
|
||||||
|
);
|
||||||
|
expect(sold).toMatchObject({ action: 'orderUpdate', orderId: 'o1', notificationId: 'n1' });
|
||||||
|
|
||||||
|
const deletion = await handleWebhook(
|
||||||
|
{ name: 'eBay' },
|
||||||
|
{ topic: 'MARKETPLACE_ACCOUNT_DELETION', data: { userId: 'u1' } }
|
||||||
|
);
|
||||||
|
expect(deletion.action).toBe('accountDeletion');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it } from '@jest/globals';
|
||||||
|
import { parseShippingServiceDetails } from '../shippingServices.js';
|
||||||
|
|
||||||
|
const sampleXml = `
|
||||||
|
<GeteBayDetailsResponse xmlns="urn:ebay:apis:eBLBaseComponents">
|
||||||
|
<Ack>Success</Ack>
|
||||||
|
<ShippingServiceDetails>
|
||||||
|
<Description>Royal Mail 1st Class</Description>
|
||||||
|
<ShippingService>UK_RoyalMailFirstClassStandard</ShippingService>
|
||||||
|
<ValidForSellingFlow>true</ValidForSellingFlow>
|
||||||
|
</ShippingServiceDetails>
|
||||||
|
<ShippingServiceDetails>
|
||||||
|
<Description>Deprecated</Description>
|
||||||
|
<ShippingService>UK_OldService</ShippingService>
|
||||||
|
<ValidForSellingFlow>false</ValidForSellingFlow>
|
||||||
|
</ShippingServiceDetails>
|
||||||
|
<ShippingServiceDetails>
|
||||||
|
<Description>Royal Mail 2nd Class</Description>
|
||||||
|
<ShippingService>UK_RoyalMailSecondClassStandard</ShippingService>
|
||||||
|
<ValidForSellingFlow>true</ValidForSellingFlow>
|
||||||
|
</ShippingServiceDetails>
|
||||||
|
</GeteBayDetailsResponse>
|
||||||
|
`;
|
||||||
|
|
||||||
|
describe('eBay shipping service details', () => {
|
||||||
|
it('extracts selling-flow shipping service codes', () => {
|
||||||
|
expect(parseShippingServiceDetails(sampleXml)).toEqual([
|
||||||
|
'UK_RoyalMailFirstClassStandard',
|
||||||
|
'UK_RoyalMailSecondClassStandard',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty list when no details are present', () => {
|
||||||
|
expect(parseShippingServiceDetails('<Ack>Success</Ack>')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -8,12 +8,21 @@ import {
|
|||||||
isAccessTokenExpired,
|
isAccessTokenExpired,
|
||||||
getRequiredAuthConfig,
|
getRequiredAuthConfig,
|
||||||
getBasicAuthHeader,
|
getBasicAuthHeader,
|
||||||
|
formatDebugPayload,
|
||||||
|
getMarketplaceDebugContext,
|
||||||
logger,
|
logger,
|
||||||
} from './shared.js';
|
} from './shared.js';
|
||||||
|
|
||||||
const TOKEN_PATH = '/identity/v1/oauth2/token';
|
const TOKEN_PATH = '/identity/v1/oauth2/token';
|
||||||
|
|
||||||
async function mintToken(marketplace, body) {
|
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}`, {
|
const response = await fetch(`${getApiBaseUrl(marketplace)}${TOKEN_PATH}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@ -24,12 +33,26 @@ async function mintToken(marketplace, body) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
const durationMs = Date.now() - startedAt;
|
||||||
if (!response.ok || data.error) {
|
if (!response.ok || data.error) {
|
||||||
const message = data.error_description || data.error || response.statusText;
|
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}`);
|
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;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -124,9 +147,18 @@ export async function refreshAuth(marketplace) {
|
|||||||
|
|
||||||
export async function ensureAuthenticatedMarketplace(marketplace) {
|
export async function ensureAuthenticatedMarketplace(marketplace) {
|
||||||
if (!isAccessTokenExpired(marketplace)) {
|
if (!isAccessTokenExpired(marketplace)) {
|
||||||
|
logger.debug(`eBay access token still valid for "${marketplace.name}"`, {
|
||||||
|
...getMarketplaceDebugContext(marketplace),
|
||||||
|
expiresAt: marketplace.config?.accessTokenExpiresAt,
|
||||||
|
});
|
||||||
return { marketplace };
|
return { marketplace };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.debug(`eBay access token expired for "${marketplace.name}", refreshing`, {
|
||||||
|
...getMarketplaceDebugContext(marketplace),
|
||||||
|
expiresAt: marketplace.config?.accessTokenExpiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
const authResult = await refreshAuth(marketplace);
|
const authResult = await refreshAuth(marketplace);
|
||||||
return {
|
return {
|
||||||
marketplace: {
|
marketplace: {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { courierServiceModel } from '../../../database/schemas/management/courie
|
|||||||
import { makeRequest, logger } from './shared.js';
|
import { makeRequest, logger } from './shared.js';
|
||||||
|
|
||||||
const SELLING_POLICY_PROGRAM = 'SELLING_POLICY_MANAGEMENT';
|
const SELLING_POLICY_PROGRAM = 'SELLING_POLICY_MANAGEMENT';
|
||||||
|
const FULFILLMENT_CATEGORY_TYPE = 'ALL_EXCLUDING_MOTORS_VEHICLES';
|
||||||
|
|
||||||
async function fetchOptedInPrograms(marketplace) {
|
async function fetchOptedInPrograms(marketplace) {
|
||||||
const result = await makeRequest({
|
const result = await makeRequest({
|
||||||
@ -42,11 +43,7 @@ function isPopulatedCourierService(service) {
|
|||||||
service &&
|
service &&
|
||||||
typeof service === 'object' &&
|
typeof service === 'object' &&
|
||||||
!(service instanceof mongoose.Types.ObjectId) &&
|
!(service instanceof mongoose.Types.ObjectId) &&
|
||||||
service.name != null &&
|
service.name != null
|
||||||
service.courier &&
|
|
||||||
typeof service.courier === 'object' &&
|
|
||||||
!(service.courier instanceof mongoose.Types.ObjectId) &&
|
|
||||||
service.courier._reference
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,14 +59,45 @@ async function resolveCourierServices(listing) {
|
|||||||
const serviceIds = serviceRefs.map((service) => service?._id || service).filter(Boolean);
|
const serviceIds = serviceRefs.map((service) => service?._id || service).filter(Boolean);
|
||||||
const services = await courierServiceModel
|
const services = await courierServiceModel
|
||||||
.find({ _id: { $in: serviceIds } })
|
.find({ _id: { $in: serviceIds } })
|
||||||
.populate('courier')
|
.populate(['courier', 'marketplaces.marketplace'])
|
||||||
.lean();
|
.lean();
|
||||||
const servicesById = new Map(services.map((service) => [String(service._id), service]));
|
const servicesById = new Map(services.map((service) => [String(service._id), service]));
|
||||||
|
|
||||||
return serviceIds.map((id) => servicesById.get(String(id))).filter(Boolean);
|
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);
|
const activeServices = services.filter((service) => service.active !== false);
|
||||||
if (!activeServices.length) {
|
if (!activeServices.length) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@ -78,14 +106,9 @@ function validateCourierServices(services, listing) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const service of activeServices) {
|
for (const service of activeServices) {
|
||||||
if (!service._reference) {
|
if (!getCourierServiceShippingCode(service, marketplace)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Courier service "${service.name}" requires a reference containing its eBay shipping service code.`
|
`Courier service "${service.name}" requires an eBay shipping service code before it can be used on an eBay listing.`
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!service.courier?._reference) {
|
|
||||||
throw new Error(
|
|
||||||
`The courier for service "${service.name}" requires a reference containing its eBay shipping carrier code.`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -101,13 +124,12 @@ function validateCourierServices(services, listing) {
|
|||||||
return activeServices;
|
return activeServices;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildShippingService(service, index, defaultCurrency) {
|
function buildShippingService(service, index, defaultCurrency, marketplace) {
|
||||||
const cost = Number(service.costWithTax ?? service.cost ?? 0);
|
const cost = Number(service.costWithTax ?? service.cost ?? 0);
|
||||||
const additionalCost = Number(service.additionalCostWithTax ?? service.additionalCost ?? 0);
|
const additionalCost = Number(service.additionalCostWithTax ?? service.additionalCost ?? 0);
|
||||||
const shippingService = {
|
const shippingService = {
|
||||||
sortOrder: index + 1,
|
sortOrder: index + 1,
|
||||||
shippingCarrierCode: service.courier._reference,
|
shippingServiceCode: getCourierServiceShippingCode(service, marketplace),
|
||||||
shippingServiceCode: service._reference,
|
|
||||||
buyerResponsibleForShipping: false,
|
buyerResponsibleForShipping: false,
|
||||||
freeShipping: cost === 0 && additionalCost === 0,
|
freeShipping: cost === 0 && additionalCost === 0,
|
||||||
shippingCost: {
|
shippingCost: {
|
||||||
@ -126,25 +148,71 @@ function buildShippingService(service, index, defaultCurrency) {
|
|||||||
return shippingService;
|
return shippingService;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildShippingOption(optionType, services, defaultCurrency) {
|
function buildShippingOption(optionType, services, defaultCurrency, marketplace) {
|
||||||
if (!services.length) return null;
|
if (!services.length) return null;
|
||||||
return {
|
return {
|
||||||
optionType,
|
optionType,
|
||||||
costType: 'FLAT_RATE',
|
costType: 'FLAT_RATE',
|
||||||
shippingServices: services.map((service, index) =>
|
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 marketplaceId = marketplace.config?.marketplaceId || 'EBAY_GB';
|
||||||
const defaultCurrency = marketplace.config?.currency || listing.currency || 'GBP';
|
const defaultCurrency = marketplace.config?.currency || listing.currency || 'GBP';
|
||||||
const domesticServices = services.filter((service) => !service.international);
|
const domesticServices = services.filter((service) => !service.international);
|
||||||
const internationalServices = services.filter((service) => service.international);
|
const internationalServices = services.filter((service) => service.international);
|
||||||
const shippingOptions = [
|
const shippingOptions = [
|
||||||
buildShippingOption('DOMESTIC', domesticServices, defaultCurrency),
|
buildShippingOption('DOMESTIC', domesticServices, defaultCurrency, marketplace),
|
||||||
buildShippingOption('INTERNATIONAL', internationalServices, defaultCurrency),
|
buildShippingOption('INTERNATIONAL', internationalServices, defaultCurrency, marketplace),
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
const deliveryTime = Math.max(0, ...services.map((service) => Number(service.deliveryTime ?? 1)));
|
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),
|
name: `FarmControl ${listing._reference}`.slice(0, 64),
|
||||||
description: `Managed by FarmControl for listing ${listing._reference}`.slice(0, 250),
|
description: `Managed by FarmControl for listing ${listing._reference}`.slice(0, 250),
|
||||||
marketplaceId,
|
marketplaceId,
|
||||||
categoryTypes: [{ name: 'ALL_EXCLUDING_MOTORS_VEHICLES' }],
|
categoryTypes: buildCategoryTypes(existingPolicy, allPolicies),
|
||||||
handlingTime: { value: deliveryTime, unit: 'DAY' },
|
handlingTime: { value: deliveryTime, unit: 'DAY' },
|
||||||
localPickup: false,
|
localPickup: false,
|
||||||
globalShipping: 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) {
|
async function fetchFulfillmentPolicyByName(marketplace, name) {
|
||||||
return makeRequest({
|
return makeRequest({
|
||||||
marketplace,
|
marketplace,
|
||||||
path: '/sell/account/v1/fulfillment_policy/get_by_policy_name',
|
path: '/sell/account/v1/fulfillment_policy/get_by_policy_name',
|
||||||
params: {
|
params: {
|
||||||
marketplace_id: marketplace.config?.marketplaceId || 'EBAY_GB',
|
marketplace_id: getMarketplaceId(marketplace),
|
||||||
name,
|
name,
|
||||||
},
|
},
|
||||||
acceptableStatuses: [404],
|
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) {
|
export async function syncFulfillmentPolicy(marketplace, listing) {
|
||||||
await ensureSellingPolicyManagement(marketplace);
|
await ensureSellingPolicyManagement(marketplace);
|
||||||
|
|
||||||
@ -181,19 +292,26 @@ export async function syncFulfillmentPolicy(marketplace, listing) {
|
|||||||
return { fulfillmentPolicyId: String(listing.fulfillmentPolicyId) };
|
return { fulfillmentPolicyId: String(listing.fulfillmentPolicyId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
const services = validateCourierServices(await resolveCourierServices(listing), listing);
|
const services = validateCourierServices(
|
||||||
const policy = buildFulfillmentPolicy(listing, marketplace, services);
|
await resolveCourierServices(listing),
|
||||||
const existingPolicy = await fetchFulfillmentPolicyByName(marketplace, policy.name);
|
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) {
|
if (existingPolicy?.fulfillmentPolicyId) {
|
||||||
await makeRequest({
|
await updateFulfillmentPolicy(marketplace, existingPolicy.fulfillmentPolicyId, policy);
|
||||||
marketplace,
|
|
||||||
method: 'PUT',
|
|
||||||
path: `/sell/account/v1/fulfillment_policy/${encodeURIComponent(
|
|
||||||
existingPolicy.fulfillmentPolicyId
|
|
||||||
)}`,
|
|
||||||
body: policy,
|
|
||||||
});
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`Updated eBay fulfillment policy "${policy.name}" (${existingPolicy.fulfillmentPolicyId})`
|
`Updated eBay fulfillment policy "${policy.name}" (${existingPolicy.fulfillmentPolicyId})`
|
||||||
);
|
);
|
||||||
|
|||||||
@ -26,5 +26,19 @@ export {
|
|||||||
mapOrderStatus,
|
mapOrderStatus,
|
||||||
mapOrderToSalesOrder,
|
mapOrderToSalesOrder,
|
||||||
mapBuyerToClient,
|
mapBuyerToClient,
|
||||||
|
mapOrderLineItems,
|
||||||
|
mapOrderShipments,
|
||||||
|
getOrder,
|
||||||
|
createShippingFulfillment,
|
||||||
handleWebhook,
|
handleWebhook,
|
||||||
} from './orders.js';
|
} from './orders.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
buildWebhookChallengeResponse,
|
||||||
|
ensureWebhookSubscriptions,
|
||||||
|
verifyNotificationSignature,
|
||||||
|
canVerifyNotificationSignature,
|
||||||
|
} from './notifications.js';
|
||||||
|
|
||||||
|
export { makeRequest as debugGet } from './shared.js';
|
||||||
|
export { syncMarketplaceMetadata } from './shippingServices.js';
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
import { stockLocationModel } from '../../../database/schemas/inventory/stocklocation.schema.js';
|
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 { FARMCONTROL_GB_SUBDIVISION_STATE, resolveEbayCountry } from './countryCodes.js';
|
||||||
import { syncProductCategory } from './categories.js';
|
import { syncProductCategory } from './categories.js';
|
||||||
import { syncFulfillmentPolicy } from './fulfillmentPolicies.js';
|
import { syncFulfillmentPolicy } from './fulfillmentPolicies.js';
|
||||||
import { makeRequest, logger } from './shared.js';
|
import { makeRequest, logger } from './shared.js';
|
||||||
|
import { marketplaceSku } from '../ids.js';
|
||||||
|
|
||||||
const WAREHOUSE_ADDRESS_DEFAULTS = {
|
const WAREHOUSE_ADDRESS_DEFAULTS = {
|
||||||
GB: { city: 'London', stateOrProvince: 'England', postalCode: 'SW1A 1AA' },
|
GB: { city: 'London', stateOrProvince: 'England', postalCode: 'SW1A 1AA' },
|
||||||
@ -22,6 +24,23 @@ const LISTING_STATUS_MAP = {
|
|||||||
UNPUBLISHED: 'draft',
|
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) {
|
function isPopulatedStockLocation(stockLocation) {
|
||||||
if (!stockLocation || typeof stockLocation !== 'object') return false;
|
if (!stockLocation || typeof stockLocation !== 'object') return false;
|
||||||
if (stockLocation instanceof mongoose.Types.ObjectId) return false;
|
if (stockLocation instanceof mongoose.Types.ObjectId) return false;
|
||||||
@ -175,23 +194,72 @@ function applyMarketplaceOfferDefaults(
|
|||||||
if (Object.keys(listingPolicies).length) offer.listingPolicies = listingPolicies;
|
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 = {
|
const item = {
|
||||||
product: { title: listing.title || varient._reference || '' },
|
condition: toEbayCondition(listing?.condition),
|
||||||
|
product: {
|
||||||
|
title: listing.title || marketplaceSku(varient) || '',
|
||||||
|
description: resolveListingDescription(listing, varient),
|
||||||
|
},
|
||||||
availability: {
|
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;
|
if (listing.imageUrls?.length) item.product.imageUrls = listing.imageUrls;
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapVarientToOffer(varient, listing, marketplace, merchantLocationKey) {
|
function mapVarientToOffer(varient, listing, marketplace, merchantLocationKey) {
|
||||||
const offer = {
|
const offer = {
|
||||||
sku: varient._reference,
|
sku: marketplaceSku(varient),
|
||||||
marketplaceId: marketplace.config?.marketplaceId || 'EBAY_GB',
|
marketplaceId: marketplace.config?.marketplaceId || 'EBAY_GB',
|
||||||
format: 'FIXED_PRICE',
|
format: 'FIXED_PRICE',
|
||||||
|
listingDescription: resolveListingDescription(listing, varient),
|
||||||
};
|
};
|
||||||
applyMarketplaceOfferDefaults(
|
applyMarketplaceOfferDefaults(
|
||||||
offer,
|
offer,
|
||||||
@ -213,12 +281,28 @@ function mapVarientToOffer(varient, listing, marketplace, merchantLocationKey) {
|
|||||||
return offer;
|
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) {
|
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({
|
const result = await makeRequest({
|
||||||
marketplace,
|
marketplace,
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
path: `/sell/inventory/v1/inventory_item/${encodeURIComponent(varient._reference)}`,
|
path: `/sell/inventory/v1/inventory_item/${encodeURIComponent(sku)}`,
|
||||||
body: inventoryItem,
|
body: inventoryItem,
|
||||||
});
|
});
|
||||||
logger.debug('inventoryItem', inventoryItem);
|
logger.debug('inventoryItem', inventoryItem);
|
||||||
@ -242,12 +326,15 @@ export async function fetchOffers(marketplace, sku) {
|
|||||||
|
|
||||||
async function upsertOrCreateOffer(marketplace, varient, listing) {
|
async function upsertOrCreateOffer(marketplace, varient, listing) {
|
||||||
const merchantLocationKey = await ensureMerchantLocation(marketplace, 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 existingOffer = offers[0];
|
||||||
const price = varient.price ?? listing.price;
|
const price = varient.price ?? listing.price;
|
||||||
|
|
||||||
if (existingOffer?.offerId) {
|
if (existingOffer?.offerId) {
|
||||||
const offerUpdate = { merchantLocationKey };
|
const offerUpdate = {
|
||||||
|
merchantLocationKey,
|
||||||
|
listingDescription: resolveListingDescription(listing, varient),
|
||||||
|
};
|
||||||
if (price != null) {
|
if (price != null) {
|
||||||
offerUpdate.pricingSummary = {
|
offerUpdate.pricingSummary = {
|
||||||
price: {
|
price: {
|
||||||
@ -309,15 +396,18 @@ export async function syncOfferAndMaybePublish(marketplace, listing, varient) {
|
|||||||
try {
|
try {
|
||||||
const publishResult = await publishOfferById(marketplace, offerResult.offerId);
|
const publishResult = await publishOfferById(marketplace, offerResult.offerId);
|
||||||
if (publishResult?.listingId) {
|
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) {
|
} catch (err) {
|
||||||
logger.warn(
|
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) {
|
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({
|
await makeRequest({
|
||||||
marketplace,
|
marketplace,
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@ -352,6 +445,7 @@ export async function publishOfferForSku(marketplace, sku, listing) {
|
|||||||
...existingOffer,
|
...existingOffer,
|
||||||
merchantLocationKey,
|
merchantLocationKey,
|
||||||
categoryId: String(category.categoryId),
|
categoryId: String(category.categoryId),
|
||||||
|
listingDescription,
|
||||||
listingPolicies: {
|
listingPolicies: {
|
||||||
...(existingOffer.listingPolicies || {}),
|
...(existingOffer.listingPolicies || {}),
|
||||||
fulfillmentPolicyId: fulfillmentPolicy.fulfillmentPolicyId,
|
fulfillmentPolicyId: fulfillmentPolicy.fulfillmentPolicyId,
|
||||||
@ -387,6 +481,7 @@ export function buildVarientEntry(item, offers) {
|
|||||||
const offer = offers?.[0];
|
const offer = offers?.[0];
|
||||||
return {
|
return {
|
||||||
_reference: item.sku,
|
_reference: item.sku,
|
||||||
|
externalReference: item.sku,
|
||||||
price: offer?.pricingSummary?.price?.value
|
price: offer?.pricingSummary?.price?.value
|
||||||
? parseFloat(offer.pricingSummary.price.value)
|
? parseFloat(offer.pricingSummary.price.value)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@ -3,11 +3,15 @@ import { syncFulfillmentPolicy } from './fulfillmentPolicies.js';
|
|||||||
import {
|
import {
|
||||||
buildVarientEntry,
|
buildVarientEntry,
|
||||||
fetchOffers,
|
fetchOffers,
|
||||||
|
fromEbayCondition,
|
||||||
|
resolveListingDescription,
|
||||||
resolveOfferState,
|
resolveOfferState,
|
||||||
syncOfferAndMaybePublish,
|
syncOfferAndMaybePublish,
|
||||||
upsertInventoryItem,
|
upsertInventoryItem,
|
||||||
|
withdrawOfferById,
|
||||||
} from './listingVarients.js';
|
} from './listingVarients.js';
|
||||||
import { makeRequest, logger } from './shared.js';
|
import { makeRequest, logger } from './shared.js';
|
||||||
|
import { marketplaceSku } from '../ids.js';
|
||||||
|
|
||||||
function sleep(ms) {
|
function sleep(ms) {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
@ -15,17 +19,14 @@ function sleep(ms) {
|
|||||||
|
|
||||||
async function createOrReplaceGroup(marketplace, listing, varients) {
|
async function createOrReplaceGroup(marketplace, listing, varients) {
|
||||||
const groupKey = listing._reference;
|
const groupKey = listing._reference;
|
||||||
const variantSKUs = varients.map((v) => v._reference).filter(Boolean);
|
const variantSKUs = varients.map((v) => marketplaceSku(v)).filter(Boolean);
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
title: listing.title || groupKey,
|
title: listing.title || groupKey,
|
||||||
variantSKUs,
|
variantSKUs,
|
||||||
|
description: resolveListingDescription(listing),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (listing.description) {
|
|
||||||
body.description = listing.description;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (listing.imageUrls?.length) {
|
if (listing.imageUrls?.length) {
|
||||||
body.imageUrls = listing.imageUrls;
|
body.imageUrls = listing.imageUrls;
|
||||||
}
|
}
|
||||||
@ -78,17 +79,17 @@ async function syncGroupedListing(marketplace, listing, varients) {
|
|||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
await createOrReplaceGroup(marketplace, listing, varients);
|
await createOrReplaceGroup(marketplace, listing, varients);
|
||||||
|
|
||||||
let firstPublishedUrl = '';
|
let firstPublished = { url: '' };
|
||||||
for (const varient of varients) {
|
for (const varient of varients) {
|
||||||
try {
|
try {
|
||||||
const publishedUrl = await syncOfferAndMaybePublish(marketplace, listing, varient);
|
const published = await syncOfferAndMaybePublish(marketplace, listing, varient);
|
||||||
if (publishedUrl && !firstPublishedUrl) firstPublishedUrl = publishedUrl;
|
if (published?.url && !firstPublished.url) firstPublished = published;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(`Failed to create offer for varient ${varient._reference}: ${err.message}`);
|
logger.warn(`Failed to create offer for varient ${varient._reference}: ${err.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return firstPublishedUrl;
|
return firstPublished;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncListing(marketplace, listing, varients, actionLabel) {
|
async function syncListing(marketplace, listing, varients, actionLabel) {
|
||||||
@ -116,12 +117,25 @@ async function syncListing(marketplace, listing, varients, actionLabel) {
|
|||||||
logger.info(
|
logger.info(
|
||||||
`Syncing standalone eBay inventory item "${validVarients[0]._reference}" for listing "${ref}"`
|
`Syncing standalone eBay inventory item "${validVarients[0]._reference}" for listing "${ref}"`
|
||||||
);
|
);
|
||||||
const url = await syncSingleVarientListing(marketplace, listingWithContext, validVarients[0]);
|
const published = await syncSingleVarientListing(
|
||||||
return { url };
|
marketplace,
|
||||||
|
listingWithContext,
|
||||||
|
validVarients[0]
|
||||||
|
);
|
||||||
|
return listingSyncResult(published);
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = await syncGroupedListing(marketplace, listingWithContext, validVarients);
|
const published = await syncGroupedListing(marketplace, listingWithContext, validVarients);
|
||||||
return { url };
|
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) {
|
export async function createItem(marketplace, listing, varients) {
|
||||||
@ -132,12 +146,37 @@ export async function updateItem(marketplace, listing, varients) {
|
|||||||
return syncListing(marketplace, listing, varients, 'update');
|
return syncListing(marketplace, listing, varients, 'update');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteItem(marketplace, listing) {
|
export async function deleteItem(marketplace, listing, varients = []) {
|
||||||
const ref = listing._reference;
|
const skus = varients.map((v) => marketplaceSku(v)).filter(Boolean);
|
||||||
if (!ref) return;
|
|
||||||
|
|
||||||
|
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}"`);
|
logger.info(`Deleting eBay inventory item group "${ref}"`);
|
||||||
await deleteGroup(marketplace, ref);
|
await deleteGroup(marketplace, ref);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Sync helpers (inbound from eBay) ---
|
// --- Sync helpers (inbound from eBay) ---
|
||||||
@ -278,8 +317,10 @@ export function mapProductToListing(ebayItem) {
|
|||||||
const varients = variants.map((v) => buildVarientEntry(v, v._offers || []));
|
const varients = variants.map((v) => buildVarientEntry(v, v._offers || []));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
_reference: ebayItem._groupKey,
|
externalReference: firstPublishedOffer?.listingId || ebayItem._groupKey,
|
||||||
title: group.title || ebayItem._groupKey,
|
title: group.title || ebayItem._groupKey,
|
||||||
|
description: group.description,
|
||||||
|
condition: fromEbayCondition(variants[0]?.condition),
|
||||||
state: { type: stateType },
|
state: { type: stateType },
|
||||||
price,
|
price,
|
||||||
currency,
|
currency,
|
||||||
@ -301,8 +342,10 @@ export function mapProductToListing(ebayItem) {
|
|||||||
const varients = [buildVarientEntry(ebayItem, ebayItem._offers || [])];
|
const varients = [buildVarientEntry(ebayItem, ebayItem._offers || [])];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
_reference: ebayItem.sku,
|
externalReference: offer?.listingId || ebayItem.sku,
|
||||||
title: ebayItem.product?.title || ebayItem.sku,
|
title: ebayItem.product?.title || ebayItem.sku,
|
||||||
|
description: ebayItem.product?.description,
|
||||||
|
condition: fromEbayCondition(ebayItem.condition),
|
||||||
state: { type: stateType },
|
state: { type: stateType },
|
||||||
price,
|
price,
|
||||||
currency,
|
currency,
|
||||||
|
|||||||
251
src/integrations/marketplaces/ebay/notifications.js
Normal file
251
src/integrations/marketplaces/ebay/notifications.js
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import { getApiBaseUrl, getBasicAuthHeader, makeRequest, logger, formatDebugPayload, getMarketplaceDebugContext } from './shared.js';
|
||||||
|
|
||||||
|
const NOTIFICATION_BASE = '/commerce/notification/v1';
|
||||||
|
const APPLICATION_SCOPE =
|
||||||
|
'https://api.ebay.com/oauth/api_scope/commerce.notification.subscription';
|
||||||
|
|
||||||
|
const HANDLED_TOPIC_MATCHERS = [
|
||||||
|
'MARKETPLACE_ACCOUNT_DELETION',
|
||||||
|
'AUTHORIZATION_REVOCATION',
|
||||||
|
'ITEM_AVAILABILITY',
|
||||||
|
'ITEM_PRICE_REVISION',
|
||||||
|
'ORDER',
|
||||||
|
];
|
||||||
|
|
||||||
|
function getVerificationToken(marketplace) {
|
||||||
|
return (
|
||||||
|
marketplace.config?.verificationToken ||
|
||||||
|
marketplace.config?.verificationToken ||
|
||||||
|
''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWebhookEndpoint(marketplace) {
|
||||||
|
return marketplace.config?.webhookUrl || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWebhookChallengeResponse(marketplace, { challengeCode, endpoint } = {}) {
|
||||||
|
const verificationToken = getVerificationToken(marketplace);
|
||||||
|
const destination = endpoint || getWebhookEndpoint(marketplace);
|
||||||
|
if (!challengeCode || !verificationToken || !destination) {
|
||||||
|
throw new Error(
|
||||||
|
'eBay webhook challenge requires challengeCode, verificationToken, and endpoint'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const challengeResponse = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(challengeCode + verificationToken + destination)
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
return { challengeResponse };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getApplicationToken(marketplace) {
|
||||||
|
logger.debug('eBay application token request', getMarketplaceDebugContext(marketplace));
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const response = await fetch(`${getApiBaseUrl(marketplace)}/identity/v1/oauth2/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
Authorization: getBasicAuthHeader(marketplace),
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
scope: APPLICATION_SCOPE,
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
const durationMs = Date.now() - startedAt;
|
||||||
|
if (!response.ok || data.error) {
|
||||||
|
logger.error('eBay application token request failed', {
|
||||||
|
status: response.status,
|
||||||
|
durationMs,
|
||||||
|
...getMarketplaceDebugContext(marketplace),
|
||||||
|
response: formatDebugPayload(data),
|
||||||
|
});
|
||||||
|
throw new Error(data.error_description || data.error || 'Failed to mint eBay application token');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`eBay application token request succeeded (${durationMs}ms)`, {
|
||||||
|
...getMarketplaceDebugContext(marketplace),
|
||||||
|
expiresIn: data.expires_in,
|
||||||
|
tokenType: data.token_type,
|
||||||
|
});
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notificationRequest(marketplace, { method = 'GET', path, body, useApplicationToken = false }) {
|
||||||
|
if (!useApplicationToken) {
|
||||||
|
return makeRequest({ marketplace, method, path, body, acceptableStatuses: [404] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await getApplicationToken(marketplace);
|
||||||
|
const url = `${getApiBaseUrl(marketplace)}${path}`;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
logger.debug(`eBay notification API ${method} ${path}`, {
|
||||||
|
...getMarketplaceDebugContext(marketplace),
|
||||||
|
useApplicationToken: true,
|
||||||
|
body: body ? formatDebugPayload(body) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
},
|
||||||
|
...(body && method !== 'GET' ? { body: JSON.stringify(body) } : {}),
|
||||||
|
});
|
||||||
|
const durationMs = Date.now() - startedAt;
|
||||||
|
if (response.status === 204 || response.status === 404) {
|
||||||
|
logger.debug(
|
||||||
|
`eBay notification API ${method} ${path} -> ${response.status} (${durationMs}ms)`,
|
||||||
|
getMarketplaceDebugContext(marketplace)
|
||||||
|
);
|
||||||
|
return response.status === 404 ? null : null;
|
||||||
|
}
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = data?.errors?.[0]?.message || data?.error_description || response.statusText;
|
||||||
|
logger.error(`eBay notification API error (${response.status}): ${message}`, {
|
||||||
|
path,
|
||||||
|
durationMs,
|
||||||
|
...getMarketplaceDebugContext(marketplace),
|
||||||
|
response: data ? formatDebugPayload(data) : undefined,
|
||||||
|
});
|
||||||
|
throw new Error(`eBay notification API error (${response.status}): ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`eBay notification API ${method} ${path} -> ${response.status} (${durationMs}ms)`, {
|
||||||
|
...getMarketplaceDebugContext(marketplace),
|
||||||
|
response: data ? formatDebugPayload(data) : undefined,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function topicIsHandled(topicId = '') {
|
||||||
|
const id = String(topicId).toUpperCase();
|
||||||
|
return HANDLED_TOPIC_MATCHERS.some((matcher) => id.includes(matcher));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureWebhookSubscriptions(marketplace) {
|
||||||
|
const endpoint = getWebhookEndpoint(marketplace);
|
||||||
|
const verificationToken = getVerificationToken(marketplace);
|
||||||
|
if (!endpoint || !verificationToken) {
|
||||||
|
logger.warn(
|
||||||
|
`Skipping eBay webhook subscription for "${marketplace.name}" (missing webhookUrl or verificationToken)`
|
||||||
|
);
|
||||||
|
return { skipped: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const destinations = await notificationRequest(marketplace, {
|
||||||
|
path: `${NOTIFICATION_BASE}/destination`,
|
||||||
|
});
|
||||||
|
let destination = (destinations?.destinations || []).find(
|
||||||
|
(item) => item.deliveryConfig?.endpoint === endpoint
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!destination) {
|
||||||
|
destination = await notificationRequest(marketplace, {
|
||||||
|
method: 'POST',
|
||||||
|
path: `${NOTIFICATION_BASE}/destination`,
|
||||||
|
body: {
|
||||||
|
name: `FarmControl ${marketplace.name}`.slice(0, 200),
|
||||||
|
status: 'ENABLED',
|
||||||
|
deliveryConfig: {
|
||||||
|
endpoint,
|
||||||
|
verificationToken,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const destinationId = destination?.destinationId || destination?.id;
|
||||||
|
if (!destinationId) {
|
||||||
|
throw new Error('Failed to create or resolve eBay notification destination');
|
||||||
|
}
|
||||||
|
|
||||||
|
const topics = await notificationRequest(marketplace, { path: `${NOTIFICATION_BASE}/topic` });
|
||||||
|
const subscriptions = await notificationRequest(marketplace, {
|
||||||
|
path: `${NOTIFICATION_BASE}/subscription`,
|
||||||
|
});
|
||||||
|
const existingTopicIds = new Set(
|
||||||
|
(subscriptions?.subscriptions || []).map((item) => item.topicId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const created = [];
|
||||||
|
for (const topic of topics?.topics || []) {
|
||||||
|
if (topic.status && String(topic.status).toUpperCase() !== 'ENABLED') continue;
|
||||||
|
if (!topicIsHandled(topic.topicId)) continue;
|
||||||
|
if (existingTopicIds.has(topic.topicId)) continue;
|
||||||
|
|
||||||
|
const useApplicationToken = String(topic.scope || '').toUpperCase() === 'APPLICATION';
|
||||||
|
try {
|
||||||
|
await notificationRequest(marketplace, {
|
||||||
|
method: 'POST',
|
||||||
|
path: `${NOTIFICATION_BASE}/subscription`,
|
||||||
|
useApplicationToken,
|
||||||
|
body: {
|
||||||
|
topicId: topic.topicId,
|
||||||
|
status: 'ENABLED',
|
||||||
|
destinationId,
|
||||||
|
payload: { format: 'JSON', schemaVersion: topic.supportedPayloads?.[0]?.schemaVersion || '1.0' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
created.push(topic.topicId);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Failed to subscribe to eBay topic ${topic.topicId}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`eBay webhook destination ${destinationId} ready for "${marketplace.name}" (${created.length} new subscription(s))`
|
||||||
|
);
|
||||||
|
return { destinationId, created };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPublicKey(marketplace, kid) {
|
||||||
|
return makeRequest({
|
||||||
|
marketplace,
|
||||||
|
path: `${NOTIFICATION_BASE}/public_key/${encodeURIComponent(kid)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyNotificationSignature(marketplace, rawBody, signatureHeader) {
|
||||||
|
if (!signatureHeader) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decodedJson = Buffer.from(signatureHeader, 'base64').toString('utf8');
|
||||||
|
const decoded = JSON.parse(decodedJson);
|
||||||
|
const kid = decoded.kid;
|
||||||
|
const signature = decoded.signature;
|
||||||
|
if (!kid || !signature) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = await getPublicKey(marketplace, kid);
|
||||||
|
const pem = publicKey?.key;
|
||||||
|
if (!pem) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const digest = (decoded.digest || publicKey.digest || 'SHA256').replace('-', '');
|
||||||
|
const verifier = crypto.createVerify(digest);
|
||||||
|
verifier.update(rawBody);
|
||||||
|
verifier.end();
|
||||||
|
return verifier.verify(pem, signature, 'base64');
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`eBay notification signature verification failed: ${err.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canVerifyNotificationSignature(marketplace) {
|
||||||
|
return !!marketplace.config?.accessToken;
|
||||||
|
}
|
||||||
@ -90,6 +90,7 @@ export function mapOrderToSalesOrder(ebayOrder) {
|
|||||||
const grandTotal = parseFloat(pricingSummary.total?.value || 0);
|
const grandTotal = parseFloat(pricingSummary.total?.value || 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
externalReference: ebayOrder.orderId,
|
||||||
externalId: ebayOrder.orderId,
|
externalId: ebayOrder.orderId,
|
||||||
state: { type: mapOrderStatus(ebayOrder) },
|
state: { type: mapOrderStatus(ebayOrder) },
|
||||||
totalAmount,
|
totalAmount,
|
||||||
@ -112,6 +113,7 @@ export function mapBuyerToClient(ebayOrder) {
|
|||||||
name: fullName,
|
name: fullName,
|
||||||
email: buyer.buyerRegistrationAddress?.email || '',
|
email: buyer.buyerRegistrationAddress?.email || '',
|
||||||
phone: address.primaryPhone?.phoneNumber || '',
|
phone: address.primaryPhone?.phoneNumber || '',
|
||||||
|
externalReference: buyer.username || '',
|
||||||
address: {
|
address: {
|
||||||
addressLine1: contactAddress.addressLine1 || '',
|
addressLine1: contactAddress.addressLine1 || '',
|
||||||
addressLine2: contactAddress.addressLine2 || '',
|
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) {
|
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}`);
|
logger.info(`eBay webhook received: ${topic} for marketplace ${marketplace.name}`);
|
||||||
|
|
||||||
if (topic?.startsWith('marketplace.account_deletion')) {
|
const orderId = data.orderId || data.order?.orderId || data.legacyOrderId;
|
||||||
return { action: 'accountDeletion', userId: data?.userId };
|
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) {
|
if (normalized.includes('AUTHORIZATION_REVOCATION')) {
|
||||||
case 'item.sold':
|
return { action: 'disconnect', notificationId, topic };
|
||||||
return { action: 'orderCreate', orderId: data?.orderId };
|
}
|
||||||
|
|
||||||
case 'item.created':
|
if (normalized.includes('ORDER') || normalized === 'ITEM.SOLD' || normalized === 'ORDER.FULFILLMENT') {
|
||||||
case 'item.updated':
|
return {
|
||||||
return { action: 'productUpdate', itemId: data?.itemId };
|
action: orderId && topic.toLowerCase().includes('cancel') ? 'orderCancel' : 'orderUpdate',
|
||||||
|
orderId,
|
||||||
|
notificationId,
|
||||||
|
topic,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
case 'item.ended':
|
if (
|
||||||
return { action: 'productEnded', itemId: data?.itemId };
|
normalized.includes('ITEM') ||
|
||||||
|
normalized.includes('AVAILABILITY') ||
|
||||||
|
normalized.includes('PRICE')
|
||||||
|
) {
|
||||||
|
return { action: 'productUpdate', itemId: sku, sku, notificationId, topic };
|
||||||
|
}
|
||||||
|
|
||||||
case 'order.cancelled':
|
|
||||||
return { action: 'orderCancel', orderId: data?.orderId };
|
|
||||||
|
|
||||||
case 'order.fulfillment':
|
|
||||||
return { action: 'orderUpdate', orderId: data?.orderId };
|
|
||||||
|
|
||||||
default:
|
|
||||||
logger.debug(`Unhandled eBay webhook topic: ${topic}`);
|
logger.debug(`Unhandled eBay webhook topic: ${topic}`);
|
||||||
return { action: 'unknown', topic };
|
return { action: 'unknown', topic, notificationId };
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,35 @@ import log4js from 'log4js';
|
|||||||
const logger = log4js.getLogger('eBay');
|
const logger = log4js.getLogger('eBay');
|
||||||
logger.level = config.server.logLevel;
|
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 SANDBOX_API_URL = 'https://api.sandbox.ebay.com';
|
||||||
const PRODUCTION_API_URL = 'https://api.ebay.com';
|
const PRODUCTION_API_URL = 'https://api.ebay.com';
|
||||||
const SANDBOX_AUTH_URL = 'https://auth.sandbox.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.inventory',
|
||||||
'https://api.ebay.com/oauth/api_scope/sell.fulfillment',
|
'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/sell.account',
|
||||||
|
'https://api.ebay.com/oauth/api_scope/commerce.notification.subscription',
|
||||||
];
|
];
|
||||||
const MARKETPLACE_LANGUAGE_MAP = {
|
const MARKETPLACE_LANGUAGE_MAP = {
|
||||||
EBAY_US: 'en-US',
|
EBAY_US: 'en-US',
|
||||||
@ -144,11 +174,21 @@ export async function makeRequest({
|
|||||||
fetchOptions.body = JSON.stringify(body);
|
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 response = await fetch(url, fetchOptions);
|
||||||
|
const durationMs = Date.now() - startedAt;
|
||||||
|
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
|
logger.debug(`eBay API ${method} ${path} -> 204 No Content (${durationMs}ms)`, {
|
||||||
|
...getMarketplaceDebugContext(marketplace),
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -163,19 +203,40 @@ export async function makeRequest({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok && acceptableStatuses.includes(response.status)) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const message =
|
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) ||
|
(typeof data === 'string' && data) ||
|
||||||
response.statusText;
|
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}`);
|
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;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { logger };
|
export { logger, formatDebugPayload, getMarketplaceDebugContext };
|
||||||
|
|||||||
144
src/integrations/marketplaces/ebay/shippingServices.js
Normal file
144
src/integrations/marketplaces/ebay/shippingServices.js
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import { logger, getMarketplaceDebugContext } from './shared.js';
|
||||||
|
|
||||||
|
const TRADING_COMPATIBILITY_LEVEL = '1399';
|
||||||
|
const TRADING_SANDBOX_URL = 'https://api.sandbox.ebay.com/ws/api.dll';
|
||||||
|
const TRADING_PRODUCTION_URL = 'https://api.ebay.com/ws/api.dll';
|
||||||
|
|
||||||
|
const MARKETPLACE_SITE_IDS = {
|
||||||
|
EBAY_US: '0',
|
||||||
|
EBAY_ENCA: '2',
|
||||||
|
EBAY_GB: '3',
|
||||||
|
EBAY_AU: '15',
|
||||||
|
EBAY_AT: '16',
|
||||||
|
EBAY_FRBE: '23',
|
||||||
|
EBAY_FR: '71',
|
||||||
|
EBAY_DE: '77',
|
||||||
|
EBAY_IT: '101',
|
||||||
|
EBAY_NLBE: '123',
|
||||||
|
EBAY_NL: '146',
|
||||||
|
EBAY_ES: '186',
|
||||||
|
EBAY_CH: '193',
|
||||||
|
EBAY_HK: '201',
|
||||||
|
EBAY_IN: '203',
|
||||||
|
EBAY_IE: '205',
|
||||||
|
EBAY_MY: '207',
|
||||||
|
EBAY_PH: '211',
|
||||||
|
EBAY_PL: '212',
|
||||||
|
EBAY_SG: '216',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getTradingApiUrl(marketplace) {
|
||||||
|
return marketplace.config?.sandbox ? TRADING_SANDBOX_URL : TRADING_PRODUCTION_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSiteId(marketplace) {
|
||||||
|
const marketplaceId = marketplace.config?.marketplaceId || 'EBAY_GB';
|
||||||
|
return MARKETPLACE_SITE_IDS[marketplaceId] || MARKETPLACE_SITE_IDS.EBAY_GB;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseShippingServiceDetails(xml = '') {
|
||||||
|
const blocks = xml.match(/<ShippingServiceDetails>[\s\S]*?<\/ShippingServiceDetails>/g) || [];
|
||||||
|
const services = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
const validMatch = block.match(/<ValidForSellingFlow>([^<]+)<\/ValidForSellingFlow>/i);
|
||||||
|
if (validMatch && String(validMatch[1]).toLowerCase() !== 'true') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const serviceMatch = block.match(/<ShippingService>([^<]+)<\/ShippingService>/i);
|
||||||
|
const code = serviceMatch?.[1]?.trim();
|
||||||
|
if (!code || seen.has(code)) continue;
|
||||||
|
seen.add(code);
|
||||||
|
services.push(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
return services.sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGeteBayDetailsRequest() {
|
||||||
|
return `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<GeteBayDetailsRequest xmlns="urn:ebay:apis:eBLBaseComponents">
|
||||||
|
<ErrorLanguage>en_GB</ErrorLanguage>
|
||||||
|
<WarningLevel>High</WarningLevel>
|
||||||
|
<DetailName>ShippingServiceDetails</DetailName>
|
||||||
|
</GeteBayDetailsRequest>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEbayShippingServices(marketplace) {
|
||||||
|
const accessToken = marketplace.config?.accessToken;
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error(
|
||||||
|
'eBay marketplace is not authenticated. Complete marketplace authorization first.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = getTradingApiUrl(marketplace);
|
||||||
|
const siteId = getSiteId(marketplace);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
logger.debug('eBay Trading API GeteBayDetails request', {
|
||||||
|
...getMarketplaceDebugContext(marketplace),
|
||||||
|
siteId,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/xml',
|
||||||
|
'X-EBAY-API-CALL-NAME': 'GeteBayDetails',
|
||||||
|
'X-EBAY-API-SITEID': siteId,
|
||||||
|
'X-EBAY-API-COMPATIBILITY-LEVEL': TRADING_COMPATIBILITY_LEVEL,
|
||||||
|
'X-EBAY-API-IAF-TOKEN': accessToken,
|
||||||
|
},
|
||||||
|
body: buildGeteBayDetailsRequest(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const xml = await response.text();
|
||||||
|
const durationMs = Date.now() - startedAt;
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.error(`GeteBayDetails failed (${response.status})`, {
|
||||||
|
durationMs,
|
||||||
|
...getMarketplaceDebugContext(marketplace),
|
||||||
|
siteId,
|
||||||
|
});
|
||||||
|
throw new Error(`eBay GeteBayDetails error (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ackMatch = xml.match(/<Ack>([^<]+)<\/Ack>/);
|
||||||
|
const ack = ackMatch?.[1];
|
||||||
|
if (ack && ack !== 'Success' && ack !== 'Warning') {
|
||||||
|
const message =
|
||||||
|
xml.match(/<LongMessage>([^<]+)<\/LongMessage>/)?.[1] ||
|
||||||
|
xml.match(/<ShortMessage>([^<]+)<\/ShortMessage>/)?.[1] ||
|
||||||
|
ack;
|
||||||
|
logger.error(`GeteBayDetails failed: ${message}`, {
|
||||||
|
ack,
|
||||||
|
durationMs,
|
||||||
|
...getMarketplaceDebugContext(marketplace),
|
||||||
|
siteId,
|
||||||
|
});
|
||||||
|
throw new Error(`eBay GeteBayDetails failed: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const services = parseShippingServiceDetails(xml);
|
||||||
|
logger.debug(`GeteBayDetails succeeded (${durationMs}ms)`, {
|
||||||
|
...getMarketplaceDebugContext(marketplace),
|
||||||
|
siteId,
|
||||||
|
serviceCount: services.length,
|
||||||
|
});
|
||||||
|
logger.info(
|
||||||
|
`Fetched ${services.length} eBay shipping service(s) for marketplace "${marketplace.name}"`
|
||||||
|
);
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncMarketplaceMetadata(marketplace) {
|
||||||
|
const availableShippingServices = await fetchEbayShippingServices(marketplace);
|
||||||
|
return {
|
||||||
|
eBay: {
|
||||||
|
...(marketplace.eBay && typeof marketplace.eBay === 'object' ? marketplace.eBay : {}),
|
||||||
|
availableShippingServices,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
9
src/integrations/marketplaces/ids.js
Normal file
9
src/integrations/marketplaces/ids.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export function marketplaceSku(record) {
|
||||||
|
return record?.externalReference || record?._reference;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function marketplaceActor(marketplace) {
|
||||||
|
const plain =
|
||||||
|
typeof marketplace?.toObject === 'function' ? marketplace.toObject() : { ...marketplace };
|
||||||
|
return { ...plain, _objectType: 'marketplace' };
|
||||||
|
}
|
||||||
@ -429,8 +429,8 @@ function mapListingToProduct(listing) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (listing.externalId) {
|
if (listing.externalReference || listing.externalId) {
|
||||||
sku.seller_sku = listing.externalId;
|
sku.seller_sku = listing.externalReference || listing.externalId;
|
||||||
}
|
}
|
||||||
|
|
||||||
product.skus = [sku];
|
product.skus = [sku];
|
||||||
@ -452,15 +452,16 @@ export async function createItem(marketplace, listing) {
|
|||||||
const productId = result?.product_id || result?.id;
|
const productId = result?.product_id || result?.id;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
externalReference: productId,
|
||||||
externalId: productId,
|
externalId: productId,
|
||||||
url: '',
|
url: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateItem(marketplace, listing) {
|
export async function updateItem(marketplace, listing) {
|
||||||
const productId = listing.externalId;
|
const productId = listing.externalReference || listing.externalId;
|
||||||
if (!productId) {
|
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}`);
|
logger.info(`Updating TikTok Shop product: ${productId}`);
|
||||||
@ -473,7 +474,7 @@ export async function updateItem(marketplace, listing) {
|
|||||||
body: productBody,
|
body: productBody,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { externalId: productId };
|
return { externalReference: productId, externalId: productId };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function syncItems(marketplace) {
|
export async function syncItems(marketplace) {
|
||||||
@ -537,6 +538,7 @@ export function mapOrderToSalesOrder(tiktokOrder) {
|
|||||||
const totalTax = parseFloat(paymentInfo.tax || 0);
|
const totalTax = parseFloat(paymentInfo.tax || 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
externalReference: tiktokOrder.id,
|
||||||
externalId: tiktokOrder.id,
|
externalId: tiktokOrder.id,
|
||||||
state: { type: mapOrderStatus(tiktokOrder.status) },
|
state: { type: mapOrderStatus(tiktokOrder.status) },
|
||||||
totalAmount,
|
totalAmount,
|
||||||
@ -579,6 +581,7 @@ export function mapProductToListing(tiktokProduct) {
|
|||||||
: undefined;
|
: undefined;
|
||||||
return {
|
return {
|
||||||
_reference: sku.id || sku.seller_sku || '',
|
_reference: sku.id || sku.seller_sku || '',
|
||||||
|
externalReference: sku.seller_sku || sku.id || '',
|
||||||
price: skuPrice,
|
price: skuPrice,
|
||||||
currency: sku.price?.currency || currency,
|
currency: sku.price?.currency || currency,
|
||||||
state: { type: PRODUCT_STATUS_MAP[tiktokProduct.status] || 'draft' },
|
state: { type: PRODUCT_STATUS_MAP[tiktokProduct.status] || 'draft' },
|
||||||
@ -586,6 +589,7 @@ export function mapProductToListing(tiktokProduct) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
externalReference: tiktokProduct.id,
|
||||||
_reference: tiktokProduct.id,
|
_reference: tiktokProduct.id,
|
||||||
title: tiktokProduct.title || '',
|
title: tiktokProduct.title || '',
|
||||||
state: { type: PRODUCT_STATUS_MAP[tiktokProduct.status] || 'draft' },
|
state: { type: PRODUCT_STATUS_MAP[tiktokProduct.status] || 'draft' },
|
||||||
@ -602,6 +606,7 @@ export function mapBuyerToClient(tiktokOrder) {
|
|||||||
const address = tiktokOrder.recipient_address || {};
|
const address = tiktokOrder.recipient_address || {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
externalReference: buyer.open_id || buyer.username || '',
|
||||||
name: buyer.name || address.name || 'Unknown',
|
name: buyer.name || address.name || 'Unknown',
|
||||||
email: buyer.email || '',
|
email: buyer.email || '',
|
||||||
phone: buyer.phone_number || address.phone_number || '',
|
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) {
|
export async function handleWebhook(marketplace, event) {
|
||||||
const { type, data } = event;
|
const { type, data } = event;
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,19 @@
|
|||||||
import config from '../config.js';
|
import config from '../config.js';
|
||||||
import log4js from 'log4js';
|
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 { listingModel } from '../database/schemas/sales/listing.schema.js';
|
||||||
import { listingVarientModel } from '../database/schemas/sales/listingvarient.schema.js';
|
import { listingVarientModel } from '../database/schemas/sales/listingvarient.schema.js';
|
||||||
import { marketplaceModel } from '../database/schemas/sales/marketplace.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 tiktokShop from './marketplaces/tiktokShop.js';
|
||||||
import * as ebay from './marketplaces/ebay/index.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');
|
const logger = log4js.getLogger('Marketplace Worker');
|
||||||
logger.level = config.server.logLevel;
|
logger.level = config.server.logLevel;
|
||||||
@ -128,8 +134,17 @@ export async function exchangeAuthorizationCode(marketplace, user, { code, state
|
|||||||
user
|
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 {
|
return {
|
||||||
marketplace: updatedMarketplace,
|
marketplace: marketplaceWithAuth,
|
||||||
...(authResult?.data ? { data: authResult.data } : {}),
|
...(authResult?.data ? { data: authResult.data } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -156,10 +171,17 @@ export async function refreshMarketplaceAuth(marketplace, user) {
|
|||||||
|
|
||||||
export function canVerifyWebhookSignature(marketplace) {
|
export function canVerifyWebhookSignature(marketplace) {
|
||||||
const provider = getProvider(marketplace);
|
const provider = getProvider(marketplace);
|
||||||
if (typeof provider.verifyWebhookSignature !== 'function') {
|
if (
|
||||||
|
typeof provider.verifyWebhookSignature !== 'function' &&
|
||||||
|
typeof provider.verifyNotificationSignature !== 'function'
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof provider.canVerifyNotificationSignature === 'function') {
|
||||||
|
return provider.canVerifyNotificationSignature(marketplace);
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof provider.canVerifyWebhookSignature === 'function') {
|
if (typeof provider.canVerifyWebhookSignature === 'function') {
|
||||||
return provider.canVerifyWebhookSignature(marketplace);
|
return provider.canVerifyWebhookSignature(marketplace);
|
||||||
}
|
}
|
||||||
@ -169,6 +191,9 @@ export function canVerifyWebhookSignature(marketplace) {
|
|||||||
|
|
||||||
export function verifyWebhookSignature(marketplace, rawBody, signature) {
|
export function verifyWebhookSignature(marketplace, rawBody, signature) {
|
||||||
const provider = getProvider(marketplace);
|
const provider = getProvider(marketplace);
|
||||||
|
if (typeof provider.verifyNotificationSignature === 'function') {
|
||||||
|
return provider.verifyNotificationSignature(marketplace, rawBody, signature);
|
||||||
|
}
|
||||||
if (!provider.verifyWebhookSignature) {
|
if (!provider.verifyWebhookSignature) {
|
||||||
logger.warn(`Provider ${marketplace.provider} does not support webhook signature verification`);
|
logger.warn(`Provider ${marketplace.provider} does not support webhook signature verification`);
|
||||||
return true;
|
return true;
|
||||||
@ -177,9 +202,67 @@ export function verifyWebhookSignature(marketplace, rawBody, signature) {
|
|||||||
return provider.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);
|
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) {
|
async function setListingState(listingId, stateType, user, message) {
|
||||||
@ -262,13 +345,18 @@ export function createListing(marketplace, user, listingData) {
|
|||||||
if (listingData._id) {
|
if (listingData._id) {
|
||||||
const updateData = { lastSyncedAt: new Date(), state: { type: 'active' } };
|
const updateData = { lastSyncedAt: new Date(), state: { type: 'active' } };
|
||||||
if (result?.url) updateData.url = result.url;
|
if (result?.url) updateData.url = result.url;
|
||||||
|
if (result?.externalReference) updateData.externalReference = result.externalReference;
|
||||||
await editObject({ model: listingModel, id: listingData._id, updateData, user });
|
await editObject({ model: listingModel, id: listingData._id, updateData, user });
|
||||||
|
|
||||||
for (const varient of varients) {
|
for (const varient of varients) {
|
||||||
|
const varientUpdate = { lastSyncedAt: new Date(), state: { type: 'active' } };
|
||||||
|
if (!varient.externalReference && marketplaceSku(varient)) {
|
||||||
|
varientUpdate.externalReference = marketplaceSku(varient);
|
||||||
|
}
|
||||||
await editObject({
|
await editObject({
|
||||||
model: listingVarientModel,
|
model: listingVarientModel,
|
||||||
id: varient._id,
|
id: varient._id,
|
||||||
updateData: { lastSyncedAt: new Date(), state: { type: 'active' } },
|
updateData: varientUpdate,
|
||||||
user,
|
user,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
@ -312,21 +400,28 @@ export function updateListing(marketplace, user, listingData) {
|
|||||||
logger.info(
|
logger.info(
|
||||||
`Updating listing on marketplace "${marketplace.name}" (${marketplace.provider})`
|
`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) {
|
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({
|
await editObject({
|
||||||
model: listingModel,
|
model: listingModel,
|
||||||
id: listingData._id,
|
id: listingData._id,
|
||||||
updateData: { state: { type: 'active' }, lastSyncedAt: new Date() },
|
updateData,
|
||||||
user,
|
user,
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const varient of varients) {
|
for (const varient of varients) {
|
||||||
|
const varientUpdate = { lastSyncedAt: new Date(), state: { type: 'active' } };
|
||||||
|
if (!varient.externalReference && marketplaceSku(varient)) {
|
||||||
|
varientUpdate.externalReference = marketplaceSku(varient);
|
||||||
|
}
|
||||||
await editObject({
|
await editObject({
|
||||||
model: listingVarientModel,
|
model: listingVarientModel,
|
||||||
id: varient._id,
|
id: varient._id,
|
||||||
updateData: { lastSyncedAt: new Date(), state: { type: 'active' } },
|
updateData: varientUpdate,
|
||||||
user,
|
user,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
@ -359,7 +454,10 @@ export function deleteListing(marketplace, user, listingData) {
|
|||||||
logger.info(
|
logger.info(
|
||||||
`Deleting listing from marketplace "${marketplace.name}" (${marketplace.provider})`
|
`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}"`);
|
logger.info(`Background deleteListing complete for marketplace "${marketplace.name}"`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(
|
logger.error(
|
||||||
@ -380,10 +478,29 @@ export function syncItems(marketplace, user) {
|
|||||||
try {
|
try {
|
||||||
const provider = getProvider(marketplace);
|
const provider = getProvider(marketplace);
|
||||||
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
|
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
|
||||||
|
const actor = marketplaceActor(authenticatedMarketplace);
|
||||||
logger.info(
|
logger.info(
|
||||||
`Starting item sync for marketplace "${marketplace.name}" (${marketplace.provider})`
|
`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
|
const existingListings = await listingModel
|
||||||
.find({
|
.find({
|
||||||
marketplace: authenticatedMarketplace._id,
|
marketplace: authenticatedMarketplace._id,
|
||||||
@ -428,6 +545,9 @@ export function syncItems(marketplace, user) {
|
|||||||
if (result?.url) {
|
if (result?.url) {
|
||||||
listingUpdateData.url = result.url;
|
listingUpdateData.url = result.url;
|
||||||
}
|
}
|
||||||
|
if (result?.externalReference) {
|
||||||
|
listingUpdateData.externalReference = result.externalReference;
|
||||||
|
}
|
||||||
|
|
||||||
await editObject({
|
await editObject({
|
||||||
model: listingModel,
|
model: listingModel,
|
||||||
@ -443,6 +563,9 @@ export function syncItems(marketplace, user) {
|
|||||||
updateData: {
|
updateData: {
|
||||||
lastSyncedAt: new Date(),
|
lastSyncedAt: new Date(),
|
||||||
state: varient.state || { type: 'draft' },
|
state: varient.state || { type: 'draft' },
|
||||||
|
...(!varient.externalReference && marketplaceSku(varient)
|
||||||
|
? { externalReference: marketplaceSku(varient) }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
user,
|
user,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
@ -490,6 +613,7 @@ export function syncOrders(marketplace, user, { startTime, endTime } = {}) {
|
|||||||
try {
|
try {
|
||||||
const provider = getProvider(marketplace);
|
const provider = getProvider(marketplace);
|
||||||
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
|
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
|
||||||
|
const actor = marketplaceActor(authenticatedMarketplace);
|
||||||
logger.info(
|
logger.info(
|
||||||
`Starting order sync for marketplace "${marketplace.name}" (${marketplace.provider})`
|
`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) {
|
for (const externalOrder of externalOrders) {
|
||||||
try {
|
try {
|
||||||
const mapped = provider.mapOrderToSalesOrder(externalOrder);
|
const result = await upsertExternalOrder(
|
||||||
const clientData = provider.mapBuyerToClient(externalOrder);
|
authenticatedMarketplace,
|
||||||
|
provider,
|
||||||
let client = await clientModel.findOne({
|
externalOrder,
|
||||||
name: clientData.name,
|
actor
|
||||||
marketplace: authenticatedMarketplace._id,
|
);
|
||||||
});
|
|
||||||
|
|
||||||
if (!client) {
|
|
||||||
client = await newObject({
|
|
||||||
model: clientModel,
|
|
||||||
newData: {
|
|
||||||
...clientData,
|
|
||||||
marketplace: authenticatedMarketplace._id,
|
|
||||||
active: true,
|
|
||||||
},
|
|
||||||
user,
|
|
||||||
});
|
|
||||||
logger.debug(`Created client "${clientData.name}" from marketplace order`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingOrder = await salesOrderModel.findOne({
|
|
||||||
marketplace: authenticatedMarketplace._id,
|
|
||||||
_reference: mapped.externalId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingOrder) {
|
|
||||||
await editObject({
|
|
||||||
model: salesOrderModel,
|
|
||||||
id: existingOrder._id,
|
|
||||||
updateData: {
|
|
||||||
state: mapped.state,
|
|
||||||
totalAmount: mapped.totalAmount,
|
|
||||||
totalAmountWithTax: mapped.totalAmountWithTax,
|
|
||||||
shippingAmount: mapped.shippingAmount,
|
|
||||||
shippingAmountWithTax: mapped.shippingAmountWithTax,
|
|
||||||
grandTotalAmount: mapped.grandTotalAmount,
|
|
||||||
totalTaxAmount: mapped.totalTaxAmount,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
},
|
|
||||||
user,
|
|
||||||
});
|
|
||||||
results.push({
|
results.push({
|
||||||
externalId: mapped.externalId,
|
externalReference: result.externalReference,
|
||||||
action: 'updated',
|
action: result.action,
|
||||||
id: existingOrder._id,
|
id: result.salesOrder?._id,
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
const salesOrder = await newObject({
|
|
||||||
model: salesOrderModel,
|
|
||||||
newData: {
|
|
||||||
_reference: mapped.externalId,
|
|
||||||
client: client._id,
|
|
||||||
marketplace: authenticatedMarketplace._id,
|
|
||||||
state: mapped.state,
|
|
||||||
totalAmount: mapped.totalAmount,
|
|
||||||
totalAmountWithTax: mapped.totalAmountWithTax,
|
|
||||||
shippingAmount: mapped.shippingAmount,
|
|
||||||
shippingAmountWithTax: mapped.shippingAmountWithTax,
|
|
||||||
grandTotalAmount: mapped.grandTotalAmount,
|
|
||||||
totalTaxAmount: mapped.totalTaxAmount,
|
|
||||||
},
|
|
||||||
user,
|
|
||||||
});
|
|
||||||
results.push({ externalId: mapped.externalId, action: 'created', id: salesOrder._id });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(`Failed to process order: ${err.message}`);
|
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();
|
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 };
|
||||||
|
|||||||
@ -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 hostId = req.headers['x-host-id'];
|
||||||
const authCode = req.headers['x-auth-code'];
|
const authCode = req.headers['x-auth-code'];
|
||||||
if (hostId && authCode) {
|
if (hostId && authCode) {
|
||||||
|
|||||||
@ -15,6 +15,7 @@ const listAllowedFilters = [
|
|||||||
'deliveryTime',
|
'deliveryTime',
|
||||||
'cost',
|
'cost',
|
||||||
'costWithTax',
|
'costWithTax',
|
||||||
|
'marketplaces.marketplace',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'updatedAt',
|
'updatedAt',
|
||||||
'_reference',
|
'_reference',
|
||||||
@ -41,6 +42,7 @@ const propertiesAllowedFilters = [
|
|||||||
'deliveryTime',
|
'deliveryTime',
|
||||||
'cost',
|
'cost',
|
||||||
'costWithTax',
|
'costWithTax',
|
||||||
|
'marketplaces.marketplace',
|
||||||
];
|
];
|
||||||
import {
|
import {
|
||||||
listCourierServicesRouteHandler,
|
listCourierServicesRouteHandler,
|
||||||
|
|||||||
@ -12,6 +12,7 @@ const listAllowedFilters = [
|
|||||||
'vendor._id',
|
'vendor._id',
|
||||||
'stockLocation',
|
'stockLocation',
|
||||||
'stockLocation._id',
|
'stockLocation._id',
|
||||||
|
'stockQuantity',
|
||||||
'marketplace',
|
'marketplace',
|
||||||
'marketplace._id',
|
'marketplace._id',
|
||||||
'courierServices',
|
'courierServices',
|
||||||
@ -25,6 +26,7 @@ const listAllowedSorters = [
|
|||||||
'title',
|
'title',
|
||||||
'vendor',
|
'vendor',
|
||||||
'state',
|
'state',
|
||||||
|
'stockQuantity',
|
||||||
'price',
|
'price',
|
||||||
'lastSyncedAt',
|
'lastSyncedAt',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
@ -35,6 +37,7 @@ const propertiesAllowedFilters = [
|
|||||||
'product',
|
'product',
|
||||||
'vendor',
|
'vendor',
|
||||||
'stockLocation',
|
'stockLocation',
|
||||||
|
'stockQuantity',
|
||||||
'marketplace',
|
'marketplace',
|
||||||
'courierServices',
|
'courierServices',
|
||||||
'state',
|
'state',
|
||||||
|
|||||||
@ -12,18 +12,20 @@ const listAllowedFilters = [
|
|||||||
'product._id',
|
'product._id',
|
||||||
'productSku',
|
'productSku',
|
||||||
'productSku._id',
|
'productSku._id',
|
||||||
|
'stockQuantity',
|
||||||
'state',
|
'state',
|
||||||
'state.type',
|
'state.type',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'updatedAt',
|
'updatedAt',
|
||||||
'_reference',
|
'_reference',
|
||||||
];
|
];
|
||||||
const listAllowedSorters = ['state', 'price', 'lastSyncedAt', 'createdAt', 'updatedAt', '_id'];
|
const listAllowedSorters = ['state', 'stockQuantity', 'price', 'lastSyncedAt', 'createdAt', 'updatedAt', '_id'];
|
||||||
const propertiesAllowedFilters = [
|
const propertiesAllowedFilters = [
|
||||||
'listing',
|
'listing',
|
||||||
'listing._id',
|
'listing._id',
|
||||||
'product',
|
'product',
|
||||||
'productSku',
|
'productSku',
|
||||||
|
'stockQuantity',
|
||||||
'state',
|
'state',
|
||||||
'state.type',
|
'state.type',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
|
|||||||
@ -26,7 +26,15 @@ const listAllowedSorters = [
|
|||||||
'updatedAt',
|
'updatedAt',
|
||||||
'_id',
|
'_id',
|
||||||
];
|
];
|
||||||
const propertiesAllowedFilters = ['name', 'provider', 'active', 'connected', 'state.type', 'createdAt', 'updatedAt'];
|
const propertiesAllowedFilters = [
|
||||||
|
'name',
|
||||||
|
'provider',
|
||||||
|
'active',
|
||||||
|
'connected',
|
||||||
|
'state.type',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
];
|
||||||
import {
|
import {
|
||||||
listMarketplacesRouteHandler,
|
listMarketplacesRouteHandler,
|
||||||
getMarketplaceRouteHandler,
|
getMarketplaceRouteHandler,
|
||||||
@ -39,22 +47,39 @@ import {
|
|||||||
getMarketplaceAuthUrlRouteHandler,
|
getMarketplaceAuthUrlRouteHandler,
|
||||||
exchangeMarketplaceAuthCodeRouteHandler,
|
exchangeMarketplaceAuthCodeRouteHandler,
|
||||||
refreshMarketplaceAuthRouteHandler,
|
refreshMarketplaceAuthRouteHandler,
|
||||||
|
syncMarketplaceRouteHandler,
|
||||||
syncMarketplaceItemsRouteHandler,
|
syncMarketplaceItemsRouteHandler,
|
||||||
syncMarketplaceOrdersRouteHandler,
|
syncMarketplaceOrdersRouteHandler,
|
||||||
marketplaceWebhookRouteHandler,
|
marketplaceWebhookRouteHandler,
|
||||||
|
marketplaceWebhookChallengeRouteHandler,
|
||||||
|
subscribeMarketplaceWebhooksRouteHandler,
|
||||||
|
debugEbayRouteHandler,
|
||||||
searchMarketplacesRouteHandler,
|
searchMarketplacesRouteHandler,
|
||||||
getMarketplacePropertyValuesRouteHandler,
|
getMarketplacePropertyValuesRouteHandler,
|
||||||
|
getMarketplaceNeighborsRouteHandler,
|
||||||
getMarketplaceNeighborsRouteHandler
|
|
||||||
} from '../../services/sales/marketplaces.js';
|
} from '../../services/sales/marketplaces.js';
|
||||||
|
|
||||||
router.get('/', isAuthenticated, async (req, res) => {
|
router.get('/', isAuthenticated, async (req, res) => {
|
||||||
const { page, limit, property, search, sortProperty, sortOrder } = req.query;
|
const { page, limit, property, search, sortProperty, sortOrder } = req.query;
|
||||||
const filter = await getFilter(req.query, listAllowedFilters);
|
const filter = await getFilter(req.query, listAllowedFilters);
|
||||||
listMarketplacesRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder);
|
listMarketplacesRouteHandler(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
property,
|
||||||
|
filter,
|
||||||
|
search,
|
||||||
|
getSort(sortProperty, listAllowedSorters),
|
||||||
|
sortOrder
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/properties', checkPermissions('marketplace', 'list'), isAuthenticated, async (req, res) => {
|
router.get(
|
||||||
|
'/properties',
|
||||||
|
checkPermissions('marketplace', 'list'),
|
||||||
|
isAuthenticated,
|
||||||
|
async (req, res) => {
|
||||||
let properties = convertPropertiesString(req.query.properties);
|
let properties = convertPropertiesString(req.query.properties);
|
||||||
const filter = await getFilter(req.query, propertiesAllowedFilters, false);
|
const filter = await getFilter(req.query, propertiesAllowedFilters, false);
|
||||||
var masterFilter = {};
|
var masterFilter = {};
|
||||||
@ -62,17 +87,27 @@ router.get('/properties', checkPermissions('marketplace', 'list'), isAuthenticat
|
|||||||
masterFilter = JSON.parse(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) => {
|
router.get(
|
||||||
|
'/values',
|
||||||
|
checkPermissions('marketplace', 'list'),
|
||||||
|
isAuthenticated,
|
||||||
|
async (req, res) => {
|
||||||
const { property } = req.query;
|
const { property } = req.query;
|
||||||
getMarketplacePropertyValuesRouteHandler(req, res, property);
|
getMarketplacePropertyValuesRouteHandler(req, res, property);
|
||||||
});
|
}
|
||||||
router.get('/search', checkPermissions('marketplace', 'list'), isAuthenticated, async (req, res) => {
|
);
|
||||||
|
router.get(
|
||||||
|
'/search',
|
||||||
|
checkPermissions('marketplace', 'list'),
|
||||||
|
isAuthenticated,
|
||||||
|
async (req, res) => {
|
||||||
const { search } = req.query;
|
const { search } = req.query;
|
||||||
searchMarketplacesRouteHandler(req, res, search);
|
searchMarketplacesRouteHandler(req, res, search);
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
router.post('/', isAuthenticated, checkPermissions('marketplace', 'new'), async (req, res) => {
|
router.post('/', isAuthenticated, checkPermissions('marketplace', 'new'), async (req, res) => {
|
||||||
newMarketplaceRouteHandler(req, res);
|
newMarketplaceRouteHandler(req, res);
|
||||||
@ -90,7 +125,10 @@ router.get('/:id/auth/url', isAuthenticated, async (req, res) => {
|
|||||||
getMarketplaceAuthUrlRouteHandler(req, res);
|
getMarketplaceAuthUrlRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/:id/auth/exchange', isAuthenticated, async (req, res, next) => {
|
router.post(
|
||||||
|
'/:id/auth/exchange',
|
||||||
|
isAuthenticated,
|
||||||
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const allowed =
|
const allowed =
|
||||||
(await hasPermission(req.user, 'marketplace', 'connect')) ||
|
(await hasPermission(req.user, 'marketplace', 'connect')) ||
|
||||||
@ -102,31 +140,92 @@ router.post('/:id/auth/exchange', isAuthenticated, async (req, res, next) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
return next(err);
|
return next(err);
|
||||||
}
|
}
|
||||||
}, async (req, res) => {
|
},
|
||||||
|
async (req, res) => {
|
||||||
exchangeMarketplaceAuthCodeRouteHandler(req, res);
|
exchangeMarketplaceAuthCodeRouteHandler(req, res);
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
router.post('/:id/auth/refresh', isAuthenticated, checkPermissions('marketplace', 'refreshToken'), async (req, res) => {
|
router.post(
|
||||||
|
'/:id/auth/refresh',
|
||||||
|
isAuthenticated,
|
||||||
|
checkPermissions('marketplace', 'refreshToken'),
|
||||||
|
async (req, res) => {
|
||||||
refreshMarketplaceAuthRouteHandler(req, res);
|
refreshMarketplaceAuthRouteHandler(req, res);
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
router.post('/:id/sync/items', isAuthenticated, checkPermissions('marketplace', 'syncListings'), async (req, res) => {
|
router.post(
|
||||||
|
'/:id/sync',
|
||||||
|
isAuthenticated,
|
||||||
|
checkPermissions('marketplace', 'sync'),
|
||||||
|
async (req, res) => {
|
||||||
|
syncMarketplaceRouteHandler(req, res);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:id/sync/items',
|
||||||
|
isAuthenticated,
|
||||||
|
checkPermissions('marketplace', 'sync'),
|
||||||
|
async (req, res) => {
|
||||||
syncMarketplaceItemsRouteHandler(req, res);
|
syncMarketplaceItemsRouteHandler(req, res);
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
router.post('/:id/sync/orders', isAuthenticated, checkPermissions('marketplace', 'syncOrders'), async (req, res) => {
|
router.post(
|
||||||
|
'/:id/sync/orders',
|
||||||
|
isAuthenticated,
|
||||||
|
checkPermissions('marketplace', 'sync'),
|
||||||
|
async (req, res) => {
|
||||||
syncMarketplaceOrdersRouteHandler(req, res);
|
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
|
// Webhook endpoint — no auth, provider verifies via signature
|
||||||
router.post('/:id/hook', async (req, res) => {
|
router.post('/:id/hook', async (req, res) => {
|
||||||
marketplaceWebhookRouteHandler(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) => {
|
router.get('/neighbors', isAuthenticated, async (req, res) => {
|
||||||
const { property, search, sortProperty, sortOrder, id } = req.query;
|
const { property, search, sortProperty, sortOrder, id } = req.query;
|
||||||
const filter = await getFilter(req.query, listAllowedFilters);
|
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) => {
|
router.get('/:id', isAuthenticated, checkPermissions('marketplace', 'info'), async (req, res) => {
|
||||||
|
|||||||
@ -20,6 +20,9 @@ import {
|
|||||||
const logger = log4js.getLogger('Shipments');
|
const logger = log4js.getLogger('Shipments');
|
||||||
logger.level = config.server.logLevel;
|
logger.level = config.server.logLevel;
|
||||||
import { orderItemModel } from '../../database/schemas/inventory/orderitem.schema.js';
|
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 (
|
export const listShipmentsRouteHandler = async (
|
||||||
req,
|
req,
|
||||||
@ -316,6 +319,30 @@ export const shipShipmentRouteHandler = async (req, res) => {
|
|||||||
}
|
}
|
||||||
logger.debug(`Shipped shipment with ID: ${id}`);
|
logger.debug(`Shipped shipment with ID: ${id}`);
|
||||||
res.send(result);
|
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;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,8 @@ import {
|
|||||||
const logger = log4js.getLogger('CourierServices');
|
const logger = log4js.getLogger('CourierServices');
|
||||||
logger.level = config.server.logLevel;
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
|
const COURIER_SERVICE_POPULATE = ['courier', 'costTaxRate', 'marketplaces.marketplace'];
|
||||||
|
|
||||||
export const listCourierServicesRouteHandler = async (
|
export const listCourierServicesRouteHandler = async (
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
@ -38,7 +40,7 @@ export const listCourierServicesRouteHandler = async (
|
|||||||
search,
|
search,
|
||||||
sort,
|
sort,
|
||||||
order,
|
order,
|
||||||
populate: ['courier', 'costTaxRate'],
|
populate: COURIER_SERVICE_POPULATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
@ -63,7 +65,7 @@ export const listCourierServicesByPropertiesRouteHandler = async (
|
|||||||
properties,
|
properties,
|
||||||
filter,
|
filter,
|
||||||
masterFilter,
|
masterFilter,
|
||||||
populate: ['courier', 'costTaxRate'],
|
populate: COURIER_SERVICE_POPULATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
@ -88,6 +90,7 @@ export const searchCourierServicesRouteHandler = async (req, res, search) => {
|
|||||||
const result = await searchObjects({
|
const result = await searchObjects({
|
||||||
model: courierServiceModel,
|
model: courierServiceModel,
|
||||||
search,
|
search,
|
||||||
|
populate: COURIER_SERVICE_POPULATE,
|
||||||
});
|
});
|
||||||
res.send(result);
|
res.send(result);
|
||||||
};
|
};
|
||||||
@ -97,7 +100,7 @@ export const getCourierServiceRouteHandler = async (req, res) => {
|
|||||||
const result = await getObject({
|
const result = await getObject({
|
||||||
model: courierServiceModel,
|
model: courierServiceModel,
|
||||||
id,
|
id,
|
||||||
populate: ['courier', 'costTaxRate'],
|
populate: COURIER_SERVICE_POPULATE,
|
||||||
});
|
});
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
logger.warn(`Courier service not found with supplied id.`);
|
logger.warn(`Courier service not found with supplied id.`);
|
||||||
@ -128,14 +131,14 @@ export const editCourierServiceRouteHandler = async (req, res) => {
|
|||||||
additionalCostWithTax: req.body?.additionalCostWithTax,
|
additionalCostWithTax: req.body?.additionalCostWithTax,
|
||||||
shippingCurrency: req.body?.shippingCurrency,
|
shippingCurrency: req.body?.shippingCurrency,
|
||||||
international: req.body?.international,
|
international: req.body?.international,
|
||||||
|
marketplaces: req.body?.marketplaces,
|
||||||
};
|
};
|
||||||
// Create audit log before updating
|
|
||||||
const result = await editObject({
|
const result = await editObject({
|
||||||
model: courierServiceModel,
|
model: courierServiceModel,
|
||||||
id,
|
id,
|
||||||
updateData,
|
updateData,
|
||||||
user: req.user,
|
user: req.user,
|
||||||
populate: ['courier', 'costTaxRate'],
|
populate: COURIER_SERVICE_POPULATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
@ -166,6 +169,7 @@ export const newCourierServiceRouteHandler = async (req, res) => {
|
|||||||
additionalCostWithTax: req.body?.additionalCostWithTax,
|
additionalCostWithTax: req.body?.additionalCostWithTax,
|
||||||
shippingCurrency: req.body?.shippingCurrency,
|
shippingCurrency: req.body?.shippingCurrency,
|
||||||
international: req.body?.international,
|
international: req.body?.international,
|
||||||
|
marketplaces: req.body?.marketplaces,
|
||||||
};
|
};
|
||||||
const result = await newObject({
|
const result = await newObject({
|
||||||
model: courierServiceModel,
|
model: courierServiceModel,
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import {
|
|||||||
deleteListing as deleteExternalListing,
|
deleteListing as deleteExternalListing,
|
||||||
publishMarketplaceOfferForSku,
|
publishMarketplaceOfferForSku,
|
||||||
withdrawMarketplaceOfferForSku,
|
withdrawMarketplaceOfferForSku,
|
||||||
|
marketplaceSku,
|
||||||
} from '../../integrations/marketplaceworker.js';
|
} from '../../integrations/marketplaceworker.js';
|
||||||
|
|
||||||
const logger = log4js.getLogger('Listings');
|
const logger = log4js.getLogger('Listings');
|
||||||
@ -159,7 +160,9 @@ export const editListingRouteHandler = async (req, res) => {
|
|||||||
stockLocation: req.body.stockLocation,
|
stockLocation: req.body.stockLocation,
|
||||||
marketplace: req.body.marketplace,
|
marketplace: req.body.marketplace,
|
||||||
title: req.body.title,
|
title: req.body.title,
|
||||||
|
description: req.body.description,
|
||||||
url: req.body.url,
|
url: req.body.url,
|
||||||
|
condition: req.body.condition,
|
||||||
courierServices: req.body.courierServices,
|
courierServices: req.body.courierServices,
|
||||||
};
|
};
|
||||||
const result = await editObject({
|
const result = await editObject({
|
||||||
@ -193,8 +196,10 @@ export const newListingRouteHandler = async (req, res) => {
|
|||||||
stockLocation: req.body.stockLocation,
|
stockLocation: req.body.stockLocation,
|
||||||
marketplace: req.body.marketplace,
|
marketplace: req.body.marketplace,
|
||||||
title: req.body.title,
|
title: req.body.title,
|
||||||
|
description: req.body.description,
|
||||||
state: req.body.state || { type: 'draft' },
|
state: req.body.state || { type: 'draft' },
|
||||||
url: req.body.url,
|
url: req.body.url,
|
||||||
|
condition: req.body.condition,
|
||||||
courierServices: req.body.courierServices,
|
courierServices: req.body.courierServices,
|
||||||
};
|
};
|
||||||
const result = await newObject({
|
const result = await newObject({
|
||||||
@ -370,7 +375,7 @@ export const publishListingRouteHandler = async (req, res) => {
|
|||||||
const apiResult = await publishMarketplaceOfferForSku(
|
const apiResult = await publishMarketplaceOfferForSku(
|
||||||
marketplace,
|
marketplace,
|
||||||
req.user,
|
req.user,
|
||||||
v._reference,
|
marketplaceSku(v),
|
||||||
listing
|
listing
|
||||||
);
|
);
|
||||||
await editObject({
|
await editObject({
|
||||||
@ -464,7 +469,7 @@ export const unpublishListingRouteHandler = async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
for (const v of toUnpublish) {
|
for (const v of toUnpublish) {
|
||||||
await withdrawMarketplaceOfferForSku(marketplace, req.user, v._reference);
|
await withdrawMarketplaceOfferForSku(marketplace, req.user, marketplaceSku(v));
|
||||||
await editObject({
|
await editObject({
|
||||||
model: listingVarientModel,
|
model: listingVarientModel,
|
||||||
id: v._id,
|
id: v._id,
|
||||||
@ -531,4 +536,3 @@ export const getListingNeighborsRouteHandler = async (
|
|||||||
logger.debug(`Retrieved listing neighbors for ID: ${id}`);
|
logger.debug(`Retrieved listing neighbors for ID: ${id}`);
|
||||||
res.send(result);
|
res.send(result);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import {
|
|||||||
hasIntegration,
|
hasIntegration,
|
||||||
publishMarketplaceOfferForSku,
|
publishMarketplaceOfferForSku,
|
||||||
withdrawMarketplaceOfferForSku,
|
withdrawMarketplaceOfferForSku,
|
||||||
|
marketplaceSku,
|
||||||
} from '../../integrations/marketplaceworker.js';
|
} from '../../integrations/marketplaceworker.js';
|
||||||
|
|
||||||
const logger = log4js.getLogger('ListingVarients');
|
const logger = log4js.getLogger('ListingVarients');
|
||||||
@ -306,7 +307,7 @@ export const publishListingVarientRouteHandler = async (req, res) => {
|
|||||||
const apiResult = await publishMarketplaceOfferForSku(
|
const apiResult = await publishMarketplaceOfferForSku(
|
||||||
marketplace,
|
marketplace,
|
||||||
req.user,
|
req.user,
|
||||||
doc._reference,
|
marketplaceSku(doc),
|
||||||
doc.listing
|
doc.listing
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -415,7 +416,7 @@ export const unpublishListingVarientRouteHandler = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withdrawMarketplaceOfferForSku(marketplace, req.user, doc._reference);
|
await withdrawMarketplaceOfferForSku(marketplace, req.user, marketplaceSku(doc));
|
||||||
|
|
||||||
await editObject({
|
await editObject({
|
||||||
model: listingVarientModel,
|
model: listingVarientModel,
|
||||||
|
|||||||
@ -257,6 +257,9 @@ export const exchangeMarketplaceAuthCodeRouteHandler = async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Marketplace authorization completed for ${marketplace.name}`);
|
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 });
|
res.send({ success: true, ...result });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Error exchanging marketplace authorization code:', err.message);
|
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) => {
|
export const syncMarketplaceItemsRouteHandler = async (req, res) => {
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
|
|
||||||
@ -361,27 +397,88 @@ export const marketplaceWebhookRouteHandler = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const signature =
|
const signature =
|
||||||
req.headers['x-tts-signature'] ||
|
|
||||||
req.headers['x-ebay-signature'] ||
|
req.headers['x-ebay-signature'] ||
|
||||||
|
req.headers['x-tts-signature'] ||
|
||||||
req.headers['x-signature'] ||
|
req.headers['x-signature'] ||
|
||||||
'';
|
'';
|
||||||
const rawBody = JSON.stringify(req.body);
|
const rawBody = req.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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
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}`);
|
logger.info(`Webhook processed for marketplace ${marketplace.name}: ${result.action}`);
|
||||||
res.send({ success: true, ...result });
|
res.send({ success: true, ...result });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Error processing marketplace webhook:', err.message);
|
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 (
|
export const getMarketplaceNeighborsRouteHandler = async (
|
||||||
|
|||||||
36
src/utils.js
36
src/utils.js
@ -16,6 +16,7 @@ import { Worker } from 'worker_threads';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { diffJson } from 'diff';
|
import { diffJson } from 'diff';
|
||||||
|
import { resolveAuditOwner, actorDisplayName } from './auditOwner.js';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
@ -1064,7 +1065,7 @@ function getChangedValues(oldObj, newObj, old = false) {
|
|||||||
return changes;
|
return changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUDIT_EXCLUDED_MODELS = ['notification', 'userNotifier'];
|
const AUDIT_EXCLUDED_MODELS = ['notification', 'userNotifier', 'marketplaceEvent'];
|
||||||
const SENSITIVE_KEYS = ['secret'];
|
const SENSITIVE_KEYS = ['secret'];
|
||||||
|
|
||||||
const DISTRIBUTE_KEYS = {
|
const DISTRIBUTE_KEYS = {
|
||||||
@ -1101,8 +1102,7 @@ async function newAuditLog(newValue, parentId, parentType, user) {
|
|||||||
},
|
},
|
||||||
parent: parentId,
|
parent: parentId,
|
||||||
parentType,
|
parentType,
|
||||||
owner: user._id,
|
...resolveAuditOwner(user),
|
||||||
ownerType: 'user',
|
|
||||||
operation: 'new',
|
operation: 'new',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1130,8 +1130,7 @@ async function editAuditLog(oldValue, newValue, parentId, parentType, user) {
|
|||||||
},
|
},
|
||||||
parent: parentId,
|
parent: parentId,
|
||||||
parentType,
|
parentType,
|
||||||
owner: user._id,
|
...resolveAuditOwner(user),
|
||||||
ownerType: 'user',
|
|
||||||
operation: 'edit',
|
operation: 'edit',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1153,7 +1152,7 @@ async function editNotification(oldValue, newValue, parentId, parentType, user)
|
|||||||
await notfiyObjectUserNotifiers(
|
await notfiyObjectUserNotifiers(
|
||||||
parentId,
|
parentId,
|
||||||
parentType,
|
parentType,
|
||||||
`${objectName} edited by ${user?.firstName ?? 'unknown'} ${user?.lastName ?? ''}`,
|
`${objectName} edited by ${actorDisplayName(user)}`,
|
||||||
`The ${parentType} ${parentId} has been updated.`,
|
`The ${parentType} ${parentId} has been updated.`,
|
||||||
'editObject',
|
'editObject',
|
||||||
{
|
{
|
||||||
@ -1163,8 +1162,8 @@ async function editNotification(oldValue, newValue, parentId, parentType, user)
|
|||||||
object: { _id: String(parentId ?? '') },
|
object: { _id: String(parentId ?? '') },
|
||||||
user: {
|
user: {
|
||||||
_id: String(user?._id ?? ''),
|
_id: String(user?._id ?? ''),
|
||||||
firstName: user.firstName,
|
firstName: user?.firstName ?? user?.name ?? '',
|
||||||
lastName: user.lastName,
|
lastName: user?.lastName ?? '',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -1179,8 +1178,7 @@ async function deleteAuditLog(deleteValue, parentId, parentType, user) {
|
|||||||
},
|
},
|
||||||
parent: parentId,
|
parent: parentId,
|
||||||
parentType,
|
parentType,
|
||||||
owner: user._id,
|
...resolveAuditOwner(user),
|
||||||
ownerType: 'user',
|
|
||||||
operation: 'delete',
|
operation: 'delete',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1195,14 +1193,18 @@ async function deleteNotification(object, parentId, parentType, user) {
|
|||||||
await notfiyObjectUserNotifiers(
|
await notfiyObjectUserNotifiers(
|
||||||
parentId,
|
parentId,
|
||||||
parentType,
|
parentType,
|
||||||
`${objectName} deleted by ${user?.firstName ?? 'unknown'} ${user?.lastName ?? ''}`,
|
`${objectName} deleted by ${actorDisplayName(user)}`,
|
||||||
`The ${parentType} ${parentId} has been deleted.`,
|
`The ${parentType} ${parentId} has been deleted.`,
|
||||||
'deleteObject',
|
'deleteObject',
|
||||||
{
|
{
|
||||||
object: omitSensitive(object),
|
object: omitSensitive(object),
|
||||||
objectType: parentType,
|
objectType: parentType,
|
||||||
object: { _id: parentId },
|
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(
|
await notfiyObjectUserNotifiers(
|
||||||
note.parent,
|
note.parent,
|
||||||
note.parentType,
|
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.`,
|
`A new note has been created.`,
|
||||||
'newNote',
|
'newNote',
|
||||||
{
|
{
|
||||||
@ -1233,8 +1235,7 @@ async function subscribeAuditLog(parentId, parentType, user) {
|
|||||||
const auditLog = new auditLogModel({
|
const auditLog = new auditLogModel({
|
||||||
parent: parentId,
|
parent: parentId,
|
||||||
parentType,
|
parentType,
|
||||||
owner: user._id,
|
...resolveAuditOwner(user),
|
||||||
ownerType: 'user',
|
|
||||||
operation: 'subscribe',
|
operation: 'subscribe',
|
||||||
});
|
});
|
||||||
await auditLog.save();
|
await auditLog.save();
|
||||||
@ -1246,8 +1247,7 @@ async function unsubscribeAuditLog(parentId, parentType, user) {
|
|||||||
const auditLog = new auditLogModel({
|
const auditLog = new auditLogModel({
|
||||||
parent: parentId,
|
parent: parentId,
|
||||||
parentType,
|
parentType,
|
||||||
owner: user._id,
|
...resolveAuditOwner(user),
|
||||||
ownerType: 'user',
|
|
||||||
operation: 'unsubscribe',
|
operation: 'unsubscribe',
|
||||||
});
|
});
|
||||||
await auditLog.save();
|
await auditLog.save();
|
||||||
@ -1811,4 +1811,6 @@ export {
|
|||||||
jsonToCacheKey,
|
jsonToCacheKey,
|
||||||
subscribeAuditLog,
|
subscribeAuditLog,
|
||||||
unsubscribeAuditLog,
|
unsubscribeAuditLog,
|
||||||
|
resolveAuditOwner,
|
||||||
|
actorDisplayName,
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user