diff --git a/config.json b/config.json index 505a01b..7357926 100644 --- a/config.json +++ b/config.json @@ -2,7 +2,7 @@ "development": { "server": { "port": 8787, - "logLevel": "trace", + "logLevel": "debug", "corsOrigins": [ "https://web.farmcontrol.app", "https://dev.tombutcher.work", diff --git a/src/database/database.js b/src/database/database.js index 78312c3..79657bc 100644 --- a/src/database/database.js +++ b/src/database/database.js @@ -466,10 +466,7 @@ export const aggregateRollupsHistory = async ({ workingObjects.delete(objectId); } else if (log.operation === 'delete' && log.changes?.old) { if (!workingObjects.has(objectId)) { - workingObjects.set( - objectId, - expandObjectIds({ ...log.changes.old, _id: log.parent }) - ); + workingObjects.set(objectId, expandObjectIds({ ...log.changes.old, _id: log.parent })); } } else if (object && log.changes?.old) { mergeObjectUpdates(object, log.changes.old); @@ -956,12 +953,9 @@ export const listObjectsByProperties = async ({ // Build aggregation pipeline const pipeline = []; - logger.debug('Master filter:', masterFilter); - // Match before populate so reference fields (e.g. filament) are still ObjectIds if (Object.keys(masterFilter).length > 0) { const convertedFilter = convertObjectIdStringsInFilter(masterFilter); - logger.debug('Converted filter:', convertedFilter); pipeline.push({ $match: convertedFilter }); } @@ -1255,7 +1249,6 @@ export const editObject = async ({ model, id, updateData, user, populate, recalc object: updatedObject, populate, }); - console.log('updatedObject', updatedObject); await invalidateNeighborsCacheForObject({ model, id }); if (model.recalculate && recalculate == true) { diff --git a/src/database/permissions.js b/src/database/permissions.js index 47aac4a..737eec4 100644 --- a/src/database/permissions.js +++ b/src/database/permissions.js @@ -30,6 +30,22 @@ const loadPermissionsFromMongo = async (userId) => { return userDoc?.permissions || {}; }; +const countTruePermissions = (obj) => { + if (obj == null || typeof obj !== 'object') { + return 0; + } + + return Object.values(obj).reduce((count, value) => { + if (value === true) { + return count + 1; + } + if (value && typeof value === 'object') { + return count + countTruePermissions(value); + } + return count; + }, 0); +}; + export const hasPermission = async (user, objectType, action) => { const userId = getUserId(user); if (!userId || !objectType || !action) { @@ -46,7 +62,13 @@ export const hasPermission = async (user, objectType, action) => { await saveUserPermissionsToRedis(userId, permissions); } - return permissions?.[objectType]?.[action] === true; + const allowed = permissions?.[objectType]?.[action] === true; + + logger.debug( + `Retrieved permissions for user: ${userId}, Allowed: ${allowed ? 'Yes' : 'No'}, Allowed Count: ${countTruePermissions(permissions)}` + ); + + return allowed; }; export const checkPermissions = (objectType, action) => async (req, res, next) => { diff --git a/src/database/schemas/finance/paymentpolicy.schema.js b/src/database/schemas/finance/paymentpolicy.schema.js new file mode 100644 index 0000000..6520018 --- /dev/null +++ b/src/database/schemas/finance/paymentpolicy.schema.js @@ -0,0 +1,25 @@ +import mongoose from 'mongoose'; +import { generateId } from '../../utils.js'; +import { marketplaceSyncMappingSchema } from '../sales/marketplaceMapping.schema.js'; + +const paymentPolicySchema = new mongoose.Schema( + { + _reference: { type: String, default: () => generateId()() }, + name: { type: String, required: true }, + description: { type: String, required: false }, + immediatePay: { type: Boolean, required: false, default: true }, + paymentInstructions: { type: String, required: false }, + marketplaces: { type: [marketplaceSyncMappingSchema()], default: [] }, + }, + { timestamps: true } +); + +paymentPolicySchema.index({ name: 'text', description: 'text', paymentInstructions: 'text' }); + +paymentPolicySchema.virtual('id').get(function () { + return this._id; +}); + +paymentPolicySchema.set('toJSON', { virtuals: true }); + +export const paymentPolicyModel = mongoose.model('paymentPolicy', paymentPolicySchema); diff --git a/src/database/schemas/inventory/partstock.schema.js b/src/database/schemas/inventory/partstock.schema.js index 4d2aaa6..dab4fef 100644 --- a/src/database/schemas/inventory/partstock.schema.js +++ b/src/database/schemas/inventory/partstock.schema.js @@ -1,16 +1,16 @@ import mongoose from 'mongoose'; import { generateId } from '../../utils.js'; -const { Schema } = mongoose; import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js'; // Define the main partStock schema -const partStockSchema = new Schema( +const partStockSchema = new mongoose.Schema( { _reference: { type: String, default: () => generateId()() }, state: { - type: { type: String, required: true }, + type: { type: String, required: true, default: 'draft' }, progress: { type: Number, required: false }, }, + postedAt: { type: Date, required: false }, part: { type: mongoose.Schema.Types.ObjectId, ref: 'part', required: true }, partSku: { type: mongoose.Schema.Types.ObjectId, ref: 'partSku', required: true }, stockLocation: { @@ -25,13 +25,11 @@ const partStockSchema = new Schema( timestamp: { type: Date, default: Date.now }, }, ], - sourceType: { type: String, required: true }, - source: { type: Schema.Types.ObjectId, refPath: 'sourceType', required: true }, }, { timestamps: true } ); -partStockSchema.index({ sourceType: 'text', 'state.type': 'text' }); +partStockSchema.index({ 'state.type': 'text' }); partStockSchema.pre('validate', async function () { if (!this.part && this.partSku) { @@ -46,6 +44,11 @@ const rollupConfigs = [ filter: {}, rollups: [{ name: 'totalCurrentQuantity', property: 'currentQuantity', operation: 'sum' }], }, + { + name: 'draft', + filter: { 'state.type': 'draft' }, + rollups: [{ name: 'draft', property: 'state.type', operation: 'count' }], + }, { name: 'new', filter: { 'state.type': 'new' }, diff --git a/src/database/schemas/management/productcategory.schema.js b/src/database/schemas/management/productcategory.schema.js index 5d735f6..1a75e04 100644 --- a/src/database/schemas/management/productcategory.schema.js +++ b/src/database/schemas/management/productcategory.schema.js @@ -1,10 +1,20 @@ import mongoose from 'mongoose'; import { generateId } from '../../utils.js'; +const { Schema } = mongoose; + +const marketplaceMappingSchema = new mongoose.Schema( + { + marketplace: { type: Schema.Types.ObjectId, ref: 'marketplace', required: true }, + externalReference: { type: String, required: false }, + }, + { _id: true } +); const productCategorySchema = new mongoose.Schema( { _reference: { type: String, default: () => generateId()() }, name: { required: true, type: String }, + marketplaces: { type: [marketplaceMappingSchema], default: [] }, }, { timestamps: true } ); diff --git a/src/database/schemas/management/taxrate.schema.js b/src/database/schemas/management/taxrate.schema.js index 074eb63..ba00165 100644 --- a/src/database/schemas/management/taxrate.schema.js +++ b/src/database/schemas/management/taxrate.schema.js @@ -1,5 +1,6 @@ import mongoose from 'mongoose'; import { generateId } from '../../utils.js'; +import { marketplaceSyncMappingSchema } from '../sales/marketplaceMapping.schema.js'; const taxRateSchema = new mongoose.Schema( { @@ -10,8 +11,11 @@ const taxRateSchema = new mongoose.Schema( active: { required: true, type: Boolean, default: true }, description: { required: false, type: String }, country: { required: false, type: String }, + jurisdiction: { required: false, type: String }, + shippingAndHandlingTaxed: { required: false, type: Boolean, default: false }, effectiveFrom: { required: false, type: Date }, effectiveTo: { required: false, type: Date }, + marketplaces: { type: [marketplaceSyncMappingSchema()], default: [] }, }, { timestamps: true } ); diff --git a/src/database/schemas/misc/usersettings.schema.js b/src/database/schemas/misc/usersettings.schema.js index 8a5faa5..17e8df9 100644 --- a/src/database/schemas/misc/usersettings.schema.js +++ b/src/database/schemas/misc/usersettings.schema.js @@ -23,6 +23,7 @@ const userSettingsSchema = new mongoose.Schema({ columnVisibility: { type: Schema.Types.Mixed, default: () => ({}) }, collapseState: { type: Schema.Types.Mixed, default: () => ({}) }, }, + pageLayout: { type: Schema.Types.Mixed, default: () => ({}) }, createdAt: { type: Date, diff --git a/src/database/schemas/models.js b/src/database/schemas/models.js index a72289f..138a752 100644 --- a/src/database/schemas/models.js +++ b/src/database/schemas/models.js @@ -50,365 +50,79 @@ import { listingModel } from './sales/listing.schema.js'; import { listingVarientModel } from './sales/listingvarient.schema.js'; import { marketplaceEventModel } from './sales/marketplaceevent.schema.js'; import { paymentModel } from './finance/payment.schema.js'; +import { paymentPolicyModel } from './finance/paymentpolicy.schema.js'; +import { fulfillmentPolicyModel } from './sales/fulfillmentpolicy.schema.js'; +import { returnPolicyModel } from './sales/returnpolicy.schema.js'; -// Map prefixes to models and id fields +function modelEntry(getModel, type, label) { + return { + get model() { + return getModel(); + }, + idField: '_id', + type, + referenceField: '_reference', + label, + }; +} + +// Map prefixes to models and id fields. +// Model getters are lazy so circular ESM imports (schema -> utils -> models -> schema) +// do not read bindings that are still in the temporal dead zone. export const models = { - PRN: { - model: printerModel, - idField: '_id', - type: 'printer', - referenceField: '_reference', - label: 'Printer', - }, - PPF: { - model: printerProfileModel, - idField: '_id', - type: 'printerProfile', - referenceField: '_reference', - label: 'Printer Profile', - }, - FPF: { - model: filamentProfileModel, - idField: '_id', - type: 'filamentProfile', - referenceField: '_reference', - label: 'Filament Profile', - }, - FIL: { - model: filamentModel, - idField: '_id', - type: 'filament', - referenceField: '_reference', - label: 'Filament', - }, - FSU: { - model: filamentSkuModel, - idField: '_id', - type: 'filamentSku', - referenceField: '_reference', - label: 'Filament SKU', - }, - GCF: { - model: gcodeFileModel, - idField: '_id', - type: 'gcodeFile', - referenceField: '_reference', - label: 'G-Code File', - }, - JOB: { model: jobModel, idField: '_id', type: 'job', referenceField: '_reference', label: 'Job' }, - PRT: { - model: partModel, - idField: '_id', - type: 'part', - referenceField: '_reference', - label: 'Part', - }, - PSU: { - model: partSkuModel, - idField: '_id', - type: 'partSku', - referenceField: '_reference', - label: 'Part SKU', - }, - PRD: { - model: productModel, - idField: '_id', - type: 'product', - referenceField: '_reference', - label: 'Product', - }, - PCG: { - model: productCategoryModel, - idField: '_id', - type: 'productCategory', - referenceField: '_reference', - label: 'Product Category', - }, - SKU: { - model: productSkuModel, - idField: '_id', - type: 'productSku', - referenceField: '_reference', - label: 'Product SKU', - }, - VEN: { - model: vendorModel, - idField: '_id', - type: 'vendor', - referenceField: '_reference', - label: 'Vendor', - }, - MAT: { - model: materialModel, - idField: '_id', - type: 'material', - referenceField: '_reference', - label: 'Material', - }, - SJB: { - model: subJobModel, - idField: '_id', - type: 'subJob', - referenceField: '_reference', - label: 'Sub Job', - }, - FLS: { - model: filamentStockModel, - idField: '_id', - type: 'filamentStock', - referenceField: '_reference', - label: 'Filament Stock', - }, - SEV: { - model: stockEventModel, - idField: '_id', - type: 'stockEvent', - referenceField: '_reference', - label: 'Stock Event', - }, - SAU: { - model: stockAuditModel, - idField: '_id', - type: 'stockAudit', - referenceField: '_reference', - label: 'Stock Audit', - }, - PTS: { - model: partStockModel, - idField: '_id', - type: 'partStock', - referenceField: '_reference', - label: 'Part Stock', - }, - PDS: { - model: productStockModel, - idField: '_id', - type: 'productStock', - referenceField: '_reference', - label: 'Product Stock', - }, - SLN: { - model: stockLocationModel, - idField: '_id', - type: 'stockLocation', - referenceField: '_reference', - label: 'Stock Location', - }, - STT: { - model: stockTransferModel, - idField: '_id', - type: 'stockTransfer', - referenceField: '_reference', - label: 'Stock Transfer', - }, - ADL: { - model: auditLogModel, - idField: '_id', - type: 'auditLog', - referenceField: '_reference', - label: 'Audit Log', - }, - USR: { - model: userModel, - idField: '_id', - type: 'user', - referenceField: '_reference', - label: 'User', - }, - UGP: { - model: userGroupModel, - idField: '_id', - type: 'userGroup', - referenceField: '_reference', - label: 'User Group', - }, - PMS: { - model: permissionSettingModel, - idField: '_id', - type: 'permissionSetting', - referenceField: '_reference', - label: 'Permission Settings', - }, - APP: { - model: appPasswordModel, - idField: '_id', - type: 'appPassword', - referenceField: '_reference', - label: 'App Password', - }, - NTY: { - model: noteTypeModel, - idField: '_id', - type: 'noteType', - referenceField: '_reference', - label: 'Note Type', - }, - NTE: { - model: noteModel, - idField: '_id', - type: 'note', - referenceField: '_reference', - label: 'Note', - }, - NTF: { - model: notificationModel, - idField: '_id', - type: 'notification', - label: 'Notification', - referenceField: '_reference', - }, - ONF: { - model: userNotifierModel, - idField: '_id', - type: 'userNotifier', - label: 'User Notifier', - referenceField: '_reference', - }, - DSZ: { - model: documentSizeModel, - idField: '_id', - type: 'documentSize', - label: 'Document Size', - referenceField: '_reference', - }, - DTP: { - model: documentTemplateModel, - idField: '_id', - type: 'documentTemplate', - label: 'Document Template', - referenceField: '_reference', - }, - DPR: { - model: documentPrinterModel, - idField: '_id', - type: 'documentPrinter', - label: 'Document Printer', - referenceField: '_reference', - }, - DJB: { - model: documentJobModel, - idField: '_id', - type: 'documentJob', - label: 'Document Job', - referenceField: '_reference', - }, - HST: { - model: hostModel, - idField: '_id', - type: 'host', - referenceField: '_reference', - label: 'Host', - }, - FLE: { - model: fileModel, - idField: '_id', - type: 'file', - referenceField: '_reference', - label: 'File', - }, - POR: { - model: purchaseOrderModel, - idField: '_id', - type: 'purchaseOrder', - label: 'Purchase Order', - referenceField: '_reference', - }, - ODI: { - model: orderItemModel, - idField: '_id', - type: 'orderItem', - label: 'Order Item', - referenceField: '_reference', - }, - COS: { - model: courierServiceModel, - idField: '_id', - type: 'courierService', - label: 'Courier Service', - referenceField: '_reference', - }, - COR: { - model: courierModel, - idField: '_id', - type: 'courier', - label: 'Courier', - referenceField: '_reference', - }, - TXR: { - model: taxRateModel, - idField: '_id', - type: 'taxRate', - label: 'Tax Rate', - referenceField: '_reference', - }, - TXD: { - model: taxRecordModel, - idField: '_id', - type: 'taxRecord', - label: 'Tax Record', - referenceField: '_reference', - }, - SHP: { - model: shipmentModel, - idField: '_id', - type: 'shipment', - label: 'Shipment', - referenceField: '_reference', - }, - INV: { - model: invoiceModel, - idField: '_id', - type: 'invoice', - label: 'Invoice', - referenceField: '_reference', - }, - CLI: { - model: clientModel, - idField: '_id', - type: 'client', - label: 'Client', - referenceField: '_reference', - }, - SOR: { - model: salesOrderModel, - idField: '_id', - type: 'salesOrder', - label: 'Sales Order', - referenceField: '_reference', - }, - MKT: { - model: marketplaceModel, - idField: '_id', - type: 'marketplace', - label: 'Marketplace', - referenceField: '_reference', - }, - LST: { - model: listingModel, - idField: '_id', - type: 'listing', - label: 'Listing', - referenceField: '_reference', - }, - LVR: { - model: listingVarientModel, - idField: '_id', - type: 'listingVarient', - label: 'Listing Varient', - referenceField: '_reference', - }, - MKE: { - model: marketplaceEventModel, - idField: '_id', - type: 'marketplaceEvent', - label: 'Marketplace Event', - referenceField: '_reference', - }, - PAY: { - model: paymentModel, - idField: '_id', - type: 'payment', - label: 'Payment', - referenceField: '_reference', - }, + PRN: modelEntry(() => printerModel, 'printer', 'Printer'), + PPF: modelEntry(() => printerProfileModel, 'printerProfile', 'Printer Profile'), + FPF: modelEntry(() => filamentProfileModel, 'filamentProfile', 'Filament Profile'), + FIL: modelEntry(() => filamentModel, 'filament', 'Filament'), + FSU: modelEntry(() => filamentSkuModel, 'filamentSku', 'Filament SKU'), + GCF: modelEntry(() => gcodeFileModel, 'gcodeFile', 'G-Code File'), + JOB: modelEntry(() => jobModel, 'job', 'Job'), + PRT: modelEntry(() => partModel, 'part', 'Part'), + PSU: modelEntry(() => partSkuModel, 'partSku', 'Part SKU'), + PRD: modelEntry(() => productModel, 'product', 'Product'), + PCG: modelEntry(() => productCategoryModel, 'productCategory', 'Product Category'), + SKU: modelEntry(() => productSkuModel, 'productSku', 'Product SKU'), + VEN: modelEntry(() => vendorModel, 'vendor', 'Vendor'), + MAT: modelEntry(() => materialModel, 'material', 'Material'), + SJB: modelEntry(() => subJobModel, 'subJob', 'Sub Job'), + FLS: modelEntry(() => filamentStockModel, 'filamentStock', 'Filament Stock'), + SEV: modelEntry(() => stockEventModel, 'stockEvent', 'Stock Event'), + SAU: modelEntry(() => stockAuditModel, 'stockAudit', 'Stock Audit'), + PTS: modelEntry(() => partStockModel, 'partStock', 'Part Stock'), + PDS: modelEntry(() => productStockModel, 'productStock', 'Product Stock'), + SLN: modelEntry(() => stockLocationModel, 'stockLocation', 'Stock Location'), + STT: modelEntry(() => stockTransferModel, 'stockTransfer', 'Stock Transfer'), + ADL: modelEntry(() => auditLogModel, 'auditLog', 'Audit Log'), + USR: modelEntry(() => userModel, 'user', 'User'), + UGP: modelEntry(() => userGroupModel, 'userGroup', 'User Group'), + PMS: modelEntry(() => permissionSettingModel, 'permissionSetting', 'Permission Settings'), + APP: modelEntry(() => appPasswordModel, 'appPassword', 'App Password'), + NTY: modelEntry(() => noteTypeModel, 'noteType', 'Note Type'), + NTE: modelEntry(() => noteModel, 'note', 'Note'), + NTF: modelEntry(() => notificationModel, 'notification', 'Notification'), + ONF: modelEntry(() => userNotifierModel, 'userNotifier', 'User Notifier'), + DSZ: modelEntry(() => documentSizeModel, 'documentSize', 'Document Size'), + DTP: modelEntry(() => documentTemplateModel, 'documentTemplate', 'Document Template'), + DPR: modelEntry(() => documentPrinterModel, 'documentPrinter', 'Document Printer'), + DJB: modelEntry(() => documentJobModel, 'documentJob', 'Document Job'), + HST: modelEntry(() => hostModel, 'host', 'Host'), + FLE: modelEntry(() => fileModel, 'file', 'File'), + POR: modelEntry(() => purchaseOrderModel, 'purchaseOrder', 'Purchase Order'), + ODI: modelEntry(() => orderItemModel, 'orderItem', 'Order Item'), + COS: modelEntry(() => courierServiceModel, 'courierService', 'Courier Service'), + COR: modelEntry(() => courierModel, 'courier', 'Courier'), + TXR: modelEntry(() => taxRateModel, 'taxRate', 'Tax Rate'), + TXD: modelEntry(() => taxRecordModel, 'taxRecord', 'Tax Record'), + SHP: modelEntry(() => shipmentModel, 'shipment', 'Shipment'), + INV: modelEntry(() => invoiceModel, 'invoice', 'Invoice'), + CLI: modelEntry(() => clientModel, 'client', 'Client'), + SOR: modelEntry(() => salesOrderModel, 'salesOrder', 'Sales Order'), + MKT: modelEntry(() => marketplaceModel, 'marketplace', 'Marketplace'), + LST: modelEntry(() => listingModel, 'listing', 'Listing'), + LVR: modelEntry(() => listingVarientModel, 'listingVarient', 'Listing Varient'), + MKE: modelEntry(() => marketplaceEventModel, 'marketplaceEvent', 'Marketplace Event'), + PAY: modelEntry(() => paymentModel, 'payment', 'Payment'), + PPL: modelEntry(() => paymentPolicyModel, 'paymentPolicy', 'Payment Policy'), + FPL: modelEntry(() => fulfillmentPolicyModel, 'fulfillmentPolicy', 'Fulfillment Policy'), + RPL: modelEntry(() => returnPolicyModel, 'returnPolicy', 'Return Policy'), }; diff --git a/src/database/schemas/sales/fulfillmentpolicy.schema.js b/src/database/schemas/sales/fulfillmentpolicy.schema.js new file mode 100644 index 0000000..25e91d3 --- /dev/null +++ b/src/database/schemas/sales/fulfillmentpolicy.schema.js @@ -0,0 +1,31 @@ +import mongoose from 'mongoose'; +import { generateId } from '../../utils.js'; +import { marketplaceSyncMappingSchema } from './marketplaceMapping.schema.js'; + +const { Schema } = mongoose; + +const fulfillmentPolicySchema = new Schema( + { + _reference: { type: String, default: () => generateId()() }, + name: { type: String, required: true }, + description: { type: String, required: false }, + handlingTime: { type: Number, required: false, default: 1 }, + localPickup: { type: Boolean, required: false, default: false }, + globalShipping: { type: Boolean, required: false, default: false }, + freightShipping: { type: Boolean, required: false, default: false }, + pickupDropOff: { type: Boolean, required: false, default: false }, + courierServices: [{ type: Schema.Types.ObjectId, ref: 'courierService', required: false }], + marketplaces: { type: [marketplaceSyncMappingSchema()], default: [] }, + }, + { timestamps: true } +); + +fulfillmentPolicySchema.index({ name: 'text', description: 'text' }); + +fulfillmentPolicySchema.virtual('id').get(function () { + return this._id; +}); + +fulfillmentPolicySchema.set('toJSON', { virtuals: true }); + +export const fulfillmentPolicyModel = mongoose.model('fulfillmentPolicy', fulfillmentPolicySchema); diff --git a/src/database/schemas/sales/listing.schema.js b/src/database/schemas/sales/listing.schema.js index 6b3f08f..a110f27 100644 --- a/src/database/schemas/sales/listing.schema.js +++ b/src/database/schemas/sales/listing.schema.js @@ -1,6 +1,12 @@ import mongoose from 'mongoose'; import { generateId } from '../../utils.js'; -import { editObject, newObject, deleteObject, aggregateRollups, aggregateRollupsHistory } from '../../database.js'; +import { + editObject, + newObject, + deleteObject, + aggregateRollups, + aggregateRollupsHistory, +} from '../../database.js'; const { Schema } = mongoose; const listingSchema = new Schema( @@ -14,13 +20,24 @@ const listingSchema = new Schema( state: { type: { type: String, - enum: ['draft', 'active', 'inactive', 'deleted', 'suspended', 'syncing'], + enum: [ + 'draft', + 'active', + 'inactive', + 'deleted', + 'suspended', + 'syncing', + 'publishing', + 'unpublishing', + ], default: 'draft', }, + progress: { type: Number, required: false }, message: { type: String, required: false }, }, url: { type: String, required: false }, description: { type: String, required: false }, + listingImages: [{ type: Schema.Types.ObjectId, ref: 'file', required: false }], externalReference: { type: String, required: false }, price: { type: Number, required: false }, currency: { type: String, required: false }, @@ -51,6 +68,9 @@ const listingSchema = new Schema( required: false, }, courierServices: [{ type: Schema.Types.ObjectId, ref: 'courierService', required: true }], + fulfillmentPolicy: { type: Schema.Types.ObjectId, ref: 'fulfillmentPolicy', required: false }, + paymentPolicy: { type: Schema.Types.ObjectId, ref: 'paymentPolicy', required: false }, + returnPolicy: { type: Schema.Types.ObjectId, ref: 'returnPolicy', required: false }, }, { timestamps: true } ); @@ -192,6 +212,16 @@ const rollupConfigs = [ filter: { 'state.type': 'syncing' }, rollups: [{ name: 'syncing', property: 'state.type', operation: 'count' }], }, + { + name: 'publishing', + filter: { 'state.type': 'publishing' }, + rollups: [{ name: 'publishing', property: 'state.type', operation: 'count' }], + }, + { + name: 'unpublishing', + filter: { 'state.type': 'unpublishing' }, + rollups: [{ name: 'unpublishing', property: 'state.type', operation: 'count' }], + }, { name: 'suspended', filter: { 'state.type': 'suspended' }, diff --git a/src/database/schemas/sales/listingvarient.schema.js b/src/database/schemas/sales/listingvarient.schema.js index 164f04d..a8072fc 100644 --- a/src/database/schemas/sales/listingvarient.schema.js +++ b/src/database/schemas/sales/listingvarient.schema.js @@ -9,16 +9,34 @@ const toId = (value) => { return String(value); }; +const listingVarientAspectSchema = new Schema( + { + name: { type: String, required: true }, + value: { type: String, required: true }, + }, + { _id: true } +); + const listingVarientSchema = new Schema( { _reference: { type: String, default: () => generateId()() }, listing: { type: Schema.Types.ObjectId, ref: 'listing', required: true }, product: { type: Schema.Types.ObjectId, ref: 'product', required: false }, productSku: { type: Schema.Types.ObjectId, ref: 'productSku', required: false }, + aspects: { type: [listingVarientAspectSchema], default: [] }, state: { type: { type: String, - enum: ['draft', 'active', 'inactive', 'deleted', 'suspended', 'syncing'], + enum: [ + 'draft', + 'active', + 'inactive', + 'deleted', + 'suspended', + 'syncing', + 'publishing', + 'unpublishing', + ], default: 'draft', }, message: { type: String, required: false }, @@ -29,6 +47,7 @@ const listingVarientSchema = new Schema( priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false }, priceWithTax: { type: Number, required: false }, lastSyncedAt: { type: Date, required: false }, + listingImages: [{ type: Schema.Types.ObjectId, ref: 'file', required: false }], stockQuantity: { type: Number, required: false, default: 0 }, }, { timestamps: true } diff --git a/src/database/schemas/sales/marketplace.schema.js b/src/database/schemas/sales/marketplace.schema.js index 85c79a1..2e931c0 100644 --- a/src/database/schemas/sales/marketplace.schema.js +++ b/src/database/schemas/sales/marketplace.schema.js @@ -24,8 +24,24 @@ const marketplaceSchema = new mongoose.Schema( }, // Provider-specific API configuration (flexible for eBay, Etsy, TikTok Shop) config: { type: mongoose.Schema.Types.Mixed, default: {} }, + defaultFulfillmentPolicy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'fulfillmentPolicy', + required: false, + }, + defaultPaymentPolicy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'paymentPolicy', + required: false, + }, + defaultReturnPolicy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'returnPolicy', + required: false, + }, eBay: { availableShippingServices: { type: [String], default: [] }, + categoryReferences: { type: [mongoose.Schema.Types.Mixed], default: [] }, }, }, { timestamps: true } diff --git a/src/database/schemas/sales/marketplaceMapping.schema.js b/src/database/schemas/sales/marketplaceMapping.schema.js new file mode 100644 index 0000000..1326d4e --- /dev/null +++ b/src/database/schemas/sales/marketplaceMapping.schema.js @@ -0,0 +1,23 @@ +import mongoose from 'mongoose'; + +const { Schema } = mongoose; + +export const MARKETPLACE_MAPPING_STATES = ['pending', 'syncing', 'ready', 'failed']; + +export function marketplaceSyncMappingSchema() { + return new Schema( + { + marketplace: { type: Schema.Types.ObjectId, ref: 'marketplace', required: true }, + externalReference: { type: String, required: false }, + state: { + type: { + type: String, + enum: MARKETPLACE_MAPPING_STATES, + default: 'pending', + }, + message: { type: String, required: false }, + }, + }, + { _id: true } + ); +} diff --git a/src/database/schemas/sales/returnpolicy.schema.js b/src/database/schemas/sales/returnpolicy.schema.js new file mode 100644 index 0000000..b416d62 --- /dev/null +++ b/src/database/schemas/sales/returnpolicy.schema.js @@ -0,0 +1,46 @@ +import mongoose from 'mongoose'; +import { generateId } from '../../utils.js'; +import { marketplaceSyncMappingSchema } from './marketplaceMapping.schema.js'; + +const returnPolicySchema = new mongoose.Schema( + { + _reference: { type: String, default: () => generateId()() }, + name: { type: String, required: true }, + description: { type: String, required: false }, + returnsAccepted: { type: Boolean, required: true, default: true }, + returnPeriodDays: { type: Number, required: false, default: 30 }, + returnShippingCostPayer: { + type: String, + enum: ['buyer', 'seller'], + required: false, + default: 'buyer', + }, + refundMethod: { + type: String, + enum: ['moneyBack', 'merchandiseCredit'], + required: false, + default: 'moneyBack', + }, + restockingFeePercentage: { type: Number, required: false }, + returnInstructions: { type: String, required: false }, + internationalReturnsAccepted: { type: Boolean, required: false }, + internationalReturnPeriodDays: { type: Number, required: false }, + internationalReturnShippingCostPayer: { + type: String, + enum: ['buyer', 'seller'], + required: false, + }, + marketplaces: { type: [marketplaceSyncMappingSchema()], default: [] }, + }, + { timestamps: true } +); + +returnPolicySchema.index({ name: 'text', description: 'text', returnInstructions: 'text' }); + +returnPolicySchema.virtual('id').get(function () { + return this._id; +}); + +returnPolicySchema.set('toJSON', { virtuals: true }); + +export const returnPolicyModel = mongoose.model('returnPolicy', returnPolicySchema); diff --git a/src/index.js b/src/index.js index c5ae333..ac94754 100644 --- a/src/index.js +++ b/src/index.js @@ -56,6 +56,9 @@ import { marketplaceRoutes, listingRoutes, listingVarientRoutes, + fulfillmentPolicyRoutes, + returnPolicyRoutes, + paymentPolicyRoutes, userNotifierRoutes, notificationRoutes, odataRoutes, @@ -134,6 +137,13 @@ async function initializeApp() { logger.error('Failed to start persistent Puppeteer browser:', err); } + try { + const { startMarketplaceWorker } = await import('./integrations/marketplace.js'); + await startMarketplaceWorker(); + } catch (err) { + logger.error('Failed to start marketplace worker:', err); + } + // Start server app.listen(PORT, () => logger.info(`Server listening to port ${PORT}`)); logger.info(`Allowed origins: ${allowedOrigins.join(', ')}`); @@ -213,6 +223,9 @@ app.use('/salesorders', salesOrderRoutes); app.use('/marketplaces', marketplaceRoutes); app.use('/listings', listingRoutes); app.use('/listingvarients', listingVarientRoutes); +app.use('/fulfillmentpolicies', fulfillmentPolicyRoutes); +app.use('/returnpolicies', returnPolicyRoutes); +app.use('/paymentpolicies', paymentPolicyRoutes); app.use('/notes', noteRoutes); app.use('/usernotifiers', userNotifierRoutes); app.use('/notifications', notificationRoutes); diff --git a/src/integrations/__tests__/marketplace.test.js b/src/integrations/__tests__/marketplace.test.js new file mode 100644 index 0000000..7c8c9ae --- /dev/null +++ b/src/integrations/__tests__/marketplace.test.js @@ -0,0 +1,69 @@ +import { expect, jest } from '@jest/globals'; +import { hasIntegration, MARKETPLACE_BUSY_STATES, canAuthorize, syncMappedEbayPolicies } from '../marketplace.js'; + +describe('marketplace client', () => { + it('reports integrated providers without talking to the worker', () => { + expect(hasIntegration('ebay')).toBe(true); + expect(hasIntegration('tiktokShop')).toBe(true); + expect(hasIntegration('etsy')).toBe(false); + }); + + it('treats integrated providers as authorizable', () => { + expect(canAuthorize({ provider: 'ebay' })).toBe(true); + expect(canAuthorize({ provider: 'etsy' })).toBe(false); + }); + + it('exports the busy states used by publish/unpublish/sync', () => { + expect(MARKETPLACE_BUSY_STATES).toEqual(['syncing', 'publishing', 'unpublishing']); + }); +}); + +describe('syncMappedEbayPolicies', () => { + it('syncs each eBay mapping and skips other providers', async () => { + const syncFn = jest.fn().mockResolvedValue({}); + const ebayMarketplace = { _id: 'mp-ebay', provider: 'ebay', name: 'eBay GB' }; + const etsyMarketplace = { _id: 'mp-etsy', provider: 'etsy', name: 'Etsy' }; + const policy = { + _id: 'ppl-1', + marketplaces: [ + { marketplace: ebayMarketplace }, + { marketplace: etsyMarketplace }, + { marketplace: 'mp-id-only' }, + ], + }; + + await expect(syncMappedEbayPolicies(policy, { _id: 'user-1' }, syncFn)).resolves.toBe(1); + expect(syncFn).toHaveBeenCalledTimes(1); + expect(syncFn).toHaveBeenCalledWith(ebayMarketplace, { _id: 'user-1' }, policy); + }); + + it('syncs marketplace stubs that omit provider', async () => { + const syncFn = jest.fn().mockResolvedValue({}); + const policy = { + _id: 'ppl-1', + marketplaces: [{ marketplace: { _id: 'mp-ebay', name: 'eBay GB' } }], + }; + + await expect(syncMappedEbayPolicies(policy, { _id: 'user-1' }, syncFn)).resolves.toBe(1); + expect(syncFn).toHaveBeenCalledTimes(1); + }); + + it('continues when one marketplace sync fails', async () => { + const syncFn = jest + .fn() + .mockRejectedValueOnce(new Error('eBay timeout')) + .mockResolvedValueOnce({}); + const logger = { error: jest.fn() }; + const policy = { + _id: 'ppl-1', + marketplaces: [ + { marketplace: { _id: 'mp-1', provider: 'ebay', name: 'One' } }, + { marketplace: { _id: 'mp-2', provider: 'ebay', name: 'Two' } }, + ], + }; + + await expect(syncMappedEbayPolicies(policy, {}, syncFn, logger)).resolves.toBe(2); + expect(syncFn).toHaveBeenCalledTimes(2); + expect(logger.error).toHaveBeenCalled(); + }); +}); diff --git a/src/integrations/marketplace.js b/src/integrations/marketplace.js new file mode 100644 index 0000000..99596e3 --- /dev/null +++ b/src/integrations/marketplace.js @@ -0,0 +1,481 @@ +import { fork } from 'child_process'; +import { randomUUID } from 'crypto'; +import { fileURLToPath } from 'url'; +import log4js from 'log4js'; +import config from '../config.js'; +import { marketplaceSku, marketplaceActor } from './marketplaces/ids.js'; + +const logger = log4js.getLogger('Marketplace'); +logger.level = config.server.logLevel; + +const INTEGRATED_PROVIDERS = new Set(['ebay', 'tiktokShop']); +const WORKER_PATH = fileURLToPath(new URL('./marketplaceworker.js', import.meta.url)); +const DEFAULT_TIMEOUT_MS = 60000; + +export const MARKETPLACE_BUSY_STATES = ['syncing', 'publishing', 'unpublishing']; + +let workerProcess = null; +let workerReady = null; +let shuttingDown = false; +const pending = new Map(); + +export function hasIntegration(provider) { + return INTEGRATED_PROVIDERS.has(provider); +} + +function idOf(value) { + if (!value) return null; + if (typeof value === 'string') return value; + if (value._id) return String(value._id); + return String(value); +} + +function serializeListing(listing) { + if (!listing) return null; + try { + return JSON.parse(JSON.stringify(listing)); + } catch { + return { + _id: idOf(listing), + _reference: listing._reference, + externalReference: listing.externalReference, + marketplace: idOf(listing.marketplace), + }; + } +} + +function inTestProcess() { + return process.env.NODE_ENV === 'test' || process.env.MARKETPLACE_WORKER === '1'; +} + +function rejectPending(error) { + for (const [id, waiter] of pending) { + clearTimeout(waiter.timer); + waiter.reject(error); + pending.delete(id); + } +} + +function attachWorkerHandlers(child) { + child.on('message', (message) => { + if (!message) return; + if (message.type === 'ready') return; + const waiter = pending.get(message.id); + if (!waiter) return; + clearTimeout(waiter.timer); + pending.delete(message.id); + if (message.ok) waiter.resolve(message.result); + else waiter.reject(new Error(message.error || 'Marketplace worker job failed')); + }); + + child.on('error', (err) => { + logger.error('Marketplace worker error:', err); + rejectPending(err); + }); + + child.on('exit', (code) => { + logger.warn(`Marketplace worker exited with code ${code}`); + workerProcess = null; + workerReady = null; + rejectPending(new Error('Marketplace worker exited')); + if (!shuttingDown && process.env.NODE_ENV !== 'test') { + setTimeout(() => { + startMarketplaceWorker().catch((err) => { + logger.error('Failed to restart marketplace worker:', err.message); + }); + }, 1000); + } + }); +} + +function forkWorker() { + const child = fork(WORKER_PATH, [], { + env: { ...process.env, MARKETPLACE_WORKER: '1' }, + stdio: ['inherit', 'inherit', 'inherit', 'ipc'], + serialization: 'json', + }); + attachWorkerHandlers(child); + return child; +} + +function waitForReady(child, timeoutMs = 30000) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + child.off('message', onMessage); + reject(new Error('Marketplace worker failed to become ready')); + }, timeoutMs); + + function onMessage(message) { + if (message?.type !== 'ready') return; + clearTimeout(timer); + child.off('message', onMessage); + resolve(child); + } + + child.on('message', onMessage); + }); +} + +export async function startMarketplaceWorker() { + if (inTestProcess()) return null; + if (workerProcess && workerReady) return workerReady; + + shuttingDown = false; + workerProcess = forkWorker(); + workerReady = waitForReady(workerProcess).catch((err) => { + workerProcess = null; + workerReady = null; + throw err; + }); + return workerReady; +} + +export function stopMarketplaceWorker() { + shuttingDown = true; + rejectPending(new Error('Marketplace worker stopped')); + if (workerProcess) { + workerProcess.kill(); + workerProcess = null; + } + workerReady = null; +} + +async function getWorker() { + if (inTestProcess()) return null; + if (!workerProcess || !workerReady) { + await startMarketplaceWorker(); + } + await workerReady; + if (!workerProcess) { + throw new Error('Marketplace worker is not running'); + } + return workerProcess; +} + +async function runInProcess(action, payload) { + const { runJob } = await import('./marketplaceworker.js'); + return runJob(action, payload); +} + +async function sendJob( + action, + payload, + { wait = false, timeout = DEFAULT_TIMEOUT_MS, inProcess = false } = {} +) { + if (inTestProcess() || inProcess) { + const result = runInProcess(action, payload); + if (wait) return result; + result.catch((err) => { + logger.warn(`In-process marketplace job "${action}" failed: ${err.message}`); + }); + return; + } + + const worker = await getWorker(); + const id = randomUUID(); + if (!wait) { + worker.send({ id, action, payload, wait: false }); + return undefined; + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`Marketplace worker timed out waiting for ${action}`)); + }, timeout); + pending.set(id, { resolve, reject, timer }); + worker.send({ id, action, payload, wait: true }); + }); +} + +function marketplacePayload(marketplace, extra = {}) { + return { + marketplaceId: idOf(marketplace), + ...extra, + }; +} + +function userPayload(user) { + return { userId: idOf(user) }; +} + +export function canAuthorize(marketplace) { + return hasIntegration(marketplace?.provider); +} + +export function canVerifyWebhookSignature(marketplace) { + return hasIntegration(marketplace?.provider); +} + +export function createListing(marketplace, user, listingData) { + return sendJob('createListing', { + ...marketplacePayload(marketplace), + ...userPayload(user), + listingId: idOf(listingData), + }); +} + +export function updateListing(marketplace, user, listingData) { + return sendJob('updateListing', { + ...marketplacePayload(marketplace), + ...userPayload(user), + listingId: idOf(listingData), + }); +} + +export function deleteListing(marketplace, user, listingData) { + return sendJob('deleteListing', { + ...marketplacePayload(marketplace), + ...userPayload(user), + listing: serializeListing(listingData), + }); +} + +export function publishListing( + marketplace, + user, + listing, + { varientIds, restoreStateType, varientRestoreStateType } = {} +) { + return sendJob('publishListing', { + ...marketplacePayload(marketplace), + ...userPayload(user), + listingId: idOf(listing), + varientIds: (varientIds || []).map((id) => String(id)), + restoreStateType, + varientRestoreStateType, + }); +} + +export function unpublishListing( + marketplace, + user, + listing, + { varientIds, restoreStateType, varientRestoreStateType } = {} +) { + return sendJob('unpublishListing', { + ...marketplacePayload(marketplace), + ...userPayload(user), + listingId: idOf(listing), + varientIds: (varientIds || []).map((id) => String(id)), + restoreStateType, + varientRestoreStateType, + }); +} + +export function syncItems(marketplace, user) { + return sendJob('syncItems', { + ...marketplacePayload(marketplace), + ...userPayload(user), + }); +} + +export function syncOrders(marketplace, user, { startTime, endTime } = {}) { + return sendJob('syncOrders', { + ...marketplacePayload(marketplace), + ...userPayload(user), + startTime, + endTime, + }); +} + +export function syncMarketplaceMetadata(marketplace, user) { + return sendJob( + 'syncMarketplaceMetadata', + { + ...marketplacePayload(marketplace), + ...userPayload(user), + }, + { wait: true } + ); +} + +function syncAccountPolicyJob(action, marketplace, user) { + return sendJob( + action, + { + ...marketplacePayload(marketplace), + ...userPayload(user), + }, + { wait: false, inProcess: true } + ); +} + +export function syncFulfillmentPolicies(marketplace, user) { + return syncAccountPolicyJob('syncFulfillmentPolicies', marketplace, user); +} + +export function syncPaymentPolicies(marketplace, user) { + return syncAccountPolicyJob('syncPaymentPolicies', marketplace, user); +} + +export function syncReturnPolicies(marketplace, user) { + return syncAccountPolicyJob('syncReturnPolicies', marketplace, user); +} + +export function syncTaxRates(marketplace, user) { + return syncAccountPolicyJob('syncTaxRates', marketplace, user); +} + +function syncOutboundPolicyJob(action, marketplace, user, policy) { + return sendJob( + action, + { + ...marketplacePayload(marketplace), + ...userPayload(user), + policyId: idOf(policy), + }, + { wait: false } + ); +} + +export function syncPaymentPolicy(marketplace, user, policy) { + return syncOutboundPolicyJob('syncPaymentPolicy', marketplace, user, policy); +} + +export function syncReturnPolicy(marketplace, user, policy) { + return syncOutboundPolicyJob('syncReturnPolicy', marketplace, user, policy); +} + +export function syncFulfillmentPolicy(marketplace, user, policy) { + return syncOutboundPolicyJob('syncFulfillmentPolicy', marketplace, user, policy); +} + +export function syncTaxRate(marketplace, user, policy) { + return syncOutboundPolicyJob('syncTaxRate', marketplace, user, policy); +} + +export async function syncMappedEbayPolicies(policy, user, syncFn, logger) { + const mappings = Array.isArray(policy?.marketplaces) ? policy.marketplaces : []; + let attempted = 0; + for (const mapping of mappings) { + const marketplace = mapping?.marketplace; + if (!marketplace || typeof marketplace !== 'object') { + continue; + } + if (marketplace.provider && marketplace.provider !== 'ebay') { + continue; + } + attempted += 1; + try { + await syncFn(marketplace, user, policy); + } catch (err) { + logger?.error?.( + `Failed to sync ${policy._reference || policy._id} to marketplace ${ + marketplace.name || marketplace._id + }: ${err.message}` + ); + } + } + return attempted; +} + +export function ensureWebhookSubscriptions(marketplace, user, { wait = false } = {}) { + return sendJob( + 'ensureWebhookSubscriptions', + { + ...marketplacePayload(marketplace), + ...userPayload(user), + }, + { wait } + ); +} + +export function pushMarketplaceShipmentFulfillment(marketplace, user, shipment) { + return sendJob('pushShipmentFulfillment', { + ...marketplacePayload(marketplace), + ...userPayload(user), + shipmentId: idOf(shipment), + }); +} + +export function getAuthorizationUrl(marketplace, { state } = {}) { + return sendJob( + 'getAuthorizationUrl', + { + ...marketplacePayload(marketplace), + state, + }, + { wait: true } + ); +} + +export function exchangeAuthorizationCode(marketplace, user, { code, state } = {}) { + return sendJob( + 'exchangeAuthorizationCode', + { + ...marketplacePayload(marketplace), + ...userPayload(user), + code, + state, + }, + { wait: true } + ); +} + +export function refreshMarketplaceAuth(marketplace, user) { + return sendJob( + 'refreshMarketplaceAuth', + { + ...marketplacePayload(marketplace), + ...userPayload(user), + }, + { wait: true } + ); +} + +export function handleWebhook(marketplace, event, { rawBody, signature } = {}) { + return sendJob( + 'handleWebhook', + { + ...marketplacePayload(marketplace), + event, + rawBody, + signature, + }, + { wait: true } + ); +} + +export function buildWebhookChallengeResponse(marketplace, query) { + return sendJob( + 'buildWebhookChallengeResponse', + { + ...marketplacePayload(marketplace), + query, + }, + { wait: true } + ); +} + +export function verifyWebhookSignature(marketplace, rawBody, signature) { + return sendJob( + 'verifyWebhookSignature', + { + ...marketplacePayload(marketplace), + rawBody, + signature, + }, + { wait: true } + ); +} + +export function debugMarketplaceGet(marketplace, user, path, params = {}) { + return sendJob( + 'debugMarketplaceGet', + { + ...marketplacePayload(marketplace), + ...userPayload(user), + path, + params, + }, + { wait: true } + ); +} + +process.on('exit', () => { + shuttingDown = true; + if (workerProcess) workerProcess.kill(); +}); + +export { marketplaceSku, marketplaceActor }; diff --git a/src/integrations/marketplaceSync.js b/src/integrations/marketplaceSync.js index 1a47375..fd76dbe 100644 --- a/src/integrations/marketplaceSync.js +++ b/src/integrations/marketplaceSync.js @@ -303,6 +303,7 @@ async function upsertInboundListing(marketplace, mapped, actor) { lastSyncedAt: new Date(), product: product?._id || listing.product, productSku: productSku?._id, + ...(Array.isArray(mappedVarient.aspects) ? { aspects: mappedVarient.aspects } : {}), }; const existingVarient = await listingVarientModel.findOne({ listing: listing._id, @@ -335,7 +336,7 @@ export async function importExternalItems(marketplace, provider, actor) { const results = []; for (const item of externalItems || []) { try { - const mapped = provider.mapProductToListing(item); + const mapped = provider.mapProductToListing(item, marketplace); const result = await upsertInboundListing(marketplace, mapped, actor); results.push(result); } catch (err) { diff --git a/src/integrations/marketplaces/ebay/__tests__/accountPolicies.test.js b/src/integrations/marketplaces/ebay/__tests__/accountPolicies.test.js new file mode 100644 index 0000000..eacfdd7 --- /dev/null +++ b/src/integrations/marketplaces/ebay/__tests__/accountPolicies.test.js @@ -0,0 +1,283 @@ +import { describe, expect, it, jest } from '@jest/globals'; + +jest.unstable_mockModule('../shared.js', () => ({ + makeRequest: jest.fn(), + logger: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() }, + getEbayMarketplaceId: (marketplace) => marketplace?.config?.marketplaceId || 'EBAY_GB', +})); + +jest.unstable_mockModule('../../../../database/database.js', () => ({ + deleteObjectCache: jest.fn().mockResolvedValue(undefined), +})); + +jest.unstable_mockModule('../../../../utils.js', () => ({ + distributeUpdate: jest.fn().mockResolvedValue(undefined), +})); + +const { upsertLocalPolicyFromRemote, resolveListingPolicy, persistMarketplaceMapping, idOf, updateAccountPolicy } = + await import('../accountPolicies.js'); +const { makeRequest } = await import('../shared.js'); +const { buildPaymentPolicy } = await import('../paymentPolicies.js'); +const { buildReturnPolicy } = await import('../returnPolicies.js'); +const { + isEbayTaxTableSupported, + taxExternalReference, + buildSalesTaxEntry, +} = await import('../salesTax.js'); + +const marketplace = { _id: 'mp1', config: { marketplaceId: 'EBAY_GB' } }; + +function createModelMock({ existing = null } = {}) { + const leanQuery = (result) => ({ + lean: jest.fn().mockResolvedValue(result), + }); + return { + findOne: jest.fn().mockReturnValue(leanQuery(existing)), + findById: jest.fn().mockReturnValue(leanQuery(existing)), + updateOne: jest.fn().mockResolvedValue({}), + create: jest.fn().mockImplementation(async (doc) => ({ + ...doc, + _id: 'new-id', + toObject() { + return { ...doc, _id: 'new-id' }; + }, + })), + }; +} + +describe('eBay payment and return policy payloads', () => { + it('builds a payment policy with ALL_EXCLUDING_MOTORS_VEHICLES and no paymentMethods', () => { + const payload = buildPaymentPolicy( + { + name: 'Immediate Pay', + description: 'Managed payments', + immediatePay: true, + paymentInstructions: 'Pay now', + }, + marketplace + ); + + expect(payload).toEqual({ + name: 'Immediate Pay', + marketplaceId: 'EBAY_GB', + categoryTypes: [{ name: 'ALL_EXCLUDING_MOTORS_VEHICLES' }], + immediatePay: true, + description: 'Managed payments', + paymentInstructions: 'Pay now', + }); + expect(payload.paymentMethods).toBeUndefined(); + expect(payload.categoryTypes[0].default).toBeUndefined(); + }); + + it('maps return periods and payers for eBay', () => { + const payload = buildReturnPolicy( + { + name: '30 Day Returns', + returnsAccepted: true, + returnPeriodDays: 30, + returnShippingCostPayer: 'seller', + refundMethod: 'moneyBack', + internationalReturnsAccepted: true, + internationalReturnPeriodDays: 14, + internationalReturnShippingCostPayer: 'buyer', + }, + marketplace + ); + + expect(payload.categoryTypes).toEqual([{ name: 'ALL_EXCLUDING_MOTORS_VEHICLES' }]); + expect(payload.returnsAccepted).toBe(true); + expect(payload.returnPeriod).toEqual({ value: 30, unit: 'DAY' }); + expect(payload.returnShippingCostPayer).toBe('SELLER'); + expect(payload.refundMethod).toBe('MONEY_BACK'); + expect(payload.internationalOverride).toEqual({ + returnsAccepted: true, + returnPeriod: { value: 14, unit: 'DAY' }, + returnShippingCostPayer: 'BUYER', + }); + }); + + it('snaps unsupported return periods to 14, 30, or 60 days and omits restocking fees', () => { + const payload = buildReturnPolicy( + { + name: 'Test', + returnsAccepted: true, + returnPeriodDays: 20, + restockingFeePercentage: 0, + internationalReturnsAccepted: false, + }, + marketplace + ); + + expect(payload.returnPeriod).toEqual({ value: 30, unit: 'DAY' }); + expect(payload.restockingFeePercentage).toBeUndefined(); + expect(payload.internationalOverride).toEqual({ returnsAccepted: false }); + }); +}); + +describe('eBay tax table jurisdictions', () => { + it('allows US territories and Canada, and skips US states', () => { + expect(isEbayTaxTableSupported({ country: 'US', jurisdiction: 'GU' })).toBe(true); + expect(isEbayTaxTableSupported({ country: 'US', jurisdiction: 'VI' })).toBe(true); + expect(isEbayTaxTableSupported({ country: 'CA', jurisdiction: 'ON' })).toBe(true); + expect(isEbayTaxTableSupported({ country: 'US', jurisdiction: 'CA' })).toBe(false); + expect(isEbayTaxTableSupported({ country: 'US', jurisdiction: 'NY' })).toBe(false); + expect(isEbayTaxTableSupported({ country: 'GB', jurisdiction: 'ENG' })).toBe(false); + }); + + it('builds COUNTRY:JURISDICTION external references', () => { + expect(taxExternalReference('US', 'GU')).toBe('US:GU'); + expect(buildSalesTaxEntry({ country: 'us', jurisdiction: 'gu', rate: 4, shippingAndHandlingTaxed: true })).toEqual({ + countryCode: 'US', + jurisdictionId: 'GU', + salesTaxPercentage: '4', + shippingAndHandlingTaxed: true, + }); + }); +}); + +describe('inbound policy upsert', () => { + it('creates a local policy with externalReference and ready state', async () => { + const model = createModelMock(); + const created = await upsertLocalPolicyFromRemote({ + model, + marketplace, + remoteId: 'pay-99', + name: 'Immediate Pay', + fields: { immediatePay: true }, + }); + + expect(model.create).toHaveBeenCalledWith({ + immediatePay: true, + name: 'Immediate Pay', + marketplaces: [ + { + marketplace: 'mp1', + externalReference: 'pay-99', + state: { type: 'ready' }, + }, + ], + }); + expect(created.marketplaces[0].externalReference).toBe('pay-99'); + expect(created.marketplaces[0].state.type).toBe('ready'); + }); + + it('updates an existing policy mapping to ready', async () => { + const existing = { + _id: 'local-1', + name: 'Immediate Pay', + marketplaces: [{ marketplace: 'mp1', externalReference: '', state: { type: 'pending' } }], + }; + const model = createModelMock({ existing }); + await upsertLocalPolicyFromRemote({ + model, + marketplace, + remoteId: 'pay-99', + name: 'Immediate Pay', + fields: { immediatePay: true }, + }); + + expect(model.updateOne).toHaveBeenCalledWith( + { _id: 'local-1' }, + { + $set: { + immediatePay: true, + name: 'Immediate Pay', + marketplaces: [ + expect.objectContaining({ + marketplace: 'mp1', + externalReference: 'pay-99', + state: { type: 'ready' }, + }), + ], + }, + } + ); + }); +}); + +describe('persistMarketplaceMapping', () => { + it('matches mappings stored as raw ObjectIds', async () => { + const marketplaceId = { + toString() { + return '507f1f77bcf86cd799439011'; + }, + }; + const existing = { + _id: 'ppl-1', + marketplaces: [ + { + _id: 'map-1', + marketplace: marketplaceId, + state: { type: 'pending' }, + }, + ], + }; + const model = { + modelName: 'paymentPolicy', + findById: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue(existing), + populate: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue(existing), + }), + }), + updateOne: jest.fn().mockResolvedValue({}), + }; + + expect(idOf(marketplaceId)).toBe('507f1f77bcf86cd799439011'); + + await persistMarketplaceMapping( + model, + existing, + { _id: '507f1f77bcf86cd799439011', provider: 'ebay' }, + { stateType: 'syncing' } + ); + + expect(model.updateOne).toHaveBeenCalledTimes(1); + const mappings = model.updateOne.mock.calls[0][1].$set.marketplaces; + expect(mappings).toHaveLength(1); + expect(mappings[0].state.type).toBe('syncing'); + expect(idOf(mappings[0].marketplace)).toBe('507f1f77bcf86cd799439011'); + }); +}); + +describe('updateAccountPolicy', () => { + it('treats an unchanged eBay policy as already synced', async () => { + makeRequest.mockRejectedValueOnce( + new Error( + 'eBay API error (400): Business profile information in the request is the same as in the system' + ) + ); + + await expect( + updateAccountPolicy(marketplace, '/sell/account/v1/payment_policy/6246724000', { + name: 'eBay Managed Payments', + categoryTypes: [{ name: 'ALL_EXCLUDING_MOTORS_VEHICLES', default: true }], + immediatePay: true, + }) + ).resolves.toBeUndefined(); + }); +}); + +describe('listing policy resolution', () => { + it('prefers the listing policy over marketplace defaults', () => { + expect( + resolveListingPolicy( + { paymentPolicy: { _id: 'listing-pay' }, marketplace: { defaultPaymentPolicy: { _id: 'default-pay' } } }, + { defaultPaymentPolicy: { _id: 'default-pay' } }, + 'paymentPolicy', + 'defaultPaymentPolicy' + ) + ).toEqual({ _id: 'listing-pay' }); + }); + + it('falls back to marketplace defaults', () => { + expect( + resolveListingPolicy( + { marketplace: { defaultReturnPolicy: { _id: 'default-ret' } } }, + { defaultReturnPolicy: { _id: 'default-ret' } }, + 'returnPolicy', + 'defaultReturnPolicy' + ) + ).toEqual({ _id: 'default-ret' }); + }); +}); diff --git a/src/integrations/marketplaces/ebay/__tests__/categories.test.js b/src/integrations/marketplaces/ebay/__tests__/categories.test.js new file mode 100644 index 0000000..0c808d7 --- /dev/null +++ b/src/integrations/marketplaces/ebay/__tests__/categories.test.js @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; + +jest.unstable_mockModule('../../../../database/schemas/management/product.schema.js', () => ({ + productModel: { findById: jest.fn() }, +})); +jest.unstable_mockModule('../../../../database/schemas/management/productcategory.schema.js', () => ({ + productCategoryModel: { findById: jest.fn() }, +})); +jest.unstable_mockModule('../shared.js', () => ({ + makeRequest: jest.fn(), + logger: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() }, +})); + +const { productModel } = await import('../../../../database/schemas/management/product.schema.js'); +const { productCategoryModel } = await import( + '../../../../database/schemas/management/productcategory.schema.js' +); +const { makeRequest } = await import('../shared.js'); +const { syncProductCategory } = await import('../categories.js'); + +const marketplace = { _id: 'mp1', config: { marketplaceId: 'EBAY_GB' } }; + +function listingWithCategory(productCategory) { + return { + _reference: 'LST-1', + product: { + _id: 'prod-1', + productCategory, + }, + }; +} + +describe('eBay product category mapping', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('uses the marketplace mapping externalReference as the eBay categoryId', async () => { + const category = await syncProductCategory( + marketplace, + listingWithCategory({ + _id: 'cat-1', + name: 'Seeds', + marketplaces: [ + { + marketplace: { _id: 'mp1' }, + externalReference: '12345', + }, + ], + }) + ); + + expect(category).toEqual({ categoryId: '12345' }); + expect(makeRequest).not.toHaveBeenCalled(); + expect(productModel.findById).not.toHaveBeenCalled(); + expect(productCategoryModel.findById).not.toHaveBeenCalled(); + }); + + it('throws when the product category has no mapping for the listing marketplace', async () => { + await expect( + syncProductCategory( + marketplace, + listingWithCategory({ + _id: 'cat-1', + name: 'Seeds', + marketplaces: [ + { + marketplace: { _id: 'mp-other' }, + externalReference: '99999', + }, + ], + }) + ) + ).rejects.toThrow( + 'Product category "Seeds" requires an eBay category ID before it can be used on an eBay listing.' + ); + expect(makeRequest).not.toHaveBeenCalled(); + }); + + it('throws when the matching mapping has an empty externalReference', async () => { + await expect( + syncProductCategory( + marketplace, + listingWithCategory({ + _id: 'cat-1', + name: 'Seeds', + marketplaces: [ + { + marketplace: { _id: 'mp1' }, + externalReference: '', + }, + ], + }) + ) + ).rejects.toThrow( + 'Product category "Seeds" requires an eBay category ID before it can be used on an eBay listing.' + ); + expect(makeRequest).not.toHaveBeenCalled(); + }); + + it('does not call the taxonomy suggestions API', async () => { + await syncProductCategory( + marketplace, + listingWithCategory({ + _id: 'cat-1', + name: 'Seeds', + marketplaces: [ + { + marketplace: 'mp1', + externalReference: '67890', + }, + ], + }) + ); + + expect(makeRequest).not.toHaveBeenCalled(); + }); + + it('returns listing.categoryId when already set', async () => { + const category = await syncProductCategory(marketplace, { + _reference: 'LST-1', + categoryId: '11111', + }); + + expect(category).toEqual({ categoryId: '11111' }); + expect(makeRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/src/integrations/marketplaces/ebay/__tests__/categoryTree.test.js b/src/integrations/marketplaces/ebay/__tests__/categoryTree.test.js new file mode 100644 index 0000000..5f1cf00 --- /dev/null +++ b/src/integrations/marketplaces/ebay/__tests__/categoryTree.test.js @@ -0,0 +1,71 @@ +import { describe, expect, it } from '@jest/globals'; +import { countCategoryReferences, mapCategoryTreeToReferences } from '../categoryTree.js'; + +const sampleTree = { + category: { categoryId: '0', categoryName: 'Root' }, + childCategoryTreeNodes: [ + { + category: { categoryId: '11450', categoryName: 'Clothing, Shoes & Accessories' }, + categoryTreeNodeLevel: 1, + leafCategoryTreeNode: false, + childCategoryTreeNodes: [ + { + category: { categoryId: '15724', categoryName: "Women's Clothing" }, + categoryTreeNodeLevel: 2, + leafCategoryTreeNode: false, + childCategoryTreeNodes: [ + { + category: { categoryId: '63861', categoryName: 'Dresses' }, + categoryTreeNodeLevel: 3, + leafCategoryTreeNode: true, + }, + ], + }, + ], + }, + { + category: { categoryId: '99', categoryName: 'Everything Else' }, + categoryTreeNodeLevel: 1, + leafCategoryTreeNode: true, + }, + ], +}; + +describe('eBay category tree mapping', () => { + it('maps the taxonomy tree into treeSelect references and skips the root', () => { + expect(mapCategoryTreeToReferences(sampleTree)).toEqual([ + { + title: 'Clothing, Shoes & Accessories (11450)', + value: '11450', + selectable: false, + children: [ + { + title: "Women's Clothing (15724)", + value: '15724', + selectable: false, + children: [ + { + title: 'Dresses (63861)', + value: '63861', + selectable: true, + }, + ], + }, + ], + }, + { + title: 'Everything Else (99)', + value: '99', + selectable: true, + }, + ]); + }); + + it('returns an empty list when the tree is missing', () => { + expect(mapCategoryTreeToReferences(null)).toEqual([]); + }); + + it('counts mapped category references', () => { + expect(countCategoryReferences(mapCategoryTreeToReferences(sampleTree))).toBe(4); + }); +}); diff --git a/src/integrations/marketplaces/ebay/__tests__/description.test.js b/src/integrations/marketplaces/ebay/__tests__/description.test.js new file mode 100644 index 0000000..ca56d02 --- /dev/null +++ b/src/integrations/marketplaces/ebay/__tests__/description.test.js @@ -0,0 +1,30 @@ +import { describe, expect, it } from '@jest/globals'; +import { toEbayHtmlDescription, toEbayPlainDescription } from '../description.js'; + +describe('toEbayPlainDescription', () => { + it('strips markdown headings and HTML entities from TipTap output', () => { + expect(toEbayPlainDescription('# Test description. :)\n\n ')).toBe('Test description. :)'); + }); + + it('leaves plain text unchanged', () => { + expect(toEbayPlainDescription('Custom copy')).toBe('Custom copy'); + }); +}); + +describe('toEbayHtmlDescription', () => { + it('converts markdown headings to HTML and drops empty nbsp paragraphs', () => { + expect(toEbayHtmlDescription('# Test description. :)\n\n ')).toBe( + '
Test description. :)
' + ); + }); + + it('leaves plain text unchanged', () => { + expect(toEbayHtmlDescription('Custom copy')).toBe('Custom copy'); + }); + + it('converts markdown lists and emphasis', () => { + expect(toEbayHtmlDescription('- **Red**\n- Blue')).toBe( + 'Test description. :)
'); + }); + + it('sets aspectsImageVariesBy when the listing has images', () => { + const body = buildInventoryItemGroupBody( + { ...listing, imageUrls: ['https://example.com/red.jpg'] }, + [red, blue] + ); + expect(body.imageUrls).toEqual(['https://example.com/red.jpg']); + expect(body.variesBy.aspectsImageVariesBy).toEqual(['Color']); + }); + + it('throws before makeRequest when grouped varients are missing aspects', () => { + expect(() => buildInventoryItemGroupBody(listing, [red, { _reference: 'SKU-BLUE' }])).toThrow( + /SKU-BLUE.*at least one aspect/ + ); + expect(makeRequest).not.toHaveBeenCalled(); + }); +}); + +describe('syncListingImages', () => { + it('uploads listing images and upserts inventory without publishing offers', async () => { + const listing = { _reference: 'LST-1', title: 'Widget' }; + const varients = [{ _reference: 'SKU-RED' }, { _reference: 'SKU-BLUE' }]; + const listingWithImages = { ...listing, imageUrls: ['https://i.ebayimg.com/1.jpg'] }; + const varientsWithImages = [ + { _reference: 'SKU-RED', imageUrls: ['https://i.ebayimg.com/1.jpg'] }, + { _reference: 'SKU-BLUE', imageUrls: ['https://i.ebayimg.com/2.jpg'] }, + ]; + attachImageUrlsToListingAndVarients.mockResolvedValueOnce({ + listing: listingWithImages, + varients: varientsWithImages, + }); + + const result = await syncListingImages({ name: 'eBay UK' }, listing, varients); + + expect(attachImageUrlsToListingAndVarients).toHaveBeenCalledWith( + { name: 'eBay UK' }, + listing, + varients + ); + expect(upsertInventoryItem).toHaveBeenCalledTimes(2); + expect(upsertInventoryItem).toHaveBeenCalledWith( + { name: 'eBay UK' }, + varientsWithImages[0], + listingWithImages + ); + expect(upsertInventoryItem).toHaveBeenCalledWith( + { name: 'eBay UK' }, + varientsWithImages[1], + listingWithImages + ); + expect(syncOfferAndMaybePublish).not.toHaveBeenCalled(); + expect(result).toEqual({ listing: listingWithImages, varients: varientsWithImages }); + }); }); diff --git a/src/integrations/marketplaces/ebay/__tests__/variationAspects.test.js b/src/integrations/marketplaces/ebay/__tests__/variationAspects.test.js new file mode 100644 index 0000000..9658b4c --- /dev/null +++ b/src/integrations/marketplaces/ebay/__tests__/variationAspects.test.js @@ -0,0 +1,89 @@ +import { describe, expect, it } from '@jest/globals'; +import { + buildGroupVariesBy, + fromEbayProductAspects, + toEbayProductAspects, +} from '../variationAspects.js'; + +describe('toEbayProductAspects', () => { + it('converts name/value pairs into eBay product.aspects', () => { + expect( + toEbayProductAspects([ + { name: 'Color', value: 'Red' }, + { name: 'Size', value: 'Large' }, + ]) + ).toEqual({ Color: ['Red'], Size: ['Large'] }); + }); + + it('returns undefined when there are no usable aspects', () => { + expect(toEbayProductAspects([])).toBeUndefined(); + expect(toEbayProductAspects([{ name: ' ', value: 'Red' }])).toBeUndefined(); + }); +}); + +describe('fromEbayProductAspects', () => { + it('converts eBay product.aspects into name/value pairs', () => { + expect(fromEbayProductAspects({ Color: ['Red'], Size: ['Large'] })).toEqual([ + { name: 'Color', value: 'Red' }, + { name: 'Size', value: 'Large' }, + ]); + }); + + it('returns an empty array for missing aspects', () => { + expect(fromEbayProductAspects(undefined)).toEqual([]); + expect(fromEbayProductAspects(null)).toEqual([]); + }); +}); + +describe('buildGroupVariesBy', () => { + it('puts differing values in variesBy.specifications and shared values in aspects', () => { + expect( + buildGroupVariesBy([ + { + _reference: 'SKU-RED-S', + aspects: [ + { name: 'Color', value: 'Red' }, + { name: 'Brand', value: 'Acme' }, + ], + }, + { + _reference: 'SKU-BLUE-S', + aspects: [ + { name: 'Color', value: 'Blue' }, + { name: 'Brand', value: 'Acme' }, + ], + }, + ]) + ).toEqual({ + variesBy: { specifications: [{ name: 'Color', values: ['Red', 'Blue'] }] }, + aspects: { Brand: ['Acme'] }, + }); + }); + + it('throws when a varient has no aspects', () => { + expect(() => + buildGroupVariesBy([ + { _reference: 'SKU-RED', aspects: [{ name: 'Color', value: 'Red' }] }, + { _reference: 'SKU-BLUE' }, + ]) + ).toThrow(/SKU-BLUE.*at least one aspect/); + }); + + it('throws when varients use different aspect names', () => { + expect(() => + buildGroupVariesBy([ + { _reference: 'SKU-RED', aspects: [{ name: 'Color', value: 'Red' }] }, + { _reference: 'SKU-LARGE', aspects: [{ name: 'Size', value: 'Large' }] }, + ]) + ).toThrow(/missing aspect/); + }); + + it('throws when varients do not differ by any aspect', () => { + expect(() => + buildGroupVariesBy([ + { _reference: 'SKU-1', aspects: [{ name: 'Color', value: 'Red' }] }, + { _reference: 'SKU-2', aspects: [{ name: 'Color', value: 'Red' }] }, + ]) + ).toThrow(/differ by at least one aspect/); + }); +}); diff --git a/src/integrations/marketplaces/ebay/accountPolicies.js b/src/integrations/marketplaces/ebay/accountPolicies.js new file mode 100644 index 0000000..7acd674 --- /dev/null +++ b/src/integrations/marketplaces/ebay/accountPolicies.js @@ -0,0 +1,279 @@ +import { makeRequest, logger, getEbayMarketplaceId } from './shared.js'; + +const SELLING_POLICY_PROGRAM = 'SELLING_POLICY_MANAGEMENT'; +export const POLICY_CATEGORY_TYPE = 'ALL_EXCLUDING_MOTORS_VEHICLES'; + +export function idOf(value) { + if (value == null) return ''; + if (typeof value === 'object') { + if (value._id != null) return String(value._id); + return String(value); + } + return String(value); +} + +export function isPopulatedDocument(value) { + return ( + value && + typeof value === 'object' && + !(value.constructor?.name === 'ObjectId') && + (value.name != null || value._reference != null || Array.isArray(value.marketplaces)) + ); +} + +export function getMarketplaceMapping(doc, marketplace) { + const marketplaceId = idOf(marketplace); + if (!marketplaceId) return (doc?.marketplaces || [])[0] || null; + return ( + (doc?.marketplaces || []).find((entry) => idOf(entry.marketplace) === marketplaceId) || null + ); +} + +export function mappingExternalReference(doc, marketplace) { + return getMarketplaceMapping(doc, marketplace)?.externalReference || ''; +} + +export async function persistMarketplaceMapping( + model, + doc, + marketplace, + { externalReference, stateType, message } = {} +) { + if (!model || !doc) return null; + const marketplaceId = idOf(marketplace); + const docId = idOf(doc); + if (!marketplaceId || !docId) return null; + + const current = (await model.findById(docId).lean()) || doc; + const mappings = [...(current?.marketplaces || [])]; + const index = mappings.findIndex((entry) => idOf(entry.marketplace) === marketplaceId); + const previous = index >= 0 ? mappings[index] : {}; + const next = { + ...(previous._id ? { _id: previous._id } : {}), + marketplace: previous.marketplace || marketplaceId, + externalReference: + externalReference !== undefined ? externalReference : previous.externalReference, + state: { + type: stateType || previous.state?.type || 'pending', + ...(message != null && message !== '' + ? { message } + : stateType === 'failed' + ? { message: previous.state?.message } + : {}), + }, + }; + + if (index >= 0) mappings[index] = next; + else mappings.push(next); + + await model.updateOne({ _id: docId }, { $set: { marketplaces: mappings } }); + + try { + const { deleteObjectCache } = await import('../../../database/database.js'); + const { distributeUpdate } = await import('../../../utils.js'); + await deleteObjectCache({ model, id: docId }); + let broadcastMappings = mappings; + const query = model.findById(docId); + if (typeof query?.populate === 'function') { + const populated = await query.populate('marketplaces.marketplace').lean(); + if (populated?.marketplaces) broadcastMappings = populated.marketplaces; + } + await distributeUpdate({ marketplaces: broadcastMappings }, docId, model.modelName); + } catch (err) { + logger.debug(`Could not broadcast marketplace mapping update for ${docId}: ${err.message}`); + } + + return next; +} + +async function fetchOptedInPrograms(marketplace) { + const result = await makeRequest({ + marketplace, + path: '/sell/account/v1/program/get_opted_in_programs', + }); + return result?.programs || []; +} + +function hasSellingPolicyManagement(programs) { + return programs.some((program) => program?.programType === SELLING_POLICY_PROGRAM); +} + +export async function ensureSellingPolicyManagement(marketplace) { + if (hasSellingPolicyManagement(await fetchOptedInPrograms(marketplace))) { + return; + } + + logger.info('Opting eBay seller account into Selling Policy Management'); + await makeRequest({ + marketplace, + method: 'POST', + path: '/sell/account/v1/program/opt_in', + body: { programType: SELLING_POLICY_PROGRAM }, + acceptableStatuses: [409], + }); + + if (!hasSellingPolicyManagement(await fetchOptedInPrograms(marketplace))) { + throw new Error( + 'eBay Selling Policy Management enrollment was requested but is not active yet. eBay can take up to 24 hours to process enrollment; retry publishing later.' + ); + } +} + +export function isDefaultAccountPolicy(existingPolicy, allPolicies = [], idField) { + const existingId = existingPolicy?.[idField]; + if (!existingId) return false; + + const existingIdString = String(existingId); + const sources = [existingPolicy, ...allPolicies]; + if ( + sources.some( + (policy) => + String(policy?.[idField]) === existingIdString && + (policy.categoryTypes || []).some((type) => type?.default === true) + ) + ) { + return true; + } + + const uniqueIds = [ + ...new Set( + allPolicies.filter((policy) => policy?.[idField]).map((policy) => String(policy[idField])) + ), + ]; + return uniqueIds.length === 1 && uniqueIds[0] === existingIdString; +} + +export function buildCategoryTypes(existingPolicy, allPolicies = [], idField) { + const categoryType = { name: POLICY_CATEGORY_TYPE }; + if (idField && isDefaultAccountPolicy(existingPolicy, allPolicies, idField)) { + categoryType.default = true; + } + return [categoryType]; +} + +function isDefaultStatusError(err) { + return /changing the default status/i.test(err?.message || ''); +} + +function isUnchangedPolicyError(err) { + return /same as in the system/i.test(err?.message || ''); +} + +export async function updateAccountPolicy(marketplace, path, policy) { + try { + await makeRequest({ marketplace, method: 'PUT', path, body: policy }); + } catch (err) { + if (isUnchangedPolicyError(err)) { + logger.debug(`eBay account policy ${path} is already up to date`); + return; + } + if (policy.categoryTypes?.[0]?.default === true || !isDefaultStatusError(err)) { + throw err; + } + logger.debug(`Retrying account policy ${path} with categoryTypes.default=true`); + try { + await makeRequest({ + marketplace, + method: 'PUT', + path, + body: { + ...policy, + categoryTypes: [{ name: POLICY_CATEGORY_TYPE, default: true }], + }, + }); + } catch (retryErr) { + if (isUnchangedPolicyError(retryErr)) { + logger.debug(`eBay account policy ${path} is already up to date`); + return; + } + throw retryErr; + } + } +} + +export async function loadPolicyDocument(model, value) { + if (!value) return null; + if (isPopulatedDocument(value)) return value; + const id = idOf(value); + if (!id) return null; + return model.findById(id).populate(['marketplaces.marketplace']).lean(); +} + +export function resolveListingPolicy(listing, marketplace, listingField, defaultField) { + return ( + listing?.[listingField] || + listing?.marketplace?.[defaultField] || + marketplace?.[defaultField] || + null + ); +} + +export async function findLocalPolicyByExternalReference(model, marketplace, externalReference) { + if (!externalReference) return null; + return model + .findOne({ + marketplaces: { + $elemMatch: { + marketplace: idOf(marketplace), + externalReference: String(externalReference), + }, + }, + }) + .lean(); +} + +export async function findLocalPolicyByName(model, marketplace, name) { + if (!name) return null; + return model + .findOne({ + name, + 'marketplaces.marketplace': idOf(marketplace), + }) + .lean(); +} + +export async function upsertLocalPolicyFromRemote({ + model, + marketplace, + remoteId, + name, + fields, + user, +}) { + let local = + (await findLocalPolicyByExternalReference(model, marketplace, remoteId)) || + (await findLocalPolicyByName(model, marketplace, name)); + + const mapping = { + marketplace: idOf(marketplace), + externalReference: String(remoteId), + state: { type: 'ready' }, + }; + + if (local) { + const mappings = [...(local.marketplaces || [])]; + const index = mappings.findIndex((entry) => idOf(entry.marketplace) === idOf(marketplace)); + if (index >= 0) mappings[index] = { ...mappings[index], ...mapping }; + else mappings.push(mapping); + await model.updateOne( + { _id: local._id }, + { + $set: { + ...fields, + name, + marketplaces: mappings, + }, + } + ); + return model.findById(local._id).lean(); + } + + const created = await model.create({ + ...fields, + name, + marketplaces: [mapping], + }); + return created.toObject ? created.toObject() : created; +} + +export { getEbayMarketplaceId }; diff --git a/src/integrations/marketplaces/ebay/accountPolicySync.js b/src/integrations/marketplaces/ebay/accountPolicySync.js new file mode 100644 index 0000000..55984de --- /dev/null +++ b/src/integrations/marketplaces/ebay/accountPolicySync.js @@ -0,0 +1,17 @@ +import { logger } from './shared.js'; +import { syncFulfillmentPoliciesFromEbay } from './fulfillmentPolicies.js'; +import { syncPaymentPoliciesFromEbay } from './paymentPolicies.js'; +import { syncReturnPoliciesFromEbay } from './returnPolicies.js'; +import { syncTaxRatesFromEbay, syncTaxRatesToEbay } from './salesTax.js'; + +export async function syncAccountPolicies(marketplace) { + const fulfillment = await syncFulfillmentPoliciesFromEbay(marketplace); + const payment = await syncPaymentPoliciesFromEbay(marketplace); + const returns = await syncReturnPoliciesFromEbay(marketplace); + const taxInbound = await syncTaxRatesFromEbay(marketplace); + const taxOutbound = await syncTaxRatesToEbay(marketplace); + logger.info( + `Synced account policies for marketplace "${marketplace.name}": fulfillment=${fulfillment.count}, payment=${payment.count}, return=${returns.count}, taxIn=${taxInbound.count}, taxOut=${taxOutbound.synced}` + ); + return { fulfillment, payment, returns, taxInbound, taxOutbound }; +} diff --git a/src/integrations/marketplaces/ebay/categories.js b/src/integrations/marketplaces/ebay/categories.js index 347ba27..5876a1c 100644 --- a/src/integrations/marketplaces/ebay/categories.js +++ b/src/integrations/marketplaces/ebay/categories.js @@ -1,15 +1,20 @@ import mongoose from 'mongoose'; import { productModel } from '../../../database/schemas/management/product.schema.js'; import { productCategoryModel } from '../../../database/schemas/management/productcategory.schema.js'; -import { makeRequest, logger } from './shared.js'; +import { logger } from './shared.js'; -const categoryTreeIds = new Map(); -const categoryMatches = new Map(); +const PRODUCT_CATEGORY_POPULATE = ['marketplaces.marketplace']; function isPopulated(value) { return value && typeof value === 'object' && !(value instanceof mongoose.Types.ObjectId); } +function idOf(value) { + if (value == null) return ''; + if (typeof value === 'object') return String(value._id || ''); + return String(value); +} + async function resolveProduct(listing, varients) { const productRef = listing?.product || varients?.find((varient) => varient?.product)?.product; if (!productRef) return null; @@ -19,7 +24,10 @@ async function resolveProduct(listing, varients) { } const productId = productRef._id || productRef; - return productModel.findById(productId).populate('productCategory').lean(); + return productModel + .findById(productId) + .populate({ path: 'productCategory', populate: { path: 'marketplaces.marketplace' } }) + .lean(); } async function resolveProductCategory(listing, varients) { @@ -27,49 +35,34 @@ async function resolveProductCategory(listing, varients) { const categoryRef = product?.productCategory; if (!categoryRef) return null; - if (isPopulated(categoryRef) && categoryRef.name) { + if (isPopulated(categoryRef) && Array.isArray(categoryRef.marketplaces)) { return categoryRef; } const categoryId = categoryRef._id || categoryRef; - return productCategoryModel.findById(categoryId).lean(); + return productCategoryModel.findById(categoryId).populate(PRODUCT_CATEGORY_POPULATE).lean(); } -async function fetchDefaultCategoryTreeId(marketplace) { - const marketplaceId = marketplace.config?.marketplaceId || 'EBAY_GB'; - const cacheKey = `${marketplace.config?.sandbox ? 'sandbox' : 'production'}:${marketplaceId}`; - if (categoryTreeIds.has(cacheKey)) { - return categoryTreeIds.get(cacheKey); +export function getProductCategoryMarketplaceMappings(productCategory) { + if (Array.isArray(productCategory?.marketplaces) && productCategory.marketplaces.length) { + return productCategory.marketplaces; } - - const result = await makeRequest({ - marketplace, - path: '/commerce/taxonomy/v1/get_default_category_tree_id', - params: { marketplace_id: marketplaceId }, - }); - - if (!result?.categoryTreeId) { - throw new Error(`eBay did not return a category tree for marketplace "${marketplaceId}"`); - } - - categoryTreeIds.set(cacheKey, result.categoryTreeId); - return result.categoryTreeId; + return []; } -function selectCategorySuggestion(suggestions, categoryName) { - const normalizedName = categoryName.trim().toLocaleLowerCase(); - return ( - suggestions.find( - (suggestion) => - suggestion?.category?.categoryName?.trim().toLocaleLowerCase() === normalizedName - ) || suggestions[0] - ); +export function getProductCategoryExternalReference(productCategory, marketplace) { + const marketplaceId = idOf(marketplace); + const mappings = getProductCategoryMarketplaceMappings(productCategory); + if (!marketplaceId) { + return mappings[0]?.externalReference || ''; + } + const mapping = mappings.find((entry) => idOf(entry?.marketplace) === marketplaceId); + return mapping?.externalReference || ''; } /** - * Resolves a FarmControl product category to the closest category in eBay's - * marketplace taxonomy. eBay owns its category tree, so categories are matched - * rather than created. + * Resolves a FarmControl product category to the eBay category ID stored on + * the product category's marketplace mapping. */ export async function syncProductCategory(marketplace, listing, varients = []) { if (listing?.categoryId) { @@ -77,45 +70,22 @@ export async function syncProductCategory(marketplace, listing, varients = []) { } const productCategory = await resolveProductCategory(listing, varients); - if (!productCategory?.name) { + if (!productCategory) { logger.debug( `No product category found for listing "${listing?._reference}"; skipping eBay category sync` ); return null; } - const categoryTreeId = await fetchDefaultCategoryTreeId(marketplace); - const marketplaceId = marketplace.config?.marketplaceId || 'EBAY_GB'; - const cacheKey = `${categoryTreeId}:${productCategory.name.trim().toLocaleLowerCase()}`; - if (categoryMatches.has(cacheKey)) { - return categoryMatches.get(cacheKey); - } - - const result = await makeRequest({ - marketplace, - path: `/commerce/taxonomy/v1/category_tree/${encodeURIComponent( - categoryTreeId - )}/get_category_suggestions`, - params: { q: productCategory.name }, - }); - const suggestion = selectCategorySuggestion( - result?.categorySuggestions || [], - productCategory.name - ); - - if (!suggestion?.category?.categoryId) { + const categoryId = getProductCategoryExternalReference(productCategory, marketplace); + if (!categoryId) { throw new Error( - `No eBay category found for product category "${productCategory.name}" on ${marketplaceId}` + `Product category "${productCategory.name}" requires an eBay category ID before it can be used on an eBay listing.` ); } - const category = { - categoryId: String(suggestion.category.categoryId), - categoryName: suggestion.category.categoryName, - }; - categoryMatches.set(cacheKey, category); logger.info( - `Matched product category "${productCategory.name}" to eBay category "${category.categoryName}" (${category.categoryId})` + `Using product category "${productCategory.name}" marketplace mapping ${categoryId} for listing "${listing?._reference}"` ); - return category; + return { categoryId: String(categoryId) }; } diff --git a/src/integrations/marketplaces/ebay/categoryTree.js b/src/integrations/marketplaces/ebay/categoryTree.js new file mode 100644 index 0000000..d057835 --- /dev/null +++ b/src/integrations/marketplaces/ebay/categoryTree.js @@ -0,0 +1,65 @@ +import { makeRequest, logger } from './shared.js'; + +function mapCategoryNode(node) { + const categoryId = node?.category?.categoryId; + if (categoryId == null || categoryId === '') return null; + + const children = (node.childCategoryTreeNodes || []).map(mapCategoryNode).filter(Boolean); + const isLeaf = node.leafCategoryTreeNode === true || children.length === 0; + const categoryName = node.category.categoryName || String(categoryId); + + return { + title: `${categoryName} (${categoryId})`, + value: String(categoryId), + selectable: isLeaf, + ...(children.length ? { children } : {}), + }; +} + +export function mapCategoryTreeToReferences(rootCategoryNode) { + const mapped = mapCategoryNode(rootCategoryNode); + if (!mapped) return []; + if (mapped.value === '0' && mapped.children?.length) { + return mapped.children; + } + return [mapped]; +} + +export function countCategoryReferences(nodes) { + let count = 0; + for (const node of nodes || []) { + count += 1; + if (node?.children?.length) { + count += countCategoryReferences(node.children); + } + } + return count; +} + +export async function fetchEbayCategoryReferences(marketplace) { + const marketplaceId = marketplace.config?.marketplaceId || 'EBAY_GB'; + const treeIdResult = await makeRequest({ + marketplace, + path: '/commerce/taxonomy/v1/get_default_category_tree_id', + params: { marketplace_id: marketplaceId }, + }); + + const categoryTreeId = treeIdResult?.categoryTreeId; + if (!categoryTreeId) { + throw new Error(`eBay did not return a category tree for marketplace "${marketplaceId}"`); + } + + const tree = await makeRequest({ + marketplace, + path: `/commerce/taxonomy/v1/category_tree/${encodeURIComponent(categoryTreeId)}`, + extraHeaders: { 'Accept-Encoding': 'gzip' }, + logResponse: false, + }); + + const categoryReferences = mapCategoryTreeToReferences(tree?.rootCategoryNode); + const categoryCount = countCategoryReferences(categoryReferences); + logger.info( + `Fetched ${categoryCount} eBay categor${categoryCount === 1 ? 'y' : 'ies'} for marketplace "${marketplace.name}" (tree ${categoryTreeId})` + ); + return categoryReferences; +} diff --git a/src/integrations/marketplaces/ebay/description.js b/src/integrations/marketplaces/ebay/description.js new file mode 100644 index 0000000..fa9ee0b --- /dev/null +++ b/src/integrations/marketplaces/ebay/description.js @@ -0,0 +1,142 @@ +const NAMED_ENTITIES = { + nbsp: ' ', + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + mdash: '—', + ndash: '–', + hellip: '…', + copy: '©', + reg: '®', + trade: '™', +}; + +const PRODUCT_DESCRIPTION_MAX_LENGTH = 4000; +const LISTING_DESCRIPTION_MAX_LENGTH = 500000; + +function decodeHtmlEntities(text) { + return String(text || '').replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]+);/gi, (match, entity) => { + if (entity[0] === '#') { + const code = + entity[1] === 'x' || entity[1] === 'X' + ? parseInt(entity.slice(2), 16) + : parseInt(entity.slice(1), 10); + if (!Number.isFinite(code) || code <= 0) return ''; + if (code === 160) return ' '; + try { + return String.fromCodePoint(code); + } catch { + return ''; + } + } + const mapped = NAMED_ENTITIES[entity.toLowerCase()]; + return mapped !== undefined ? mapped : ''; + }); +} + +function normalizeSource(text) { + return decodeHtmlEntities(text) + .replace(/\u00A0/g, ' ') + .replace(/\r\n/g, '\n') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +function looksLikeMarkdown(text) { + return /(?:^|\n)#{1,6}\s|(?:^|\n)[-*+]\s|(?:^|\n)\d+\.\s|\*\*|__|\[.+\]\(https?:/m.test(text); +} + +function escapeHtml(text) { + return text.replace(/&/g, '&').replace(//g, '>'); +} + +function inlineMarkdown(text) { + return escapeHtml(text) + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/__(.+?)__/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/\[([^\]]+)\]\((https?:[^)]+)\)/g, '$1'); +} + +function truncate(text, maxLength) { + if (!maxLength || text.length <= maxLength) return text; + return text.slice(0, maxLength).trimEnd(); +} + +function stripMarkdown(text) { + return text + .replace(/^#{1,6}\s+/gm, '') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/__(.+?)__/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/\[([^\]]+)\]\((https?:[^)]+)\)/g, '$1') + .replace(/^[-*+]\s+/gm, '') + .replace(/^\d+\.\s+/gm, '') + .replace(/[ \t]+/g, ' ') + .replace(/ *\n */g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +export function toEbayPlainDescription(text, { maxLength = PRODUCT_DESCRIPTION_MAX_LENGTH } = {}) { + return truncate(stripMarkdown(normalizeSource(text)), maxLength); +} + +export function toEbayHtmlDescription(text, { maxLength = LISTING_DESCRIPTION_MAX_LENGTH } = {}) { + const source = normalizeSource(text); + if (!source) return ''; + if (!looksLikeMarkdown(source)) { + return truncate(source, maxLength); + } + + const blocks = []; + let listType = null; + let listItems = []; + + const flushList = () => { + if (!listType) return; + blocks.push(`<${listType}>${listItems.map((item) => `${inlineMarkdown(heading[2])}
`); + continue; + } + + const unordered = trimmed.match(/^[-*+]\s+(.*)$/); + if (unordered) { + if (listType && listType !== 'ul') flushList(); + listType = 'ul'; + listItems.push(inlineMarkdown(unordered[1])); + continue; + } + + const ordered = trimmed.match(/^\d+\.\s+(.*)$/); + if (ordered) { + if (listType && listType !== 'ol') flushList(); + listType = 'ol'; + listItems.push(inlineMarkdown(ordered[1])); + continue; + } + + flushList(); + blocks.push(`${inlineMarkdown(trimmed)}
`); + } + flushList(); + + return truncate(blocks.join(''), maxLength); +} diff --git a/src/integrations/marketplaces/ebay/fulfillmentPolicies.js b/src/integrations/marketplaces/ebay/fulfillmentPolicies.js index b11150a..10d9d9c 100644 --- a/src/integrations/marketplaces/ebay/fulfillmentPolicies.js +++ b/src/integrations/marketplaces/ebay/fulfillmentPolicies.js @@ -1,42 +1,21 @@ import mongoose from 'mongoose'; import { courierServiceModel } from '../../../database/schemas/management/courierservice.schema.js'; +import { fulfillmentPolicyModel } from '../../../database/schemas/sales/fulfillmentpolicy.schema.js'; import { makeRequest, logger } from './shared.js'; +import { + POLICY_CATEGORY_TYPE, + buildCategoryTypes as buildSharedCategoryTypes, + ensureSellingPolicyManagement, + getEbayMarketplaceId, + idOf, + isDefaultAccountPolicy, + mappingExternalReference, + persistMarketplaceMapping, + updateAccountPolicy, + upsertLocalPolicyFromRemote, +} from './accountPolicies.js'; -const SELLING_POLICY_PROGRAM = 'SELLING_POLICY_MANAGEMENT'; -const FULFILLMENT_CATEGORY_TYPE = 'ALL_EXCLUDING_MOTORS_VEHICLES'; - -async function fetchOptedInPrograms(marketplace) { - const result = await makeRequest({ - marketplace, - path: '/sell/account/v1/program/get_opted_in_programs', - }); - return result?.programs || []; -} - -function hasSellingPolicyManagement(programs) { - return programs.some((program) => program?.programType === SELLING_POLICY_PROGRAM); -} - -async function ensureSellingPolicyManagement(marketplace) { - if (hasSellingPolicyManagement(await fetchOptedInPrograms(marketplace))) { - return; - } - - logger.info('Opting eBay seller account into Selling Policy Management'); - await makeRequest({ - marketplace, - method: 'POST', - path: '/sell/account/v1/program/opt_in', - body: { programType: SELLING_POLICY_PROGRAM }, - acceptableStatuses: [409], - }); - - if (!hasSellingPolicyManagement(await fetchOptedInPrograms(marketplace))) { - throw new Error( - 'eBay Selling Policy Management enrollment was requested but is not active yet. eBay can take up to 24 hours to process enrollment; retry publishing later.' - ); - } -} +const FULFILLMENT_CATEGORY_TYPE = POLICY_CATEGORY_TYPE; function isPopulatedCourierService(service) { return ( @@ -66,12 +45,6 @@ async function resolveCourierServices(listing) { return serviceIds.map((id) => servicesById.get(String(id))).filter(Boolean); } -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; @@ -160,43 +133,11 @@ function buildShippingOption(optionType, services, defaultCurrency, marketplace) } 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; + return isDefaultAccountPolicy(existingPolicy, allPolicies, 'fulfillmentPolicyId'); } 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]; + return buildSharedCategoryTypes(existingPolicy, allPolicies, 'fulfillmentPolicyId'); } export function buildFulfillmentPolicy( @@ -204,9 +145,10 @@ export function buildFulfillmentPolicy( marketplace, services, existingPolicy, - allPolicies = [] + allPolicies = [], + overrides = {} ) { - const marketplaceId = marketplace.config?.marketplaceId || 'EBAY_GB'; + const marketplaceId = getEbayMarketplaceId(marketplace); const defaultCurrency = marketplace.config?.currency || listing.currency || 'GBP'; const domesticServices = services.filter((service) => !service.international); const internationalServices = services.filter((service) => service.international); @@ -214,24 +156,92 @@ export function buildFulfillmentPolicy( buildShippingOption('DOMESTIC', domesticServices, defaultCurrency, marketplace), buildShippingOption('INTERNATIONAL', internationalServices, defaultCurrency, marketplace), ].filter(Boolean); - const deliveryTime = Math.max(0, ...services.map((service) => Number(service.deliveryTime ?? 1))); + const deliveryTime = + overrides.handlingTime != null + ? Number(overrides.handlingTime) + : Math.max(0, ...services.map((service) => Number(service.deliveryTime ?? 1))); return { - name: `FarmControl ${listing._reference}`.slice(0, 64), - description: `Managed by FarmControl for listing ${listing._reference}`.slice(0, 250), + name: String(overrides.name || `FarmControl ${listing._reference || ''}`).slice(0, 64), + description: String( + overrides.description || `Managed by FarmControl for listing ${listing._reference || ''}` + ).slice(0, 250), marketplaceId, categoryTypes: buildCategoryTypes(existingPolicy, allPolicies), - handlingTime: { value: deliveryTime, unit: 'DAY' }, - localPickup: false, - globalShipping: false, - freightShipping: false, - pickupDropOff: false, + handlingTime: { value: Number.isFinite(deliveryTime) ? deliveryTime : 1, unit: 'DAY' }, + localPickup: overrides.localPickup === true, + globalShipping: overrides.globalShipping === true, + freightShipping: overrides.freightShipping === true, + pickupDropOff: overrides.pickupDropOff === true, shippingOptions, }; } +function moneyKey(amount) { + if (amount == null || amount.value == null || amount.value === '') { + return '0:'; + } + return `${Number(amount.value)}:${amount.currency || ''}`; +} + +function shippingServiceFingerprint(service = {}) { + return [ + service.sortOrder || 1, + service.shippingServiceCode || '', + service.freeShipping ? '1' : '0', + service.buyerResponsibleForShipping ? '1' : '0', + moneyKey(service.shippingCost), + moneyKey(service.additionalShippingCost), + ].join('|'); +} + +function shippingOptionFingerprint(option = {}) { + const services = [...(option.shippingServices || [])] + .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)) + .map(shippingServiceFingerprint); + return `${option.optionType || ''}|${option.costType || ''}|${services.join(',')}`; +} + +export function fulfillmentPolicyFingerprint(policy) { + const options = [...(policy?.shippingOptions || [])] + .sort((a, b) => String(a?.optionType || '').localeCompare(String(b?.optionType || ''))) + .map(shippingOptionFingerprint); + const handling = policy?.handlingTime || {}; + return [ + policy?.marketplaceId || '', + Number(handling.value) || 0, + handling.unit || 'DAY', + policy?.localPickup ? 1 : 0, + policy?.globalShipping ? 1 : 0, + policy?.freightShipping ? 1 : 0, + policy?.pickupDropOff ? 1 : 0, + options.join(';'), + ].join('~'); +} + +export function findMatchingFulfillmentPolicy(desiredPolicy, allPolicies = []) { + const desiredFingerprint = fulfillmentPolicyFingerprint(desiredPolicy); + return ( + allPolicies.find( + (policy) => + policy?.fulfillmentPolicyId && fulfillmentPolicyFingerprint(policy) === desiredFingerprint + ) || null + ); +} + +export function duplicateFulfillmentPolicyId(err) { + const params = []; + for (const ebayError of err?.ebayErrors || []) { + if (Array.isArray(ebayError?.parameters)) params.push(...ebayError.parameters); + } + const match = params.find((param) => + /^(DuplicateProfileId|Shipping Profile Id)$/i.test(param?.name || '') + ); + return match?.value ? String(match.value) : ''; +} + function getMarketplaceId(marketplace) { - return marketplace.config?.marketplaceId || 'EBAY_GB'; + return getEbayMarketplaceId(marketplace); } async function fetchFulfillmentPolicyByName(marketplace, name) { @@ -256,35 +266,188 @@ async function fetchFulfillmentPolicies(marketplace) { return result?.fulfillmentPolicies || []; } -function isDefaultStatusError(err) { - return /changing the default status/i.test(err?.message || ''); -} +export { fetchFulfillmentPolicies }; async function updateFulfillmentPolicy(marketplace, fulfillmentPolicyId, policy) { - const path = `/sell/account/v1/fulfillment_policy/${encodeURIComponent(fulfillmentPolicyId)}`; + await updateAccountPolicy( + marketplace, + `/sell/account/v1/fulfillment_policy/${encodeURIComponent(fulfillmentPolicyId)}`, + policy + ); +} + +async function resolvePolicyCourierServices(policy) { + const serviceRefs = policy?.courierServices || []; + if (!serviceRefs.length) return []; + const populatedServices = serviceRefs.filter(isPopulatedCourierService); + if (populatedServices.length === serviceRefs.length) { + return populatedServices; + } + const serviceIds = serviceRefs.map((service) => service?._id || service).filter(Boolean); + const services = await courierServiceModel + .find({ _id: { $in: serviceIds } }) + .populate(['courier', 'marketplaces.marketplace']) + .lean(); + const servicesById = new Map(services.map((service) => [String(service._id), service])); + return serviceIds.map((id) => servicesById.get(String(id))).filter(Boolean); +} + +export async function ensureFulfillmentPolicySynced(marketplace, policy) { + if (!policy) { + throw new Error('A fulfillment policy is required before publishing to eBay.'); + } + + await ensureSellingPolicyManagement(marketplace); + await persistMarketplaceMapping(fulfillmentPolicyModel, policy, marketplace, { + stateType: 'syncing', + }); + try { - await makeRequest({ marketplace, method: 'PUT', path, body: policy }); - return; - } catch (err) { - if (policy.categoryTypes?.[0]?.default === true || !isDefaultStatusError(err)) { - throw err; + const services = validateCourierServices( + await resolvePolicyCourierServices(policy), + { _reference: policy._reference || policy.name }, + marketplace + ); + const existingId = mappingExternalReference(policy, marketplace); + const [existingByName, allPolicies] = await Promise.all([ + existingId ? null : fetchFulfillmentPolicyByName(marketplace, String(policy.name).slice(0, 64)), + fetchFulfillmentPolicies(marketplace), + ]); + const existingPolicy = + (existingId && + allPolicies.find((item) => String(item.fulfillmentPolicyId) === String(existingId))) || + existingByName || + null; + const payload = buildFulfillmentPolicy( + { _reference: policy._reference, currency: marketplace.config?.currency || 'GBP' }, + marketplace, + services, + existingPolicy, + allPolicies, + { + name: policy.name, + description: policy.description, + handlingTime: policy.handlingTime, + localPickup: policy.localPickup, + globalShipping: policy.globalShipping, + freightShipping: policy.freightShipping, + pickupDropOff: policy.pickupDropOff, + } + ); + let fulfillmentPolicyId = existingId || existingPolicy?.fulfillmentPolicyId; + + if (fulfillmentPolicyId) { + await updateFulfillmentPolicy(marketplace, fulfillmentPolicyId, payload); + } else { + const matchingPolicy = findMatchingFulfillmentPolicy(payload, allPolicies); + if (matchingPolicy?.fulfillmentPolicyId) { + fulfillmentPolicyId = matchingPolicy.fulfillmentPolicyId; + } else { + try { + const created = await makeRequest({ + marketplace, + method: 'POST', + path: '/sell/account/v1/fulfillment_policy', + body: payload, + }); + fulfillmentPolicyId = created?.fulfillmentPolicyId; + } catch (err) { + const duplicateId = duplicateFulfillmentPolicyId(err); + if (!duplicateId) throw err; + fulfillmentPolicyId = duplicateId; + } + } } - 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 }], - }, + if (!fulfillmentPolicyId) { + throw new Error(`eBay did not return an ID for fulfillment policy "${payload.name}"`); + } + + await persistMarketplaceMapping(fulfillmentPolicyModel, policy, marketplace, { + externalReference: String(fulfillmentPolicyId), + stateType: 'ready', }); + logger.info(`Synced eBay fulfillment policy "${payload.name}" (${fulfillmentPolicyId})`); + return { fulfillmentPolicyId: String(fulfillmentPolicyId) }; + } catch (err) { + await persistMarketplaceMapping(fulfillmentPolicyModel, policy, marketplace, { + stateType: 'failed', + message: err.message, + }); + throw err; } } +function collectRemoteShippingCodes(remote) { + const codes = []; + for (const option of remote?.shippingOptions || []) { + for (const service of option.shippingServices || []) { + if (service.shippingServiceCode) codes.push(String(service.shippingServiceCode)); + } + } + return codes; +} + +async function matchCourierServicesByShippingCodes(marketplace, codes) { + if (!codes.length) return []; + const services = await courierServiceModel + .find({ 'marketplaces.marketplace': idOf(marketplace) }) + .lean(); + const matched = []; + for (const code of codes) { + const service = services.find((item) => + (item.marketplaces || []).some( + (entry) => + idOf(entry.marketplace) === idOf(marketplace) && + String(entry.externalReference) === String(code) + ) + ); + if (service && !matched.some((item) => String(item._id) === String(service._id))) { + matched.push(service); + } + } + return matched.map((service) => service._id); +} + +function mapRemoteFulfillmentPolicy(remote, courierServiceIds) { + return { + description: remote.description, + handlingTime: Number(remote.handlingTime?.value) || 1, + localPickup: Boolean(remote.localPickup), + globalShipping: Boolean(remote.globalShipping), + freightShipping: Boolean(remote.freightShipping), + pickupDropOff: Boolean(remote.pickupDropOff), + courierServices: courierServiceIds, + }; +} + +export async function syncFulfillmentPoliciesFromEbay(marketplace) { + await ensureSellingPolicyManagement(marketplace); + const remotes = await fetchFulfillmentPolicies(marketplace); + const upserted = []; + for (const remote of remotes) { + const fulfillmentPolicyId = remote.fulfillmentPolicyId; + if (!fulfillmentPolicyId || !remote.name) continue; + const courierServiceIds = await matchCourierServicesByShippingCodes( + marketplace, + collectRemoteShippingCodes(remote) + ); + upserted.push( + await upsertLocalPolicyFromRemote({ + model: fulfillmentPolicyModel, + marketplace, + remoteId: fulfillmentPolicyId, + name: remote.name, + fields: mapRemoteFulfillmentPolicy(remote, courierServiceIds), + }) + ); + } + logger.info( + `Imported ${upserted.length} eBay fulfillment polic${upserted.length === 1 ? 'y' : 'ies'}` + ); + return { count: upserted.length }; +} + export async function syncFulfillmentPolicy(marketplace, listing) { await ensureSellingPolicyManagement(marketplace); @@ -318,16 +481,35 @@ export async function syncFulfillmentPolicy(marketplace, listing) { return { fulfillmentPolicyId: String(existingPolicy.fulfillmentPolicyId) }; } - const result = await makeRequest({ - marketplace, - method: 'POST', - path: '/sell/account/v1/fulfillment_policy', - body: policy, - }); - if (!result?.fulfillmentPolicyId) { - throw new Error(`eBay did not return an ID for fulfillment policy "${policy.name}"`); + const matchingPolicy = findMatchingFulfillmentPolicy(policy, allPolicies); + if (matchingPolicy?.fulfillmentPolicyId) { + logger.info( + `Reusing eBay fulfillment policy "${matchingPolicy.name}" (${matchingPolicy.fulfillmentPolicyId}) with matching shipping settings` + ); + return { fulfillmentPolicyId: String(matchingPolicy.fulfillmentPolicyId) }; } - logger.info(`Created eBay fulfillment policy "${policy.name}" (${result.fulfillmentPolicyId})`); - return { fulfillmentPolicyId: String(result.fulfillmentPolicyId) }; + try { + const result = await makeRequest({ + marketplace, + method: 'POST', + path: '/sell/account/v1/fulfillment_policy', + body: policy, + }); + if (!result?.fulfillmentPolicyId) { + throw new Error(`eBay did not return an ID for fulfillment policy "${policy.name}"`); + } + + logger.info(`Created eBay fulfillment policy "${policy.name}" (${result.fulfillmentPolicyId})`); + return { fulfillmentPolicyId: String(result.fulfillmentPolicyId) }; + } catch (err) { + const duplicateId = duplicateFulfillmentPolicyId(err); + if (duplicateId) { + logger.info( + `Reusing existing eBay fulfillment policy ${duplicateId} after duplicate-policy response` + ); + return { fulfillmentPolicyId: duplicateId }; + } + throw err; + } } diff --git a/src/integrations/marketplaces/ebay/images.js b/src/integrations/marketplaces/ebay/images.js new file mode 100644 index 0000000..0672fb5 --- /dev/null +++ b/src/integrations/marketplaces/ebay/images.js @@ -0,0 +1,151 @@ +import { downloadFile, BUCKETS } from '../../../database/ceph.js'; +import { getMediaApiBaseUrl, makeRequest, logger } from './shared.js'; + +function fileId(file) { + if (!file) return null; + if (typeof file === 'string') return file; + return file._id || file.id || null; +} + +function fileExtension(file) { + if (!file || typeof file === 'string') return ''; + return file.extension || ''; +} + +function fileContentType(file) { + if (!file || typeof file === 'string') return 'application/octet-stream'; + return file.type || 'application/octet-stream'; +} + +function sanitizeFileName(name) { + const sanitized = String(name || 'image') + .replace(/[\r\n"]/g, '_') + .replace(/[^\w.\-]+/g, '_') + .slice(0, 120); + return sanitized || 'image'; +} + +export function fileUploadName(file) { + if (file && typeof file !== 'string' && file.name) { + return sanitizeFileName(file.name); + } + const id = fileId(file) || 'image'; + let ext = fileExtension(file) || ''; + if (ext && !ext.startsWith('.')) ext = `.${ext}`; + return sanitizeFileName(`${id}${ext || '.jpg'}`); +} + +export function buildImageUploadBody(file, bytes) { + const boundary = `----EbayFormBoundary${process.hrtime.bigint().toString(16)}`; + const filename = fileUploadName(file); + const type = fileContentType(file); + const header = Buffer.from( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="image"; filename="${filename}"\r\n` + + `Content-Type: ${type}\r\n` + + `\r\n` + ); + const footer = Buffer.from(`\r\n--${boundary}--\r\n`); + const payload = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes); + return { + body: Buffer.concat([header, payload, footer]), + contentType: `multipart/form-data; boundary=${boundary}`, + }; +} + +export async function streamToBuffer(body) { + if (body == null) return Buffer.alloc(0); + if (Buffer.isBuffer(body)) return body; + if (body instanceof Uint8Array) return Buffer.from(body); + if (typeof body.transformToByteArray === 'function') { + return Buffer.from(await body.transformToByteArray()); + } + const chunks = []; + for await (const chunk of body) { + chunks.push(chunk); + } + return Buffer.concat(chunks.map((chunk) => (Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))); +} + +export function getListingImageFiles(listing, varient) { + if (varient?.listingImages?.length) return varient.listingImages; + if (listing?.listingImages?.length) return listing.listingImages; + return []; +} + +function extractImageUrl(data) { + if (!data) return null; + if (typeof data === 'string' && data.startsWith('http')) return data; + return data.imageUrl || data.image?.imageUrl || data.image?.url || null; +} + +export async function uploadFileToEbay(marketplace, file, bytes) { + const { body, contentType } = buildImageUploadBody(file, bytes); + const result = await makeRequest({ + marketplace, + method: 'POST', + path: '/commerce/media/v1_beta/image/create_image_from_file', + baseUrl: getMediaApiBaseUrl(marketplace), + body, + rawBody: true, + contentType, + }); + const imageUrl = extractImageUrl(result); + if (!imageUrl) { + throw new Error( + `eBay Media API did not return an image URL for file ${fileId(file) || 'unknown'}` + ); + } + return imageUrl; +} + +export async function filesToImageUrls(marketplace, files = []) { + const urls = []; + for (const file of files) { + const id = fileId(file); + if (!id) continue; + const extension = fileExtension(file); + const cephKey = `files/${id}${extension}`; + try { + const body = await downloadFile(BUCKETS.FILES, cephKey); + const bytes = await streamToBuffer(body); + const imageUrl = await uploadFileToEbay(marketplace, file, bytes); + urls.push(imageUrl); + } catch (err) { + logger.warn(`Failed to upload listing image ${id} to eBay: ${err.message}`); + throw err; + } + } + return urls; +} + +export async function resolveListingImageUrls(marketplace, listing, varient) { + const files = getListingImageFiles(listing, varient); + if (files.length) { + return filesToImageUrls(marketplace, files); + } + if (varient?.imageUrls?.length) return varient.imageUrls; + if (listing?.imageUrls?.length) return listing.imageUrls; + return []; +} + +export async function attachImageUrlsToListingAndVarients(marketplace, listing, varients = []) { + const listingImageUrls = await resolveListingImageUrls(marketplace, listing); + const listingWithUrls = listingImageUrls.length + ? { ...listing, imageUrls: listingImageUrls } + : listing; + + const varientsWithUrls = []; + for (const varient of varients) { + if (varient?.listingImages?.length) { + const urls = await filesToImageUrls(marketplace, varient.listingImages); + varientsWithUrls.push(urls.length ? { ...varient, imageUrls: urls } : varient); + } else if (listingImageUrls.length) { + varientsWithUrls.push({ ...varient, imageUrls: listingImageUrls }); + } else { + varientsWithUrls.push(varient); + } + } + + return { listing: listingWithUrls, varients: varientsWithUrls }; +} diff --git a/src/integrations/marketplaces/ebay/index.js b/src/integrations/marketplaces/ebay/index.js index 610a3b2..2e27c07 100644 --- a/src/integrations/marketplaces/ebay/index.js +++ b/src/integrations/marketplaces/ebay/index.js @@ -7,10 +7,30 @@ export { verifyWebhookSignature, } from './auth.js'; -export { syncItems, mapProductToListing, createItem, updateItem, deleteItem } from './listings.js'; +export { + syncItems, + mapProductToListing, + createItem, + updateItem, + deleteItem, + syncListingImages, +} from './listings.js'; export { syncProductCategory } from './categories.js'; -export { syncFulfillmentPolicy } from './fulfillmentPolicies.js'; +export { + syncFulfillmentPolicy, + syncFulfillmentPoliciesFromEbay, + ensureFulfillmentPolicySynced, +} from './fulfillmentPolicies.js'; +export { syncListingPolicies } from './listingPolicies.js'; +export { syncPaymentPoliciesFromEbay, ensurePaymentPolicySynced } from './paymentPolicies.js'; +export { syncReturnPoliciesFromEbay, ensureReturnPolicySynced } from './returnPolicies.js'; +export { + syncTaxRatesFromEbay, + syncTaxRatesToEbay, + syncTaxRates, + ensureTaxRateSynced, +} from './salesTax.js'; export { publishOfferById, @@ -42,3 +62,7 @@ export { export { makeRequest as debugGet } from './shared.js'; export { syncMarketplaceMetadata } from './shippingServices.js'; +export { syncAccountPolicies } from './accountPolicySync.js'; +export { syncFulfillmentPoliciesFromEbay as syncFulfillmentPolicies } from './fulfillmentPolicies.js'; +export { syncPaymentPoliciesFromEbay as syncPaymentPolicies } from './paymentPolicies.js'; +export { syncReturnPoliciesFromEbay as syncReturnPolicies } from './returnPolicies.js'; diff --git a/src/integrations/marketplaces/ebay/itemUrl.js b/src/integrations/marketplaces/ebay/itemUrl.js new file mode 100644 index 0000000..bc63f4b --- /dev/null +++ b/src/integrations/marketplaces/ebay/itemUrl.js @@ -0,0 +1,15 @@ +export function parseEbayItemId(value) { + if (value == null) return ''; + const text = String(value).trim(); + if (!text) return ''; + const fromUrl = text.match(/\/itm\/(\d+)/i); + if (fromUrl) return fromUrl[1]; + return text; +} + +export function getEbayItemUrl(marketplace, listingId) { + const id = parseEbayItemId(listingId); + if (!id) return ''; + const host = marketplace?.config?.sandbox ? 'https://sandbox.ebay.com' : 'https://www.ebay.com'; + return `${host}/itm/${id}`; +} diff --git a/src/integrations/marketplaces/ebay/listingPolicies.js b/src/integrations/marketplaces/ebay/listingPolicies.js new file mode 100644 index 0000000..4dd0321 --- /dev/null +++ b/src/integrations/marketplaces/ebay/listingPolicies.js @@ -0,0 +1,68 @@ +import { fulfillmentPolicyModel } from '../../../database/schemas/sales/fulfillmentpolicy.schema.js'; +import { paymentPolicyModel } from '../../../database/schemas/finance/paymentpolicy.schema.js'; +import { returnPolicyModel } from '../../../database/schemas/sales/returnpolicy.schema.js'; +import { + loadPolicyDocument, + resolveListingPolicy, +} from './accountPolicies.js'; +import { + ensureFulfillmentPolicySynced, + syncFulfillmentPolicy, +} from './fulfillmentPolicies.js'; +import { ensurePaymentPolicySynced } from './paymentPolicies.js'; +import { ensureReturnPolicySynced } from './returnPolicies.js'; + +async function loadFulfillmentPolicy(value) { + const policy = await loadPolicyDocument(fulfillmentPolicyModel, value); + if (!policy) return null; + if (Array.isArray(policy.courierServices) && policy.courierServices.length) { + return fulfillmentPolicyModel + .findById(policy._id) + .populate(['courierServices', 'marketplaces.marketplace']) + .lean(); + } + return policy; +} + +export async function syncListingPolicies(marketplace, listing) { + const fulfillmentRef = resolveListingPolicy( + listing, + marketplace, + 'fulfillmentPolicy', + 'defaultFulfillmentPolicy' + ); + const paymentRef = resolveListingPolicy( + listing, + marketplace, + 'paymentPolicy', + 'defaultPaymentPolicy' + ); + const returnRef = resolveListingPolicy( + listing, + marketplace, + 'returnPolicy', + 'defaultReturnPolicy' + ); + + let fulfillmentPolicyId; + if (fulfillmentRef) { + const fulfillmentPolicy = await loadFulfillmentPolicy(fulfillmentRef); + ({ fulfillmentPolicyId } = await ensureFulfillmentPolicySynced( + marketplace, + fulfillmentPolicy + )); + } else { + ({ fulfillmentPolicyId } = await syncFulfillmentPolicy(marketplace, listing)); + } + + const paymentPolicy = await loadPolicyDocument(paymentPolicyModel, paymentRef); + const returnPolicy = await loadPolicyDocument(returnPolicyModel, returnRef); + const payment = await ensurePaymentPolicySynced(marketplace, paymentPolicy); + const returned = await ensureReturnPolicySynced(marketplace, returnPolicy); + + return { + fulfillmentPolicyId, + paymentPolicyId: payment.paymentPolicyId, + returnPolicyId: returned.returnPolicyId, + }; +} diff --git a/src/integrations/marketplaces/ebay/listingVarients.js b/src/integrations/marketplaces/ebay/listingVarients.js index 085607d..12b95aa 100644 --- a/src/integrations/marketplaces/ebay/listingVarients.js +++ b/src/integrations/marketplaces/ebay/listingVarients.js @@ -1,11 +1,13 @@ import mongoose from 'mongoose'; import { stockLocationModel } from '../../../database/schemas/inventory/stocklocation.schema.js'; -import { productStockModel } from '../../../database/schemas/inventory/productstock.schema.js'; import { FARMCONTROL_GB_SUBDIVISION_STATE, resolveEbayCountry } from './countryCodes.js'; import { syncProductCategory } from './categories.js'; -import { syncFulfillmentPolicy } from './fulfillmentPolicies.js'; +import { syncListingPolicies } from './listingPolicies.js'; import { makeRequest, logger } from './shared.js'; +import { getEbayItemUrl, parseEbayItemId } from './itemUrl.js'; import { marketplaceSku } from '../ids.js'; +import { fromEbayProductAspects, toEbayProductAspects } from './variationAspects.js'; +import { toEbayHtmlDescription, toEbayPlainDescription } from './description.js'; const WAREHOUSE_ADDRESS_DEFAULTS = { GB: { city: 'London', stateOrProvince: 'England', postalCode: 'SW1A 1AA' }, @@ -181,17 +183,20 @@ function applyMarketplaceOfferDefaults( offer, marketplace, merchantLocationKey, - fulfillmentPolicyId + listingPolicies = {} ) { offer.merchantLocationKey = merchantLocationKey; - const config = marketplace.config || {}; - const listingPolicies = {}; - if (fulfillmentPolicyId || config.fulfillmentPolicyId) { - listingPolicies.fulfillmentPolicyId = fulfillmentPolicyId || config.fulfillmentPolicyId; + const policies = {}; + if (listingPolicies.fulfillmentPolicyId) { + policies.fulfillmentPolicyId = String(listingPolicies.fulfillmentPolicyId); } - if (config.paymentPolicyId) listingPolicies.paymentPolicyId = config.paymentPolicyId; - if (config.returnPolicyId) listingPolicies.returnPolicyId = config.returnPolicyId; - if (Object.keys(listingPolicies).length) offer.listingPolicies = listingPolicies; + if (listingPolicies.paymentPolicyId) { + policies.paymentPolicyId = String(listingPolicies.paymentPolicyId); + } + if (listingPolicies.returnPolicyId) { + policies.returnPolicyId = String(listingPolicies.returnPolicyId); + } + if (Object.keys(policies).length) offer.listingPolicies = policies; } export function resolveListingDescription(listing, varient) { @@ -211,32 +216,74 @@ export function resolveListingDescription(listing, varient) { 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); +export function resolveEbayProductDescription(listing, varient) { + return toEbayPlainDescription(resolveListingDescription(listing, varient)); +} + +export function resolveEbayListingDescription(listing, varient) { + return toEbayHtmlDescription(resolveListingDescription(listing, varient)); +} + +const PRODUCT_PUT_FIELDS = [ + 'aspects', + 'brand', + 'description', + 'ean', + 'epid', + 'imageUrls', + 'isbn', + 'mpn', + 'subtitle', + 'title', + 'upc', + 'videoIds', +]; + +function pickDefined(source, keys) { + const result = {}; + if (!source || typeof source !== 'object') return result; + for (const key of keys) { + if (source[key] != null) result[key] = source[key]; + } + return result; +} + +export function inventoryItemPutBody(existing, listing, sku, varient, quantity) { + const product = pickDefined(existing?.product, PRODUCT_PUT_FIELDS); + product.title = listing?.title || product.title || sku; + product.description = resolveEbayProductDescription(listing, varient); + const aspects = toEbayProductAspects(varient?.aspects); + if (aspects) product.aspects = aspects; + const imageUrls = varient?.imageUrls?.length ? varient.imageUrls : listing?.imageUrls; + if (imageUrls?.length) product.imageUrls = imageUrls; + + const shipTo = existing?.availability?.shipToLocationAvailability || {}; + const availability = { + shipToLocationAvailability: { quantity }, + }; + if (Array.isArray(shipTo.availabilityDistributions) && shipTo.availabilityDistributions.length) { + availability.shipToLocationAvailability.availabilityDistributions = + shipTo.availabilityDistributions; + } + if (existing?.availability?.pickupAtLocationAvailability?.length) { + availability.pickupAtLocationAvailability = existing.availability.pickupAtLocationAvailability; + } + + const body = { + product, + availability, + condition: toEbayCondition(listing?.condition || existing?.condition), + }; if (existing?.conditionDescription) body.conditionDescription = existing.conditionDescription; + if (existing?.conditionDescriptors?.length) { + body.conditionDescriptors = existing.conditionDescriptors; + } 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), - }); +async function ensureInventoryItem(marketplace, sku, listing, varient) { + await upsertInventoryItem(marketplace, resolvePublishVarient(sku, varient), listing); } function mapVarientToInventoryItem(varient, listing, quantity = 0) { @@ -244,29 +291,32 @@ function mapVarientToInventoryItem(varient, listing, quantity = 0) { condition: toEbayCondition(listing?.condition), product: { title: listing.title || marketplaceSku(varient) || '', - description: resolveListingDescription(listing, varient), + description: resolveEbayProductDescription(listing, varient), }, availability: { shipToLocationAvailability: { quantity }, }, }; - if (listing.imageUrls?.length) item.product.imageUrls = listing.imageUrls; + const aspects = toEbayProductAspects(varient?.aspects); + if (aspects) item.product.aspects = aspects; + const imageUrls = varient?.imageUrls?.length ? varient.imageUrls : listing?.imageUrls; + if (imageUrls?.length) item.product.imageUrls = imageUrls; return item; } -function mapVarientToOffer(varient, listing, marketplace, merchantLocationKey) { +function mapVarientToOffer(varient, listing, marketplace, merchantLocationKey, quantity) { const offer = { sku: marketplaceSku(varient), marketplaceId: marketplace.config?.marketplaceId || 'EBAY_GB', format: 'FIXED_PRICE', - listingDescription: resolveListingDescription(listing, varient), + listingDescription: resolveEbayListingDescription(listing, varient), + availableQuantity: quantity, }; - applyMarketplaceOfferDefaults( - offer, - marketplace, - merchantLocationKey, - listing.fulfillmentPolicyId - ); + applyMarketplaceOfferDefaults(offer, marketplace, merchantLocationKey, { + fulfillmentPolicyId: listing.fulfillmentPolicyId, + paymentPolicyId: listing.paymentPolicyId, + returnPolicyId: listing.returnPolicyId, + }); const price = varient.price ?? listing.price; if (price != null) { @@ -281,24 +331,22 @@ function mapVarientToOffer(varient, listing, marketplace, merchantLocationKey) { return offer; } -export async function resolveVarientQuantity(varient, listing) { - const skuId = varient.productSku?._id || varient.productSku; - if (!skuId) { - return Number(varient.inventory) || 0; - } - const locationId = listing?.stockLocation?._id || listing?.stockLocation; - const filter = { productSku: skuId }; - if (locationId) { - filter.stockLocation = locationId; - } - const stocks = await productStockModel.find(filter).lean(); - return stocks.reduce((sum, stock) => sum + (Number(stock.currentQuantity) || 0), 0); +export function resolveVarientQuantity(varient) { + return Math.max(0, Number(varient?.stockQuantity) || 0); } export async function upsertInventoryItem(marketplace, varient, listing) { const sku = marketplaceSku(varient); - const quantity = await resolveVarientQuantity(varient, listing); - const inventoryItem = mapVarientToInventoryItem(varient, listing, quantity); + if (!sku) throw new Error('SKU is required to upsert an eBay inventory item'); + const quantity = resolveVarientQuantity(varient); + const existing = await makeRequest({ + marketplace, + path: `/sell/inventory/v1/inventory_item/${encodeURIComponent(sku)}`, + acceptableStatuses: [404], + }); + const inventoryItem = existing + ? inventoryItemPutBody(existing, listing, sku, varient, quantity) + : mapVarientToInventoryItem(varient, listing, quantity); const result = await makeRequest({ marketplace, method: 'PUT', @@ -309,6 +357,39 @@ export async function upsertInventoryItem(marketplace, varient, listing) { logger.debug('result', result); } +export function resolveOfferListingId(offer) { + return parseEbayItemId(offer?.listingId || offer?.listing?.listingId); +} + +export function storedEbayItemId(listing) { + return parseEbayItemId(listing?.externalReference) || parseEbayItemId(listing?.url); +} + +function isOfferPublished(offer) { + if (!offer) return false; + if (offer.status === 'PUBLISHED') return true; + const listingStatus = offer.listing?.listingStatus; + return listingStatus === 'ACTIVE' || listingStatus === 'OUT_OF_STOCK'; +} + +function findOfferForListing(offers = [], listing) { + const wantedId = storedEbayItemId(listing); + if (wantedId) { + const match = offers.find((offer) => resolveOfferListingId(offer) === wantedId); + if (match) return match; + } + return offers[0] || null; +} + +function ebayListingPublishResult(marketplace, { offerId, listingId } = {}) { + const id = parseEbayItemId(listingId); + return { + ...(offerId ? { offerId } : {}), + listingId: id || undefined, + ...(id ? { externalReference: id, url: getEbayItemUrl(marketplace, id) } : { url: '' }), + }; +} + export async function fetchOffers(marketplace, sku) { try { const data = await makeRequest({ @@ -327,13 +408,15 @@ export async function fetchOffers(marketplace, sku) { async function upsertOrCreateOffer(marketplace, varient, listing) { const merchantLocationKey = await ensureMerchantLocation(marketplace, listing); const offers = await fetchOffers(marketplace, marketplaceSku(varient)); - const existingOffer = offers[0]; + const existingOffer = findOfferForListing(offers, listing); const price = varient.price ?? listing.price; + const quantity = resolveVarientQuantity(varient); if (existingOffer?.offerId) { const offerUpdate = { merchantLocationKey, - listingDescription: resolveListingDescription(listing, varient), + listingDescription: resolveEbayListingDescription(listing, varient), + availableQuantity: quantity, }; if (price != null) { offerUpdate.pricingSummary = { @@ -344,10 +427,20 @@ async function upsertOrCreateOffer(marketplace, varient, listing) { }; } if (listing.categoryId) offerUpdate.categoryId = String(listing.categoryId); - if (listing.fulfillmentPolicyId) { + if ( + listing.fulfillmentPolicyId || + listing.paymentPolicyId || + listing.returnPolicyId + ) { offerUpdate.listingPolicies = { ...(existingOffer.listingPolicies || {}), - fulfillmentPolicyId: String(listing.fulfillmentPolicyId), + ...(listing.fulfillmentPolicyId + ? { fulfillmentPolicyId: String(listing.fulfillmentPolicyId) } + : {}), + ...(listing.paymentPolicyId + ? { paymentPolicyId: String(listing.paymentPolicyId) } + : {}), + ...(listing.returnPolicyId ? { returnPolicyId: String(listing.returnPolicyId) } : {}), }; } const body = { ...existingOffer, ...offerUpdate }; @@ -361,7 +454,7 @@ async function upsertOrCreateOffer(marketplace, varient, listing) { return existingOffer; } - const offerBody = mapVarientToOffer(varient, listing, marketplace, merchantLocationKey); + const offerBody = mapVarientToOffer(varient, listing, marketplace, merchantLocationKey, quantity); const result = await makeRequest({ marketplace, method: 'POST', @@ -392,14 +485,15 @@ export async function withdrawOfferById(marketplace, offerId) { export async function syncOfferAndMaybePublish(marketplace, listing, varient) { const offerResult = await upsertOrCreateOffer(marketplace, varient, listing); + const existingListingId = resolveOfferListingId(offerResult) || storedEbayItemId(listing); + if (isOfferPublished(offerResult) && existingListingId) { + return ebayListingPublishResult(marketplace, { listingId: existingListingId }); + } if (offerResult?.offerId && listing.state?.type === 'active') { try { const publishResult = await publishOfferById(marketplace, offerResult.offerId); if (publishResult?.listingId) { - return { - url: `https://www.ebay.com/itm/${publishResult.listingId}`, - listingId: publishResult.listingId, - }; + return ebayListingPublishResult(marketplace, { listingId: publishResult.listingId }); } } catch (err) { logger.warn( @@ -407,10 +501,17 @@ export async function syncOfferAndMaybePublish(marketplace, listing, varient) { ); } } - return { url: '', listingId: offerResult?.listingId }; + return ebayListingPublishResult(marketplace, { listingId: existingListingId }); } -export async function publishOfferForSku(marketplace, sku, listing) { +function resolvePublishVarient(sku, varient) { + if (varient && (marketplaceSku(varient) === sku || varient._reference === sku)) { + return varient; + } + return { ...(varient || {}), _reference: sku, externalReference: sku }; +} + +export async function publishOfferForSku(marketplace, sku, listing, varient) { if (!sku) throw new Error('SKU (_reference) is required to publish an offer'); if (!listing) { throw new Error( @@ -418,51 +519,52 @@ export async function publishOfferForSku(marketplace, sku, listing) { ); } - const merchantLocationKey = await ensureMerchantLocation(marketplace, listing); - const category = await syncProductCategory(marketplace, listing); - const fulfillmentPolicy = await syncFulfillmentPolicy(marketplace, listing); + const resolvedVarient = resolvePublishVarient(sku, varient); + const category = await syncProductCategory(marketplace, listing, [resolvedVarient]); + const listingPolicies = await syncListingPolicies(marketplace, listing); if (!category?.categoryId) { throw new Error( `Listing "${listing._reference || sku}" must have a product with a product category before publishing on eBay.` ); } - const existingOffer = (await fetchOffers(marketplace, sku))[0]; - if (!existingOffer?.offerId) { - throw new Error( - `No eBay offer exists for SKU "${sku}". Create or sync the listing so an offer exists before publishing.` - ); + const listingWithContext = { + ...listing, + categoryId: category.categoryId, + ...listingPolicies, + }; + + await ensureInventoryItem(marketplace, sku, listingWithContext, resolvedVarient); + + const offerResult = await upsertOrCreateOffer(marketplace, resolvedVarient, listingWithContext); + const offers = offerResult?.offerId ? [offerResult] : await fetchOffers(marketplace, sku); + const matchedOffer = findOfferForListing(offers, listingWithContext) || offerResult; + const offerId = matchedOffer?.offerId || offers[0]?.offerId; + if (!offerId) { + throw new Error(`Failed to create an eBay offer for SKU "${sku}".`); } - const listingDescription = resolveListingDescription(listing, { _reference: sku }); - await ensureInventoryItemDescription(marketplace, sku, listing, { _reference: sku }); + const existingListingId = resolveOfferListingId(matchedOffer) || storedEbayItemId(listing); + if (isOfferPublished(matchedOffer) && existingListingId) { + return ebayListingPublishResult(marketplace, { offerId, listingId: existingListingId }); + } - await makeRequest({ - marketplace, - method: 'PUT', - path: `/sell/inventory/v1/offer/${existingOffer.offerId}`, - body: { - ...existingOffer, - merchantLocationKey, - categoryId: String(category.categoryId), - listingDescription, - listingPolicies: { - ...(existingOffer.listingPolicies || {}), - fulfillmentPolicyId: fulfillmentPolicy.fulfillmentPolicyId, - }, - }, + const publishResult = await publishOfferById(marketplace, offerId); + return ebayListingPublishResult(marketplace, { + offerId, + listingId: publishResult?.listingId || existingListingId, }); - - const publishResult = await publishOfferById(marketplace, existingOffer.offerId); - return { offerId: existingOffer.offerId, listingId: publishResult?.listingId }; } -export async function withdrawOfferForSku(marketplace, sku) { +export async function withdrawOfferForSku(marketplace, sku, listing) { if (!sku) throw new Error('SKU (_reference) is required to withdraw an offer'); - const existingOffer = (await fetchOffers(marketplace, sku))[0]; + const existingOffer = findOfferForListing(await fetchOffers(marketplace, sku), listing); if (!existingOffer?.offerId) throw new Error(`No eBay offer exists for SKU "${sku}".`); await withdrawOfferById(marketplace, existingOffer.offerId); - return { offerId: existingOffer.offerId }; + return { + offerId: existingOffer.offerId, + listingId: resolveOfferListingId(existingOffer) || storedEbayItemId(listing), + }; } export function resolveOfferState(offers) { @@ -479,6 +581,7 @@ export function resolveOfferState(offers) { export function buildVarientEntry(item, offers) { const offer = offers?.[0]; + const aspects = fromEbayProductAspects(item.product?.aspects); return { _reference: item.sku, externalReference: item.sku, @@ -487,5 +590,6 @@ export function buildVarientEntry(item, offers) { : undefined, currency: offer?.pricingSummary?.price?.currency || undefined, state: { type: resolveOfferState(offers || []) }, + ...(aspects.length ? { aspects } : {}), }; } diff --git a/src/integrations/marketplaces/ebay/listings.js b/src/integrations/marketplaces/ebay/listings.js index 6819929..7866ecd 100644 --- a/src/integrations/marketplaces/ebay/listings.js +++ b/src/integrations/marketplaces/ebay/listings.js @@ -1,5 +1,5 @@ import { syncProductCategory } from './categories.js'; -import { syncFulfillmentPolicy } from './fulfillmentPolicies.js'; +import { syncListingPolicies } from './listingPolicies.js'; import { buildVarientEntry, fetchOffers, @@ -11,26 +11,50 @@ import { withdrawOfferById, } from './listingVarients.js'; import { makeRequest, logger } from './shared.js'; +import { getEbayItemUrl, parseEbayItemId } from './itemUrl.js'; import { marketplaceSku } from '../ids.js'; +import { buildGroupVariesBy } from './variationAspects.js'; +import { toEbayHtmlDescription } from './description.js'; +import { attachImageUrlsToListingAndVarients } from './images.js'; function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } -async function createOrReplaceGroup(marketplace, listing, varients) { +export function buildInventoryItemGroupBody(listing, varients) { const groupKey = listing._reference; const variantSKUs = varients.map((v) => marketplaceSku(v)).filter(Boolean); + const variation = buildGroupVariesBy(varients); const body = { title: listing.title || groupKey, variantSKUs, - description: resolveListingDescription(listing), + description: toEbayHtmlDescription(resolveListingDescription(listing)), + variesBy: variation.variesBy, }; + if (variation.aspects) { + body.aspects = variation.aspects; + } + if (listing.imageUrls?.length) { body.imageUrls = listing.imageUrls; + const firstSpecName = variation.variesBy?.specifications?.[0]?.name; + if (firstSpecName) { + body.variesBy = { + ...body.variesBy, + aspectsImageVariesBy: [firstSpecName], + }; + } } + return body; +} + +async function createOrReplaceGroup(marketplace, listing, varients) { + const groupKey = listing._reference; + const body = buildInventoryItemGroupBody(listing, varients); + await makeRequest({ marketplace, method: 'PUT', @@ -105,36 +129,40 @@ async function syncListing(marketplace, listing, varients, actionLabel) { ); } - const category = await syncProductCategory(marketplace, listing, validVarients); - const fulfillmentPolicy = await syncFulfillmentPolicy(marketplace, listing); + const { listing: listingWithImages, varients: varientsWithImages } = + await attachImageUrlsToListingAndVarients(marketplace, listing, validVarients); + + const category = await syncProductCategory(marketplace, listingWithImages, varientsWithImages); + const listingPolicies = await syncListingPolicies(marketplace, listingWithImages); const listingWithContext = { - ...listing, + ...listingWithImages, ...(category ? { categoryId: category.categoryId } : {}), - fulfillmentPolicyId: fulfillmentPolicy.fulfillmentPolicyId, + ...listingPolicies, }; - if (validVarients.length === 1) { + if (varientsWithImages.length === 1) { logger.info( - `Syncing standalone eBay inventory item "${validVarients[0]._reference}" for listing "${ref}"` + `Syncing standalone eBay inventory item "${varientsWithImages[0]._reference}" for listing "${ref}"` ); const published = await syncSingleVarientListing( marketplace, listingWithContext, - validVarients[0] + varientsWithImages[0] ); - return listingSyncResult(published); + return listingSyncResult(published, marketplace); } - const published = await syncGroupedListing(marketplace, listingWithContext, validVarients); - return listingSyncResult(published); + const published = await syncGroupedListing(marketplace, listingWithContext, varientsWithImages); + return listingSyncResult(published, marketplace); } -function listingSyncResult(published) { +function listingSyncResult(published, marketplace) { if (!published) return { url: '' }; if (typeof published === 'string') return { url: published }; + const listingId = parseEbayItemId(published.listingId || published.externalReference); return { - url: published.url || '', - ...(published.listingId ? { externalReference: published.listingId } : {}), + url: published.url || getEbayItemUrl(marketplace, listingId), + ...(listingId ? { externalReference: listingId } : {}), }; } @@ -146,6 +174,20 @@ export async function updateItem(marketplace, listing, varients) { return syncListing(marketplace, listing, varients, 'update'); } +export async function syncListingImages(marketplace, listing, varients = []) { + const validVarients = (varients || []).filter((varient) => varient?._reference); + if (validVarients.length === 0) return null; + + const { listing: listingWithImages, varients: varientsWithImages } = + await attachImageUrlsToListingAndVarients(marketplace, listing, validVarients); + + for (const varient of varientsWithImages) { + await upsertInventoryItem(marketplace, varient, listingWithImages); + } + + return { listing: listingWithImages, varients: varientsWithImages }; +} + export async function deleteItem(marketplace, listing, varients = []) { const skus = varients.map((v) => marketplaceSku(v)).filter(Boolean); @@ -296,17 +338,19 @@ export async function syncItems(marketplace) { return results; } -export function mapProductToListing(ebayItem) { +export function mapProductToListing(ebayItem, marketplace) { if (ebayItem._type === 'group') { const group = ebayItem._group; const variants = ebayItem._variants || []; const allOffers = variants.flatMap((v) => v._offers || []); const stateType = resolveOfferState(allOffers); - const firstPublishedOffer = allOffers.find((o) => o?.listingId); - const url = firstPublishedOffer?.listingId - ? `https://www.ebay.com/itm/${firstPublishedOffer.listingId}` - : ''; + const firstPublishedOffer = allOffers.find((o) => o?.listingId || o?.listing?.listingId); + const publishedListingId = parseEbayItemId( + firstPublishedOffer?.listingId || firstPublishedOffer?.listing?.listingId + ); + const listingId = publishedListingId || ebayItem._groupKey; + const url = getEbayItemUrl(marketplace, publishedListingId); const firstOffer = allOffers[0]; const price = firstOffer?.pricingSummary?.price?.value @@ -317,7 +361,7 @@ export function mapProductToListing(ebayItem) { const varients = variants.map((v) => buildVarientEntry(v, v._offers || [])); return { - externalReference: firstPublishedOffer?.listingId || ebayItem._groupKey, + externalReference: listingId, title: group.title || ebayItem._groupKey, description: group.description, condition: fromEbayCondition(variants[0]?.condition), @@ -336,13 +380,14 @@ export function mapProductToListing(ebayItem) { const currency = offer?.pricingSummary?.price?.currency || undefined; const stateType = resolveOfferState(ebayItem._offers || []); - - const url = offer?.listingId ? `https://www.ebay.com/itm/${offer.listingId}` : ''; + const publishedListingId = parseEbayItemId(offer?.listingId || offer?.listing?.listingId); + const listingId = publishedListingId || ebayItem.sku; + const url = getEbayItemUrl(marketplace, publishedListingId); const varients = [buildVarientEntry(ebayItem, ebayItem._offers || [])]; return { - externalReference: offer?.listingId || ebayItem.sku, + externalReference: listingId, title: ebayItem.product?.title || ebayItem.sku, description: ebayItem.product?.description, condition: fromEbayCondition(ebayItem.condition), diff --git a/src/integrations/marketplaces/ebay/paymentPolicies.js b/src/integrations/marketplaces/ebay/paymentPolicies.js new file mode 100644 index 0000000..46bdf3e --- /dev/null +++ b/src/integrations/marketplaces/ebay/paymentPolicies.js @@ -0,0 +1,133 @@ +import { paymentPolicyModel } from '../../../database/schemas/finance/paymentpolicy.schema.js'; +import { makeRequest, logger } from './shared.js'; +import { + buildCategoryTypes, + ensureSellingPolicyManagement, + getEbayMarketplaceId, + mappingExternalReference, + persistMarketplaceMapping, + updateAccountPolicy, + upsertLocalPolicyFromRemote, +} from './accountPolicies.js'; + +export function buildPaymentPolicy(policy, marketplace, existingPolicy, allPolicies = []) { + const payload = { + name: String(policy?.name || '').slice(0, 64), + marketplaceId: getEbayMarketplaceId(marketplace), + categoryTypes: buildCategoryTypes(existingPolicy, allPolicies, 'paymentPolicyId'), + immediatePay: policy?.immediatePay !== false, + }; + if (policy?.description) payload.description = String(policy.description).slice(0, 250); + if (policy?.paymentInstructions) { + payload.paymentInstructions = String(policy.paymentInstructions).slice(0, 1000); + } + return payload; +} + +async function fetchPaymentPolicyByName(marketplace, name) { + return makeRequest({ + marketplace, + path: '/sell/account/v1/payment_policy/get_by_policy_name', + params: { + marketplace_id: getEbayMarketplaceId(marketplace), + name, + }, + acceptableStatuses: [404], + }); +} + +export async function fetchPaymentPolicies(marketplace) { + const result = await makeRequest({ + marketplace, + path: '/sell/account/v1/payment_policy', + params: { marketplace_id: getEbayMarketplaceId(marketplace) }, + acceptableStatuses: [404], + }); + return result?.paymentPolicies || []; +} + +export async function ensurePaymentPolicySynced(marketplace, policy) { + if (!policy) { + throw new Error('A payment policy is required before publishing to eBay.'); + } + + await ensureSellingPolicyManagement(marketplace); + await persistMarketplaceMapping(paymentPolicyModel, policy, marketplace, { + stateType: 'syncing', + }); + + try { + const policyName = String(policy.name || '').slice(0, 64); + const existingId = mappingExternalReference(policy, marketplace); + const [existingByName, allPolicies] = await Promise.all([ + existingId ? null : fetchPaymentPolicyByName(marketplace, policyName), + fetchPaymentPolicies(marketplace), + ]); + const existingPolicy = + (existingId && allPolicies.find((item) => String(item.paymentPolicyId) === String(existingId))) || + existingByName || + null; + const payload = buildPaymentPolicy(policy, marketplace, existingPolicy, allPolicies); + let paymentPolicyId = existingId || existingPolicy?.paymentPolicyId; + + if (paymentPolicyId) { + await updateAccountPolicy( + marketplace, + `/sell/account/v1/payment_policy/${encodeURIComponent(paymentPolicyId)}`, + payload + ); + } else { + const created = await makeRequest({ + marketplace, + method: 'POST', + path: '/sell/account/v1/payment_policy', + body: payload, + }); + paymentPolicyId = created?.paymentPolicyId; + if (!paymentPolicyId) { + throw new Error(`eBay did not return an ID for payment policy "${payload.name}"`); + } + } + + await persistMarketplaceMapping(paymentPolicyModel, policy, marketplace, { + externalReference: String(paymentPolicyId), + stateType: 'ready', + }); + logger.info(`Synced eBay payment policy "${payload.name}" (${paymentPolicyId})`); + return { paymentPolicyId: String(paymentPolicyId) }; + } catch (err) { + await persistMarketplaceMapping(paymentPolicyModel, policy, marketplace, { + stateType: 'failed', + message: err.message, + }); + throw err; + } +} + +function mapRemotePaymentPolicy(remote) { + return { + description: remote.description, + immediatePay: remote.immediatePay !== false, + paymentInstructions: remote.paymentInstructions, + }; +} + +export async function syncPaymentPoliciesFromEbay(marketplace) { + await ensureSellingPolicyManagement(marketplace); + const remotes = await fetchPaymentPolicies(marketplace); + const upserted = []; + for (const remote of remotes) { + const paymentPolicyId = remote.paymentPolicyId; + if (!paymentPolicyId || !remote.name) continue; + upserted.push( + await upsertLocalPolicyFromRemote({ + model: paymentPolicyModel, + marketplace, + remoteId: paymentPolicyId, + name: remote.name, + fields: mapRemotePaymentPolicy(remote), + }) + ); + } + return { count: upserted.length }; +} diff --git a/src/integrations/marketplaces/ebay/returnPolicies.js b/src/integrations/marketplaces/ebay/returnPolicies.js new file mode 100644 index 0000000..dd1085b --- /dev/null +++ b/src/integrations/marketplaces/ebay/returnPolicies.js @@ -0,0 +1,189 @@ +import { returnPolicyModel } from '../../../database/schemas/sales/returnpolicy.schema.js'; +import { makeRequest, logger } from './shared.js'; +import { + buildCategoryTypes, + ensureSellingPolicyManagement, + getEbayMarketplaceId, + mappingExternalReference, + persistMarketplaceMapping, + updateAccountPolicy, + upsertLocalPolicyFromRemote, +} from './accountPolicies.js'; + +const PAYER_TO_EBAY = { buyer: 'BUYER', seller: 'SELLER' }; +const EBAY_TO_PAYER = { BUYER: 'buyer', SELLER: 'seller' }; +const REFUND_TO_EBAY = { moneyBack: 'MONEY_BACK', merchandiseCredit: 'MERCHANDISE_CREDIT' }; +const EBAY_TO_REFUND = { MONEY_BACK: 'moneyBack', MERCHANDISE_CREDIT: 'merchandiseCredit' }; +const EBAY_RETURN_PERIOD_DAYS = [14, 30, 60]; + +export function snapReturnPeriodDays(days, fallback = 30) { + const value = Number(days); + if (!Number.isFinite(value) || value <= 0) return fallback; + return EBAY_RETURN_PERIOD_DAYS.find((allowed) => allowed >= value) || EBAY_RETURN_PERIOD_DAYS.at(-1); +} + +function periodFromDays(days, fallback = 30) { + return { value: snapReturnPeriodDays(days, fallback), unit: 'DAY' }; +} + +function daysFromPeriod(period) { + if (period == null) return undefined; + const value = Number(period.value ?? period); + return Number.isFinite(value) ? value : undefined; +} + +export function buildReturnPolicy(policy, marketplace, existingPolicy, allPolicies = []) { + const returnsAccepted = policy?.returnsAccepted !== false; + const payload = { + name: String(policy?.name || '').slice(0, 64), + marketplaceId: getEbayMarketplaceId(marketplace), + categoryTypes: buildCategoryTypes(existingPolicy, allPolicies, 'returnPolicyId'), + returnsAccepted, + }; + if (policy?.description) payload.description = String(policy.description).slice(0, 250); + if (returnsAccepted) { + payload.returnPeriod = periodFromDays(policy?.returnPeriodDays ?? 30); + payload.returnShippingCostPayer = + PAYER_TO_EBAY[policy?.returnShippingCostPayer] || 'BUYER'; + payload.refundMethod = REFUND_TO_EBAY[policy?.refundMethod] || 'MONEY_BACK'; + } + if (policy?.returnInstructions) { + payload.returnInstructions = String(policy.returnInstructions).slice(0, 5000); + } + + if (policy?.internationalReturnsAccepted === true) { + payload.internationalOverride = { + returnsAccepted: true, + returnPeriod: periodFromDays( + policy.internationalReturnPeriodDays ?? policy.returnPeriodDays ?? 30 + ), + returnShippingCostPayer: + PAYER_TO_EBAY[policy.internationalReturnShippingCostPayer] || + PAYER_TO_EBAY[policy.returnShippingCostPayer] || + 'BUYER', + }; + } else if (policy?.internationalReturnsAccepted === false) { + payload.internationalOverride = { returnsAccepted: false }; + } + + return payload; +} + +async function fetchReturnPolicyByName(marketplace, name) { + return makeRequest({ + marketplace, + path: '/sell/account/v1/return_policy/get_by_policy_name', + params: { + marketplace_id: getEbayMarketplaceId(marketplace), + name, + }, + acceptableStatuses: [404], + }); +} + +export async function fetchReturnPolicies(marketplace) { + const result = await makeRequest({ + marketplace, + path: '/sell/account/v1/return_policy', + params: { marketplace_id: getEbayMarketplaceId(marketplace) }, + acceptableStatuses: [404], + }); + return result?.returnPolicies || []; +} + +export async function ensureReturnPolicySynced(marketplace, policy) { + if (!policy) { + throw new Error('A return policy is required before publishing to eBay.'); + } + + await ensureSellingPolicyManagement(marketplace); + await persistMarketplaceMapping(returnPolicyModel, policy, marketplace, { + stateType: 'syncing', + }); + + try { + const policyName = String(policy.name || '').slice(0, 64); + const existingId = mappingExternalReference(policy, marketplace); + const [existingByName, allPolicies] = await Promise.all([ + existingId ? null : fetchReturnPolicyByName(marketplace, policyName), + fetchReturnPolicies(marketplace), + ]); + const existingPolicy = + (existingId && allPolicies.find((item) => String(item.returnPolicyId) === String(existingId))) || + existingByName || + null; + const payload = buildReturnPolicy(policy, marketplace, existingPolicy, allPolicies); + let returnPolicyId = existingId || existingPolicy?.returnPolicyId; + + if (returnPolicyId) { + await updateAccountPolicy( + marketplace, + `/sell/account/v1/return_policy/${encodeURIComponent(returnPolicyId)}`, + payload + ); + } else { + const created = await makeRequest({ + marketplace, + method: 'POST', + path: '/sell/account/v1/return_policy', + body: payload, + }); + returnPolicyId = created?.returnPolicyId; + if (!returnPolicyId) { + throw new Error(`eBay did not return an ID for return policy "${payload.name}"`); + } + } + + await persistMarketplaceMapping(returnPolicyModel, policy, marketplace, { + externalReference: String(returnPolicyId), + stateType: 'ready', + }); + logger.info(`Synced eBay return policy "${payload.name}" (${returnPolicyId})`); + return { returnPolicyId: String(returnPolicyId) }; + } catch (err) { + await persistMarketplaceMapping(returnPolicyModel, policy, marketplace, { + stateType: 'failed', + message: err.message, + }); + throw err; + } +} + +function mapRemoteReturnPolicy(remote) { + const international = remote.internationalOverride || {}; + return { + description: remote.description, + returnsAccepted: remote.returnsAccepted !== false, + returnPeriodDays: daysFromPeriod(remote.returnPeriod), + returnShippingCostPayer: EBAY_TO_PAYER[remote.returnShippingCostPayer] || 'buyer', + refundMethod: EBAY_TO_REFUND[remote.refundMethod] || 'moneyBack', + restockingFeePercentage: + remote.restockingFeePercentage != null ? Number(remote.restockingFeePercentage) : undefined, + returnInstructions: remote.returnInstructions, + internationalReturnsAccepted: + international.returnsAccepted == null ? undefined : Boolean(international.returnsAccepted), + internationalReturnPeriodDays: daysFromPeriod(international.returnPeriod), + internationalReturnShippingCostPayer: EBAY_TO_PAYER[international.returnShippingCostPayer], + }; +} + +export async function syncReturnPoliciesFromEbay(marketplace) { + await ensureSellingPolicyManagement(marketplace); + const remotes = await fetchReturnPolicies(marketplace); + const upserted = []; + for (const remote of remotes) { + const returnPolicyId = remote.returnPolicyId; + if (!returnPolicyId || !remote.name) continue; + upserted.push( + await upsertLocalPolicyFromRemote({ + model: returnPolicyModel, + marketplace, + remoteId: returnPolicyId, + name: remote.name, + fields: mapRemoteReturnPolicy(remote), + }) + ); + } + logger.info(`Imported ${upserted.length} eBay return polic${upserted.length === 1 ? 'y' : 'ies'}`); + return { count: upserted.length }; +} diff --git a/src/integrations/marketplaces/ebay/salesTax.js b/src/integrations/marketplaces/ebay/salesTax.js new file mode 100644 index 0000000..fa4b8ce --- /dev/null +++ b/src/integrations/marketplaces/ebay/salesTax.js @@ -0,0 +1,155 @@ +import { taxRateModel } from '../../../database/schemas/management/taxrate.schema.js'; +import { makeRequest, logger } from './shared.js'; +import { + idOf, + persistMarketplaceMapping, + upsertLocalPolicyFromRemote, +} from './accountPolicies.js'; + +export const US_TERRITORY_JURISDICTIONS = ['AS', 'GU', 'MP', 'PW', 'VI']; +export const EBAY_TAX_COUNTRIES = ['US', 'CA']; + +export function taxExternalReference(country, jurisdiction) { + const countryCode = String(country || '').toUpperCase(); + const jurisdictionId = String(jurisdiction || '').toUpperCase(); + if (!countryCode || !jurisdictionId) return ''; + return `${countryCode}:${jurisdictionId}`; +} + +export function isEbayTaxTableSupported({ country, jurisdiction } = {}) { + const countryCode = String(country || '').toUpperCase(); + const jurisdictionId = String(jurisdiction || '').toUpperCase(); + if (!countryCode || !jurisdictionId) return false; + if (countryCode === 'CA') return true; + if (countryCode === 'US') return US_TERRITORY_JURISDICTIONS.includes(jurisdictionId); + return false; +} + +export function buildSalesTaxEntry(taxRate) { + const countryCode = String(taxRate?.country || '').toUpperCase(); + const jurisdictionId = String(taxRate?.jurisdiction || '').toUpperCase(); + return { + countryCode, + jurisdictionId, + salesTaxPercentage: String(Number(taxRate?.rate) || 0), + shippingAndHandlingTaxed: taxRate?.shippingAndHandlingTaxed === true, + }; +} + +export async function fetchSalesTaxes(marketplace, countryCode) { + const result = await makeRequest({ + marketplace, + path: '/sell/account/v1/sales_tax', + params: { country_code: countryCode }, + acceptableStatuses: [204, 404], + }); + return result?.salesTaxes || result?.salesTaxJurisdictions || []; +} + +export async function ensureTaxRateSynced(marketplace, taxRate) { + if (!isEbayTaxTableSupported(taxRate)) { + await persistMarketplaceMapping(taxRateModel, taxRate, marketplace, { + stateType: 'failed', + message: + 'eBay tax tables only apply to US territories (AS, GU, MP, PW, VI) and Canada.', + }); + return { skipped: true }; + } + + await persistMarketplaceMapping(taxRateModel, taxRate, marketplace, { + stateType: 'syncing', + }); + + try { + const entry = buildSalesTaxEntry(taxRate); + await makeRequest({ + marketplace, + method: 'PUT', + path: `/sell/account/v1/sales_tax/${encodeURIComponent(entry.countryCode)}/${encodeURIComponent(entry.jurisdictionId)}`, + body: { + salesTaxPercentage: entry.salesTaxPercentage, + shippingAndHandlingTaxed: entry.shippingAndHandlingTaxed, + }, + }); + const externalReference = taxExternalReference(entry.countryCode, entry.jurisdictionId); + await persistMarketplaceMapping(taxRateModel, taxRate, marketplace, { + externalReference, + stateType: 'ready', + }); + logger.info( + `Synced eBay sales tax ${externalReference} for tax rate "${taxRate.name || taxRate._reference}"` + ); + return { externalReference }; + } catch (err) { + await persistMarketplaceMapping(taxRateModel, taxRate, marketplace, { + stateType: 'failed', + message: err.message, + }); + throw err; + } +} + +export async function syncTaxRatesFromEbay(marketplace) { + const remotes = []; + for (const countryCode of EBAY_TAX_COUNTRIES) { + const entries = await fetchSalesTaxes(marketplace, countryCode); + for (const entry of entries) { + remotes.push({ + ...entry, + countryCode: entry.countryCode || countryCode, + }); + } + } + + const upserted = []; + for (const remote of remotes) { + const country = String(remote.countryCode || '').toUpperCase(); + const jurisdiction = String(remote.jurisdictionId || '').toUpperCase(); + if (!isEbayTaxTableSupported({ country, jurisdiction })) continue; + + const name = `${country} ${jurisdiction} sales tax`; + const remoteId = taxExternalReference(country, jurisdiction); + upserted.push( + await upsertLocalPolicyFromRemote({ + model: taxRateModel, + marketplace, + remoteId, + name, + fields: { + country, + jurisdiction, + rate: Number(remote.salesTaxPercentage) || 0, + rateType: 'percentage', + active: true, + shippingAndHandlingTaxed: remote.shippingAndHandlingTaxed === true, + }, + }) + ); + } + + logger.info(`Imported ${upserted.length} eBay sales tax table ${upserted.length === 1 ? 'entry' : 'entries'}`); + return { count: upserted.length }; +} + +export async function syncTaxRatesToEbay(marketplace) { + const taxRates = await taxRateModel.find({ + 'marketplaces.marketplace': idOf(marketplace), + }); + let synced = 0; + let skipped = 0; + for (const taxRate of taxRates) { + if (!isEbayTaxTableSupported(taxRate)) { + skipped += 1; + continue; + } + await ensureTaxRateSynced(marketplace, taxRate); + synced += 1; + } + return { synced, skipped }; +} + +export async function syncTaxRates(marketplace) { + const inbound = await syncTaxRatesFromEbay(marketplace); + const outbound = await syncTaxRatesToEbay(marketplace); + return { inbound, outbound }; +} diff --git a/src/integrations/marketplaces/ebay/shared.js b/src/integrations/marketplaces/ebay/shared.js index 145d332..1e4212d 100644 --- a/src/integrations/marketplaces/ebay/shared.js +++ b/src/integrations/marketplaces/ebay/shared.js @@ -25,16 +25,22 @@ function formatDebugPayload(value, { maxLength = DEBUG_PAYLOAD_MAX_LENGTH } = {} return `${text.slice(0, maxLength)}... [truncated ${text.length - maxLength} chars]`; } +export function getEbayMarketplaceId(marketplace) { + return marketplace?.config?.marketplaceId || 'EBAY_GB'; +} + function getMarketplaceDebugContext(marketplace) { return { marketplace: marketplace?.name, - marketplaceId: marketplace?.config?.marketplaceId, + marketplaceId: getEbayMarketplaceId(marketplace), sandbox: marketplace?.config?.sandbox ?? false, }; } const SANDBOX_API_URL = 'https://api.sandbox.ebay.com'; const PRODUCTION_API_URL = 'https://api.ebay.com'; +const SANDBOX_MEDIA_API_URL = 'https://apim.sandbox.ebay.com'; +const PRODUCTION_MEDIA_API_URL = 'https://apim.ebay.com'; const SANDBOX_AUTH_URL = 'https://auth.sandbox.ebay.com'; const PRODUCTION_AUTH_URL = 'https://auth.ebay.com'; const TOKEN_PATH = '/identity/v1/oauth2/token'; @@ -44,6 +50,7 @@ const DEFAULT_SCOPES = [ 'https://api.ebay.com/oauth/api_scope/sell.fulfillment', 'https://api.ebay.com/oauth/api_scope/sell.account', 'https://api.ebay.com/oauth/api_scope/commerce.notification.subscription', + 'https://api.ebay.com/oauth/api_scope/commerce.media', ]; const MARKETPLACE_LANGUAGE_MAP = { EBAY_US: 'en-US', @@ -62,6 +69,10 @@ export function getApiBaseUrl(marketplace) { return marketplace.config.sandbox ? SANDBOX_API_URL : PRODUCTION_API_URL; } +export function getMediaApiBaseUrl(marketplace) { + return marketplace.config.sandbox ? SANDBOX_MEDIA_API_URL : PRODUCTION_MEDIA_API_URL; +} + export function getAuthorizeBaseUrl(marketplace) { return marketplace.config.sandbox ? SANDBOX_AUTH_URL : PRODUCTION_AUTH_URL; } @@ -137,6 +148,11 @@ export async function makeRequest({ params = {}, body = null, acceptableStatuses = [], + extraHeaders = {}, + logResponse = true, + rawBody = false, + contentType, + baseUrl, } = {}) { const { accessToken } = marketplace.config || {}; if (!accessToken) { @@ -150,35 +166,47 @@ export async function makeRequest({ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) .join('&'); - const url = queryString - ? `${getApiBaseUrl(marketplace)}${path}?${queryString}` - : `${getApiBaseUrl(marketplace)}${path}`; + const apiBase = baseUrl || getApiBaseUrl(marketplace); + const url = queryString ? `${apiBase}${path}?${queryString}` : `${apiBase}${path}`; const headers = { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', 'Accept-Language': getAcceptLanguage(marketplace), + ...extraHeaders, }; - if (marketplace.config.marketplaceId) { - headers['X-EBAY-C-MARKETPLACE-ID'] = marketplace.config.marketplaceId; - } + headers['X-EBAY-C-MARKETPLACE-ID'] = getEbayMarketplaceId(marketplace); const fetchOptions = { method, headers, }; - if (body && method !== 'GET') { - fetchOptions.headers['Content-Type'] = 'application/json'; - fetchOptions.headers['Content-Language'] = getAcceptLanguage(marketplace); - fetchOptions.body = JSON.stringify(body); + const isRawBody = rawBody === true || Boolean(contentType); + if (body != null && method !== 'GET') { + if (isRawBody) { + if (contentType) { + fetchOptions.headers['Content-Type'] = contentType; + } + fetchOptions.body = body; + } else { + fetchOptions.headers['Content-Type'] = 'application/json'; + fetchOptions.headers['Content-Language'] = getAcceptLanguage(marketplace); + fetchOptions.body = JSON.stringify(body); + } } const startedAt = Date.now(); + const debugBody = isRawBody + ? `[binary ${body?.length ?? 0} bytes]` + : body + ? formatDebugPayload(body) + : undefined; logger.debug(`eBay API ${method} ${path}`, { ...getMarketplaceDebugContext(marketplace), + host: apiBase, params: Object.keys(params).length ? params : undefined, - body: body ? formatDebugPayload(body) : undefined, + body: debugBody, acceptableStatuses: acceptableStatuses.length ? acceptableStatuses : undefined, }); @@ -233,7 +261,7 @@ export async function makeRequest({ logger.debug(`eBay API ${method} ${path} -> ${response.status} (${durationMs}ms)`, { ...getMarketplaceDebugContext(marketplace), - response: data ? formatDebugPayload(data) : undefined, + response: logResponse && data ? formatDebugPayload(data) : undefined, }); return data; diff --git a/src/integrations/marketplaces/ebay/shippingServices.js b/src/integrations/marketplaces/ebay/shippingServices.js index 79711ae..a5de3ca 100644 --- a/src/integrations/marketplaces/ebay/shippingServices.js +++ b/src/integrations/marketplaces/ebay/shippingServices.js @@ -1,4 +1,6 @@ import { logger, getMarketplaceDebugContext } from './shared.js'; +import { fetchEbayCategoryReferences } from './categoryTree.js'; +import { syncAccountPolicies } from './accountPolicySync.js'; const TRADING_COMPATIBILITY_LEVEL = '1399'; const TRADING_SANDBOX_URL = 'https://api.sandbox.ebay.com/ws/api.dll'; @@ -135,10 +137,13 @@ export async function fetchEbayShippingServices(marketplace) { export async function syncMarketplaceMetadata(marketplace) { const availableShippingServices = await fetchEbayShippingServices(marketplace); + const categoryReferences = await fetchEbayCategoryReferences(marketplace); + await syncAccountPolicies(marketplace); return { eBay: { ...(marketplace.eBay && typeof marketplace.eBay === 'object' ? marketplace.eBay : {}), availableShippingServices, + categoryReferences, }, }; } diff --git a/src/integrations/marketplaces/ebay/variationAspects.js b/src/integrations/marketplaces/ebay/variationAspects.js new file mode 100644 index 0000000..a152a79 --- /dev/null +++ b/src/integrations/marketplaces/ebay/variationAspects.js @@ -0,0 +1,88 @@ +export function toEbayProductAspects(aspects = []) { + const result = {}; + for (const aspect of aspects) { + const name = typeof aspect?.name === 'string' ? aspect.name.trim() : ''; + const value = aspect?.value == null ? '' : String(aspect.value).trim(); + if (!name || !value) continue; + if (!result[name]) result[name] = []; + if (!result[name].includes(value)) result[name].push(value); + } + return Object.keys(result).length ? result : undefined; +} + +export function fromEbayProductAspects(ebayAspects) { + if (!ebayAspects || typeof ebayAspects !== 'object' || Array.isArray(ebayAspects)) { + return []; + } + const result = []; + for (const [name, values] of Object.entries(ebayAspects)) { + const list = Array.isArray(values) ? values : [values]; + for (const value of list) { + if (value == null || String(value).trim() === '') continue; + result.push({ name, value: String(value) }); + } + } + return result; +} + +function skuLabel(varient) { + return varient?._reference || varient?.externalReference || 'unknown'; +} + +export function buildGroupVariesBy(varients = []) { + const perVarient = varients.map((varient) => { + const aspects = toEbayProductAspects(varient?.aspects) || {}; + const names = Object.keys(aspects); + if (!names.length) { + throw new Error( + `Listing varient "${skuLabel(varient)}" must have at least one aspect (e.g. Color, Size) before publishing a multi-variation eBay listing.` + ); + } + return { varient, aspects }; + }); + + const allNames = []; + for (const { aspects } of perVarient) { + for (const name of Object.keys(aspects)) { + if (!allNames.includes(name)) allNames.push(name); + } + } + + for (const { varient, aspects } of perVarient) { + for (const name of allNames) { + if (!aspects[name]?.length) { + throw new Error( + `Listing varient "${skuLabel(varient)}" is missing aspect "${name}". All varients in a listing must use the same aspect names.` + ); + } + } + } + + const specifications = []; + const sharedAspects = {}; + + for (const name of allNames) { + const uniqueValues = []; + for (const { aspects } of perVarient) { + for (const value of aspects[name]) { + if (!uniqueValues.includes(value)) uniqueValues.push(value); + } + } + if (uniqueValues.length >= 2) { + specifications.push({ name, values: uniqueValues }); + } else { + sharedAspects[name] = uniqueValues; + } + } + + if (!specifications.length) { + throw new Error( + 'Multi-variation eBay listings require varients that differ by at least one aspect (e.g. Color or Size).' + ); + } + + return { + variesBy: { specifications }, + ...(Object.keys(sharedAspects).length ? { aspects: sharedAspects } : {}), + }; +} diff --git a/src/integrations/marketplaceworker.js b/src/integrations/marketplaceworker.js index 5f91b97..7ee8b72 100644 --- a/src/integrations/marketplaceworker.js +++ b/src/integrations/marketplaceworker.js @@ -3,7 +3,16 @@ import log4js from 'log4js'; 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 { shipmentModel } from '../database/schemas/inventory/shipment.schema.js'; +import { paymentPolicyModel } from '../database/schemas/finance/paymentpolicy.schema.js'; +import { returnPolicyModel } from '../database/schemas/sales/returnpolicy.schema.js'; +import { fulfillmentPolicyModel } from '../database/schemas/sales/fulfillmentpolicy.schema.js'; +import { taxRateModel } from '../database/schemas/management/taxrate.schema.js'; +import { persistMarketplaceMapping } from './marketplaces/ebay/accountPolicies.js'; import { editObject } from '../database/database.js'; +import { dbConnect } from '../database/mongo.js'; +import { redisServer } from '../database/redis.js'; +import { natsServer } from '../database/nats.js'; import * as tiktokShop from './marketplaces/tiktokShop.js'; import * as ebay from './marketplaces/ebay/index.js'; import { @@ -35,7 +44,7 @@ export function hasIntegration(provider) { return !!providers[provider]; } -export async function publishMarketplaceOfferForSku(marketplace, user, sku, listing) { +export async function publishMarketplaceOfferForSku(marketplace, user, sku, listing, varient) { const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user); const provider = getProvider(authenticatedMarketplace); if (typeof provider.publishOfferForSku !== 'function') { @@ -43,10 +52,39 @@ export async function publishMarketplaceOfferForSku(marketplace, user, sku, list `Marketplace provider "${marketplace.provider}" does not support publishing offers` ); } - return provider.publishOfferForSku(authenticatedMarketplace, sku, listing); + return provider.publishOfferForSku(authenticatedMarketplace, sku, listing, varient); } -export async function withdrawMarketplaceOfferForSku(marketplace, user, sku) { +export async function ensureMarketplaceListingInventory(marketplace, user, listing, varients = []) { + const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user); + const provider = getProvider(authenticatedMarketplace); + if ( + typeof provider.updateItem !== 'function' || + typeof provider.publishOfferForSku !== 'function' + ) { + return null; + } + const fullListing = listing?._id ? await fetchFullListing(listing._id) : listing; + if (!fullListing) throw new Error('Listing not found'); + const listingVarients = + varients.length > 0 ? varients : listing?._id ? await fetchListingVarients(listing._id) : []; + return provider.updateItem(authenticatedMarketplace, fullListing, listingVarients); +} + +export async function syncMarketplaceListingImages(marketplace, user, listing, varients = []) { + const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user); + const provider = getProvider(authenticatedMarketplace); + if (typeof provider.syncListingImages !== 'function') { + return null; + } + const fullListing = listing?._id ? await fetchFullListing(listing._id) : listing; + if (!fullListing) throw new Error('Listing not found'); + const listingVarients = + varients.length > 0 ? varients : listing?._id ? await fetchListingVarients(listing._id) : []; + return provider.syncListingImages(authenticatedMarketplace, fullListing, listingVarients); +} + +export async function withdrawMarketplaceOfferForSku(marketplace, user, sku, listing) { const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user); const provider = getProvider(authenticatedMarketplace); if (typeof provider.withdrawOfferForSku !== 'function') { @@ -54,7 +92,7 @@ export async function withdrawMarketplaceOfferForSku(marketplace, user, sku) { `Marketplace provider "${marketplace.provider}" does not support withdrawing offers` ); } - return provider.withdrawOfferForSku(authenticatedMarketplace, sku); + return provider.withdrawOfferForSku(authenticatedMarketplace, sku, listing); } async function persistMarketplaceUpdate(marketplace, configUpdates, marketplaceUpdates, user) { @@ -207,7 +245,11 @@ export async function handleWebhook(marketplace, event, { rawBody, signature } = const actor = marketplaceActor(marketplace); if (signature && (await canVerifyWebhookSignature(marketplace))) { - const valid = await verifyWebhookSignature(marketplace, rawBody || JSON.stringify(event), signature); + const valid = await verifyWebhookSignature( + marketplace, + rawBody || JSON.stringify(event), + signature + ); if (!valid) { const error = new Error('Invalid webhook signature'); error.status = 401; @@ -240,6 +282,102 @@ export async function syncMarketplaceMetadata(marketplace, user) { return persistMarketplaceUpdate(authenticatedMarketplace, {}, metadataUpdates, user); } +export async function syncMarketplacePolicySet(marketplace, user, methodName) { + const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user); + const provider = getProvider(authenticatedMarketplace); + if (typeof provider[methodName] !== 'function') { + throw new Error( + `Marketplace provider "${marketplace.provider}" does not support ${methodName}` + ); + } + return provider[methodName](authenticatedMarketplace); +} + +export async function syncFulfillmentPolicies(marketplace, user) { + return syncMarketplacePolicySet(marketplace, user, 'syncFulfillmentPolicies'); +} + +export async function syncPaymentPolicies(marketplace, user) { + return syncMarketplacePolicySet(marketplace, user, 'syncPaymentPolicies'); +} + +export async function syncReturnPolicies(marketplace, user) { + return syncMarketplacePolicySet(marketplace, user, 'syncReturnPolicies'); +} + +export async function syncTaxRates(marketplace, user) { + return syncMarketplacePolicySet(marketplace, user, 'syncTaxRates'); +} + +async function syncOutboundAccountPolicy( + marketplace, + user, + { policyId, model, populate, methodName, label } +) { + const policy = await model.findById(policyId).populate(populate).lean(); + if (!policy) throw new Error(`${label} not found`); + + try { + const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user); + const provider = getProvider(authenticatedMarketplace); + if (typeof provider[methodName] !== 'function') { + throw new Error( + `Marketplace provider "${marketplace.provider}" does not support ${methodName}` + ); + } + return provider[methodName](authenticatedMarketplace, policy); + } catch (err) { + await persistMarketplaceMapping(model, policy, marketplace, { + stateType: 'failed', + message: err.message, + }); + throw err; + } +} + +export async function syncPaymentPolicyOutbound(marketplace, user, policyId) { + return syncOutboundAccountPolicy(marketplace, user, { + policyId, + model: paymentPolicyModel, + populate: ['marketplaces.marketplace'], + methodName: 'ensurePaymentPolicySynced', + label: 'Payment policy', + }); +} + +export async function syncReturnPolicyOutbound(marketplace, user, policyId) { + return syncOutboundAccountPolicy(marketplace, user, { + policyId, + model: returnPolicyModel, + populate: ['marketplaces.marketplace'], + methodName: 'ensureReturnPolicySynced', + label: 'Return policy', + }); +} + +export async function syncFulfillmentPolicyOutbound(marketplace, user, policyId) { + return syncOutboundAccountPolicy(marketplace, user, { + policyId, + model: fulfillmentPolicyModel, + populate: [ + { path: 'courierServices', populate: ['marketplaces.marketplace'] }, + 'marketplaces.marketplace', + ], + methodName: 'ensureFulfillmentPolicySynced', + label: 'Fulfillment policy', + }); +} + +export async function syncTaxRateOutbound(marketplace, user, policyId) { + return syncOutboundAccountPolicy(marketplace, user, { + policyId, + model: taxRateModel, + populate: ['marketplaces.marketplace'], + methodName: 'ensureTaxRateSynced', + label: 'Tax rate', + }); +} + export async function ensureWebhookSubscriptions(marketplace, user) { const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user); const provider = getProvider(authenticatedMarketplace); @@ -265,9 +403,19 @@ export async function debugMarketplaceGet(marketplace, user, path, params = {}) }); } -async function setListingState(listingId, stateType, user, message) { +function roundProgress(progress) { + const clamped = Math.min(1, Math.max(0, Number(progress) || 0)); + return Math.round(clamped * 1000) / 1000; +} + +async function setListingState(listingId, stateType, user, messageOrOptions) { + const options = + typeof messageOrOptions === 'string' ? { message: messageOrOptions } : messageOrOptions || {}; const state = { type: stateType }; - if (message) state.message = message; + if (options.message) state.message = options.message; + if (options.progress != null && Number.isFinite(Number(options.progress))) { + state.progress = roundProgress(options.progress); + } return editObject({ model: listingModel, id: listingId, @@ -308,15 +456,37 @@ async function recalculateMarketplaceState(marketplace, user) { async function fetchFullListing(listingId) { return listingModel .findById(listingId) - .populate(['product', 'vendor', 'stockLocation', 'marketplace', 'courierServices']) + .populate([ + 'product', + 'vendor', + 'stockLocation', + 'courierServices', + { + path: 'marketplace', + populate: ['defaultFulfillmentPolicy', 'defaultPaymentPolicy', 'defaultReturnPolicy'], + }, + { + path: 'fulfillmentPolicy', + populate: ['courierServices', 'marketplaces.marketplace'], + }, + { + path: 'paymentPolicy', + populate: ['marketplaces.marketplace'], + }, + { + path: 'returnPolicy', + populate: ['marketplaces.marketplace'], + }, + 'listingImages', + ]) .lean(); } async function fetchListingVarients(listingId) { - return listingVarientModel.find({ listing: listingId }).lean(); + return listingVarientModel.find({ listing: listingId }).populate('listingImages').lean(); } -export function createListing(marketplace, user, listingData) { +export async function createListing(marketplace, user, listingData) { const provider = getProvider(marketplace); if (!provider.createItem) { logger.debug(`Provider ${marketplace.provider} does not support createItem — skipping`); @@ -324,59 +494,54 @@ export function createListing(marketplace, user, listingData) { } if (listingData._id) { - setListingState(listingData._id, 'syncing', user).catch((err) => + await setListingState(listingData._id, 'syncing', user).catch((err) => logger.warn(`Failed to set listing syncing state: ${err.message}`) ); } - const work = async () => { - try { - const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user); - const fullListing = listingData._id ? await fetchFullListing(listingData._id) : listingData; - if (!fullListing) throw new Error('Listing not found'); + try { + const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user); + const fullListing = listingData._id ? await fetchFullListing(listingData._id) : listingData; + if (!fullListing) throw new Error('Listing not found'); - const varients = listingData._id ? await fetchListingVarients(listingData._id) : []; + const varients = listingData._id ? await fetchListingVarients(listingData._id) : []; - logger.info( - `Creating listing on marketplace "${marketplace.name}" (${marketplace.provider})` - ); - const result = await provider.createItem(authenticatedMarketplace, fullListing, varients); + logger.info(`Creating listing on marketplace "${marketplace.name}" (${marketplace.provider})`); + const result = await provider.createItem(authenticatedMarketplace, fullListing, varients); - if (listingData._id) { - const updateData = { lastSyncedAt: new Date(), state: { type: 'active' } }; - if (result?.url) updateData.url = result.url; - if (result?.externalReference) updateData.externalReference = result.externalReference; - await editObject({ model: listingModel, id: listingData._id, updateData, user }); + if (listingData._id) { + const updateData = { lastSyncedAt: new Date(), state: { type: 'active' } }; + if (result?.url) updateData.url = result.url; + if (result?.externalReference) updateData.externalReference = result.externalReference; + await editObject({ model: listingModel, id: listingData._id, updateData, user }); - for (const varient of varients) { - const varientUpdate = { lastSyncedAt: new Date(), state: { type: 'active' } }; - if (!varient.externalReference && marketplaceSku(varient)) { - varientUpdate.externalReference = marketplaceSku(varient); - } - await editObject({ - model: listingVarientModel, - id: varient._id, - updateData: varientUpdate, - user, - }).catch(() => {}); + for (const varient of varients) { + const varientUpdate = { lastSyncedAt: new Date(), state: { type: 'active' } }; + if (!varient.externalReference && marketplaceSku(varient)) { + varientUpdate.externalReference = marketplaceSku(varient); } - } - - logger.info(`Background createListing complete for marketplace "${marketplace.name}"`); - } catch (err) { - logger.error( - `Background createListing failed for marketplace "${marketplace.name}": ${err.message}` - ); - if (listingData._id) { - await setListingState(listingData._id, 'draft', user, err.message).catch(() => {}); + await editObject({ + model: listingVarientModel, + id: varient._id, + updateData: varientUpdate, + user, + }).catch(() => {}); } } - }; - work(); + logger.info(`Background createListing complete for marketplace "${marketplace.name}"`); + } catch (err) { + logger.error( + `Background createListing failed for marketplace "${marketplace.name}": ${err.message}` + ); + if (listingData._id) { + await setListingState(listingData._id, 'draft', user, err.message).catch(() => {}); + } + throw err; + } } -export function updateListing(marketplace, user, listingData) { +export async function updateListing(marketplace, user, listingData) { const provider = getProvider(marketplace); if (!provider.updateItem) { logger.debug(`Provider ${marketplace.provider} does not support updateItem — skipping`); @@ -384,283 +549,266 @@ export function updateListing(marketplace, user, listingData) { } if (listingData._id) { - setListingState(listingData._id, 'syncing', user).catch((err) => + await setListingState(listingData._id, 'syncing', user).catch((err) => logger.warn(`Failed to set listing syncing state: ${err.message}`) ); } - const work = async () => { - try { - const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user); - const fullListing = listingData._id ? await fetchFullListing(listingData._id) : listingData; - if (!fullListing) throw new Error('Listing not found'); + try { + const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user); + const fullListing = listingData._id ? await fetchFullListing(listingData._id) : listingData; + if (!fullListing) throw new Error('Listing not found'); - const varients = listingData._id ? await fetchListingVarients(listingData._id) : []; + const varients = listingData._id ? await fetchListingVarients(listingData._id) : []; - logger.info( - `Updating listing on marketplace "${marketplace.name}" (${marketplace.provider})` - ); - const result = await provider.updateItem(authenticatedMarketplace, fullListing, varients); + logger.info(`Updating listing on marketplace "${marketplace.name}" (${marketplace.provider})`); + const result = await provider.updateItem(authenticatedMarketplace, fullListing, varients); - if (listingData._id) { - const updateData = { state: { type: 'active' }, lastSyncedAt: new Date() }; - if (result?.url) updateData.url = result.url; - if (result?.externalReference) updateData.externalReference = result.externalReference; - await editObject({ - model: listingModel, - id: listingData._id, - updateData, - user, - }); + if (listingData._id) { + const updateData = { state: { type: 'active' }, lastSyncedAt: new Date() }; + if (result?.url) updateData.url = result.url; + if (result?.externalReference) updateData.externalReference = result.externalReference; + await editObject({ + model: listingModel, + id: listingData._id, + updateData, + user, + }); - for (const varient of varients) { - const varientUpdate = { lastSyncedAt: new Date(), state: { type: 'active' } }; - if (!varient.externalReference && marketplaceSku(varient)) { - varientUpdate.externalReference = marketplaceSku(varient); - } - await editObject({ - model: listingVarientModel, - id: varient._id, - updateData: varientUpdate, - user, - }).catch(() => {}); + for (const varient of varients) { + const varientUpdate = { lastSyncedAt: new Date(), state: { type: 'active' } }; + if (!varient.externalReference && marketplaceSku(varient)) { + varientUpdate.externalReference = marketplaceSku(varient); } - } - - logger.info(`Background updateListing complete for marketplace "${marketplace.name}"`); - } catch (err) { - logger.error( - `Background updateListing failed for marketplace "${marketplace.name}": ${err.message}` - ); - if (listingData._id) { - await setListingState(listingData._id, 'active', user, err.message).catch(() => {}); + await editObject({ + model: listingVarientModel, + id: varient._id, + updateData: varientUpdate, + user, + }).catch(() => {}); } } - }; - work(); + logger.info(`Background updateListing complete for marketplace "${marketplace.name}"`); + } catch (err) { + logger.error( + `Background updateListing failed for marketplace "${marketplace.name}": ${err.message}` + ); + if (listingData._id) { + await setListingState(listingData._id, 'active', user, err.message).catch(() => {}); + } + throw err; + } } -export function deleteListing(marketplace, user, listingData) { +export async function deleteListing(marketplace, user, listingData) { const provider = getProvider(marketplace); if (!provider.deleteItem) { logger.debug(`Provider ${marketplace.provider} does not support deleteItem — skipping`); return; } - const work = async () => { - try { - const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user); - logger.info( - `Deleting listing from marketplace "${marketplace.name}" (${marketplace.provider})` - ); - const varients = listingData._id - ? await fetchListingVarients(listingData._id) - : []; - await provider.deleteItem(authenticatedMarketplace, listingData, varients); - logger.info(`Background deleteListing complete for marketplace "${marketplace.name}"`); - } catch (err) { - logger.error( - `Background deleteListing failed for marketplace "${marketplace.name}": ${err.message}` - ); - } - }; - - work(); + try { + const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user); + logger.info( + `Deleting listing from marketplace "${marketplace.name}" (${marketplace.provider})` + ); + const varients = listingData?._id + ? await fetchListingVarients(listingData._id).catch(() => listingData.varients || []) + : listingData?.varients || []; + await provider.deleteItem(authenticatedMarketplace, listingData, varients); + logger.info(`Background deleteListing complete for marketplace "${marketplace.name}"`); + } catch (err) { + logger.error( + `Background deleteListing failed for marketplace "${marketplace.name}": ${err.message}` + ); + throw err; + } } -export function syncItems(marketplace, user) { - setMarketplaceState(marketplace._id, 'syncing', user).catch((err) => +export async function syncItems(marketplace, user) { + await setMarketplaceState(marketplace._id, 'syncing', user).catch((err) => logger.warn(`Failed to set marketplace syncing state: ${err.message}`) ); - const work = async () => { - try { - const provider = getProvider(marketplace); - const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user); - const actor = marketplaceActor(authenticatedMarketplace); - logger.info( - `Starting item sync for marketplace "${marketplace.name}" (${marketplace.provider})` - ); + try { + const provider = getProvider(marketplace); + const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user); + const actor = marketplaceActor(authenticatedMarketplace); + logger.info( + `Starting item sync for marketplace "${marketplace.name}" (${marketplace.provider})` + ); - if (typeof provider.syncMarketplaceMetadata === 'function') { - try { - const metadataUpdates = await provider.syncMarketplaceMetadata(authenticatedMarketplace); - if (metadataUpdates && Object.keys(metadataUpdates).length) { - Object.assign( - authenticatedMarketplace, - await persistMarketplaceUpdate(authenticatedMarketplace, {}, metadataUpdates, user) - ); - } - } catch (err) { - logger.warn( - `Failed to sync marketplace metadata for "${marketplace.name}": ${err.message}` + 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); + await importExternalItems(authenticatedMarketplace, provider, actor); - const existingListings = await listingModel - .find({ - marketplace: authenticatedMarketplace._id, - 'state.type': { $ne: 'deleted' }, - }) - .lean(); - for (const listing of existingListings) { - await setListingState(listing._id, 'syncing', user).catch(() => {}); - } + const existingListings = await listingModel + .find({ + marketplace: authenticatedMarketplace._id, + 'state.type': { $ne: 'deleted' }, + }) + .lean(); + for (const listing of existingListings) { + await setListingState(listing._id, 'syncing', user).catch(() => {}); + } - const existingVarients = await listingVarientModel - .find({ - listing: { $in: existingListings.map((l) => l._id) }, - }) - .lean(); - for (const varient of existingVarients) { - await setListingVarientState(varient._id, 'syncing', user).catch(() => {}); - } + const existingVarients = await listingVarientModel + .find({ + listing: { $in: existingListings.map((l) => l._id) }, + }) + .lean(); + for (const varient of existingVarients) { + await setListingVarientState(varient._id, 'syncing', user).catch(() => {}); + } - const results = []; + const results = []; - for (const listing of existingListings) { - try { - const listingVarients = existingVarients.filter( - (varient) => String(varient.listing) === String(listing._id) - ); + for (const listing of existingListings) { + try { + const listingVarients = existingVarients.filter( + (varient) => String(varient.listing) === String(listing._id) + ); - if (!listingVarients.length) { - throw new Error('Listing has no varients to sync'); - } + if (!listingVarients.length) { + throw new Error('Listing has no varients to sync'); + } - const result = await provider.updateItem( - authenticatedMarketplace, - listing, - listingVarients - ); + const result = await provider.updateItem( + authenticatedMarketplace, + listing, + listingVarients + ); - const listingUpdateData = { - lastSyncedAt: new Date(), - state: listing.state || { type: 'draft' }, - }; - if (result?.url) { - listingUpdateData.url = result.url; - } - if (result?.externalReference) { - listingUpdateData.externalReference = result.externalReference; - } + const listingUpdateData = { + lastSyncedAt: new Date(), + state: listing.state || { type: 'draft' }, + }; + if (result?.url) { + listingUpdateData.url = result.url; + } + if (result?.externalReference) { + listingUpdateData.externalReference = result.externalReference; + } + await editObject({ + model: listingModel, + id: listing._id, + updateData: listingUpdateData, + user, + }); + + for (const varient of listingVarients) { await editObject({ - model: listingModel, - id: listing._id, - updateData: listingUpdateData, + model: listingVarientModel, + id: varient._id, + updateData: { + lastSyncedAt: new Date(), + state: varient.state || { type: 'draft' }, + ...(!varient.externalReference && marketplaceSku(varient) + ? { externalReference: marketplaceSku(varient) } + : {}), + }, user, - }); - - for (const varient of listingVarients) { - await editObject({ - model: listingVarientModel, - id: varient._id, - updateData: { - lastSyncedAt: new Date(), - state: varient.state || { type: 'draft' }, - ...(!varient.externalReference && marketplaceSku(varient) - ? { externalReference: marketplaceSku(varient) } - : {}), - }, - user, - }).catch(() => {}); - } - - results.push({ _reference: listing._reference, action: 'synced', id: listing._id }); - } catch (err) { - logger.warn(`Failed to sync listing ${listing._reference}: ${err.message}`); - await setListingState( - listing._id, - listing.state?.type || 'draft', - user, - err.message - ).catch(() => {}); - results.push({ - _reference: listing._reference, - action: 'error', - error: err.message, - }); + }).catch(() => {}); } + + results.push({ _reference: listing._reference, action: 'synced', id: listing._id }); + } catch (err) { + logger.warn(`Failed to sync listing ${listing._reference}: ${err.message}`); + await setListingState(listing._id, listing.state?.type || 'draft', user, err.message).catch( + () => {} + ); + results.push({ + _reference: listing._reference, + action: 'error', + error: err.message, + }); } - - logger.info( - `Item sync complete for marketplace ${marketplace.name}: ${results.length} processed` - ); - - await recalculateMarketplaceState(marketplace, user); - } catch (err) { - logger.error( - `Background syncItems failed for marketplace "${marketplace.name}": ${err.message}` - ); - await recalculateMarketplaceState(marketplace, user); } - }; - work(); + logger.info( + `Item sync complete for marketplace ${marketplace.name}: ${results.length} processed` + ); + + await recalculateMarketplaceState(marketplace, user); + } catch (err) { + logger.error( + `Background syncItems failed for marketplace "${marketplace.name}": ${err.message}` + ); + await recalculateMarketplaceState(marketplace, user); + throw err; + } } -export function syncOrders(marketplace, user, { startTime, endTime } = {}) { - setMarketplaceState(marketplace._id, 'syncing', user).catch((err) => +export async function syncOrders(marketplace, user, { startTime, endTime } = {}) { + await setMarketplaceState(marketplace._id, 'syncing', user).catch((err) => logger.warn(`Failed to set marketplace syncing state: ${err.message}`) ); - const work = async () => { - try { - const provider = getProvider(marketplace); - const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user); - const actor = marketplaceActor(authenticatedMarketplace); - logger.info( - `Starting order sync for marketplace "${marketplace.name}" (${marketplace.provider})` - ); + try { + const provider = getProvider(marketplace); + const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user); + const actor = marketplaceActor(authenticatedMarketplace); + logger.info( + `Starting order sync for marketplace "${marketplace.name}" (${marketplace.provider})` + ); - const externalOrders = await provider.syncOrders(authenticatedMarketplace, { - startTime, - endTime, - }); - const results = []; + const externalOrders = await provider.syncOrders(authenticatedMarketplace, { + startTime, + endTime, + }); + const results = []; - for (const externalOrder of externalOrders) { - try { - const result = await upsertExternalOrder( - authenticatedMarketplace, - provider, - externalOrder, - actor - ); - results.push({ - externalReference: result.externalReference, - action: result.action, - id: result.salesOrder?._id, - }); - } catch (err) { - logger.warn(`Failed to process order: ${err.message}`); - results.push({ - externalId: externalOrder.id || externalOrder.orderId, - action: 'error', - error: err.message, - }); - } + for (const externalOrder of externalOrders) { + try { + const result = await upsertExternalOrder( + authenticatedMarketplace, + provider, + externalOrder, + actor + ); + results.push({ + externalReference: result.externalReference, + action: result.action, + id: result.salesOrder?._id, + }); + } catch (err) { + logger.warn(`Failed to process order: ${err.message}`); + results.push({ + externalId: externalOrder.id || externalOrder.orderId, + action: 'error', + error: err.message, + }); } - - logger.info( - `Order sync complete for marketplace ${marketplace.name}: ${results.length} processed` - ); - - await recalculateMarketplaceState(marketplace, user); - } catch (err) { - logger.error( - `Background syncOrders failed for marketplace "${marketplace.name}": ${err.message}` - ); - await recalculateMarketplaceState(marketplace, user); } - }; - work(); + logger.info( + `Order sync complete for marketplace ${marketplace.name}: ${results.length} processed` + ); + + await recalculateMarketplaceState(marketplace, user); + } catch (err) { + logger.error( + `Background syncOrders failed for marketplace "${marketplace.name}": ${err.message}` + ); + await recalculateMarketplaceState(marketplace, user); + throw err; + } } export async function pushMarketplaceShipmentFulfillment(marketplace, user, shipment) { @@ -674,4 +822,374 @@ export async function pushMarketplaceShipmentFulfillment(marketplace, user, ship ); } +function userRef(userId) { + if (!userId) return null; + return { _id: userId }; +} + +async function loadMarketplace(marketplaceId) { + if (!marketplaceId) throw new Error('Marketplace id is required'); + const marketplace = await marketplaceModel.findById(marketplaceId); + if (!marketplace) throw new Error('Marketplace not found'); + return marketplace; +} + +export async function publishListingOffers({ + listingId, + userId, + varientIds, + restoreStateType = 'draft', + varientRestoreStateType = 'draft', +}) { + const user = userRef(userId); + const listing = await fetchFullListing(listingId); + if (!listing) throw new Error('Listing not found'); + + const marketplace = listing.marketplace?._id + ? listing.marketplace + : await loadMarketplace(listing.marketplace); + if (!marketplace) throw new Error('Listing has no marketplace'); + + const allVarients = await fetchListingVarients(listingId); + const selectedIds = + Array.isArray(varientIds) && varientIds.length > 0 + ? new Set(varientIds.map((id) => String(id))) + : null; + const toPublish = allVarients.filter((varient) => { + if (!varient._reference) return false; + if (selectedIds && !selectedIds.has(String(varient._id))) return false; + return varient.state?.type !== 'active'; + }); + + if (toPublish.length === 0) { + throw new Error('No variants to publish (all are already active or missing SKU).'); + } + + await setListingState(listingId, 'publishing', user, { progress: 0.05 }); + for (const varient of toPublish) { + await setListingVarientState(varient._id, 'publishing', user).catch(() => {}); + } + + try { + await ensureMarketplaceListingInventory(marketplace, user, listing, allVarients); + await setListingState(listingId, 'publishing', user, { progress: 0.15 }); + + let publishedListingId = listing.externalReference; + let publishedUrl = listing.url; + for (let i = 0; i < toPublish.length; i += 1) { + const varient = toPublish[i]; + const apiResult = await publishMarketplaceOfferForSku( + marketplace, + user, + marketplaceSku(varient), + listing, + varient + ); + await editObject({ + model: listingVarientModel, + id: varient._id, + updateData: { + updatedAt: new Date(), + state: { type: 'active' }, + lastSyncedAt: new Date(), + }, + user, + recalculate: false, + }); + if (apiResult?.listingId) { + publishedListingId = apiResult.listingId; + listing.externalReference = apiResult.listingId; + } else if (apiResult?.externalReference) { + publishedListingId = apiResult.externalReference; + listing.externalReference = apiResult.externalReference; + } + if (apiResult?.url) { + publishedUrl = apiResult.url; + listing.url = apiResult.url; + } + await setListingState(listingId, 'publishing', user, { + progress: 0.15 + (0.8 * (i + 1)) / toPublish.length, + }); + } + + const listingUpdate = { + updatedAt: new Date(), + state: { type: 'active' }, + lastSyncedAt: new Date(), + }; + if (publishedListingId) listingUpdate.externalReference = publishedListingId; + if (publishedUrl) listingUpdate.url = publishedUrl; + await editObject({ + model: listingModel, + id: listingId, + updateData: listingUpdate, + user, + }); + logger.info(`Background publishListing complete for listing ${listingId}`); + } catch (err) { + logger.error(`Background publishListing failed for listing ${listingId}: ${err.message}`); + await setListingState(listingId, restoreStateType, user, err.message).catch(() => {}); + for (const varient of toPublish) { + await setListingVarientState(varient._id, varientRestoreStateType, user, err.message).catch( + () => {} + ); + } + throw err; + } +} + +export async function unpublishListingOffers({ + listingId, + userId, + varientIds, + restoreStateType = 'active', + varientRestoreStateType = 'active', +}) { + const user = userRef(userId); + const listing = await fetchFullListing(listingId); + if (!listing) throw new Error('Listing not found'); + + const marketplace = listing.marketplace?._id + ? listing.marketplace + : await loadMarketplace(listing.marketplace); + if (!marketplace) throw new Error('Listing has no marketplace'); + + const allVarients = await fetchListingVarients(listingId); + const selectedIds = + Array.isArray(varientIds) && varientIds.length > 0 + ? new Set(varientIds.map((id) => String(id))) + : null; + const toUnpublish = allVarients.filter((varient) => { + if (!varient._reference) return false; + if (selectedIds && !selectedIds.has(String(varient._id))) return false; + return varient.state?.type === 'active' || varient.state?.type === 'unpublishing'; + }); + + if (toUnpublish.length === 0) { + throw new Error('No active variants to unpublish.'); + } + + await setListingState(listingId, 'unpublishing', user, { progress: 0.05 }); + for (const varient of toUnpublish) { + await setListingVarientState(varient._id, 'unpublishing', user).catch(() => {}); + } + + try { + await syncMarketplaceListingImages(marketplace, user, listing, allVarients); + await setListingState(listingId, 'unpublishing', user, { progress: 0.15 }); + + for (let i = 0; i < toUnpublish.length; i += 1) { + const varient = toUnpublish[i]; + await withdrawMarketplaceOfferForSku(marketplace, user, marketplaceSku(varient), listing); + await editObject({ + model: listingVarientModel, + id: varient._id, + updateData: { + updatedAt: new Date(), + state: { type: 'draft' }, + lastSyncedAt: new Date(), + }, + user, + recalculate: false, + }); + await setListingState(listingId, 'unpublishing', user, { + progress: 0.15 + (0.8 * (i + 1)) / toUnpublish.length, + }); + } + + const remainingActive = await listingVarientModel.exists({ + listing: listingId, + 'state.type': 'active', + }); + await editObject({ + model: listingModel, + id: listingId, + updateData: { + updatedAt: new Date(), + state: { type: remainingActive ? 'active' : 'draft' }, + lastSyncedAt: new Date(), + }, + user, + }); + logger.info(`Background unpublishListing complete for listing ${listingId}`); + } catch (err) { + logger.error(`Background unpublishListing failed for listing ${listingId}: ${err.message}`); + await setListingState(listingId, restoreStateType, user, err.message).catch(() => {}); + for (const varient of toUnpublish) { + await setListingVarientState(varient._id, varientRestoreStateType, user, err.message).catch( + () => {} + ); + } + throw err; + } +} + +export async function runJob(action, payload = {}) { + const user = userRef(payload.userId); + + switch (action) { + case 'createListing': { + const marketplace = await loadMarketplace(payload.marketplaceId); + return createListing(marketplace, user, { _id: payload.listingId }); + } + case 'updateListing': { + const marketplace = await loadMarketplace(payload.marketplaceId); + return updateListing(marketplace, user, { _id: payload.listingId }); + } + case 'deleteListing': { + const marketplace = await loadMarketplace(payload.marketplaceId); + return deleteListing(marketplace, user, payload.listing); + } + case 'publishListing': + return publishListingOffers(payload); + case 'unpublishListing': + return unpublishListingOffers(payload); + case 'syncItems': { + const marketplace = await loadMarketplace(payload.marketplaceId); + return syncItems(marketplace, user); + } + case 'syncOrders': { + const marketplace = await loadMarketplace(payload.marketplaceId); + return syncOrders(marketplace, user, { + startTime: payload.startTime, + endTime: payload.endTime, + }); + } + case 'syncMarketplaceMetadata': { + const marketplace = await loadMarketplace(payload.marketplaceId); + return syncMarketplaceMetadata(marketplace, user); + } + case 'syncFulfillmentPolicies': { + const marketplace = await loadMarketplace(payload.marketplaceId); + return syncFulfillmentPolicies(marketplace, user); + } + case 'syncPaymentPolicies': { + const marketplace = await loadMarketplace(payload.marketplaceId); + return syncPaymentPolicies(marketplace, user); + } + case 'syncReturnPolicies': { + const marketplace = await loadMarketplace(payload.marketplaceId); + return syncReturnPolicies(marketplace, user); + } + case 'syncTaxRates': { + const marketplace = await loadMarketplace(payload.marketplaceId); + return syncTaxRates(marketplace, user); + } + case 'syncPaymentPolicy': { + const marketplace = await loadMarketplace(payload.marketplaceId); + return syncPaymentPolicyOutbound(marketplace, user, payload.policyId); + } + case 'syncReturnPolicy': { + const marketplace = await loadMarketplace(payload.marketplaceId); + return syncReturnPolicyOutbound(marketplace, user, payload.policyId); + } + case 'syncFulfillmentPolicy': { + const marketplace = await loadMarketplace(payload.marketplaceId); + return syncFulfillmentPolicyOutbound(marketplace, user, payload.policyId); + } + case 'syncTaxRate': { + const marketplace = await loadMarketplace(payload.marketplaceId); + return syncTaxRateOutbound(marketplace, user, payload.policyId); + } + case 'ensureWebhookSubscriptions': { + const marketplace = await loadMarketplace(payload.marketplaceId); + return ensureWebhookSubscriptions(marketplace, user); + } + case 'pushShipmentFulfillment': { + const marketplace = await loadMarketplace(payload.marketplaceId); + const shipment = payload.shipmentId + ? await shipmentModel.findById(payload.shipmentId).populate('courierService').lean() + : payload.shipment; + if (!shipment) throw new Error('Shipment not found'); + return pushMarketplaceShipmentFulfillment(marketplace, user, shipment); + } + case 'getAuthorizationUrl': { + const marketplace = await loadMarketplace(payload.marketplaceId); + return getAuthorizationUrl(marketplace, { state: payload.state }); + } + case 'exchangeAuthorizationCode': { + const marketplace = await loadMarketplace(payload.marketplaceId); + return exchangeAuthorizationCode(marketplace, user, { + code: payload.code, + state: payload.state, + }); + } + case 'refreshMarketplaceAuth': { + const marketplace = await loadMarketplace(payload.marketplaceId); + return refreshMarketplaceAuth(marketplace, user); + } + case 'handleWebhook': { + const marketplace = await loadMarketplace(payload.marketplaceId); + return handleWebhook(marketplace, payload.event, { + rawBody: payload.rawBody, + signature: payload.signature, + }); + } + case 'buildWebhookChallengeResponse': { + const marketplace = await loadMarketplace(payload.marketplaceId); + return buildWebhookChallengeResponse(marketplace, payload.query); + } + case 'canAuthorize': { + const marketplace = await loadMarketplace(payload.marketplaceId); + return canAuthorize(marketplace); + } + case 'canVerifyWebhookSignature': { + const marketplace = await loadMarketplace(payload.marketplaceId); + return canVerifyWebhookSignature(marketplace); + } + case 'verifyWebhookSignature': { + const marketplace = await loadMarketplace(payload.marketplaceId); + return verifyWebhookSignature(marketplace, payload.rawBody, payload.signature); + } + case 'debugMarketplaceGet': { + const marketplace = await loadMarketplace(payload.marketplaceId); + return debugMarketplaceGet(marketplace, user, payload.path, payload.params); + } + case 'hasIntegration': + return hasIntegration(payload.provider); + default: + throw new Error(`Unknown marketplace worker action: ${action}`); + } +} + +async function startWorkerProcess() { + logger.info('Starting marketplace worker process...'); + await dbConnect(); + await redisServer.connect(); + await natsServer.connect(); + + process.on('message', (message) => { + if (!message || message.type === 'ready') return; + const { id, action, payload, wait } = message; + if (!action) return; + + const job = runJob(action, payload || {}); + if (wait) { + job + .then((result) => { + process.send?.({ id, ok: true, result: result ?? null }); + }) + .catch((err) => { + logger.error(`Marketplace job "${action}" failed: ${err.message}`); + process.send?.({ id, ok: false, error: err.message }); + }); + return; + } + + job.catch((err) => { + logger.error(`Background marketplace job "${action}" failed: ${err.message}`); + }); + }); + + process.send?.({ type: 'ready' }); + logger.info('Marketplace worker process ready'); +} + +if (process.env.MARKETPLACE_WORKER === '1') { + startWorkerProcess().catch((err) => { + logger.error('Marketplace worker failed to start:', err); + process.exit(1); + }); +} + export { marketplaceSku, marketplaceActor }; diff --git a/src/routes/finance/invoices.js b/src/routes/finance/invoices.js index 8272795..5590e61 100644 --- a/src/routes/finance/invoices.js +++ b/src/routes/finance/invoices.js @@ -58,7 +58,7 @@ import { } from '../../services/finance/invoices.js'; // list of invoices -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('invoice', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters, true, invoiceModel); listInvoicesRouteHandler( diff --git a/src/routes/finance/paymentpolicies.js b/src/routes/finance/paymentpolicies.js new file mode 100644 index 0000000..108ec2d --- /dev/null +++ b/src/routes/finance/paymentpolicies.js @@ -0,0 +1,120 @@ +import express from 'express'; +import { isAuthenticated } from '../../keycloak.js'; +import { checkPermissions } from '../../database/permissions.js'; +import { getFilter, convertPropertiesString, getSort } from '../../utils.js'; +import { + listPaymentPoliciesRouteHandler, + getPaymentPolicyRouteHandler, + editPaymentPolicyRouteHandler, + newPaymentPolicyRouteHandler, + deletePaymentPolicyRouteHandler, + listPaymentPoliciesByPropertiesRouteHandler, + getPaymentPolicyStatsRouteHandler, + getPaymentPolicyHistoryRouteHandler, + searchPaymentPoliciesRouteHandler, + getPaymentPolicyPropertyValuesRouteHandler, + getPaymentPolicyNeighborsRouteHandler, +} from '../../services/finance/paymentpolicies.js'; + +const router = express.Router(); + +const listAllowedFilters = [ + 'name', + 'immediatePay', + 'marketplaces.marketplace', + 'createdAt', + 'updatedAt', + '_reference', +]; +const listAllowedSorters = ['name', 'immediatePay', 'createdAt', '_id', 'updatedAt']; +const propertiesAllowedFilters = ['name', 'immediatePay', 'marketplaces.marketplace']; + +router.get('/', isAuthenticated, checkPermissions('paymentPolicy', 'list'), async (req, res) => { + const { page, limit, property, search, sortProperty, sortOrder } = req.query; + const filter = await getFilter(req.query, listAllowedFilters); + listPaymentPoliciesRouteHandler( + req, + res, + page, + limit, + property, + filter, + search, + getSort(sortProperty, listAllowedSorters), + sortOrder + ); +}); + +router.get( + '/properties', + checkPermissions('paymentPolicy', 'list'), + isAuthenticated, + async (req, res) => { + const properties = convertPropertiesString(req.query.properties); + const filter = await getFilter(req.query, propertiesAllowedFilters, false); + let masterFilter = {}; + if (req.query.masterFilter) { + masterFilter = JSON.parse(req.query.masterFilter); + } + listPaymentPoliciesByPropertiesRouteHandler(req, res, properties, filter, masterFilter); + } +); + +router.get( + '/values', + checkPermissions('paymentPolicy', 'list'), + isAuthenticated, + async (req, res) => { + getPaymentPolicyPropertyValuesRouteHandler(req, res, req.query.property); + } +); + +router.get( + '/search', + checkPermissions('paymentPolicy', 'list'), + isAuthenticated, + async (req, res) => { + searchPaymentPoliciesRouteHandler(req, res, req.query.search); + } +); + +router.post('/', isAuthenticated, checkPermissions('paymentPolicy', 'new'), async (req, res) => { + newPaymentPolicyRouteHandler(req, res); +}); + +router.get('/stats', isAuthenticated, async (req, res) => { + getPaymentPolicyStatsRouteHandler(req, res); +}); + +router.get('/history', isAuthenticated, async (req, res) => { + getPaymentPolicyHistoryRouteHandler(req, res); +}); + +router.get('/neighbors', isAuthenticated, async (req, res) => { + const { property, search, sortProperty, sortOrder, id } = req.query; + const filter = await getFilter(req.query, listAllowedFilters); + getPaymentPolicyNeighborsRouteHandler( + req, + res, + property, + filter, + search, + getSort(sortProperty, listAllowedSorters), + sortOrder, + id + ); +}); + +router.get('/:id', isAuthenticated, async (req, res) => { + getPaymentPolicyRouteHandler(req, res); +}); + +router.put('/:id', isAuthenticated, checkPermissions('paymentPolicy', 'edit'), async (req, res) => { + editPaymentPolicyRouteHandler(req, res); +}); + +router.delete('/:id', isAuthenticated, async (req, res) => { + deletePaymentPolicyRouteHandler(req, res); +}); + +export default router; diff --git a/src/routes/finance/payments.js b/src/routes/finance/payments.js index 2ef6c56..62109c1 100644 --- a/src/routes/finance/payments.js +++ b/src/routes/finance/payments.js @@ -49,7 +49,7 @@ import { } from '../../services/finance/payments.js'; // list of payments -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('payment', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listPaymentsRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/finance/taxrecords.js b/src/routes/finance/taxrecords.js index e1eed15..9435fca 100644 --- a/src/routes/finance/taxrecords.js +++ b/src/routes/finance/taxrecords.js @@ -34,7 +34,7 @@ import { } from '../../services/finance/taxrecords.js'; // list of tax records -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('taxRecord', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listTaxRecordsRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/index.js b/src/routes/index.js index 4c33889..283161c 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -48,6 +48,9 @@ import salesOrderRoutes from './sales/salesorders.js'; import marketplaceRoutes from './sales/marketplaces.js'; import listingRoutes from './sales/listings.js'; import listingVarientRoutes from './sales/listingvarients.js'; +import fulfillmentPolicyRoutes from './sales/fulfillmentpolicies.js'; +import returnPolicyRoutes from './sales/returnpolicies.js'; +import paymentPolicyRoutes from './finance/paymentpolicies.js'; import noteRoutes from './misc/notes.js'; import userNotifierRoutes from './misc/usernotifiers.js'; import notificationRoutes from './misc/notifications.js'; @@ -112,6 +115,9 @@ export { marketplaceRoutes, listingRoutes, listingVarientRoutes, + fulfillmentPolicyRoutes, + returnPolicyRoutes, + paymentPolicyRoutes, userNotifierRoutes, notificationRoutes, odataRoutes, diff --git a/src/routes/inventory/filamentstocks.js b/src/routes/inventory/filamentstocks.js index be16ca3..f4c8e4f 100644 --- a/src/routes/inventory/filamentstocks.js +++ b/src/routes/inventory/filamentstocks.js @@ -44,7 +44,7 @@ import { } from '../../services/inventory/filamentstocks.js'; // list of filament stocks -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('filamentStock', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listFilamentStocksRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/inventory/orderitems.js b/src/routes/inventory/orderitems.js index f5281d9..97ef876 100644 --- a/src/routes/inventory/orderitems.js +++ b/src/routes/inventory/orderitems.js @@ -48,7 +48,7 @@ import { } from '../../services/inventory/orderitems.js'; // list of order items -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('orderItem', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listOrderItemsRouteHandler( diff --git a/src/routes/inventory/partstocks.js b/src/routes/inventory/partstocks.js index 8670064..4eedd5b 100644 --- a/src/routes/inventory/partstocks.js +++ b/src/routes/inventory/partstocks.js @@ -9,7 +9,6 @@ const listAllowedFilters = [ 'partSku', 'partSku._id', 'state', - 'startingQuantity', 'currentQuantity', 'stockLocation', 'stockLocation._id', @@ -17,7 +16,7 @@ const listAllowedFilters = [ 'updatedAt', '_reference', ]; -const listAllowedSorters = ['partSku', 'startingQuantity', 'currentQuantity', 'state', 'createdAt', 'updatedAt']; +const listAllowedSorters = ['partSku', 'currentQuantity', 'state', 'createdAt', 'updatedAt']; const propertiesAllowedFilters = ['part', 'state.type']; import { listPartStocksRouteHandler, @@ -26,6 +25,7 @@ import { editMultiplePartStocksRouteHandler, newPartStockRouteHandler, deletePartStockRouteHandler, + postPartStockRouteHandler, listPartStocksByPropertiesRouteHandler, getPartStockStatsRouteHandler, getPartStockHistoryRouteHandler, @@ -36,7 +36,7 @@ import { } from '../../services/inventory/partstocks.js'; // list of part stocks -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('partStock', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listPartStocksRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); @@ -99,4 +99,8 @@ router.delete('/:id', isAuthenticated, async (req, res) => { deletePartStockRouteHandler(req, res); }); +router.post('/:id/post', isAuthenticated, checkPermissions('partStock', 'post'), async (req, res) => { + postPartStockRouteHandler(req, res); +}); + export default router; diff --git a/src/routes/inventory/productstocks.js b/src/routes/inventory/productstocks.js index 2d9e5ac..4a2045e 100644 --- a/src/routes/inventory/productstocks.js +++ b/src/routes/inventory/productstocks.js @@ -35,7 +35,7 @@ import { getProductStockNeighborsRouteHandler } from '../../services/inventory/productstocks.js'; -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('productStock', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listProductStocksRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/inventory/purchaseorders.js b/src/routes/inventory/purchaseorders.js index 06deacd..e0a91c0 100644 --- a/src/routes/inventory/purchaseorders.js +++ b/src/routes/inventory/purchaseorders.js @@ -62,7 +62,7 @@ import { } from '../../services/inventory/purchaseorders.js'; // list of purchase orders -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('purchaseOrder', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listPurchaseOrdersRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/inventory/shipments.js b/src/routes/inventory/shipments.js index 2924cf5..25c60d9 100644 --- a/src/routes/inventory/shipments.js +++ b/src/routes/inventory/shipments.js @@ -53,7 +53,7 @@ import { } from '../../services/inventory/shipments.js'; // list of shipments -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('shipment', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listShipmentsRouteHandler( diff --git a/src/routes/inventory/stockaudits.js b/src/routes/inventory/stockaudits.js index ca46016..44b7052 100644 --- a/src/routes/inventory/stockaudits.js +++ b/src/routes/inventory/stockaudits.js @@ -28,7 +28,7 @@ const listAllowedFilters = [ const listAllowedSorters = ['createdAt', 'updatedAt', 'state']; // List stock audits -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('stockAudit', 'list'), async (req, res) => { const { page, limit, property } = req.query; var filter = {}; diff --git a/src/routes/inventory/stockevents.js b/src/routes/inventory/stockevents.js index f7b6b6f..2c4df13 100644 --- a/src/routes/inventory/stockevents.js +++ b/src/routes/inventory/stockevents.js @@ -25,7 +25,7 @@ import { } from '../../services/inventory/stockevents.js'; // list of stock events -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('stockEvent', 'list'), async (req, res) => { const { page, limit, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listStockEventsRouteHandler(req, res, page, limit, filter, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/inventory/stocklocations.js b/src/routes/inventory/stocklocations.js index 5d2fe30..176040b 100644 --- a/src/routes/inventory/stocklocations.js +++ b/src/routes/inventory/stocklocations.js @@ -24,7 +24,7 @@ import { getStockLocationNeighborsRouteHandler } from '../../services/inventory/stocklocations.js'; -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('stockLocation', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listStockLocationsRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/inventory/stocktransfers.js b/src/routes/inventory/stocktransfers.js index c8f494f..4f27fc1 100644 --- a/src/routes/inventory/stocktransfers.js +++ b/src/routes/inventory/stocktransfers.js @@ -33,7 +33,7 @@ import { getStockTransferNeighborsRouteHandler } from '../../services/inventory/stocktransfers.js'; -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('stockTransfer', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listStockTransfersRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/management/apppasswords.js b/src/routes/management/apppasswords.js index 4ebd70f..82ed023 100644 --- a/src/routes/management/apppasswords.js +++ b/src/routes/management/apppasswords.js @@ -33,7 +33,7 @@ import { getAppPasswordNeighborsRouteHandler } from '../../services/management/apppasswords.js'; -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('appPassword', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listAppPasswordsRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/management/auditlogs.js b/src/routes/management/auditlogs.js index ea1325a..e4ee9ea 100644 --- a/src/routes/management/auditlogs.js +++ b/src/routes/management/auditlogs.js @@ -22,7 +22,7 @@ const listAllowedFilters = [ ]; const listAllowedSorters = ['createdAt', 'updatedAt']; -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('auditLog', 'list'), async (req, res) => { const { page, limit, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listAuditLogsRouteHandler( diff --git a/src/routes/management/courier.js b/src/routes/management/courier.js index 527f048..7b974d9 100644 --- a/src/routes/management/courier.js +++ b/src/routes/management/courier.js @@ -34,7 +34,7 @@ import { } from '../../services/management/courier.js'; // list of couriers -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('courier', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listCouriersRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/management/courierservice.js b/src/routes/management/courierservice.js index 1af5c43..f8ec906 100644 --- a/src/routes/management/courierservice.js +++ b/src/routes/management/courierservice.js @@ -60,7 +60,7 @@ import { } from '../../services/management/courierservice.js'; // list of courier services -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('courierService', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listCourierServicesRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/management/documentjobs.js b/src/routes/management/documentjobs.js index 7bd279e..0e7684f 100644 --- a/src/routes/management/documentjobs.js +++ b/src/routes/management/documentjobs.js @@ -33,7 +33,7 @@ import { } from '../../services/management/documentjobs.js'; // list of document jobs -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('documentJob', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listDocumentJobsRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/management/documentprinters.js b/src/routes/management/documentprinters.js index 0d3c31d..475a83d 100644 --- a/src/routes/management/documentprinters.js +++ b/src/routes/management/documentprinters.js @@ -33,7 +33,7 @@ import { } from '../../services/management/documentprinters.js'; // list of document printers -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('documentPrinter', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listDocumentPrintersRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/management/documentsizes.js b/src/routes/management/documentsizes.js index 0454684..1a3fc61 100644 --- a/src/routes/management/documentsizes.js +++ b/src/routes/management/documentsizes.js @@ -48,7 +48,7 @@ import { } from '../../services/management/documentsizes.js'; // list of document sizes -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('documentSize', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listDocumentSizesRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/management/documenttemplates.js b/src/routes/management/documenttemplates.js index ec128d7..3682208 100644 --- a/src/routes/management/documenttemplates.js +++ b/src/routes/management/documenttemplates.js @@ -50,7 +50,7 @@ import { } from '../../services/management/documenttemplates.js'; // list of document templates -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('documentTemplate', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listDocumentTemplatesRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/management/filaments.js b/src/routes/management/filaments.js index 25aa990..f4662c1 100644 --- a/src/routes/management/filaments.js +++ b/src/routes/management/filaments.js @@ -43,7 +43,7 @@ import { } from '../../services/management/filaments.js'; // list of filaments -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('filament', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; diff --git a/src/routes/management/filamentskus.js b/src/routes/management/filamentskus.js index 1cb9a80..6c09176 100644 --- a/src/routes/management/filamentskus.js +++ b/src/routes/management/filamentskus.js @@ -43,7 +43,7 @@ import { getFilamentSkuNeighborsRouteHandler, } from '../../services/management/filamentskus.js'; -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('filamentSku', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listFilamentSkusRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/management/files.js b/src/routes/management/files.js index 988e0af..41ffa23 100644 --- a/src/routes/management/files.js +++ b/src/routes/management/files.js @@ -36,7 +36,7 @@ import { } from '../../services/management/files.js'; // list of files -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('file', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listFilesRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/management/hosts.js b/src/routes/management/hosts.js index 1cd7cca..051cb37 100644 --- a/src/routes/management/hosts.js +++ b/src/routes/management/hosts.js @@ -32,7 +32,7 @@ import { } from '../../services/management/hosts.js'; // list of hosts -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('host', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listHostsRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/management/materials.js b/src/routes/management/materials.js index 441c3c0..807bd96 100644 --- a/src/routes/management/materials.js +++ b/src/routes/management/materials.js @@ -23,7 +23,7 @@ import { } from '../../services/management/materials.js'; // list of materials -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('material', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; diff --git a/src/routes/management/notetypes.js b/src/routes/management/notetypes.js index 29c2774..8e164cd 100644 --- a/src/routes/management/notetypes.js +++ b/src/routes/management/notetypes.js @@ -32,7 +32,7 @@ import { } from '../../services/management/notetypes.js'; // list of note types -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('noteType', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listNoteTypesRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/management/parts.js b/src/routes/management/parts.js index aaa2271..48996a8 100644 --- a/src/routes/management/parts.js +++ b/src/routes/management/parts.js @@ -33,7 +33,7 @@ import { } from '../../services/management/parts.js'; // list of parts -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('part', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listPartsRouteHandler( diff --git a/src/routes/management/partskus.js b/src/routes/management/partskus.js index 07b8e3c..3b14a45 100644 --- a/src/routes/management/partskus.js +++ b/src/routes/management/partskus.js @@ -44,7 +44,7 @@ import { getPartSkuNeighborsRouteHandler } from '../../services/management/partskus.js'; -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('partSku', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listPartSkusRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/management/permissionsetting.js b/src/routes/management/permissionsetting.js index 19e7039..af49af4 100644 --- a/src/routes/management/permissionsetting.js +++ b/src/routes/management/permissionsetting.js @@ -22,7 +22,7 @@ import { getPermissionSettingsNeighborsRouteHandler, } from '../../services/management/permissionsetting.js'; -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('permissionSetting', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listPermissionSettingsRouteHandler( diff --git a/src/routes/management/productcategories.js b/src/routes/management/productcategories.js index c1d0a2b..70e5fae 100644 --- a/src/routes/management/productcategories.js +++ b/src/routes/management/productcategories.js @@ -19,11 +19,18 @@ import { const router = express.Router(); -const listAllowedFilters = ['_id', 'name', 'createdAt', 'updatedAt', '_reference']; +const listAllowedFilters = [ + '_id', + 'name', + 'createdAt', + 'updatedAt', + '_reference', + 'marketplaces.marketplace', +]; const listAllowedSorters = ['name', 'createdAt', 'updatedAt', '_id']; -const propertiesAllowedFilters = ['name']; +const propertiesAllowedFilters = ['name', 'marketplaces.marketplace']; -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('productCategory', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listProductCategoriesRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/management/products.js b/src/routes/management/products.js index 8dca584..76e2012 100644 --- a/src/routes/management/products.js +++ b/src/routes/management/products.js @@ -44,7 +44,7 @@ import { } from '../../services/management/products.js'; // list of products -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('product', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listProductsRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/management/productskus.js b/src/routes/management/productskus.js index 00ddf0f..5898cd7 100644 --- a/src/routes/management/productskus.js +++ b/src/routes/management/productskus.js @@ -44,7 +44,7 @@ import { getProductSkuNeighborsRouteHandler } from '../../services/management/productskus.js'; -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('productSku', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listProductSkusRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/management/taxrates.js b/src/routes/management/taxrates.js index 7f13d72..97a9dee 100644 --- a/src/routes/management/taxrates.js +++ b/src/routes/management/taxrates.js @@ -11,6 +11,8 @@ const listAllowedFilters = [ 'rateType', 'active', 'country', + 'jurisdiction', + 'marketplaces.marketplace', 'createdAt', 'updatedAt', '_reference', @@ -21,11 +23,12 @@ const listAllowedSorters = [ 'rateType', 'active', 'country', + 'jurisdiction', 'createdAt', '_id', 'updatedAt', ]; -const propertiesAllowedFilters = ['rateType', 'country', 'active']; +const propertiesAllowedFilters = ['rateType', 'country', 'jurisdiction', 'active']; import { listTaxRatesRouteHandler, getTaxRateRouteHandler, @@ -42,7 +45,7 @@ import { } from '../../services/management/taxrates.js'; // list of tax rates -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('taxRate', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listTaxRatesRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/management/usergroups.js b/src/routes/management/usergroups.js index 9c5c771..a0f8ab6 100644 --- a/src/routes/management/usergroups.js +++ b/src/routes/management/usergroups.js @@ -22,7 +22,7 @@ import { getUserGroupNeighborsRouteHandler, } from '../../services/management/usergroups.js'; -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('userGroup', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listUserGroupsRouteHandler( diff --git a/src/routes/management/users.js b/src/routes/management/users.js index bdd3dd8..5fd230e 100644 --- a/src/routes/management/users.js +++ b/src/routes/management/users.js @@ -34,7 +34,7 @@ import { } from '../../services/management/users.js'; // list of document templates -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('user', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listUsersRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/management/vendors.js b/src/routes/management/vendors.js index 18fdb12..6bea367 100644 --- a/src/routes/management/vendors.js +++ b/src/routes/management/vendors.js @@ -32,7 +32,7 @@ import { } from '../../services/management/vendors.js'; // list of vendors -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('vendor', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listVendorsRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/production/filamentprofiles.js b/src/routes/production/filamentprofiles.js index 4ab3557..57ec28b 100644 --- a/src/routes/production/filamentprofiles.js +++ b/src/routes/production/filamentprofiles.js @@ -20,7 +20,7 @@ const listAllowedFilters = ['_id', 'name', 'createdAt', 'updatedAt', '_reference const listAllowedSorters = ['name', 'createdAt', 'updatedAt']; const propertiesAllowedFilters = ['name']; -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('filamentProfile', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listFilamentProfilesRouteHandler( diff --git a/src/routes/production/gcodefiles.js b/src/routes/production/gcodefiles.js index 41e714f..556a7f0 100644 --- a/src/routes/production/gcodefiles.js +++ b/src/routes/production/gcodefiles.js @@ -33,7 +33,7 @@ import { import { convertPropertiesString, getFilter, getSort } from '../../utils.js'; // list of gcodeFiles -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('gcodeFile', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listGCodeFilesRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/production/jobs.js b/src/routes/production/jobs.js index 9eedc12..12fc92c 100644 --- a/src/routes/production/jobs.js +++ b/src/routes/production/jobs.js @@ -42,7 +42,7 @@ import { import { convertPropertiesString, getFilter, getSort } from '../../utils.js'; // list of jobs -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('job', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listJobsRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/production/printerprofiles.js b/src/routes/production/printerprofiles.js index c758946..556f7fd 100644 --- a/src/routes/production/printerprofiles.js +++ b/src/routes/production/printerprofiles.js @@ -29,7 +29,7 @@ const listAllowedSorters = [ ]; const propertiesAllowedFilters = ['name']; -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('printerProfile', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listPrinterProfilesRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/production/printers.js b/src/routes/production/printers.js index becb016..aaf4fd6 100644 --- a/src/routes/production/printers.js +++ b/src/routes/production/printers.js @@ -33,7 +33,7 @@ const listAllowedFilters = [ const listAllowedSorters = ['name', 'state', 'connectedAt', 'createdAt', 'updatedAt', 'host']; const propertiesAllowedFilters = ['tags']; // list of printers -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('printer', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listPrintersRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/production/subjobs.js b/src/routes/production/subjobs.js index 66b5a73..6d04947 100644 --- a/src/routes/production/subjobs.js +++ b/src/routes/production/subjobs.js @@ -40,7 +40,7 @@ import { import { getFilter, convertPropertiesString, getSort } from '../../utils.js'; // list of sub jobs -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('subJob', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listSubJobsRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/sales/clients.js b/src/routes/sales/clients.js index befa7d6..fb3e6d5 100644 --- a/src/routes/sales/clients.js +++ b/src/routes/sales/clients.js @@ -33,7 +33,7 @@ import { } from '../../services/sales/clients.js'; // list of clients -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('client', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listClientsRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/sales/fulfillmentpolicies.js b/src/routes/sales/fulfillmentpolicies.js new file mode 100644 index 0000000..558b6d2 --- /dev/null +++ b/src/routes/sales/fulfillmentpolicies.js @@ -0,0 +1,141 @@ +import express from 'express'; +import { isAuthenticated } from '../../keycloak.js'; +import { checkPermissions } from '../../database/permissions.js'; +import { getFilter, convertPropertiesString, getSort } from '../../utils.js'; +import { + listFulfillmentPoliciesRouteHandler, + getFulfillmentPolicyRouteHandler, + editFulfillmentPolicyRouteHandler, + newFulfillmentPolicyRouteHandler, + deleteFulfillmentPolicyRouteHandler, + listFulfillmentPoliciesByPropertiesRouteHandler, + getFulfillmentPolicyStatsRouteHandler, + getFulfillmentPolicyHistoryRouteHandler, + searchFulfillmentPoliciesRouteHandler, + getFulfillmentPolicyPropertyValuesRouteHandler, + getFulfillmentPolicyNeighborsRouteHandler, +} from '../../services/sales/fulfillmentpolicies.js'; + +const router = express.Router(); + +const listAllowedFilters = [ + 'name', + 'handlingTime', + 'localPickup', + 'globalShipping', + 'freightShipping', + 'pickupDropOff', + 'courierServices', + 'marketplaces.marketplace', + 'createdAt', + 'updatedAt', + '_reference', +]; +const listAllowedSorters = ['name', 'handlingTime', 'createdAt', '_id', 'updatedAt']; +const propertiesAllowedFilters = [ + 'name', + 'handlingTime', + 'localPickup', + 'courierServices', + 'marketplaces.marketplace', +]; + +router.get('/', isAuthenticated, checkPermissions('fulfillmentPolicy', 'list'), async (req, res) => { + const { page, limit, property, search, sortProperty, sortOrder } = req.query; + const filter = await getFilter(req.query, listAllowedFilters); + listFulfillmentPoliciesRouteHandler( + req, + res, + page, + limit, + property, + filter, + search, + getSort(sortProperty, listAllowedSorters), + sortOrder + ); +}); + +router.get( + '/properties', + checkPermissions('fulfillmentPolicy', 'list'), + isAuthenticated, + async (req, res) => { + const properties = convertPropertiesString(req.query.properties); + const filter = await getFilter(req.query, propertiesAllowedFilters, false); + let masterFilter = {}; + if (req.query.masterFilter) { + masterFilter = JSON.parse(req.query.masterFilter); + } + listFulfillmentPoliciesByPropertiesRouteHandler(req, res, properties, filter, masterFilter); + } +); + +router.get( + '/values', + checkPermissions('fulfillmentPolicy', 'list'), + isAuthenticated, + async (req, res) => { + getFulfillmentPolicyPropertyValuesRouteHandler(req, res, req.query.property); + } +); + +router.get( + '/search', + checkPermissions('fulfillmentPolicy', 'list'), + isAuthenticated, + async (req, res) => { + searchFulfillmentPoliciesRouteHandler(req, res, req.query.search); + } +); + +router.post( + '/', + isAuthenticated, + checkPermissions('fulfillmentPolicy', 'new'), + async (req, res) => { + newFulfillmentPolicyRouteHandler(req, res); + } +); + +router.get('/stats', isAuthenticated, async (req, res) => { + getFulfillmentPolicyStatsRouteHandler(req, res); +}); + +router.get('/history', isAuthenticated, async (req, res) => { + getFulfillmentPolicyHistoryRouteHandler(req, res); +}); + +router.get('/neighbors', isAuthenticated, async (req, res) => { + const { property, search, sortProperty, sortOrder, id } = req.query; + const filter = await getFilter(req.query, listAllowedFilters); + getFulfillmentPolicyNeighborsRouteHandler( + req, + res, + property, + filter, + search, + getSort(sortProperty, listAllowedSorters), + sortOrder, + id + ); +}); + +router.get('/:id', isAuthenticated, async (req, res) => { + getFulfillmentPolicyRouteHandler(req, res); +}); + +router.put( + '/:id', + isAuthenticated, + checkPermissions('fulfillmentPolicy', 'edit'), + async (req, res) => { + editFulfillmentPolicyRouteHandler(req, res); + } +); + +router.delete('/:id', isAuthenticated, async (req, res) => { + deleteFulfillmentPolicyRouteHandler(req, res); +}); + +export default router; diff --git a/src/routes/sales/listings.js b/src/routes/sales/listings.js index 7cdbb38..6df8868 100644 --- a/src/routes/sales/listings.js +++ b/src/routes/sales/listings.js @@ -16,6 +16,9 @@ const listAllowedFilters = [ 'marketplace', 'marketplace._id', 'courierServices', + 'fulfillmentPolicy', + 'paymentPolicy', + 'returnPolicy', 'state', 'state.type', 'createdAt', @@ -40,6 +43,9 @@ const propertiesAllowedFilters = [ 'stockQuantity', 'marketplace', 'courierServices', + 'fulfillmentPolicy', + 'paymentPolicy', + 'returnPolicy', 'state', 'state.type', 'createdAt', @@ -62,7 +68,7 @@ import { getListingNeighborsRouteHandler } from '../../services/sales/listings.js'; -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('listing', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listListingsRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/sales/listingvarients.js b/src/routes/sales/listingvarients.js index 8ef37d8..e7a1443 100644 --- a/src/routes/sales/listingvarients.js +++ b/src/routes/sales/listingvarients.js @@ -48,7 +48,7 @@ import { getListingVarientNeighborsRouteHandler } from '../../services/sales/listingvarients.js'; -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('listingVarient', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listListingVarientsRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/routes/sales/marketplaces.js b/src/routes/sales/marketplaces.js index 10f3816..4480b63 100644 --- a/src/routes/sales/marketplaces.js +++ b/src/routes/sales/marketplaces.js @@ -50,6 +50,10 @@ import { syncMarketplaceRouteHandler, syncMarketplaceItemsRouteHandler, syncMarketplaceOrdersRouteHandler, + syncMarketplaceFulfillmentPoliciesRouteHandler, + syncMarketplacePaymentPoliciesRouteHandler, + syncMarketplaceReturnPoliciesRouteHandler, + syncMarketplaceTaxRatesRouteHandler, marketplaceWebhookRouteHandler, marketplaceWebhookChallengeRouteHandler, subscribeMarketplaceWebhooksRouteHandler, @@ -59,7 +63,7 @@ import { getMarketplaceNeighborsRouteHandler, } from '../../services/sales/marketplaces.js'; -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('marketplace', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listMarketplacesRouteHandler( @@ -164,6 +168,42 @@ router.post( } ); +router.post( + '/:id/sync/fulfillmentPolicies', + isAuthenticated, + checkPermissions('marketplace', 'sync'), + async (req, res) => { + syncMarketplaceFulfillmentPoliciesRouteHandler(req, res); + } +); + +router.post( + '/:id/sync/paymentPolicies', + isAuthenticated, + checkPermissions('marketplace', 'sync'), + async (req, res) => { + syncMarketplacePaymentPoliciesRouteHandler(req, res); + } +); + +router.post( + '/:id/sync/returnPolicies', + isAuthenticated, + checkPermissions('marketplace', 'sync'), + async (req, res) => { + syncMarketplaceReturnPoliciesRouteHandler(req, res); + } +); + +router.post( + '/:id/sync/taxRates', + isAuthenticated, + checkPermissions('marketplace', 'sync'), + async (req, res) => { + syncMarketplaceTaxRatesRouteHandler(req, res); + } +); + router.post( '/:id/sync/items', isAuthenticated, diff --git a/src/routes/sales/returnpolicies.js b/src/routes/sales/returnpolicies.js new file mode 100644 index 0000000..2256953 --- /dev/null +++ b/src/routes/sales/returnpolicies.js @@ -0,0 +1,136 @@ +import express from 'express'; +import { isAuthenticated } from '../../keycloak.js'; +import { checkPermissions } from '../../database/permissions.js'; +import { getFilter, convertPropertiesString, getSort } from '../../utils.js'; +import { + listReturnPoliciesRouteHandler, + getReturnPolicyRouteHandler, + editReturnPolicyRouteHandler, + newReturnPolicyRouteHandler, + deleteReturnPolicyRouteHandler, + listReturnPoliciesByPropertiesRouteHandler, + getReturnPolicyStatsRouteHandler, + getReturnPolicyHistoryRouteHandler, + searchReturnPoliciesRouteHandler, + getReturnPolicyPropertyValuesRouteHandler, + getReturnPolicyNeighborsRouteHandler, +} from '../../services/sales/returnpolicies.js'; + +const router = express.Router(); + +const listAllowedFilters = [ + 'name', + 'returnsAccepted', + 'returnPeriodDays', + 'returnShippingCostPayer', + 'refundMethod', + 'marketplaces.marketplace', + 'createdAt', + 'updatedAt', + '_reference', +]; +const listAllowedSorters = [ + 'name', + 'returnsAccepted', + 'returnPeriodDays', + 'createdAt', + '_id', + 'updatedAt', +]; +const propertiesAllowedFilters = [ + 'name', + 'returnsAccepted', + 'returnShippingCostPayer', + 'refundMethod', + 'marketplaces.marketplace', +]; + +router.get('/', isAuthenticated, checkPermissions('returnPolicy', 'list'), async (req, res) => { + const { page, limit, property, search, sortProperty, sortOrder } = req.query; + const filter = await getFilter(req.query, listAllowedFilters); + listReturnPoliciesRouteHandler( + req, + res, + page, + limit, + property, + filter, + search, + getSort(sortProperty, listAllowedSorters), + sortOrder + ); +}); + +router.get( + '/properties', + checkPermissions('returnPolicy', 'list'), + isAuthenticated, + async (req, res) => { + const properties = convertPropertiesString(req.query.properties); + const filter = await getFilter(req.query, propertiesAllowedFilters, false); + let masterFilter = {}; + if (req.query.masterFilter) { + masterFilter = JSON.parse(req.query.masterFilter); + } + listReturnPoliciesByPropertiesRouteHandler(req, res, properties, filter, masterFilter); + } +); + +router.get( + '/values', + checkPermissions('returnPolicy', 'list'), + isAuthenticated, + async (req, res) => { + getReturnPolicyPropertyValuesRouteHandler(req, res, req.query.property); + } +); + +router.get( + '/search', + checkPermissions('returnPolicy', 'list'), + isAuthenticated, + async (req, res) => { + searchReturnPoliciesRouteHandler(req, res, req.query.search); + } +); + +router.post('/', isAuthenticated, checkPermissions('returnPolicy', 'new'), async (req, res) => { + newReturnPolicyRouteHandler(req, res); +}); + +router.get('/stats', isAuthenticated, async (req, res) => { + getReturnPolicyStatsRouteHandler(req, res); +}); + +router.get('/history', isAuthenticated, async (req, res) => { + getReturnPolicyHistoryRouteHandler(req, res); +}); + +router.get('/neighbors', isAuthenticated, async (req, res) => { + const { property, search, sortProperty, sortOrder, id } = req.query; + const filter = await getFilter(req.query, listAllowedFilters); + getReturnPolicyNeighborsRouteHandler( + req, + res, + property, + filter, + search, + getSort(sortProperty, listAllowedSorters), + sortOrder, + id + ); +}); + +router.get('/:id', isAuthenticated, async (req, res) => { + getReturnPolicyRouteHandler(req, res); +}); + +router.put('/:id', isAuthenticated, checkPermissions('returnPolicy', 'edit'), async (req, res) => { + editReturnPolicyRouteHandler(req, res); +}); + +router.delete('/:id', isAuthenticated, async (req, res) => { + deleteReturnPolicyRouteHandler(req, res); +}); + +export default router; diff --git a/src/routes/sales/salesorders.js b/src/routes/sales/salesorders.js index 6882d58..4ffdd02 100644 --- a/src/routes/sales/salesorders.js +++ b/src/routes/sales/salesorders.js @@ -35,7 +35,7 @@ import { } from '../../services/sales/salesorders.js'; // list of sales orders -router.get('/', isAuthenticated, async (req, res) => { +router.get('/', isAuthenticated, checkPermissions('salesOrder', 'list'), async (req, res) => { const { page, limit, property, search, sortProperty, sortOrder } = req.query; const filter = await getFilter(req.query, listAllowedFilters); listSalesOrdersRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder); diff --git a/src/services/finance/paymentpolicies.js b/src/services/finance/paymentpolicies.js new file mode 100644 index 0000000..11cc2da --- /dev/null +++ b/src/services/finance/paymentpolicies.js @@ -0,0 +1,232 @@ +import config from '../../config.js'; +import { paymentPolicyModel } from '../../database/schemas/finance/paymentpolicy.schema.js'; +import log4js from 'log4js'; +import mongoose from 'mongoose'; +import { + deleteObject, + listObjects, + getObject, + editObject, + newObject, + listObjectsByProperties, + getModelStats, + getModelHistory, + searchObjects, + getPropertyValues, + getObjectNeighbors, +} from '../../database/database.js'; +import { syncPaymentPolicy } from '../../integrations/marketplace.js'; +import { startPolicyMarketplaceSync } from '../misc/syncPolicyMarketplaces.js'; + +const logger = log4js.getLogger('PaymentPolicies'); +logger.level = config.server.logLevel; + +export const PAYMENT_POLICY_POPULATE = ['marketplaces.marketplace']; + +const policyFields = (body = {}) => ({ + name: body.name, + description: body.description, + immediatePay: body.immediatePay, + paymentInstructions: body.paymentInstructions, + marketplaces: body.marketplaces, +}); + +export const listPaymentPoliciesRouteHandler = async ( + req, + res, + page = 1, + limit = 25, + property = '', + filter = {}, + search = '', + sort = '', + order = 'ascend' +) => { + const result = await listObjects({ + model: paymentPolicyModel, + page, + limit, + property, + filter, + search, + sort, + order, + populate: PAYMENT_POLICY_POPULATE, + }); + + if (result?.error) { + logger.error('Error listing payment policies.'); + res.status(result.code).send(result); + return; + } + + logger.debug(`List of payment policies (Page ${page}, Limit ${limit}). Count: ${result.length}.`); + res.send(result); +}; + +export const listPaymentPoliciesByPropertiesRouteHandler = async ( + req, + res, + properties = '', + filter = {}, + masterFilter = {} +) => { + const result = await listObjectsByProperties({ + model: paymentPolicyModel, + properties, + filter, + masterFilter, + populate: PAYMENT_POLICY_POPULATE, + }); + + if (result?.error) { + logger.error('Error listing payment policies.'); + res.status(result.code).send(result); + return; + } + + logger.debug(`List of payment policies. Count: ${result.length}`); + res.send(result); +}; + +export const getPaymentPolicyPropertyValuesRouteHandler = async (req, res, property) => { + const result = await getPropertyValues({ + model: paymentPolicyModel, + property, + }); + res.send(result); +}; + +export const searchPaymentPoliciesRouteHandler = async (req, res, search) => { + const result = await searchObjects({ + model: paymentPolicyModel, + search, + populate: PAYMENT_POLICY_POPULATE, + }); + res.send(result); +}; + +export const getPaymentPolicyRouteHandler = async (req, res) => { + const id = req.params.id; + const result = await getObject({ + model: paymentPolicyModel, + id, + populate: PAYMENT_POLICY_POPULATE, + }); + if (result?.error) { + logger.warn('Payment policy not found with supplied id.'); + return res.status(result.code).send(result); + } + logger.debug(`Retrieved payment policy with ID: ${id}`); + res.send(result); +}; + +export const editPaymentPolicyRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + const result = await editObject({ + model: paymentPolicyModel, + id, + updateData: { updatedAt: new Date(), ...policyFields(req.body) }, + user: req.user, + populate: PAYMENT_POLICY_POPULATE, + }); + + if (result.error) { + logger.error('Error editing payment policy:', result.error); + res.status(result.code).send(result); + return; + } + + logger.debug(`Edited payment policy with ID: ${id}`); + const synced = await startPolicyMarketplaceSync({ + policy: result, + user: req.user, + syncFn: syncPaymentPolicy, + logger, + model: paymentPolicyModel, + populate: PAYMENT_POLICY_POPULATE, + }); + res.send(synced || result); +}; + +export const newPaymentPolicyRouteHandler = async (req, res) => { + const result = await newObject({ + model: paymentPolicyModel, + newData: { updatedAt: new Date(), ...policyFields(req.body) }, + user: req.user, + }); + if (result.error) { + logger.error('No payment policy created:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`New payment policy with ID: ${result._id}`); + res.send(result); +}; + +export const deletePaymentPolicyRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + const result = await deleteObject({ + model: paymentPolicyModel, + id, + user: req.user, + }); + if (result.error) { + logger.error('No payment policy deleted:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`Deleted payment policy with ID: ${result._id}`); + res.send(result); +}; + +export const getPaymentPolicyStatsRouteHandler = async (req, res) => { + const result = await getModelStats({ model: paymentPolicyModel }); + if (result?.error) { + logger.error('Error fetching payment policy stats:', result.error); + return res.status(result.code).send(result); + } + res.send(result); +}; + +export const getPaymentPolicyHistoryRouteHandler = async (req, res) => { + const from = req.query.from; + const to = req.query.to; + const result = await getModelHistory({ model: paymentPolicyModel, from, to }); + if (result?.error) { + logger.error('Error fetching payment policy history:', result.error); + return res.status(result.code).send(result); + } + res.send(result); +}; + +export const getPaymentPolicyNeighborsRouteHandler = async ( + req, + res, + property = '', + filter = {}, + search = '', + sort = '', + order = 'ascend', + id +) => { + if (!id) { + return res.status(400).send({ error: 'Missing id parameter', code: 400 }); + } + + const result = await getObjectNeighbors({ + model: paymentPolicyModel, + id, + filter, + search, + sort, + order, + }); + + if (result?.error) { + logger.error('Error fetching paymentPolicy neighbors.'); + return res.status(result.code).send(result); + } + + res.send(result); +}; diff --git a/src/services/inventory/__tests__/partstocks.test.js b/src/services/inventory/__tests__/partstocks.test.js index e9e6473..635144b 100644 --- a/src/services/inventory/__tests__/partstocks.test.js +++ b/src/services/inventory/__tests__/partstocks.test.js @@ -12,6 +12,7 @@ jest.unstable_mockModule('../../../database/database.js', () => ({ listObjectsByProperties: jest.fn(), getModelStats: jest.fn(), getModelHistory: jest.fn(), + checkStates: jest.fn(), getObjectNeighbors: jest.fn(), })); @@ -19,6 +20,10 @@ jest.unstable_mockModule('../../../database/schemas/inventory/partstock.schema.j partStockModel: { modelName: 'PartStock' }, })); +jest.unstable_mockModule('../../../database/schemas/inventory/stockevent.schema.js', () => ({ + stockEventModel: { modelName: 'StockEvent' }, +})); + jest.unstable_mockModule('log4js', () => ({ default: { getLogger: () => ({ @@ -33,16 +38,17 @@ jest.unstable_mockModule('log4js', () => ({ const { listPartStocksRouteHandler, - getPartStockRouteHandler, newPartStockRouteHandler, - editPartStockRouteHandler, - deletePartStockRouteHandler, + postPartStockRouteHandler, } = await import('../partstocks.js'); -const { listObjects, getObject, editObject, newObject, deleteObject } = await import( +const { listObjects, getObject, editObject, newObject, checkStates } = await import( '../../../database/database.js' ); const { partStockModel } = await import('../../../database/schemas/inventory/partstock.schema.js'); +const { stockEventModel } = await import( + '../../../database/schemas/inventory/stockevent.schema.js' +); describe('Part Stock Service Route Handlers', () => { let req, res; @@ -52,7 +58,7 @@ describe('Part Stock Service Route Handlers', () => { params: {}, query: {}, body: {}, - user: { id: 'test-user-id' }, + user: { _id: 'test-user-id' }, }; res = { send: jest.fn(), @@ -76,16 +82,78 @@ describe('Part Stock Service Route Handlers', () => { }); describe('newPartStockRouteHandler', () => { - it('should create a new part stock', async () => { + it('should create a new part stock without source fields', async () => { req.body = { part: 'part123', currentQuantity: 50 }; const mockStock = { _id: '456', ...req.body }; newObject.mockResolvedValue(mockStock); await newPartStockRouteHandler(req, res); - expect(newObject).toHaveBeenCalled(); + expect(newObject).toHaveBeenCalledWith( + expect.objectContaining({ + newData: expect.objectContaining({ + currentQuantity: 50, + part: 'part123', + state: { type: 'draft' }, + }), + }) + ); + expect(newObject.mock.calls[0][0].newData).not.toHaveProperty('source'); + expect(newObject.mock.calls[0][0].newData).not.toHaveProperty('sourceType'); expect(res.send).toHaveBeenCalledWith(mockStock); }); }); -}); + describe('postPartStockRouteHandler', () => { + it('should post a draft part stock and create an initial stock event', async () => { + req.params.id = '507f1f77bcf86cd799439011'; + checkStates.mockResolvedValue(true); + getObject.mockResolvedValue({ + _id: '507f1f77bcf86cd799439011', + currentQuantity: 50, + state: { type: 'draft' }, + }); + newObject.mockResolvedValue({ _id: 'event1' }); + editObject.mockResolvedValue({ + _id: '507f1f77bcf86cd799439011', + state: { type: 'new' }, + }); + + await postPartStockRouteHandler(req, res); + + expect(checkStates).toHaveBeenCalledWith(expect.objectContaining({ states: ['draft'] })); + expect(newObject).toHaveBeenCalledWith( + expect.objectContaining({ + model: stockEventModel, + newData: expect.objectContaining({ + value: 50, + unit: 'qty', + parentType: 'partStock', + ownerType: 'user', + }), + recalculate: true, + }) + ); + expect(editObject).toHaveBeenCalledWith( + expect.objectContaining({ + updateData: expect.objectContaining({ + state: { type: 'new' }, + }), + }) + ); + expect(res.send).toHaveBeenCalled(); + }); + + it('should fail if part stock is not in draft state', async () => { + req.params.id = '507f1f77bcf86cd799439011'; + checkStates.mockResolvedValue(false); + + await postPartStockRouteHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.send).toHaveBeenCalledWith( + expect.objectContaining({ error: 'Part stock is not in draft state.' }) + ); + }); + }); +}); diff --git a/src/services/inventory/__tests__/shipments.test.js b/src/services/inventory/__tests__/shipments.test.js index 4a293fe..06d3369 100644 --- a/src/services/inventory/__tests__/shipments.test.js +++ b/src/services/inventory/__tests__/shipments.test.js @@ -34,7 +34,7 @@ jest.unstable_mockModule('../../../database/schemas/sales/marketplace.schema.js' marketplaceModel: { modelName: 'Marketplace', findById: jest.fn() }, })); -jest.unstable_mockModule('../../../integrations/marketplaceworker.js', () => ({ +jest.unstable_mockModule('../../../integrations/marketplace.js', () => ({ pushMarketplaceShipmentFulfillment: jest.fn(), })); @@ -57,9 +57,8 @@ const { shipShipmentRouteHandler, } = await import('../shipments.js'); -const { listObjects, getObject, editObject, newObject, checkStates } = await import( - '../../../database/database.js' -); +const { listObjects, getObject, editObject, newObject, checkStates } = + await import('../../../database/database.js'); const { shipmentModel } = await import('../../../database/schemas/inventory/shipment.schema.js'); const { orderItemModel } = await import('../../../database/schemas/inventory/orderitem.schema.js'); @@ -125,4 +124,3 @@ describe('Shipment Service Route Handlers', () => { }); }); }); - diff --git a/src/services/inventory/partstocks.js b/src/services/inventory/partstocks.js index fd498b1..816c49f 100644 --- a/src/services/inventory/partstocks.js +++ b/src/services/inventory/partstocks.js @@ -12,10 +12,12 @@ import { listObjectsByProperties, getModelStats, getModelHistory, + checkStates, searchObjects, getPropertyValues, getObjectNeighbors, } from '../../database/database.js'; +import { stockEventModel } from '../../database/schemas/inventory/stockevent.schema.js'; const logger = log4js.getLogger('Part Stocks'); logger.level = config.server.logLevel; @@ -23,7 +25,6 @@ const PART_STOCK_POPULATE = [ { path: 'part' }, { path: 'partSku', populate: 'part' }, { path: 'stockLocation' }, - { path: 'source' }, ]; export const listPartStocksRouteHandler = async ( @@ -121,7 +122,26 @@ export const editPartStockRouteHandler = async (req, res) => { logger.trace(`Part Stock with ID: ${id}`); - const updateData = {}; + const checkStatesResult = await checkStates({ model: partStockModel, id, states: ['draft'] }); + + if (checkStatesResult.error) { + logger.error('Error checking part stock states:', checkStatesResult.error); + res.status(checkStatesResult.code).send(checkStatesResult); + return; + } + + if (checkStatesResult === false) { + logger.error('Part stock is not in draft state.'); + res.status(400).send({ error: 'Part stock is not in draft state.', code: 400 }); + return; + } + + const updateData = { + part: req.body?.part, + partSku: req.body?.partSku, + stockLocation: req.body?.stockLocation, + currentQuantity: req.body?.currentQuantity, + }; const result = await editObject({ model: partStockModel, id, @@ -170,13 +190,10 @@ export const editMultiplePartStocksRouteHandler = async (req, res) => { export const newPartStockRouteHandler = async (req, res) => { const newData = { updatedAt: new Date(), - startingQuantity: req.body.startingQuantity, currentQuantity: req.body.currentQuantity, part: req.body.part, partSku: req.body.partSku, - state: req.body.state, - sourceType: req.body.sourceType, - source: req.body.source, + state: req.body.state ?? { type: 'draft' }, stockLocation: req.body.stockLocation, }; const result = await newObject({ @@ -200,6 +217,20 @@ export const deletePartStockRouteHandler = async (req, res) => { logger.trace(`Part Stock with ID: ${id}`); + const checkStatesResult = await checkStates({ model: partStockModel, id, states: ['draft'] }); + + if (checkStatesResult.error) { + logger.error('Error checking part stock states:', checkStatesResult.error); + res.status(checkStatesResult.code).send(checkStatesResult); + return; + } + + if (checkStatesResult === false) { + logger.error('Part stock is not in draft state.'); + res.status(400).send({ error: 'Part stock is not in draft state.', code: 400 }); + return; + } + const result = await deleteObject({ model: partStockModel, id, @@ -236,6 +267,79 @@ export const getPartStockHistoryRouteHandler = async (req, res) => { logger.trace('Part stock history:', result); res.send(result); }; + +export const postPartStockRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + logger.trace(`Part Stock with ID: ${id}`); + + const checkStatesResult = await checkStates({ model: partStockModel, id, states: ['draft'] }); + + if (checkStatesResult.error) { + logger.error('Error checking part stock states:', checkStatesResult.error); + res.status(checkStatesResult.code).send(checkStatesResult); + return; + } + + if (checkStatesResult === false) { + logger.error('Part stock is not in draft state.'); + res.status(400).send({ error: 'Part stock is not in draft state.', code: 400 }); + return; + } + + const partStock = await getObject({ + model: partStockModel, + id, + populate: PART_STOCK_POPULATE, + }); + if (partStock?.error) { + logger.error('Error loading part stock to post:', partStock.error); + res.status(partStock.code || 500).send(partStock); + return; + } + + const initialStockEventResult = await newObject({ + model: stockEventModel, + newData: { + value: partStock.currentQuantity, + unit: 'qty', + parent: { _id: id }, + parentType: 'partStock', + owner: { _id: req.user._id }, + ownerType: 'user', + }, + recalculate: true, + user: req.user, + }); + if (initialStockEventResult?.error) { + logger.error('Error creating initial stock event:', initialStockEventResult.error); + res.status(initialStockEventResult.code || 500).send(initialStockEventResult); + return; + } + + const updateData = { + updatedAt: new Date(), + state: { type: 'new' }, + postedAt: new Date(), + }; + const result = await editObject({ + model: partStockModel, + id, + updateData, + user: req.user, + populate: PART_STOCK_POPULATE, + }); + + if (result.error) { + logger.error('Error posting part stock:', result.error); + res.status(result.code).send(result); + return; + } + + logger.debug(`Posted part stock with ID: ${id}`); + res.send(result); +}; + export const getPartStockNeighborsRouteHandler = async ( req, res, @@ -267,4 +371,3 @@ export const getPartStockNeighborsRouteHandler = async ( logger.debug(`Retrieved partStock neighbors for ID: ${id}`); res.send(result); }; - diff --git a/src/services/inventory/shipments.js b/src/services/inventory/shipments.js index 2cbb384..1cf2a3e 100644 --- a/src/services/inventory/shipments.js +++ b/src/services/inventory/shipments.js @@ -22,7 +22,7 @@ logger.level = config.server.logLevel; import { orderItemModel } from '../../database/schemas/inventory/orderitem.schema.js'; import { salesOrderModel } from '../../database/schemas/sales/salesorder.schema.js'; import { marketplaceModel } from '../../database/schemas/sales/marketplace.schema.js'; -import * as marketplaceIntegration from '../../integrations/marketplaceworker.js'; +import * as marketplaceIntegration from '../../integrations/marketplace.js'; export const listShipmentsRouteHandler = async ( req, @@ -329,15 +329,8 @@ export const shipShipmentRouteHandler = async (req, res) => { 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 - ); + const shipment = await shipmentModel.findById(id).populate('courierService').lean(); + marketplaceIntegration.pushMarketplaceShipmentFulfillment(marketplace, req.user, shipment); }) .catch((err) => { logger.warn(`Failed to push marketplace fulfillment for shipment ${id}: ${err.message}`); @@ -518,4 +511,3 @@ export const getShipmentNeighborsRouteHandler = async ( logger.debug(`Retrieved shipment neighbors for ID: ${id}`); res.send(result); }; - diff --git a/src/services/inventory/stocktransfers.js b/src/services/inventory/stocktransfers.js index da4bae7..9ac0017 100644 --- a/src/services/inventory/stocktransfers.js +++ b/src/services/inventory/stocktransfers.js @@ -157,8 +157,7 @@ async function executePostedLine(transferId, line, user) { partSku: src.partSku, currentQuantity: line.quantity, state: { type: 'new' }, - sourceType: 'stockTransfer', - source: transferId, + postedAt: new Date(), stockLocation: toLocId, }, user diff --git a/src/services/management/__tests__/taxrates.test.js b/src/services/management/__tests__/taxrates.test.js index 52d5d2e..ae85760 100644 --- a/src/services/management/__tests__/taxrates.test.js +++ b/src/services/management/__tests__/taxrates.test.js @@ -12,12 +12,21 @@ jest.unstable_mockModule('../../../database/database.js', () => ({ getModelStats: jest.fn(), getModelHistory: jest.fn(), getObjectNeighbors: jest.fn(), + deleteObjectCache: jest.fn(), })); jest.unstable_mockModule('../../../database/schemas/management/taxrate.schema.js', () => ({ taxRateModel: { modelName: 'TaxRate' }, })); +jest.unstable_mockModule('../../../integrations/marketplace.js', () => ({ + syncTaxRate: jest.fn(), +})); + +jest.unstable_mockModule('../../misc/syncPolicyMarketplaces.js', () => ({ + startPolicyMarketplaceSync: jest.fn(), +})); + jest.unstable_mockModule('log4js', () => ({ default: { getLogger: () => ({ @@ -41,6 +50,7 @@ const { listObjects, getObject, editObject, newObject } = await import( '../../../database/database.js' ); const { taxRateModel } = await import('../../../database/schemas/management/taxrate.schema.js'); +const { startPolicyMarketplaceSync } = await import('../../misc/syncPolicyMarketplaces.js'); describe('Tax Rate Service Route Handlers', () => { let req, res; @@ -94,6 +104,12 @@ describe('Tax Rate Service Route Handlers', () => { await editTaxRateRouteHandler(req, res); expect(editObject).toHaveBeenCalled(); + expect(startPolicyMarketplaceSync).toHaveBeenCalledWith( + expect.objectContaining({ + policy: mockResult, + syncFn: expect.any(Function), + }) + ); expect(res.send).toHaveBeenCalledWith(mockResult); }); }); diff --git a/src/services/management/productcategories.js b/src/services/management/productcategories.js index d804c7b..edcffae 100644 --- a/src/services/management/productcategories.js +++ b/src/services/management/productcategories.js @@ -19,6 +19,8 @@ import { const logger = log4js.getLogger('ProductCategories'); logger.level = config.server.logLevel; +const PRODUCT_CATEGORY_POPULATE = ['marketplaces.marketplace']; + export const listProductCategoriesRouteHandler = async ( req, res, @@ -39,7 +41,7 @@ export const listProductCategoriesRouteHandler = async ( search, sort, order, - populate: [], + populate: PRODUCT_CATEGORY_POPULATE, }); if (result?.error) { @@ -64,7 +66,7 @@ export const listProductCategoriesByPropertiesRouteHandler = async ( properties, filter, masterFilter, - populate: [], + populate: PRODUCT_CATEGORY_POPULATE, }); if (result?.error) { @@ -89,6 +91,7 @@ export const searchProductCategoriesRouteHandler = async (req, res, search) => { const result = await searchObjects({ model: productCategoryModel, search, + populate: PRODUCT_CATEGORY_POPULATE, }); res.send(result); }; @@ -98,7 +101,7 @@ export const getProductCategoryRouteHandler = async (req, res) => { const result = await getObject({ model: productCategoryModel, id, - populate: [], + populate: PRODUCT_CATEGORY_POPULATE, }); if (result?.error) { logger.warn(`Product category not found with supplied id.`); @@ -116,6 +119,7 @@ export const editProductCategoryRouteHandler = async (req, res) => { const updateData = { updatedAt: new Date(), name: req.body.name, + marketplaces: req.body?.marketplaces, }; const result = await editObject({ @@ -123,6 +127,7 @@ export const editProductCategoryRouteHandler = async (req, res) => { id, updateData, user: req.user, + populate: PRODUCT_CATEGORY_POPULATE, }); if (result.error) { @@ -140,6 +145,7 @@ export const newProductCategoryRouteHandler = async (req, res) => { createdAt: new Date(), updatedAt: new Date(), name: req.body.name, + marketplaces: req.body?.marketplaces, }; const result = await newObject({ diff --git a/src/services/management/taxrates.js b/src/services/management/taxrates.js index 608de28..69e7a69 100644 --- a/src/services/management/taxrates.js +++ b/src/services/management/taxrates.js @@ -15,9 +15,13 @@ import { getPropertyValues, getObjectNeighbors, } from '../../database/database.js'; +import { syncTaxRate } from '../../integrations/marketplace.js'; +import { startPolicyMarketplaceSync } from '../misc/syncPolicyMarketplaces.js'; const logger = log4js.getLogger('TaxRates'); logger.level = config.server.logLevel; +const TAX_RATE_POPULATE = ['marketplaces.marketplace']; + export const listTaxRatesRouteHandler = async ( req, res, @@ -38,6 +42,7 @@ export const listTaxRatesRouteHandler = async ( search, sort, order, + populate: TAX_RATE_POPULATE, }); if (result?.error) { @@ -62,6 +67,7 @@ export const listTaxRatesByPropertiesRouteHandler = async ( properties, filter, masterFilter, + populate: TAX_RATE_POPULATE, }); if (result?.error) { @@ -86,6 +92,7 @@ export const searchTaxRatesRouteHandler = async (req, res, search) => { const result = await searchObjects({ model: taxRateModel, search, + populate: TAX_RATE_POPULATE, }); res.send(result); }; @@ -95,6 +102,7 @@ export const getTaxRateRouteHandler = async (req, res) => { const result = await getObject({ model: taxRateModel, id, + populate: TAX_RATE_POPULATE, }); if (result?.error) { logger.warn(`Tax rate not found with supplied id.`); @@ -118,15 +126,18 @@ export const editTaxRateRouteHandler = async (req, res) => { active: req.body.active, description: req.body.description, country: req.body.country, + jurisdiction: req.body.jurisdiction, + shippingAndHandlingTaxed: req.body.shippingAndHandlingTaxed, effectiveFrom: req.body.effectiveFrom, effectiveTo: req.body.effectiveTo, + marketplaces: req.body.marketplaces, }; - // Create audit log before updating const result = await editObject({ model: taxRateModel, id, updateData, user: req.user, + populate: TAX_RATE_POPULATE, }); if (result.error) { @@ -137,7 +148,15 @@ export const editTaxRateRouteHandler = async (req, res) => { logger.debug(`Edited tax rate with ID: ${id}`); - res.send(result); + const synced = await startPolicyMarketplaceSync({ + policy: result, + user: req.user, + syncFn: syncTaxRate, + logger, + model: taxRateModel, + populate: TAX_RATE_POPULATE, + }); + res.send(synced || result); }; export const newTaxRateRouteHandler = async (req, res) => { @@ -149,8 +168,11 @@ export const newTaxRateRouteHandler = async (req, res) => { active: req.body.active, description: req.body.description, country: req.body.country, + jurisdiction: req.body.jurisdiction, + shippingAndHandlingTaxed: req.body.shippingAndHandlingTaxed, effectiveFrom: req.body.effectiveFrom, effectiveTo: req.body.effectiveTo, + marketplaces: req.body.marketplaces, }; const result = await newObject({ model: taxRateModel, diff --git a/src/services/misc/export.js b/src/services/misc/export.js index 1473033..7f2c66e 100644 --- a/src/services/misc/export.js +++ b/src/services/misc/export.js @@ -18,7 +18,7 @@ export const EXPORT_FILTER_BY_TYPE = { material: ['name', 'tags'], partStock: ['part', 'partSku'], partSku: ['part', 'vendor', 'priceTaxRate', 'costTaxRate'], - productCategory: ['name'], + productCategory: ['name', 'marketplaces.marketplace'], product: ['productCategory', 'productCategory._id', 'vendor', 'priceTaxRate', 'costTaxRate'], productStock: ['productSku'], productSku: ['product', 'vendor', 'priceTaxRate', 'costTaxRate'], diff --git a/src/services/misc/syncPolicyMarketplaces.js b/src/services/misc/syncPolicyMarketplaces.js new file mode 100644 index 0000000..fe2401e --- /dev/null +++ b/src/services/misc/syncPolicyMarketplaces.js @@ -0,0 +1,48 @@ +import { syncMappedEbayPolicies } from '../../integrations/marketplace.js'; +import { persistMarketplaceMapping } from '../../integrations/marketplaces/ebay/accountPolicies.js'; + +function loadPolicy(model, id, populate) { + let query = model.findById(id); + if (typeof query?.populate === 'function') { + const paths = Array.isArray(populate) ? populate : [populate]; + for (const path of paths) { + if (path) query = query.populate(path); + } + } + return typeof query?.lean === 'function' ? query.lean() : query; +} + +export async function startPolicyMarketplaceSync({ + policy, + user, + syncFn, + logger, + model, + populate = ['marketplaces.marketplace'], +}) { + let resolved = policy; + if (model && policy?._id) { + const loaded = await loadPolicy(model, policy._id, populate); + if (loaded) resolved = loaded; + } + + const mappings = Array.isArray(resolved?.marketplaces) ? resolved.marketplaces : []; + for (const mapping of mappings) { + const marketplace = mapping?.marketplace; + if (!marketplace || typeof marketplace !== 'object' || marketplace.provider !== 'ebay') { + continue; + } + if (model) { + await persistMarketplaceMapping(model, resolved, marketplace, { stateType: 'syncing' }); + } + } + + syncMappedEbayPolicies(resolved, user, syncFn, logger).catch((err) => { + logger?.error?.( + `Failed to start marketplace sync for ${resolved?._reference || resolved?._id}: ${err.message}` + ); + }); + + if (!model || !policy?._id) return resolved; + return (await loadPolicy(model, policy._id, populate)) || resolved; +} diff --git a/src/services/sales/__tests__/listings.test.js b/src/services/sales/__tests__/listings.test.js new file mode 100644 index 0000000..d36f2c9 --- /dev/null +++ b/src/services/sales/__tests__/listings.test.js @@ -0,0 +1,177 @@ +import { jest } from '@jest/globals'; + +const publishListing = jest.fn().mockResolvedValue(undefined); +const unpublishListing = jest.fn().mockResolvedValue(undefined); + +jest.unstable_mockModule('../../../database/database.js', () => ({ + searchObjects: jest.fn(), + getPropertyValues: jest.fn(), + listObjects: jest.fn(), + getObject: jest.fn(), + editObject: jest.fn(), + editObjects: jest.fn(), + newObject: jest.fn(), + deleteObject: jest.fn(), + listObjectsByProperties: jest.fn(), + getModelStats: jest.fn(), + getModelHistory: jest.fn(), + checkStates: jest.fn(), + getObjectNeighbors: jest.fn(), +})); + +const listingFindById = jest.fn(); +jest.unstable_mockModule('../../../database/schemas/sales/listing.schema.js', () => ({ + listingModel: { + modelName: 'listing', + findById: listingFindById, + }, +})); + +const listingVarientFind = jest.fn(); +jest.unstable_mockModule('../../../database/schemas/sales/listingvarient.schema.js', () => ({ + listingVarientModel: { + modelName: 'listingVarient', + find: listingVarientFind, + }, +})); + +jest.unstable_mockModule('../../../database/schemas/sales/marketplace.schema.js', () => ({ + marketplaceModel: { modelName: 'marketplace', findById: jest.fn() }, +})); + +jest.unstable_mockModule('../../../integrations/marketplace.js', () => ({ + hasIntegration: jest.fn(() => true), + createListing: jest.fn(), + updateListing: jest.fn(), + deleteListing: jest.fn(), + publishListing, + unpublishListing, + MARKETPLACE_BUSY_STATES: ['syncing', 'publishing', 'unpublishing'], +})); + +jest.unstable_mockModule('log4js', () => ({ + default: { + getLogger: () => ({ + level: 'info', + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + trace: jest.fn(), + }), + }, +})); + +const { publishListingRouteHandler, unpublishListingRouteHandler } = await import('../listings.js'); +const { checkStates, editObject } = await import('../../../database/database.js'); + +describe('listing marketplace publish/unpublish', () => { + let req; + let res; + const listingId = '507f1f77bcf86cd799439011'; + const varientId = '507f1f77bcf86cd799439012'; + const marketplace = { + _id: '507f1f77bcf86cd799439013', + name: 'eBay UK', + provider: 'ebay', + active: true, + }; + + beforeEach(() => { + req = { + params: { id: listingId }, + user: { _id: 'user-1' }, + }; + res = { + send: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + jest.clearAllMocks(); + listingFindById.mockReturnValue({ + populate: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue({ + _id: listingId, + state: { type: 'draft' }, + stockLocation: 'loc-1', + marketplace, + }), + }); + listingVarientFind.mockReturnValue({ + lean: jest + .fn() + .mockResolvedValue([{ _id: varientId, _reference: 'SKU-1', state: { type: 'draft' } }]), + }); + editObject.mockImplementation(async ({ updateData }) => ({ + _id: listingId, + ...updateData, + marketplace, + })); + }); + + it('sets publishing state with progress and returns without waiting for marketplace work', async () => { + checkStates.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + + await publishListingRouteHandler(req, res); + + expect(editObject).toHaveBeenCalledWith( + expect.objectContaining({ + updateData: expect.objectContaining({ + state: { type: 'publishing', progress: 0.05 }, + }), + }) + ); + expect(editObject).toHaveBeenCalledWith( + expect.objectContaining({ + updateData: expect.objectContaining({ + state: { type: 'publishing' }, + }), + }) + ); + expect(publishListing).toHaveBeenCalledTimes(1); + expect(res.send).toHaveBeenCalledWith( + expect.objectContaining({ + state: { type: 'publishing', progress: 0.05 }, + }) + ); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('sets unpublishing state with progress and returns without waiting for marketplace work', async () => { + checkStates.mockResolvedValueOnce(false); + listingFindById.mockReturnValue({ + populate: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue({ + _id: listingId, + state: { type: 'active' }, + marketplace, + }), + }); + listingVarientFind.mockReturnValue({ + lean: jest + .fn() + .mockResolvedValue([{ _id: varientId, _reference: 'SKU-1', state: { type: 'active' } }]), + }); + + await unpublishListingRouteHandler(req, res); + + expect(editObject).toHaveBeenCalledWith( + expect.objectContaining({ + updateData: expect.objectContaining({ + state: { type: 'unpublishing', progress: 0.05 }, + }), + }) + ); + expect(editObject).toHaveBeenCalledWith( + expect.objectContaining({ + updateData: expect.objectContaining({ + state: { type: 'unpublishing' }, + }), + }) + ); + expect(unpublishListing).toHaveBeenCalledTimes(1); + expect(res.send).toHaveBeenCalledWith( + expect.objectContaining({ + state: { type: 'unpublishing', progress: 0.05 }, + }) + ); + }); +}); diff --git a/src/services/sales/fulfillmentpolicies.js b/src/services/sales/fulfillmentpolicies.js new file mode 100644 index 0000000..13b81c3 --- /dev/null +++ b/src/services/sales/fulfillmentpolicies.js @@ -0,0 +1,238 @@ +import config from '../../config.js'; +import { fulfillmentPolicyModel } from '../../database/schemas/sales/fulfillmentpolicy.schema.js'; +import log4js from 'log4js'; +import mongoose from 'mongoose'; +import { + deleteObject, + listObjects, + getObject, + editObject, + newObject, + listObjectsByProperties, + getModelStats, + getModelHistory, + searchObjects, + getPropertyValues, + getObjectNeighbors, +} from '../../database/database.js'; +import { syncFulfillmentPolicy } from '../../integrations/marketplace.js'; +import { startPolicyMarketplaceSync } from '../misc/syncPolicyMarketplaces.js'; + +const logger = log4js.getLogger('FulfillmentPolicies'); +logger.level = config.server.logLevel; + +export const FULFILLMENT_POLICY_POPULATE = ['courierServices', 'marketplaces.marketplace']; + +const policyFields = (body = {}) => ({ + name: body.name, + description: body.description, + handlingTime: body.handlingTime, + localPickup: body.localPickup, + globalShipping: body.globalShipping, + freightShipping: body.freightShipping, + pickupDropOff: body.pickupDropOff, + courierServices: body.courierServices, + marketplaces: body.marketplaces, +}); + +export const listFulfillmentPoliciesRouteHandler = async ( + req, + res, + page = 1, + limit = 25, + property = '', + filter = {}, + search = '', + sort = '', + order = 'ascend' +) => { + const result = await listObjects({ + model: fulfillmentPolicyModel, + page, + limit, + property, + filter, + search, + sort, + order, + populate: FULFILLMENT_POLICY_POPULATE, + }); + + if (result?.error) { + logger.error('Error listing fulfillment policies.'); + res.status(result.code).send(result); + return; + } + + logger.debug( + `List of fulfillment policies (Page ${page}, Limit ${limit}). Count: ${result.length}.` + ); + res.send(result); +}; + +export const listFulfillmentPoliciesByPropertiesRouteHandler = async ( + req, + res, + properties = '', + filter = {}, + masterFilter = {} +) => { + const result = await listObjectsByProperties({ + model: fulfillmentPolicyModel, + properties, + filter, + masterFilter, + populate: FULFILLMENT_POLICY_POPULATE, + }); + + if (result?.error) { + logger.error('Error listing fulfillment policies.'); + res.status(result.code).send(result); + return; + } + + logger.debug(`List of fulfillment policies. Count: ${result.length}`); + res.send(result); +}; + +export const getFulfillmentPolicyPropertyValuesRouteHandler = async (req, res, property) => { + const result = await getPropertyValues({ + model: fulfillmentPolicyModel, + property, + }); + res.send(result); +}; + +export const searchFulfillmentPoliciesRouteHandler = async (req, res, search) => { + const result = await searchObjects({ + model: fulfillmentPolicyModel, + search, + populate: FULFILLMENT_POLICY_POPULATE, + }); + res.send(result); +}; + +export const getFulfillmentPolicyRouteHandler = async (req, res) => { + const id = req.params.id; + const result = await getObject({ + model: fulfillmentPolicyModel, + id, + populate: FULFILLMENT_POLICY_POPULATE, + }); + if (result?.error) { + logger.warn('Fulfillment policy not found with supplied id.'); + return res.status(result.code).send(result); + } + logger.debug(`Retrieved fulfillment policy with ID: ${id}`); + res.send(result); +}; + +export const editFulfillmentPolicyRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + const result = await editObject({ + model: fulfillmentPolicyModel, + id, + updateData: { updatedAt: new Date(), ...policyFields(req.body) }, + user: req.user, + populate: FULFILLMENT_POLICY_POPULATE, + }); + + if (result.error) { + logger.error('Error editing fulfillment policy:', result.error); + res.status(result.code).send(result); + return; + } + + logger.debug(`Edited fulfillment policy with ID: ${id}`); + const synced = await startPolicyMarketplaceSync({ + policy: result, + user: req.user, + syncFn: syncFulfillmentPolicy, + logger, + model: fulfillmentPolicyModel, + populate: FULFILLMENT_POLICY_POPULATE, + }); + res.send(synced || result); +}; + +export const newFulfillmentPolicyRouteHandler = async (req, res) => { + const result = await newObject({ + model: fulfillmentPolicyModel, + newData: { updatedAt: new Date(), ...policyFields(req.body) }, + user: req.user, + }); + if (result.error) { + logger.error('No fulfillment policy created:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`New fulfillment policy with ID: ${result._id}`); + res.send(result); +}; + +export const deleteFulfillmentPolicyRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + const result = await deleteObject({ + model: fulfillmentPolicyModel, + id, + user: req.user, + }); + if (result.error) { + logger.error('No fulfillment policy deleted:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`Deleted fulfillment policy with ID: ${result._id}`); + res.send(result); +}; + +export const getFulfillmentPolicyStatsRouteHandler = async (req, res) => { + const result = await getModelStats({ model: fulfillmentPolicyModel }); + if (result?.error) { + logger.error('Error fetching fulfillment policy stats:', result.error); + return res.status(result.code).send(result); + } + res.send(result); +}; + +export const getFulfillmentPolicyHistoryRouteHandler = async (req, res) => { + const from = req.query.from; + const to = req.query.to; + const result = await getModelHistory({ model: fulfillmentPolicyModel, from, to }); + if (result?.error) { + logger.error('Error fetching fulfillment policy history:', result.error); + return res.status(result.code).send(result); + } + res.send(result); +}; + +export const getFulfillmentPolicyNeighborsRouteHandler = async ( + req, + res, + property = '', + filter = {}, + search = '', + sort = '', + order = 'ascend', + id +) => { + if (!id) { + return res.status(400).send({ error: 'Missing id parameter', code: 400 }); + } + + const result = await getObjectNeighbors({ + model: fulfillmentPolicyModel, + id, + filter, + search, + sort, + order, + }); + + if (result?.error) { + logger.error('Error fetching fulfillmentPolicy neighbors.'); + return res.status(result.code).send(result); + } + + res.send(result); +}; diff --git a/src/services/sales/listings.js b/src/services/sales/listings.js index 111c6d9..53de105 100644 --- a/src/services/sales/listings.js +++ b/src/services/sales/listings.js @@ -23,14 +23,41 @@ import { createListing as createExternalListing, updateListing as updateExternalListing, deleteListing as deleteExternalListing, - publishMarketplaceOfferForSku, - withdrawMarketplaceOfferForSku, - marketplaceSku, -} from '../../integrations/marketplaceworker.js'; + publishListing as publishExternalListing, + unpublishListing as unpublishExternalListing, + MARKETPLACE_BUSY_STATES, +} from '../../integrations/marketplace.js'; const logger = log4js.getLogger('Listings'); logger.level = config.server.logLevel; +const LISTING_POPULATE = [ + 'product', + 'vendor', + 'stockLocation', + 'courierServices', + 'fulfillmentPolicy', + 'paymentPolicy', + 'returnPolicy', + 'listingImages', + { + path: 'marketplace', + populate: ['defaultFulfillmentPolicy', 'defaultPaymentPolicy', 'defaultReturnPolicy'], + }, + { + path: 'fulfillmentPolicy', + populate: ['courierServices', 'marketplaces.marketplace'], + }, + { + path: 'paymentPolicy', + populate: ['marketplaces.marketplace'], + }, + { + path: 'returnPolicy', + populate: ['marketplaces.marketplace'], + }, +]; + function pushToMarketplace( marketplaceId, listingData, @@ -45,11 +72,11 @@ function pushToMarketplace( } if (isDelete) { - deleteExternalListing(marketplace, user, listingData); + await deleteExternalListing(marketplace, user, listingData); } else if (isNew) { - createExternalListing(marketplace, user, listingData); + await createExternalListing(marketplace, user, listingData); } else { - updateExternalListing(marketplace, user, listingData); + await updateExternalListing(marketplace, user, listingData); } } catch (err) { logger.warn(`Failed to initiate marketplace sync for listing: ${err.message}`); @@ -79,7 +106,7 @@ export const listListingsRouteHandler = async ( search, sort, order, - populate: ['product', 'vendor', 'stockLocation', 'marketplace', 'courierServices'], + populate: LISTING_POPULATE, }); if (result?.error) { @@ -104,7 +131,7 @@ export const listListingsByPropertiesRouteHandler = async ( properties, filter, masterFilter, - populate: ['product', 'vendor', 'stockLocation', 'marketplace', 'courierServices'], + populate: LISTING_POPULATE, }); if (result?.error) { @@ -138,7 +165,7 @@ export const getListingRouteHandler = async (req, res) => { const result = await getObject({ model: listingModel, id, - populate: ['product', 'vendor', 'stockLocation', 'marketplace', 'courierServices'], + populate: LISTING_POPULATE, }); if (result?.error) { logger.warn(`Listing not found with supplied id.`); @@ -164,13 +191,19 @@ export const editListingRouteHandler = async (req, res) => { url: req.body.url, condition: req.body.condition, courierServices: req.body.courierServices, + fulfillmentPolicy: req.body.fulfillmentPolicy, + paymentPolicy: req.body.paymentPolicy, + returnPolicy: req.body.returnPolicy, + listingImages: Array.isArray(req.body.listingImages) + ? req.body.listingImages.map((item) => item?._id || item) + : req.body.listingImages, }; const result = await editObject({ model: listingModel, id, updateData, user: req.user, - populate: ['product', 'vendor', 'stockLocation', 'marketplace', 'courierServices'], + populate: LISTING_POPULATE, }); if (result.error) { @@ -192,7 +225,6 @@ export const editListingRouteHandler = async (req, res) => { if (marketplaceId) { pushToMarketplace(marketplaceId, { _id: id }, req.user, { isNew: false }); } - return; } logger.debug(`Edited listing with ID: ${id}`); @@ -212,6 +244,12 @@ export const newListingRouteHandler = async (req, res) => { url: req.body.url, condition: req.body.condition, courierServices: req.body.courierServices, + fulfillmentPolicy: req.body.fulfillmentPolicy, + paymentPolicy: req.body.paymentPolicy, + returnPolicy: req.body.returnPolicy, + listingImages: Array.isArray(req.body.listingImages) + ? req.body.listingImages.map((item) => item?._id || item) + : req.body.listingImages, }; const result = await newObject({ model: listingModel, @@ -293,6 +331,35 @@ export const getListingHistoryRouteHandler = async (req, res) => { res.send(result); }; +async function setListingBusyState(listingId, stateType, user, varientIds = []) { + const listingResult = await editObject({ + model: listingModel, + id: listingId, + updateData: { + updatedAt: new Date(), + state: { type: stateType, progress: 0.05 }, + }, + user, + recalculate: false, + populate: LISTING_POPULATE, + }); + for (const varientId of varientIds) { + await editObject({ + model: listingVarientModel, + id: varientId, + updateData: { + updatedAt: new Date(), + state: { type: stateType }, + }, + user, + recalculate: false, + }).catch((err) => { + logger.warn(`Failed to set listing varient ${stateType} state: ${err.message}`); + }); + } + return listingResult; +} + export const publishListingRouteHandler = async (req, res) => { const id = new mongoose.Types.ObjectId(req.params.id); @@ -312,21 +379,21 @@ export const publishListingRouteHandler = async (req, res) => { }); } - const syncingCheck = await checkStates({ + const busyCheck = await checkStates({ model: listingModel, id, - states: ['syncing'], + states: MARKETPLACE_BUSY_STATES, }); - if (syncingCheck === true) { + if (busyCheck === true) { return res.status(400).send({ - error: 'Listing is syncing; wait for sync to finish before publishing.', + error: 'Listing is publishing, unpublishing, or syncing; wait for it to finish.', code: 400, }); } const listing = await listingModel .findById(id) - .populate(['marketplace', 'vendor', 'stockLocation', 'courierServices']) + .populate(LISTING_POPULATE) .lean(); if (!listing) { return res.status(404).send({ error: 'Listing not found.', code: 404 }); @@ -365,69 +432,43 @@ export const publishListingRouteHandler = async (req, res) => { }); } - try { - let firstListingId; - for (const v of toPublish) { - const apiResult = await publishMarketplaceOfferForSku( - marketplace, - req.user, - marketplaceSku(v), - listing - ); - await editObject({ - model: listingVarientModel, - id: v._id, - updateData: { - updatedAt: new Date(), - state: { type: 'active' }, - lastSyncedAt: new Date(), - }, - user: req.user, - }); - if (apiResult?.listingId && !firstListingId) { - firstListingId = apiResult.listingId; - } - } - - const listingUpdate = { - updatedAt: new Date(), - state: { type: 'active' }, - lastSyncedAt: new Date(), - }; - if (firstListingId) { - listingUpdate.url = `https://www.ebay.com/itm/${firstListingId}`; - } - await editObject({ - model: listingModel, - id, - updateData: listingUpdate, - user: req.user, - populate: ['product', 'vendor', 'stockLocation', 'marketplace', 'courierServices'], - }); - - const updated = await getObject({ - model: listingModel, - id, - populate: ['product', 'vendor', 'stockLocation', 'marketplace', 'courierServices'], - }); - res.send(updated); - } catch (err) { - logger.error(`Publish listing failed: ${err.message}`); - res.status(500).send({ error: err.message, code: 500 }); + const restoreStateType = listing.state?.type || 'draft'; + const updated = await setListingBusyState( + id, + 'publishing', + req.user, + toPublish.map((v) => v._id) + ); + if (updated?.error) { + return res.status(updated.code).send(updated); } + + try { + await publishExternalListing(marketplace, req.user, listing, { + varientIds: toPublish.map((v) => v._id), + restoreStateType, + varientRestoreStateType: 'draft', + }); + } catch (err) { + logger.error(`Failed to enqueue listing publish: ${err.message}`); + return res.status(500).send({ error: err.message, code: 500 }); + } + + logger.debug(`Publish listing started for ID: ${id}`); + res.send(updated); }; export const unpublishListingRouteHandler = async (req, res) => { const id = new mongoose.Types.ObjectId(req.params.id); - const syncingCheck = await checkStates({ + const busyCheck = await checkStates({ model: listingModel, id, - states: ['syncing'], + states: MARKETPLACE_BUSY_STATES, }); - if (syncingCheck === true) { + if (busyCheck === true) { return res.status(400).send({ - error: 'Listing is syncing; wait for sync to finish before unpublishing.', + error: 'Listing is publishing, unpublishing, or syncing; wait for it to finish.', code: 400, }); } @@ -463,43 +504,30 @@ export const unpublishListingRouteHandler = async (req, res) => { }); } - try { - for (const v of toUnpublish) { - await withdrawMarketplaceOfferForSku(marketplace, req.user, marketplaceSku(v)); - await editObject({ - model: listingVarientModel, - id: v._id, - updateData: { - updatedAt: new Date(), - state: { type: 'draft' }, - lastSyncedAt: new Date(), - }, - user: req.user, - }); - } - - await editObject({ - model: listingModel, - id, - updateData: { - updatedAt: new Date(), - state: { type: 'draft' }, - lastSyncedAt: new Date(), - }, - user: req.user, - populate: ['product', 'vendor', 'stockLocation', 'marketplace', 'courierServices'], - }); - - const updated = await getObject({ - model: listingModel, - id, - populate: ['product', 'vendor', 'stockLocation', 'marketplace', 'courierServices'], - }); - res.send(updated); - } catch (err) { - logger.error(`Unpublish listing failed: ${err.message}`); - res.status(500).send({ error: err.message, code: 500 }); + const restoreStateType = listing.state?.type || 'active'; + const updated = await setListingBusyState( + id, + 'unpublishing', + req.user, + toUnpublish.map((v) => v._id) + ); + if (updated?.error) { + return res.status(updated.code).send(updated); } + + try { + await unpublishExternalListing(marketplace, req.user, listing, { + varientIds: toUnpublish.map((v) => v._id), + restoreStateType, + varientRestoreStateType: 'active', + }); + } catch (err) { + logger.error(`Failed to enqueue listing unpublish: ${err.message}`); + return res.status(500).send({ error: err.message, code: 500 }); + } + + logger.debug(`Unpublish listing started for ID: ${id}`); + res.send(updated); }; export const getListingNeighborsRouteHandler = async ( req, diff --git a/src/services/sales/listingvarients.js b/src/services/sales/listingvarients.js index 07de971..424b747 100644 --- a/src/services/sales/listingvarients.js +++ b/src/services/sales/listingvarients.js @@ -17,17 +17,41 @@ import { getObjectNeighbors, } from '../../database/database.js'; import { listingModel } from '../../database/schemas/sales/listing.schema.js'; +import { marketplaceModel } from '../../database/schemas/sales/marketplace.schema.js'; import { hasIntegration, - publishMarketplaceOfferForSku, - withdrawMarketplaceOfferForSku, - marketplaceSku, -} from '../../integrations/marketplaceworker.js'; + publishListing as publishExternalListing, + unpublishListing as unpublishExternalListing, + updateListing as updateExternalListing, + MARKETPLACE_BUSY_STATES, +} from '../../integrations/marketplace.js'; const logger = log4js.getLogger('ListingVarients'); logger.level = config.server.logLevel; -const POPULATE_FIELDS = ['listing', 'product', 'productSku', 'priceTaxRate']; +function pushListingToMarketplace(listingId, marketplaceId, user) { + const run = async () => { + try { + const marketplace = await marketplaceModel.findById(marketplaceId); + if (!marketplace || !marketplace.active || !hasIntegration(marketplace.provider)) { + return; + } + await updateExternalListing(marketplace, user, { _id: listingId }); + } catch (err) { + logger.warn(`Failed to initiate marketplace sync for listing: ${err.message}`); + } + }; + + run(); +} + +const POPULATE_FIELDS = [ + 'listing', + 'product', + 'productSku', + 'priceTaxRate', + 'listingImages', +]; export const listListingVarientsRouteHandler = async ( req, @@ -128,10 +152,14 @@ export const editListingVarientRouteHandler = async (req, res) => { listing: req.body.listing, product: req.body.product, productSku: req.body.productSku, + aspects: req.body.aspects, price: req.body.price, currency: req.body.currency, priceTaxRate: req.body.priceTaxRate, priceWithTax: req.body.priceWithTax, + listingImages: Array.isArray(req.body.listingImages) + ? req.body.listingImages.map((item) => item?._id || item) + : req.body.listingImages, }; const result = await editObject({ model: listingVarientModel, @@ -147,6 +175,32 @@ export const editListingVarientRouteHandler = async (req, res) => { return; } + const listingId = result.listing?._id || result.listing; + if (listingId) { + const checkStatesResult = await checkStates({ + model: listingModel, + id: listingId, + states: ['draft'], + }); + + if (checkStatesResult.error) { + logger.error('Error checking listing states:', checkStatesResult.error); + res.status(checkStatesResult.code).send(checkStatesResult); + return; + } + + if (checkStatesResult == false) { + const parentListing = + result.listing?.marketplace != null + ? result.listing + : await listingModel.findById(listingId).select('marketplace').lean(); + const marketplaceId = parentListing?.marketplace?._id || parentListing?.marketplace; + if (marketplaceId) { + pushListingToMarketplace(listingId, marketplaceId, req.user); + } + } + } + logger.debug(`Edited listing varient with ID: ${id}`); res.send(result); }; @@ -157,11 +211,15 @@ export const newListingVarientRouteHandler = async (req, res) => { listing: req.body.listing, product: req.body.product, productSku: req.body.productSku, + aspects: req.body.aspects, state: req.body.state || { type: 'draft' }, price: req.body.price, currency: req.body.currency, priceTaxRate: req.body.priceTaxRate, priceWithTax: req.body.priceWithTax, + listingImages: Array.isArray(req.body.listingImages) + ? req.body.listingImages.map((item) => item?._id || item) + : req.body.listingImages, }; const result = await newObject({ model: listingVarientModel, @@ -223,6 +281,7 @@ const VARIENT_POPULATE_MARKETPLACE = [ 'product', 'productSku', 'priceTaxRate', + 'listingImages', ]; export const publishListingVarientRouteHandler = async (req, res) => { @@ -244,27 +303,30 @@ export const publishListingVarientRouteHandler = async (req, res) => { }); } - const syncingCheck = await checkStates({ + const busyCheck = await checkStates({ model: listingVarientModel, id, - states: ['syncing'], + states: MARKETPLACE_BUSY_STATES, }); - if (syncingCheck === true) { + if (busyCheck === true) { return res.status(400).send({ - error: 'Listing varient is syncing; wait for sync to finish before publishing.', + error: 'Listing varient is publishing, unpublishing, or syncing; wait for it to finish.', code: 400, }); } const doc = await listingVarientModel .findById(id) + .populate(['product', 'productSku', 'listingImages']) .populate({ path: 'listing', populate: [ + { path: 'product' }, { path: 'marketplace' }, { path: 'vendor' }, { path: 'stockLocation' }, { path: 'courierServices' }, + { path: 'listingImages' }, ], }) .lean(); @@ -296,60 +358,61 @@ export const publishListingVarientRouteHandler = async (req, res) => { }); } - try { - if (!doc.listing?.stockLocation) { - return res.status(400).send({ - error: 'Listing must have a stock location before publishing.', - code: 400, - }); - } + if (!doc.listing?.stockLocation) { + return res.status(400).send({ + error: 'Listing must have a stock location before publishing.', + code: 400, + }); + } - const apiResult = await publishMarketplaceOfferForSku( - marketplace, - req.user, - marketplaceSku(doc), - doc.listing - ); + const listingId = doc.listing?._id || doc.listing; + const restoreStateType = doc.listing?.state?.type || 'draft'; + const varientRestoreStateType = doc.state?.type || 'draft'; + await editObject({ + model: listingVarientModel, + id, + updateData: { + updatedAt: new Date(), + state: { type: 'publishing' }, + }, + user: req.user, + recalculate: false, + }); + + if (listingId) { await editObject({ - model: listingVarientModel, - id, + model: listingModel, + id: listingId, updateData: { updatedAt: new Date(), - state: { type: 'active' }, - lastSyncedAt: new Date(), + state: { type: 'publishing', progress: 0.05 }, }, user: req.user, + recalculate: false, + }).catch((err) => { + logger.warn(`Failed to set listing publishing state: ${err.message}`); }); - - const listingId = doc.listing?._id || doc.listing; - if (listingId) { - const listingUpdate = { - updatedAt: new Date(), - state: { type: 'active' }, - lastSyncedAt: new Date(), - }; - if (apiResult?.listingId) { - listingUpdate.url = `https://www.ebay.com/itm/${apiResult.listingId}`; - } - await editObject({ - model: listingModel, - id: listingId, - updateData: listingUpdate, - user: req.user, - }); - } - - const updated = await getObject({ - model: listingVarientModel, - id, - populate: VARIENT_POPULATE_MARKETPLACE, - }); - res.send(updated); - } catch (err) { - logger.error(`Publish listing varient failed: ${err.message}`); - res.status(500).send({ error: err.message, code: 500 }); } + + try { + await publishExternalListing(marketplace, req.user, doc.listing, { + varientIds: [id], + restoreStateType, + varientRestoreStateType, + }); + } catch (err) { + logger.error(`Failed to enqueue listing varient publish: ${err.message}`); + return res.status(500).send({ error: err.message, code: 500 }); + } + + const updated = await getObject({ + model: listingVarientModel, + id, + populate: VARIENT_POPULATE_MARKETPLACE, + }); + logger.debug(`Publish listing varient started for ID: ${id}`); + res.send(updated); }; export const unpublishListingVarientRouteHandler = async (req, res) => { @@ -371,14 +434,14 @@ export const unpublishListingVarientRouteHandler = async (req, res) => { }); } - const syncingCheck = await checkStates({ + const busyCheck = await checkStates({ model: listingVarientModel, id, - states: ['syncing'], + states: MARKETPLACE_BUSY_STATES, }); - if (syncingCheck === true) { + if (busyCheck === true) { return res.status(400).send({ - error: 'Listing varient is syncing; wait for sync to finish before unpublishing.', + error: 'Listing varient is publishing, unpublishing, or syncing; wait for it to finish.', code: 400, }); } @@ -415,50 +478,54 @@ export const unpublishListingVarientRouteHandler = async (req, res) => { }); } - try { - await withdrawMarketplaceOfferForSku(marketplace, req.user, marketplaceSku(doc)); + const parentListingId = doc.listing?._id || doc.listing; + const restoreStateType = doc.listing?.state?.type || 'active'; + const varientRestoreStateType = doc.state?.type || 'active'; + await editObject({ + model: listingVarientModel, + id, + updateData: { + updatedAt: new Date(), + state: { type: 'unpublishing' }, + }, + user: req.user, + recalculate: false, + }); + + if (parentListingId) { await editObject({ - model: listingVarientModel, - id, + model: listingModel, + id: parentListingId, updateData: { updatedAt: new Date(), - state: { type: 'draft' }, - lastSyncedAt: new Date(), + state: { type: 'unpublishing', progress: 0.05 }, }, user: req.user, + recalculate: false, + }).catch((err) => { + logger.warn(`Failed to set listing unpublishing state: ${err.message}`); }); - - const parentListingId = doc.listing?._id || doc.listing; - if (parentListingId) { - const siblings = await listingVarientModel.find({ listing: parentListingId }).lean(); - const anyActive = siblings.some( - (v) => String(v._id) !== String(id) && v.state?.type === 'active' - ); - if (!anyActive) { - await editObject({ - model: listingModel, - id: parentListingId, - updateData: { - updatedAt: new Date(), - state: { type: 'draft' }, - lastSyncedAt: new Date(), - }, - user: req.user, - }); - } - } - - const updated = await getObject({ - model: listingVarientModel, - id, - populate: VARIENT_POPULATE_MARKETPLACE, - }); - res.send(updated); - } catch (err) { - logger.error(`Unpublish listing varient failed: ${err.message}`); - res.status(500).send({ error: err.message, code: 500 }); } + + try { + await unpublishExternalListing(marketplace, req.user, doc.listing, { + varientIds: [id], + restoreStateType, + varientRestoreStateType, + }); + } catch (err) { + logger.error(`Failed to enqueue listing varient unpublish: ${err.message}`); + return res.status(500).send({ error: err.message, code: 500 }); + } + + const updated = await getObject({ + model: listingVarientModel, + id, + populate: VARIENT_POPULATE_MARKETPLACE, + }); + logger.debug(`Unpublish listing varient started for ID: ${id}`); + res.send(updated); }; export const getListingVarientNeighborsRouteHandler = async ( req, @@ -491,4 +558,3 @@ export const getListingVarientNeighborsRouteHandler = async ( logger.debug(`Retrieved listingVarient neighbors for ID: ${id}`); res.send(result); }; - diff --git a/src/services/sales/marketplaces.js b/src/services/sales/marketplaces.js index fe5eefd..6225681 100644 --- a/src/services/sales/marketplaces.js +++ b/src/services/sales/marketplaces.js @@ -15,10 +15,16 @@ import { getPropertyValues, getObjectNeighbors, } from '../../database/database.js'; -import * as marketplaceIntegration from '../../integrations/marketplaceworker.js'; +import * as marketplaceIntegration from '../../integrations/marketplace.js'; const logger = log4js.getLogger('Marketplaces'); logger.level = config.server.logLevel; +const MARKETPLACE_POPULATE = [ + 'defaultFulfillmentPolicy', + 'defaultPaymentPolicy', + 'defaultReturnPolicy', +]; + export const listMarketplacesRouteHandler = async ( req, res, @@ -39,6 +45,7 @@ export const listMarketplacesRouteHandler = async ( search, sort, order, + populate: MARKETPLACE_POPULATE, }); if (result?.error) { @@ -63,6 +70,7 @@ export const listMarketplacesByPropertiesRouteHandler = async ( properties, filter, masterFilter, + populate: MARKETPLACE_POPULATE, }); if (result?.error) { @@ -87,6 +95,7 @@ export const searchMarketplacesRouteHandler = async (req, res, search) => { const result = await searchObjects({ model: marketplaceModel, search, + populate: MARKETPLACE_POPULATE, }); res.send(result); }; @@ -96,6 +105,7 @@ export const getMarketplaceRouteHandler = async (req, res) => { const result = await getObject({ model: marketplaceModel, id, + populate: MARKETPLACE_POPULATE, }); if (result?.error) { logger.warn(`Marketplace not found with supplied id.`); @@ -115,6 +125,9 @@ export const editMarketplaceRouteHandler = async (req, res) => { name: req.body.name, provider: req.body.provider, active: req.body.active, + defaultFulfillmentPolicy: req.body.defaultFulfillmentPolicy, + defaultPaymentPolicy: req.body.defaultPaymentPolicy, + defaultReturnPolicy: req.body.defaultReturnPolicy, config: req.body.config || {}, }; const result = await editObject({ @@ -122,6 +135,7 @@ export const editMarketplaceRouteHandler = async (req, res) => { id, updateData, user: req.user, + populate: MARKETPLACE_POPULATE, }); if (result.error) { @@ -142,6 +156,9 @@ export const newMarketplaceRouteHandler = async (req, res) => { active: req.body.active !== false, connected: req.body.connected === true, state: req.body.state || { type: req.body.active ? 'disconnected' : 'inactive' }, + defaultFulfillmentPolicy: req.body.defaultFulfillmentPolicy, + defaultPaymentPolicy: req.body.defaultPaymentPolicy, + defaultReturnPolicy: req.body.defaultReturnPolicy, config: req.body.config || {}, }; const result = await newObject({ @@ -222,7 +239,7 @@ export const getMarketplaceAuthUrlRouteHandler = async (req, res) => { } try { - const url = marketplaceIntegration.getAuthorizationUrl(marketplace, { + const url = await marketplaceIntegration.getAuthorizationUrl(marketplace, { state: req.query.state, }); @@ -257,9 +274,13 @@ export const exchangeMarketplaceAuthCodeRouteHandler = async (req, res) => { }); logger.info(`Marketplace authorization completed for ${marketplace.name}`); - marketplaceIntegration.ensureWebhookSubscriptions(result.marketplace || marketplace, req.user).catch((err) => { - logger.warn(`Failed to subscribe marketplace webhooks for ${marketplace.name}: ${err.message}`); - }); + marketplaceIntegration + .ensureWebhookSubscriptions(result.marketplace || marketplace, req.user) + .catch((err) => { + logger.warn( + `Failed to subscribe marketplace webhooks for ${marketplace.name}: ${err.message}` + ); + }); res.send({ success: true, ...result }); } catch (err) { logger.error('Error exchanging marketplace authorization code:', err.message); @@ -325,6 +346,55 @@ export const syncMarketplaceRouteHandler = async (req, res) => { } }; +async function runMarketplacePolicySync(req, res, methodName, label) { + const id = req.params.id; + const marketplace = await getObject({ model: marketplaceModel, id }); + if (marketplace?.error) { + logger.warn(`Marketplace not found for ${label} 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, + }); + } + + if (typeof marketplaceIntegration[methodName] !== 'function') { + return res.status(400).send({ + error: `Sync ${label} is not available.`, + code: 400, + }); + } + + marketplaceIntegration[methodName](marketplace, req.user)?.catch?.((err) => { + logger.error(`Error syncing ${label} for marketplace ${marketplace.name}:`, err.message); + }); + logger.info(`${label} sync started for marketplace ${marketplace.name}`); + res.send({ success: true, message: `${label} sync started` }); +} + +export const syncMarketplaceFulfillmentPoliciesRouteHandler = async (req, res) => { + await runMarketplacePolicySync(req, res, 'syncFulfillmentPolicies', 'fulfillment policies'); +}; + +export const syncMarketplacePaymentPoliciesRouteHandler = async (req, res) => { + await runMarketplacePolicySync(req, res, 'syncPaymentPolicies', 'payment policies'); +}; + +export const syncMarketplaceReturnPoliciesRouteHandler = async (req, res) => { + await runMarketplacePolicySync(req, res, 'syncReturnPolicies', 'return policies'); +}; + +export const syncMarketplaceTaxRatesRouteHandler = async (req, res) => { + await runMarketplacePolicySync(req, res, 'syncTaxRates', 'tax rates'); +}; + export const syncMarketplaceItemsRouteHandler = async (req, res) => { const id = req.params.id; @@ -429,12 +499,14 @@ export const marketplaceWebhookChallengeRouteHandler = async (req, res) => { `${req.protocol}://${req.get('host')}/marketplaces/${id}/hook`; try { - const result = marketplaceIntegration.buildWebhookChallengeResponse(marketplace, { + const result = await marketplaceIntegration.buildWebhookChallengeResponse(marketplace, { challengeCode, endpoint, }); if (!result) { - return res.status(400).send({ error: 'Provider does not support webhook challenges.', code: 400 }); + return res + .status(400) + .send({ error: 'Provider does not support webhook challenges.', code: 400 }); } res.send(result); } catch (err) { @@ -450,7 +522,9 @@ export const subscribeMarketplaceWebhooksRouteHandler = async (req, res) => { return res.status(marketplace.code).send(marketplace); } try { - const result = await marketplaceIntegration.ensureWebhookSubscriptions(marketplace, req.user); + const result = await marketplaceIntegration.ensureWebhookSubscriptions(marketplace, req.user, { + wait: true, + }); res.send({ success: true, ...result }); } catch (err) { logger.error('Error subscribing marketplace webhooks:', err.message); @@ -465,7 +539,9 @@ export const debugEbayRouteHandler = async (req, res) => { 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 }); + 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 }); @@ -512,4 +588,3 @@ export const getMarketplaceNeighborsRouteHandler = async ( logger.debug(`Retrieved marketplace neighbors for ID: ${id}`); res.send(result); }; - diff --git a/src/services/sales/returnpolicies.js b/src/services/sales/returnpolicies.js new file mode 100644 index 0000000..3547ce3 --- /dev/null +++ b/src/services/sales/returnpolicies.js @@ -0,0 +1,239 @@ +import config from '../../config.js'; +import { returnPolicyModel } from '../../database/schemas/sales/returnpolicy.schema.js'; +import log4js from 'log4js'; +import mongoose from 'mongoose'; +import { + deleteObject, + listObjects, + getObject, + editObject, + newObject, + listObjectsByProperties, + getModelStats, + getModelHistory, + searchObjects, + getPropertyValues, + getObjectNeighbors, +} from '../../database/database.js'; +import { syncReturnPolicy } from '../../integrations/marketplace.js'; +import { startPolicyMarketplaceSync } from '../misc/syncPolicyMarketplaces.js'; + +const logger = log4js.getLogger('ReturnPolicies'); +logger.level = config.server.logLevel; + +export const RETURN_POLICY_POPULATE = ['marketplaces.marketplace']; + +const policyFields = (body = {}) => ({ + name: body.name, + description: body.description, + returnsAccepted: body.returnsAccepted, + returnPeriodDays: body.returnPeriodDays, + returnShippingCostPayer: body.returnShippingCostPayer, + refundMethod: body.refundMethod, + restockingFeePercentage: body.restockingFeePercentage, + returnInstructions: body.returnInstructions, + internationalReturnsAccepted: body.internationalReturnsAccepted, + internationalReturnPeriodDays: body.internationalReturnPeriodDays, + internationalReturnShippingCostPayer: body.internationalReturnShippingCostPayer, + marketplaces: body.marketplaces, +}); + +export const listReturnPoliciesRouteHandler = async ( + req, + res, + page = 1, + limit = 25, + property = '', + filter = {}, + search = '', + sort = '', + order = 'ascend' +) => { + const result = await listObjects({ + model: returnPolicyModel, + page, + limit, + property, + filter, + search, + sort, + order, + populate: RETURN_POLICY_POPULATE, + }); + + if (result?.error) { + logger.error('Error listing return policies.'); + res.status(result.code).send(result); + return; + } + + logger.debug(`List of return policies (Page ${page}, Limit ${limit}). Count: ${result.length}.`); + res.send(result); +}; + +export const listReturnPoliciesByPropertiesRouteHandler = async ( + req, + res, + properties = '', + filter = {}, + masterFilter = {} +) => { + const result = await listObjectsByProperties({ + model: returnPolicyModel, + properties, + filter, + masterFilter, + populate: RETURN_POLICY_POPULATE, + }); + + if (result?.error) { + logger.error('Error listing return policies.'); + res.status(result.code).send(result); + return; + } + + logger.debug(`List of return policies. Count: ${result.length}`); + res.send(result); +}; + +export const getReturnPolicyPropertyValuesRouteHandler = async (req, res, property) => { + const result = await getPropertyValues({ + model: returnPolicyModel, + property, + }); + res.send(result); +}; + +export const searchReturnPoliciesRouteHandler = async (req, res, search) => { + const result = await searchObjects({ + model: returnPolicyModel, + search, + populate: RETURN_POLICY_POPULATE, + }); + res.send(result); +}; + +export const getReturnPolicyRouteHandler = async (req, res) => { + const id = req.params.id; + const result = await getObject({ + model: returnPolicyModel, + id, + populate: RETURN_POLICY_POPULATE, + }); + if (result?.error) { + logger.warn('Return policy not found with supplied id.'); + return res.status(result.code).send(result); + } + logger.debug(`Retrieved return policy with ID: ${id}`); + res.send(result); +}; + +export const editReturnPolicyRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + const result = await editObject({ + model: returnPolicyModel, + id, + updateData: { updatedAt: new Date(), ...policyFields(req.body) }, + user: req.user, + populate: RETURN_POLICY_POPULATE, + }); + + if (result.error) { + logger.error('Error editing return policy:', result.error); + res.status(result.code).send(result); + return; + } + + logger.debug(`Edited return policy with ID: ${id}`); + const synced = await startPolicyMarketplaceSync({ + policy: result, + user: req.user, + syncFn: syncReturnPolicy, + logger, + model: returnPolicyModel, + populate: RETURN_POLICY_POPULATE, + }); + res.send(synced || result); +}; + +export const newReturnPolicyRouteHandler = async (req, res) => { + const result = await newObject({ + model: returnPolicyModel, + newData: { updatedAt: new Date(), ...policyFields(req.body) }, + user: req.user, + }); + if (result.error) { + logger.error('No return policy created:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`New return policy with ID: ${result._id}`); + res.send(result); +}; + +export const deleteReturnPolicyRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + const result = await deleteObject({ + model: returnPolicyModel, + id, + user: req.user, + }); + if (result.error) { + logger.error('No return policy deleted:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`Deleted return policy with ID: ${result._id}`); + res.send(result); +}; + +export const getReturnPolicyStatsRouteHandler = async (req, res) => { + const result = await getModelStats({ model: returnPolicyModel }); + if (result?.error) { + logger.error('Error fetching return policy stats:', result.error); + return res.status(result.code).send(result); + } + res.send(result); +}; + +export const getReturnPolicyHistoryRouteHandler = async (req, res) => { + const from = req.query.from; + const to = req.query.to; + const result = await getModelHistory({ model: returnPolicyModel, from, to }); + if (result?.error) { + logger.error('Error fetching return policy history:', result.error); + return res.status(result.code).send(result); + } + res.send(result); +}; + +export const getReturnPolicyNeighborsRouteHandler = async ( + req, + res, + property = '', + filter = {}, + search = '', + sort = '', + order = 'ascend', + id +) => { + if (!id) { + return res.status(400).send({ error: 'Missing id parameter', code: 400 }); + } + + const result = await getObjectNeighbors({ + model: returnPolicyModel, + id, + filter, + search, + sort, + order, + }); + + if (result?.error) { + logger.error('Error fetching returnPolicy neighbors.'); + return res.status(result.code).send(result); + } + + res.send(result); +}; diff --git a/src/utils.js b/src/utils.js index 9ce604e..19a4df7 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1072,6 +1072,7 @@ const DISTRIBUTE_KEYS = { _id: true, _reference: true, name: true, + type: true, tags: true, state: true, createdAt: true, @@ -1676,7 +1677,6 @@ async function getFilter(query, allowedFilters, parse = true, model = null) { for (const key of ['sortProperty', 'sortOrder', 'page', 'limit']) { if (key in queryClean) delete queryClean[key]; } - logger.info('queryExcludingSortAndOrder', queryClean); if (!parse && queryClean['order._id'] !== undefined && allowedFilters.includes('order')) { queryClean.order = queryClean['order._id']; delete queryClean['order._id'];