diff --git a/src/database/schemas/management/courierservice.schema.js b/src/database/schemas/management/courierservice.schema.js index db5690c..cbb24a0 100644 --- a/src/database/schemas/management/courierservice.schema.js +++ b/src/database/schemas/management/courierservice.schema.js @@ -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 } ); diff --git a/src/database/schemas/sales/listing.schema.js b/src/database/schemas/sales/listing.schema.js index 20ecc48..c86c983 100644 --- a/src/database/schemas/sales/listing.schema.js +++ b/src/database/schemas/sales/listing.schema.js @@ -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 } ); diff --git a/src/integrations/marketplaces/ebay/categories.js b/src/integrations/marketplaces/ebay/categories.js new file mode 100644 index 0000000..347ba27 --- /dev/null +++ b/src/integrations/marketplaces/ebay/categories.js @@ -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; +} diff --git a/src/integrations/marketplaces/ebay/fulfillmentPolicies.js b/src/integrations/marketplaces/ebay/fulfillmentPolicies.js new file mode 100644 index 0000000..c2fea76 --- /dev/null +++ b/src/integrations/marketplaces/ebay/fulfillmentPolicies.js @@ -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) }; +} diff --git a/src/integrations/marketplaces/ebay/index.js b/src/integrations/marketplaces/ebay/index.js index 8608c49..b08d571 100644 --- a/src/integrations/marketplaces/ebay/index.js +++ b/src/integrations/marketplaces/ebay/index.js @@ -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, diff --git a/src/integrations/marketplaces/ebay/listingVarients.js b/src/integrations/marketplaces/ebay/listingVarients.js new file mode 100644 index 0000000..42e962f --- /dev/null +++ b/src/integrations/marketplaces/ebay/listingVarients.js @@ -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 || []) }, + }; +} diff --git a/src/integrations/marketplaces/ebay/listings.js b/src/integrations/marketplaces/ebay/listings.js index a14557a..c668cd7 100644 --- a/src/integrations/marketplaces/ebay/listings.js +++ b/src/integrations/marketplaces/ebay/listings.js @@ -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}` : ''; diff --git a/src/integrations/marketplaces/ebay/shared.js b/src/integrations/marketplaces/ebay/shared.js index 375181a..6e001e3 100644 --- a/src/integrations/marketplaces/ebay/shared.js +++ b/src/integrations/marketplaces/ebay/shared.js @@ -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}`); } diff --git a/src/integrations/marketplaceworker.js b/src/integrations/marketplaceworker.js index a46b2fe..9aeb690 100644 --- a/src/integrations/marketplaceworker.js +++ b/src/integrations/marketplaceworker.js @@ -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(); } diff --git a/src/routes/management/courierservice.js b/src/routes/management/courierservice.js index 0e8df16..cc6790b 100644 --- a/src/routes/management/courierservice.js +++ b/src/routes/management/courierservice.js @@ -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); }); diff --git a/src/routes/sales/listings.js b/src/routes/sales/listings.js index 7df6916..2c7a274 100644 --- a/src/routes/sales/listings.js +++ b/src/routes/sales/listings.js @@ -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); }); diff --git a/src/services/management/courier.js b/src/services/management/courier.js index 8937d5a..e9e516d 100644 --- a/src/services/management/courier.js +++ b/src/services/management/courier.js @@ -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, diff --git a/src/services/management/courierservice.js b/src/services/management/courierservice.js index 72cf1a3..6d9890d 100644 --- a/src/services/management/courierservice.js +++ b/src/services/management/courierservice.js @@ -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, diff --git a/src/services/sales/listings.js b/src/services/sales/listings.js index bc59827..767ca20 100644 --- a/src/services/sales/listings.js +++ b/src/services/sales/listings.js @@ -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) { diff --git a/src/services/sales/listingvarients.js b/src/services/sales/listingvarients.js index 2ce544e..412a781 100644 --- a/src/services/sales/listingvarients.js +++ b/src/services/sales/listingvarients.js @@ -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) {