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

This commit is contained in:
Tom Butcher 2026-07-18 13:36:01 +01:00
parent 561520be9a
commit e192c04000
15 changed files with 857 additions and 486 deletions

View File

@ -11,6 +11,13 @@ const courierServiceSchema = new mongoose.Schema(
tracked: { required: true, type: Boolean }, tracked: { required: true, type: Boolean },
deliveryTime: { required: true, type: Number }, deliveryTime: { required: true, type: Number },
website: { required: false, type: String }, 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 } { timestamps: true }
); );

View File

@ -22,6 +22,7 @@ const listingSchema = new Schema(
price: { type: Number, required: false }, price: { type: Number, required: false },
currency: { type: String, required: false }, currency: { type: String, required: false },
lastSyncedAt: { type: Date, required: false }, lastSyncedAt: { type: Date, required: false },
courierServices: [{ type: Schema.Types.ObjectId, ref: 'courierService', required: true }],
}, },
{ timestamps: true } { timestamps: true }
); );

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

View 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) };
}

View File

@ -7,17 +7,19 @@ export {
verifyWebhookSignature, verifyWebhookSignature,
} from './auth.js'; } from './auth.js';
export { syncItems, mapProductToListing, createItem, updateItem, deleteItem } from './listings.js';
export { syncProductCategory } from './categories.js';
export { syncFulfillmentPolicy } from './fulfillmentPolicies.js';
export { export {
syncItems,
mapProductToListing,
createItem,
updateItem,
deleteItem,
publishOfferById, publishOfferById,
withdrawOfferById, withdrawOfferById,
publishOfferForSku, publishOfferForSku,
withdrawOfferForSku, withdrawOfferForSku,
} from './listings.js'; resolveStockLocationCountryCode,
resolveStockLocationEbayCountry,
} from './listingVarients.js';
export { export {
syncOrders, syncOrders,

View 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 || []) },
};
}

View File

