/** * Shared Amazon Selling Partner API (SP-API) helper. * * Auth model: LWA refresh-token -> access-token exchange (no AWS SigV4 needed), * access token sent as the `x-amz-access-token` header. Credentials live per * order source on `c_ordersource` (marketplace_key / marketplace_secret / * marketplace_token). EU endpoint only. * * The LWA auth + marketplace resolution were originally inlined in * server/api/invoices/amazon-upload.post.ts; this util centralises them and adds * the Finances API helpers used by the accounting / settlement-report feature * (server/api/accounting/amazon/{settlements,settlement}.get.ts) and the Orders * API helpers used by the order-reconciliation ("Bestellabgleich") feature. */ export const LWA_TOKEN_URL = 'https://api.amazon.com/auth/o2/token' export const SP_API_BASE_URL = 'https://sellingpartnerapi-eu.amazon.com' // EU Marketplace IDs export const MARKETPLACE_IDS: Record = { 'DE': 'A1PA6795UKMFR9', 'FR': 'A13V1IB3VIYZZH', 'IT': 'APJ6JRA9NG5V4', 'ES': 'A1RKKUPIHCS9HS', 'NL': 'A1805IZSGTT6HS', 'BE': 'AMEN7PMS3EDWL', 'UK': 'A1F83G8C2ARO7P', 'GB': 'A1F83G8C2ARO7P', 'PL': 'A1C3SOZRARQ6R3', 'SE': 'A2NODRKZP88ZB9', 'AT': 'A2CVHYRTWLQO9T' } const KNOWN_MARKETPLACE_IDS = new Set(Object.values(MARKETPLACE_IDS)) // Resolve an Amazon marketplaceId from c_ordersource. // Tries Marketplace.identifier (raw marketplaceId, country code, or suffixed), // then falls back to parsing the Description URL (e.g. "https://sellercentral.amazon.de/..." -> DE). // Returns null when no match is found. export const resolveMarketplaceId = ( identifier: string | null | undefined, description: string | null | undefined = null ): string | null => { const raw = String(identifier ?? '').trim() if (raw) { if (KNOWN_MARKETPLACE_IDS.has(raw)) return raw const upper = raw.toUpperCase() if (MARKETPLACE_IDS[upper]) return MARKETPLACE_IDS[upper] const suffixMatch = upper.match(/[-._ ]([A-Z]{2})$/) if (suffixMatch && MARKETPLACE_IDS[suffixMatch[1]]) return MARKETPLACE_IDS[suffixMatch[1]] } const desc = String(description ?? '') if (desc) { const urlMatch = desc.match(/amazon\.([a-z]{2,3}(?:\.[a-z]{2})?)/i) if (urlMatch) { const tld = urlMatch[1].toLowerCase() const tldToCountry: Record = { 'de': 'DE', 'fr': 'FR', 'it': 'IT', 'es': 'ES', 'nl': 'NL', 'be': 'BE', 'pl': 'PL', 'se': 'SE', 'at': 'AT', 'co.uk': 'UK', 'uk': 'UK' } const country = tldToCountry[tld] if (country && MARKETPLACE_IDS[country]) return MARKETPLACE_IDS[country] } } return null } // Every raw call into Amazon (LWA token exchange, SP-API, report document // download) is tagged with `isAmazonError` on failure so route handlers can // tell an Amazon-side problem apart from an iDempiere auth failure — see // refreshTokenHelper.ts's `isRetryableAuthFailure`, which only ever looks at // `event.context.lastIdempiereError`. Without this tag, an Amazon timeout/429 // falls through to that helper's "no fetch error recorded -> plain refresh" // default and the ENTIRE route handler (report poll, or a full price push) // silently re-runs a second time, which is what made this page occasionally // hang for 90s+ instead of failing cleanly at ~45s. const tagAmazonError = (err: any): any => { err.isAmazonError = true return err } // No SP-API/LWA call in this file had a timeout before — a stalled request // hung indefinitely. 20s is short enough to fail fast and let the callers' // own retry/poll loops (pollReport's 45s cap, the price-push poll) take over. const SP_API_TIMEOUT_MS = 20000 const SP_API_MAX_RETRIES = 2 // Get LWA access token using a refresh token. export const getAmazonAccessToken = async ( clientId: string, clientSecret: string, refreshToken: string ): Promise => { let response: Response try { response = await fetch(LWA_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: clientId, client_secret: clientSecret }), signal: AbortSignal.timeout(SP_API_TIMEOUT_MS) }) } catch (e: any) { throw tagAmazonError(new Error(`Failed to reach Amazon LWA token endpoint: ${e?.name === 'TimeoutError' ? 'timed out' : (e?.message || String(e))}`)) } if (!response.ok) { const errorText = await response.text() throw tagAmazonError(new Error(`Failed to get Amazon access token (${response.status}): ${errorText}`)) } const data: any = await response.json() return data.access_token } // Shared low-level SP-API request: timeout, retry-after-aware backoff on // 429/503 (mirrors amazonAds/adsApi.ts's adsFetch, which already does this // for the Ads API), and consistent error tagging/shape for every caller // (spApiGet/spApiWrite below, so Reports/Listings/Fees/Finances/Orders all // get this for free). const spApiRequest = async (accessToken: string, method: 'GET' | 'POST' | 'PATCH', path: string, body?: any): Promise => { for (let attempt = 0; ; attempt++) { let response: Response try { response = await fetch(`${SP_API_BASE_URL}${path}`, { method, headers: { 'x-amz-access-token': accessToken, 'Content-Type': 'application/json' }, body: body !== undefined ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(SP_API_TIMEOUT_MS) }) } catch (e: any) { if (attempt < SP_API_MAX_RETRIES) { await sleep(1000 * (attempt + 1)); continue } throw tagAmazonError(new Error(`SP-API request failed: ${e?.name === 'TimeoutError' ? 'timed out' : (e?.message || String(e))}`)) } if ((response.status === 429 || response.status === 503) && attempt < SP_API_MAX_RETRIES) { const retryAfter = Number(response.headers.get('retry-after')) const delayMs = Math.min((Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : (attempt + 1) * 2) * 1000, 15000) await sleep(delayMs) continue } const text = await response.text() let json: any = {} try { json = text ? JSON.parse(text) : {} } catch { json = { raw: text } } if (!response.ok) { const msg = json?.errors?.[0]?.message || json?.errors?.[0]?.details || text || response.statusText const err: any = tagAmazonError(new Error(`SP-API ${response.status}: ${msg}`)) err.status = response.status err.spapi = json throw err } return json } } // Generic SP-API GET. Returns parsed JSON, throws an Error carrying the Amazon // error message + status on a non-2xx (so callers can surface it verbatim). export const spApiGet = async (accessToken: string, path: string): Promise => spApiRequest(accessToken, 'GET', path) // Robustly parse a settlement amount string to a Number (handles "-12.34", // "1,234.56" en and "1.234,56" de). Returns 0 for blank/invalid. Also used for // Amazon JSON numeric fields (harmless no-op on an already-numeric value). export const parseAmazonAmount = (v: any): number => { if (v == null) return 0 let s = String(v).trim() if (!s) return 0 const hasComma = s.includes(',') const hasDot = s.includes('.') if (hasComma && hasDot) { if (s.lastIndexOf(',') > s.lastIndexOf('.')) s = s.replace(/\./g, '').replace(',', '.') else s = s.replace(/,/g, '') } else if (hasComma) { s = s.replace(',', '.') } const n = parseFloat(s) return isNaN(n) ? 0 : n } // --------------------------------------------------------------------------- // Finances API (v0) — settlement periods + line-item detail. Replaces the old // Reports-API-based GET_V2_SETTLEMENT_REPORT_DATA_FLAT_FILE_V2 flow: that // endpoint hard-rejects a `createdSince` older than 90 days (verified against // production — "RequestedFromDate ... is more than 90 days old"), so settlement // history beyond ~90 days was permanently unreachable through it. The Finances // API's `financialEventGroups` has no such cap — it reaches back to the // account's actual history start. Verified 1:1 against the old flat file across // 7 real settlement periods (308 order/fee rows): every amount and date matches // to the cent; see reference/recipes.md for the reconciliation notes. // --------------------------------------------------------------------------- export interface FinancialEventGroupRef { groupId: string periodStart: string | null periodEnd: string | null totalAmount: number currency: string fundTransferStatus: string | null fundTransferDate: string | null } // List Closed settlement periods (financial event groups) in one window. Amazon // rejects FinancialEventGroupStartedBefore/After pairs more than 180 days apart, // so a longer history must be chunked — see listFinancialEventGroupsSince. export const listFinancialEventGroups = async ( accessToken: string, opts: { startedAfter?: string; startedBefore?: string; nextToken?: string; pageSize?: number } = {} ): Promise<{ groups: FinancialEventGroupRef[]; nextToken: string | null }> => { let path: string if (opts.nextToken) { const params = new URLSearchParams() params.set('MaxResultsPerPage', String(opts.pageSize ?? 100)) params.set('NextToken', opts.nextToken) path = `/finances/v0/financialEventGroups?${params.toString()}` } else { const params = new URLSearchParams() params.set('MaxResultsPerPage', String(opts.pageSize ?? 100)) if (opts.startedAfter) params.set('FinancialEventGroupStartedAfter', opts.startedAfter) if (opts.startedBefore) params.set('FinancialEventGroupStartedBefore', opts.startedBefore) path = `/finances/v0/financialEventGroups?${params.toString()}` } const json = await spApiGet(accessToken, path) const groups: FinancialEventGroupRef[] = (json.payload?.FinancialEventGroupList || []) .filter((g: any) => g.ProcessingStatus === 'Closed') .map((g: any) => ({ groupId: g.FinancialEventGroupId, periodStart: g.FinancialEventGroupStart ?? null, periodEnd: g.FinancialEventGroupEnd ?? null, totalAmount: Number(g.OriginalTotal?.CurrencyAmount ?? 0), currency: g.OriginalTotal?.CurrencyCode || 'EUR', fundTransferStatus: g.FundTransferStatus ?? null, fundTransferDate: g.FundTransferDate ?? null })) return { groups, nextToken: json.payload?.NextToken ?? null } } // Fetch every Closed settlement period in [sinceISO, untilISO] (untilISO // defaults to now), chunking into ≤170-day windows (Amazon rejects Before/After // pairs >180 days apart) and de-duping by groupId across chunk boundaries. // Capped at MAX_WINDOWS so a very old `since` can't cause unbounded latency — a // window with no data in it is a cheap, ordinary empty response, not an error // (verified against production). export const listFinancialEventGroupsSince = async ( accessToken: string, sinceISO: string, untilISO?: string ): Promise => { const WINDOW_MS = 170 * 86400000 const MAX_WINDOWS = 14 const now = Date.now() const until = untilISO ? Math.min(new Date(untilISO).getTime(), now) : now const since = new Date(sinceISO).getTime() const byId = new Map() let windowStart = since let windows = 0 while (windowStart < until && windows < MAX_WINDOWS) { const windowEnd = Math.min(windowStart + WINDOW_MS, until) // Amazon requires "Before" to be at least ~2 minutes in the past; omit it // entirely only when the window's end is genuinely "now" — an explicit // past `until` always gets passed through so the query is actually bounded. const isFinalWindow = windowEnd >= now - 5 * 60000 let nextToken: string | undefined do { const { groups, nextToken: next } = await listFinancialEventGroups(accessToken, { startedAfter: new Date(windowStart).toISOString(), startedBefore: isFinalWindow ? undefined : new Date(windowEnd).toISOString(), nextToken }) for (const g of groups) byId.set(g.groupId, g) nextToken = next ?? undefined } while (nextToken) windowStart = windowEnd windows++ } return [...byId.values()].sort((a, b) => (b.periodStart || '').localeCompare(a.periodStart || '')) } // Fetch every page of line-item financial events for one settlement period. // Returns the raw per-page `FinancialEvents` payloads for flattenFinancialEvents // to walk — kept as an array rather than deep-merged so a page-boundary bug // can't silently corrupt data by merging the wrong keys together. export const listFinancialEventsForGroup = async ( accessToken: string, groupId: string, opts: { maxPages?: number } = {} ): Promise => { const maxPages = opts.maxPages ?? 20 const pages: any[] = [] let nextToken: string | undefined let count = 0 do { const params = new URLSearchParams() params.set('MaxResultsPerPage', '100') if (nextToken) params.set('NextToken', nextToken) const json = await spApiGet( accessToken, `/finances/v0/financialEventGroups/${encodeURIComponent(groupId)}/financialEvents?${params.toString()}` ) pages.push(json.payload?.FinancialEvents || {}) nextToken = json.payload?.NextToken || undefined count++ } while (nextToken && count < maxPages) return pages } export type SettlementCategory = | 'umsaetze' | 'retouren' | 'amazonFees' | 'fbaFees' | 'werbekosten' | 'sonstiges' export interface SettlementDetailRow { category: SettlementCategory transactionType: string amountType: string amountDescription: string amount: number orderId: string merchantOrderId: string sku: string qty: number | null postedDate: string marketplace: string } const currencyAmount = (obj: any): number => { const v = obj?.CurrencyAmount return typeof v === 'number' ? v : Number(v ?? 0) || 0 } // Flattens the Finances API's ~30-shape event payload into the row shape the // settlement grid consumes (mirrors the old flat-file row shape so the rest of // the page — matching, KPI cards, AG Grid, Excel export — needed no changes). // // The 7 event types explicitly handled below cover 100% of real data seen // across a 20-month / 38-period sample of this account's history (445 // ShipmentEventList entries dominate; AdjustmentEventList 30, ProductAds 12, // ServiceFee 12, RefundEventList 8, DebtRecovery 2, AdhocDisbursement 1 — see // reference/recipes.md). Categorization here is STRUCTURAL — decided by which // list/field a row came from, not by re-parsing a description string — which // is more robust than the old flat-file categorizer's keyword matching: that // approach mis-bucketed a real "Shipping label purchase for return" cost as // Umsätze (revenue) purely because its description happened to contain // "shipping". The equivalent Finances API entry (AdjustmentType // "ReturnPostageBilling_*") is explicitly routed to Retouren below instead. // // Zero-amount entries are skipped, matching how Amazon's own flat file already // omits fee/charge types that don't apply to a given line item. export const flattenFinancialEvents = (pages: any[]): SettlementDetailRow[] => { const rows: SettlementDetailRow[] = [] const push = (r: SettlementDetailRow) => { if (!r.amount) return rows.push({ ...r, amount: Math.round(r.amount * 100) / 100 }) } const KNOWN_KEYS = new Set([ 'ShipmentEventList', 'RefundEventList', 'ServiceFeeEventList', 'ProductAdsPaymentEventList', 'AdjustmentEventList', 'DebtRecoveryEventList', 'AdhocDisbursementEventList', 'NextToken' ]) for (const events of pages) { for (const ev of events.ShipmentEventList || []) { const orderId = ev.AmazonOrderId || '' const date = ev.PostedDate || '' const marketplace = ev.MarketplaceName || '' for (const item of ev.ShipmentItemList || []) { const sku = item.SellerSKU || '' const qty = item.QuantityShipped ?? null for (const c of item.ItemChargeList || []) { push({ category: 'umsaetze', transactionType: 'Shipment', amountType: 'ItemPrice', amountDescription: c.ChargeType || '', amount: currencyAmount(c.ChargeAmount), orderId, merchantOrderId: '', sku, qty, postedDate: date, marketplace }) } for (const c of item.ItemFeeList || []) { const category: SettlementCategory = c.FeeType === 'ShippingHB' ? 'fbaFees' : 'amazonFees' push({ category, transactionType: 'Shipment', amountType: 'ItemFees', amountDescription: c.FeeType || '', amount: currencyAmount(c.FeeAmount), orderId, merchantOrderId: '', sku, qty, postedDate: date, marketplace }) } for (const c of item.PromotionList || []) { push({ category: 'umsaetze', transactionType: 'Shipment', amountType: 'Promotion', amountDescription: c.PromotionType || 'Promotion', amount: currencyAmount(c.PromotionAmount), orderId, merchantOrderId: '', sku, qty, postedDate: date, marketplace }) } } // Order/shipment-level (not per-item) charge/fee lists — not seen in this // account's sample data but part of Amazon's schema (e.g. multi-item // shipments can carry the shipping charge at this level instead). for (const c of ev.OrderChargeList || []) { push({ category: 'umsaetze', transactionType: 'Shipment', amountType: 'ItemPrice', amountDescription: c.ChargeType || '', amount: currencyAmount(c.ChargeAmount), orderId, merchantOrderId: '', sku: '', qty: null, postedDate: date, marketplace }) } for (const c of ev.OrderFeeList || []) { const category: SettlementCategory = c.FeeType === 'ShippingHB' ? 'fbaFees' : 'amazonFees' push({ category, transactionType: 'Shipment', amountType: 'ItemFees', amountDescription: c.FeeType || '', amount: currencyAmount(c.FeeAmount), orderId, merchantOrderId: '', sku: '', qty: null, postedDate: date, marketplace }) } } for (const ev of events.RefundEventList || []) { const orderId = ev.AmazonOrderId || '' const date = ev.PostedDate || '' const marketplace = ev.MarketplaceName || '' for (const item of ev.ShipmentItemAdjustmentList || []) { const sku = item.SellerSKU || '' const qty = item.QuantityShipped ?? null for (const c of item.ItemChargeAdjustmentList || []) { push({ category: 'retouren', transactionType: 'Refund', amountType: 'ItemPrice', amountDescription: c.ChargeType || '', amount: currencyAmount(c.ChargeAmount), orderId, merchantOrderId: '', sku, qty, postedDate: date, marketplace }) } // Fee adjustments on a refund are Amazon FEES, not returns: the (positive) // reversal of the original Commission/FBA fee plus Amazon's retained // "RefundCommission". Routing them to the fee buckets — the same split // PayJoe uses (AMA-BG-… / AMA-SG-…-RFNDCMMS rows next to the bare refund // row) — keeps the Retouren row of an order equal to the refunded price, // i.e. the amount of the credit memo lexoffice has to match it against. for (const c of item.ItemFeeAdjustmentList || []) { const category: SettlementCategory = c.FeeType === 'ShippingHB' ? 'fbaFees' : 'amazonFees' push({ category, transactionType: 'Refund', amountType: 'ItemFees', amountDescription: c.FeeType || '', amount: currencyAmount(c.FeeAmount), orderId, merchantOrderId: '', sku, qty, postedDate: date, marketplace }) } for (const c of item.PromotionAdjustmentList || []) { push({ category: 'retouren', transactionType: 'Refund', amountType: 'Promotion', amountDescription: c.PromotionType || 'Promotion', amount: currencyAmount(c.PromotionAmount), orderId, merchantOrderId: '', sku, qty, postedDate: date, marketplace }) } } } for (const ev of events.ServiceFeeEventList || []) { const date = ev.PostedDate || '' for (const c of ev.FeeList || []) { push({ category: 'amazonFees', transactionType: 'ServiceFee', amountType: 'ItemFees', amountDescription: c.FeeType || ev.FeeReason || 'ServiceFee', amount: currencyAmount(c.FeeAmount), orderId: ev.AmazonOrderId || '', merchantOrderId: '', sku: '', qty: null, postedDate: date, marketplace: '' }) } } // Note the lowercase-first field names here — unlike every other event type // above, ProductAdsPaymentEventList entries use `postedDate`/`transactionType`, // not `PostedDate`/`TransactionType` (verified against production). Only // `transactionValue` is used: it already equals baseValue + taxValue, so // summing multiple fields here would double-count ad spend (verified — two // real settlement periods only reconciled to the cent once this was fixed). for (const ev of events.ProductAdsPaymentEventList || []) { const date = ev.postedDate || ev.PostedDate || '' const value = ev.transactionValue ?? ev.TransactionValue push({ category: 'werbekosten', transactionType: 'ServiceFee', amountType: 'Advertising', amountDescription: ev.transactionType || ev.TransactionType || 'ProductAds', amount: currencyAmount(value), orderId: '', merchantOrderId: ev.invoiceId || ev.InvoiceId || '', sku: '', qty: null, postedDate: date, marketplace: '' }) } for (const ev of events.AdjustmentEventList || []) { const type = ev.AdjustmentType || 'Adjustment' // Return-shipping-label costs belong in Retouren — this is the one case // verified against real data where the OLD flat-file categorizer got it // wrong (see the file-level comment above). Everything else here is // genuinely mixed (reserve credit/debit movements, an unclassifiable // "Other") and stays in Sonstiges for manual review, same as the old // catch-all philosophy — never silently dropped. const category: SettlementCategory = type.startsWith('ReturnPostageBilling') ? 'retouren' : 'sonstiges' push({ category, transactionType: 'Adjustment', amountType: type, amountDescription: type, amount: currencyAmount(ev.AdjustmentAmount), orderId: '', merchantOrderId: '', sku: '', qty: null, postedDate: ev.PostedDate || '', marketplace: '' }) } for (const ev of events.DebtRecoveryEventList || []) { // Deliberately uses ONLY the top-level RecoveryAmount. DebtRecoveryItemList // and ChargeInstrumentList re-express the same amount from other angles // (per-item breakdown / how it was collected) — summing those too would // double-count, the same class of bug fixed for ProductAdsPaymentEventList // above. Rare (2 occurrences across 20 months) — kept in Sonstiges for review. push({ category: 'sonstiges', transactionType: 'DebtRecovery', amountType: ev.DebtRecoveryType || 'DebtRecovery', amountDescription: ev.DebtRecoveryType || 'DebtRecovery', amount: currencyAmount(ev.RecoveryAmount), orderId: '', merchantOrderId: '', sku: '', qty: null, postedDate: ev.PostedDate || '', marketplace: '' }) } for (const ev of events.AdhocDisbursementEventList || []) { push({ category: 'sonstiges', transactionType: 'AdhocDisbursement', amountType: ev.TransactionType || 'AdhocDisbursement', amountDescription: ev.TransactionType || 'AdhocDisbursement', amount: currencyAmount(ev.TransactionAmount), orderId: '', merchantOrderId: '', sku: '', qty: null, postedDate: ev.PostedDate || '', marketplace: '' }) } // Generic fallback for the ~27 other Amazon event types never observed in // this account's real history — a different order source (FBA-liquidation- // or chargeback-heavy, say) may hit them. Best-effort: take the FIRST // top-level {CurrencyCode,CurrencyAmount} field on each entry (never sums // multiple fields — see the double-counting notes above) and always land it // in Sonstiges for manual review rather than silently dropping it. for (const key of Object.keys(events)) { if (KNOWN_KEYS.has(key)) continue const list = events[key] if (!Array.isArray(list)) continue for (const ev of list) { const amountField = Object.entries(ev).find( ([, v]: [string, any]) => v && typeof v === 'object' && typeof (v as any).CurrencyAmount !== 'undefined' ) as [string, any] | undefined if (!amountField) continue push({ category: 'sonstiges', transactionType: key, amountType: amountField[0], amountDescription: `${key}: ${amountField[0]}`, amount: currencyAmount(amountField[1]), orderId: ev.AmazonOrderId || '', merchantOrderId: '', sku: '', qty: null, postedDate: ev.PostedDate || ev.postedDate || '', marketplace: ev.MarketplaceName || '' }) } } } return rows } // --------------------------------------------------------------------------- // Orders API (v0) — used by the Amazon order-reconciliation ("Bestellabgleich") // page to list ALL orders in a date window and compare them to iDempiere. // --------------------------------------------------------------------------- const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) export interface AmazonOrderRef { amazonOrderId: string purchaseDate: string | null lastUpdateDate: string | null orderStatus: string orderTotalAmount: number | null currency: string | null salesChannel: string | null orderType: string | null fulfillmentChannel: string | null numberOfItemsShipped: number | null numberOfItemsUnshipped: number | null marketplaceId: string | null } // Fetch EVERY Amazon order created in [createdAfter, createdBefore) for a // marketplace, following NextToken pagination. Intentionally passes NO // OrderStatuses filter so all statuses (Pending, Shipped, Canceled, …) are // returned — the page filters by date only. Robust to 429 throttling with // exponential backoff; caps the page count so a runaway never loops forever and // surfaces `truncated` when the cap is hit. export const listAllOrders = async ( accessToken: string, marketplaceId: string, opts: { createdAfter: string; createdBefore?: string; maxPages?: number; pageDelayMs?: number; budgetMs?: number } ): Promise<{ orders: AmazonOrderRef[]; truncated: boolean; throttled: boolean; pages: number }> => { const maxPages = opts.maxPages ?? 200 const pageDelayMs = opts.pageDelayMs ?? 600 // getOrders is rate-limited to ~1 req/60s (burst 20). A high-volume year can // never page fully inside the HTTP proxy timeout, so cap wall-clock and return // a partial result + truncated flag (the page warns and suggests month mode). const budgetMs = opts.budgetMs ?? 45000 const startedAt = Date.now() const orders: AmazonOrderRef[] = [] let nextToken: string | null = null let pages = 0 let truncated = false let throttled = false // Returns the page JSON, or null when pagination must stop early but we already // hold a usable partial result (terminal throttle, or a transient error after // at least one successful page). Throws only when nothing was collected yet, so // a real error (e.g. missing Orders role) is still surfaced verbatim. const fetchPage = async (): Promise => { const MAX_RETRY = 6 let attempt = 0 while (true) { const params = new URLSearchParams() params.set('MarketplaceIds', marketplaceId) if (nextToken) { params.set('NextToken', nextToken) } else { params.set('CreatedAfter', opts.createdAfter) if (opts.createdBefore) params.set('CreatedBefore', opts.createdBefore) params.set('MaxResultsPerPage', '100') } try { return await spApiGet(accessToken, `/orders/v0/orders?${params.toString()}`) } catch (e: any) { // 429 = throttled; back off and retry within the retry + wall-clock budget. if (e?.status === 429 && attempt < MAX_RETRY) { throttled = true const wait = Math.min(2000 * Math.pow(2, attempt), 30000) // If the backoff sleep would blow the wall-clock budget and we already // hold data, stop now and return the partial — don't risk a proxy timeout. if (orders.length > 0 && (Date.now() - startedAt) + wait > budgetMs) return null await sleep(wait) attempt++ continue } // Terminal: 429 retries exhausted, or a non-429 error. Don't discard the // orders already collected — stop gracefully with a partial result. if (orders.length > 0) { if (e?.status === 429) throttled = true return null } throw e } } } do { const json = await fetchPage() if (json === null) { truncated = true; break } // partial result — stop early const payload = json?.payload || json || {} for (const o of (payload.Orders || [])) { orders.push({ amazonOrderId: o.AmazonOrderId || '', purchaseDate: o.PurchaseDate || null, lastUpdateDate: o.LastUpdateDate || null, orderStatus: o.OrderStatus || '', orderTotalAmount: o.OrderTotal?.Amount != null ? parseAmazonAmount(o.OrderTotal.Amount) : null, currency: o.OrderTotal?.CurrencyCode || null, salesChannel: o.SalesChannel || null, orderType: o.OrderType || null, fulfillmentChannel: o.FulfillmentChannel || null, numberOfItemsShipped: o.NumberOfItemsShipped ?? null, numberOfItemsUnshipped: o.NumberOfItemsUnshipped ?? null, marketplaceId: o.MarketplaceId || null }) } nextToken = payload.NextToken || null pages++ if (nextToken && pages >= maxPages) { truncated = true; break } if (nextToken) await sleep(pageDelayMs) // stay gentle on the getOrders rate limit } while (nextToken) return { orders, truncated, throttled, pages } } // --------------------------------------------------------------------------- // Shared order-source -> Amazon connection resolution. Used by every SIS // route (listings, offers, price-update) to avoid repeating the credential + // marketplace-id lookup boilerplate three times. // --------------------------------------------------------------------------- export interface AmazonOrderSourceConnection { orgId: number sellerId: string marketplaceId: string accessToken: string } // Resolves one c_ordersource's Amazon credentials into a ready-to-use access // token + marketplaceId + sellerId. Throws a plain Error with a clear message // (never a raw SP-API error) when the order source is missing required setup — // callers surface `.message` directly via the route's normal error response. export async function resolveOrderSourceAmazonConnection( event: any, token: string, orderSourceId: number ): Promise { const orderSource: any = await fetchHelperForAmazon(event, `models/c_ordersource/${orderSourceId}`, token) if (!orderSource) { const err: any = new Error('Order source not found') err.status = 404 throw err } const orgId = orderSource?.AD_Org_ID?.id ?? null const clientId = orderSource.marketplace_key const clientSecret = orderSource.marketplace_secret const refreshToken = orderSource.marketplace_token const sellerId = orderSource.amazon_merchant_id || orderSource.Amazon_Merchant_ID || '' const configError = (msg: string) => { const err: any = new Error(msg) err.status = 400 return err } if (!clientId || !clientSecret || !refreshToken) { throw configError('Missing Amazon SP-API credentials on this order source (Client ID / Secret / Refresh Token).') } if (!sellerId) throw configError('Missing Amazon Merchant ID on this order source.') if (!orgId) throw configError('Order source has no organization set.') const marketplaceId = resolveMarketplaceId(orderSource?.Marketplace?.identifier, orderSource?.Description) if (!marketplaceId) throw configError('Could not resolve the Amazon marketplace for this order source.') const accessToken = await getAmazonAccessToken(clientId, clientSecret, refreshToken) return { orgId, sellerId, marketplaceId, accessToken } } // Thin wrapper so this file doesn't need to import fetchHelper directly (it // has no other iDempiere dependency) — kept local/unexported. async function fetchHelperForAmazon(event: any, path: string, token: string): Promise { return event.context.fetch(path, 'GET', token, null) } // --------------------------------------------------------------------------- // Generic SP-API write helpers (POST/PATCH) — same error normalization as // spApiGet. Added for the SIS (Sales Information System) pricing-control page: // Reports API report creation, Listings Items PATCH, Fees Estimate POST. // --------------------------------------------------------------------------- const spApiWrite = async (accessToken: string, method: 'POST' | 'PATCH', path: string, body?: any): Promise => spApiRequest(accessToken, method, path, body) export const spApiPost = (accessToken: string, path: string, body?: any): Promise => spApiWrite(accessToken, 'POST', path, body) export const spApiPatch = (accessToken: string, path: string, body?: any): Promise => spApiWrite(accessToken, 'PATCH', path, body) // Runs `fn` over `items` one at a time with a fixed delay between calls // (never in parallel) — SP-API's Pricing/Fees/Listings operations are tightly // rate-limited (~1 req/sec) and have no bulk equivalent, so a page of a few // dozen SKUs needs pacing, not a burst. Never aborts the batch on one item's // failure; a single 429 gets one backed-off retry before being recorded as a // per-item error (mirrors the retry shape in listAllOrders above, simplified // since these are single calls, not paginated). export const throttledBatch = async ( items: T[], fn: (item: T) => Promise, delayMs: number = 1100 ): Promise<{ item: T; result: R | null; error: string | null }[]> => { const out: { item: T; result: R | null; error: string | null }[] = [] for (let i = 0; i < items.length; i++) { try { out.push({ item: items[i], result: await fn(items[i]), error: null }) } catch (e: any) { if (e?.status === 429) { await sleep(3000) try { out.push({ item: items[i], result: await fn(items[i]), error: null }) if (i < items.length - 1) await sleep(delayMs) continue } catch (e2: any) { out.push({ item: items[i], result: null, error: e2?.message || String(e2) }) if (i < items.length - 1) await sleep(delayMs) continue } } out.push({ item: items[i], result: null, error: e?.message || String(e) }) } if (i < items.length - 1) await sleep(delayMs) } return out } // Serializes SIS price pushes per order source (seller) so the frontend's // deliberately non-blocking per-field push (see index.vue pushPriceDirect — // the user can tab to the next row before the previous push resolves) can // never fire genuinely concurrent Listings Items calls at the same seller. // Each push's Amazon calls run to completion, then the next queued push // waits at least PRICE_PUSH_MIN_SPACING_MS before starting — the same ~1 // req/sec pacing throttledBatch above already assumes Listings operations // need. Per-process Map, same convention as listingsReportCache/ // recentPushedPrices below. const priceQueueTail = new Map>() const PRICE_PUSH_MIN_SPACING_MS = 1100 export const withPricePushQueue = async (orderSourceId: number, fn: () => Promise): Promise => { const prev = priceQueueTail.get(orderSourceId) || Promise.resolve() let releaseNext: () => void = () => {} const gate = new Promise((resolve) => { releaseNext = resolve }) priceQueueTail.set(orderSourceId, prev.then(() => gate)) await prev try { return await fn() } finally { setTimeout(releaseNext, PRICE_PUSH_MIN_SPACING_MS) } } // --------------------------------------------------------------------------- // Reports API (2021-06-30) — bulk merchant listings. This is the primary // source of "what's actually live on Amazon" for SIS: one report gives // seller-sku, asin, price, quantity, fulfillment channel and status for the // ENTIRE catalog, instead of guessing from the local catalog or looping // Listings Items GET per SKU (which also requires already knowing the SKU — // this report is how we discover it in the first place). // --------------------------------------------------------------------------- export interface MerchantListingRow { sellerSku: string asin: string itemName: string price: number | null quantity: number | null fulfillmentChannel: string status: string openDate: string | null } const REPORT_POLL_INTERVAL_MS = 4000 // A GET_MERCHANT_LISTINGS_ALL_DATA report normally finishes in well under a // minute. Kept under nginx's 60s proxy timeout (see the "iDempiere REST load // rules" recipe) so a slow report fails cleanly with a message the frontend // can show ("still generating, try again shortly") instead of trailing off // into an opaque 502/504 — the next request re-polls the same reportId's // result via a fresh report (report creation is cheap; Amazon dedupes/queues // close-together requests for the same report type on its side). const REPORT_POLL_MAX_MS = 45000 export const createReport = async (accessToken: string, reportType: string, marketplaceIds: string[]): Promise => { const json = await spApiPost(accessToken, '/reports/2021-06-30/reports', { reportType, marketplaceIds }) return json.reportId } export const pollReport = async ( accessToken: string, reportId: string ): Promise<{ status: string; reportDocumentId: string | null }> => { const startedAt = Date.now() while (Date.now() - startedAt < REPORT_POLL_MAX_MS) { const json = await spApiGet(accessToken, `/reports/2021-06-30/reports/${encodeURIComponent(reportId)}`) const status = json.processingStatus if (status === 'DONE') return { status, reportDocumentId: json.reportDocumentId ?? null } if (status === 'CANCELLED' || status === 'FATAL') return { status, reportDocumentId: null } await sleep(REPORT_POLL_INTERVAL_MS) } return { status: 'TIMEOUT', reportDocumentId: null } } export const downloadReportDocument = async (accessToken: string, reportDocumentId: string): Promise => { const meta = await spApiGet(accessToken, `/reports/2021-06-30/documents/${encodeURIComponent(reportDocumentId)}`) const url = meta.url if (!url) throw tagAmazonError(new Error('Report document has no download URL')) let response: Response try { response = await fetch(url, { signal: AbortSignal.timeout(30000) }) } catch (e: any) { throw tagAmazonError(new Error(`Failed to download report document: ${e?.name === 'TimeoutError' ? 'timed out' : (e?.message || String(e))}`)) } if (!response.ok) throw tagAmazonError(new Error(`Failed to download report document (${response.status})`)) const buffer = Buffer.from(await response.arrayBuffer()) if (meta.compressionAlgorithm === 'GZIP') { const zlib = await import('node:zlib') return zlib.gunzipSync(buffer).toString('utf-8') } return buffer.toString('utf-8') } // Parses the GET_MERCHANT_LISTINGS_ALL_DATA tab-separated report. Column // order isn't guaranteed stable across accounts/marketplaces, so this reads // the header row rather than assuming fixed positions. Rows without a // seller-sku are skipped (not a real listing). export const parseMerchantListingsReport = (tsv: string): MerchantListingRow[] => { const lines = tsv.split(/\r?\n/).filter(l => l.length > 0) if (lines.length < 2) return [] const headers = lines[0].split('\t') const idx = (name: string) => headers.indexOf(name) const iSku = idx('seller-sku') const iAsin = idx('asin1') const iName = idx('item-name') const iPrice = idx('price') const iQty = idx('quantity') const iChannel = idx('fulfillment-channel') const iStatus = idx('status') const iOpenDate = idx('open-date') const rows: MerchantListingRow[] = [] for (let i = 1; i < lines.length; i++) { const cols = lines[i].split('\t') const sellerSku = (iSku >= 0 ? cols[iSku] : '') || '' if (!sellerSku) continue rows.push({ sellerSku, asin: (iAsin >= 0 ? cols[iAsin] : '') || '', itemName: (iName >= 0 ? cols[iName] : '') || '', price: iPrice >= 0 && cols[iPrice] ? parseAmazonAmount(cols[iPrice]) : null, quantity: iQty >= 0 && cols[iQty] ? Number(cols[iQty]) : null, fulfillmentChannel: (iChannel >= 0 ? cols[iChannel] : '') || 'DEFAULT', status: (iStatus >= 0 ? cols[iStatus] : '') || '', openDate: (iOpenDate >= 0 ? cols[iOpenDate] : null) || null }) } return rows } // Per-order-source cache of the parsed listings report — report generation is // slow (seconds to a couple minutes) and there is no reason to regenerate it // on every page view. 30 min TTL; a failed regeneration falls back to serving // the stale cache rather than breaking the page. const LISTINGS_REPORT_TTL_MS = 30 * 60 * 1000 const listingsReportCache = new Map() // Overlay of just-pushed prices, keyed by "orderSourceId:sku" — survives a // full report REGENERATION, not just cache reads. Verified against real // production data: Amazon's bulk GET_MERCHANT_LISTINGS_ALL_DATA report lags // well behind a confirmed Listings Items price push (still showed the old // price on a forced "Refresh from Amazon" more than 40s after a push that // getListingsItem had already confirmed) — so a fresh report can otherwise // silently REVERT the just-pushed price in the UI. Entries expire after // RECENT_PUSH_TTL_MS on the assumption Amazon's own report has caught up by // then; until it does, the overlay wins over whatever the report says. const RECENT_PUSH_TTL_MS = 20 * 60 * 1000 const recentPushedPrices = new Map() const recentPushKey = (orderSourceId: number, sellerSku: string) => `${orderSourceId}:${sellerSku}` const applyRecentPushOverlay = (orderSourceId: number, rows: MerchantListingRow[]): MerchantListingRow[] => { if (recentPushedPrices.size === 0) return rows const now = Date.now() return rows.map(row => { const key = recentPushKey(orderSourceId, row.sellerSku) const pushed = recentPushedPrices.get(key) if (!pushed) return row if (now - pushed.at > RECENT_PUSH_TTL_MS) { recentPushedPrices.delete(key) return row } return { ...row, price: pushed.price } }) } export const getMerchantListingsReport = async ( accessToken: string, orderSourceId: number, marketplaceId: string, opts: { forceRefresh?: boolean } = {} ): Promise<{ rows: MerchantListingRow[]; fromCache: boolean; generatedAt: number }> => { const cached = listingsReportCache.get(orderSourceId) if (!opts.forceRefresh && cached && Date.now() - cached.at < LISTINGS_REPORT_TTL_MS) { return { rows: applyRecentPushOverlay(orderSourceId, cached.rows), fromCache: true, generatedAt: cached.at } } try { const reportId = await createReport(accessToken, 'GET_MERCHANT_LISTINGS_ALL_DATA', [marketplaceId]) const { status, reportDocumentId } = await pollReport(accessToken, reportId) if (status !== 'DONE' || !reportDocumentId) { throw tagAmazonError(new Error(`Amazon listings report did not complete (status: ${status})`)) } const tsv = await downloadReportDocument(accessToken, reportDocumentId) const rows = parseMerchantListingsReport(tsv) const at = Date.now() listingsReportCache.set(orderSourceId, { rows, at }) return { rows: applyRecentPushOverlay(orderSourceId, rows), fromCache: false, generatedAt: at } } catch (err) { if (cached) return { rows: applyRecentPushOverlay(orderSourceId, cached.rows), fromCache: true, generatedAt: cached.at } throw err } } // Records a just-pushed price so it survives both a cached read AND a full // report regeneration until Amazon's own bulk report catches up (see the // overlay note above). Call this right after patchListingsItemPrice // succeeds. Also patches the cached report row in place (cheap, keeps the // two sources of truth consistent even before the overlay would kick in). export const updateCachedListingPrice = (orderSourceId: number, sellerSku: string, newPrice: number): void => { recentPushedPrices.set(recentPushKey(orderSourceId, sellerSku), { price: newPrice, at: Date.now() }) const cached = listingsReportCache.get(orderSourceId) if (!cached) return const row = cached.rows.find(r => r.sellerSku === sellerSku) if (row) row.price = newPrice } // --------------------------------------------------------------------------- // Listings Items API (2021-08-01) — one-SKU read + price patch. Used by SIS // to confirm the live price/quantity for a single row and to push a new // price. Chosen over the Feeds API for the push action: single-item, // admin-triggered, no feed-document upload/polling dance to set up. // // NOT actually synchronous, though — verified against real production data // (server/api/sales/sis/price-update.post.ts): the PATCH returns `status: // "ACCEPTED"` with a `submissionId`, the same async-submission shape as the // Feeds API, just normally fast. A GET immediately after can still return // the pre-patch price. Callers that need the applied value should poll // getListingsItem briefly rather than trusting one immediate read. // --------------------------------------------------------------------------- export interface ListingsItemOffer { price: number | null currency: string | null quantity: number | null fulfillmentChannel: string status: string productType: string | null } export const getListingsItem = async ( accessToken: string, sellerId: string, sku: string, marketplaceId: string ): Promise => { const params = new URLSearchParams() params.set('marketplaceIds', marketplaceId) params.set('includedData', 'summaries,offers,fulfillmentAvailability') const json = await spApiGet( accessToken, `/listings/2021-08-01/items/${encodeURIComponent(sellerId)}/${encodeURIComponent(sku)}?${params.toString()}` ) const offer = (json.offers || [])[0] const avail = (json.fulfillmentAvailability || [])[0] const summary = (json.summaries || [])[0] return { price: offer?.price?.amount != null ? Number(offer.price.amount) : null, currency: offer?.price?.currency ?? null, quantity: avail?.quantity ?? null, fulfillmentChannel: avail?.fulfillmentChannelCode || 'DEFAULT', status: summary?.status || '', productType: summary?.productType || null } } // Pushes a new "our price" for one SKU. `productType` is per-listing (part // of Amazon's category taxonomy) and required on every Listings Items PATCH — // resolved here from the item's own summary rather than hardcoded/guessed, // which would make every push fail with a 400. export const patchListingsItemPrice = async ( accessToken: string, sellerId: string, sku: string, marketplaceId: string, newPrice: number, currency: string = 'EUR', // Callers that already fetched the listing (e.g. price-update.post.ts's // pre-push `before` read) can pass it here to skip a second, redundant GET // of the same SKU just to read productType. preloadedItem: ListingsItemOffer | null = null ): Promise => { const current = preloadedItem?.productType ? preloadedItem : await getListingsItem(accessToken, sellerId, sku, marketplaceId) if (!current?.productType) { const err: any = tagAmazonError(new Error(`Could not resolve productType for SKU "${sku}" — item may not be listed in this marketplace`)) err.status = 404 throw err } const params = new URLSearchParams() params.set('marketplaceIds', marketplaceId) return spApiPatch( accessToken, `/listings/2021-08-01/items/${encodeURIComponent(sellerId)}/${encodeURIComponent(sku)}?${params.toString()}`, { productType: current.productType, patches: [ { op: 'replace', path: '/attributes/purchasable_offer', value: [ { marketplace_id: marketplaceId, currency, our_price: [{ schedule: [{ value_with_tax: newPrice }] }] } ] } ] } ) } // --------------------------------------------------------------------------- // Product Pricing API (v0) — Buy Box / competitive offers for one ASIN. No // bulk equivalent exists, so this stays a per-ASIN call — SIS throttles it // via throttledBatch and caches the result. // --------------------------------------------------------------------------- export interface ItemOffersSummary { buyBoxWinner: boolean | null buyBoxPrice: number | null buyBoxCurrency: string | null competitorCount: number lowestOtherPrice: number | null } export const getItemOffers = async ( accessToken: string, asin: string, marketplaceId: string, itemCondition: string = 'New' ): Promise => { const params = new URLSearchParams() params.set('MarketplaceId', marketplaceId) params.set('ItemCondition', itemCondition) const json = await spApiGet(accessToken, `/products/pricing/v0/items/${encodeURIComponent(asin)}/offers?${params.toString()}`) const payload = json.payload if (!payload) return null const offers: any[] = payload.Offers || [] const myOffer = offers.find((o: any) => o.MyOffer === true) const buyBoxEntry = (payload.Summary?.BuyBoxPrices || [])[0] const otherPrices = offers .filter((o: any) => o.MyOffer !== true) .map((o: any) => currencyAmount(o.ListingPrice) + currencyAmount(o.Shipping || {})) return { buyBoxWinner: myOffer ? myOffer.IsBuyBoxWinner === true : null, buyBoxPrice: buyBoxEntry ? currencyAmount(buyBoxEntry.ListingPrice) + currencyAmount(buyBoxEntry.Shipping || {}) : null, buyBoxCurrency: buyBoxEntry?.ListingPrice?.CurrencyCode ?? null, competitorCount: Math.max(0, offers.length - (myOffer ? 1 : 0)), lowestOtherPrice: otherPrices.length ? Math.min(...otherPrices) : null } } // --------------------------------------------------------------------------- // Product Fees API (v0) — estimated referral/fulfillment fees for one SKU at // a given price. No bulk equivalent; per-SKU call like Buy Box above. // --------------------------------------------------------------------------- export interface FeesEstimateResult { totalFees: number | null currency: string | null breakdown: { type: string; amount: number }[] } export const getFeesEstimate = async ( accessToken: string, sku: string, marketplaceId: string, price: number, isAmazonFulfilled: boolean, currency: string = 'EUR' ): Promise => { const json = await spApiPost(accessToken, `/products/fees/v0/listings/${encodeURIComponent(sku)}/feesEstimate`, { FeesEstimateRequest: { MarketplaceId: marketplaceId, IsAmazonFulfilled: isAmazonFulfilled, PriceToEstimateFees: { ListingPrice: { CurrencyCode: currency, Amount: price } }, Identifier: `sis-${sku}-${Date.now()}` } }) const feesResult = json?.payload?.FeesEstimateResult?.FeesEstimate if (!feesResult) return null return { totalFees: feesResult.TotalFeesEstimate ? currencyAmount(feesResult.TotalFeesEstimate) : null, currency: feesResult.TotalFeesEstimate?.CurrencyCode ?? null, breakdown: (feesResult.FeeDetailList || []).map((f: any) => ({ type: f.FeeType || '', amount: currencyAmount(f.FeeAmount) })) } }