fix: liability sign convention, account typing, and net-worth coverage boundary - #95
Merged
Merged
Conversation
accounts.current_balance had no documented sign convention and the two
ingest paths disagreed. Plaid reports credit/loan balances.current POSITIVE
when owed ("a positive balance indicates amount owed"); SimpleFIN reports
them negative. Meanwhile getAccountSummary and getDashboardSummary computed
netWorth = assets - liabilities, which only holds for positive-stored
liabilities -- so with real (negative) data it ADDED the debt.
On the demo household that showed $71,700 instead of $51,700, a $20,000
overstatement, hidden by the Debts tile rendering Math.abs().
Canonicalize on negative = owed, for every account type:
- Normalize at ingest via plaidBalanceToCents(), not in the query.
plaidAmountToCents is left alone -- it also converts transaction
amounts and must not flip anything.
- Net worth is now the plain sum of balances, so a mis-typed account
can no longer invert it.
- availableBalance/creditLimit are NOT flipped: for a credit account
those are spending capacity, not debt.
- MCP tools report liabilities as a positive magnitude; a field named
"liabilities" handing an agent -$150 reads as a credit.
- Document the convention on the schema column.
Also infer SimpleFIN account types from the account name. SimpleFIN sends
no type field and everything defaulted to "checking", so credit cards never
registered as debt at all -- which is why the inverted sign went unnoticed.
The existing test asserted the opposite convention (credit card at +50000),
which is why this never failed; fixtures updated to match real data.
Fixes #84
Fixes #85
The dashboard read "+$55,214.00 (2430.7%) past 6 months" against a real household. Nothing earned that: five accounts holding $51,703 have their first-ever balance snapshot on 2026-08-28, so every earlier point counted them as $0 and plotted the credit cards alone. Carry-forward (#68) fixed gaps BETWEEN snapshots. It cannot fix a gap BEFORE the first one - there is nothing to carry backward - so this is a presentation problem, not a reconstruction one. Per the #67 precedent, do not invent the missing history; say which stretch is missing. getNetWorthHistory now reports coveredAccounts/totalAccounts per point (lastBalanceByAccount.size is exactly the accounts we have any balance for by that date). On top of that: - coveredTrendDelta measures across the fully covered span only, and returns null with fewer than two such points. A delta from a partial baseline reports accounts appearing, not money arriving. - The chart splits at the boundary: muted dashed line plus a hatched band for the partial span, solid area after, with a boundary rule. The boundary point belongs to both keys so the segments meet. - A caption names the shortfall ("2-5 of 10 accounts had balance history before Aug 28"), so the treatment is never color-alone. - Zero deltas drop the arrow - it pointed somewhere the number did not. Real household now: coverage steps 2 -> 3 -> 4 -> 5 -> 10, boundary Aug 28. NetWorthPoint splits into NetWorthSeriesPoint (no coverage) plus the coverage-carrying dashboard shape. The reports series has the same leading- gap exposure and keeps the base type; surfacing coverage there is separate work, not silently claimed here. Fixes #86
Shows the budgets screen with per-category progress and the reports tab alongside the existing hero and Plaid Link shots.
…ered little of the spend threshold was Math.round(30 / tmpl.frequency) - an interval in days - but it gates a counter that advances by exactly 1 per day ((dayOffset * 31) %% 30), so `hash < threshold` yields `threshold` occurrences per 30-day window, not one every `threshold` days. A 4x/month template fired 8 times; a daily one fired 30. Using frequency directly makes the count match the name. Budget limits covered 5 categories, so most of the generated month landed in "Everything Else" and the budgets screen read as mostly untracked. Now spans the 16 categories the seed actually spends in, sized to the generated trend, with Groceries deliberately under it so the over-budget state is represented.
SECURITY.pdf documents private vulnerability reporting via GitHub Security Advisories rather than public issues. The CLAUDE.md block is generated and re-added by `next dev` (node_modules/next/dist/server/lib/generate-agent-files.js); committing it keeps the working tree clean instead of it reappearing on every run.
KenTaniguchi-R
force-pushed
the
fix/liability-sign-convention
branch
from
August 29, 2026 22:03
ad92688 to
a277928
Compare
This was referenced Aug 30, 2026
KenTaniguchi-R
added a commit
that referenced
this pull request
Aug 30, 2026
…r read (#105) normalizeAmount(amountCents, _accountType) took an account type it never used. Plaid applies one sign convention across every account type, so normalization does not depend on one -- and a parameter implying it does is misleading next to the sign-convention work in #84/#95. Removing it orphaned the whole chain that existed only to supply it: - processBatch's accountTypeMap parameter - the map construction in syncPlaidItem - the accounts query feeding that map, a DB read on every Plaid sync That in turn unmasked a second dead parameter. processBatch's householdId was never used either, but ESLint's `args: "after-used"` default only reports parameters following the last used one, so accountTypeMap was hiding it. Dropped as well. `pnpm lint` is now completely clean -- zero problems, zero warnings. The 11 normalizeAmount unit tests were the same assertion repeated once per account-type string, a distinction the signature can no longer express. Collapsed to the three behaviours that actually differ (positive, negative, zero-not-negative-zero) and de-looped the two property tests. Verified this costs nothing: money.ts mutation score is 76.47% (78 killed / 24 survived) both before and after, unchanged. Closes #92.
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.
Fixes #84. Fixes #85. Fixes #86.
Five commits. The first two are a coupled pair and must land together; the rest are unrelated work that ended up on this branch (see the note at the bottom).
1.
59c1400— Liability balance signs (#84, #85)accounts.current_balancehad no documented convention and the ingest paths disagreed:balances.current.plaidAmountToCentsdid no sign flip.demo.ts:225,:253accounts-queries.test.ts:91inserted a credit card at+50000getAccountSummaryandgetDashboardSummarycomputednetWorth = totalAssets - totalLiabilities, correct only for positive-stored liabilities. Against real data it added the debt — demo household showed $71,700 instead of $51,700, masked by the Debts tile renderingMath.abs().Separately, SimpleFIN sends no account type and
simplefin-connect-flow.tsxdefaulted everything to"checking", so four credit cards and an Alpaca brokerage were filed as deposit accounts and Debts read $0.00. That is why the inverted sign went unnoticed: nothing was ever classified as a liability.Approach — canonicalize on negative = owed, normalized at ingest (
plaidBalanceToCents, kept separate fromplaidAmountToCents, which also converts transaction amounts). Net worth becomes the plain sum of balances, so it no longer depends on classification at all and a mis-typed account can never invert it again.availableBalance/creditLimitare not flipped — for a credit account those are capacity, not debt. MCP tools report liabilities as a positive magnitude. AddedinferAccountTypeFromName, ordered so"Discover Bank Checking"doesn't match the card-brand rule.Verified on the real household — net worth held at $52,942.44 while Assets went $52,942.44 → $57,500.85 and Debts $0.00 → $4,558.41.
2.
6ed2d68— Net-worth coverage boundary (#86)The dashboard read ↑ $55,214.00 (2430.7%) past 6 months. Nothing earned it: five accounts holding $51,703 have their first-ever snapshot on 2026-08-28, so every earlier point counted them as $0 and plotted the credit cards alone.
Carry-forward (#68) fixed gaps between snapshots; it cannot fix a gap before the first one. Per the #67 precedent this doesn't invent the missing history — it says which stretch is missing.
getNetWorthHistorynow emitscoveredAccounts/totalAccountsper point.coveredTrendDeltameasures across the fully covered span only and returns null below two such points. The chart splits at the boundary — muted dashed line and hatched band before, solid area after — with a caption naming the shortfall in words so the treatment is never colour-alone. Zero deltas lose the arrow, which pointed somewhere the number didn't.Three treatments were mocked against the real 84-point series before building; this is option B. Option A (trim to full coverage) collapses 6M/1Y/All to two points; option C (suppress the percentage only) leaves a line diving to −$13,456 unexplained.
Known limitation: nothing distinguishes "the account didn't exist yet" from "we have no data yet" —
accounts.createdAtis when Ledgr learned of an account, not when it opened. A genuinely new account is also marked partial. Conservative, but wrong in that case.3.
eaad082— README screenshotsBudgets and reports shots alongside the existing hero and Plaid Link images.
4.
44c620e— Demo seed rate and budget coveragethresholdwasMath.round(30 / tmpl.frequency)— an interval in days — but it gates a counter advancing by exactly 1 per day ((dayOffset * 31) % 30), sohash < thresholdyieldsthresholdoccurrences per 30-day window, not one everythresholddays. A 4×/month template fired 8 times. Budget limits also covered only 5 categories, so most of the generated month landed in "Everything Else"; now 16 categories sized to the generated trend, with Groceries deliberately under it so the over-budget state is represented.5.
ad92688— Security policy and thenext devagent-rules blockSECURITY.pdfdocuments private vulnerability reporting. TheCLAUDE.mdblock is generated and re-added bynext dev; committing it keeps the tree clean.Test changes
accounts-queries.test.ts:91encoded the opposite sign convention from every real data source — which is why a $20,000 error never failed a test. Fixtures acrossdashboard-queries,report-queries,mcp-net-worth-history, andplaid-exchangenow store liabilities negative with unchanged net-worth expectations. Added a regression test assertingnetWorth < totalAssetswhen debt is present, aplaid-exchangeassertion that the normalized value reachesbalance_history, and 10 new cases for the coverage boundary.NetWorthPointsplits intoNetWorthSeriesPoint(no coverage) plus the coverage-carrying dashboard shape. The reports series has the same leading-gap exposure and keeps the base type — surfacing coverage there is separate work.Full suite green: 113/113 test files.
Why one PR
#86 was developed on a branch stacked on this one, since both touch
getNetWorthHistoryandNetWorthPoint. Its commit landed here rather than on that branch, and the branch ended up at main's head. Untangling meant force-pushing two branches with open PRs, so they were kept combined instead and #97 was closed.🤖 Generated with Claude Code