diff --git a/src/integrations/marketplaces/ebay/__tests__/orders.sync.test.js b/src/integrations/marketplaces/ebay/__tests__/orders.sync.test.js new file mode 100644 index 0000000..9ffcbef --- /dev/null +++ b/src/integrations/marketplaces/ebay/__tests__/orders.sync.test.js @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it, jest } from '@jest/globals'; +import { getOrder, syncOrders } from '../orders.js'; + +const successXml = ` + + Success + false + + + 12-34567-89012 + Completed + 10.00 + 12.50 + ebay-buyer + + +`; + +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('30'); + expect(body).toContain('Seller'); + 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('2025-08-30T00:00:00.000Z'); + expect(body).toContain('2025-08-31T00:00:00.000Z'); + expect(body).not.toContain(''); + }); + + 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('12-34567-89012'); + expect(order.orderId).toBe('12-34567-89012'); + }); +}); diff --git a/src/integrations/marketplaces/ebay/__tests__/orders.test.js b/src/integrations/marketplaces/ebay/__tests__/orders.test.js index 77b7355..28e670f 100644 --- a/src/integrations/marketplaces/ebay/__tests__/orders.test.js +++ b/src/integrations/marketplaces/ebay/__tests__/orders.test.js @@ -6,56 +6,106 @@ import { mapOrderLineItems, mapOrderShipments, handleWebhook, + parseGetOrdersResponse, } from '../orders.js'; -const ebayOrder = { - 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', +const sampleGetOrdersXml = ` + + Success + false + + + 12-34567-89012 + 12-34567-89012 + Completed + 13.70 + + Complete + + + + 1.20 + + + Royal Mail + TRACK123 + + + 2026-08-01T09:00:00.000Z + + Jane Buyer + 1 High St + London + SW1A 1AA + GB + 012345 + + + UK_RoyalMailFirstClassStandard + 2.50 + + 10.00 + 13.70 + + + + buyer@example.com + Jane + Buyer + + + 111 + Red widget + SKU-RED + + 2 + line-1 + 5.00 + + 1.00 + + line-1 + + + ebay-buyer + 2026-08-01T09:05:00.000Z + 2026-08-01T10:00:00.000Z + NotApplicable + + + +`; + +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', + 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('Success')).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' }); diff --git a/src/integrations/marketplaces/ebay/orders.js b/src/integrations/marketplaces/ebay/orders.js index 2569e97..abf3602 100644 --- a/src/integrations/marketplaces/ebay/orders.js +++ b/src/integrations/marketplaces/ebay/orders.js @@ -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(/[\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( + ` \n${orderIds + .map((id) => ` ${xmlEscape(id)}`) + .join('\n')}\n ` + ); + } else if (numberOfDays) { + filters.push(` ${numberOfDays}`); + } else { + if (startIso) filters.push(` ${startIso}`); + if (endIso) filters.push(` ${endIso}`); + } + + return ` + + en_GB + High + ReturnAll + Seller + All +${filters.join('\n')} + + ${ENTRIES_PER_PAGE} + ${pageNumber} + +`; +} + +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, diff --git a/src/integrations/marketplaces/ebay/shippingServices.js b/src/integrations/marketplaces/ebay/shippingServices.js index a5de3ca..823ba21 100644 --- a/src/integrations/marketplaces/ebay/shippingServices.js +++ b/src/integrations/marketplaces/ebay/shippingServices.js @@ -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(/[\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>/); - const ack = ackMatch?.[1]; - if (ack && ack !== 'Success' && ack !== 'Warning') { - const message = - xml.match(/([^<]+)<\/LongMessage>/)?.[1] || - xml.match(/([^<]+)<\/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}"` ); diff --git a/src/integrations/marketplaces/ebay/trading.js b/src/integrations/marketplaces/ebay/trading.js new file mode 100644 index 0000000..afe4191 --- /dev/null +++ b/src/integrations/marketplaces/ebay/trading.js @@ -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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function xmlUnescape(value) { + return String(value ?? '') + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(/>/g, '>') + .replace(/</g, '<') + .replace(/&/g, '&'); +} + +export function xmlBlocks(xml, tag) { + const re = new RegExp(`<${tag}(?:\\s[^>]*)?>[\\s\\S]*?`, 'gi'); + return String(xml || '').match(re) || []; +} + +export function xmlText(xml, tag) { + const re = new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)`, '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; +}