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é
|
||||
|
||||
- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
## [0.12.0] - 2026-07-05
|
||||
|
||||
|
|
|
|||
|
|
@ -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<SectionType, string> = {
|
||||
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 (
|
||||
<Fragment key={section.type}>
|
||||
<tr className="bg-[var(--muted)]">
|
||||
<td colSpan={9} className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]">
|
||||
{section.label}
|
||||
</td>
|
||||
</tr>
|
||||
{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 (
|
||||
<tr
|
||||
key={`${row.category_id}-${row.is_parent}-${depth}`}
|
||||
className={`border-b border-[var(--border)]/50 ${
|
||||
isTopParent ? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold" :
|
||||
isIntermediateParent ? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium" : ""
|
||||
}`}
|
||||
>
|
||||
<td className={`py-1.5 sticky left-0 z-10 ${
|
||||
isTopParent
|
||||
? "px-3 bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))]"
|
||||
: isIntermediateParent
|
||||
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
|
||||
: `${paddingClass} bg-[var(--card)]`
|
||||
}`}>
|
||||
{isTopParent ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => groups.toggle(row)}
|
||||
aria-expanded={!collapsed}
|
||||
className="flex items-center gap-2 w-full text-left hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight size={14} className="shrink-0 text-[var(--muted-foreground)]" />
|
||||
) : (
|
||||
<ChevronDown size={14} className="shrink-0 text-[var(--muted-foreground)]" />
|
||||
)}
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: row.category_color }}
|
||||
/>
|
||||
{row.category_name}
|
||||
</button>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: row.category_color }}
|
||||
/>
|
||||
{row.category_name}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className={`text-right px-3 py-1.5 border-l border-[var(--border)]/50`}>
|
||||
{cadFormatter(row.monthActual)}
|
||||
</td>
|
||||
<td className="text-right px-3 py-1.5">{cadFormatter(row.monthBudget)}</td>
|
||||
<td className={`text-right px-3 py-1.5 ${variationColor(row.monthVariation)}`}>
|
||||
{cadFormatter(row.monthVariation)}
|
||||
</td>
|
||||
<td className={`text-right px-3 py-1.5 ${variationColor(row.monthVariation)}`}>
|
||||
{pctFormatter(row.monthVariationPct)}
|
||||
</td>
|
||||
<td className={`text-right px-3 py-1.5 border-l border-[var(--border)]/50`}>
|
||||
{cadFormatter(row.ytdActual)}
|
||||
</td>
|
||||
<td className="text-right px-3 py-1.5">{cadFormatter(row.ytdBudget)}</td>
|
||||
<td className={`text-right px-3 py-1.5 ${variationColor(row.ytdVariation)}`}>
|
||||
{cadFormatter(row.ytdVariation)}
|
||||
</td>
|
||||
<td className={`text-right px-3 py-1.5 ${variationColor(row.ytdVariation)}`}>
|
||||
{pctFormatter(row.ytdVariationPct)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold text-sm">
|
||||
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">{t(typeTotalKeys[section.type])}</td>
|
||||
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
|
||||
{cadFormatter(sectionTotals.monthActual)}
|
||||
</td>
|
||||
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.monthBudget)}</td>
|
||||
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
|
||||
{cadFormatter(sectionTotals.monthVariation)}
|
||||
</td>
|
||||
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
|
||||
{pctFormatter(sectionMonthPct)}
|
||||
</td>
|
||||
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
|
||||
{cadFormatter(sectionTotals.ytdActual)}
|
||||
</td>
|
||||
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.ytdBudget)}</td>
|
||||
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
|
||||
{cadFormatter(sectionTotals.ytdVariation)}
|
||||
</td>
|
||||
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
|
||||
{pctFormatter(sectionYtdPct)}
|
||||
</td>
|
||||
</tr>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<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 className={`${cell} ${variationColor(tot.monthBudget)}`}>{cadFormatter(tot.monthBudget)}</td>
|
||||
<td className={`${cell} ${variationColor(tot.monthVariation)}`}>
|
||||
{cadFormatter(tot.monthVariation)}
|
||||
</td>
|
||||
<td className={`${cell} ${variationColor(tot.monthVariation)}`}>{pctFormatter(monthPct)}</td>
|
||||
<td className={`${cell} border-l border-[var(--border)]/50 ${variationColor(tot.ytdActual)}`}>
|
||||
{cadFormatter(tot.ytdActual)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
const hasGroups = groups.groupCount(data) > 0;
|
||||
const allExpanded = groups.allExpanded(data);
|
||||
|
|
@ -173,151 +312,15 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
|||
</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 (
|
||||
<Fragment key={section.type}>
|
||||
<tr className="bg-[var(--muted)]">
|
||||
<td colSpan={9} className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]">
|
||||
{section.label}
|
||||
</td>
|
||||
</tr>
|
||||
{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 (
|
||||
<tr
|
||||
key={`${row.category_id}-${row.is_parent}-${depth}`}
|
||||
className={`border-b border-[var(--border)]/50 ${
|
||||
isTopParent ? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold" :
|
||||
isIntermediateParent ? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium" : ""
|
||||
}`}
|
||||
>
|
||||
<td className={`py-1.5 sticky left-0 z-10 ${
|
||||
isTopParent
|
||||
? "px-3 bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))]"
|
||||
: isIntermediateParent
|
||||
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
|
||||
: `${paddingClass} bg-[var(--card)]`
|
||||
}`}>
|
||||
{isTopParent ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => groups.toggle(row)}
|
||||
aria-expanded={!collapsed}
|
||||
className="flex items-center gap-2 w-full text-left hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight size={14} className="shrink-0 text-[var(--muted-foreground)]" />
|
||||
) : (
|
||||
<ChevronDown size={14} className="shrink-0 text-[var(--muted-foreground)]" />
|
||||
)}
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: row.category_color }}
|
||||
/>
|
||||
{row.category_name}
|
||||
</button>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: row.category_color }}
|
||||
/>
|
||||
{row.category_name}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className={`text-right px-3 py-1.5 border-l border-[var(--border)]/50`}>
|
||||
{cadFormatter(row.monthActual)}
|
||||
</td>
|
||||
<td className="text-right px-3 py-1.5">{cadFormatter(row.monthBudget)}</td>
|
||||
<td className={`text-right px-3 py-1.5 ${variationColor(row.monthVariation)}`}>
|
||||
{cadFormatter(row.monthVariation)}
|
||||
</td>
|
||||
<td className={`text-right px-3 py-1.5 ${variationColor(row.monthVariation)}`}>
|
||||
{pctFormatter(row.monthVariationPct)}
|
||||
</td>
|
||||
<td className={`text-right px-3 py-1.5 border-l border-[var(--border)]/50`}>
|
||||
{cadFormatter(row.ytdActual)}
|
||||
</td>
|
||||
<td className="text-right px-3 py-1.5">{cadFormatter(row.ytdBudget)}</td>
|
||||
<td className={`text-right px-3 py-1.5 ${variationColor(row.ytdVariation)}`}>
|
||||
{cadFormatter(row.ytdVariation)}
|
||||
</td>
|
||||
<td className={`text-right px-3 py-1.5 ${variationColor(row.ytdVariation)}`}>
|
||||
{pctFormatter(row.ytdVariationPct)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold text-sm">
|
||||
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">{t(typeTotalKeys[section.type])}</td>
|
||||
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
|
||||
{cadFormatter(sectionTotals.monthActual)}
|
||||
</td>
|
||||
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.monthBudget)}</td>
|
||||
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
|
||||
{cadFormatter(sectionTotals.monthVariation)}
|
||||
</td>
|
||||
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
|
||||
{pctFormatter(sectionMonthPct)}
|
||||
</td>
|
||||
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
|
||||
{cadFormatter(sectionTotals.ytdActual)}
|
||||
</td>
|
||||
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.ytdBudget)}</td>
|
||||
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
|
||||
{cadFormatter(sectionTotals.ytdVariation)}
|
||||
</td>
|
||||
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
|
||||
{pctFormatter(sectionYtdPct)}
|
||||
</td>
|
||||
</tr>
|
||||
</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))]">
|
||||
<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>
|
||||
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
|
||||
{cadFormatter(totals.monthActual)}
|
||||
</td>
|
||||
<td className="text-right px-3 py-3">{cadFormatter(totals.monthBudget)}</td>
|
||||
<td className={`text-right px-3 py-3 ${variationColor(totals.monthVariation)}`}>
|
||||
{cadFormatter(totals.monthVariation)}
|
||||
</td>
|
||||
<td className={`text-right px-3 py-3 ${variationColor(totals.monthVariation)}`}>
|
||||
{pctFormatter(totalMonthPct)}
|
||||
</td>
|
||||
<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>
|
||||
</tr>
|
||||
{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>
|
||||
</table>
|
||||
</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`
|
||||
|
|
|
|||
Loading…
Reference in a new issue