Skip to content

fix: liability sign convention, account typing, and net-worth coverage boundary - #95

Merged
KenTaniguchi-R merged 5 commits into
mainfrom
fix/liability-sign-convention
Aug 29, 2026
Merged

fix: liability sign convention, account typing, and net-worth coverage boundary#95
KenTaniguchi-R merged 5 commits into
mainfrom
fix/liability-sign-convention

Conversation

@KenTaniguchi-R

@KenTaniguchi-R KenTaniguchi-R commented Aug 29, 2026

Copy link
Copy Markdown
Owner

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_balance had no documented convention and the ingest paths disagreed:

source credit/loan balance evidence
Plaid positive when owed "For credit and loan accounts, a positive balance indicates amount owed" — Plaid docs for balances.current. plaidAmountToCents did no sign flip.
SimpleFIN negative real card balances arrive negative
demo seed negative demo.ts:225, :253
existing test positive accounts-queries.test.ts:91 inserted a credit card at +50000

getAccountSummary and getDashboardSummary computed netWorth = 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 rendering Math.abs().

Separately, SimpleFIN sends no account type and simplefin-connect-flow.tsx defaulted 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 from plaidAmountToCents, 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/creditLimit are not flipped — for a credit account those are capacity, not debt. MCP tools report liabilities as a positive magnitude. Added inferAccountTypeFromName, 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.

2026-05-31    2 of 10 accounts
2026-06-01    3 of 10
2026-06-07    4 of 10
2026-07-29    5 of 10
2026-08-28   10 of 10   <- boundary

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.

getNetWorthHistory now emits coveredAccounts/totalAccounts per point. coveredTrendDelta measures 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.createdAt is 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 screenshots

Budgets and reports shots alongside the existing hero and Plaid Link images.

4. 44c620e — Demo seed rate and budget coverage

threshold was Math.round(30 / tmpl.frequency) — an interval in days — but it gates a counter advancing by exactly 1 per day ((dayOffset * 31) % 30), so hash < threshold yields threshold occurrences per 30-day window, not one every threshold days. 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 the next dev agent-rules block

SECURITY.pdf documents private vulnerability reporting. The CLAUDE.md block is generated and re-added by next dev; committing it keeps the tree clean.


Test changes

accounts-queries.test.ts:91 encoded the opposite sign convention from every real data source — which is why a $20,000 error never failed a test. Fixtures across dashboard-queries, report-queries, mcp-net-worth-history, and plaid-exchange now store liabilities negative with unchanged net-worth expectations. Added a regression test asserting netWorth < totalAssets when debt is present, a plaid-exchange assertion that the normalized value reaches balance_history, and 10 new cases for the coverage boundary.

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.

Full suite green: 113/113 test files.

Why one PR

#86 was developed on a branch stacked on this one, since both touch getNetWorthHistory and NetWorthPoint. 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

@KenTaniguchi-R KenTaniguchi-R changed the title fix: normalize liability balance signs so net worth subtracts debt fix: liability sign convention, account typing, and net-worth coverage boundary Aug 29, 2026
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
KenTaniguchi-R force-pushed the fix/liability-sign-convention branch from ad92688 to a277928 Compare August 29, 2026 22:03
@KenTaniguchi-R
KenTaniguchi-R merged commit 369be27 into main Aug 29, 2026
5 checks passed
@KenTaniguchi-R
KenTaniguchi-R deleted the fix/liability-sign-convention branch August 29, 2026 22:52
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment