Refactor eBay order and shipping services integration; introduce trading request utilities

This commit refactors the eBay order integration by introducing a new module for trading requests, encapsulating common functionalities such as XML parsing and request handling. The `orders.js` file is significantly expanded to include new parsing functions for shipping addresses, tracking details, and trading transactions, enhancing the overall order processing capabilities. Additionally, the `shippingServices.js` file is streamlined by removing deprecated constants and functions, replacing them with calls to the new trading request utilities. Comprehensive tests are added to ensure the correctness of the new functionalities and their integration with existing systems, improving the reliability of eBay marketplace operations.
This commit is contained in:
Tom Butcher 2026-08-30 11:11:59 +01:00
parent 1cef3a8d1d
commit 933d76969f
5 changed files with 614 additions and 263 deletions

View File

@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, jest } from '@jest/globals';
import { getOrder, syncOrders } from '../orders.js';
const successXml = `<?xml version="1.0" encoding="utf-8"?>
<GetOrdersResponse xmlns="urn:ebay:apis:eBLBaseComponents">
<Ack>Success</Ack>
<HasMoreOrders>false</HasMoreOrders>
<OrderArray>
<Order>
<OrderID>12-34567-89012</OrderID>
<OrderStatus>Completed</OrderStatus>
<Subtotal currencyID="GBP">10.00</Subtotal>
<Total currencyID="GBP">12.50</Total>
<BuyerUserID>ebay-buyer</BuyerUserID>
</Order>
</OrderArray>
</GetOrdersResponse>`;
const marketplace = {
name: 'eBay UK',
config: { accessToken: 'token', marketplaceId: 'EBAY_GB' },
};
function mockTradingResponse(xml = successXml) {
return jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
status: 200,
text: async () => xml,
});
}
describe('eBay Trading API order fetch', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('syncs orders with GetOrders and a default lookback', async () => {
const fetchSpy = mockTradingResponse();
const orders = await syncOrders(marketplace);
expect(fetchSpy).toHaveBeenCalledWith(
'https://api.ebay.com/ws/api.dll',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'X-EBAY-API-CALL-NAME': 'GetOrders',
'X-EBAY-API-IAF-TOKEN': 'token',
'X-EBAY-API-SITEID': '3',
}),
})
);
const body = fetchSpy.mock.calls[0][1].body;
expect(body).toContain('<GetOrdersRequest');
expect(body).toContain('<NumberOfDays>30</NumberOfDays>');
expect(body).toContain('<OrderRole>Seller</OrderRole>');
expect(orders).toHaveLength(1);
expect(orders[0].orderId).toBe('12-34567-89012');
});
it('uses CreateTimeFrom/To when a sync window is provided', async () => {
const fetchSpy = mockTradingResponse();
await syncOrders(marketplace, { startTime: 1756512000, endTime: 1756598400 });
const body = fetchSpy.mock.calls[0][1].body;
expect(body).toContain('<CreateTimeFrom>2025-08-30T00:00:00.000Z</CreateTimeFrom>');
expect(body).toContain('<CreateTimeTo>2025-08-31T00:00:00.000Z</CreateTimeTo>');
expect(body).not.toContain('<NumberOfDays>');
});
it('loads a single order by OrderID', async () => {
const fetchSpy = mockTradingResponse();
const order = await getOrder(marketplace, '12-34567-89012');
const body = fetchSpy.mock.calls[0][1].body;
expect(body).toContain('<OrderID>12-34567-89012</OrderID>');
expect(order.orderId).toBe('12-34567-89012');
});
});

View File

