import { string } from 'alga-js' import refreshTokenHelper from "../../../utils/refreshTokenHelper" import forceLogoutHelper from "../../../utils/forceLogoutHelper" import errorHandlingHelper from "../../../utils/errorHandlingHelper" import fetchHelper from "../../../utils/fetchHelper" import { getEbayContext, getPayout, getTransactionsForPayout, decomposeTransaction, parseAmount } from "../../../utils/ebayFinancesApi" // READ-ONLY: matches a set of eBay order ids to internal c_order records. // Only GETs orders to show the reference/match indicator — writes nothing. // Looks up by ebay_order_id first, then ExternalOrderId for any still unmatched. const matchOrders = async (event: any, token: any, orderIds: string[]) => { const map: Record = {} const ids = orderIds.filter(Boolean) if (!ids.length) return { map, truncated: false } const CAP = 3000 const truncated = ids.length > CAP const work = truncated ? ids.slice(0, CAP) : ids const lookup = async (field: string, pending: string[]) => { const CHUNK = 45 for (let i = 0; i < pending.length; i += CHUNK) { const chunk = pending.slice(i, i + CHUNK) const filter = chunk.map(id => `${field} eq '${String(id).replace(/'/g, "''")}'`).join(' OR ') const res: any = await fetchHelper( event, `models/c_order?$filter=${string.urlEncode(filter)}&$select=${string.urlEncode('C_Order_ID,DocumentNo,ebay_order_id,ExternalOrderId')}&$top=200`, 'GET', token, null ) for (const rec of (res?.records || [])) { const entry = { id: rec.id, documentNo: rec.DocumentNo || '' } // Register under both ids so a hit on either field resolves the order. if (rec.ebay_order_id) map[String(rec.ebay_order_id)] = entry if (rec.ExternalOrderId) map[String(rec.ExternalOrderId)] = entry } } } // Pass 1: ebay_order_id await lookup('ebay_order_id', work) // Pass 2: ExternalOrderId for whatever is still unmatched const stillMissing = work.filter(id => !map[id]) if (stillMissing.length) await lookup('ExternalOrderId', stillMissing) return { map, truncated } } type InvoiceRef = { invoiceId: number; documentNo: string; grandTotal: number; docStatus: string } // For matched orders, find any linked c_invoice so we can show the invoice no/link. // Prefers a Completed/Closed invoice over a draft when an order has several. // Mirrors the orders-check endpoint's lookupInvoices. const lookupInvoices = async (event: any, token: any, internalOrderIds: number[]) => { const map: Record = {} const ids = [...new Set(internalOrderIds.filter(Boolean))] if (!ids.length) return map const CHUNK = 45 for (let i = 0; i < ids.length; i += CHUNK) { const chunk = ids.slice(i, i + CHUNK) // Only live invoices count as "invoice exists" — exclude voided/reversed ones // (iDempiere keeps them IsActive=true). Whitelist: DR / CO / CL. const filter = chunk .map(id => `((C_Order_ID eq ${id}) and (DocStatus eq 'CO' or DocStatus eq 'CL' or DocStatus eq 'DR'))`) .join(' OR ') const res: any = await fetchHelper( event, `models/c_invoice?$filter=${string.urlEncode(filter)}&$select=${string.urlEncode('C_Invoice_ID,DocumentNo,GrandTotal,DocStatus,C_Order_ID')}&$top=400`, 'GET', token, null ) for (const rec of (res?.records || [])) { const oid = rec.C_Order_ID?.id ?? rec.C_Order_ID if (oid == null) continue const docStatus = rec.DocStatus?.id || rec.DocStatus?.identifier || rec.DocStatus || '' const ref: InvoiceRef = { invoiceId: rec.id ?? rec.C_Invoice_ID, documentNo: rec.DocumentNo || '', grandTotal: typeof rec.GrandTotal === 'number' ? rec.GrandTotal : Number(rec.GrandTotal || 0), docStatus } const existing = map[oid] const isFinal = (s: string) => { const u = String(s || '').toUpperCase() return u === 'CO' || u === 'CL' || u.includes('COMPLET') || u.includes('CLOSED') } if (!existing || (!isFinal(existing.docStatus) && isFinal(docStatus))) { map[oid] = ref } } } return map } const handleFunc = async (event: any, authToken: any = null) => { const token = authToken ?? await getTokenHelper(event) const query = getQuery(event) const orderSourceId = query.orderSourceId const payoutId = query.payoutId ? String(query.payoutId) : null if (!orderSourceId) return { status: 400, message: 'orderSourceId is required' } if (!payoutId) return { status: 400, message: 'payoutId is required' } const orderSource: any = await fetchHelper(event, `models/c_ordersource/${orderSourceId}`, 'GET', token, null) if (!orderSource) return { status: 404, message: 'Order source not found' } const marketplaceIdentifier = String(orderSource?.Marketplace?.identifier || '').toLowerCase() const isEbay = marketplaceIdentifier === 'ebay' || String(orderSource?.Marketplace?.id) === '5' || String(orderSource?.marketplace) === '5' if (!isEbay) return { status: 400, message: `Order source "${orderSource?.Name || orderSourceId}" is not an eBay marketplace` } if (!orderSource?.marketplace_token) { return { status: 400, message: 'eBay account is not connected for this order source (no stored token).' } } const ctx = await getEbayContext(orderSource) // Payout header (authoritative payout amount + date) and its transactions. const payout = await getPayout(ctx, payoutId) const transactions = await getTransactionsForPayout(ctx, payoutId) // Decompose every transaction into signed accounting rows. const detailRows = transactions.flatMap(decomposeTransaction) // Derive a period from transaction dates (eBay payouts carry no explicit range). const dates = transactions.map((t: any) => t?.transactionDate).filter(Boolean).sort() const settlement = { settlementId: payout?.payoutId || payoutId, periodStart: dates[0] || '', periodEnd: dates[dates.length - 1] || '', depositDate: payout?.payoutDate || '', totalAmount: parseAmount(payout?.amount), currency: payout?.amount?.currency || 'EUR', status: payout?.payoutStatus || '', transactionCount: payout?.transactionCount ?? transactions.length } // READ-ONLY match to internal orders (best-effort; never blocks the view). const distinctOrderIds = [...new Set(detailRows.map(r => r.orderId).filter(Boolean))] let matchTruncated = false let matchError: string | null = null let matchedCount = 0 try { const { map, truncated } = await matchOrders(event, token, distinctOrderIds) matchTruncated = truncated for (const row of detailRows) { const hit = row.orderId ? map[row.orderId] : null ;(row as any).internalOrderId = hit?.id ?? null ;(row as any).internalDocumentNo = hit?.documentNo ?? '' ;(row as any).matched = !!hit } matchedCount = distinctOrderIds.filter(id => map[id]).length // Best-effort: resolve a linked invoice for every matched order so the grid // can show a Rechnung column (never blocks the view if it fails). try { const matchedInternalIds = [...new Set( detailRows.map((r: any) => r.internalOrderId).filter(Boolean) )] as number[] const invoiceMap = await lookupInvoices(event, token, matchedInternalIds) for (const row of detailRows) { const inv = (row as any).internalOrderId ? invoiceMap[(row as any).internalOrderId] : null ;(row as any).invoiceExists = !!inv ;(row as any).invoiceId = inv?.invoiceId || null ;(row as any).invoiceDocumentNo = inv?.documentNo || '' ;(row as any).invoiceTotal = inv?.grandTotal ?? null ;(row as any).invoiceDocStatus = inv?.docStatus || '' } } catch { for (const row of detailRows) { ;(row as any).invoiceExists = false ;(row as any).invoiceId = null ;(row as any).invoiceDocumentNo = '' } } } catch (e: any) { matchError = e?.message || 'Order matching failed' for (const row of detailRows) { ;(row as any).internalOrderId = null ;(row as any).internalDocumentNo = '' ;(row as any).matched = false ;(row as any).invoiceExists = false ;(row as any).invoiceId = null ;(row as any).invoiceDocumentNo = '' } } // Category sums + reconciliation (sum of ALL detail amounts should ≈ payout total). const categorySums: Record = { umsaetze: 0, retouren: 0, ebayGebuehren: 0, versandkosten: 0, werbekosten: 0, sonstiges: 0 } let detailSum = 0 for (const r of detailRows) { detailSum += r.amount if (categorySums[r.category] !== undefined) categorySums[r.category] += r.amount } const summary = { ...categorySums, auszahlung: settlement.totalAmount, lineCount: detailRows.length, ordersWithId: distinctOrderIds.length, matchedOrders: matchedCount, unmatchedOrders: distinctOrderIds.length - matchedCount, matchTruncated, matchError, reconciliation: { detailSum: Math.round(detailSum * 100) / 100, payout: settlement.totalAmount, difference: Math.round((settlement.totalAmount - detailSum) * 100) / 100 } } return { status: 200, settlement, summary, rows: detailRows } } export default defineEventHandler(async (event) => { let data: any = {} try { data = await handleFunc(event) } catch (err: any) { // Surface eBay API errors (status-carrying) verbatim instead of treating them // as an iDempiere auth failure. if (err?.status && err?.message) { return { status: err.status, message: err.message, ebay: err.ebay } } try { let authToken: any = await refreshTokenHelper(event) data = await handleFunc(event, authToken) } catch (error: any) { data = errorHandlingHelper(err?.data ?? err, error?.data ?? error) forceLogoutHelper(event, data) } } return data })