import zlib from 'node:zlib' /** * 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 Reports API helpers used by the accounting / settlement-report feature. */ export const LWA_TOKEN_URL = 'https://api.amazon.com/auth/o2/token' export const SP_API_BASE_URL = 'https://sellingpartnerapi-eu.amazon.com' // V2 flat-file settlement report — the canonical payout-reconciliation source. // Amazon AUTO-generates these per settlement period; they cannot be created on // demand. We list existing ones via getReports and download the document. export const SETTLEMENT_REPORT_TYPE = 'GET_V2_SETTLEMENT_REPORT_DATA_FLAT_FILE_V2' // 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 } // Get LWA access token using a refresh token. export const getAmazonAccessToken = async ( clientId: string, clientSecret: string, refreshToken: string ): Promise => { const 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 }) }) if (!response.ok) { const errorText = await response.text() throw new Error(`Failed to get Amazon access token (${response.status}): ${errorText}`) } const data: any = await response.json() return data.access_token } // 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 => { const response = await fetch(`${SP_API_BASE_URL}${path}`, { method: 'GET', headers: { 'x-amz-access-token': accessToken, 'Content-Type': 'application/json' } }) 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 = new Error(`SP-API ${response.status}: ${msg}`) err.status = response.status err.spapi = json throw err } return json } export interface SettlementReportRef { reportId: string reportDocumentId: string | null dataStartTime: string | null dataEndTime: string | null createdTime: string | null processingStatus: string | null } // List available settlement reports for a marketplace (DONE only). Settlement // reports are auto-scheduled by Amazon — they can't be requested via createReport. export const listSettlementReports = async ( accessToken: string, marketplaceId: string, opts: { createdSince?: string; createdUntil?: string; nextToken?: string; pageSize?: number } = {} ): Promise<{ reports: SettlementReportRef[]; nextToken: string | null }> => { let path: string if (opts.nextToken) { // When paginating, Amazon requires nextToken as the ONLY query parameter. path = `/reports/2021-06-30/reports?nextToken=${encodeURIComponent(opts.nextToken)}` } else { const params = new URLSearchParams() params.set('reportTypes', SETTLEMENT_REPORT_TYPE) params.set('marketplaceIds', marketplaceId) params.set('processingStatuses', 'DONE') params.set('pageSize', String(opts.pageSize ?? 100)) if (opts.createdSince) params.set('createdSince', opts.createdSince) if (opts.createdUntil) params.set('createdUntil', opts.createdUntil) path = `/reports/2021-06-30/reports?${params.toString()}` } const json = await spApiGet(accessToken, path) const reports: SettlementReportRef[] = (json.reports || []).map((r: any) => ({ reportId: r.reportId, reportDocumentId: r.reportDocumentId ?? null, dataStartTime: r.dataStartTime ?? null, dataEndTime: r.dataEndTime ?? null, createdTime: r.createdTime ?? null, processingStatus: r.processingStatus ?? null })) return { reports, nextToken: json.nextToken ?? null } } // Fetch a single report (to resolve reportDocumentId from a reportId). export const getReport = async (accessToken: string, reportId: string): Promise => { return await spApiGet(accessToken, `/reports/2021-06-30/reports/${encodeURIComponent(reportId)}`) } // Get report document metadata: the pre-signed download URL + compression algo. export const getReportDocumentMeta = async ( accessToken: string, reportDocumentId: string ): Promise<{ url: string; compressionAlgorithm: string | null }> => { const json = await spApiGet(accessToken, `/reports/2021-06-30/documents/${encodeURIComponent(reportDocumentId)}`) return { url: json.url, compressionAlgorithm: json.compressionAlgorithm ?? null } } // Download a report document and return its text. Gunzips when GZIP. Documents // from the 2021-06-30 Reports API are NOT encrypted. Decodes UTF-8, falling back // to latin1 (settlement flat files are historically Cp1252 — German umlauts). export const downloadReportText = async ( url: string, compressionAlgorithm: string | null = null ): Promise => { const response = await fetch(url) if (!response.ok) { const t = await response.text().catch(() => '') throw new Error(`Failed to download report document (${response.status}): ${t}`) } const arrayBuf = await response.arrayBuffer() let buf = Buffer.from(arrayBuf) if (String(compressionAlgorithm).toUpperCase() === 'GZIP') { buf = zlib.gunzipSync(buf) } let text = buf.toString('utf8') if (text.includes('�')) text = buf.toString('latin1') return text } // Parse a tab-delimited settlement flat file into keyed row objects (first line // = header). Values are trimmed. export const parseSettlementFlatFile = (text: string): any[] => { if (!text) return [] const lines = text.split(/\r?\n/).filter(l => l.length > 0) if (lines.length < 2) return [] const header = lines[0].split('\t').map(h => h.trim()) const rows: any[] = [] for (let i = 1; i < lines.length; i++) { const cells = lines[i].split('\t') const row: any = {} header.forEach((key, idx) => { row[key] = (cells[idx] ?? '').trim() }) rows.push(row) } return rows } // 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. 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 } export type SettlementCategory = | 'umsaetze' | 'retouren' | 'amazonFees' | 'fbaFees' | 'werbekosten' | 'auszahlung' | 'sonstiges' // A settlement's first row is a summary/header carrying total-amount + deposit-date // (= the actual bank payout / Auszahlung) with no transaction-type. export const isSettlementHeaderRow = (row: any): boolean => { const total = String(row['total-amount'] ?? '').trim() const txType = String(row['transaction-type'] ?? '').trim() return total !== '' && txType === '' } const lc = (v: any) => String(v ?? '').trim().toLowerCase() // Map a settlement detail row to one of the six accounting buckets (+ a // catch-all `sonstiges` so nothing is silently dropped). Strings are matched // loosely because Amazon's amount-description values vary by account/locale — // this mapping should be validated against a real downloaded report and refined. export const categorizeSettlementRow = (row: any): SettlementCategory => { const tx = lc(row['transaction-type']) const amtType = lc(row['amount-type']) const amtDesc = lc(row['amount-description']) const hay = `${tx} ${amtType} ${amtDesc}` // Werbekosten — Sponsored Products / advertising deductions. if (hay.includes('advertising')) return 'werbekosten' // Retouren — refunds, returns, A-to-z, chargebacks (whole row). if (tx.includes('refund') || tx.includes('return') || tx.includes('guarantee') || tx.includes('chargeback')) { return 'retouren' } // FBA Fees — fulfillment + storage/inventory fees. if (amtDesc.startsWith('fba') || hay.includes('storage') || hay.includes('inventory') || hay.includes('fulfillment fee')) { return 'fbaFees' } // Amazon Fees — referral commission, closing fees, subscription, service fees. if (amtType === 'itemfees' || hay.includes('commission') || hay.includes('closing fee') || hay.includes('closingfee') || hay.includes('subscription') || hay.includes('service fee') || hay.includes('servicefee')) { return 'amazonFees' } // Umsätze — order/shipment revenue (principal, shipping, gift wrap, tax, promotions). if (amtType === 'itemprice' || amtType === 'promotion' || amtType === 'itemwithheldtax' || tx === 'order' || tx === 'shipment' || hay.includes('principal') || hay.includes('shipping') || hay.includes('giftwrap') || hay.includes('promotion')) { return 'umsaetze' } return 'sonstiges' } // --------------------------------------------------------------------------- // 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 } }