Skip to content

fix(reports): make every tab agree on what spending means - #148

Merged
KenTaniguchi-R merged 1 commit into
mainfrom
fix/142-unify-spending-definition
Sep 2, 2026
Merged

fix(reports): make every tab agree on what spending means#148
KenTaniguchi-R merged 1 commit into
mainfrom
fix/142-unify-spending-definition

Conversation

@KenTaniguchi-R

Copy link
Copy Markdown
Owner

Closes #142.

Clicking through the Reports tabs gave four different answers to "how much did I spend?", all under one Last 3 months chip:

Tab Before After
Spending $25,495.49 $25,495.49
Income vs Expense $28,494.34 $25,495.49
Trends $9,905.63 $25,495.49

Measured against the dev database, range Jun 2 – Sep 2.

What was wrong

spendingBaseConditions (src/lib/spending-helpers.ts:26) is the canonical rule — a negative, settled, non-transfer, non-income transaction, summed by magnitude. Four queries diverged from it.

getIncomeVsExpense summed ABS() over all non-income rows, so every refund and credit counted as spending. The discrepancy was exactly the period's credits: $28,494.34 − $25,495.49 = $2,998.85 = $2,600.00 of rent that only received credits + $398.85 of refunds.

getIncomeExpenseByCategory did three things at once:

  • isNotNull(transactions.categoryId) dropped uncategorized, so 61% of spending was missing from the table explaining the tile directly above it.
  • Summing signed amounts made a net-positive category (Rent/Mortgage) render as a positive "expense" among negatives.
  • Sorting those signed totals ranked expenses backwards — the largest expense was the most negative, so it sorted last and Home Insurance ($5.06) sat at the top.
  • percentOfTotal divided by the signed net of category totals (−$6,906.78), a number shown nowhere, producing shares like -38% that summed past 100.

getCategoryTrends skipped null-category rows, so its "Total Spent" tile was silently total-minus-uncategorized — $25,495.49 − $15,589.86 = $9,905.63, exactly.

getCashFlowSankey excluded uncategorized twice: an explicit isNotNull, and a notInArray that evaluates to NULL rather than TRUE for a null category_id. The second one is why the first fix silently didn't work — the new test caught it.

What changed

All four share one definition. Expense rows sum to their tile, expenses sort largest-first as positive magnitudes, percentages are positive and sum to 100 within each pool, and uncategorized is a first-class row everywhere.

IncomeExpenseCategoryRow.categoryId and CategoryTrendRow.categoryId are now string | null; the category table and its drill-down handler carry the null through, which DrillDownSheet already reads as uncategorized.

Deliberately not in scope

Income aggregation is unchanged. Making it symmetric drops Salary and Investment Income entirely — they net −$7,563.92 signed in this dataset, while ABS() overshoots the Total Income tile. Gross vs net income is a product decision, so this PR leaves it alone rather than settling it silently. The income rows still undershoot the tile, which counts uncategorized credits as income; worth its own issue.

Testing

tests/integration/report-consistency.test.ts — 11 new tests that pin the definition of spending itself rather than any one tab's arithmetic: cross-tab totals, refunds excluded, credit-only categories excluded, uncategorized present in every surface, rows summing to their tile, sort order, and percentage sign.

Three tests in report-queries.test.ts asserted the old behavior (negative totals, uncategorized excluded) and were updated with the reasoning recorded.

Typecheck and lint clean. Full suite 1042/1043 — the one failure, investment-queries.test.ts > returns dayChange from holdings_history, is pre-existing hardcoded-date rot and fails identically on a clean main (verified by stashing).

🤖 Generated with Claude Code

Clicking through the Reports tabs gave four different answers to "how
much did I spend?", all under one "Last 3 months" chip: Spending
$25,495.49, Income vs Expense $28,494.34, Trends $9,905.63, Cash Flow
$0.00.

`spendingBaseConditions` is the canonical rule — a negative, settled,
non-transfer, non-income transaction, summed by magnitude. Four queries
diverged from it:

- getIncomeVsExpense summed ABS() over *all* non-income rows, so every
  refund and credit counted as spending. The gap was exactly the period's
  credits: $2,600.00 of rent that only received credits, plus $398.85 of
  refunds.
- getIncomeExpenseByCategory excluded uncategorized via isNotNull, so 61%
  of spending was missing from the table explaining the tile above it. It
  also summed signed amounts, so a net-positive category rendered as a
  positive "expense", expenses sorted backwards (most negative last), and
  percentages divided by a signed net shown nowhere on screen — yielding
  shares like -38%.
- getCategoryTrends skipped null-category rows, so its "Total Spent" tile
  was silently total-minus-uncategorized.
- getCashFlowSankey excluded uncategorized twice over: an explicit
  isNotNull, and a notInArray that evaluates to NULL rather than TRUE for
  a null category_id.

All four now share one definition. Spending, Income vs Expense and Trends
report an identical total, expense rows sum to their tile, and
uncategorized is a first-class row everywhere instead of a silent gap.

Income aggregation is deliberately unchanged. Its rows still undershoot
the Total Income tile, which counts uncategorized credits as income;
reconciling those is a product decision about gross vs net income, not
something to settle silently inside this fix.

Closes #142
@KenTaniguchi-R
KenTaniguchi-R merged commit bb8a7d4 into main Sep 2, 2026
4 of 5 checks passed
@KenTaniguchi-R
KenTaniguchi-R deleted the fix/142-unify-spending-definition branch September 2, 2026 10:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reports: the five tabs give four different totals for the same date range

1 participant