Plaid Loan Sign Normalization (Port Washington State Bank & HELOC/LOC quirk)
TL;DR: A small set of Plaid institutions (Port Washington State Bank, some HELOC/LOC providers) reporttype=loan balances with a negative sign — the
opposite of Plaid’s documented positive-for-debt convention. Left unflipped, a
-$250k mortgage arrives as -$250k and reduces total liabilities instead
of adding to them, inflating net worth by twice the debt. We fix this by
flipping the sign at every read surface; stored data is never mutated. The
canonical helper is lib/liability-balance.ts.
Fleet scope when audited (2026-07-19, strict type='loan' AND source_type='loan' AND balance<0 predicate): 44 rows / 32 active users /
8,658,048 net-worth overstatement (2× swing).
The user who reported the bug (user_33TM7kqEBv8k26hNMx7OW8GbywT, 8 Wisconsin
mortgages under “Port Washington State Bank - Business” with negative
balances) had two open escalations against this issue.
Shipped in PR #2890.
1. The problem
Plaid’s contract fortype=loan accounts is unambiguous:
balance.current is the amount currently owed. Positive means the user owes
the institution.
Empirically, this holds for the overwhelming majority of Plaid loan rows we
see. But a small class of institutions ships the balance signed the OTHER way
around: a -$250,364 line-of-credit / HELOC / mortgage means the user owes
$250,364. We first saw it on Port Washington State Bank (8 mortgages under one
user’s Business item), then found the same pattern on other HELOC/LOC feeds.
Top institutions by aggregate mis-signed debt (as of 2026-07-19):
Concentrated in
subtype ∈ {mortgage, home equity, line of credit, loan}.
Every Plaid type=loan AND subtype=mortgage row in the DB with a positive
balance is fine — the flip is triggered by sign alone, not subtype.
2. Why this understates liabilities by 2×
Downstream aggregators everywhere (useAccounts, financial-data-cache,
process-daily-snapshot, PDF export, the account_details view read by the
AI execute_sql tool) sum the signed balance into a debt bucket:
-$250,364 Port-Washington mortgage, that becomes:
- Correct:
totalDebts = +1_280_000→ net worth reduced by $1.28M - Bug:
totalDebts = -1_280_000→ net worth inflated by $1.28M - Δ: $2.56M net-worth swing (2× the debt), one user
execute_sql tool returning wrong “aggregate debt” and
“net worth” answers when asked.
Users noticed. The user who filed the bug had emailed us in the fall of 2025
and again in mid-2026; the dashboard consistently overstated her net worth
even though the raw chat correctly summarized the mortgages. Chat was reading
account_effective_balances, dashboard was reading the raw accounts.balance_current.
3. The strict predicate (why type='loan' AND source_type='loan')
Not every account that looks like a loan should get the sign flipped. Two real
override patterns in prod:
3a. Schwab margin-loan re-tagged as investment (source_type='loan' → type='investment')
One user has a Charles Schwab “Loan Acct” that Plaid reports as
type=loan, source_type=loan, balance -$859,613.93. The user overrode
type='investment' / subtype='brokerage' in the app so the margin loan sits
alongside their brokerage — the negative balance correctly reduces the
portfolio value in their investments bucket. If we flipped that sign, we’d
inflate their portfolio by $860k and skip the negative offset. So the flip
must respect the user override.
3b. Plaid credit card re-tagged as loan (source_type='credit' → type='loan')
A Plaid credit card can have a legitimately negative balance_current when
the user has a credit balance / overpayment / statement credit (bank owes
user). If a user re-tags such a row as type='loan' in the app, we must NOT
flip the sign — the negative offset (-$100 debt = $100 offset) is real. So
type='loan' alone is not enough.
The predicate: flip only if BOTH the effective type === 'loan' AND the
raw source_type === 'loan'. That opts out both overrides:
Only one row in prod hits the “type=loan, source_type ≠ loan” edge (a
brokerage user overrode to line-of-credit, $4,280 balance). The strict
predicate is deliberate: the user can re-tag that row as a manual loan to get
the correct behavior, and we prefer that over silently flipping a sign the
user didn’t ask us to flip.
4. The four normalization surfaces
Sign normalization is applied in every code path that aggregates a Plaid loan balance:4a. Value / account form: lib/liability-balance.ts
Single source of truth. Two entry points:
normalizeLiabilityDebt(balance, isSnaptrade, { isPlaidLoan?: boolean })— caller computes the predicate.liabilityDebtAmount(account)— helper computes the predicate fromaccount.type+account.source_type+account.is_snaptrade.
lib/__tests__/liability-balance.test.ts,
including regression tests for both override cases and the SnapTrade + Plaid
- zero-balance edges.
4b. Dashboard: hooks/queries/use-accounts.ts
The useAccounts() hook’s rawTotalDebts reducer delegates to
liabilityDebtAmount(account) for every liability row. Replaces the inline
is_snaptrade ? -balance : balance pattern.
4c. Chat / SMS / email / PFS export: lib/financial-data-cache.ts
getFinancialMetrics() computes totalDebts for chat, SMS, email digests,
and the PFS export. The Plaid liability loop calls normalizeLiabilityDebt
with the strict predicate; the SnapTrade loop uses its own convention.
4d. Daily snapshots cron: app/api/cron/process-daily-snapshot/route.ts
Four sites in the cron aggregate totalDebtsForAccount:
- Fresh sync path — intentionally uses
updatedAccount.type === 'loan'(raw Plaid response type, not the stored/effective type). This preserves the net-worth impact of Schwab-style margin loans that the user overrode to investment:computePlaidInvestmentValuereturns{marketValue: 0}for a negative-balance-no-holdings account, so the -$860k would silently contribute 0 to net worth otherwise. This is a documented trade-off; the comment at the site explains it. Fixing it properly requires refactoringcomputePlaidInvestmentValueand is deferred. - Fallback path (stored value when Plaid sync failed) — strict predicate.
- Cached path (item already synced today) — strict predicate.
- Visibility path (recomputes visible-only totals) — strict predicate.
4e. PDF export: app/api/chat/tools/pdf-export-tool.ts
Two sections consume Plaid loan balances:
- Section 3 (Mortgages & Loans category subtotal) — strict predicate.
- Section 5 (per-row loan detail) — strict predicate.
normalizeLiabilityDebt so the printed PFS matches the “Total
Liabilities” figure computed by getFinancialMetrics.
5. Database view: account_details
The AI execute_sql tool (Silvia chat / radar / internal / proactive prompts
/ silvia-simulation) reads balances through the account_details view
[documented in the silvia-database-views skill]. Before this fix Silvia would
see the raw negative balance and repeat the wrong figure when asked about
aggregate debt or net worth.
Migration
20260719_01_normalize_plaid_loan_sign_in_account_details.sql
adds two CASE WHEN branches to the Plaid subquery of the view:
balance_available. pre_dedup_balance is
intentionally NOT sign-normalized — it is the audit column that exposes the
pre-suppression raw value; consumers that want to know what Plaid actually
returned should still see the negative.
5a. Verifying the migration is behavior-preserving
The migration replaces the live view definition wholesale, which is dangerous if the check-in copy of the base migration diverged from prod. We proved byte-level equivalence by creating a throwaway copy of the view under a different name and comparing all 25 columns row-by-row across all 192,241 rows:5b. The soft-delete filter we caught along the way
Important gotcha: the base migration20260707100000_dedup_cash_sweep_in_account_details.sql does NOT contain
AND p.status = 'active' in the SnapTrade positions subqueries, but the LIVE
prod view DOES — the soft-delete filter was added out-of-band as a sibling to
the RPC filters shipped in 20260708_02_snaptrade_valuation_fns_status_filter.sql.
Re-emitting the July-7 base verbatim would have silently regressed the
ghost-position fix (~$55.8M across 8,139 rows / 716 accounts). We caught this
by fetching pg_get_viewdef('public.account_details') and diffing against
the base; the two AND p.status = 'active' predicates are carried forward in
the new migration and the discrepancy is documented in its header.
The two-view diff above (0 diffs on snaptrade_crypto_value /
snaptrade_cash_adjustment) proves the filter is preserved.
5c. Why the view, not the raw table
We do this at read time rather than mutatingaccounts.balance_current for
three reasons:
accounts.balance_currentis the raw truth from Plaid. Mutating it would diverge our system’s state from Plaid’s, making sync reconciliation and audit harder.- The next Plaid sync would re-insert the negative value; a raw-table fix would silently regress on every sync.
- A read-time transform lets us adjust the predicate (subtype, source_subtype, institution-scoped flags, feature flags) without a data migration.
6. End-to-end verification for the reporting user
user_33TM7kqEBv8k26hNMx7OW8GbywT has 15 liability rows total, 8 of which
are the negative-signed Port-Washington mortgages the strict predicate
flips:
7. Testing & rollout
- Helper tests (
lib/__tests__/liability-balance.test.ts): 27 tests covering all four normalization branches plus the two override cases plus the SnapTrade / -0 / null-balance edges. All 27 pass. - Type check + lint: clean on all touched files.
- Full test suite (
npx vitest run): 215 test files / 2,823 tests total. Every touched-file test passes. Unrelated pre-existing failures on staging: threeworkflow/radarfiles fail withCannot find module 'workflow'(import-resolution problem in an unrelated area) and one flaky/timeout test inchat-inline-citations.stories.test.ts/file-conversion.test.ts(both pre-existing on staging, verified by stashing this PR’s changes and re-running). - Migration safety: verified byte-level equivalence on prod via a
throwaway
public._account_details_test_20260719view (see §5a). Test view was dropped immediately after. - Simulation vs prod (strict predicate:
type='loan' AND source_type='loan' AND balance<0): 44 rows / 32 active users / 8,658,048 (2× the corrected debt).
8. Related surfaces / references
- Helper implementation:
lib/liability-balance.ts - Helper tests:
lib/__tests__/liability-balance.test.ts - Dashboard:
hooks/queries/use-accounts.ts - Chat / SMS / email / PFS:
lib/financial-data-cache.ts - Daily snapshot cron:
app/api/cron/process-daily-snapshot/route.ts - PDF export tool:
app/api/chat/tools/pdf-export-tool.ts - View migration:
supabase/migrations/20260719_01_normalize_plaid_loan_sign_in_account_details.sql - Related fix (cash sweep dedup):
BROKERAGE-CASH-SWEEP-DEDUP.md - PR: Silvia-Web #2890