/** * Pure helpers for filtering and aggregating WayForPay transaction rows. * * These run client-side over the response from a single `TRANSACTION_LIST` * call. They exist because the raw response is a context-window risk on * busy merchants — agents need to ask "how many" / "what's the total" / * "find the declined ones over 5000 UAH" without dumping 3000 rows into * the LLM. Both summarize and filter are O(n) over rows; n is bounded by * WFP's 31-day window, so this is fine without caching. */ export interface TransactionRow { orderReference?: string; transactionStatus?: string; amount?: number | string; currency?: string; createdDate?: string | number; processingDate?: string | number; settlementDate?: string | number | null; paymentSystem?: string; cardPan?: string; email?: string; phone?: string; clientEmail?: string; clientPhone?: string; clientName?: string; clientComment?: string | null; prroLink?: string; prroNumber?: string; reasonCode?: number | string; reason?: string; fee?: number | string; settlementAmount?: number | string; products?: Array<{ name?: string; price?: number | string; count?: number | string }>; [key: string]: unknown; } export interface TransactionFilters { status?: string; amount_min?: number; amount_max?: number; payment_system?: string; orderReference_contains?: string; email_contains?: string; card_pan_contains?: string; } /** * Returns true iff at least one filter field is set. Used by * `wfp_search_transactions` to refuse unfiltered calls — without this, * search would silently dump the whole window like the old behaviour. */ export function hasAnyTransactionFilter(filters: TransactionFilters): boolean { return ( filters.status !== undefined || filters.amount_min !== undefined || filters.amount_max !== undefined || filters.payment_system !== undefined || filters.orderReference_contains !== undefined || filters.email_contains !== undefined || filters.card_pan_contains !== undefined ); } export function filterTransactions( rows: readonly TransactionRow[], filters: TransactionFilters, ): TransactionRow[] { return rows.filter((row) => { if (filters.status && !ciEquals(row.transactionStatus, filters.status)) return false; if (filters.payment_system && !ciEquals(row.paymentSystem, filters.payment_system)) return false; if (filters.orderReference_contains && !ciContains(row.orderReference, filters.orderReference_contains)) return false; if (filters.email_contains && !ciContains(row.email, filters.email_contains)) return false; if (filters.card_pan_contains && !ciContains(row.cardPan, filters.card_pan_contains)) return false; const amount = toNumber(row.amount); if (filters.amount_min !== undefined && (amount === null || amount < filters.amount_min)) return false; if (filters.amount_max !== undefined && (amount === null || amount > filters.amount_max)) return false; return true; }); } export interface TransactionSummary { total_count: number; total_amount_by_currency: Record; approved_count: number; declined_count: number; refunded_count: number; by_status: Array<{ key: string; count: number; amount_by_currency: Record }>; by_currency: Array<{ key: string; count: number; total_amount: number }>; by_payment_system: Array<{ key: string; count: number; amount_by_currency: Record }>; by_day: Array<{ date: string; count: number; amount_by_currency: Record }>; } /** * Aggregate a transaction window into a single small response. The output is * O(distinct statuses + distinct currencies + distinct payment systems + * distinct days) — bounded by ≤ 31 days × ≤ 8 statuses × ≤ 6 systems regardless * of underlying transaction count, so even a busy merchant's summary stays * under ~2KB. */ export function summarizeTransactions(rows: readonly TransactionRow[]): TransactionSummary { const total_amount_by_currency: Record = {}; const byStatus = new Map }>(); const byCurrency = new Map(); const byPaymentSystem = new Map }>(); const byDay = new Map }>(); let approved = 0; let declined = 0; let refunded = 0; for (const row of rows) { const status = String(row.transactionStatus ?? "Unknown"); const currency = String(row.currency ?? "Unknown"); const paymentSystem = String(row.paymentSystem ?? "Unknown"); const amount = toNumber(row.amount) ?? 0; const day = extractDay(row.createdDate ?? row.processingDate); total_amount_by_currency[currency] = (total_amount_by_currency[currency] ?? 0) + amount; bumpGroup(byStatus, status, currency, amount); bumpGroup(byPaymentSystem, paymentSystem, currency, amount); if (day) bumpGroup(byDay, day, currency, amount); const cur = byCurrency.get(currency) ?? { count: 0, total_amount: 0 }; cur.count += 1; cur.total_amount += amount; byCurrency.set(currency, cur); const lower = status.toLowerCase(); if (lower === "approved") approved += 1; else if (lower === "declined" || lower === "expired") declined += 1; else if (lower === "refunded") refunded += 1; } return { total_count: rows.length, total_amount_by_currency: roundMap(total_amount_by_currency), approved_count: approved, declined_count: declined, refunded_count: refunded, by_status: groupToRows(byStatus).sort((a, b) => b.count - a.count), by_currency: Array.from(byCurrency, ([key, v]) => ({ key, count: v.count, total_amount: round2(v.total_amount), })).sort((a, b) => b.count - a.count), by_payment_system: groupToRows(byPaymentSystem).sort((a, b) => b.count - a.count), by_day: Array.from(byDay, ([key, v]) => ({ date: key, count: v.count, amount_by_currency: roundMap(v.amount_by_currency), })).sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0)), }; } // -------------------------------------------------------------------------- // Internals // -------------------------------------------------------------------------- function bumpGroup( map: Map }>, key: string, currency: string, amount: number, ): void { const entry = map.get(key) ?? { count: 0, amount_by_currency: {} }; entry.count += 1; entry.amount_by_currency[currency] = (entry.amount_by_currency[currency] ?? 0) + amount; map.set(key, entry); } function groupToRows( map: Map }>, ): Array<{ key: string; count: number; amount_by_currency: Record }> { return Array.from(map, ([key, v]) => ({ key, count: v.count, amount_by_currency: roundMap(v.amount_by_currency), })); } function roundMap(rec: Record): Record { const out: Record = {}; for (const [k, v] of Object.entries(rec)) out[k] = round2(v); return out; } function round2(n: number): number { return Math.round(n * 100) / 100; } function toNumber(value: unknown): number | null { if (typeof value === "number") return Number.isFinite(value) ? value : null; if (typeof value === "string" && value !== "") { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } return null; } function ciEquals(a: unknown, b: string): boolean { if (a === undefined || a === null) return false; return String(a).toLowerCase() === b.toLowerCase(); } function ciContains(haystack: unknown, needle: string): boolean { if (haystack === undefined || haystack === null) return false; return String(haystack).toLowerCase().includes(needle.toLowerCase()); } /** * Extract a YYYY-MM-DD day key from whatever `createdDate` shape WFP returned. * Handles unix timestamps (seconds or millis), ISO strings, and "DD.MM.YYYY * HH:MM:SS" — the formats observed across WFP responses. Returns null if none * recognized so by_day silently skips malformed rows instead of misbucketing. */ function extractDay(value: unknown): string | null { if (value === undefined || value === null || value === "") return null; if (typeof value === "number") { const ms = value > 1e12 ? value : value * 1000; const d = new Date(ms); return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10); } const text = String(value).trim(); const ddmmyyyy = /^(\d{2})\.(\d{2})\.(\d{4})/.exec(text); if (ddmmyyyy) return `${ddmmyyyy[3]}-${ddmmyyyy[2]}-${ddmmyyyy[1]}`; const iso = /^(\d{4}-\d{2}-\d{2})/.exec(text); if (iso) return iso[1] ?? null; const numeric = Number(text); if (Number.isFinite(numeric)) { const ms = numeric > 1e12 ? numeric : numeric * 1000; const d = new Date(ms); return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10); } return null; }