// Client-side aggregation for the cash-basis GuV / EÜR BI reports // (pages /bi/profit-loss and /bi/income-surplus, component BI/BankCashReport.vue). // Input = the raw lines of /api/bi/bank-cash-report; every grouping (month, // account, counterpart, transfers between own accounts) happens here so the // filters on the page never need another server round-trip. export interface BankCashLine { id: number date: string // yyyy-mm-dd amount: number // StmtAmt: + income, − expense accountId: number | null statementNo: string statementStatus: string counterpart: string counterpartAccount: string memo: string reference: string matched: 'payment' | 'charge' | null paymentNo: string chargeName: string partnerName: string transfer?: boolean } export interface BankCashAccount { id: number name: string accountNo: string iban: string bankName: string isActive: boolean } export interface MonthBucket { month: number // 1..12 income: number expense: number // positive magnitude net: number count: number } export interface CounterpartRow { name: string total: number // positive magnitude count: number months: number[] // 12 positive magnitudes } const round2 = (v: number) => Math.round(v * 100) / 100 const normalizeAccountNo = (v: any) => String(v || '').toUpperCase().replace(/[\s\-\.]/g, '') const normalizeName = (v: any) => String(v || '').toLowerCase().replace(/[^a-z0-9äöüß]/g, '') export const emptyMonths = (): MonthBucket[] => Array.from({ length: 12 }, (_, i) => ({ month: i + 1, income: 0, expense: 0, net: 0, count: 0 })) /** * Flag lines that only move money between the company's own bank accounts * (PayPal payout → Volksbank, …). Heuristic, no booking data needed. A line is a * transfer when its counterpart is ANOTHER own account that itself has statement * lines in this data set — then the same money shows up twice (out here, in * there). Matching: counterpart IBAN / account no., or the other account's * bank / account name inside the counterpart name ("PayPal (Europe) S.a.r.l."). * Lines whose counterpart is the account's OWN IBAN (bank fees, interest) are * never transfers, and nothing is flagged while only one account has lines. */ export const flagTransfers = (lines: BankCashLine[], accounts: BankCashAccount[]): BankCashLine[] => { const accountsWithLines = new Set(lines.map(l => Number(l.accountId) || 0)) const ownNumbers = new Map() const ownNames: { accountId: number; tokens: string[] }[] = [] for (const a of accounts) { if (!accountsWithLines.has(Number(a.id))) continue for (const v of [a.iban, a.accountNo]) { const n = normalizeAccountNo(v) if (n.length >= 6) ownNumbers.set(n, Number(a.id)) } const tokens = [a.bankName, a.name] .map(normalizeName) .filter(t => t.length >= 4 && !['bank', 'konto', 'account'].includes(t)) ownNames.push({ accountId: Number(a.id), tokens }) } if (accountsWithLines.size < 2) return lines.map(l => ({ ...l, transfer: false })) return lines.map(l => { const own = Number(l.accountId) || 0 const cpNo = normalizeAccountNo(l.counterpartAccount) const byNumber = cpNo.length >= 6 ? ownNumbers.get(cpNo) : undefined let transfer = byNumber !== undefined && byNumber !== own if (!transfer && byNumber === undefined) { const cp = normalizeName(l.counterpart) if (cp) transfer = ownNames.some(o => o.accountId !== own && o.tokens.some(t => cp.includes(t))) } return { ...l, transfer } }) } export interface AggregateOptions { accountId?: number | null // null / undefined = all accounts includeTransfers?: boolean } export interface BankCashAggregate { months: MonthBucket[] totals: MonthBucket byAccount: Record payers: CounterpartRow[] // income by counterpart, desc payees: CounterpartRow[] // expense by counterpart, desc transfers: { count: number; amount: number } lines: BankCashLine[] // the lines that were counted lastDate: string } export const aggregateBankCash = (lines: BankCashLine[], opts: AggregateOptions = {}): BankCashAggregate => { const months = emptyMonths() const byAccount: Record = {} const payerMap: Record = {} const payeeMap: Record = {} const transfers = { count: 0, amount: 0 } const counted: BankCashLine[] = [] let lastDate = '' for (const l of lines) { if (opts.accountId && Number(l.accountId) !== Number(opts.accountId)) continue if (l.transfer) { transfers.count++ transfers.amount = round2(transfers.amount + Math.abs(l.amount)) if (!opts.includeTransfers) continue } const m = Number(String(l.date).slice(5, 7)) if (!(m >= 1 && m <= 12)) continue const amt = Number(l.amount) || 0 if (!amt) continue counted.push(l) if (l.date > lastDate) lastDate = l.date const add = (b: MonthBucket) => { if (amt > 0) b.income = round2(b.income + amt) else b.expense = round2(b.expense - amt) b.net = round2(b.income - b.expense) b.count++ } add(months[m - 1]) const accId = Number(l.accountId) || 0 if (!byAccount[accId]) byAccount[accId] = emptyMonths() add(byAccount[accId][m - 1]) const name = (l.counterpart || l.partnerName || '').trim() || '—' const map = amt > 0 ? payerMap : payeeMap if (!map[name]) map[name] = { name, total: 0, count: 0, months: Array(12).fill(0) } map[name].total = round2(map[name].total + Math.abs(amt)) map[name].count++ map[name].months[m - 1] = round2(map[name].months[m - 1] + Math.abs(amt)) } const totals: MonthBucket = months.reduce((acc, b) => ({ month: 0, income: round2(acc.income + b.income), expense: round2(acc.expense + b.expense), net: round2(acc.net + b.net), count: acc.count + b.count }), { month: 0, income: 0, expense: 0, net: 0, count: 0 }) const sortRows = (map: Record) => Object.values(map).sort((a, b) => b.total - a.total || a.name.localeCompare(b.name)) return { months, totals, byAccount, payers: sortRows(payerMap), payees: sortRows(payeeMap), transfers, lines: counted, lastDate } } /** Top-N rows + one "other" remainder row (null when nothing is folded). */ export const topWithRemainder = (rows: CounterpartRow[], n: number, otherLabel: string): { top: CounterpartRow[]; other: CounterpartRow | null } => { const top = rows.slice(0, n) const rest = rows.slice(n) if (!rest.length) return { top, other: null } const other: CounterpartRow = { name: otherLabel, total: 0, count: 0, months: Array(12).fill(0) } for (const r of rest) { other.total = round2(other.total + r.total) other.count += r.count r.months.forEach((v, i) => { other.months[i] = round2(other.months[i] + v) }) } return { top, other } } export const formatEur = (v: number | null | undefined, opts: { blankZero?: boolean } = {}) => { const n = Number(v) || 0 if (opts.blankZero && n === 0) return '' return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n) } export const useBankCashReport = () => { const cache = useState>('bank_cash_report_cache', () => ({})) const loadYear = async (year: number, force = false) => { if (!force && cache.value[year]) return cache.value[year] const res: any = await $fetch('/api/bi/bank-cash-report', { query: { year }, headers: useRequestHeaders(['cookie']) }) if (!res || res.lines === undefined) { throw new Error(res?.message || `Error ${res?.status || ''}`.trim()) } const accounts: BankCashAccount[] = res.accounts || [] const entry = { accounts, lines: flagTransfers(res.lines || [], accounts), truncated: !!res.truncated, generatedAt: res.generatedAt || '' } cache.value = { ...cache.value, [year]: entry } return entry } return { loadYear } }