import type { BudgetVsActualRow } from "../../shared/types"; export type SectionType = "expense" | "income" | "transfer"; /** Aggregate of the 6 budget-vs-actual figures across a set of leaf rows. */ export interface Totals { monthActual: number; monthBudget: number; monthVariation: number; ytdActual: number; ytdBudget: number; ytdVariation: number; } const ZERO: Totals = { monthActual: 0, monthBudget: 0, monthVariation: 0, ytdActual: 0, ytdBudget: 0, ytdVariation: 0, }; /** Sum every non-subtotal (leaf) row into a single Totals. */ export function sumLeaves(rows: BudgetVsActualRow[]): Totals { return rows .filter((r) => !r.is_parent) .reduce( (acc, r) => ({ monthActual: acc.monthActual + r.monthActual, monthBudget: acc.monthBudget + r.monthBudget, monthVariation: acc.monthVariation + r.monthVariation, ytdActual: acc.ytdActual + r.ytdActual, ytdBudget: acc.ytdBudget + r.ytdBudget, ytdVariation: acc.ytdVariation + r.ytdVariation, }), { ...ZERO }, ); } /** Variation as a fraction of budget (null when budget is 0 — mirrors the * row-level `*VariationPct` fields computed in `getBudgetVsActualData`). */ export function pct(variation: number, budget: number): number | null { return budget !== 0 ? variation / Math.abs(budget) : null; } /** Component-wise a + b across the 6 figures. */ function add(a: Totals, b: Totals): Totals { return { monthActual: a.monthActual + b.monthActual, monthBudget: a.monthBudget + b.monthBudget, monthVariation: a.monthVariation + b.monthVariation, ytdActual: a.ytdActual + b.ytdActual, ytdBudget: a.ytdBudget + b.ytdBudget, ytdVariation: a.ytdVariation + b.ytdVariation, }; } export interface BudgetVsActualResults { income: Totals; expense: Totals; transfer: Totals; /** Income + expense: the operating result, before transfers. */ resultBefore: Totals; /** resultBefore + transfers: the bottom-line total. */ resultNet: Totals; hasTransfers: boolean; } /** * Income-statement roll-up for the budget-vs-actual report (Issue #277). * * Mirrors `compareResults.computeResults`, with one key sign difference: BVA * amounts already arrive signed the "accounting" way rather than ABS'd to * positive magnitudes. `getBudgetVsActualData` computes `monthActual`/ * `ytdActual` as the raw (unsigned-by-us) `SUM(transactions.amount)` — negative * for expense categories, since expense transactions are stored as negative * amounts throughout this app (see reportService/dashboardService `amount < 0` * conventions) — and negates the user-entered (positive) budget figure to * match via `signFor`. So expense totals here are already ≤ 0, and the * operating result is a straight ADD of income + expense (not a subtraction * like the real-vs-real compare, whose expense figures are ABS'd positive * magnitudes). The net just adds the transfer total on top. */ export function computeResults(rows: BudgetVsActualRow[]): BudgetVsActualResults { const byType = (type: SectionType): Totals => sumLeaves(rows.filter((r) => r.category_type === type)); const income = byType("income"); const expense = byType("expense"); const transfer = byType("transfer"); const resultBefore = add(income, expense); const resultNet = add(resultBefore, transfer); const hasTransfers = rows.some((r) => r.category_type === "transfer" && !r.is_parent); return { income, expense, transfer, resultBefore, resultNet, hasTransfers }; }