diff --git a/src/components/budget/BudgetTable.tsx b/src/components/budget/BudgetTable.tsx index aa92e4f..b398c95 100644 --- a/src/components/budget/BudgetTable.tsx +++ b/src/components/budget/BudgetTable.tsx @@ -150,7 +150,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu monthTotals[m] += row.months[m] * sign; } annualTotal += row.annual * sign; - prevYearTotal += row.previousYearTotal * sign; + prevYearTotal += row.previousYearTotal; // actuals are already signed in the DB } const totalCols = 15; // category + prev year + annual + 12 months @@ -197,7 +197,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu - {formatSigned(row.previousYearTotal * sign)} + {formatSigned(row.previousYearTotal)} {formatSigned(row.annual * sign)} @@ -230,7 +230,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu {/* Previous year total — read-only */} - {formatSigned(row.previousYearTotal * sign)} + {formatSigned(row.previousYearTotal)} {/* Annual total — editable */} @@ -340,7 +340,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu sectionMonthTotals[m] += row.months[m] * sign; } sectionAnnualTotal += row.annual * sign; - sectionPrevYearTotal += row.previousYearTotal * sign; + sectionPrevYearTotal += row.previousYearTotal; // actuals are already signed in the DB } return ( diff --git a/src/hooks/useBudget.test.ts b/src/hooks/useBudget.test.ts index baa88af..220b684 100644 --- a/src/hooks/useBudget.test.ts +++ b/src/hooks/useBudget.test.ts @@ -1,35 +1,23 @@ import { describe, it, expect } from "vitest"; +import { buildPrevYearTotalMap } from "./useBudget"; /** * Unit tests for the previous-year actuals normalization logic used in useBudget. * - * Transaction amounts in the database use signed values (expenses are negative). - * Budget entries are always stored as positive values, with sign applied at display time. - * When building the previousYearTotal map from raw actuals, we must normalize with - * Math.abs() to match the budget convention. Without this, expenses would show - * inverted signs (negative × -1 = positive instead of positive × -1 = negative). + * Transaction amounts in the database use signed values (expenses are negative, + * income is positive). The buildPrevYearTotalMap function preserves these signs + * as-is, because the budget display layer does NOT apply a sign multiplier to + * previous year actuals (unlike planned budget amounts). */ -describe("previous year actuals normalization", () => { - // Simulates the normalization logic from useBudget.ts - function buildPrevYearTotalMap( - actuals: Array<{ category_id: number | null; actual: number }> - ): Map { - const prevYearTotalMap = new Map(); - for (const a of actuals) { - if (a.category_id != null) - prevYearTotalMap.set(a.category_id, Math.abs(a.actual)); - } - return prevYearTotalMap; - } - - it("should store absolute values for expense actuals (negative in DB)", () => { +describe("buildPrevYearTotalMap", () => { + it("should preserve negative sign for expense actuals", () => { const actuals = [{ category_id: 1, actual: -500 }]; // expense: negative in DB const map = buildPrevYearTotalMap(actuals); - expect(map.get(1)).toBe(500); + expect(map.get(1)).toBe(-500); }); - it("should store absolute values for income actuals (positive in DB)", () => { + it("should preserve positive sign for income actuals", () => { const actuals = [{ category_id: 2, actual: 3000 }]; // income: positive in DB const map = buildPrevYearTotalMap(actuals); expect(map.get(2)).toBe(3000); @@ -47,16 +35,16 @@ describe("previous year actuals normalization", () => { expect(map.get(3)).toBe(0); }); - it("should display correctly with sign multiplier applied", () => { - // Simulate the display logic: previousYearTotal * sign - const signFor = (type: string) => (type === "expense" ? -1 : 1); - - // Expense category: actual was -500 in DB → normalized to 500 → displayed as 500 * -1 = -500 - const expenseActual = Math.abs(-500); - expect(expenseActual * signFor("expense")).toBe(-500); - - // Income category: actual was 3000 in DB → normalized to 3000 → displayed as 3000 * 1 = 3000 - const incomeActual = Math.abs(3000); - expect(incomeActual * signFor("income")).toBe(3000); + it("should handle multiple categories", () => { + const actuals = [ + { category_id: 1, actual: -200 }, + { category_id: 2, actual: 1500 }, + { category_id: 3, actual: -75.5 }, + ]; + const map = buildPrevYearTotalMap(actuals); + expect(map.size).toBe(3); + expect(map.get(1)).toBe(-200); + expect(map.get(2)).toBe(1500); + expect(map.get(3)).toBe(-75.5); }); }); diff --git a/src/hooks/useBudget.ts b/src/hooks/useBudget.ts index aa04a99..2f41eab 100644 --- a/src/hooks/useBudget.ts +++ b/src/hooks/useBudget.ts @@ -63,6 +63,21 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState { const TYPE_ORDER: Record = { expense: 0, income: 1, transfer: 2 }; +/** + * Build a map of category_id -> annual actual total from raw actuals. + * Transaction amounts are already signed (expenses negative, income positive), + * so they are stored as-is without normalization. + */ +export function buildPrevYearTotalMap( + actuals: Array<{ category_id: number | null; actual: number }> +): Map { + const prevYearTotalMap = new Map(); + for (const a of actuals) { + if (a.category_id != null) prevYearTotalMap.set(a.category_id, a.actual); + } + return prevYearTotalMap; +} + export function useBudget() { const [state, dispatch] = useReducer(reducer, undefined, initialState); const fetchIdRef = useRef(0); @@ -90,12 +105,7 @@ export function useBudget() { } // Build a map for previous year actuals: categoryId -> annual actual total - // Use Math.abs because transaction amounts are stored with sign (expenses negative), - // but budget entries are always positive — the sign is applied at display time. - const prevYearTotalMap = new Map(); - for (const a of prevYearActuals) { - if (a.category_id != null) prevYearTotalMap.set(a.category_id, Math.abs(a.actual)); - } + const prevYearTotalMap = buildPrevYearTotalMap(prevYearActuals); // Helper: build months array from entryMap const buildMonths = (catId: number) => {