Merge issue-277 (BVA income-first) into main

# Conflicts:
#	CHANGELOG.fr.md
#	CHANGELOG.md
This commit is contained in:
le king fu 2026-07-11 21:21:20 -04:00
commit 6dbaaa25fe
6 changed files with 356 additions and 163 deletions

View file

@ -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

View file

@ -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

View file

@ -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,103 +92,18 @@ 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 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>
{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;
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)]">
@ -293,31 +209,118 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
</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)}
};
// 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="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 className={`${cell} ${variationColor(tot.monthBudget)}`}>{cadFormatter(tot.monthBudget)}</td>
<td className={`${cell} ${variationColor(tot.monthVariation)}`}>
{cadFormatter(tot.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 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);
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>
</table>
</div>

View 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);
});
});

View 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 };
}

View file

@ -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`