diff --git a/src/components/molecules/income-expense-category-table.tsx b/src/components/molecules/income-expense-category-table.tsx index 3796a8d..1ad1e23 100644 --- a/src/components/molecules/income-expense-category-table.tsx +++ b/src/components/molecules/income-expense-category-table.tsx @@ -17,7 +17,7 @@ import { interface IncomeExpenseCategoryTableProps { data: IncomeExpenseCategoryRow[]; - onCategoryClick?: (categoryId: string, isIncome: boolean) => void; + onCategoryClick?: (categoryId: string | null, isIncome: boolean) => void; } export function IncomeExpenseCategoryTable({ data, onCategoryClick }: IncomeExpenseCategoryTableProps) { @@ -42,7 +42,7 @@ function Section({ }: { label: string; rows: IncomeExpenseCategoryRow[]; - onCategoryClick?: (categoryId: string, isIncome: boolean) => void; + onCategoryClick?: (categoryId: string | null, isIncome: boolean) => void; }) { if (rows.length === 0) { return ( @@ -71,7 +71,7 @@ function Section({ {rows.map((row) => ( onCategoryClick?.(row.categoryId, row.isIncome)} > diff --git a/src/components/organisms/report-income-expense.tsx b/src/components/organisms/report-income-expense.tsx index 186095d..74ec2dd 100644 --- a/src/components/organisms/report-income-expense.tsx +++ b/src/components/organisms/report-income-expense.tsx @@ -36,7 +36,7 @@ export function ReportIncomeExpense({ data, categoryData }: ReportIncomeExpenseP { label: "Net", value: totalNet, color: "dynamic", icon: Scale }, ]; - function handleCategoryDrillDown(categoryId: string, isIncome: boolean) { + function handleCategoryDrillDown(categoryId: string | null, isIncome: boolean) { const cat = categoryData?.find((c) => c.categoryId === categoryId); setDrillDown({ categoryId, diff --git a/src/queries/reports.ts b/src/queries/reports.ts index 9f1b561..1b07466 100644 --- a/src/queries/reports.ts +++ b/src/queries/reports.ts @@ -1,4 +1,4 @@ -import { eq, gte, lt, lte, sql, and, inArray, notInArray, isNull, isNotNull } from "drizzle-orm"; +import { eq, gte, lt, lte, sql, and, inArray, notInArray, isNull } from "drizzle-orm"; import { db as defaultDb, type LedgrDb } from "@/db"; import { transactions, @@ -12,7 +12,7 @@ import { scopedQuery } from "@/lib/scoped-query"; import { notDeleted, sumAbs } from "@/lib/query-helpers"; import { getIncomeCategoryIds, notIncome } from "@/queries/shared-conditions"; import { classifyAccountType } from "@/lib/account-utils"; -import { resolvedCategoryLabel } from "@/lib/labels"; +import { resolvedCategoryLabel, UNCATEGORIZED } from "@/lib/labels"; import { aggregateSpending, enrichSpendingMap, @@ -46,7 +46,8 @@ export interface IncomeExpenseRow { export interface CategoryTrendRow { period: string; - categoryId: string; + /** `null` is uncategorized spending, not "no category filter". */ + categoryId: string | null; categoryName: string; total: number; } @@ -103,10 +104,10 @@ export async function getIncomeVsExpense( const incomeCatIds = [...(await getIncomeCategoryIds(householdId, db))]; - // Income sums the raw (signed) amount of income-category txns; expenses sum - // the absolute value of everything else. An uncategorized row (no - // categoryId) falls back to its sign: a positive normalizedAmount (credit) - // counts as income rather than defaulting to expense. + // Income sums the raw (signed) amount of income-category txns. An + // uncategorized row (no categoryId) falls back to its sign: a positive + // normalizedAmount (credit) counts as income rather than defaulting to + // expense. const inIncomeCat = incomeCatIds.length > 0 ? inArray(transactions.categoryId, incomeCatIds) @@ -115,13 +116,18 @@ export async function getIncomeVsExpense( COALESCE(${inIncomeCat}, false) OR (${transactions.categoryId} IS NULL AND ${transactions.normalizedAmount} > 0) )`; + // Expenses use the same rule as the Spending tab (`spendingBaseConditions`): + // only *negative* non-income rows count. Without the sign guard, ABS() turned + // every refund and credit into spending, so this tile disagreed with the + // Spending tab by exactly the sum of the period's credits. + const isSpending = sql`(NOT (${isIncome}) AND ${transactions.normalizedAmount} < 0)`; const monthExpr = sql`substring(${transactions.date}, 1, 7)`; const rows = await db .select({ period: monthExpr, income: sql`COALESCE(SUM(CASE WHEN ${isIncome} THEN ${transactions.normalizedAmount} ELSE 0 END), 0)`.mapWith(Number), - expenses: sql`COALESCE(SUM(CASE WHEN NOT (${isIncome}) THEN ABS(${transactions.normalizedAmount}) ELSE 0 END), 0)`.mapWith(Number), + expenses: sql`COALESCE(SUM(CASE WHEN ${isSpending} THEN ABS(${transactions.normalizedAmount}) ELSE 0 END), 0)`.mapWith(Number), }) .from(transactions) .where(scoped.where(transactions, ...conditions)) @@ -184,9 +190,11 @@ export async function getCategoryTrends( const trendMap = new Map(); // "YYYY-MM|catId" -> total + // Uncategorized rows keyed on an empty id rather than skipped. Dropping them + // made the Trends total read as spending-minus-uncategorized while the tile + // above it was labelled simply "Total Spent". for (const row of nonSplitRows) { - if (!row.categoryId) continue; - const key = `${row.month}|${row.categoryId}`; + const key = `${row.month}|${row.categoryId ?? ""}`; trendMap.set(key, (trendMap.get(key) ?? 0) + row.total); } @@ -218,7 +226,7 @@ export async function getCategoryTrends( } // Resolve category names - const allCatIds = [...new Set([...trendMap.keys()].map((k) => k.split("|")[1]))]; + const allCatIds = [...new Set([...trendMap.keys()].map((k) => k.split("|")[1]))].filter(Boolean); const catNames = new Map(); if (allCatIds.length > 0) { const cats = await db @@ -230,11 +238,12 @@ export async function getCategoryTrends( const result: CategoryTrendRow[] = []; for (const [key, total] of trendMap.entries()) { - const [period, categoryId] = key.split("|"); + const [period, rawCategoryId] = key.split("|"); + const categoryId = rawCategoryId === "" ? null : rawCategoryId; result.push({ period, categoryId, - categoryName: resolvedCategoryLabel(catNames.get(categoryId)), + categoryName: categoryId === null ? UNCATEGORIZED : resolvedCategoryLabel(catNames.get(categoryId)), total, }); } @@ -243,7 +252,8 @@ export async function getCategoryTrends( } export interface IncomeExpenseCategoryRow { - categoryId: string; + /** `null` is uncategorized, which is a real row here. */ + categoryId: string | null; categoryName: string; categoryIcon: string | null; isIncome: boolean; @@ -288,49 +298,90 @@ export async function getIncomeExpenseByCategory( .where(scoped.where(transactions, ...conditions)); const monthCount = Math.max(monthRow?.count ?? 0, 1); - // Per-category totals: income categories sum ABS(amount), everything else sums - // the raw signed amount — pushed into SQL via SUM(CASE ...). Null-category rows - // are excluded from the grouping (they never produced a row in the JS version). - const isIncome = + const inIncomeCat = incomeCatIds.size > 0 ? sql`COALESCE(${inArray(transactions.categoryId, [...incomeCatIds])}, false)` : sql`false`; + // The expense side is exactly `spendingBaseConditions`: a negative amount in a + // non-income category, summed by magnitude. Uncategorized rows are grouped + // like any other category rather than filtered out — they are usually the + // single largest line, and dropping them made this table disagree with the + // Total Expenses tile above it. + // + // Income is left as it was (gross magnitudes, income categories only). Its own + // rows still undershoot the Total Income tile, which counts uncategorized + // credits as income — reconciling those two is a product decision about gross + // vs net income, tracked separately, not something to settle silently here. + // + // Both pools are aggregated in one pass and classified in JS: emitting the + // classifier in GROUP BY trips Postgres 42803, because Drizzle re-renders the + // template with fresh placeholders and the planner cannot match the terms. const catRows = await db .select({ categoryId: transactions.categoryId, categoryName: categories.name, categoryIcon: categories.icon, - total: sql`COALESCE(SUM(CASE WHEN ${isIncome} THEN ABS(${transactions.normalizedAmount}) ELSE ${transactions.normalizedAmount} END), 0)`.mapWith(Number), + incomeTotal: sql`COALESCE(SUM(CASE + WHEN ${inIncomeCat} THEN ABS(${transactions.normalizedAmount}) + ELSE 0 END), 0)`.mapWith(Number), + expenseTotal: sql`COALESCE(SUM(CASE + WHEN NOT (${inIncomeCat}) AND ${transactions.normalizedAmount} < 0 + THEN ABS(${transactions.normalizedAmount}) + ELSE 0 END), 0)`.mapWith(Number), }) .from(transactions) .leftJoin(categories, eq(transactions.categoryId, categories.id)) - .where(scoped.where(transactions, ...conditions, isNotNull(transactions.categoryId))) + .where(scoped.where(transactions, ...conditions)) .groupBy(transactions.categoryId, categories.name, categories.icon); + // A category can legitimately land on both sides — uncategorized most often, + // where credits are income and debits are spending. A side that sums to zero + // (a period of pure credits, say) is not a category of anything, so it is + // dropped rather than rendered as a $0 row. + const scored = catRows.flatMap((row) => + ( + [ + { isIncome: true, total: row.incomeTotal }, + { isIncome: false, total: row.expenseTotal }, + ] as const + ) + .filter((side) => side.total > 0) + .map((side) => ({ + categoryId: row.categoryId, + categoryName: row.categoryName, + categoryIcon: row.categoryIcon, + isIncome: side.isIncome, + total: side.total, + })), + ); + let totalIncome = 0; let totalExpenses = 0; - for (const row of catRows) { - if (incomeCatIds.has(row.categoryId!)) totalIncome += row.total; + for (const row of scored) { + if (row.isIncome) totalIncome += row.total; else totalExpenses += row.total; } - const result: IncomeExpenseCategoryRow[] = catRows.map((row) => { - const rowIsIncome = incomeCatIds.has(row.categoryId!); - const denominator = rowIsIncome ? totalIncome : totalExpenses; + const result: IncomeExpenseCategoryRow[] = scored.map((row) => { + const denominator = row.isIncome ? totalIncome : totalExpenses; return { - categoryId: row.categoryId!, - categoryName: resolvedCategoryLabel(row.categoryName), + categoryId: row.categoryId, + categoryName: row.categoryId === null ? UNCATEGORIZED : resolvedCategoryLabel(row.categoryName), categoryIcon: row.categoryIcon, - isIncome: rowIsIncome, + isIncome: row.isIncome, total: row.total, monthlyAverage: Math.round(row.total / monthCount), - // A category total and its pool total share the same sign, so the ratio is - // a positive share. Guard only against divide-by-zero — an empty (0) pool. + // Both the row and its pool are positive magnitudes, so the share is a + // positive percentage that sums to 100 across the pool. Guard only against + // divide-by-zero — an empty (0) pool. percentOfTotal: denominator !== 0 ? (row.total / denominator) * 100 : 0, }; }); + // Magnitudes, largest first. Sorting the previous signed totals ranked + // expenses backwards: the biggest expense was the most negative, so it sorted + // last and the smallest sat at the top of the table. return result.sort((a, b) => b.total - a.total); } @@ -438,16 +489,20 @@ export async function getCashFlowSankey( } } - // Expense side: non-income categories with a NEGATIVE normalizedAmount, summed - // as ABS — matching the codebase's expense convention (getCashFlow, etc.). - // Null categories are excluded. + // Expense side: non-income rows with a NEGATIVE normalizedAmount, summed as + // ABS — the same rule as the Spending tab. Uncategorized rows are included: + // excluding them drew a money-flow diagram missing the largest outflow in the + // period, which is precisely the flow a reader is looking for. const expenseConditions = [ ...conditions, - isNotNull(transactions.categoryId), sql`${transactions.normalizedAmount} < 0`, ]; if (incomeCatIds.size > 0) { - expenseConditions.push(notInArray(transactions.categoryId, [...incomeCatIds])); + // `category_id NOT IN (...)` is NULL — not TRUE — for an uncategorized row, + // so a bare notInArray silently drops every one of them. + expenseConditions.push( + sql`(${transactions.categoryId} IS NULL OR ${notInArray(transactions.categoryId, [...incomeCatIds])})`, + ); } const expenseRows = await db .select({ @@ -461,7 +516,10 @@ export async function getCashFlowSankey( .groupBy(transactions.categoryId, categories.name); for (const row of expenseRows) { - expenseMap.set(row.categoryId!, { name: resolvedCategoryLabel(row.categoryName), total: row.total }); + expenseMap.set(row.categoryId ?? "uncategorized", { + name: row.categoryId === null ? UNCATEGORIZED : resolvedCategoryLabel(row.categoryName), + total: row.total, + }); } const totalIncome = [...incomeMap.values()].reduce((s, v) => s + v.total, 0); diff --git a/tests/integration/report-consistency.test.ts b/tests/integration/report-consistency.test.ts new file mode 100644 index 0000000..787bea6 --- /dev/null +++ b/tests/integration/report-consistency.test.ts @@ -0,0 +1,168 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { createTestDb } from "./setup"; +import { + insertHousehold, + insertAccount, + insertCategoryGroup, + insertCategory, +} from "./helpers"; +import { insertTransaction } from "./helpers"; +import type { LedgrDb } from "../../src/db"; + +/** + * Every Reports tab answers "how much did I spend?" from its own query. They + * are only trustworthy if they agree, so these tests pin the definition of + * spending itself rather than any one tab's arithmetic. + * + * The canonical rule lives in `spendingBaseConditions`: a negative, settled, + * non-transfer, non-income transaction, summed by magnitude. + */ + +let db: LedgrDb; +let close: () => Promise; +let householdId: string; +let accountId: string; +let foodCatId: string; +let rentCatId: string; +let incomeCatId: string; + +const RANGE = { dateFrom: "2026-03-01", dateTo: "2026-03-31" }; + +beforeEach(async () => { + ({ db, close } = await createTestDb()); + ({ householdId } = await insertHousehold(db)); + ({ accountId } = await insertAccount(db, householdId)); + + const { groupId } = await insertCategoryGroup(db, householdId, { name: "Living" }); + ({ categoryId: foodCatId } = await insertCategory(db, householdId, groupId, { name: "Food" })); + ({ categoryId: rentCatId } = await insertCategory(db, householdId, groupId, { name: "Rent" })); + const incGroup = await insertCategoryGroup(db, householdId, { name: "Income" }); + ({ categoryId: incomeCatId } = await insertCategory(db, householdId, incGroup.groupId, { + name: "Salary", + isIncome: true, + })); + + // Food: 8000 of charges against a 2000 refund. + await insertTransaction(db, householdId, accountId, { date: "2026-03-05", normalizedAmount: -5000, amount: 5000, categoryId: foodCatId, name: "Grocery" }); + await insertTransaction(db, householdId, accountId, { date: "2026-03-15", normalizedAmount: -3000, amount: 3000, categoryId: foodCatId, name: "Restaurant" }); + await insertTransaction(db, householdId, accountId, { date: "2026-03-20", normalizedAmount: 2000, amount: -2000, categoryId: foodCatId, name: "Grocery refund" }); + + // Rent carries only a credit this period — it nets positive, so it is not + // spending at all. This is the shape that made $2,600 of rent vanish from + // the Spending tab while Income vs Expense counted it as an expense. + await insertTransaction(db, householdId, accountId, { date: "2026-03-02", normalizedAmount: 250000, amount: -250000, categoryId: rentCatId, name: "Rent credit" }); + + // Uncategorized spending — the single largest line in most real households. + await insertTransaction(db, householdId, accountId, { date: "2026-03-08", normalizedAmount: -7000, amount: 7000, categoryId: null, name: "Unknown merchant" }); + + await insertTransaction(db, householdId, accountId, { date: "2026-03-01", normalizedAmount: 500000, amount: -500000, categoryId: incomeCatId, name: "Salary" }); +}); + +afterEach(async () => { + await close(); +}); + +/** Food 8000 + uncategorized 7000. Rent and the refund are not spending. */ +const EXPECTED_SPENDING = 15000; + +describe("spending is defined once across every Reports tab", () => { + test("the Spending tab totals only negative, non-income transactions", async () => { + const { getSpendingByCategory } = await import("../../src/queries/reports"); + const rows = await getSpendingByCategory(householdId, RANGE, db); + + expect(rows.reduce((s, r) => s + r.total, 0)).toBe(EXPECTED_SPENDING); + expect(rows.find((r) => r.categoryName === "Rent")).toBeUndefined(); + }); + + test("Income vs Expense reports the same total as the Spending tab", async () => { + const { getIncomeVsExpense } = await import("../../src/queries/reports"); + const rows = await getIncomeVsExpense(householdId, RANGE, db); + + expect(rows.reduce((s, r) => s + r.expenses, 0)).toBe(EXPECTED_SPENDING); + }); + + test("a refund is not counted as spending", async () => { + const { getIncomeVsExpense, getIncomeExpenseByCategory } = await import("../../src/queries/reports"); + + const totals = await getIncomeVsExpense(householdId, RANGE, db); + const byCategory = await getIncomeExpenseByCategory(householdId, RANGE, db); + const food = byCategory.find((r) => r.categoryName === "Food"); + + // 8000 of charges, not 8000 + 2000 of absolute movement. + expect(food?.total).toBe(8000); + expect(totals.reduce((s, r) => s + r.expenses, 0)).toBe(EXPECTED_SPENDING); + }); + + test("a category that only received credits is not an expense category", async () => { + const { getIncomeExpenseByCategory } = await import("../../src/queries/reports"); + const rows = await getIncomeExpenseByCategory(householdId, RANGE, db); + + expect(rows.find((r) => r.categoryName === "Rent")).toBeUndefined(); + }); + + test("Trends counts the same spending as the Spending tab", async () => { + const { getCategoryTrends } = await import("../../src/queries/reports"); + const rows = await getCategoryTrends(householdId, RANGE, db); + + expect(rows.reduce((s, r) => s + r.total, 0)).toBe(EXPECTED_SPENDING); + }); +}); + +describe("uncategorized spending is never silently dropped", () => { + test("it appears in the Income vs Expense breakdown", async () => { + const { getIncomeExpenseByCategory } = await import("../../src/queries/reports"); + const rows = await getIncomeExpenseByCategory(householdId, RANGE, db); + + const uncategorized = rows.find((r) => r.categoryId === null); + expect(uncategorized?.total).toBe(7000); + expect(uncategorized?.isIncome).toBe(false); + }); + + test("it appears in Trends", async () => { + const { getCategoryTrends } = await import("../../src/queries/reports"); + const rows = await getCategoryTrends(householdId, RANGE, db); + + expect(rows.find((r) => r.categoryId === null)?.total).toBe(7000); + }); + + test("it is a node in the cash-flow Sankey", async () => { + const { getCashFlowSankey } = await import("../../src/queries/reports"); + const { nodes } = await getCashFlowSankey(householdId, RANGE, db); + + // A money-flow diagram that omits the largest outflow is not a money-flow + // diagram. Rent, which only received a credit, is correctly absent. + expect(nodes.find((n) => n.id === "expense-uncategorized")?.name).toBe("Uncategorized"); + expect(nodes.find((n) => n.name === "Rent")).toBeUndefined(); + }); + + test("the expense rows sum to the Total Expenses tile", async () => { + const { getIncomeVsExpense, getIncomeExpenseByCategory } = await import("../../src/queries/reports"); + + const tile = (await getIncomeVsExpense(householdId, RANGE, db)).reduce((s, r) => s + r.expenses, 0); + const rows = (await getIncomeExpenseByCategory(householdId, RANGE, db)) + .filter((r) => !r.isIncome) + .reduce((s, r) => s + r.total, 0); + + expect(rows).toBe(tile); + }); +}); + +describe("the Income vs Expense category table reads correctly", () => { + test("expenses are positive magnitudes, largest first", async () => { + const { getIncomeExpenseByCategory } = await import("../../src/queries/reports"); + const expenses = (await getIncomeExpenseByCategory(householdId, RANGE, db)).filter((r) => !r.isIncome); + + expect(expenses.every((r) => r.total > 0)).toBe(true); + expect(expenses.map((r) => r.total)).toEqual([8000, 7000]); + }); + + test("percentages are positive and sum to 100 within each pool", async () => { + const { getIncomeExpenseByCategory } = await import("../../src/queries/reports"); + const rows = await getIncomeExpenseByCategory(householdId, RANGE, db); + const expenses = rows.filter((r) => !r.isIncome); + + expect(expenses.every((r) => r.percentOfTotal > 0)).toBe(true); + expect(expenses.reduce((s, r) => s + r.percentOfTotal, 0)).toBeCloseTo(100, 5); + expect(rows.filter((r) => r.isIncome).reduce((s, r) => s + r.percentOfTotal, 0)).toBeCloseTo(100, 5); + }); +}); diff --git a/tests/integration/report-queries.test.ts b/tests/integration/report-queries.test.ts index 57ff21f..beac2f0 100644 --- a/tests/integration/report-queries.test.ts +++ b/tests/integration/report-queries.test.ts @@ -104,7 +104,7 @@ describe("getIncomeVsExpense", () => { }); describe("getIncomeExpenseByCategory", () => { - test("income sums abs amount, expense sums raw signed amount", async () => { + test("both pools sum as positive magnitudes", async () => { const { getIncomeExpenseByCategory } = await import("../../src/queries/reports"); const result = await getIncomeExpenseByCategory(householdId, { dateFrom: "2026-03-01", dateTo: "2026-03-31" }, db); @@ -115,32 +115,38 @@ describe("getIncomeExpenseByCategory", () => { expect(salary?.isIncome).toBe(true); expect(salary?.total).toBe(500000); // abs of +500000 expect(food?.isIncome).toBe(false); - expect(food?.total).toBe(-8000); // raw signed (-5000 + -3000) - expect(rent?.total).toBe(-100000); // raw signed + // Magnitudes, not signed sums: the table renders these directly and the + // pool percentages divide by them. See report-consistency.test.ts. + expect(food?.total).toBe(8000); // |-5000| + |-3000| + expect(rent?.total).toBe(100000); }); test("sorts rows by total descending", async () => { const { getIncomeExpenseByCategory } = await import("../../src/queries/reports"); const result = await getIncomeExpenseByCategory(householdId, { dateFrom: "2026-03-01", dateTo: "2026-03-31" }, db); - expect(result.map((r) => r.categoryName)).toEqual(["Salary", "Food", "Rent"]); + // Largest first within the combined list. Sorting signed totals used to + // rank expenses backwards, putting the smallest expense at the top. + expect(result.map((r) => r.categoryName)).toEqual(["Salary", "Rent", "Food"]); }); - test("monthlyAverage divides by distinct month count; null-category months count in the divisor but rows are excluded", async () => { + test("monthlyAverage divides by distinct month count, and uncategorized gets its own row", async () => { // A lone null-category txn in January adds a third distinct month to the - // divisor even though it produces no output row. + // divisor, and is itself reported as uncategorized spending. await insertTransaction(db, householdId, accountId, { date: "2026-01-20", normalizedAmount: -9999, amount: 9999, categoryId: null, name: "Uncat Jan" }); const { getIncomeExpenseByCategory } = await import("../../src/queries/reports"); const result = await getIncomeExpenseByCategory(householdId, { dateFrom: "2026-01-01", dateTo: "2026-03-31" }, db); - // No row for the null-category transaction. - expect(result.every((r) => r.categoryName !== "Uncat Jan")).toBe(true); + // The null-category transaction is spending and is reported as such. + const uncategorized = result.find((r) => r.categoryId === null); + expect(uncategorized?.total).toBe(9999); + expect(uncategorized?.isIncome).toBe(false); - // Food = Feb (-4000) + Mar (-8000) = -12000 over 3 distinct months. + // Food = Feb (4000) + Mar (8000) = 12000 over 3 distinct months. const food = result.find((r) => r.categoryName === "Food"); - expect(food?.total).toBe(-12000); - expect(food?.monthlyAverage).toBe(Math.round(-12000 / 3)); // -4000 + expect(food?.total).toBe(12000); + expect(food?.monthlyAverage).toBe(Math.round(12000 / 3)); // 4000 }); test("percentOfTotal is relative to the income vs expense pool", async () => {