Some checks failed
farmcontrol/farmcontrol-api/pipeline/head There was a failure building this commit
This commit modifies the log level in the configuration file from "trace" to "debug" for improved logging clarity. It also introduces new routes for fulfillment, return, and payment policies in the index file, enhancing the marketplace integration capabilities. Additionally, the initialization process is updated to start the marketplace worker, ensuring that the application can handle marketplace operations effectively. Various schemas are updated to include new fields and relationships for policies, improving the overall functionality and flexibility of the marketplace management system.
516 lines
17 KiB
JavaScript
516 lines
17 KiB
JavaScript
import mongoose from 'mongoose';
|
|
import { courierServiceModel } from '../../../database/schemas/management/courierservice.schema.js';
|
|
import { fulfillmentPolicyModel } from '../../../database/schemas/sales/fulfillmentpolicy.schema.js';
|
|
import { makeRequest, logger } from './shared.js';
|
|
import {
|
|
POLICY_CATEGORY_TYPE,
|
|
buildCategoryTypes as buildSharedCategoryTypes,
|
|
ensureSellingPolicyManagement,
|
|
getEbayMarketplaceId,
|
|
idOf,
|
|
isDefaultAccountPolicy,
|
|
mappingExternalReference,
|
|
persistMarketplaceMapping,
|
|
updateAccountPolicy,
|
|
upsertLocalPolicyFromRemote,
|
|
} from './accountPolicies.js';
|
|
|
|
const FULFILLMENT_CATEGORY_TYPE = POLICY_CATEGORY_TYPE;
|
|
|
|
function isPopulatedCourierService(service) {
|
|
return (
|
|
service &&
|
|
typeof service === 'object' &&
|
|
!(service instanceof mongoose.Types.ObjectId) &&
|
|
service.name != null
|
|
);
|
|
}
|
|
|
|
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', 'marketplaces.marketplace'])
|
|
.lean();
|
|
const servicesById = new Map(services.map((service) => [String(service._id), service]));
|
|
|
|
return serviceIds.map((id) => servicesById.get(String(id))).filter(Boolean);
|
|
}
|
|
|
|
export function getCourierServiceMarketplaceMappings(service) {
|
|
if (Array.isArray(service?.marketplaces) && service.marketplaces.length) {
|
|
return service.marketplaces;
|
|
}
|
|
if (service?.marketplace) {
|
|
return [
|
|
{
|
|
marketplace: service.marketplace,
|
|
externalReference: service.externalReference,
|
|
},
|
|
];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
export function getCourierServiceShippingCode(service, marketplace) {
|
|
const marketplaceId = idOf(marketplace);
|
|
const mappings = getCourierServiceMarketplaceMappings(service);
|
|
if (!marketplaceId) {
|
|
return mappings[0]?.externalReference || service?.externalReference || '';
|
|
}
|
|
const mapping = mappings.find((entry) => idOf(entry?.marketplace) === marketplaceId);
|
|
return mapping?.externalReference || '';
|
|
}
|
|
|
|
export function validateCourierServices(services, listing, marketplace) {
|
|
const activeServices = services.filter((service) => service.active !== false);
|
|
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 (!getCourierServiceShippingCode(service, marketplace)) {
|
|
throw new Error(
|
|
`Courier service "${service.name}" requires an eBay shipping service code before it can be used on an eBay listing.`
|
|
);
|
|
}
|
|
}
|
|
|
|
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, marketplace) {
|
|
const cost = Number(service.costWithTax ?? service.cost ?? 0);
|
|
const additionalCost = Number(service.additionalCostWithTax ?? service.additionalCost ?? 0);
|
|
const shippingService = {
|
|
sortOrder: index + 1,
|
|
shippingServiceCode: getCourierServiceShippingCode(service, marketplace),
|
|
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, marketplace) {
|
|
if (!services.length) return null;
|
|
return {
|
|
optionType,
|
|
costType: 'FLAT_RATE',
|
|
shippingServices: services.map((service, index) =>
|
|
buildShippingService(service, index, defaultCurrency, marketplace)
|
|
),
|
|
};
|
|
}
|
|
|
|
export function isDefaultFulfillmentPolicy(existingPolicy, allPolicies = []) {
|
|
return isDefaultAccountPolicy(existingPolicy, allPolicies, 'fulfillmentPolicyId');
|
|
}
|
|
|
|
export function buildCategoryTypes(existingPolicy, allPolicies = []) {
|
|
return buildSharedCategoryTypes(existingPolicy, allPolicies, 'fulfillmentPolicyId');
|
|
}
|
|
|
|
export function buildFulfillmentPolicy(
|
|
listing,
|
|
marketplace,
|
|
services,
|
|
existingPolicy,
|
|
allPolicies = [],
|
|
overrides = {}
|
|
) {
|
|
const marketplaceId = getEbayMarketplaceId(marketplace);
|
|
const defaultCurrency = marketplace.config?.currency || listing.currency || 'GBP';
|
|
const domesticServices = services.filter((service) => !service.international);
|
|
const internationalServices = services.filter((service) => service.international);
|
|
const shippingOptions = [
|
|
buildShippingOption('DOMESTIC', domesticServices, defaultCurrency, marketplace),
|
|
buildShippingOption('INTERNATIONAL', internationalServices, defaultCurrency, marketplace),
|
|
].filter(Boolean);
|
|
const deliveryTime =
|
|
overrides.handlingTime != null
|
|
? Number(overrides.handlingTime)
|
|
: Math.max(0, ...services.map((service) => Number(service.deliveryTime ?? 1)));
|
|
|
|
return {
|
|
name: String(overrides.name || `FarmControl ${listing._reference || ''}`).slice(0, 64),
|
|
description: String(
|
|
overrides.description || `Managed by FarmControl for listing ${listing._reference || ''}`
|
|
).slice(0, 250),
|
|
marketplaceId,
|
|
categoryTypes: buildCategoryTypes(existingPolicy, allPolicies),
|
|
handlingTime: { value: Number.isFinite(deliveryTime) ? deliveryTime : 1, unit: 'DAY' },
|
|
localPickup: overrides.localPickup === true,
|
|
globalShipping: overrides.globalShipping === true,
|
|
freightShipping: overrides.freightShipping === true,
|
|
pickupDropOff: overrides.pickupDropOff === true,
|
|
shippingOptions,
|
|
};
|
|
}
|
|
|
|
function moneyKey(amount) {
|
|
if (amount == null || amount.value == null || amount.value === '') {
|
|
return '0:';
|
|
}
|
|
return `${Number(amount.value)}:${amount.currency || ''}`;
|
|
}
|
|
|
|
function shippingServiceFingerprint(service = {}) {
|
|
return [
|
|
service.sortOrder || 1,
|
|
service.shippingServiceCode || '',
|
|
service.freeShipping ? '1' : '0',
|
|
service.buyerResponsibleForShipping ? '1' : '0',
|
|
moneyKey(service.shippingCost),
|
|
moneyKey(service.additionalShippingCost),
|
|
].join('|');
|
|
}
|
|
|
|
function shippingOptionFingerprint(option = {}) {
|
|
const services = [...(option.shippingServices || [])]
|
|
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
|
|
.map(shippingServiceFingerprint);
|
|
return `${option.optionType || ''}|${option.costType || ''}|${services.join(',')}`;
|
|
}
|
|
|
|
export function fulfillmentPolicyFingerprint(policy) {
|
|
const options = [...(policy?.shippingOptions || [])]
|
|
.sort((a, b) => String(a?.optionType || '').localeCompare(String(b?.optionType || '')))
|
|
.map(shippingOptionFingerprint);
|
|
const handling = policy?.handlingTime || {};
|
|
return [
|
|
policy?.marketplaceId || '',
|
|
Number(handling.value) || 0,
|
|
handling.unit || 'DAY',
|
|
policy?.localPickup ? 1 : 0,
|
|
policy?.globalShipping ? 1 : 0,
|
|
policy?.freightShipping ? 1 : 0,
|
|
policy?.pickupDropOff ? 1 : 0,
|
|
options.join(';'),
|
|
].join('~');
|
|
}
|
|
|
|
export function findMatchingFulfillmentPolicy(desiredPolicy, allPolicies = []) {
|
|
const desiredFingerprint = fulfillmentPolicyFingerprint(desiredPolicy);
|
|
return (
|
|
allPolicies.find(
|
|
(policy) =>
|
|
policy?.fulfillmentPolicyId && fulfillmentPolicyFingerprint(policy) === desiredFingerprint
|
|
) || null
|
|
);
|
|
}
|
|
|
|
export function duplicateFulfillmentPolicyId(err) {
|
|
const params = [];
|
|
for (const ebayError of err?.ebayErrors || []) {
|
|
if (Array.isArray(ebayError?.parameters)) params.push(...ebayError.parameters);
|
|
}
|
|
const match = params.find((param) =>
|
|
/^(DuplicateProfileId|Shipping Profile Id)$/i.test(param?.name || '')
|
|
);
|
|
return match?.value ? String(match.value) : '';
|
|
}
|
|
|
|
function getMarketplaceId(marketplace) {
|
|
return getEbayMarketplaceId(marketplace);
|
|
}
|
|
|
|
async function fetchFulfillmentPolicyByName(marketplace, name) {
|
|
return makeRequest({
|
|
marketplace,
|
|
path: '/sell/account/v1/fulfillment_policy/get_by_policy_name',
|
|
params: {
|
|
marketplace_id: getMarketplaceId(marketplace),
|
|
name,
|
|
},
|
|
acceptableStatuses: [404],
|
|
});
|
|
}
|
|
|
|
async function fetchFulfillmentPolicies(marketplace) {
|
|
const result = await makeRequest({
|
|
marketplace,
|
|
path: '/sell/account/v1/fulfillment_policy',
|
|
params: { marketplace_id: getMarketplaceId(marketplace) },
|
|
acceptableStatuses: [404],
|
|
});
|
|
return result?.fulfillmentPolicies || [];
|
|
}
|
|
|
|
export { fetchFulfillmentPolicies };
|
|
|
|
async function updateFulfillmentPolicy(marketplace, fulfillmentPolicyId, policy) {
|
|
await updateAccountPolicy(
|
|
marketplace,
|
|
`/sell/account/v1/fulfillment_policy/${encodeURIComponent(fulfillmentPolicyId)}`,
|
|
policy
|
|
);
|
|
}
|
|
|
|
async function resolvePolicyCourierServices(policy) {
|
|
const serviceRefs = policy?.courierServices || [];
|
|
if (!serviceRefs.length) return [];
|
|
const populatedServices = serviceRefs.filter(isPopulatedCourierService);
|
|
if (populatedServices.length === serviceRefs.length) {
|
|
return populatedServices;
|
|
}
|
|
const serviceIds = serviceRefs.map((service) => service?._id || service).filter(Boolean);
|
|
const services = await courierServiceModel
|
|
.find({ _id: { $in: serviceIds } })
|
|
.populate(['courier', 'marketplaces.marketplace'])
|
|
.lean();
|
|
const servicesById = new Map(services.map((service) => [String(service._id), service]));
|
|
return serviceIds.map((id) => servicesById.get(String(id))).filter(Boolean);
|
|
}
|
|
|
|
export async function ensureFulfillmentPolicySynced(marketplace, policy) {
|
|
if (!policy) {
|
|
throw new Error('A fulfillment policy is required before publishing to eBay.');
|
|
}
|
|
|
|
await ensureSellingPolicyManagement(marketplace);
|
|
await persistMarketplaceMapping(fulfillmentPolicyModel, policy, marketplace, {
|
|
stateType: 'syncing',
|
|
});
|
|
|
|
try {
|
|
const services = validateCourierServices(
|
|
await resolvePolicyCourierServices(policy),
|
|
{ _reference: policy._reference || policy.name },
|
|
marketplace
|
|
);
|
|
const existingId = mappingExternalReference(policy, marketplace);
|
|
const [existingByName, allPolicies] = await Promise.all([
|
|
existingId ? null : fetchFulfillmentPolicyByName(marketplace, String(policy.name).slice(0, 64)),
|
|
fetchFulfillmentPolicies(marketplace),
|
|
]);
|
|
const existingPolicy =
|
|
(existingId &&
|
|
allPolicies.find((item) => String(item.fulfillmentPolicyId) === String(existingId))) ||
|
|
existingByName ||
|
|
null;
|
|
const payload = buildFulfillmentPolicy(
|
|
{ _reference: policy._reference, currency: marketplace.config?.currency || 'GBP' },
|
|
marketplace,
|
|
services,
|
|
existingPolicy,
|
|
allPolicies,
|
|
{
|
|
name: policy.name,
|
|
description: policy.description,
|
|
handlingTime: policy.handlingTime,
|
|
localPickup: policy.localPickup,
|
|
globalShipping: policy.globalShipping,
|
|
freightShipping: policy.freightShipping,
|
|
pickupDropOff: policy.pickupDropOff,
|
|
}
|
|
);
|
|
let fulfillmentPolicyId = existingId || existingPolicy?.fulfillmentPolicyId;
|
|
|
|
if (fulfillmentPolicyId) {
|
|
await updateFulfillmentPolicy(marketplace, fulfillmentPolicyId, payload);
|
|
} else {
|
|
const matchingPolicy = findMatchingFulfillmentPolicy(payload, allPolicies);
|
|
if (matchingPolicy?.fulfillmentPolicyId) {
|
|
fulfillmentPolicyId = matchingPolicy.fulfillmentPolicyId;
|
|
} else {
|
|
try {
|
|
const created = await makeRequest({
|
|
marketplace,
|
|
method: 'POST',
|
|
path: '/sell/account/v1/fulfillment_policy',
|
|
body: payload,
|
|
});
|
|
fulfillmentPolicyId = created?.fulfillmentPolicyId;
|
|
} catch (err) {
|
|
const duplicateId = duplicateFulfillmentPolicyId(err);
|
|
if (!duplicateId) throw err;
|
|
fulfillmentPolicyId = duplicateId;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!fulfillmentPolicyId) {
|
|
throw new Error(`eBay did not return an ID for fulfillment policy "${payload.name}"`);
|
|
}
|
|
|
|
await persistMarketplaceMapping(fulfillmentPolicyModel, policy, marketplace, {
|
|
externalReference: String(fulfillmentPolicyId),
|
|
stateType: 'ready',
|
|
});
|
|
logger.info(`Synced eBay fulfillment policy "${payload.name}" (${fulfillmentPolicyId})`);
|
|
return { fulfillmentPolicyId: String(fulfillmentPolicyId) };
|
|
} catch (err) {
|
|
await persistMarketplaceMapping(fulfillmentPolicyModel, policy, marketplace, {
|
|
stateType: 'failed',
|
|
message: err.message,
|
|
});
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function collectRemoteShippingCodes(remote) {
|
|
const codes = [];
|
|
for (const option of remote?.shippingOptions || []) {
|
|
for (const service of option.shippingServices || []) {
|
|
if (service.shippingServiceCode) codes.push(String(service.shippingServiceCode));
|
|
}
|
|
}
|
|
return codes;
|
|
}
|
|
|
|
async function matchCourierServicesByShippingCodes(marketplace, codes) {
|
|
if (!codes.length) return [];
|
|
const services = await courierServiceModel
|
|
.find({ 'marketplaces.marketplace': idOf(marketplace) })
|
|
.lean();
|
|
const matched = [];
|
|
for (const code of codes) {
|
|
const service = services.find((item) =>
|
|
(item.marketplaces || []).some(
|
|
(entry) =>
|
|
idOf(entry.marketplace) === idOf(marketplace) &&
|
|
String(entry.externalReference) === String(code)
|
|
)
|
|
);
|
|
if (service && !matched.some((item) => String(item._id) === String(service._id))) {
|
|
matched.push(service);
|
|
}
|
|
}
|
|
return matched.map((service) => service._id);
|
|
}
|
|
|
|
function mapRemoteFulfillmentPolicy(remote, courierServiceIds) {
|
|
return {
|
|
description: remote.description,
|
|
handlingTime: Number(remote.handlingTime?.value) || 1,
|
|
localPickup: Boolean(remote.localPickup),
|
|
globalShipping: Boolean(remote.globalShipping),
|
|
freightShipping: Boolean(remote.freightShipping),
|
|
pickupDropOff: Boolean(remote.pickupDropOff),
|
|
courierServices: courierServiceIds,
|
|
};
|
|
}
|
|
|
|
export async function syncFulfillmentPoliciesFromEbay(marketplace) {
|
|
await ensureSellingPolicyManagement(marketplace);
|
|
const remotes = await fetchFulfillmentPolicies(marketplace);
|
|
const upserted = [];
|
|
for (const remote of remotes) {
|
|
const fulfillmentPolicyId = remote.fulfillmentPolicyId;
|
|
if (!fulfillmentPolicyId || !remote.name) continue;
|
|
const courierServiceIds = await matchCourierServicesByShippingCodes(
|
|
marketplace,
|
|
collectRemoteShippingCodes(remote)
|
|
);
|
|
upserted.push(
|
|
await upsertLocalPolicyFromRemote({
|
|
model: fulfillmentPolicyModel,
|
|
marketplace,
|
|
remoteId: fulfillmentPolicyId,
|
|
name: remote.name,
|
|
fields: mapRemoteFulfillmentPolicy(remote, courierServiceIds),
|
|
})
|
|
);
|
|
}
|
|
logger.info(
|
|
`Imported ${upserted.length} eBay fulfillment polic${upserted.length === 1 ? 'y' : 'ies'}`
|
|
);
|
|
return { count: upserted.length };
|
|
}
|
|
|
|
export async function syncFulfillmentPolicy(marketplace, listing) {
|
|
await ensureSellingPolicyManagement(marketplace);
|
|
|
|
if (listing?.fulfillmentPolicyId) {
|
|
return { fulfillmentPolicyId: String(listing.fulfillmentPolicyId) };
|
|
}
|
|
|
|
const services = validateCourierServices(
|
|
await resolveCourierServices(listing),
|
|
listing,
|
|
marketplace
|
|
);
|
|
const policyName = `FarmControl ${listing._reference}`.slice(0, 64);
|
|
const [existingPolicy, allPolicies] = await Promise.all([
|
|
fetchFulfillmentPolicyByName(marketplace, policyName),
|
|
fetchFulfillmentPolicies(marketplace),
|
|
]);
|
|
const policy = buildFulfillmentPolicy(
|
|
listing,
|
|
marketplace,
|
|
services,
|
|
existingPolicy,
|
|
allPolicies
|
|
);
|
|
|
|
if (existingPolicy?.fulfillmentPolicyId) {
|
|
await updateFulfillmentPolicy(marketplace, existingPolicy.fulfillmentPolicyId, policy);
|
|
logger.info(
|
|
`Updated eBay fulfillment policy "${policy.name}" (${existingPolicy.fulfillmentPolicyId})`
|
|
);
|
|
return { fulfillmentPolicyId: String(existingPolicy.fulfillmentPolicyId) };
|
|
}
|
|
|
|
const matchingPolicy = findMatchingFulfillmentPolicy(policy, allPolicies);
|
|
if (matchingPolicy?.fulfillmentPolicyId) {
|
|
logger.info(
|
|
`Reusing eBay fulfillment policy "${matchingPolicy.name}" (${matchingPolicy.fulfillmentPolicyId}) with matching shipping settings`
|
|
);
|
|
return { fulfillmentPolicyId: String(matchingPolicy.fulfillmentPolicyId) };
|
|
}
|
|
|
|
try {
|
|
const result = await makeRequest({
|
|
marketplace,
|
|
method: 'POST',
|
|
path: '/sell/account/v1/fulfillment_policy',
|
|
body: policy,
|
|
});
|
|
if (!result?.fulfillmentPolicyId) {
|
|
throw new Error(`eBay did not return an ID for fulfillment policy "${policy.name}"`);
|
|
}
|
|
|
|
logger.info(`Created eBay fulfillment policy "${policy.name}" (${result.fulfillmentPolicyId})`);
|
|
return { fulfillmentPolicyId: String(result.fulfillmentPolicyId) };
|
|
} catch (err) {
|
|
const duplicateId = duplicateFulfillmentPolicyId(err);
|
|
if (duplicateId) {
|
|
logger.info(
|
|
`Reusing existing eBay fulfillment policy ${duplicateId} after duplicate-policy response`
|
|
);
|
|
return { fulfillmentPolicyId: duplicateId };
|
|
}
|
|
throw err;
|
|
}
|
|
}
|