@ -1,307 +1,18 @@
import mongoose from 'mongoose'; import { syncProductCategory } from './categories.js';
import { stockLocationModel } from '../../../database/schemas/inventory/stocklocation.schema.js'; import { syncFulfillmentPolicy } from './fulfillmentPolicies.js';
import { import {
FARMCONTROL_GB_SUBDIVISION_STATE, buildVarientEntry,
resolveEbayCountry, fetchOffers,
} from './countryCodes.js'; resolveOfferState,
syncOfferAndMaybePublish,
upsertInventoryItem,
} from './listingVarients.js';
import { makeRequest, logger } from './shared.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) { function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, 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) { async function createOrReplaceGroup(marketplace, listing, varients) {
const groupKey = listing._reference; const groupKey = listing._reference;
const variantSKUs = varients.map((v) => v._reference).filter(Boolean); const variantSKUs = varients.map((v) => 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) { async function syncSingleVarientListing(marketplace, listing, varient) {
await upsertInventoryItem(marketplace, varient, listing); 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) { if (validVarients.length === 1) {
logger.info( logger.info(
`Syncing standalone eBay inventory item "${validVarients[0]._reference}" for listing "${ref}"` `Syncing standalone eBay inventory item "${validVarients[0]._reference}" for listing "${ref}"`
); );
const url = await syncSingleVarientListing(marketplace, listing, validVarients[0]); const url = await syncSingleVarientListing(marketplace, listingWithContext, validVarients[0]);
return { url }; return { url };
} }
const url = await syncGroupedListing(marketplace, listing, validVarients); const url = await syncGroupedListing(marketplace, listingWithContext, validVarients);
return { url }; return { url };
} }
@ -469,99 +168,6 @@ async function fetchAllInventoryItems(marketplace) {
return items; 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) { async function safeFetchInventoryItemGroup(marketplace, groupKey) {
try { try {
return await makeRequest({ return await makeRequest({
@ -651,41 +257,6 @@ export async function syncItems(marketplace) {
return results; 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) { export function mapProductToListing(ebayItem) {
if (ebayItem._type === 'group') { if (ebayItem._type === 'group') {
const group = ebayItem._group; const group = ebayItem._group;
@ -723,10 +294,7 @@ export function mapProductToListing(ebayItem) {
: undefined; : undefined;
const currency = offer?.pricingSummary?.price?.currency || undefined; const currency = offer?.pricingSummary?.price?.currency || undefined;
let stateType = 'draft'; const stateType = resolveOfferState(ebayItem._offers || []);
if (offer?.status && LISTING_STATUS_MAP[offer.status]) {
stateType = LISTING_STATUS_MAP[offer.status];
}
const url = offer?.listingId ? `https://www.ebay.com/itm/${offer.listingId}` : ''; const url = offer?.listingId ? `https://www.ebay.com/itm/${offer.listingId}` : '';

View File

@ -152,16 +152,29 @@ export async function makeRequest({
return null; 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)) { if (!response.ok && acceptableStatuses.includes(response.status)) {
return null; return null;
} }
if (!response.ok) { 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 }); logger.error(`eBay API error: ${message}`, { status: response.status, path });
throw new Error(`eBay API error (${response.status}): ${message}`); throw new Error(`eBay API error (${response.status}): ${message}`);
} }

View File

@ -225,7 +225,7 @@ async function recalculateMarketplaceState(marketplace, user) {
async function fetchFullListing(listingId) { async function fetchFullListing(listingId) {
return listingModel return listingModel
.findById(listingId) .findById(listingId)
.populate(['product', 'vendor', 'stockLocation', 'marketplace']) .populate(['product', 'vendor', 'stockLocation', 'marketplace', 'courierServices'])
.lean(); .lean();
} }

View File

@ -12,20 +12,40 @@ import {
listCourierServicesByPropertiesRouteHandler, listCourierServicesByPropertiesRouteHandler,
getCourierServiceStatsRouteHandler, getCourierServiceStatsRouteHandler,
getCourierServiceHistoryRouteHandler, getCourierServiceHistoryRouteHandler,
searchCourierServicesRouteHandler searchCourierServicesRouteHandler,
} from '../../services/management/courierservice.js'; } from '../../services/management/courierservice.js';
// list of courier services // list of courier services
router.get('/', isAuthenticated, (req, res) => { router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query; 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); const filter = getFilter(req.query, allowedFilters);
listCourierServicesRouteHandler(req, res, page, limit, property, filter, search, sort, order); listCourierServicesRouteHandler(req, res, page, limit, property, filter, search, sort, order);
}); });
router.get('/properties', isAuthenticated, (req, res) => { router.get('/properties', isAuthenticated, (req, res) => {
let properties = convertPropertiesString(req.query.properties); 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); const filter = getFilter(req.query, allowedFilters, false);
listCourierServicesByPropertiesRouteHandler(req, res, properties, filter); listCourierServicesByPropertiesRouteHandler(req, res, properties, filter);
}); });
@ -34,7 +54,6 @@ router.get('/search', isAuthenticated, (req, res) => {
searchCourierServicesRouteHandler(req, res, search); searchCourierServicesRouteHandler(req, res, search);
}); });
router.post('/', isAuthenticated, (req, res) => { router.post('/', isAuthenticated, (req, res) => {
newCourierServiceRouteHandler(req, res); newCourierServiceRouteHandler(req, res);
}); });

View File

@ -14,7 +14,7 @@ import {
getListingHistoryRouteHandler, getListingHistoryRouteHandler,
publishListingRouteHandler, publishListingRouteHandler,
unpublishListingRouteHandler, unpublishListingRouteHandler,
searchListingsRouteHandler searchListingsRouteHandler,
} from '../../services/sales/listings.js'; } from '../../services/sales/listings.js';
router.get('/', isAuthenticated, (req, res) => { router.get('/', isAuthenticated, (req, res) => {
@ -24,6 +24,7 @@ router.get('/', isAuthenticated, (req, res) => {
'vendor', 'vendor',
'stockLocation', 'stockLocation',
'marketplace', 'marketplace',
'courierServices',
'state', 'state',
'state.type', 'state.type',
'createdAt', 'createdAt',
@ -40,6 +41,7 @@ router.get('/properties', isAuthenticated, (req, res) => {
'vendor', 'vendor',
'stockLocation', 'stockLocation',
'marketplace', 'marketplace',
'courierServices',
'state', 'state',
'state.type', 'state.type',
'createdAt', 'createdAt',
@ -53,7 +55,6 @@ router.get('/search', isAuthenticated, (req, res) => {
searchListingsRouteHandler(req, res, search); searchListingsRouteHandler(req, res, search);
}); });
router.post('/', isAuthenticated, (req, res) => { router.post('/', isAuthenticated, (req, res) => {
newListingRouteHandler(req, res); newListingRouteHandler(req, res);
}); });

View File

@ -11,7 +11,7 @@ import {
listObjectsByProperties, listObjectsByProperties,
getModelStats, getModelStats,
getModelHistory, getModelHistory,
searchObjects searchObjects,
} from '../../database/database.js'; } from '../../database/database.js';
const logger = log4js.getLogger('Couriers'); const logger = log4js.getLogger('Couriers');
logger.level = config.server.logLevel; logger.level = config.server.logLevel;
@ -129,6 +129,7 @@ export const editCourierRouteHandler = async (req, res) => {
export const newCourierRouteHandler = async (req, res) => { export const newCourierRouteHandler = async (req, res) => {
const newData = { const newData = {
updatedAt: new Date(), updatedAt: new Date(),
_reference: req.body?._reference,
contact: req.body?.contact, contact: req.body?.contact,
country: req.body?.country, country: req.body?.country,
name: req.body?.name, name: req.body?.name,

View File

@ -11,7 +11,7 @@ import {
listObjectsByProperties, listObjectsByProperties,
getModelStats, getModelStats,
getModelHistory, getModelHistory,
searchObjects searchObjects,
} from '../../database/database.js'; } from '../../database/database.js';
const logger = log4js.getLogger('CourierServices'); const logger = log4js.getLogger('CourierServices');
logger.level = config.server.logLevel; logger.level = config.server.logLevel;
@ -36,7 +36,7 @@ export const listCourierServicesRouteHandler = async (
search, search,
sort, sort,
order, order,
populate: ['courier'], populate: ['courier', 'costTaxRate'],
}); });
if (result?.error) { if (result?.error) {
@ -59,7 +59,7 @@ export const listCourierServicesByPropertiesRouteHandler = async (
model: courierServiceModel, model: courierServiceModel,
properties, properties,
filter, filter,
populate: ['courier'], populate: ['courier', 'costTaxRate'],
}); });
if (result?.error) { if (result?.error) {
@ -85,7 +85,7 @@ export const getCourierServiceRouteHandler = async (req, res) => {
const result = await getObject({ const result = await getObject({
model: courierServiceModel, model: courierServiceModel,
id, id,
populate: ['courier'], populate: ['courier', 'costTaxRate'],
}); });
if (result?.error) { if (result?.error) {
logger.warn(`Courier service not found with supplied id.`); logger.warn(`Courier service not found with supplied id.`);
@ -109,6 +109,13 @@ export const editCourierServiceRouteHandler = async (req, res) => {
active: req.body?.active, active: req.body?.active,
tracked: req.body?.tracked, tracked: req.body?.tracked,
name: req.body?.name, 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 // Create audit log before updating
const result = await editObject({ const result = await editObject({
@ -116,7 +123,7 @@ export const editCourierServiceRouteHandler = async (req, res) => {
id, id,
updateData, updateData,
user: req.user, user: req.user,
populate: ['courier'], populate: ['courier', 'costTaxRate'],
}); });
if (result.error) { if (result.error) {
@ -133,12 +140,20 @@ export const editCourierServiceRouteHandler = async (req, res) => {
export const newCourierServiceRouteHandler = async (req, res) => { export const newCourierServiceRouteHandler = async (req, res) => {
const newData = { const newData = {
updatedAt: new Date(), updatedAt: new Date(),
_reference: req.body?._reference,
courier: req.body?.courier, courier: req.body?.courier,
website: req.body?.website, website: req.body?.website,
deliveryTime: req.body?.deliveryTime, deliveryTime: req.body?.deliveryTime,
active: req.body?.active, active: req.body?.active,
tracked: req.body?.tracked, tracked: req.body?.tracked,
name: req.body?.name, 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({ const result = await newObject({
model: courierServiceModel, model: courierServiceModel,

View File

@ -14,7 +14,7 @@ import {
getModelStats, getModelStats,
getModelHistory, getModelHistory,
checkStates, checkStates,
searchObjects searchObjects,
} from '../../database/database.js'; } from '../../database/database.js';
import { import {
hasIntegration, hasIntegration,
@ -28,7 +28,12 @@ import {
const logger = log4js.getLogger('Listings'); const logger = log4js.getLogger('Listings');
logger.level = config.server.logLevel; 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 () => { const run = async () => {
try { try {
const marketplace = await marketplaceModel.findById(marketplaceId); const marketplace = await marketplaceModel.findById(marketplaceId);
@ -71,7 +76,7 @@ export const listListingsRouteHandler = async (
search, search,
sort, sort,
order, order,
populate: ['product', 'vendor', 'stockLocation', 'marketplace'], populate: ['product', 'vendor', 'stockLocation', 'marketplace', 'courierServices'],
}); });
if (result?.error) { if (result?.error) {
@ -94,7 +99,7 @@ export const listListingsByPropertiesRouteHandler = async (
model: listingModel, model: listingModel,
properties, properties,
filter, filter,
populate: ['product', 'vendor', 'stockLocation', 'marketplace'], populate: ['product', 'vendor', 'stockLocation', 'marketplace', 'courierServices'],
}); });
if (result?.error) { if (result?.error) {
@ -120,7 +125,7 @@ export const getListingRouteHandler = async (req, res) => {
const result = await getObject({ const result = await getObject({
model: listingModel, model: listingModel,
id, id,
populate: ['product', 'vendor', 'stockLocation', 'marketplace'], populate: ['product', 'vendor', 'stockLocation', 'marketplace', 'courierServices'],
}); });
if (result?.error) { if (result?.error) {
logger.warn(`Listing not found with supplied id.`); logger.warn(`Listing not found with supplied id.`);
@ -143,13 +148,14 @@ export const editListingRouteHandler = async (req, res) => {
marketplace: req.body.marketplace, marketplace: req.body.marketplace,
title: req.body.title, title: req.body.title,
url: req.body.url, url: req.body.url,
courierServices: req.body.courierServices,
}; };
const result = await editObject({ const result = await editObject({
model: listingModel, model: listingModel,
id, id,
updateData, updateData,
user: req.user, user: req.user,
populate: ['product', 'vendor', 'stockLocation', 'marketplace'], populate: ['product', 'vendor', 'stockLocation', 'marketplace', 'courierServices'],
}); });
if (result.error) { if (result.error) {
@ -177,6 +183,7 @@ export const newListingRouteHandler = async (req, res) => {
title: req.body.title, title: req.body.title,
state: req.body.state || { type: 'draft' }, state: req.body.state || { type: 'draft' },
url: req.body.url, url: req.body.url,
courierServices: req.body.courierServices,
}; };
const result = await newObject({ const result = await newObject({
model: listingModel, model: listingModel,
@ -306,7 +313,7 @@ export const publishListingRouteHandler = async (req, res) => {
const listing = await listingModel const listing = await listingModel
.findById(id) .findById(id)
.populate(['marketplace', 'vendor', 'stockLocation']) .populate(['marketplace', 'vendor', 'stockLocation', 'courierServices'])
.lean(); .lean();
if (!listing) { if (!listing) {
return res.status(404).send({ error: 'Listing not found.', code: 404 }); return res.status(404).send({ error: 'Listing not found.', code: 404 });
@ -382,13 +389,13 @@ export const publishListingRouteHandler = async (req, res) => {
id, id,
updateData: listingUpdate, updateData: listingUpdate,
user: req.user, user: req.user,
populate: ['product', 'vendor', 'stockLocation', 'marketplace'], populate: ['product', 'vendor', 'stockLocation', 'marketplace', 'courierServices'],
}); });
const updated = await getObject({ const updated = await getObject({
model: listingModel, model: listingModel,
id, id,
populate: ['product', 'vendor', 'stockLocation', 'marketplace'], populate: ['product', 'vendor', 'stockLocation', 'marketplace', 'courierServices'],
}); });
res.send(updated); res.send(updated);
} catch (err) { } catch (err) {
@ -467,13 +474,13 @@ export const unpublishListingRouteHandler = async (req, res) => {
lastSyncedAt: new Date(), lastSyncedAt: new Date(),
}, },
user: req.user, user: req.user,
populate: ['product', 'vendor', 'stockLocation', 'marketplace'], populate: ['product', 'vendor', 'stockLocation', 'marketplace', 'courierServices'],
}); });
const updated = await getObject({ const updated = await getObject({
model: listingModel, model: listingModel,
id, id,
populate: ['product', 'vendor', 'stockLocation', 'marketplace'], populate: ['product', 'vendor', 'stockLocation', 'marketplace', 'courierServices'],
}); });
res.send(updated); res.send(updated);
} catch (err) { } catch (err) {

View File

@ -12,7 +12,7 @@ import {
getModelStats, getModelStats,
getModelHistory, getModelHistory,
checkStates, checkStates,
searchObjects searchObjects,
} from '../../database/database.js'; } from '../../database/database.js';
import { listingModel } from '../../database/schemas/sales/listing.schema.js'; import { listingModel } from '../../database/schemas/sales/listing.schema.js';
import { import {
@ -247,7 +247,12 @@ export const publishListingVarientRouteHandler = async (req, res) => {
.findById(id) .findById(id)
.populate({ .populate({
path: 'listing', path: 'listing',
populate: [{ path: 'marketplace' }, { path: 'vendor' }, { path: 'stockLocation' }], populate: [
{ path: 'marketplace' },
{ path: 'vendor' },
{ path: 'stockLocation' },
{ path: 'courierServices' },
],
}) })
.lean(); .lean();
if (!doc) { if (!doc) {