import { buildSigningHeaders } from "./ebaySignature" /** * Shared eBay Finances API helper. * * Auth model: OAuth2 user tokens stored per order source on `c_ordersource` * (marketplace_key = client/app id, marketplace_secret = client secret, * marketplace_token = JSON blob { access_token, refresh_token, expires_in, * obtained_at, ... }, ebay_environment = 'production' | 'sandbox'). * * The OAuth authorization + initial token exchange already live in * server/api/oauth/ebay/* (authorize / callback / test-connection). This util * adds the read side for the accounting / payout-reconciliation feature: * refreshing a stale access token in-memory, then calling the Finances API * (Payouts + Transactions) and decomposing transactions into accounting buckets. * * READ-ONLY: nothing here writes to iDempiere. The access-token refresh is an * in-memory eBay call only (refresh tokens do not rotate, so it is side-effect * free); the refreshed token is intentionally NOT persisted. */ export const EBAY_TOKEN_URL = { production: 'https://api.ebay.com/identity/v1/oauth2/token', sandbox: 'https://api.sandbox.ebay.com/identity/v1/oauth2/token' } // The Finances API is served from the apiz.* host. export const EBAY_API_BASE = { production: 'https://apiz.ebay.com', sandbox: 'https://apiz.sandbox.ebay.com' } // Scope requested on refresh — must be a subset of the originally granted scopes // (sell.finances is already granted by the authorize flow). const EBAY_REFRESH_SCOPE = 'https://api.ebay.com/oauth/api_scope/sell.finances' export type EbayCategory = | 'umsaetze' | 'retouren' | 'ebayGebuehren' | 'versandkosten' | 'werbekosten' | 'auszahlung' | 'sonstiges' // Parse an amount that may arrive as a number, a string, or an eBay // { value, currency } money object. Tolerant of en ("1,234.56") and de // ("1.234,56") formatting, though eBay normally uses dot decimals. export const parseAmount = (v: any): number => { if (v == null) return 0 if (typeof v === 'object') return parseAmount(v.value) 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 } // --- Token handling ------------------------------------------------------- // Parse the marketplace_token field — handles an object, a JSON string, a // double-encoded JSON string, or a JSON blob truncated by the DB column length // (falls back to a regex extraction of access/refresh tokens). export const parseEbayTokenBlob = (raw: any): any | null => { if (!raw) return null if (typeof raw === 'object') return raw if (typeof raw === 'string') { try { let t = JSON.parse(raw) if (typeof t === 'string') t = JSON.parse(t) return t } catch { const accessMatch = raw.match(/"access_token"\s*:\s*"([^"]+)"/) const refreshMatch = raw.match(/"refresh_token"\s*:\s*"([^"]+)"/) if (accessMatch || refreshMatch) { return { access_token: accessMatch?.[1], refresh_token: refreshMatch?.[1] } } return null } } return null } // Is the stored access token still valid (with a 2-minute safety buffer)? const isAccessTokenFresh = (tokens: any): boolean => { if (!tokens?.access_token) return false if (!tokens.obtained_at || !tokens.expires_in) return false const obtained = new Date(tokens.obtained_at).getTime() if (isNaN(obtained)) return false const expiresMs = obtained + Number(tokens.expires_in) * 1000 return Date.now() < expiresMs - 120000 } // Exchange a refresh token for a fresh access token (in-memory, not persisted). export const refreshEbayAccessToken = async ( clientId: string, clientSecret: string, refreshToken: string, environment: string = 'production' ): Promise<{ access_token: string; expires_in: number }> => { const url = environment === 'sandbox' ? EBAY_TOKEN_URL.sandbox : EBAY_TOKEN_URL.production const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64') const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: `Basic ${credentials}` }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, scope: EBAY_REFRESH_SCOPE }).toString() }) const text = await response.text() let json: any = {} try { json = text ? JSON.parse(text) : {} } catch { json = { raw: text } } if (!response.ok || !json.access_token) { const msg = json?.error_description || json?.error || text || response.statusText const err: any = new Error(`eBay token refresh failed (${response.status}): ${msg}`) err.status = response.status err.ebay = json throw err } return { access_token: json.access_token, expires_in: json.expires_in } } // Everything needed to make a signed Finances API call: the per-user access // token, the environment, and the app client id/secret (needed to mint the // Ed25519 signing key for eBay's mandatory digital signatures). export interface EbayContext { accessToken: string environment: string clientId: string clientSecret: string } // Resolve a full call context from an order source, refreshing the access token // if stale. Throws Error carrying a .status so callers can surface it verbatim. export const getEbayContext = async (orderSource: any): Promise => { const clientId = orderSource?.SHOPWARE_KEY || orderSource?.marketplace_key const clientSecret = orderSource?.SHOPWARE_SECRET || orderSource?.marketplace_secret const environment = orderSource?.ebay_environment || 'production' const tokens = parseEbayTokenBlob(orderSource?.marketplace_token) if (!tokens) { const err: any = new Error('eBay account is not connected (no stored token). Connect it under Order Source settings.') err.status = 400 throw err } let accessToken: string if (isAccessTokenFresh(tokens)) { accessToken = tokens.access_token } else if (tokens.refresh_token && clientId && clientSecret) { const refreshed = await refreshEbayAccessToken(clientId, clientSecret, tokens.refresh_token, environment) accessToken = refreshed.access_token } else if (tokens.access_token) { // Can't refresh — try the stored access token anyway; a 401 surfaces clearly. accessToken = tokens.access_token } else { const err: any = new Error('eBay token expired and no refresh token available. Please reconnect the eBay account.') err.status = 401 throw err } return { accessToken, environment, clientId, clientSecret } } // --- Finances API --------------------------------------------------------- // Generic SIGNED eBay GET. eBay's Finances API mandates RFC 9421 digital // signatures (else 403 "Missing x-ebay-signature-key header"), so every request // carries the x-ebay-signature-key / Signature / Signature-Input headers built // from the app's Ed25519 signing key. Returns parsed JSON, throws an Error // carrying the eBay error message + status on a non-2xx. export const ebayApiGet = async (ctx: EbayContext, path: string): Promise => { const base = ctx.environment === 'sandbox' ? EBAY_API_BASE.sandbox : EBAY_API_BASE.production const url = `${base}${path}` const signHeaders = await buildSigningHeaders({ method: 'GET', url, clientId: ctx.clientId, clientSecret: ctx.clientSecret, environment: ctx.environment }) const response = await fetch(url, { method: 'GET', headers: { Authorization: `Bearer ${ctx.accessToken}`, 'Content-Type': 'application/json', ...signHeaders } }) 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]?.longMessage || json?.error_description || text || response.statusText const err: any = new Error(`eBay API ${response.status}: ${msg}`) err.status = response.status err.ebay = json throw err } return json } // List payouts (the lump-sum bank deposits = "settlements"). Most recent first. export const listPayouts = async ( ctx: EbayContext, opts: { payoutDateFrom?: string; payoutDateTo?: string; limit?: number; offset?: number } = {} ): Promise<{ payouts: any[]; total: number; limit: number; offset: number }> => { const limit = opts.limit ?? 200 const offset = opts.offset ?? 0 let path = `/sell/finances/v1/payout?limit=${limit}&offset=${offset}` if (opts.payoutDateFrom || opts.payoutDateTo) { const from = opts.payoutDateFrom || '' const to = opts.payoutDateTo || '' path += `&filter=${encodeURIComponent(`payoutDate:[${from}..${to}]`)}` } const json = await ebayApiGet(ctx, path) return { payouts: json.payouts || [], total: json.total ?? (json.payouts?.length || 0), limit: json.limit ?? limit, offset: json.offset ?? offset } } // Fetch a single payout (for its authoritative amount + date). export const getPayout = async (ctx: EbayContext, payoutId: string): Promise => { return await ebayApiGet(ctx, `/sell/finances/v1/payout/${encodeURIComponent(payoutId)}`) } // Fetch ALL transactions belonging to a payout (paginated; eBay caps limit at 1000). export const getTransactionsForPayout = async ( ctx: EbayContext, payoutId: string, maxRows: number = 20000 ): Promise => { const all: any[] = [] const limit = 1000 let offset = 0 let total = Number.POSITIVE_INFINITY while (offset < total && all.length < maxRows) { const filter = `payoutId:{${payoutId}}` const path = `/sell/finances/v1/transaction?limit=${limit}&offset=${offset}&filter=${encodeURIComponent(filter)}` const json = await ebayApiGet(ctx, path) const txns = json.transactions || [] all.push(...txns) total = json.total ?? all.length if (!txns.length) break offset += limit } return all } // --- Categorisation ------------------------------------------------------- const lc = (v: any) => String(v ?? '').trim().toLowerCase() // Promoted Listings / advertising fee? const isAdvertisingFee = (feeType: string, extra: string = ''): boolean => { const s = lc(`${feeType} ${extra}`) return s.includes('ad_fee') || s.includes('ad fee') || s.includes('advertis') || s.includes('promoted') } export interface EbaySettlementRow { category: EbayCategory transactionType: string bookingEntry: string amountDescription: string amount: number orderId: string sku: string qty: number | null postedDate: string reference: string // eBay transactionId — PayJoe's "FEE-" fee reference in the lexoffice export } // Explode one eBay transaction into one or more signed accounting rows. // eBay embeds selling fees INSIDE the SALE transaction (orderLineItems[].marketplaceFees // + totalFeeAmount) rather than as separate transactions, so a SALE is split into a // positive Umsätze row plus negative fee rows. Signs follow bookingEntry // (CREDIT = +, DEBIT = -) so that the sum of all rows reconciles to the payout total. export const decomposeTransaction = (txn: any): EbaySettlementRow[] => { const rows: EbaySettlementRow[] = [] const type = String(txn?.transactionType || '').toUpperCase() const booking = String(txn?.bookingEntry || '').toUpperCase() const value = parseAmount(txn?.amount) const sign = booking === 'DEBIT' ? -1 : 1 const orderId = txn?.orderId || '' const date = txn?.transactionDate || '' const memo = txn?.transactionMemo || '' const reference = String(txn?.transactionId || '') // Collect embedded selling fees across all order line items. const fees: { feeType: string; amount: number; sku: string }[] = [] for (const oli of (txn?.orderLineItems || [])) { for (const f of (oli?.marketplaceFees || [])) { fees.push({ feeType: f?.feeType || 'FEE', amount: parseAmount(f?.amount), sku: oli?.legacyItemId || oli?.lineItemId || '' }) } } if (type === 'SALE') { rows.push({ category: 'umsaetze', transactionType: 'SALE', bookingEntry: booking || 'CREDIT', amountDescription: memo || 'Verkauf', amount: value, orderId, sku: '', qty: null, postedDate: date, reference }) if (fees.length) { for (const f of fees) { rows.push({ category: isAdvertisingFee(f.feeType) ? 'werbekosten' : 'ebayGebuehren', transactionType: 'FEE', bookingEntry: 'DEBIT', amountDescription: f.feeType, amount: -Math.abs(f.amount), orderId, sku: f.sku || '', qty: null, postedDate: date, reference }) } } else { const totalFee = parseAmount(txn?.totalFeeAmount) if (totalFee) { rows.push({ category: 'ebayGebuehren', transactionType: 'FEE', bookingEntry: 'DEBIT', amountDescription: 'Verkaufsgebühren', amount: -Math.abs(totalFee), orderId, sku: '', qty: null, postedDate: date, reference }) } } return rows } if (type === 'REFUND' || type === 'DISPUTE') { rows.push({ category: 'retouren', transactionType: type, bookingEntry: booking || 'DEBIT', amountDescription: memo || (type === 'DISPUTE' ? 'Zahlungsstreit' : 'Erstattung'), amount: sign * value, orderId, sku: '', qty: null, postedDate: date, reference }) return rows } if (type === 'SHIPPING_LABEL') { rows.push({ category: 'versandkosten', transactionType: 'SHIPPING_LABEL', bookingEntry: booking || 'DEBIT', amountDescription: memo || 'Versandetikett', amount: sign * value, orderId, sku: '', qty: null, postedDate: date, reference }) return rows } if (type === 'NON_SALE_CHARGE') { const feeType = txn?.feeType || '' rows.push({ category: isAdvertisingFee(feeType, memo) ? 'werbekosten' : 'ebayGebuehren', transactionType: 'NON_SALE_CHARGE', bookingEntry: booking || 'DEBIT', amountDescription: feeType || memo || 'Gebühr', amount: sign * value, orderId, sku: '', qty: null, postedDate: date, reference }) return rows } // CREDIT, TRANSFER, ADJUSTMENT, WITHDRAWAL, LOAN_REPAYMENT, unknown → sonstiges // (surfaced, never silently dropped — mirrors the Amazon settlement catch-all). rows.push({ category: 'sonstiges', transactionType: type || 'UNKNOWN', bookingEntry: booking || '', amountDescription: txn?.feeType || memo || type || 'Sonstiges', amount: sign * value, orderId, sku: '', qty: null, postedDate: date, reference }) return rows }