fix(reports): make every tab agree on what spending means - #148
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #142.
Clicking through the Reports tabs gave four different answers to "how much did I spend?", all under one Last 3 months chip:
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.getIncomeVsExpensesummedABS()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.getIncomeExpenseByCategorydid three things at once:isNotNull(transactions.categoryId)dropped uncategorized, so 61% of spending was missing from the table explaining the tile directly above it.percentOfTotaldivided by the signed net of category totals (−$6,906.78), a number shown nowhere, producing shares like-38%that summed past 100.getCategoryTrendsskipped null-category rows, so its "Total Spent" tile was silently total-minus-uncategorized —$25,495.49 − $15,589.86 = $9,905.63, exactly.getCashFlowSankeyexcluded uncategorized twice: an explicitisNotNull, and anotInArraythat evaluates to NULL rather than TRUE for a nullcategory_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.categoryIdandCategoryTrendRow.categoryIdare nowstring | null; the category table and its drill-down handler carry the null through, whichDrillDownSheetalready 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.tsasserted 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 cleanmain(verified by stashing).🤖 Generated with Claude Code