@ -6,56 +6,106 @@ import {
mapOrderLineItems,
mapOrderShipments,
handleWebhook,
parseGetOrdersResponse,
} from '../orders.js';
const ebayOrder = {
const sampleGetOrdersXml = `
<GetOrdersResponse xmlns="urn:ebay:apis:eBLBaseComponents">
<Ack>Success</Ack>
<HasMoreOrders>false</HasMoreOrders>
<OrderArray>
<Order>
<OrderID>12-34567-89012</OrderID>
<ExtendedOrderID>12-34567-89012</ExtendedOrderID>
<OrderStatus>Completed</OrderStatus>
<AmountPaid currencyID="GBP">13.70</AmountPaid>
<CheckoutStatus>
<Status>Complete</Status>
</CheckoutStatus>
<ShippingDetails>
<SalesTax>
<SalesTaxAmount currencyID="GBP">1.20</SalesTaxAmount>
</SalesTax>
<ShipmentTrackingDetails>
<ShippingCarrierUsed>Royal Mail</ShippingCarrierUsed>
<ShipmentTrackingNumber>TRACK123</ShipmentTrackingNumber>
</ShipmentTrackingDetails>
</ShippingDetails>
<CreatedTime>2026-08-01T09:00:00.000Z</CreatedTime>
<ShippingAddress>
<Name>Jane Buyer</Name>
<Street1>1 High St</Street1>
<CityName>London</CityName>
<PostalCode>SW1A 1AA</PostalCode>
<Country>GB</Country>
<Phone>012345</Phone>
</ShippingAddress>
<ShippingServiceSelected>
<ShippingService>UK_RoyalMailFirstClassStandard</ShippingService>
<ShippingServiceCost currencyID="GBP">2.50</ShippingServiceCost>
</ShippingServiceSelected>
<Subtotal currencyID="GBP">10.00</Subtotal>
<Total currencyID="GBP">13.70</Total>
<TransactionArray>
<Transaction>
<Buyer>
<Email>buyer@example.com</Email>
<UserFirstName>Jane</UserFirstName>
<UserLastName>Buyer</UserLastName>
</Buyer>
<Item>
<ItemID>111</ItemID>
<Title>Red widget</Title>
<SKU>SKU-RED</SKU>
</Item>
<QuantityPurchased>2</QuantityPurchased>
<TransactionID>line-1</TransactionID>
<TransactionPrice currencyID="GBP">5.00</TransactionPrice>
<Taxes>
<TotalTaxAmount currencyID="GBP">1.00</TotalTaxAmount>
</Taxes>
<OrderLineItemID>line-1</OrderLineItemID>
</Transaction>
</TransactionArray>
<BuyerUserID>ebay-buyer</BuyerUserID>
<PaidTime>2026-08-01T09:05:00.000Z</PaidTime>
<ShippedTime>2026-08-01T10:00:00.000Z</ShippedTime>
<CancelStatus>NotApplicable</CancelStatus>
</Order>
</OrderArray>
</GetOrdersResponse>
`;
const ebayOrder = parseGetOrdersResponse(sampleGetOrdersXml).orders[0];
describe('eBay Trading API GetOrders parsing', () => {
it('parses order totals, buyer, lines, and tracking', () => {
expect(ebayOrder).toMatchObject({
orderId: '12-34567-89012',
orderFulfillmentStatus: 'NOT_STARTED',
pricingSummary: {
priceSubtotal: { value: '10.00' },
deliveryCost: { value: '2.50' },
tax: { value: '1.20' },
total: { value: '13.70' },
},
buyer: {
username: 'ebay-buyer',
buyerRegistrationAddress: { email: 'buyer@example.com' },
},
fulfillmentStartInstructions: [
{
shippingStep: {
shipTo: {
fullName: 'Jane Buyer',
primaryPhone: { phoneNumber: '012345' },
contactAddress: {
addressLine1: '1 High St',
city: 'London',
postalCode: 'SW1A 1AA',
countryCode: 'GB',
},
},
},
},
],
lineItems: [
{
lineItemId: 'line-1',
orderStatus: 'Completed',
buyerUserId: 'ebay-buyer',
buyerEmail: 'buyer@example.com',
subtotal: 10,
shippingAmount: 2.5,
salesTaxAmount: 1.2,
total: 13.7,
});
expect(ebayOrder.shippingAddress.city).toBe('London');
expect(ebayOrder.transactions[0]).toMatchObject({
sku: 'SKU-RED',
title: 'Red widget',
quantity: 2,
lineItemCost: { value: '5.00' },
taxes: [{ amount: { value: '1.00' } }],
},
],
shippingFulfillments: [
{
fulfillmentId: 'ful-1',
shipmentTrackingNumber: 'TRACK123',
shippedDate: '2026-08-01T10:00:00.000Z',
lineItems: [{ lineItemId: 'line-1' }],
},
],
};
transactionPrice: 5,
});
expect(ebayOrder.shipments[0].trackingNumber).toBe('TRACK123');
});
it('treats HasMoreOrders as false when missing', () => {
expect(parseGetOrdersResponse('<Ack>Success</Ack>')).toEqual({
orders: [],
hasMoreOrders: false,
});
});
});
describe('eBay order mappers', () => {
it('maps sales order totals and externalReference', () => {
@ -64,13 +114,18 @@ describe('eBay order mappers', () => {
expect(mapped.totalAmount).toBe(10);
expect(mapped.shippingAmount).toBe(2.5);
expect(mapped.totalTaxAmount).toBe(1.2);
expect(mapped.state.type).toBe('draft');
expect(mapped.state.type).toBe('shipped');
});
it('maps cancelled orders', () => {
expect(
mapOrderStatus({ cancelStatus: { cancelState: 'CANCELED' } })
).toBe('cancelled');
expect(mapOrderStatus({ cancelStatus: 'CancelClosedWithRefund' })).toBe('cancelled');
expect(mapOrderStatus({ orderStatus: 'Cancelled' })).toBe('cancelled');
});
it('maps paid but unshipped orders as confirmed', () => {
expect(mapOrderStatus({ orderStatus: 'Completed', checkoutStatus: 'Complete' })).toBe(
'confirmed'
);
});
it('maps buyer to client with marketplace buyer id', () => {
@ -93,10 +148,10 @@ describe('eBay order mappers', () => {
});
});
it('maps shipments from fulfillments', () => {
it('maps shipments from tracking details', () => {
const shipments = mapOrderShipments(ebayOrder);
expect(shipments[0]).toMatchObject({
externalReference: 'ful-1',
externalReference: 'TRACK123',
trackingNumber: 'TRACK123',
});
expect(shipments[0].state.type).toBe('shipped');
@ -105,7 +160,10 @@ describe('eBay order mappers', () => {
it('classifies commerce notification topics', async () => {
const sold = await handleWebhook(
{ name: 'eBay' },
{ metadata: { topic: 'ORDER_CONFIRMATION' }, notification: { data: { orderId: 'o1' }, notificationId: 'n1' } }
{
metadata: { topic: 'ORDER_CONFIRMATION' },
notification: { data: { orderId: 'o1' }, notificationId: 'n1' },
}
);
expect(sold).toMatchObject({ action: 'orderUpdate', orderId: 'o1', notificationId: 'n1' });

View File

@ -1,49 +1,217 @@
import { makeRequest, logger } from './shared.js';
import { makeTradingRequest, xmlBlocks, xmlEscape, xmlNumber, xmlText } from './trading.js';
const ENTRIES_PER_PAGE = 100;
const DEFAULT_LOOKBACK_DAYS = 30;
const MAX_RANGE_MS = 90 * 24 * 60 * 60 * 1000;
const INVALID_EMAILS = new Set(['', 'invalid request']);
const CANCELLED_STATUSES = new Set([
'Cancelled',
'CancelClosedNoRefund',
'CancelClosedWithRefund',
'CancelClosedUnknownRefund',
'CancelClosedForCommitment',
]);
function headerXml(orderXml) {
return String(orderXml || '').replace(/<TransactionArray>[\s\S]*?<\/TransactionArray>/i, '');
}
function parseShippingAddress(xml) {
const block = xmlBlocks(xml, 'ShippingAddress')[0] || '';
return {
name: xmlText(block, 'Name'),
street1: xmlText(block, 'Street1'),
street2: xmlText(block, 'Street2'),
city: xmlText(block, 'CityName'),
state: xmlText(block, 'StateOrProvince'),
postalCode: xmlText(block, 'PostalCode'),
country: xmlText(block, 'Country'),
phone: xmlText(block, 'Phone'),
};
}
function parseTrackingDetails(xml) {
return xmlBlocks(xml, 'ShipmentTrackingDetails')
.map((block) => ({
trackingNumber: xmlText(block, 'ShipmentTrackingNumber'),
carrier: xmlText(block, 'ShippingCarrierUsed'),
}))
.filter((detail) => detail.trackingNumber);
}
function parseTradingTransaction(xml) {
const item = xmlBlocks(xml, 'Item')[0] || '';
const buyer = xmlBlocks(xml, 'Buyer')[0] || '';
const taxes = xmlBlocks(xml, 'Taxes')[0] || '';
return {
transactionId: xmlText(xml, 'TransactionID'),
orderLineItemId: xmlText(xml, 'OrderLineItemID'),
sku: xmlText(item, 'SKU'),
title: xmlText(item, 'Title'),
quantity: xmlNumber(xml, 'QuantityPurchased') || 1,
transactionPrice: xmlNumber(xml, 'TransactionPrice'),
totalTaxAmount: xmlNumber(taxes, 'TotalTaxAmount'),
email: xmlText(buyer, 'Email'),
firstName: xmlText(buyer, 'UserFirstName'),
lastName: xmlText(buyer, 'UserLastName'),
tracking: parseTrackingDetails(xml),
};
}
function firstValidEmail(transactions) {
for (const transaction of transactions) {
const email = (transaction.email || '').trim();
if (email && !INVALID_EMAILS.has(email.toLowerCase())) {
return email;
}
}
return '';
}
function dedupeTracking(details) {
const seen = new Set();
const unique = [];
for (const detail of details) {
if (seen.has(detail.trackingNumber)) continue;
seen.add(detail.trackingNumber);
unique.push(detail);
}
return unique;
}
export function parseTradingOrder(orderXml) {
const header = headerXml(orderXml);
const shippingSelected = xmlBlocks(header, 'ShippingServiceSelected')[0] || '';
const salesTaxBlock = xmlBlocks(header, 'SalesTax')[0] || '';
const checkout = xmlBlocks(header, 'CheckoutStatus')[0] || '';
const transactions = xmlBlocks(orderXml, 'Transaction').map(parseTradingTransaction);
const taxFromTransactions = transactions.reduce((sum, line) => sum + line.totalTaxAmount, 0);
return {
orderId: xmlText(header, 'OrderID') || xmlText(header, 'ExtendedOrderID'),
extendedOrderId: xmlText(header, 'ExtendedOrderID'),
orderStatus: xmlText(header, 'OrderStatus'),
cancelStatus: xmlText(header, 'CancelStatus'),
checkoutStatus: xmlText(checkout, 'Status'),
paidTime: xmlText(header, 'PaidTime'),
shippedTime: xmlText(header, 'ShippedTime'),
createdTime: xmlText(header, 'CreatedTime'),
buyerUserId: xmlText(header, 'BuyerUserID'),
buyerEmail: firstValidEmail(transactions),
shippingAddress: parseShippingAddress(header),
subtotal: xmlNumber(header, 'Subtotal'),
total: xmlNumber(header, 'Total'),
amountPaid: xmlNumber(header, 'AmountPaid'),
shippingAmount: xmlNumber(shippingSelected, 'ShippingServiceCost'),
salesTaxAmount: xmlNumber(salesTaxBlock, 'SalesTaxAmount') || taxFromTransactions,
transactions,
shipments: dedupeTracking([
...parseTrackingDetails(header),
...transactions.flatMap((line) => line.tracking),
]),
};
}
export function parseGetOrdersResponse(xml = '') {
return {
orders: xmlBlocks(xml, 'Order').map(parseTradingOrder),
hasMoreOrders: xmlText(xml, 'HasMoreOrders').toLowerCase() === 'true',
};
}
function buildGetOrdersRequest({
startIso,
endIso,
numberOfDays,
pageNumber = 1,
orderIds = [],
} = {}) {
const filters = [];
if (orderIds.length) {
filters.push(
` <OrderIDArray>\n${orderIds
.map((id) => ` <OrderID>${xmlEscape(id)}</OrderID>`)
.join('\n')}\n </OrderIDArray>`
);
} else if (numberOfDays) {
filters.push(` <NumberOfDays>${numberOfDays}</NumberOfDays>`);
} else {
if (startIso) filters.push(` <CreateTimeFrom>${startIso}</CreateTimeFrom>`);
if (endIso) filters.push(` <CreateTimeTo>${endIso}</CreateTimeTo>`);
}
return `<?xml version="1.0" encoding="utf-8"?>
<GetOrdersRequest xmlns="urn:ebay:apis:eBLBaseComponents">
<ErrorLanguage>en_GB</ErrorLanguage>
<WarningLevel>High</WarningLevel>
<DetailLevel>ReturnAll</DetailLevel>
<OrderRole>Seller</OrderRole>
<OrderStatus>All</OrderStatus>
${filters.join('\n')}
<Pagination>
<EntriesPerPage>${ENTRIES_PER_PAGE}</EntriesPerPage>
<PageNumber>${pageNumber}</PageNumber>
</Pagination>
</GetOrdersRequest>`;
}
function createTimeWindows(startTime, endTime) {
if (!startTime && !endTime) {
return [{ numberOfDays: DEFAULT_LOOKBACK_DAYS }];
}
const nowSec = Math.floor(Date.now() / 1000);
const startSec = startTime || endTime - DEFAULT_LOOKBACK_DAYS * 86400;
const endSec = endTime || nowSec;
const windows = [];
let cursor = Math.min(startSec, endSec) * 1000;
const endMs = Math.max(startSec, endSec) * 1000;
while (cursor < endMs) {
const windowEnd = Math.min(cursor + MAX_RANGE_MS, endMs);
windows.push({
startIso: new Date(cursor).toISOString(),
endIso: new Date(windowEnd).toISOString(),
});
cursor = windowEnd;
}
return windows.length ? windows : [{ numberOfDays: DEFAULT_LOOKBACK_DAYS }];
}
async function fetchOrdersPage(marketplace, requestOptions) {
const xml = await makeTradingRequest({
marketplace,
callName: 'GetOrders',
body: buildGetOrdersRequest(requestOptions),
});
return parseGetOrdersResponse(xml);
}
async function fetchOrdersInWindow(marketplace, windowOptions) {
const orders = [];
let pageNumber = 1;
let hasMore = true;
while (hasMore) {
const page = await fetchOrdersPage(marketplace, { ...windowOptions, pageNumber });
if (page.orders.length) {
orders.push(...page.orders);
}
hasMore = page.hasMoreOrders && page.orders.length > 0;
pageNumber += 1;
}
return orders;
}
async function fetchAllOrders(marketplace, { startTime, endTime } = {}) {
const orders = [];
let offset = 0;
const limit = 50;
const filterParts = [];
if (startTime) {
const isoStart = new Date(startTime * 1000).toISOString();
filterParts.push(`creationdate:[${isoStart}..`);
for (const window of createTimeWindows(startTime, endTime)) {
orders.push(...(await fetchOrdersInWindow(marketplace, window)));
}
if (endTime) {
const isoEnd = new Date(endTime * 1000).toISOString();
if (filterParts.length && filterParts[0].startsWith('creationdate:')) {
filterParts[0] = filterParts[0] + `${isoEnd}]`;
} else {
filterParts.push(`creationdate:[..${isoEnd}]`);
}
} else if (filterParts.length) {
filterParts[0] = filterParts[0] + ']';
}
do {
const params = { limit, offset };
if (filterParts.length) {
params.filter = filterParts.join(',');
}
const data = await makeRequest({
marketplace,
path: '/sell/fulfillment/v1/order',
params,
});
if (data?.orders?.length) {
orders.push(...data.orders);
}
if (!data?.orders?.length || orders.length >= (data.total || 0)) {
break;
}
offset += limit;
} while (true);
return orders;
}
@ -56,38 +224,32 @@ export async function syncOrders(marketplace, { startTime, endTime } = {}) {
return orders;
}
const ORDER_STATUS_MAP = {
NOT_STARTED: 'draft',
IN_PROGRESS: 'confirmed',
FULFILLED: 'shipped',
CANCELLED: 'cancelled',
};
const FULFILLMENT_STATUS_MAP = {
NOT_STARTED: 'confirmed',
IN_PROGRESS: 'shipped',
FULFILLED: 'delivered',
};
export function mapOrderStatus(ebayOrder) {
if (ebayOrder.cancelStatus?.cancelState === 'CANCELED') {
const cancelStatus = ebayOrder.cancelStatus || '';
if (CANCELLED_STATUSES.has(ebayOrder.orderStatus) || CANCELLED_STATUSES.has(cancelStatus)) {
return 'cancelled';
}
const fulfillmentStatus = ebayOrder.fulfillmentStartInstructions?.[0]?.fulfillmentStatus;
if (fulfillmentStatus && FULFILLMENT_STATUS_MAP[fulfillmentStatus]) {
return FULFILLMENT_STATUS_MAP[fulfillmentStatus];
if (ebayOrder.shippedTime) {
return 'shipped';
}
return ORDER_STATUS_MAP[ebayOrder.orderFulfillmentStatus] || 'draft';
if (
ebayOrder.orderStatus === 'Completed' ||
ebayOrder.checkoutStatus === 'Complete' ||
ebayOrder.paidTime
) {
return 'confirmed';
}
return 'draft';
}
export function mapOrderToSalesOrder(ebayOrder) {
const pricingSummary = ebayOrder.pricingSummary || {};
const totalAmount = parseFloat(pricingSummary.priceSubtotal?.value || 0);
const shippingAmount = parseFloat(pricingSummary.deliveryCost?.value || 0);
const totalTax = parseFloat(pricingSummary.tax?.value || 0);
const grandTotal = parseFloat(pricingSummary.total?.value || 0);
const totalAmount = ebayOrder.subtotal || 0;
const shippingAmount = ebayOrder.shippingAmount || 0;
const totalTax = ebayOrder.salesTaxAmount || 0;
const grandTotal = ebayOrder.total || 0;
return {
externalReference: ebayOrder.orderId,
@ -103,54 +265,47 @@ export function mapOrderToSalesOrder(ebayOrder) {
}
export function mapBuyerToClient(ebayOrder) {
const buyer = ebayOrder.buyer || {};
const address = ebayOrder.fulfillmentStartInstructions?.[0]?.shippingStep?.shipTo || {};
const fullName = address.fullName || buyer.username || 'Unknown';
const contactAddress = address.contactAddress || {};
const address = ebayOrder.shippingAddress || {};
const buyer = ebayOrder.transactions?.[0] || {};
const buyerName = [buyer.firstName, buyer.lastName].filter(Boolean).join(' ');
const fullName = address.name || buyerName || ebayOrder.buyerUserId || 'Unknown';
return {
name: fullName,
email: buyer.buyerRegistrationAddress?.email || '',
phone: address.primaryPhone?.phoneNumber || '',
externalReference: buyer.username || '',
email: ebayOrder.buyerEmail || '',
phone: address.phone || '',
externalReference: ebayOrder.buyerUserId || '',
address: {
addressLine1: contactAddress.addressLine1 || '',
addressLine2: contactAddress.addressLine2 || '',
city: contactAddress.city || '',
state: contactAddress.stateOrProvince || '',
postcode: contactAddress.postalCode || '',
country: contactAddress.countryCode || '',
addressLine1: address.street1 || '',
addressLine2: address.street2 || '',
city: address.city || '',
state: address.state || '',
postcode: address.postalCode || '',
country: address.country || '',
},
};
}
export async function getOrder(marketplace, orderId) {
const order = await makeRequest({
const xml = await makeTradingRequest({
marketplace,
path: `/sell/fulfillment/v1/order/${encodeURIComponent(orderId)}`,
callName: 'GetOrders',
body: buildGetOrdersRequest({ orderIds: [orderId] }),
});
const fulfillments = await makeRequest({
marketplace,
path: `/sell/fulfillment/v1/order/${encodeURIComponent(orderId)}/shipping_fulfillment`,
acceptableStatuses: [404],
});
return {
...order,
shippingFulfillments: fulfillments?.fulfillments || [],
};
const { orders } = parseGetOrdersResponse(xml);
if (!orders.length) {
throw new Error(`eBay order ${orderId} was not found`);
}
return orders[0];
}
export function mapOrderLineItems(ebayOrder) {
return (ebayOrder.lineItems || []).map((line) => {
const unit = parseFloat(line.lineItemCost?.value || 0);
const tax = (line.taxes || []).reduce(
(sum, taxLine) => sum + parseFloat(taxLine.amount?.value || 0),
0
);
return (ebayOrder.transactions || []).map((line) => {
const unit = line.transactionPrice || 0;
const tax = line.totalTaxAmount || 0;
const quantity = line.quantity || 1;
return {
externalReference: line.lineItemId,
externalReference: line.orderLineItemId || line.transactionId,
sku: line.sku || '',
name: line.title || line.sku || 'Marketplace item',
quantity,
@ -163,24 +318,26 @@ export function mapOrderLineItems(ebayOrder) {
}
export function mapOrderShipments(ebayOrder) {
const shippingAmount = parseFloat(ebayOrder.pricingSummary?.deliveryCost?.value || 0);
const fulfillments = ebayOrder.shippingFulfillments || [];
if (!fulfillments.length) {
const shipments = ebayOrder.shipments || [];
if (!shipments.length) {
return [];
}
const splitAmount = shippingAmount / fulfillments.length;
return fulfillments.map((fulfillment) => ({
externalReference: fulfillment.fulfillmentId,
trackingNumber: fulfillment.shipmentTrackingNumber || '',
const shippingAmount = ebayOrder.shippingAmount || 0;
const splitAmount = shippingAmount / shipments.length;
return shipments.map((shipment) => ({
externalReference: shipment.trackingNumber,
trackingNumber: shipment.trackingNumber || '',
amount: splitAmount,
amountWithTax: splitAmount,
state: { type: fulfillment.shippedDate ? 'shipped' : 'planned' },
shippedAt: fulfillment.shippedDate ? new Date(fulfillment.shippedDate) : undefined,
lineItemIds: (fulfillment.lineItems || []).map((line) => line.lineItemId).filter(Boolean),
state: { type: ebayOrder.shippedTime ? 'shipped' : 'planned' },
shippedAt: ebayOrder.shippedTime ? new Date(ebayOrder.shippedTime) : undefined,
}));
}
export async function createShippingFulfillment(marketplace, { orderId, lineItems, trackingNumber, shippingCarrierCode }) {
export async function createShippingFulfillment(
marketplace,
{ orderId, lineItems, trackingNumber, shippingCarrierCode }
) {
if (!orderId) {
throw new Error('orderId is required to create an eBay shipping fulfillment');
}
@ -204,14 +361,9 @@ export async function createShippingFulfillment(marketplace, { orderId, lineItem
}
export async function handleWebhook(marketplace, event) {
const topic =
event?.metadata?.topic ||
event?.topic ||
event?.notification?.topic ||
'';
const topic = event?.metadata?.topic || event?.topic || event?.notification?.topic || '';
const data = event?.notification?.data || event?.data || {};
const notificationId =
event?.notification?.notificationId || event?.notificationId || '';
const notificationId = event?.notification?.notificationId || event?.notificationId || '';
logger.info(`eBay webhook received: ${topic} for marketplace ${marketplace.name}`);
@ -220,7 +372,10 @@ export async function handleWebhook(marketplace, event) {
const normalized = String(topic).toUpperCase();
if (normalized.includes('ACCOUNT_DELETION') || normalized.includes('MARKETPLACE_ACCOUNT_DELETION')) {
if (
normalized.includes('ACCOUNT_DELETION') ||
normalized.includes('MARKETPLACE_ACCOUNT_DELETION')
) {
return {
action: 'accountDeletion',
userId: data.userId || data.username,
@ -233,7 +388,11 @@ export async function handleWebhook(marketplace, event) {
return { action: 'disconnect', notificationId, topic };
}
if (normalized.includes('ORDER') || normalized === 'ITEM.SOLD' || normalized === 'ORDER.FULFILLMENT') {
if (
normalized.includes('ORDER') ||
normalized === 'ITEM.SOLD' ||
normalized === 'ORDER.FULFILLMENT'
) {
return {
action: orderId && topic.toLowerCase().includes('cancel') ? 'orderCancel' : 'orderUpdate',
orderId,

View File

@ -1,43 +1,8 @@
import { logger, getMarketplaceDebugContext } from './shared.js';
import { logger } from './shared.js';
import { makeTradingRequest } from './trading.js';
import { fetchEbayCategoryReferences } from './categoryTree.js';
import { syncAccountPolicies } from './accountPolicySync.js';
const TRADING_COMPATIBILITY_LEVEL = '1399';
const TRADING_SANDBOX_URL = 'https://api.sandbox.ebay.com/ws/api.dll';
const TRADING_PRODUCTION_URL = 'https://api.ebay.com/ws/api.dll';
const MARKETPLACE_SITE_IDS = {
EBAY_US: '0',
EBAY_ENCA: '2',
EBAY_GB: '3',
EBAY_AU: '15',
EBAY_AT: '16',
EBAY_FRBE: '23',
EBAY_FR: '71',
EBAY_DE: '77',
EBAY_IT: '101',
EBAY_NLBE: '123',
EBAY_NL: '146',
EBAY_ES: '186',
EBAY_CH: '193',
EBAY_HK: '201',
EBAY_IN: '203',
EBAY_IE: '205',
EBAY_MY: '207',
EBAY_PH: '211',
EBAY_PL: '212',
EBAY_SG: '216',
};
function getTradingApiUrl(marketplace) {
return marketplace.config?.sandbox ? TRADING_SANDBOX_URL : TRADING_PRODUCTION_URL;
}
function getSiteId(marketplace) {
const marketplaceId = marketplace.config?.marketplaceId || 'EBAY_GB';
return MARKETPLACE_SITE_IDS[marketplaceId] || MARKETPLACE_SITE_IDS.EBAY_GB;
}
export function parseShippingServiceDetails(xml = '') {
const blocks = xml.match(/<ShippingServiceDetails>[\s\S]*?<\/ShippingServiceDetails>/g) || [];
const services = [];
@ -68,67 +33,13 @@ function buildGeteBayDetailsRequest() {
}
export async function fetchEbayShippingServices(marketplace) {
const accessToken = marketplace.config?.accessToken;
if (!accessToken) {
throw new Error(
'eBay marketplace is not authenticated. Complete marketplace authorization first.'
);
}
const url = getTradingApiUrl(marketplace);
const siteId = getSiteId(marketplace);
const startedAt = Date.now();
logger.debug('eBay Trading API GeteBayDetails request', {
...getMarketplaceDebugContext(marketplace),
siteId,
url,
});
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'text/xml',
'X-EBAY-API-CALL-NAME': 'GeteBayDetails',
'X-EBAY-API-SITEID': siteId,
'X-EBAY-API-COMPATIBILITY-LEVEL': TRADING_COMPATIBILITY_LEVEL,
'X-EBAY-API-IAF-TOKEN': accessToken,
},
const xml = await makeTradingRequest({
marketplace,
callName: 'GeteBayDetails',
body: buildGeteBayDetailsRequest(),
});
const xml = await response.text();
const durationMs = Date.now() - startedAt;
if (!response.ok) {
logger.error(`GeteBayDetails failed (${response.status})`, {
durationMs,
...getMarketplaceDebugContext(marketplace),
siteId,
});
throw new Error(`eBay GeteBayDetails error (${response.status})`);
}
const ackMatch = xml.match(/<Ack>([^<]+)<\/Ack>/);
const ack = ackMatch?.[1];
if (ack && ack !== 'Success' && ack !== 'Warning') {
const message =
xml.match(/<LongMessage>([^<]+)<\/LongMessage>/)?.[1] ||
xml.match(/<ShortMessage>([^<]+)<\/ShortMessage>/)?.[1] ||
ack;
logger.error(`GeteBayDetails failed: ${message}`, {
ack,
durationMs,
...getMarketplaceDebugContext(marketplace),
siteId,
});
throw new Error(`eBay GeteBayDetails failed: ${message}`);
}
const services = parseShippingServiceDetails(xml);
logger.debug(`GeteBayDetails succeeded (${durationMs}ms)`, {
...getMarketplaceDebugContext(marketplace),
siteId,
serviceCount: services.length,
});
logger.info(
`Fetched ${services.length} eBay shipping service(s) for marketplace "${marketplace.name}"`
);

View File

@ -0,0 +1,142 @@
import { logger, getMarketplaceDebugContext, formatDebugPayload } from './shared.js';
const TRADING_COMPATIBILITY_LEVEL = '1399';
const TRADING_SANDBOX_URL = 'https://api.sandbox.ebay.com/ws/api.dll';
const TRADING_PRODUCTION_URL = 'https://api.ebay.com/ws/api.dll';
const MARKETPLACE_SITE_IDS = {
EBAY_US: '0',
EBAY_ENCA: '2',
EBAY_GB: '3',
EBAY_AU: '15',
EBAY_AT: '16',
EBAY_FRBE: '23',
EBAY_FR: '71',
EBAY_DE: '77',
EBAY_IT: '101',
EBAY_NLBE: '123',
EBAY_NL: '146',
EBAY_ES: '186',
EBAY_CH: '193',
EBAY_HK: '201',
EBAY_IN: '203',
EBAY_IE: '205',
EBAY_MY: '207',
EBAY_PH: '211',
EBAY_PL: '212',
EBAY_SG: '216',
};
export function getTradingApiUrl(marketplace) {
return marketplace.config?.sandbox ? TRADING_SANDBOX_URL : TRADING_PRODUCTION_URL;
}
export function getTradingSiteId(marketplace) {
const marketplaceId = marketplace.config?.marketplaceId || 'EBAY_GB';
return MARKETPLACE_SITE_IDS[marketplaceId] || MARKETPLACE_SITE_IDS.EBAY_GB;
}
export function xmlEscape(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
export function xmlUnescape(value) {
return String(value ?? '')
.replace(/&apos;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&gt;/g, '>')
.replace(/&lt;/g, '<')
.replace(/&amp;/g, '&');
}
export function xmlBlocks(xml, tag) {
const re = new RegExp(`<${tag}(?:\\s[^>]*)?>[\\s\\S]*?</${tag}>`, 'gi');
return String(xml || '').match(re) || [];
}
export function xmlText(xml, tag) {
const re = new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)</${tag}>`, 'i');
const match = String(xml || '').match(re);
if (!match) return '';
return xmlUnescape(match[1].replace(/<[^>]+>/g, '').trim());
}
export function xmlNumber(xml, tag) {
const parsed = parseFloat(xmlText(xml, tag));
return Number.isFinite(parsed) ? parsed : 0;
}
export async function makeTradingRequest({
marketplace,
callName,
body,
logResponse = false,
} = {}) {
const accessToken = marketplace.config?.accessToken;
if (!accessToken) {
throw new Error(
'eBay marketplace is not authenticated. Complete marketplace authorization first.'
);
}
const url = getTradingApiUrl(marketplace);
const siteId = getTradingSiteId(marketplace);
const startedAt = Date.now();
logger.debug(`eBay Trading API ${callName} request`, {
...getMarketplaceDebugContext(marketplace),
siteId,
url,
});
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'text/xml',
'X-EBAY-API-CALL-NAME': callName,
'X-EBAY-API-SITEID': siteId,
'X-EBAY-API-COMPATIBILITY-LEVEL': TRADING_COMPATIBILITY_LEVEL,
'X-EBAY-API-IAF-TOKEN': accessToken,
},
body,
});
const xml = await response.text();
const durationMs = Date.now() - startedAt;
if (!response.ok) {
logger.error(`${callName} failed (${response.status})`, {
durationMs,
...getMarketplaceDebugContext(marketplace),
siteId,
});
throw new Error(`eBay ${callName} error (${response.status})`);
}
const ack = xmlText(xml, 'Ack');
if (ack && ack !== 'Success' && ack !== 'Warning') {
const message = xmlText(xml, 'LongMessage') || xmlText(xml, 'ShortMessage') || ack;
logger.error(`${callName} failed: ${message}`, {
ack,
durationMs,
...getMarketplaceDebugContext(marketplace),
siteId,
response: formatDebugPayload(xml),
});
throw new Error(`eBay ${callName} failed: ${message}`);
}
logger.debug(`eBay Trading API ${callName} -> ${ack || response.status} (${durationMs}ms)`, {
...getMarketplaceDebugContext(marketplace),
siteId,
response: logResponse ? formatDebugPayload(xml) : undefined,
});
console.log('xml', xml);
return xml;
}