diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 757e422..7059994 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -19,6 +19,7 @@ ### Modifié - Rapports → Tendances : le rapport **s'ouvre désormais sur la vue « Par catégorie » en tableau** par défaut, au lieu du graphique mensuel global. Le tableau par catégorie comporte une section revenus, donc une catégorie de revenu (ex. votre paie) apparaît d'emblée — sans changer de vue au préalable. La vue que vous choisissez vous-même reste mémorisée (#262). +- Rapports → Comparaison (réel vs budget) : le tableau budget-vs-réel se lit désormais comme les autres rapports comparables — une **analyse de résultat**. Les catégories sont regroupées Revenus → Dépenses → Transferts (au lieu de Dépenses → Revenus → Transferts), et l'ancienne ligne « Total » plate est remplacée par une ligne **Résultat avant transferts** (affichée lorsque des transferts existent) et une ligne **Résultat net**, toutes deux colorées en vert pour un surplus, en rouge pour un déficit. Les montants de catégorie et de section sont inchangés (#277). - Budget : la grille budget se lit désormais comme une analyse de résultat — les catégories sont regroupées **Revenus → Dépenses → Transferts** (auparavant dépenses en premier), et l'ancienne ligne « Total » brute est remplacée par deux lignes de résultat : **Résultat avant transferts** (revenus − dépenses) et **Résultat net** (après transferts), calculées sur les colonnes année précédente, annuelle et mensuelles. Toutes les catégories restent affichées sur la grille — elle demeure une surface d'édition, sans ligne masquée ni repliable (#278). ## [0.12.0] - 2026-07-05 diff --git a/CHANGELOG.md b/CHANGELOG.md index 680115b..ebfd844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ ### Changed - Reports → Trends: the report now **opens on the "By category" table view** by default, instead of the global monthly chart. The by-category table has an income section, so a revenue category (e.g. your pay) shows up straight away — no need to switch views first. Whatever view you pick yourself is still remembered (#262). +- Reports → Compare (actual vs budget): the budget-vs-actual table now reads the same way as the other comparable reports — an **income statement**. Categories are grouped Income → Expenses → Transfers (instead of Expenses → Income → Transfers), and the old flat "Total" row is replaced by a **Result before transfers** line (shown when transfers exist) and a **Net result** line, both coloured green for a surplus and red for a deficit. Leaf and section figures are unchanged (#277). - Budget: the budget grid now reads as an income statement — categories are grouped **Income → Expenses → Transfers** (previously expenses first), and the old plain "Total" row is replaced by two result lines: **Result before transfers** (income − expenses) and **Net result** (after transfers), computed across the previous-year, annual, and monthly columns alike. Every category still shows on the grid — it stays an editable surface, with no rows hidden or collapsed (#278). ## [0.12.0] - 2026-07-05 diff --git a/src/components/reports/BudgetVsActualTable.tsx b/src/components/reports/BudgetVsActualTable.tsx index a750374..bd8f1b0 100644 --- a/src/components/reports/BudgetVsActualTable.tsx +++ b/src/components/reports/BudgetVsActualTable.tsx @@ -5,6 +5,7 @@ import type { BudgetVsActualRow } from "../../shared/types"; import { reorderRows } from "../../utils/reorderRows"; import type { CollapseAccessors } from "../../utils/collapsibleRows"; import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups"; +import { type SectionType, type Totals, sumLeaves, pct, computeResults } from "./budgetVsActualResults"; const cadFormatter = (value: number) => new Intl.NumberFormat("en-CA", { @@ -68,8 +69,8 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) ); } - // Group rows by type for section headers - type SectionType = "expense" | "income" | "transfer"; + // Group rows into contiguous type sections (the service already type-sorts: + // income → expense → transfer, Issue #277). const sections: { type: SectionType; label: string; rows: BudgetVsActualRow[] }[] = []; const typeLabels: Record = { expense: t("budget.expenses"), @@ -91,21 +92,159 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) sections[sections.length - 1].rows.push(row); } - // Grand totals (leaf rows only) - const leaves = data.filter((r) => !r.is_parent); - const totals = leaves.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, - }), - { monthActual: 0, monthBudget: 0, monthVariation: 0, ytdActual: 0, ytdBudget: 0, ytdVariation: 0 } - ); - const totalMonthPct = totals.monthBudget !== 0 ? totals.monthVariation / Math.abs(totals.monthBudget) : null; - const totalYtdPct = totals.ytdBudget !== 0 ? totals.ytdVariation / Math.abs(totals.ytdBudget) : null; + // Income-statement result lines (Issue #277): income + expense (expense + // already arrives signed negative in BudgetVsActualRow — see + // budgetVsActualResults.ts), then the net after adding transfers. Replaces + // the old flat grand total row. + const results = computeResults(data); + const nonTransferSections = sections.filter((s) => s.type !== "transfer"); + const transferSection = sections.find((s) => s.type === "transfer"); + + const renderSection = (section: { type: SectionType; label: string; rows: BudgetVsActualRow[] }) => { + const sectionTotals = sumLeaves(section.rows); + const sectionMonthPct = pct(sectionTotals.monthVariation, sectionTotals.monthBudget); + const sectionYtdPct = pct(sectionTotals.ytdVariation, sectionTotals.ytdBudget); + return ( + + + + {section.label} + + + {reorderRows(groups.visible(section.rows), subtotalsOnTop).map((row) => { + const isParent = row.is_parent; + const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0); + const isTopParent = isParent && depth === 0; + const isIntermediateParent = isParent && depth >= 1; + const collapsed = isTopParent && groups.isCollapsed(row); + const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3"; + return ( + + + {isTopParent ? ( + + ) : ( + + + {row.category_name} + + )} + + + {cadFormatter(row.monthActual)} + + {cadFormatter(row.monthBudget)} + + {cadFormatter(row.monthVariation)} + + + {pctFormatter(row.monthVariationPct)} + + + {cadFormatter(row.ytdActual)} + + {cadFormatter(row.ytdBudget)} + + {cadFormatter(row.ytdVariation)} + + + {pctFormatter(row.ytdVariationPct)} + + + ); + })} + + {t(typeTotalKeys[section.type])} + + {cadFormatter(sectionTotals.monthActual)} + + {cadFormatter(sectionTotals.monthBudget)} + + {cadFormatter(sectionTotals.monthVariation)} + + + {pctFormatter(sectionMonthPct)} + + + {cadFormatter(sectionTotals.ytdActual)} + + {cadFormatter(sectionTotals.ytdBudget)} + + {cadFormatter(sectionTotals.ytdVariation)} + + + {pctFormatter(sectionYtdPct)} + + + + ); + }; + + // A result line (before-transfers subtotal or the net total). Every cell is + // coloured by its own sign — a surplus is green, a deficit red — mirroring + // ComparePeriodTable.renderResultRow (unlike normal rows, which only colour + // the variation/pct columns). + const renderResultRow = (labelKey: string, tot: Totals, strong: boolean) => { + const rowBg = strong + ? "bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]" + : "bg-[color-mix(in_srgb,var(--muted)_10%,var(--card))]"; + const rowClass = strong + ? `border-t-2 border-[var(--border)] font-bold text-sm ${rowBg}` + : `border-b border-[var(--border)] font-semibold text-sm ${rowBg}`; + const cell = "text-right px-3 py-3"; + const monthPct = pct(tot.monthVariation, tot.monthBudget); + const ytdPct = pct(tot.ytdVariation, tot.ytdBudget); + return ( + + {t(labelKey)} + + {cadFormatter(tot.monthActual)} + + {cadFormatter(tot.monthBudget)} + + {cadFormatter(tot.monthVariation)} + + {pctFormatter(monthPct)} + + {cadFormatter(tot.ytdActual)} + + {cadFormatter(tot.ytdBudget)} + {cadFormatter(tot.ytdVariation)} + {pctFormatter(ytdPct)} + + ); + }; const hasGroups = groups.groupCount(data) > 0; const allExpanded = groups.allExpanded(data); @@ -173,151 +312,15 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) - {sections.map((section) => { - const sectionLeaves = section.rows.filter((r) => !r.is_parent); - const sectionTotals = sectionLeaves.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, - }), - { monthActual: 0, monthBudget: 0, monthVariation: 0, ytdActual: 0, ytdBudget: 0, ytdVariation: 0 } - ); - const sectionMonthPct = sectionTotals.monthBudget !== 0 ? sectionTotals.monthVariation / Math.abs(sectionTotals.monthBudget) : null; - const sectionYtdPct = sectionTotals.ytdBudget !== 0 ? sectionTotals.ytdVariation / Math.abs(sectionTotals.ytdBudget) : null; - return ( - - - - {section.label} - - - {reorderRows(groups.visible(section.rows), subtotalsOnTop).map((row) => { - const isParent = row.is_parent; - const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0); - const isTopParent = isParent && depth === 0; - const isIntermediateParent = isParent && depth >= 1; - const collapsed = isTopParent && groups.isCollapsed(row); - const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3"; - return ( - - - {isTopParent ? ( - - ) : ( - - - {row.category_name} - - )} - - - {cadFormatter(row.monthActual)} - - {cadFormatter(row.monthBudget)} - - {cadFormatter(row.monthVariation)} - - - {pctFormatter(row.monthVariationPct)} - - - {cadFormatter(row.ytdActual)} - - {cadFormatter(row.ytdBudget)} - - {cadFormatter(row.ytdVariation)} - - - {pctFormatter(row.ytdVariationPct)} - - - ); - })} - - {t(typeTotalKeys[section.type])} - - {cadFormatter(sectionTotals.monthActual)} - - {cadFormatter(sectionTotals.monthBudget)} - - {cadFormatter(sectionTotals.monthVariation)} - - - {pctFormatter(sectionMonthPct)} - - - {cadFormatter(sectionTotals.ytdActual)} - - {cadFormatter(sectionTotals.ytdBudget)} - - {cadFormatter(sectionTotals.ytdVariation)} - - - {pctFormatter(sectionYtdPct)} - - - - ); - })} - {/* Grand totals */} - - {t("common.total")} - - {cadFormatter(totals.monthActual)} - - {cadFormatter(totals.monthBudget)} - - {cadFormatter(totals.monthVariation)} - - - {pctFormatter(totalMonthPct)} - - - {cadFormatter(totals.ytdActual)} - - {cadFormatter(totals.ytdBudget)} - - {cadFormatter(totals.ytdVariation)} - - - {pctFormatter(totalYtdPct)} - - + {nonTransferSections.map(renderSection)} + {/* Operating result (income + expense), shown before the transfers + section only when transfers exist — otherwise it equals the net + total below and would just be noise (mirrors ComparePeriodTable). */} + {results.hasTransfers && + renderResultRow("reports.compare.resultBeforeTransfers", results.resultBefore, false)} + {transferSection && renderSection(transferSection)} + {/* Bottom line: result after adding transfers. */} + {renderResultRow("reports.compare.resultNet", results.resultNet, true)} diff --git a/src/components/reports/budgetVsActualResults.test.ts b/src/components/reports/budgetVsActualResults.test.ts new file mode 100644 index 0000000..76e7905 --- /dev/null +++ b/src/components/reports/budgetVsActualResults.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from "vitest"; +import type { BudgetVsActualRow } from "../../shared/types"; +import { computeResults, sumLeaves } from "./budgetVsActualResults"; + +// Minimal leaf factory. Amounts arrive from getBudgetVsActualData already in +// the per-type sign convention: expense actual/budget are negative (raw, +// unsigned SUM(transactions.amount) — expenses are stored negative — and the +// user-entered budget negated to match via signFor); income/transfer stay +// however they naturally sum (positive for income, signed net for transfer). +function leaf( + type: "expense" | "income" | "transfer", + monthActual: number, + monthBudget: number, + ytdActual = monthActual, + ytdBudget = monthBudget, +): BudgetVsActualRow { + return { + category_id: 1, + category_name: type, + category_color: "#000", + category_type: type, + parent_id: null, + is_parent: false, + monthActual, + monthBudget, + monthVariation: monthActual - monthBudget, + monthVariationPct: monthBudget !== 0 ? (monthActual - monthBudget) / Math.abs(monthBudget) : null, + ytdActual, + ytdBudget, + ytdVariation: ytdActual - ytdBudget, + ytdVariationPct: ytdBudget !== 0 ? (ytdActual - ytdBudget) / Math.abs(ytdBudget) : null, + }; +} + +describe("computeResults — income-statement roll-up (Issue #277)", () => { + it("resultBefore = income + expense (expense already signed negative)", () => { + const r = computeResults([ + leaf("income", 3000, 2800), + leaf("expense", -1800, -2000), // spent 1800, budgeted to spend 2000 + leaf("transfer", 0, 0), + ]); + // Operating result: 3000 + (-1800) = 1200 actual; 2800 + (-2000) = 800 budgeted. + expect(r.resultBefore.monthActual).toBe(1200); + expect(r.resultBefore.monthBudget).toBe(800); + expect(r.resultBefore.monthVariation).toBe(400); + // Balanced transfer (0) → net equals the operating result. + expect(r.resultNet.monthActual).toBe(1200); + expect(r.hasTransfers).toBe(true); + }); + + it("a non-zero transfer moves resultNet away from resultBefore", () => { + const r = computeResults([ + leaf("income", 4000, 4000), + leaf("expense", -3000, -3000), + leaf("transfer", -200, 0), // a single categorised leg + ]); + expect(r.resultBefore.monthActual).toBe(1000); + // Net folds the residual transfer in: 1000 + (−200) = 800. + expect(r.resultNet.monthActual).toBe(800); + }); + + it("a deficit yields a negative net result and no transfer line", () => { + const r = computeResults([leaf("income", 2000, 2000), leaf("expense", -2500, -2400)]); + expect(r.resultNet.monthActual).toBe(-500); + expect(r.hasTransfers).toBe(false); + }); + + it("ignores subtotal (is_parent) rows so a group is not double-counted", () => { + const parent = { ...leaf("expense", -999, -999), is_parent: true }; + const r = computeResults([parent, leaf("expense", -100, -80)]); + expect(r.expense.monthActual).toBe(-100); + }); + + it("carries the YTD figures through the same arithmetic", () => { + const r = computeResults([ + leaf("income", 100, 100, 1200, 1150), + leaf("expense", -60, -60, -700, -650), + ]); + expect(r.resultBefore.ytdActual).toBe(500); + expect(r.resultBefore.ytdBudget).toBe(500); + }); + + it("sumLeaves adds only leaves", () => { + const t = sumLeaves([ + leaf("expense", -100, -50), + { ...leaf("expense", -999, -999), is_parent: true }, + ]); + expect(t.monthActual).toBe(-100); + expect(t.monthBudget).toBe(-50); + }); +}); diff --git a/src/components/reports/budgetVsActualResults.ts b/src/components/reports/budgetVsActualResults.ts new file mode 100644 index 0000000..9a009d9 --- /dev/null +++ b/src/components/reports/budgetVsActualResults.ts @@ -0,0 +1,95 @@ +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 }; +} diff --git a/src/services/budgetService.ts b/src/services/budgetService.ts index ba1520b..13d9f41 100644 --- a/src/services/budgetService.ts +++ b/src/services/budgetService.ts @@ -211,7 +211,9 @@ async function getActualsByCategoryRange( ); } -const TYPE_ORDER: Record = { expense: 0, income: 1, transfer: 2 }; +// Income-statement reading order: revenue first, then expenses, then +// transfers (Issue #277 — matches COMPARE_TYPE_ORDER / OVER_TIME_TYPE_ORDER). +const TYPE_ORDER: Record = { income: 0, expense: 1, transfer: 2 }; /** * Budget vs actual, by category, for the reference month plus YTD. `accountIds`