feat(reports): BVA (actual vs budget) income-first + result lines
Align BudgetVsActualTable on the income-statement gold standard already shipped for the real-vs-real compare report (#253/#256): flip TYPE_ORDER to income-first (revenue -> expense -> transfer) and replace the flat "Total" row with interleaved Result before transfers / Net result lines. Roll-up logic extracted into a new pure, tested module (budgetVsActualResults.ts) mirroring compareResults.ts, with one sign difference: BVA amounts already arrive signed the accounting way (expense actual/budget are negative), so the operating result is a straight add of income + expense rather than a subtraction. Resolves #277 Generated autonomously by /autopilot run of 2026-07-11
This commit is contained in:
parent
26896bbf03
commit
14755eb155
6 changed files with 356 additions and 163 deletions
|
|
@ -19,6 +19,7 @@
|
||||||
### Modifié
|
### 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 → 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).
|
||||||
|
|
||||||
## [0.12.0] - 2026-07-05
|
## [0.12.0] - 2026-07-05
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
### Changed
|
### 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 → 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).
|
||||||
|
|
||||||
## [0.12.0] - 2026-07-05
|
## [0.12.0] - 2026-07-05
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type { BudgetVsActualRow } from "../../shared/types";
|
||||||
import { reorderRows } from "../../utils/reorderRows";
|
import { reorderRows } from "../../utils/reorderRows";
|
||||||
import type { CollapseAccessors } from "../../utils/collapsibleRows";
|
import type { CollapseAccessors } from "../../utils/collapsibleRows";
|
||||||
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
|
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
|
||||||
|
import { type SectionType, type Totals, sumLeaves, pct, computeResults } from "./budgetVsActualResults";
|
||||||
|
|
||||||
const cadFormatter = (value: number) =>
|
const cadFormatter = (value: number) =>
|
||||||
new Intl.NumberFormat("en-CA", {
|
new Intl.NumberFormat("en-CA", {
|
||||||
|
|
@ -68,8 +69,8 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group rows by type for section headers
|
// Group rows into contiguous type sections (the service already type-sorts:
|
||||||
type SectionType = "expense" | "income" | "transfer";
|
// income → expense → transfer, Issue #277).
|
||||||
const sections: { type: SectionType; label: string; rows: BudgetVsActualRow[] }[] = [];
|
const sections: { type: SectionType; label: string; rows: BudgetVsActualRow[] }[] = [];
|
||||||
const typeLabels: Record<SectionType, string> = {
|
const typeLabels: Record<SectionType, string> = {
|
||||||
expense: t("budget.expenses"),
|
expense: t("budget.expenses"),
|
||||||
|
|
@ -91,103 +92,18 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
sections[sections.length - 1].rows.push(row);
|
sections[sections.length - 1].rows.push(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grand totals (leaf rows only)
|
// Income-statement result lines (Issue #277): income + expense (expense
|
||||||
const leaves = data.filter((r) => !r.is_parent);
|
// already arrives signed negative in BudgetVsActualRow — see
|
||||||
const totals = leaves.reduce(
|
// budgetVsActualResults.ts), then the net after adding transfers. Replaces
|
||||||
(acc, r) => ({
|
// the old flat grand total row.
|
||||||
monthActual: acc.monthActual + r.monthActual,
|
const results = computeResults(data);
|
||||||
monthBudget: acc.monthBudget + r.monthBudget,
|
const nonTransferSections = sections.filter((s) => s.type !== "transfer");
|
||||||
monthVariation: acc.monthVariation + r.monthVariation,
|
const transferSection = sections.find((s) => s.type === "transfer");
|
||||||
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;
|
|
||||||
|
|
||||||
const hasGroups = groups.groupCount(data) > 0;
|
const renderSection = (section: { type: SectionType; label: string; rows: BudgetVsActualRow[] }) => {
|
||||||
const allExpanded = groups.allExpanded(data);
|
const sectionTotals = sumLeaves(section.rows);
|
||||||
|
const sectionMonthPct = pct(sectionTotals.monthVariation, sectionTotals.monthBudget);
|
||||||
return (
|
const sectionYtdPct = pct(sectionTotals.ytdVariation, sectionTotals.ytdBudget);
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
|
||||||
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
|
|
||||||
{hasGroups && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => (allExpanded ? groups.collapseAll() : groups.expandAll(data))}
|
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
|
||||||
>
|
|
||||||
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
|
|
||||||
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={toggleSubtotals}
|
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
|
||||||
>
|
|
||||||
<ArrowUpDown size={13} />
|
|
||||||
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="sticky top-0 z-20">
|
|
||||||
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
|
||||||
<th rowSpan={2} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] align-bottom sticky left-0 bg-[var(--card)] z-30 min-w-[180px]">
|
|
||||||
{t("budget.category")}
|
|
||||||
</th>
|
|
||||||
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
|
||||||
{t("reports.bva.monthly")}
|
|
||||||
</th>
|
|
||||||
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
|
||||||
{t("reports.bva.ytd")}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
|
||||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
|
||||||
{t("budget.actual")}
|
|
||||||
</th>
|
|
||||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
|
||||||
{t("budget.planned")}
|
|
||||||
</th>
|
|
||||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
|
||||||
{t("reports.bva.dollarVar")}
|
|
||||||
</th>
|
|
||||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
|
||||||
{t("reports.bva.pctVar")}
|
|
||||||
</th>
|
|
||||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
|
||||||
{t("budget.actual")}
|
|
||||||
</th>
|
|
||||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
|
||||||
{t("budget.planned")}
|
|
||||||
</th>
|
|
||||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
|
||||||
{t("reports.bva.dollarVar")}
|
|
||||||
</th>
|
|
||||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
|
||||||
{t("reports.bva.pctVar")}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{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 (
|
return (
|
||||||
<Fragment key={section.type}>
|
<Fragment key={section.type}>
|
||||||
<tr className="bg-[var(--muted)]">
|
<tr className="bg-[var(--muted)]">
|
||||||
|
|
@ -293,31 +209,118 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
</tr>
|
</tr>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
};
|
||||||
{/* Grand totals */}
|
|
||||||
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]">
|
// A result line (before-transfers subtotal or the net total). Every cell is
|
||||||
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">{t("common.total")}</td>
|
// coloured by its own sign — a surplus is green, a deficit red — mirroring
|
||||||
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
|
// ComparePeriodTable.renderResultRow (unlike normal rows, which only colour
|
||||||
{cadFormatter(totals.monthActual)}
|
// 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 (
|
||||||
|
<tr key={labelKey} className={rowClass}>
|
||||||
|
<td className={`px-3 py-3 sticky left-0 z-10 ${rowBg}`}>{t(labelKey)}</td>
|
||||||
|
<td className={`${cell} border-l border-[var(--border)]/50 ${variationColor(tot.monthActual)}`}>
|
||||||
|
{cadFormatter(tot.monthActual)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-right px-3 py-3">{cadFormatter(totals.monthBudget)}</td>
|
<td className={`${cell} ${variationColor(tot.monthBudget)}`}>{cadFormatter(tot.monthBudget)}</td>
|
||||||
<td className={`text-right px-3 py-3 ${variationColor(totals.monthVariation)}`}>
|
<td className={`${cell} ${variationColor(tot.monthVariation)}`}>
|
||||||
{cadFormatter(totals.monthVariation)}
|
{cadFormatter(tot.monthVariation)}
|
||||||
</td>
|
</td>
|
||||||
<td className={`text-right px-3 py-3 ${variationColor(totals.monthVariation)}`}>
|
<td className={`${cell} ${variationColor(tot.monthVariation)}`}>{pctFormatter(monthPct)}</td>
|
||||||
{pctFormatter(totalMonthPct)}
|
<td className={`${cell} border-l border-[var(--border)]/50 ${variationColor(tot.ytdActual)}`}>
|
||||||
</td>
|
{cadFormatter(tot.ytdActual)}
|
||||||
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
|
|
||||||
{cadFormatter(totals.ytdActual)}
|
|
||||||
</td>
|
|
||||||
<td className="text-right px-3 py-3">{cadFormatter(totals.ytdBudget)}</td>
|
|
||||||
<td className={`text-right px-3 py-3 ${variationColor(totals.ytdVariation)}`}>
|
|
||||||
{cadFormatter(totals.ytdVariation)}
|
|
||||||
</td>
|
|
||||||
<td className={`text-right px-3 py-3 ${variationColor(totals.ytdVariation)}`}>
|
|
||||||
{pctFormatter(totalYtdPct)}
|
|
||||||
</td>
|
</td>
|
||||||
|
<td className={`${cell} ${variationColor(tot.ytdBudget)}`}>{cadFormatter(tot.ytdBudget)}</td>
|
||||||
|
<td className={`${cell} ${variationColor(tot.ytdVariation)}`}>{cadFormatter(tot.ytdVariation)}</td>
|
||||||
|
<td className={`${cell} ${variationColor(tot.ytdVariation)}`}>{pctFormatter(ytdPct)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasGroups = groups.groupCount(data) > 0;
|
||||||
|
const allExpanded = groups.allExpanded(data);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||||
|
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
|
||||||
|
{hasGroups && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => (allExpanded ? groups.collapseAll() : groups.expandAll(data))}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||||
|
>
|
||||||
|
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
|
||||||
|
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={toggleSubtotals}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowUpDown size={13} />
|
||||||
|
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="sticky top-0 z-20">
|
||||||
|
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
||||||
|
<th rowSpan={2} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] align-bottom sticky left-0 bg-[var(--card)] z-30 min-w-[180px]">
|
||||||
|
{t("budget.category")}
|
||||||
|
</th>
|
||||||
|
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
||||||
|
{t("reports.bva.monthly")}
|
||||||
|
</th>
|
||||||
|
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
||||||
|
{t("reports.bva.ytd")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
||||||
|
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
||||||
|
{t("budget.actual")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||||
|
{t("budget.planned")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||||
|
{t("reports.bva.dollarVar")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||||
|
{t("reports.bva.pctVar")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
||||||
|
{t("budget.actual")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||||
|
{t("budget.planned")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||||
|
{t("reports.bva.dollarVar")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||||
|
{t("reports.bva.pctVar")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{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)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
91
src/components/reports/budgetVsActualResults.test.ts
Normal file
91
src/components/reports/budgetVsActualResults.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
95
src/components/reports/budgetVsActualResults.ts
Normal file
95
src/components/reports/budgetVsActualResults.ts
Normal file
|
|
@ -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<Totals>(
|
||||||
|
(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 };
|
||||||
|
}
|
||||||
|
|
@ -211,7 +211,9 @@ async function getActualsByCategoryRange(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_ORDER: Record<string, number> = { 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<string, number> = { income: 0, expense: 1, transfer: 2 };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Budget vs actual, by category, for the reference month plus YTD. `accountIds`
|
* Budget vs actual, by category, for the reference month plus YTD. `accountIds`
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue