Enhanced courier service and listing schemas by adding cost-related fields and integrating courier services into listings. Updated route handlers and service logic to accommodate new fields, improving data management and retrieval capabilities.
All checks were successful
farmcontrol/farmcontrol-api/pipeline/head This commit looks good
All checks were successful
farmcontrol/farmcontrol-api/pipeline/head This commit looks good
This commit is contained in:
parent
561520be9a
commit
e192c04000
@ -11,6 +11,13 @@ const courierServiceSchema = new mongoose.Schema(
|
||||
tracked: { required: true, type: Boolean },
|
||||
deliveryTime: { required: true, type: Number },
|
||||
website: { required: false, type: String },
|
||||
cost: { required: true, type: Number, default: 0 },
|
||||
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||
costWithTax: { required: false, type: Number },
|
||||
additionalCost: { required: false, type: Number },
|
||||
additionalCostWithTax: { required: false, type: Number },
|
||||
shippingCurrency: { required: true, type: String, default: 'GBP' },
|
||||
international: { required: true, type: Boolean, default: false },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
@ -22,6 +22,7 @@ const listingSchema = new Schema(
|
||||
price: { type: Number, required: false },
|
||||
currency: { type: String, required: false },
|
||||
lastSyncedAt: { type: Date, required: false },
|
||||
courierServices: [{ type: Schema.Types.ObjectId, ref: 'courierService', required: true }],
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
121
src/integrations/marketplaces/ebay/categories.js
Normal file
121
src/integrations/marketplaces/ebay/categories.js
Normal file
@ -0,0 +1,121 @@
|
||||
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';
|
||||
|
||||
const categoryTreeIds = new Map();
|
||||
const categoryMatches = new Map();
|
||||
|
||||
function isPopulated(value) {
|
||||
return value && typeof value === 'object' && !(value instanceof mongoose.Types.ObjectId);
|
||||
}
|
||||
|
||||
async function resolveProduct(listing, varients) {
|
||||
const productRef = listing?.product || varients?.find((varient) => varient?.product)?.product;
|
||||
if (!productRef) return null;
|
||||
|
||||
if (isPopulated(productRef) && productRef.productCategory) {
|
||||
return productRef;
|
||||
}
|
||||
|
||||
const productId = productRef._id || productRef;
|
||||
return productModel.findById(productId).populate('productCategory').lean();
|
||||
}
|
||||
|
||||
async function resolveProductCategory(listing, varients) {
|
||||
const product = await resolveProduct(listing, varients);
|
||||
const categoryRef = product?.productCategory;
|
||||
if (!categoryRef) return null;
|
||||
|
||||
if (isPopulated(categoryRef) && categoryRef.name) {
|
||||
return categoryRef;
|
||||
}
|
||||
|
||||
const categoryId = categoryRef._id || categoryRef;
|
||||
return productCategoryModel.findById(categoryId).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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function selectCategorySuggestion(suggestions, categoryName) {
|
||||
const normalizedName = categoryName.trim().toLocaleLowerCase();
|
||||
return (
|
||||
suggestions.find(
|
||||
(suggestion) =>
|
||||
suggestion?.category?.categoryName?.trim().toLocaleLowerCase() === normalizedName
|
||||
) || suggestions[0]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function syncProductCategory(marketplace, listing, varients = []) {
|
||||
if (listing?.categoryId) {
|
||||
return { categoryId: String(listing.categoryId) };
|
||||
}
|
||||
|
||||
const productCategory = await resolveProductCategory(listing, varients);
|
||||
if (!productCategory?.name) {
|
||||
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) {
|
||||
throw new Error(
|
||||
`No eBay category found for product category "${productCategory.name}" on ${marketplaceId}`
|
||||
);
|
||||
}
|
||||
|
||||
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})`
|
||||
);
|
||||
return category;
|
||||
}
|
||||
215
src/integrations/marketplaces/ebay/fulfillmentPolicies.js
Normal file
215
src/integrations/marketplaces/ebay/fulfillmentPolicies.js
Normal file
@ -0,0 +1,215 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { courierServiceModel } from '../../../database/schemas/management/courierservice.schema.js';
|
||||
import { makeRequest, logger } from './shared.js';
|
||||
|
||||
const SELLING_POLICY_PROGRAM = 'SELLING_POLICY_MANAGEMENT';
|
||||
|
||||
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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isPopulatedCourierService(service) {
|
||||
return (
|
||||
service &&
|
||||
typeof service === 'object' &&
|
||||
!(service instanceof mongoose.Types.ObjectId) &&
|
||||
service.name != null &&
|
||||
service.courier &&
|
||||
typeof service.courier === 'object' &&
|
||||
!(service.courier instanceof mongoose.Types.ObjectId) &&
|
||||
service.courier._reference
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveCourierServices(listing) {
|
||||
const serviceRefs = listing?.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')
|
||||
.lean();
|
||||
const servicesById = new Map(services.map((service) => [String(service._id), service]));
|
||||
|
||||
return serviceIds.map((id) => servicesById.get(String(id))).filter(Boolean);
|
||||
}
|
||||
|
||||
function validateCourierServices(services, listing) {
|
||||
const activeServices = services.filter((service) => service.active !== false);
|
||||
if (!activeServices.length) {
|
||||
throw new Error(
|
||||
`Listing "${listing._reference}" must have at least one active courier service before syncing with eBay.`
|
||||
);
|
||||
}
|
||||
|
||||
for (const service of activeServices) {
|
||||
if (!service._reference) {
|
||||
throw new Error(
|
||||
`Courier service "${service.name}" requires a reference containing its eBay shipping service code.`
|
||||
);
|
||||
}
|
||||
if (!service.courier?._reference) {
|
||||
throw new Error(
|
||||
`The courier for service "${service.name}" requires a reference containing its eBay shipping carrier code.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const domesticCount = activeServices.filter((service) => !service.international).length;
|
||||
const internationalCount = activeServices.length - domesticCount;
|
||||
if (domesticCount > 4 || internationalCount > 5) {
|
||||
throw new Error(
|
||||
'eBay fulfillment policies support at most four domestic and five international courier services.'
|
||||
);
|
||||
}
|
||||
|
||||
return activeServices;
|
||||
}
|
||||
|
||||
function buildShippingService(service, index, defaultCurrency) {
|
||||
const cost = Number(service.costWithTax ?? service.cost ?? 0);
|
||||
const additionalCost = Number(service.additionalCostWithTax ?? service.additionalCost ?? 0);
|
||||
const shippingService = {
|
||||
sortOrder: index + 1,
|
||||
shippingCarrierCode: service.courier._reference,
|
||||
shippingServiceCode: service._reference,
|
||||
buyerResponsibleForShipping: false,
|
||||
freeShipping: cost === 0 && additionalCost === 0,
|
||||
shippingCost: {
|
||||
value: String(cost),
|
||||
currency: service.shippingCurrency || defaultCurrency,
|
||||
},
|
||||
};
|
||||
|
||||
if (service.additionalCostWithTax != null || service.additionalCost != null) {
|
||||
shippingService.additionalShippingCost = {
|
||||
value: String(additionalCost),
|
||||
currency: service.shippingCurrency || defaultCurrency,
|
||||
};
|
||||
}
|
||||
|
||||
return shippingService;
|
||||
}
|
||||
|
||||
function buildShippingOption(optionType, services, defaultCurrency) {
|
||||
if (!services.length) return null;
|
||||
return {
|
||||
optionType,
|
||||
costType: 'FLAT_RATE',
|
||||
shippingServices: services.map((service, index) =>
|
||||
buildShippingService(service, index, defaultCurrency)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildFulfillmentPolicy(listing, marketplace, services) {
|
||||
const marketplaceId = marketplace.config?.marketplaceId || 'EBAY_GB';
|
||||
const defaultCurrency = marketplace.config?.currency || listing.currency || 'GBP';
|
||||
const domesticServices = services.filter((service) => !service.international);
|
||||
const internationalServices = services.filter((service) => service.international);
|
||||
const shippingOptions = [
|
||||
buildShippingOption('DOMESTIC', domesticServices, defaultCurrency),
|
||||
buildShippingOption('INTERNATIONAL', internationalServices, defaultCurrency),
|
||||
].filter(Boolean);
|
||||
const deliveryTime = 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),
|
||||
marketplaceId,
|
||||
categoryTypes: [{ name: 'ALL_EXCLUDING_MOTORS_VEHICLES' }],
|
||||
handlingTime: { value: deliveryTime, unit: 'DAY' },
|
||||
localPickup: false,
|
||||
globalShipping: false,
|
||||
freightShipping: false,
|
||||
pickupDropOff: false,
|
||||
shippingOptions,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchFulfillmentPolicyByName(marketplace, name) {
|
||||
return makeRequest({
|
||||
marketplace,
|
||||
path: '/sell/account/v1/fulfillment_policy/get_by_policy_name',
|
||||
params: {
|
||||
marketplace_id: marketplace.config?.marketplaceId || 'EBAY_GB',
|
||||
name,
|
||||
},
|
||||
acceptableStatuses: [404],
|
||||
});
|
||||
}
|
||||
|
||||
export async function syncFulfillmentPolicy(marketplace, listing) {
|
||||
await ensureSellingPolicyManagement(marketplace);
|
||||
|
||||
if (listing?.fulfillmentPolicyId) {
|
||||
return { fulfillmentPolicyId: String(listing.fulfillmentPolicyId) };
|
||||
}
|
||||
|
||||
const services = validateCourierServices(await resolveCourierServices(listing), listing);
|
||||
const policy = buildFulfillmentPolicy(listing, marketplace, services);
|
||||
const existingPolicy = await fetchFulfillmentPolicyByName(marketplace, policy.name);
|
||||
|
||||
if (existingPolicy?.fulfillmentPolicyId) {
|
||||
await makeRequest({
|
||||
marketplace,
|
||||
method: 'PUT',
|
||||
path: `/sell/account/v1/fulfillment_policy/${encodeURIComponent(
|
||||
existingPolicy.fulfillmentPolicyId
|
||||
)}`,
|
||||
body: policy,
|
||||
});
|
||||
logger.info(
|
||||
`Updated eBay fulfillment policy "${policy.name}" (${existingPolicy.fulfillmentPolicyId})`
|
||||
);
|
||||
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}"`);
|
||||
}
|
||||
|
||||
logger.info(`Created eBay fulfillment policy "${policy.name}" (${result.fulfillmentPolicyId})`);
|
||||
return { fulfillmentPolicyId: String(result.fulfillmentPolicyId) };
|
||||
}
|
||||
@ -7,17 +7,19 @@ export {
|
||||
verifyWebhookSignature,
|
||||
} from './auth.js';
|
||||
|
||||
export { syncItems, mapProductToListing, createItem, updateItem, deleteItem } from './listings.js';
|
||||
|
||||
export { syncProductCategory } from './categories.js';
|
||||
export { syncFulfillmentPolicy } from './fulfillmentPolicies.js';
|
||||
|
||||
export {
|
||||
syncItems,
|
||||
mapProductToListing,
|
||||
createItem,
|
||||
updateItem,
|
||||
deleteItem,
|
||||
publishOfferById,
|
||||
withdrawOfferById,
|
||||
publishOfferForSku,
|
||||
withdrawOfferForSku,
|
||||
} from './listings.js';
|
||||
resolveStockLocationCountryCode,
|
||||
resolveStockLocationEbayCountry,
|
||||
} from './listingVarients.js';
|
||||
|
||||
export {
|
||||
syncOrders,
|
||||
|
||||
396
src/integrations/marketplaces/ebay/listingVarients.js
Normal file
396
src/integrations/marketplaces/ebay/listingVarients.js
Normal file
@ -0,0 +1,396 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { stockLocationModel } from '../../../database/schemas/inventory/stocklocation.schema.js';
|
||||
import { FARMCONTROL_GB_SUBDIVISION_STATE, resolveEbayCountry } from './countryCodes.js';
|
||||
import { syncProductCategory } from './categories.js';
|
||||
import { syncFulfillmentPolicy } from './fulfillmentPolicies.js';
|
||||
import { makeRequest, logger } from './shared.js';
|
||||
|
||||
const WAREHOUSE_ADDRESS_DEFAULTS = {
|
||||
GB: { city: 'London', stateOrProvince: 'England', postalCode: 'SW1A 1AA' },
|
||||
US: { city: 'New York', stateOrProvince: 'NY', postalCode: '10001' },
|
||||
AU: { city: 'Sydney', stateOrProvince: 'NSW', postalCode: '2000' },
|
||||
CA: { city: 'Toronto', stateOrProvince: 'ON', postalCode: 'M5H 2N2' },
|
||||
DE: { city: 'Berlin', stateOrProvince: 'Berlin', postalCode: '10115' },
|
||||
FR: { city: 'Paris', stateOrProvince: 'Île-de-France', postalCode: '75001' },
|
||||
};
|
||||
|
||||
const LISTING_STATUS_MAP = {
|
||||
ACTIVE: 'active',
|
||||
OUT_OF_STOCK: 'inactive',
|
||||
ENDED: 'inactive',
|
||||
PUBLISHED: 'active',
|
||||
UNPUBLISHED: 'draft',
|
||||
};
|
||||
|
||||
function isPopulatedStockLocation(stockLocation) {
|
||||
if (!stockLocation || typeof stockLocation !== 'object') return false;
|
||||
if (stockLocation instanceof mongoose.Types.ObjectId) return false;
|
||||
return stockLocation.name != null || stockLocation.address != null;
|
||||
}
|
||||
|
||||
async function ensureListingStockLocation(listing) {
|
||||
const stockLocationRef = listing?.stockLocation;
|
||||
if (!stockLocationRef || isPopulatedStockLocation(stockLocationRef)) {
|
||||
return listing;
|
||||
}
|
||||
|
||||
const stockLocationId = stockLocationRef._id || stockLocationRef;
|
||||
const stockLocation = await stockLocationModel.findById(stockLocationId).lean();
|
||||
if (!stockLocation) throw new Error('Listing stock location not found.');
|
||||
return { ...listing, stockLocation };
|
||||
}
|
||||
|
||||
export function resolveStockLocationCountryCode(listing) {
|
||||
return resolveStockLocationEbayCountry(listing)?.ebayCountryCode ?? null;
|
||||
}
|
||||
|
||||
export function resolveStockLocationEbayCountry(listing) {
|
||||
const stockLocation = listing?.stockLocation;
|
||||
if (!isPopulatedStockLocation(stockLocation)) return null;
|
||||
try {
|
||||
return resolveEbayCountry(stockLocation.address?.country);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMerchantLocationKey(listing) {
|
||||
const stockLocationId = listing?.stockLocation?._id || listing?.stockLocation;
|
||||
return stockLocationId ? `fc-${String(stockLocationId)}`.slice(0, 36) : null;
|
||||
}
|
||||
|
||||
function buildEbayWarehouseAddress(addr, ebayCountryCode, farmControlCountryCode) {
|
||||
const address = { country: ebayCountryCode };
|
||||
|
||||
if (addr?.addressLine1) address.addressLine1 = addr.addressLine1;
|
||||
if (addr?.addressLine2) address.addressLine2 = addr.addressLine2;
|
||||
if (addr?.city) address.city = addr.city;
|
||||
if (addr?.state) address.stateOrProvince = addr.state;
|
||||
if (addr?.postcode) address.postalCode = addr.postcode;
|
||||
|
||||
if (
|
||||
!address.stateOrProvince &&
|
||||
farmControlCountryCode &&
|
||||
FARMCONTROL_GB_SUBDIVISION_STATE[farmControlCountryCode]
|
||||
) {
|
||||
address.stateOrProvince = FARMCONTROL_GB_SUBDIVISION_STATE[farmControlCountryCode];
|
||||
}
|
||||
|
||||
if (!address.postalCode && !(address.city && address.stateOrProvince)) {
|
||||
const defaults = WAREHOUSE_ADDRESS_DEFAULTS[ebayCountryCode];
|
||||
if (defaults) {
|
||||
Object.assign(address, defaults);
|
||||
if (farmControlCountryCode && FARMCONTROL_GB_SUBDIVISION_STATE[farmControlCountryCode]) {
|
||||
address.stateOrProvince = FARMCONTROL_GB_SUBDIVISION_STATE[farmControlCountryCode];
|
||||
}
|
||||
} else {
|
||||
address.city = address.city || 'Unknown';
|
||||
address.stateOrProvince = address.stateOrProvince || ebayCountryCode;
|
||||
address.postalCode = address.postalCode || '00000';
|
||||
}
|
||||
}
|
||||
|
||||
if (!address.country) {
|
||||
throw new Error('eBay warehouse address requires a country code.');
|
||||
}
|
||||
return address;
|
||||
}
|
||||
|
||||
function buildWarehouseLocationBody(listing, ebayCountryCode, farmControlCountryCode) {
|
||||
const stockLocation = listing.stockLocation;
|
||||
return {
|
||||
location: {
|
||||
address: buildEbayWarehouseAddress(
|
||||
stockLocation?.address || {},
|
||||
ebayCountryCode,
|
||||
farmControlCountryCode
|
||||
),
|
||||
},
|
||||
locationTypes: ['WAREHOUSE'],
|
||||
name: stockLocation?.name || `FarmControl ${ebayCountryCode}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureMerchantLocation(marketplace, listing) {
|
||||
const listingWithStockLocation = await ensureListingStockLocation(listing);
|
||||
const countryResolved = resolveEbayCountry(
|
||||
listingWithStockLocation.stockLocation?.address?.country
|
||||
);
|
||||
if (!countryResolved) {
|
||||
throw new Error(
|
||||
'Listing stock location address must include a country before syncing or publishing on eBay.'
|
||||
);
|
||||
}
|
||||
|
||||
const merchantLocationKey = resolveMerchantLocationKey(listingWithStockLocation);
|
||||
if (!merchantLocationKey) {
|
||||
throw new Error('Listing must have a stock location before syncing or publishing on eBay.');
|
||||
}
|
||||
|
||||
const locationBody = buildWarehouseLocationBody(
|
||||
listingWithStockLocation,
|
||||
countryResolved.ebayCountryCode,
|
||||
countryResolved.farmControlCountryCode
|
||||
);
|
||||
listing.stockLocation = listingWithStockLocation.stockLocation;
|
||||
const locationPath = `/sell/inventory/v1/location/${encodeURIComponent(merchantLocationKey)}`;
|
||||
const existing = await makeRequest({
|
||||
marketplace,
|
||||
path: locationPath,
|
||||
acceptableStatuses: [404],
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await makeRequest({
|
||||
marketplace,
|
||||
method: 'POST',
|
||||
path: `${locationPath}/update_location_details`,
|
||||
body: { location: locationBody.location },
|
||||
});
|
||||
} else {
|
||||
await makeRequest({
|
||||
marketplace,
|
||||
method: 'POST',
|
||||
path: locationPath,
|
||||
body: locationBody,
|
||||
});
|
||||
}
|
||||
return merchantLocationKey;
|
||||
}
|
||||
|
||||
function applyMarketplaceOfferDefaults(
|
||||
offer,
|
||||
marketplace,
|
||||
merchantLocationKey,
|
||||
fulfillmentPolicyId
|
||||
) {
|
||||
offer.merchantLocationKey = merchantLocationKey;
|
||||
const config = marketplace.config || {};
|
||||
const listingPolicies = {};
|
||||
if (fulfillmentPolicyId || config.fulfillmentPolicyId) {
|
||||
listingPolicies.fulfillmentPolicyId = fulfillmentPolicyId || config.fulfillmentPolicyId;
|
||||
}
|
||||
if (config.paymentPolicyId) listingPolicies.paymentPolicyId = config.paymentPolicyId;
|
||||
if (config.returnPolicyId) listingPolicies.returnPolicyId = config.returnPolicyId;
|
||||
if (Object.keys(listingPolicies).length) offer.listingPolicies = listingPolicies;
|
||||
}
|
||||
|
||||
function mapVarientToInventoryItem(varient, listing) {
|
||||
const item = {
|
||||
product: { title: listing.title || varient._reference || '' },
|
||||
availability: {
|
||||
shipToLocationAvailability: { quantity: varient.inventory ?? 0 },
|
||||
},
|
||||
};
|
||||
if (listing.description) item.product.description = listing.description;
|
||||
if (listing.imageUrls?.length) item.product.imageUrls = listing.imageUrls;
|
||||
return item;
|
||||
}
|
||||
|
||||
function mapVarientToOffer(varient, listing, marketplace, merchantLocationKey) {
|
||||
const offer = {
|
||||
sku: varient._reference,
|
||||
marketplaceId: marketplace.config?.marketplaceId || 'EBAY_GB',
|
||||
format: 'FIXED_PRICE',
|
||||
};
|
||||
applyMarketplaceOfferDefaults(
|
||||
offer,
|
||||
marketplace,
|
||||
merchantLocationKey,
|
||||
listing.fulfillmentPolicyId
|
||||
);
|
||||
|
||||
const price = varient.price ?? listing.price;
|
||||
if (price != null) {
|
||||
offer.pricingSummary = {
|
||||
price: {
|
||||
value: String(price),
|
||||
currency: varient.currency || listing.currency || 'GBP',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (listing.categoryId) offer.categoryId = String(listing.categoryId);
|
||||
return offer;
|
||||
}
|
||||
|
||||
export async function upsertInventoryItem(marketplace, varient, listing) {
|
||||
const inventoryItem = mapVarientToInventoryItem(varient, listing);
|
||||
const result = await makeRequest({
|
||||
marketplace,
|
||||
method: 'PUT',
|
||||
path: `/sell/inventory/v1/inventory_item/${encodeURIComponent(varient._reference)}`,
|
||||
body: inventoryItem,
|
||||
});
|
||||
logger.debug('inventoryItem', inventoryItem);
|
||||
logger.debug('result', result);
|
||||
}
|
||||
|
||||
export async function fetchOffers(marketplace, sku) {
|
||||
try {
|
||||
const data = await makeRequest({
|
||||
marketplace,
|
||||
path: '/sell/inventory/v1/offer',
|
||||
params: { sku, limit: 200 },
|
||||
acceptableStatuses: [404],
|
||||
});
|
||||
return data?.offers || [];
|
||||
} catch (err) {
|
||||
logger.debug(`No offers found for SKU ${sku}: ${err.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertOrCreateOffer(marketplace, varient, listing) {
|
||||
const merchantLocationKey = await ensureMerchantLocation(marketplace, listing);
|
||||
const offers = await fetchOffers(marketplace, varient._reference);
|
||||
const existingOffer = offers[0];
|
||||
const price = varient.price ?? listing.price;
|
||||
|
||||
if (existingOffer?.offerId) {
|
||||
const offerUpdate = { merchantLocationKey };
|
||||
if (price != null) {
|
||||
offerUpdate.pricingSummary = {
|
||||
price: {
|
||||
value: String(price),
|
||||
currency: varient.currency || listing.currency || 'GBP',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (listing.categoryId) offerUpdate.categoryId = String(listing.categoryId);
|
||||
if (listing.fulfillmentPolicyId) {
|
||||
offerUpdate.listingPolicies = {
|
||||
...(existingOffer.listingPolicies || {}),
|
||||
fulfillmentPolicyId: String(listing.fulfillmentPolicyId),
|
||||
};
|
||||
}
|
||||
const body = { ...existingOffer, ...offerUpdate };
|
||||
await makeRequest({
|
||||
marketplace,
|
||||
method: 'PUT',
|
||||
path: `/sell/inventory/v1/offer/${existingOffer.offerId}`,
|
||||
body,
|
||||
});
|
||||
logger.debug('offerUpdate', body);
|
||||
return existingOffer;
|
||||
}
|
||||
|
||||
const offerBody = mapVarientToOffer(varient, listing, marketplace, merchantLocationKey);
|
||||
const result = await makeRequest({
|
||||
marketplace,
|
||||
method: 'POST',
|
||||
path: '/sell/inventory/v1/offer',
|
||||
body: offerBody,
|
||||
});
|
||||
logger.debug('offerBody', offerBody);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function publishOfferById(marketplace, offerId) {
|
||||
if (!offerId) throw new Error('offerId is required to publish an offer');
|
||||
return makeRequest({
|
||||
marketplace,
|
||||
method: 'POST',
|
||||
path: `/sell/inventory/v1/offer/${encodeURIComponent(offerId)}/publish`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function withdrawOfferById(marketplace, offerId) {
|
||||
if (!offerId) throw new Error('offerId is required to withdraw an offer');
|
||||
return makeRequest({
|
||||
marketplace,
|
||||
method: 'POST',
|
||||
path: `/sell/inventory/v1/offer/${encodeURIComponent(offerId)}/withdraw`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function syncOfferAndMaybePublish(marketplace, listing, varient) {
|
||||
const offerResult = await upsertOrCreateOffer(marketplace, varient, listing);
|
||||
if (offerResult?.offerId && listing.state?.type === 'active') {
|
||||
try {
|
||||
const publishResult = await publishOfferById(marketplace, offerResult.offerId);
|
||||
if (publishResult?.listingId) {
|
||||
return `https://www.ebay.com/itm/${publishResult.listingId}`;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`Created offer but failed to publish for varient ${varient._reference}: ${err.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export async function publishOfferForSku(marketplace, sku, listing) {
|
||||
if (!sku) throw new Error('SKU (_reference) is required to publish an offer');
|
||||
if (!listing) {
|
||||
throw new Error(
|
||||
'Listing is required to publish an eBay offer (stock location address is used for item location).'
|
||||
);
|
||||
}
|
||||
|
||||
const merchantLocationKey = await ensureMerchantLocation(marketplace, listing);
|
||||
const category = await syncProductCategory(marketplace, listing);
|
||||
const fulfillmentPolicy = await syncFulfillmentPolicy(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.`
|
||||
);
|
||||
}
|
||||
|
||||
await makeRequest({
|
||||
marketplace,
|
||||
method: 'PUT',
|
||||
path: `/sell/inventory/v1/offer/${existingOffer.offerId}`,
|
||||
body: {
|
||||
...existingOffer,
|
||||
merchantLocationKey,
|
||||
categoryId: String(category.categoryId),
|
||||
listingPolicies: {
|
||||
...(existingOffer.listingPolicies || {}),
|
||||
fulfillmentPolicyId: fulfillmentPolicy.fulfillmentPolicyId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const publishResult = await publishOfferById(marketplace, existingOffer.offerId);
|
||||
return { offerId: existingOffer.offerId, listingId: publishResult?.listingId };
|
||||
}
|
||||
|
||||
export async function withdrawOfferForSku(marketplace, sku) {
|
||||
if (!sku) throw new Error('SKU (_reference) is required to withdraw an offer');
|
||||
const existingOffer = (await fetchOffers(marketplace, sku))[0];
|
||||
if (!existingOffer?.offerId) throw new Error(`No eBay offer exists for SKU "${sku}".`);
|
||||
await withdrawOfferById(marketplace, existingOffer.offerId);
|
||||
return { offerId: existingOffer.offerId };
|
||||
}
|
||||
|
||||
export function resolveOfferState(offers) {
|
||||
let stateType = 'draft';
|
||||
for (const offer of offers) {
|
||||
if (offer?.status && LISTING_STATUS_MAP[offer.status]) {
|
||||
const mapped = LISTING_STATUS_MAP[offer.status];
|
||||
if (mapped === 'active') return 'active';
|
||||
if (mapped !== 'draft') stateType = mapped;
|
||||
}
|
||||
}
|
||||
return stateType;
|
||||
}
|
||||
|
||||
export function buildVarientEntry(item, offers) {
|
||||
const offer = offers?.[0];
|
||||
return {
|
||||
_reference: item.sku,
|
||||
price: offer?.pricingSummary?.price?.value
|
||||
? parseFloat(offer.pricingSummary.price.value)
|
||||
: undefined,
|
||||
currency: offer?.pricingSummary?.price?.currency || undefined,
|
||||
state: { type: resolveOfferState(offers || []) },
|
||||
};
|
||||
}
|
||||
@ -1,307 +1,18 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { stockLocationModel } from '../../../database/schemas/inventory/stocklocation.schema.js';
|
||||
import { syncProductCategory } from './categories.js';
|
||||
import { syncFulfillmentPolicy } from './fulfillmentPolicies.js';
|
||||
import {
|
||||
FARMCONTROL_GB_SUBDIVISION_STATE,
|
||||
resolveEbayCountry,
|
||||
} from './countryCodes.js';
|
||||
buildVarientEntry,
|
||||
fetchOffers,
|
||||
resolveOfferState,
|
||||
syncOfferAndMaybePublish,
|
||||
upsertInventoryItem,
|
||||
} from './listingVarients.js';
|
||||
import { makeRequest, logger } from './shared.js';
|
||||
|
||||
const WAREHOUSE_ADDRESS_DEFAULTS = {
|
||||
GB: { city: 'London', stateOrProvince: 'England', postalCode: 'SW1A 1AA' },
|
||||
US: { city: 'New York', stateOrProvince: 'NY', postalCode: '10001' },
|
||||
AU: { city: 'Sydney', stateOrProvince: 'NSW', postalCode: '2000' },
|
||||
CA: { city: 'Toronto', stateOrProvince: 'ON', postalCode: 'M5H 2N2' },
|
||||
DE: { city: 'Berlin', stateOrProvince: 'Berlin', postalCode: '10115' },
|
||||
FR: { city: 'Paris', stateOrProvince: 'Île-de-France', postalCode: '75001' },
|
||||
};
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isPopulatedStockLocation(stockLocation) {
|
||||
if (!stockLocation || typeof stockLocation !== 'object') {
|
||||
return false;
|
||||
}
|
||||
if (stockLocation instanceof mongoose.Types.ObjectId) {
|
||||
return false;
|
||||
}
|
||||
return stockLocation.name != null || stockLocation.address != null;
|
||||
}
|
||||
|
||||
async function ensureListingStockLocation(listing) {
|
||||
const stockLocationRef = listing?.stockLocation;
|
||||
if (!stockLocationRef) {
|
||||
return listing;
|
||||
}
|
||||
|
||||
if (isPopulatedStockLocation(stockLocationRef)) {
|
||||
return listing;
|
||||
}
|
||||
|
||||
const stockLocationId = stockLocationRef._id || stockLocationRef;
|
||||
const stockLocation = await stockLocationModel.findById(stockLocationId).lean();
|
||||
if (!stockLocation) {
|
||||
throw new Error('Listing stock location not found.');
|
||||
}
|
||||
|
||||
return { ...listing, stockLocation };
|
||||
}
|
||||
|
||||
export function resolveStockLocationCountryCode(listing) {
|
||||
const resolved = resolveStockLocationEbayCountry(listing);
|
||||
return resolved?.ebayCountryCode ?? null;
|
||||
}
|
||||
|
||||
export function resolveStockLocationEbayCountry(listing) {
|
||||
const stockLocation = listing?.stockLocation;
|
||||
if (!isPopulatedStockLocation(stockLocation)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return resolveEbayCountry(stockLocation.address?.country);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMerchantLocationKey(listing) {
|
||||
const stockLocationId = listing?.stockLocation?._id || listing?.stockLocation;
|
||||
if (!stockLocationId) {
|
||||
return null;
|
||||
}
|
||||
return `fc-${String(stockLocationId)}`.slice(0, 36);
|
||||
}
|
||||
|
||||
function buildEbayWarehouseAddress(addr, ebayCountryCode, farmControlCountryCode) {
|
||||
const address = {
|
||||
country: ebayCountryCode,
|
||||
};
|
||||
|
||||
if (addr?.addressLine1) address.addressLine1 = addr.addressLine1;
|
||||
if (addr?.addressLine2) address.addressLine2 = addr.addressLine2;
|
||||
if (addr?.city) address.city = addr.city;
|
||||
if (addr?.state) address.stateOrProvince = addr.state;
|
||||
if (addr?.postcode) address.postalCode = addr.postcode;
|
||||
|
||||
if (
|
||||
!address.stateOrProvince &&
|
||||
farmControlCountryCode &&
|
||||
FARMCONTROL_GB_SUBDIVISION_STATE[farmControlCountryCode]
|
||||
) {
|
||||
address.stateOrProvince = FARMCONTROL_GB_SUBDIVISION_STATE[farmControlCountryCode];
|
||||
}
|
||||
|
||||
const hasPostal = Boolean(address.postalCode);
|
||||
const hasCityState = Boolean(address.city && address.stateOrProvince);
|
||||
|
||||
if (!hasPostal && !hasCityState) {
|
||||
const defaults = WAREHOUSE_ADDRESS_DEFAULTS[ebayCountryCode];
|
||||
if (defaults) {
|
||||
Object.assign(address, defaults);
|
||||
if (
|
||||
farmControlCountryCode &&
|
||||
FARMCONTROL_GB_SUBDIVISION_STATE[farmControlCountryCode]
|
||||
) {
|
||||
address.stateOrProvince = FARMCONTROL_GB_SUBDIVISION_STATE[farmControlCountryCode];
|
||||
}
|
||||
} else {
|
||||
address.city = address.city || 'Unknown';
|
||||
address.stateOrProvince = address.stateOrProvince || ebayCountryCode;
|
||||
address.postalCode = address.postalCode || '00000';
|
||||
}
|
||||
}
|
||||
|
||||
if (!address.country) {
|
||||
throw new Error('eBay warehouse address requires a country code.');
|
||||
}
|
||||
|
||||
return address;
|
||||
}
|
||||
|
||||
function buildWarehouseLocationBody(listing, ebayCountryCode, farmControlCountryCode) {
|
||||
const stockLocation = listing.stockLocation;
|
||||
const addr = stockLocation?.address || {};
|
||||
const address = buildEbayWarehouseAddress(addr, ebayCountryCode, farmControlCountryCode);
|
||||
|
||||
return {
|
||||
location: { address },
|
||||
locationTypes: ['WAREHOUSE'],
|
||||
name: stockLocation?.name || `FarmControl ${ebayCountryCode}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureMerchantLocation(marketplace, listing) {
|
||||
const listingWithStockLocation = await ensureListingStockLocation(listing);
|
||||
|
||||
const countryResolved = resolveEbayCountry(
|
||||
listingWithStockLocation.stockLocation?.address?.country
|
||||
);
|
||||
|
||||
if (!countryResolved) {
|
||||
throw new Error(
|
||||
'Listing stock location address must include a country before syncing or publishing on eBay.'
|
||||
);
|
||||
}
|
||||
|
||||
const { ebayCountryCode, farmControlCountryCode } = countryResolved;
|
||||
|
||||
const merchantLocationKey = resolveMerchantLocationKey(listingWithStockLocation);
|
||||
if (!merchantLocationKey) {
|
||||
throw new Error('Listing must have a stock location before syncing or publishing on eBay.');
|
||||
}
|
||||
|
||||
const locationBody = buildWarehouseLocationBody(
|
||||
listingWithStockLocation,
|
||||
ebayCountryCode,
|
||||
farmControlCountryCode
|
||||
);
|
||||
listing.stockLocation = listingWithStockLocation.stockLocation;
|
||||
const locationPath = `/sell/inventory/v1/location/${encodeURIComponent(merchantLocationKey)}`;
|
||||
|
||||
const existing = await makeRequest({
|
||||
marketplace,
|
||||
path: locationPath,
|
||||
acceptableStatuses: [404],
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await makeRequest({
|
||||
marketplace,
|
||||
method: 'POST',
|
||||
path: `${locationPath}/update_location_details`,
|
||||
body: { location: locationBody.location },
|
||||
});
|
||||
} else {
|
||||
await makeRequest({
|
||||
marketplace,
|
||||
method: 'POST',
|
||||
path: locationPath,
|
||||
body: locationBody,
|
||||
});
|
||||
}
|
||||
|
||||
return merchantLocationKey;
|
||||
}
|
||||
|
||||
function applyMarketplaceOfferDefaults(offer, marketplace, merchantLocationKey) {
|
||||
offer.merchantLocationKey = merchantLocationKey;
|
||||
|
||||
const config = marketplace.config || {};
|
||||
const listingPolicies = {};
|
||||
if (config.fulfillmentPolicyId) {
|
||||
listingPolicies.fulfillmentPolicyId = config.fulfillmentPolicyId;
|
||||
}
|
||||
if (config.paymentPolicyId) {
|
||||
listingPolicies.paymentPolicyId = config.paymentPolicyId;
|
||||
}
|
||||
if (config.returnPolicyId) {
|
||||
listingPolicies.returnPolicyId = config.returnPolicyId;
|
||||
}
|
||||
if (Object.keys(listingPolicies).length > 0) {
|
||||
offer.listingPolicies = listingPolicies;
|
||||
}
|
||||
}
|
||||
|
||||
function mapVarientToInventoryItem(varient, listing) {
|
||||
const item = {
|
||||
product: {
|
||||
title: listing.title || varient._reference || '',
|
||||
},
|
||||
availability: {
|
||||
shipToLocationAvailability: {
|
||||
quantity: varient.inventory ?? 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (listing.description) {
|
||||
item.product.description = listing.description;
|
||||
}
|
||||
|
||||
if (listing.imageUrls?.length) {
|
||||
item.product.imageUrls = listing.imageUrls;
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
function mapVarientToOffer(varient, listing, marketplace, merchantLocationKey) {
|
||||
const offer = {
|
||||
sku: varient._reference,
|
||||
marketplaceId: marketplace.config?.marketplaceId || 'EBAY_GB',
|
||||
format: 'FIXED_PRICE',
|
||||
};
|
||||
|
||||
applyMarketplaceOfferDefaults(offer, marketplace, merchantLocationKey);
|
||||
|
||||
const price = varient.price ?? listing.price;
|
||||
if (price != null) {
|
||||
offer.pricingSummary = {
|
||||
price: {
|
||||
value: String(price),
|
||||
currency: varient.currency || listing.currency || 'GBP',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (listing.categoryId) {
|
||||
offer.categoryId = listing.categoryId;
|
||||
}
|
||||
|
||||
return offer;
|
||||
}
|
||||
|
||||
async function upsertInventoryItem(marketplace, varient, listing) {
|
||||
const inventoryItem = mapVarientToInventoryItem(varient, listing);
|
||||
const result = await makeRequest({
|
||||
marketplace,
|
||||
method: 'PUT',
|
||||
path: `/sell/inventory/v1/inventory_item/${encodeURIComponent(varient._reference)}`,
|
||||
body: inventoryItem,
|
||||
});
|
||||
logger.debug('inventoryItem', inventoryItem);
|
||||
logger.debug('result', result);
|
||||
}
|
||||
|
||||
async function upsertOrCreateOffer(marketplace, varient, listing) {
|
||||
const merchantLocationKey = await ensureMerchantLocation(marketplace, listing);
|
||||
const offers = await fetchOffers(marketplace, varient._reference);
|
||||
const existingOffer = offers[0];
|
||||
|
||||
const price = varient.price ?? listing.price;
|
||||
|
||||
if (existingOffer?.offerId) {
|
||||
const offerUpdate = { merchantLocationKey };
|
||||
if (price != null) {
|
||||
offerUpdate.pricingSummary = {
|
||||
price: {
|
||||
value: String(price),
|
||||
currency: varient.currency || listing.currency || 'GBP',
|
||||
},
|
||||
};
|
||||
}
|
||||
await makeRequest({
|
||||
marketplace,
|
||||
method: 'PUT',
|
||||
path: `/sell/inventory/v1/offer/${existingOffer.offerId}`,
|
||||
body: { ...existingOffer, ...offerUpdate },
|
||||
});
|
||||
logger.debug('offerUpdate', { ...existingOffer, ...offerUpdate });
|
||||
return existingOffer;
|
||||
}
|
||||
|
||||
const offerBody = mapVarientToOffer(varient, listing, marketplace, merchantLocationKey);
|
||||
const result = await makeRequest({
|
||||
marketplace,
|
||||
method: 'POST',
|
||||
path: '/sell/inventory/v1/offer',
|
||||
body: offerBody,
|
||||
});
|
||||
logger.debug('offerBody', offerBody);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function createOrReplaceGroup(marketplace, listing, varients) {
|
||||
const groupKey = listing._reference;
|
||||
const variantSKUs = varients.map((v) => v._reference).filter(Boolean);
|
||||
@ -340,26 +51,6 @@ async function deleteGroup(marketplace, groupKey) {
|
||||
}
|
||||
}
|
||||
|
||||
async function syncOfferAndMaybePublish(marketplace, listing, varient) {
|
||||
const offerResult = await upsertOrCreateOffer(marketplace, varient, listing);
|
||||
|
||||
if (offerResult?.offerId && listing.state?.type === 'active') {
|
||||
try {
|
||||
const publishResult = await publishOfferById(marketplace, offerResult.offerId);
|
||||
|
||||
if (publishResult?.listingId) {
|
||||
return `https://www.ebay.com/itm/${publishResult.listingId}`;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`Created offer but failed to publish for varient ${varient._reference}: ${err.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function syncSingleVarientListing(marketplace, listing, varient) {
|
||||
await upsertInventoryItem(marketplace, varient, listing);
|
||||
|
||||
@ -413,15 +104,23 @@ async function syncListing(marketplace, listing, varients, actionLabel) {
|
||||
);
|
||||
}
|
||||
|
||||
const category = await syncProductCategory(marketplace, listing, validVarients);
|
||||
const fulfillmentPolicy = await syncFulfillmentPolicy(marketplace, listing);
|
||||
const listingWithContext = {
|
||||
...listing,
|
||||
...(category ? { categoryId: category.categoryId } : {}),
|
||||
fulfillmentPolicyId: fulfillmentPolicy.fulfillmentPolicyId,
|
||||
};
|
||||
|
||||
if (validVarients.length === 1) {
|
||||
logger.info(
|
||||
`Syncing standalone eBay inventory item "${validVarients[0]._reference}" for listing "${ref}"`
|
||||
);
|
||||
const url = await syncSingleVarientListing(marketplace, listing, validVarients[0]);
|
||||
const url = await syncSingleVarientListing(marketplace, listingWithContext, validVarients[0]);
|
||||
return { url };
|
||||
}
|
||||
|
||||
const url = await syncGroupedListing(marketplace, listing, validVarients);
|
||||
const url = await syncGroupedListing(marketplace, listingWithContext, validVarients);
|
||||
return { url };
|
||||
}
|
||||
|
||||
@ -469,99 +168,6 @@ async function fetchAllInventoryItems(marketplace) {
|
||||
return items;
|
||||
}
|
||||
|
||||
async function fetchOffers(marketplace, sku) {
|
||||
try {
|
||||
const data = await makeRequest({
|
||||
marketplace,
|
||||
path: '/sell/inventory/v1/offer',
|
||||
params: { sku, limit: 200 },
|
||||
acceptableStatuses: [404],
|
||||
});
|
||||
return data?.offers || [];
|
||||
} catch (err) {
|
||||
logger.debug(`No offers found for SKU ${sku}: ${err.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* eBay Sell Inventory API — publish offer (creates live listing).
|
||||
* @see https://developer.ebay.com/api-docs/sell/inventory/resources/offer/methods/publishOffer
|
||||
*/
|
||||
export async function publishOfferById(marketplace, offerId) {
|
||||
if (!offerId) {
|
||||
throw new Error('offerId is required to publish an offer');
|
||||
}
|
||||
return makeRequest({
|
||||
marketplace,
|
||||
method: 'POST',
|
||||
path: `/sell/inventory/v1/offer/${encodeURIComponent(offerId)}/publish`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* eBay Sell Inventory API — withdraw offer (ends live listing; offer remains for re-publish).
|
||||
* @see https://developer.ebay.com/api-docs/sell/inventory/resources/offer/methods/withdrawOffer
|
||||
*/
|
||||
export async function withdrawOfferById(marketplace, offerId) {
|
||||
if (!offerId) {
|
||||
throw new Error('offerId is required to withdraw an offer');
|
||||
}
|
||||
return makeRequest({
|
||||
marketplace,
|
||||
method: 'POST',
|
||||
path: `/sell/inventory/v1/offer/${encodeURIComponent(offerId)}/withdraw`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function publishOfferForSku(marketplace, sku, listing) {
|
||||
if (!sku) {
|
||||
throw new Error('SKU (_reference) is required to publish an offer');
|
||||
}
|
||||
if (!listing) {
|
||||
throw new Error(
|
||||
'Listing is required to publish an eBay offer (stock location address is used for item location).'
|
||||
);
|
||||
}
|
||||
|
||||
const merchantLocationKey = await ensureMerchantLocation(marketplace, listing);
|
||||
const offers = await fetchOffers(marketplace, sku);
|
||||
const existingOffer = offers[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.`
|
||||
);
|
||||
}
|
||||
|
||||
if (existingOffer.merchantLocationKey !== merchantLocationKey) {
|
||||
await makeRequest({
|
||||
marketplace,
|
||||
method: 'PUT',
|
||||
path: `/sell/inventory/v1/offer/${existingOffer.offerId}`,
|
||||
body: { ...existingOffer, merchantLocationKey },
|
||||
});
|
||||
}
|
||||
|
||||
const publishResult = await publishOfferById(marketplace, existingOffer.offerId);
|
||||
return {
|
||||
offerId: existingOffer.offerId,
|
||||
listingId: publishResult?.listingId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function withdrawOfferForSku(marketplace, sku) {
|
||||
if (!sku) {
|
||||
throw new Error('SKU (_reference) is required to withdraw an offer');
|
||||
}
|
||||
const offers = await fetchOffers(marketplace, sku);
|
||||
const existingOffer = offers[0];
|
||||
if (!existingOffer?.offerId) {
|
||||
throw new Error(`No eBay offer exists for SKU "${sku}".`);
|
||||
}
|
||||
await withdrawOfferById(marketplace, existingOffer.offerId);
|
||||
return { offerId: existingOffer.offerId };
|
||||
}
|
||||
|
||||
async function safeFetchInventoryItemGroup(marketplace, groupKey) {
|
||||
try {
|
||||
return await makeRequest({
|
||||
@ -651,41 +257,6 @@ export async function syncItems(marketplace) {
|
||||
return results;
|
||||
}
|
||||
|
||||
const LISTING_STATUS_MAP = {
|
||||
ACTIVE: 'active',
|
||||
OUT_OF_STOCK: 'inactive',
|
||||
ENDED: 'inactive',
|
||||
PUBLISHED: 'active',
|
||||
UNPUBLISHED: 'draft',
|
||||
};
|
||||
|
||||
function resolveOfferState(offers) {
|
||||
let stateType = 'draft';
|
||||
for (const offer of offers) {
|
||||
if (offer?.status && LISTING_STATUS_MAP[offer.status]) {
|
||||
const mapped = LISTING_STATUS_MAP[offer.status];
|
||||
if (mapped === 'active') return 'active';
|
||||
if (mapped !== 'draft') stateType = mapped;
|
||||
}
|
||||
}
|
||||
return stateType;
|
||||
}
|
||||
|
||||
function buildVarientEntry(item, offers) {
|
||||
const offer = offers?.[0];
|
||||
const price = offer?.pricingSummary?.price?.value
|
||||
? parseFloat(offer.pricingSummary.price.value)
|
||||
: undefined;
|
||||
const currency = offer?.pricingSummary?.price?.currency || undefined;
|
||||
|
||||
return {
|
||||
_reference: item.sku,
|
||||
price,
|
||||
currency,
|
||||
state: { type: resolveOfferState(offers || []) },
|
||||
};
|
||||
}
|
||||
|
||||
export function mapProductToListing(ebayItem) {
|
||||
if (ebayItem._type === 'group') {
|
||||
const group = ebayItem._group;
|
||||
@ -723,10 +294,7 @@ export function mapProductToListing(ebayItem) {
|
||||
: undefined;
|
||||
const currency = offer?.pricingSummary?.price?.currency || undefined;
|
||||
|
||||
let stateType = 'draft';
|
||||
if (offer?.status && LISTING_STATUS_MAP[offer.status]) {
|
||||
stateType = LISTING_STATUS_MAP[offer.status];
|
||||
}
|
||||
const stateType = resolveOfferState(ebayItem._offers || []);
|
||||
|
||||
const url = offer?.listingId ? `https://www.ebay.com/itm/${offer.listingId}` : '';
|
||||
|
||||
|
||||
@ -152,16 +152,29 @@ export async function makeRequest({
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const responseText = await response.text();
|
||||
let data = null;
|
||||
if (responseText) {
|
||||
try {
|
||||
data = JSON.parse(responseText);
|
||||
} catch {
|
||||
data = responseText;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('DATA: ' + JSON.stringify(data, null, 2));
|
||||
if (data != null) {
|
||||
console.log('DATA: ' + JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
if (!response.ok && acceptableStatuses.includes(response.status)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = data.errors?.[0]?.message || data.error_description || response.statusText;
|
||||
const message =
|
||||
(typeof data === 'object' && (data?.errors?.[0]?.message || data?.error_description)) ||
|
||||
(typeof data === 'string' && data) ||
|
||||
response.statusText;
|
||||
logger.error(`eBay API error: ${message}`, { status: response.status, path });
|
||||
throw new Error(`eBay API error (${response.status}): ${message}`);
|
||||
}
|
||||
|
||||
@ -225,7 +225,7 @@ async function recalculateMarketplaceState(marketplace, user) {
|
||||
async function fetchFullListing(listingId) {
|
||||
return listingModel
|
||||
.findById(listingId)
|
||||
.populate(['product', 'vendor', 'stockLocation', 'marketplace'])
|
||||
.populate(['product', 'vendor', 'stockLocation', 'marketplace', 'courierServices'])
|
||||
.lean();
|
||||
}
|
||||
|
||||
|
||||
@ -12,20 +12,40 @@ import {
|
||||
listCourierServicesByPropertiesRouteHandler,
|
||||
getCourierServiceStatsRouteHandler,
|
||||
getCourierServiceHistoryRouteHandler,
|
||||
searchCourierServicesRouteHandler
|
||||
searchCourierServicesRouteHandler,
|
||||
} from '../../services/management/courierservice.js';
|
||||
|
||||
// list of courier services
|
||||
router.get('/', isAuthenticated, (req, res) => {
|
||||
const { page, limit, property, search, sort, order } = req.query;
|
||||
const allowedFilters = ['courier._id', 'name', 'active', 'deliveryTime'];
|
||||
const allowedFilters = [
|
||||
'_id',
|
||||
'courier',
|
||||
'courier._id',
|
||||
'name',
|
||||
'active',
|
||||
'tracked',
|
||||
'deliveryTime',
|
||||
'cost',
|
||||
'costWithTax',
|
||||
];
|
||||
const filter = getFilter(req.query, allowedFilters);
|
||||
listCourierServicesRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
||||
});
|
||||
|
||||
router.get('/properties', isAuthenticated, (req, res) => {
|
||||
let properties = convertPropertiesString(req.query.properties);
|
||||
const allowedFilters = ['courier._id', 'name', 'active', 'deliveryTime', 'courier'];
|
||||
const allowedFilters = [
|
||||
'_id',
|
||||
'courier',
|
||||
'courier._id',
|
||||
'name',
|
||||
'active',
|
||||
'tracked',
|
||||
'deliveryTime',
|
||||
'cost',
|
||||
'costWithTax',
|
||||
];
|
||||
const filter = getFilter(req.query, allowedFilters, false);
|
||||
listCourierServicesByPropertiesRouteHandler(req, res, properties, filter);
|
||||
});
|
||||
@ -34,7 +54,6 @@ router.get('/search', isAuthenticated, (req, res) => {
|
||||
searchCourierServicesRouteHandler(req, res, search);
|
||||
});
|
||||
|
||||
|
||||
router.post('/', isAuthenticated, (req, res) => {
|
||||
newCourierServiceRouteHandler(req, res);
|
||||
});
|
||||
|
||||
@ -14,7 +14,7 @@ import {
|
||||
getListingHistoryRouteHandler,
|
||||
publishListingRouteHandler,
|
||||
unpublishListingRouteHandler,
|
||||
searchListingsRouteHandler
|
||||
searchListingsRouteHandler,
|
||||
} from '../../services/sales/listings.js';
|
||||
|
||||
router.get('/', isAuthenticated, (req, res) => {
|
||||
@ -24,6 +24,7 @@ router.get('/', isAuthenticated, (req, res) => {
|
||||
'vendor',
|
||||
'stockLocation',
|
||||
'marketplace',
|
||||
'courierServices',
|
||||
'state',
|
||||
'state.type',
|
||||
'createdAt',
|
||||
@ -40,6 +41,7 @@ router.get('/properties', isAuthenticated, (req, res) => {
|
||||
'vendor',
|
||||
'stockLocation',
|
||||
'marketplace',
|
||||
'courierServices',
|
||||
'state',
|
||||
'state.type',
|
||||
'createdAt',
|
||||
@ -53,7 +55,6 @@ router.get('/search', isAuthenticated, (req, res) => {
|
||||
searchListingsRouteHandler(req, res, search);
|
||||
});
|
||||
|
||||
|
||||
router.post('/', isAuthenticated, (req, res) => {
|
||||
newListingRouteHandler(req, res);
|
||||
});
|
||||
|
||||
@ -11,7 +11,7 @@ import {
|
||||
listObjectsByProperties,
|
||||
getModelStats,
|
||||
getModelHistory,
|
||||
searchObjects
|
||||
searchObjects,
|
||||
} from '../../database/database.js';
|
||||
const logger = log4js.getLogger('Couriers');
|
||||
logger.level = config.server.logLevel;
|
||||
@ -129,6 +129,7 @@ export const editCourierRouteHandler = async (req, res) => {
|
||||
export const newCourierRouteHandler = async (req, res) => {
|
||||
const newData = {
|
||||
updatedAt: new Date(),
|
||||
_reference: req.body?._reference,
|
||||
contact: req.body?.contact,
|
||||
country: req.body?.country,
|
||||
name: req.body?.name,
|
||||
|
||||
@ -11,7 +11,7 @@ import {
|
||||
listObjectsByProperties,
|
||||
getModelStats,
|
||||
getModelHistory,
|
||||
searchObjects
|
||||
searchObjects,
|
||||
} from '../../database/database.js';
|
||||
const logger = log4js.getLogger('CourierServices');
|
||||
logger.level = config.server.logLevel;
|
||||
@ -36,7 +36,7 @@ export const listCourierServicesRouteHandler = async (
|
||||
search,
|
||||
sort,
|
||||
order,
|
||||
populate: ['courier'],
|
||||
populate: ['courier', 'costTaxRate'],
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
@ -59,7 +59,7 @@ export const listCourierServicesByPropertiesRouteHandler = async (
|
||||
model: courierServiceModel,
|
||||
properties,
|
||||
filter,
|
||||
populate: ['courier'],
|
||||
populate: ['courier', 'costTaxRate'],
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
@ -85,7 +85,7 @@ export const getCourierServiceRouteHandler = async (req, res) => {
|
||||
const result = await getObject({
|
||||
model: courierServiceModel,
|
||||
id,
|
||||
populate: ['courier'],
|
||||
populate: ['courier', 'costTaxRate'],
|
||||
});
|
||||
if (result?.error) {
|
||||
logger.warn(`Courier service not found with supplied id.`);
|
||||
@ -109,6 +109,13 @@ export const editCourierServiceRouteHandler = async (req, res) => {
|
||||
active: req.body?.active,
|
||||
tracked: req.body?.tracked,
|
||||
name: req.body?.name,
|
||||
cost: req.body?.cost,
|
||||
costTaxRate: req.body?.costTaxRate,
|
||||
costWithTax: req.body?.costWithTax,
|
||||
additionalCost: req.body?.additionalCost,
|
||||
additionalCostWithTax: req.body?.additionalCostWithTax,
|
||||
shippingCurrency: req.body?.shippingCurrency,
|
||||
international: req.body?.international,
|
||||
};
|
||||
// Create audit log before updating
|
||||
const result = await editObject({
|
||||
@ -116,7 +123,7 @@ export const editCourierServiceRouteHandler = async (req, res) => {
|
||||
id,
|
||||
updateData,
|
||||
user: req.user,
|
||||
populate: ['courier'],
|
||||
populate: ['courier', 'costTaxRate'],
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
@ -133,12 +140,20 @@ export const editCourierServiceRouteHandler = async (req, res) => {
|
||||
export const newCourierServiceRouteHandler = async (req, res) => {
|
||||
const newData = {
|
||||
updatedAt: new Date(),
|
||||
_reference: req.body?._reference,
|
||||
courier: req.body?.courier,
|
||||
website: req.body?.website,
|
||||
deliveryTime: req.body?.deliveryTime,
|
||||
active: req.body?.active,
|
||||
tracked: req.body?.tracked,
|
||||
name: req.body?.name,
|
||||
cost: req.body?.cost,
|
||||
costTaxRate: req.body?.costTaxRate,
|
||||
costWithTax: req.body?.costWithTax,
|
||||
additionalCost: req.body?.additionalCost,
|
||||
additionalCostWithTax: req.body?.additionalCostWithTax,
|
||||
shippingCurrency: req.body?.shippingCurrency,
|
||||
international: req.body?.international,
|
||||
};
|
||||
const result = await newObject({
|
||||
model: courierServiceModel,
|
||||
|
||||
@ -14,7 +14,7 @@ import {
|
||||
getModelStats,
|
||||
getModelHistory,
|
||||
checkStates,
|
||||
searchObjects
|
||||
searchObjects,
|
||||
} from '../../database/database.js';
|
||||
import {
|
||||
hasIntegration,
|
||||
@ -28,7 +28,12 @@ import {
|
||||
const logger = log4js.getLogger('Listings');
|
||||
logger.level = config.server.logLevel;
|
||||
|
||||
function pushToMarketplace(marketplaceId, listingData, user, { isNew = false, isDelete = false } = {}) {
|
||||
function pushToMarketplace(
|
||||
marketplaceId,
|
||||
listingData,
|
||||
user,
|
||||
{ isNew = false, isDelete = false } = {}
|
||||
) {
|
||||
const run = async () => {
|
||||
try {
|
||||
const marketplace = await marketplaceModel.findById(marketplaceId);
|
||||
@ -71,7 +76,7 @@ export const listListingsRouteHandler = async (
|
||||
search,
|
||||
sort,
|
||||
order,
|
||||
populate: ['product', 'vendor', 'stockLocation', 'marketplace'],
|
||||
populate: ['product', 'vendor', 'stockLocation', 'marketplace', 'courierServices'],
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
@ -94,7 +99,7 @@ export const listListingsByPropertiesRouteHandler = async (
|
||||
model: listingModel,
|
||||
properties,
|
||||
filter,
|
||||
populate: ['product', 'vendor', 'stockLocation', 'marketplace'],
|
||||
populate: ['product', 'vendor', 'stockLocation', 'marketplace', 'courierServices'],
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
@ -120,7 +125,7 @@ export const getListingRouteHandler = async (req, res) => {
|
||||
const result = await getObject({
|
||||
model: listingModel,
|
||||
id,
|
||||
populate: ['product', 'vendor', 'stockLocation', 'marketplace'],
|
||||
populate: ['product', 'vendor', 'stockLocation', 'marketplace', 'courierServices'],
|
||||
});
|
||||
if (result?.error) {
|
||||
logger.warn(`Listing not found with supplied id.`);
|
||||
@ -143,13 +148,14 @@ export const editListingRouteHandler = async (req, res) => {
|
||||
marketplace: req.body.marketplace,
|
||||
title: req.body.title,
|
||||
url: req.body.url,
|
||||
courierServices: req.body.courierServices,
|
||||
};
|
||||
const result = await editObject({
|
||||
model: listingModel,
|
||||
id,
|
||||
updateData,
|
||||
user: req.user,
|
||||
populate: ['product', 'vendor', 'stockLocation', 'marketplace'],
|
||||
populate: ['product', 'vendor', 'stockLocation', 'marketplace', 'courierServices'],
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
@ -177,6 +183,7 @@ export const newListingRouteHandler = async (req, res) => {
|
||||
title: req.body.title,
|
||||
state: req.body.state || { type: 'draft' },
|
||||
url: req.body.url,
|
||||
courierServices: req.body.courierServices,
|
||||
};
|
||||
const result = await newObject({
|
||||
model: listingModel,
|
||||
@ -306,7 +313,7 @@ export const publishListingRouteHandler = async (req, res) => {
|
||||
|
||||
const listing = await listingModel
|
||||
.findById(id)
|
||||
.populate(['marketplace', 'vendor', 'stockLocation'])
|
||||
.populate(['marketplace', 'vendor', 'stockLocation', 'courierServices'])
|
||||
.lean();
|
||||
if (!listing) {
|
||||
return res.status(404).send({ error: 'Listing not found.', code: 404 });
|
||||
@ -382,13 +389,13 @@ export const publishListingRouteHandler = async (req, res) => {
|
||||
id,
|
||||
updateData: listingUpdate,
|
||||
user: req.user,
|
||||
populate: ['product', 'vendor', 'stockLocation', 'marketplace'],
|
||||
populate: ['product', 'vendor', 'stockLocation', 'marketplace', 'courierServices'],
|
||||
});
|
||||
|
||||
const updated = await getObject({
|
||||
model: listingModel,
|
||||
id,
|
||||
populate: ['product', 'vendor', 'stockLocation', 'marketplace'],
|
||||
populate: ['product', 'vendor', 'stockLocation', 'marketplace', 'courierServices'],
|
||||
});
|
||||
res.send(updated);
|
||||
} catch (err) {
|
||||
@ -467,13 +474,13 @@ export const unpublishListingRouteHandler = async (req, res) => {
|
||||
lastSyncedAt: new Date(),
|
||||
},
|
||||
user: req.user,
|
||||
populate: ['product', 'vendor', 'stockLocation', 'marketplace'],
|
||||
populate: ['product', 'vendor', 'stockLocation', 'marketplace', 'courierServices'],
|
||||
});
|
||||
|
||||
const updated = await getObject({
|
||||
model: listingModel,
|
||||
id,
|
||||
populate: ['product', 'vendor', 'stockLocation', 'marketplace'],
|
||||
populate: ['product', 'vendor', 'stockLocation', 'marketplace', 'courierServices'],
|
||||
});
|
||||
res.send(updated);
|
||||
} catch (err) {
|
||||
|
||||
@ -12,7 +12,7 @@ import {
|
||||
getModelStats,
|
||||
getModelHistory,
|
||||
checkStates,
|
||||
searchObjects
|
||||
searchObjects,
|
||||
} from '../../database/database.js';
|
||||
import { listingModel } from '../../database/schemas/sales/listing.schema.js';
|
||||
import {
|
||||
@ -247,7 +247,12 @@ export const publishListingVarientRouteHandler = async (req, res) => {
|
||||
.findById(id)
|
||||
.populate({
|
||||
path: 'listing',
|
||||
populate: [{ path: 'marketplace' }, { path: 'vendor' }, { path: 'stockLocation' }],
|
||||
populate: [
|
||||
{ path: 'marketplace' },
|
||||
{ path: 'vendor' },
|
||||
{ path: 'stockLocation' },
|
||||
{ path: 'courierServices' },
|
||||
],
|
||||
})
|
||||
.lean();
|
||||
if (!doc) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user