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
95 lines
3.5 KiB
TypeScript
95 lines
3.5 KiB
TypeScript
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 };
|
|
}
|