diff --git a/CLAUDE.md b/CLAUDE.md index ab518e64..689c60e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,3 +190,13 @@ Enforcement layers, fast → slow: `test:changed`/watch (you, locally) → pre-c 5. Uncategorized — flagged for manual review Each tier sets `categorySource` on the transaction (`"rule"` | `"merchant_default"` | `"pfc"` | `"ai"` | `"manual"`) to track provenance. Manual user edits always set `"manual"` and are never overwritten by lower tiers. + + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/README.md b/README.md index ebf71944..893cdac3 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,16 @@ Claude: Based on your transactions, you spent $342.18 on dining out in April... - **CSV/OFX/QFX import & CSV export** — for accounts not supported by Plaid, and for getting your data out - **Self-hosted** — Docker Compose with PostgreSQL, your data never leaves your server +
+Ledgr budgets screen with per-category spending progress +
+Monthly budgets by category, tracked against real spending +

+Ledgr spending report with category breakdown +
+Spending, income, cash flow, trends, and net worth reports +
+ ## Quick Start Requires [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/). diff --git a/SECURITY.pdf b/SECURITY.pdf new file mode 100644 index 00000000..799a44ce Binary files /dev/null and b/SECURITY.pdf differ diff --git a/docs/images/budgets.jpeg b/docs/images/budgets.jpeg new file mode 100644 index 00000000..64347a90 Binary files /dev/null and b/docs/images/budgets.jpeg differ diff --git a/docs/images/reports.jpeg b/docs/images/reports.jpeg new file mode 100644 index 00000000..0a02d04d Binary files /dev/null and b/docs/images/reports.jpeg differ diff --git a/src/actions/plaid.ts b/src/actions/plaid.ts index d84c6b74..90e83a7e 100644 --- a/src/actions/plaid.ts +++ b/src/actions/plaid.ts @@ -6,7 +6,7 @@ import { revalidatePath } from "next/cache"; import { Products, CountryCode } from "plaid"; import { getPlaidClient } from "@/lib/plaid/client"; import { encrypt, decrypt } from "@/lib/encryption"; -import { plaidAmountToCents } from "@/lib/money"; +import { plaidAmountToCents, plaidBalanceToCents } from "@/lib/money"; import { mapPlaidAccountType, extractPlaidErrorCode, extractPlaidErrorMessage } from "@/lib/plaid/utils"; import { todayDateString } from "@/lib/date-utils"; import { getHouseholdId } from "@/lib/auth/session"; @@ -129,12 +129,15 @@ export async function exchangeAndStoreAccounts( } for (const acct of plaidAccounts) { + const accountType = mapPlaidAccountType(acct.type, acct.subtype ?? null); const accountFields = { name: acct.name, officialName: acct.official_name ?? null, - type: mapPlaidAccountType(acct.type, acct.subtype ?? null), + type: accountType, subtype: acct.subtype ?? null, - currentBalance: plaidAmountToCents(acct.balances.current ?? null), + // Plaid reports credit/loan balances positive when owed; Ledgr + // stores owed as negative. See db/schema/accounts.ts. + currentBalance: plaidBalanceToCents(acct.balances.current ?? null, accountType), availableBalance: plaidAmountToCents(acct.balances.available ?? null), creditLimit: plaidAmountToCents(acct.balances.limit ?? null), currency: acct.balances.iso_currency_code ?? "USD", diff --git a/src/components/atoms/net-worth-area-chart.tsx b/src/components/atoms/net-worth-area-chart.tsx index ee47db7a..6d8b4a3c 100644 --- a/src/components/atoms/net-worth-area-chart.tsx +++ b/src/components/atoms/net-worth-area-chart.tsx @@ -8,17 +8,28 @@ import { YAxis, CartesianGrid, Tooltip, + ReferenceArea, + ReferenceLine, ResponsiveContainer, } from "recharts"; import { centsToDisplay, centsToCompact } from "@/lib/money"; import { formatDateShort } from "@/lib/date-utils"; -import { INCOME_COLOR, EXPENSE_COLOR, POSITIVE_COLOR } from "@/lib/chart-colors"; -import type { NetWorthPoint } from "@/queries/dashboard"; +import { INCOME_COLOR, EXPENSE_COLOR, POSITIVE_COLOR, UNCOVERED_COLOR } from "@/lib/chart-colors"; +import { coverageBoundary } from "@/lib/net-worth-coverage"; +import type { NetWorthSeriesPoint } from "@/queries/dashboard"; -type ChartDataPoint = Record; +type ChartDataPoint = Record; + +/** Reports pass points without coverage fields; the dashboard passes them. */ +interface SinglePoint { + date: string; + value: number; + coveredAccounts?: number; + totalAccounts?: number; +} interface NetWorthAreaChartProps { - data: NetWorthPoint[] | { date: string; value: number }[]; + data: NetWorthSeriesPoint[] | SinglePoint[]; height?: number; mode?: "multi" | "single"; seriesName?: string; @@ -34,10 +45,15 @@ const AXIS_TICK = { fontSize: 11, fill: "var(--muted-foreground)" }; function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: TooltipEntry[]; label?: string }) { if (!active || !payload?.length) return null; + // The split single-mode series leaves one key null on either side of the + // coverage boundary; Recharts still reports it, so drop the empty half + // rather than rendering the same date twice. + const entries = payload.filter((e) => e.value !== null && e.value !== undefined); + if (entries.length === 0) return null; return (

{formatDateShort(label ?? "")}

- {payload.map((entry: TooltipEntry) => ( + {entries.map((entry: TooltipEntry) => (

{entry.name}: {centsToDisplay(entry.value)}

@@ -56,14 +72,38 @@ export function NetWorthAreaChart({ data, mode = "multi", seriesName = "Value" } } if (mode === "single") { + const points = data as SinglePoint[]; + const boundary = coverageBoundary(points.map((p) => ({ ...p, netWorth: p.value }))); + + // Split the series at the boundary so the stretch that isn't yet net worth + // renders as a muted dashed line instead of a confident solid one. The + // boundary point belongs to BOTH keys, or the two segments would not meet. + // + // Three shapes to cover: no partial span (everything solid — reports, and + // any household with even history), a partial span that resolves (split at + // the boundary), and coverage that never completes (everything dashed). + const neverCompletes = boundary.index === -1; + const split: ChartDataPoint[] = points.map((p, i) => { + if (!boundary.hasPartial) return { date: p.date, partial: null, covered: p.value }; + if (neverCompletes) return { date: p.date, partial: p.value, covered: null }; + return { + date: p.date, + partial: i <= boundary.index ? p.value : null, + covered: i >= boundary.index ? p.value : null, + }; + }); + return ( - + + + + @@ -77,13 +117,36 @@ export function NetWorthAreaChart({ data, mode = "multi", seriesName = "Value" } domain={["auto", "auto"]} /> } /> + {boundary.hasPartial && boundary.date && ( + + )} + {boundary.hasPartial && boundary.date && ( + + )} + @@ -92,7 +155,7 @@ export function NetWorthAreaChart({ data, mode = "multi", seriesName = "Value" } return ( - + diff --git a/src/components/organisms/net-worth-hero.tsx b/src/components/organisms/net-worth-hero.tsx index d0e7e4d2..a7452c1c 100644 --- a/src/components/organisms/net-worth-hero.tsx +++ b/src/components/organisms/net-worth-hero.tsx @@ -4,7 +4,8 @@ import { useState, useTransition } from "react"; import { NetWorthAreaChart } from "@/components/atoms/net-worth-area-chart"; import { DateRangeSelector } from "@/components/molecules/date-range-selector"; import { centsToDisplay } from "@/lib/money"; -import { trendDelta } from "@/lib/stat-delta"; +import { formatDateShort } from "@/lib/date-utils"; +import { coverageBoundary, coveredTrendDelta } from "@/lib/net-worth-coverage"; import { cn } from "@/lib/utils"; import type { NetWorthPoint } from "@/queries/dashboard"; @@ -27,7 +28,11 @@ export function NetWorthHero({ netWorth, initialHistory, initialRange = "6M" }: const [history, setHistory] = useState(initialHistory); const [isLoading, startTransition] = useTransition(); - const delta = trendDelta(history.map((p) => p.netWorth)); + // Measured across the fully covered span only. A delta from a partial + // baseline reports accounts appearing, not money arriving — that is what + // produced the "+2430.7% past 6 months" this replaces. + const delta = coveredTrendDelta(history); + const coverage = coverageBoundary(history); const [dollars, cents] = centsToDisplay(netWorth).split("."); function handleRangeChange(next: string) { @@ -54,18 +59,36 @@ export function NetWorthHero({ netWorth, initialHistory, initialRange = "6M" }: = 0 - ? "text-positive bg-positive/10" - : "text-destructive bg-destructive/10", + delta.diff === 0 + ? "text-muted-foreground bg-muted" + : delta.diff > 0 + ? "text-positive bg-positive/10" + : "text-destructive bg-destructive/10", )} > - {delta.diff >= 0 ? "↑" : "↓"} {centsToDisplay(Math.abs(delta.diff))} - {delta.pct !== null && ` (${Math.abs(delta.pct).toFixed(1)}%)`}{" "} + {/* An arrow on a zero diff points somewhere the number doesn't. */} + {delta.diff === 0 ? ( + "No change" + ) : ( + <> + {delta.diff > 0 ? "↑" : "↓"} {centsToDisplay(Math.abs(delta.diff))} + {delta.pct !== null && ` (${Math.abs(delta.pct).toFixed(1)}%)`} + + )}{" "} - {RANGE_LABELS[range] ?? range.toLowerCase()} + {coverage.hasPartial + ? `since ${formatDateShort(coverage.date ?? "")}` + : (RANGE_LABELS[range] ?? range.toLowerCase())} )} + {!delta && coverage.hasPartial && ( + + {coverage.date + ? `full history since ${formatDateShort(coverage.date)}` + : "history incomplete"} + + )}
@@ -74,9 +97,30 @@ export function NetWorthHero({ netWorth, initialHistory, initialRange = "6M" }: ({ date: p.date, value: p.netWorth }))} + data={history.map((p) => ({ + date: p.date, + value: p.netWorth, + coveredAccounts: p.coveredAccounts, + totalAccounts: p.totalAccounts, + }))} /> + {coverage.hasPartial && ( +

+ + + Dashed: {coverage.minCovered === coverage.maxPartialCovered + ? coverage.minCovered + : `${coverage.minCovered}–${coverage.maxPartialCovered}`}{" "} + of {coverage.totalAccounts} accounts had balance history + {coverage.date ? ` before ${formatDateShort(coverage.date)}` : ""}, so it is not + yet net worth. + +

+ )} ); } diff --git a/src/components/organisms/report-net-worth.tsx b/src/components/organisms/report-net-worth.tsx index ee026553..498ec519 100644 --- a/src/components/organisms/report-net-worth.tsx +++ b/src/components/organisms/report-net-worth.tsx @@ -3,10 +3,10 @@ import { Wallet, TrendingUp } from "lucide-react"; import { NetWorthAreaChart } from "@/components/atoms/net-worth-area-chart"; import { ReportSummaryBar, type SummaryItem } from "@/components/atoms/report-summary-bar"; -import type { NetWorthPoint } from "@/queries/dashboard"; +import type { NetWorthSeriesPoint } from "@/queries/dashboard"; interface ReportNetWorthProps { - data: NetWorthPoint[]; + data: NetWorthSeriesPoint[]; } export function ReportNetWorth({ data }: ReportNetWorthProps) { diff --git a/src/components/organisms/report-tabs.tsx b/src/components/organisms/report-tabs.tsx index a09bd75c..39675fe4 100644 --- a/src/components/organisms/report-tabs.tsx +++ b/src/components/organisms/report-tabs.tsx @@ -5,7 +5,7 @@ import { PieChart, ArrowLeftRight, Waypoints, TrendingUp, LineChart } from "luci import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useSearchParamFilters } from "@/hooks/use-search-param-filters"; import type { SpendingRow, IncomeExpenseRow, CategoryTrendRow, IncomeExpenseCategoryRow, SafeToSpendResult } from "@/queries/reports"; -import type { NetWorthPoint } from "@/queries/dashboard"; +import type { NetWorthSeriesPoint } from "@/queries/dashboard"; import type { SankeyNode, SankeyLink } from "@/components/organisms/sankey-chart"; // Each report panel pulls in recharts (or d3-sankey). Load only the active @@ -40,7 +40,7 @@ interface ReportTabsProps { incomeExpenseData?: IncomeExpenseRow[]; incomeExpenseCategoryData?: IncomeExpenseCategoryRow[]; trendsData?: CategoryTrendRow[]; - netWorthData?: NetWorthPoint[]; + netWorthData?: NetWorthSeriesPoint[]; sankeyNodes?: SankeyNode[]; sankeyLinks?: SankeyLink[]; cashFlowBarData?: IncomeExpenseRow[]; diff --git a/src/components/organisms/simplefin-connect-flow.tsx b/src/components/organisms/simplefin-connect-flow.tsx index af97b262..2548f8ce 100644 --- a/src/components/organisms/simplefin-connect-flow.tsx +++ b/src/components/organisms/simplefin-connect-flow.tsx @@ -13,6 +13,7 @@ import { import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; +import { inferAccountTypeFromName } from "@/lib/account-utils"; import { Select, SelectContent, @@ -95,7 +96,11 @@ export function SimplefinConnectFlow({ variant = "dropdown-item" }: SimplefinCon accounts.push({ connectionId: connection.connectionId, ...account, - type: account.existingType ?? "checking", + // SimpleFIN sends no account type. Defaulting everything to + // "checking" filed credit cards as deposit accounts, so debt + // never registered — guess from the name and let the user + // correct it on this very screen. + type: account.existingType ?? inferAccountTypeFromName(account.name), }); } } diff --git a/src/db/schema/accounts.ts b/src/db/schema/accounts.ts index 6aac243c..d9378d2c 100644 --- a/src/db/schema/accounts.ts +++ b/src/db/schema/accounts.ts @@ -29,7 +29,20 @@ export const accounts = pgTable( officialName: text("official_name"), type: text("type", { enum: ACCOUNT_TYPES }).notNull(), subtype: text("subtype"), + // Signed cents, and the sign is load-bearing: money owed is ALWAYS + // negative, for every account type. That makes net worth the plain sum of + // this column, so a mis-typed account can never invert it. + // + // Connectors disagree, so both are normalized on the way in: + // SimpleFIN — already reports liabilities negative; passes through. + // Plaid — reports credit/loan `balances.current` POSITIVE when owed + // ("a positive balance indicates amount owed"), so it is + // flipped by plaidBalanceToCents() at every ingest site. + // + // balance_history.balance carries the same convention. currentBalance: integer("current_balance"), + // Not sign-normalized: for a credit account Plaid reports this as available + // *credit*, which is spending capacity rather than a debt. availableBalance: integer("available_balance"), creditLimit: integer("credit_limit"), currency: text("currency").default("USD"), diff --git a/src/db/seed/demo.ts b/src/db/seed/demo.ts index f869fb5c..f9880ea1 100644 --- a/src/db/seed/demo.ts +++ b/src/db/seed/demo.ts @@ -412,9 +412,12 @@ export async function seedDemoHousehold(db: LedgrDb = defaultDb): Promise for (let ti = 0; ti < txnTemplates.length; ti++) { const tmpl = txnTemplates[ti]; - // Deterministic: use modulo arithmetic to decide if transaction happens this day + // Deterministic: use modulo arithmetic to decide if transaction happens this day. + // hash advances by 1 each day (31 % 30), so it visits every value in [0, 30) + // exactly once per 30-day window — firing when hash < frequency yields + // `frequency` occurrences per month. const hash = (dayOffset * 31 + ti * 7) % 30; - const threshold = Math.round(30 / tmpl.frequency); + const threshold = tmpl.frequency; if (hash >= threshold) continue; const amtVariation = sineVariation(txnIndex, 13, Math.round(tmpl.amountBase * 0.3)); @@ -499,12 +502,27 @@ export async function seedDemoHousehold(db: LedgrDb = defaultDb): Promise updatedAt: now, }); + // Limits track the generated spend (see txnTemplates / monthlyFixed) so the + // budget covers roughly all of it rather than leaving most of the month in + // "Everything Else". Groceries is deliberately set below trend so the + // over-budget state is represented too. const budgetCategoryLimits: { category: string; limit: number }[] = [ - { category: "Groceries", limit: 60000 }, - { category: "Restaurants", limit: 25000 }, - { category: "Subscriptions", limit: 10000 }, - { category: "Gas", limit: 20000 }, - { category: "Coffee Shops", limit: 8000 }, + { category: "Rent/Mortgage", limit: 225000 }, + { category: "Car Payment", limit: 45000 }, + { category: "Groceries", limit: 58000 }, + { category: "Home Goods", limit: 24000 }, + { category: "Gas", limit: 18000 }, + { category: "Electronics", limit: 15000 }, + { category: "Electric", limit: 13000 }, + { category: "Public Transit", limit: 10000 }, + { category: "Internet", limit: 9500 }, + { category: "Phone", limit: 9000 }, + { category: "Clothing", limit: 9000 }, + { category: "Restaurants", limit: 7000 }, + { category: "Coffee Shops", limit: 6000 }, + { category: "Pharmacy", limit: 3000 }, + { category: "Subscriptions", limit: 3000 }, + { category: "Fitness", limit: 2500 }, ]; await tx.insert(budgetCategories) diff --git a/src/lib/account-utils.test.ts b/src/lib/account-utils.test.ts index e44c8c86..7708a170 100644 --- a/src/lib/account-utils.test.ts +++ b/src/lib/account-utils.test.ts @@ -1,5 +1,10 @@ import { describe, test, expect } from "vitest"; -import { classifyAccountType, ASSET_TYPES, LIABILITY_TYPES } from "./account-utils"; +import { + classifyAccountType, + inferAccountTypeFromName, + ASSET_TYPES, + LIABILITY_TYPES, +} from "./account-utils"; describe("classifyAccountType", () => { test("liability types classify as liability", () => { @@ -25,3 +30,43 @@ describe("classifyAccountType", () => { } }); }); + +describe("inferAccountTypeFromName", () => { + test("recognizes credit cards from the account name", () => { + const names = [ + "Amazon Prime Rewards Visa Signature", + "Citi Custom Cash® Card-7319", + "Robinhood Credit Card **3640", + "WELLS FARGO AUTOGRAPH VISA CARD ...2842", + "Chase Sapphire Mastercard", + "Amex Platinum", + ]; + for (const name of names) { + expect(inferAccountTypeFromName(name)).toBe("credit"); + } + }); + + test("recognizes brokerage and retirement accounts", () => { + expect(inferAccountTypeFromName("Portfolio Value")).toBe("investment"); + expect(inferAccountTypeFromName("Robinhood traditional IRA")).toBe("investment"); + expect(inferAccountTypeFromName("Fidelity Brokerage")).toBe("investment"); + expect(inferAccountTypeFromName("Roth 401k")).toBe("investment"); + }); + + test("recognizes savings and loans", () => { + expect(inferAccountTypeFromName("Emergency Fund Savings")).toBe("savings"); + expect(inferAccountTypeFromName("Car Loan")).toBe("loan"); + expect(inferAccountTypeFromName("Student Mortgage")).toBe("loan"); + }); + + test("falls back to checking when nothing matches", () => { + expect(inferAccountTypeFromName("Checking-1135")).toBe("checking"); + expect(inferAccountTypeFromName("")).toBe("checking"); + expect(inferAccountTypeFromName("Untitled")).toBe("checking"); + }); + + test("does not mistake 'Discover Bank Checking' for a credit card", () => { + // "Discover" is a card brand but this is plainly a deposit account. + expect(inferAccountTypeFromName("Discover Bank Checking")).toBe("checking"); + }); +}); diff --git a/src/lib/account-utils.ts b/src/lib/account-utils.ts index 2440a7fc..c970106c 100644 --- a/src/lib/account-utils.ts +++ b/src/lib/account-utils.ts @@ -1,3 +1,5 @@ +import type { AccountType } from "@/db/schema/accounts"; + const ASSET_TYPES = new Set(["checking", "savings", "investment", "other"]); const LIABILITY_TYPES = new Set(["credit", "loan"]); @@ -6,4 +8,38 @@ export function classifyAccountType(type: string): "asset" | "liability" { return "asset"; } +// Ordered most-specific first: a name like "Discover Bank Checking" has to hit +// the deposit rule before the card-brand rule, or every Discover/Amex deposit +// account gets filed as a credit card. +const NAME_TYPE_RULES: { type: AccountType; pattern: RegExp }[] = [ + { type: "checking", pattern: /\b(checking|chequing|debit\s+card|deposit)\b/i }, + { type: "loan", pattern: /\b(loan|mortgage|heloc|lease|financing)\b/i }, + { + type: "investment", + pattern: + /\b(brokerage|portfolio|invest\w*|ira|roth|401\s*k|403\s*b|hsa|securities|individual|crypto)\b/i, + }, + { + type: "credit", + pattern: + /\b(credit\s*card|creditcard|visa|mastercard|master\s*card|amex|american\s+express|discover|rewards\s+card|signature|platinum|sapphire|autograph)\b|\bcard\b/i, + }, + { type: "savings", pattern: /\b(savings|saving|money\s*market|cd|certificate)\b/i }, +]; + +/** + * Best-effort account type from a connector-supplied display name. + * + * SimpleFIN sends no type field at all, so without this every account defaults + * to "checking" and credit cards never register as debt. This is a starting + * guess for the classification step, not a substitute for it — the user still + * confirms. + */ +export function inferAccountTypeFromName(name: string): AccountType { + for (const { type, pattern } of NAME_TYPE_RULES) { + if (pattern.test(name)) return type; + } + return "checking"; +} + export { ASSET_TYPES, LIABILITY_TYPES }; diff --git a/src/lib/chart-colors.ts b/src/lib/chart-colors.ts index 73757b84..979ff3e6 100644 --- a/src/lib/chart-colors.ts +++ b/src/lib/chart-colors.ts @@ -19,3 +19,8 @@ export const EXPENSE_COLOR = "var(--destructive)"; export const SPENDING_COLOR = "var(--chart-neutral)"; export const PRIMARY_COLOR = "var(--primary)"; export const POSITIVE_COLOR = "var(--positive)"; + +// Used for a series stretch the data does not fully support — see +// lib/net-worth-coverage.ts. Deliberately not a status hue: the point is that +// the line is *not authoritative*, not that something is wrong. +export const UNCOVERED_COLOR = "var(--muted-foreground)"; diff --git a/src/lib/mcp/tools/accounts.ts b/src/lib/mcp/tools/accounts.ts index e40d10e6..c1e90706 100644 --- a/src/lib/mcp/tools/accounts.ts +++ b/src/lib/mcp/tools/accounts.ts @@ -53,8 +53,9 @@ export function registerAccountTools(server: McpServer, householdId: string) { return jsonResult({ totalAssetsCents: s.totalAssets, totalAssetsDisplay: centsToDisplay(s.totalAssets), - totalLiabilitiesCents: s.totalLiabilities, - totalLiabilitiesDisplay: centsToDisplay(s.totalLiabilities), + // Positive magnitude — see the note in mcp/tools/dashboard.ts. + totalLiabilitiesCents: Math.abs(s.totalLiabilities), + totalLiabilitiesDisplay: centsToDisplay(Math.abs(s.totalLiabilities)), netWorthCents: s.netWorth, netWorthDisplay: centsToDisplay(s.netWorth), }); diff --git a/src/lib/mcp/tools/dashboard.test.ts b/src/lib/mcp/tools/dashboard.test.ts index 3c2f45cc..4405164e 100644 --- a/src/lib/mcp/tools/dashboard.test.ts +++ b/src/lib/mcp/tools/dashboard.test.ts @@ -5,7 +5,7 @@ import type { NetWorthPoint } from "@/queries/dashboard"; describe("formatNetWorthHistory", () => { test("maps each point to cents plus display values for assets, liabilities, and net worth", () => { const points: NetWorthPoint[] = [ - { date: "2026-06-01", assets: 1_000_00, liabilities: 250_00, netWorth: 750_00 }, + { date: "2026-06-01", assets: 1_000_00, liabilities: 250_00, netWorth: 750_00, coveredAccounts: 2, totalAccounts: 2 }, ]; expect(formatNetWorthHistory(points)).toEqual([ @@ -23,8 +23,8 @@ describe("formatNetWorthHistory", () => { test("preserves order and handles negative net worth", () => { const points: NetWorthPoint[] = [ - { date: "2026-05-01", assets: 100_00, liabilities: 400_00, netWorth: -300_00 }, - { date: "2026-06-01", assets: 500_00, liabilities: 100_00, netWorth: 400_00 }, + { date: "2026-05-01", assets: 100_00, liabilities: 400_00, netWorth: -300_00, coveredAccounts: 2, totalAccounts: 2 }, + { date: "2026-06-01", assets: 500_00, liabilities: 100_00, netWorth: 400_00, coveredAccounts: 2, totalAccounts: 2 }, ]; const result = formatNetWorthHistory(points); diff --git a/src/lib/mcp/tools/dashboard.ts b/src/lib/mcp/tools/dashboard.ts index 39f07f82..84d7eaf7 100644 --- a/src/lib/mcp/tools/dashboard.ts +++ b/src/lib/mcp/tools/dashboard.ts @@ -26,8 +26,11 @@ export function formatNetWorthHistory(points: NetWorthPoint[]): NetWorthHistoryE date: p.date, assetsCents: p.assets, assetsDisplay: centsToDisplay(p.assets), - liabilitiesCents: p.liabilities, - liabilitiesDisplay: centsToDisplay(p.liabilities), + // Reported as a positive magnitude. Internally liabilities are stored + // negative (see db/schema/accounts.ts), but a field named "liabilities" + // handing an agent -$150 reads as a credit, not a debt. + liabilitiesCents: Math.abs(p.liabilities), + liabilitiesDisplay: centsToDisplay(Math.abs(p.liabilities)), netWorthCents: p.netWorth, netWorthDisplay: centsToDisplay(p.netWorth), })); diff --git a/src/lib/money.test.ts b/src/lib/money.test.ts index 8cc6aa3a..bb3fb92f 100644 --- a/src/lib/money.test.ts +++ b/src/lib/money.test.ts @@ -8,6 +8,7 @@ import { simplefinAmountToCents, normalizeAmount, parseToCents, + plaidBalanceToCents, } from "./money"; describe("money utilities", () => { @@ -203,4 +204,43 @@ describe("money property-based tests", () => { } } ); + + describe("plaidBalanceToCents", () => { + it("flips Plaid's positive-when-owed balance for liability accounts", () => { + // Plaid: "For credit and loan accounts, a positive balance indicates + // amount owed." Ledgr stores owed as negative. + expect(plaidBalanceToCents(1048.93, "credit")).toBe(-104893); + expect(plaidBalanceToCents(8200, "loan")).toBe(-820000); + }); + + it("leaves asset balances untouched", () => { + expect(plaidBalanceToCents(1195.2, "checking")).toBe(119520); + expect(plaidBalanceToCents(12500, "savings")).toBe(1250000); + expect(plaidBalanceToCents(37351.62, "investment")).toBe(3735162); + }); + + it("keeps a lender-owes-you credit balance positive", () => { + // Negative in Plaid means the lender owes the holder, which is an asset. + expect(plaidBalanceToCents(-50, "credit")).toBe(5000); + }); + + it("never returns -0 for a zero liability balance", () => { + expect(Object.is(plaidBalanceToCents(0, "credit"), 0)).toBe(true); + }); + + it("passes null through", () => { + expect(plaidBalanceToCents(null, "credit")).toBeNull(); + expect(plaidBalanceToCents(undefined, "checking")).toBeNull(); + }); + + test.prop([fc.integer({ min: -9999999, max: 9999999 })])( + "net worth is the plain sum of normalized balances", + (cents) => { + const dollars = cents / 100; + const asset = plaidBalanceToCents(dollars, "checking")!; + const liability = plaidBalanceToCents(dollars, "credit")!; + expect(asset + liability).toBe(0); + } + ); + }); }); diff --git a/src/lib/money.ts b/src/lib/money.ts index 247e8025..211638fb 100644 --- a/src/lib/money.ts +++ b/src/lib/money.ts @@ -1,3 +1,5 @@ +import { classifyAccountType } from "./account-utils"; + export function centsToDisplay(cents: number, currency = "USD"): string { return new Intl.NumberFormat("en-US", { style: "currency", @@ -34,6 +36,24 @@ export function plaidAmountToCents(plaidAmount: number | null | undefined): numb return Math.round(plaidAmount * 100); } +// Account *balance* normalization — distinct from plaidAmountToCents, which is +// also used for transaction amounts and must not flip anything. +// +// Plaid docs, `balances.current`: "For credit and loan accounts, a positive +// balance indicates amount owed; negative indicates lender owes account +// holder." Ledgr stores owed as negative for every account type (see the +// currentBalance note in db/schema/accounts.ts), so liabilities get flipped. +export function plaidBalanceToCents( + plaidAmount: number | null | undefined, + accountType: string +): number | null { + const cents = plaidAmountToCents(plaidAmount); + if (cents === null) return null; + if (classifyAccountType(accountType) !== "liability") return cents; + // Guard the JS -0 gotcha: -0 breaks strict equality checks downstream. + return cents === 0 ? 0 : -cents; +} + // SimpleFIN amounts are decimal strings, e.g. "-33293.43". Unlike Plaid's // convention (positive = money out), SimpleFIN's positive = deposit/income // already matches our normalizedAmount invariant — no sign flip needed at diff --git a/src/lib/net-worth-coverage.test.ts b/src/lib/net-worth-coverage.test.ts new file mode 100644 index 00000000..f11cb2f0 --- /dev/null +++ b/src/lib/net-worth-coverage.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from "vitest"; +import { coverageBoundary, coveredTrendDelta } from "./net-worth-coverage"; + +function point(date: string, netWorth: number, covered: number, total: number) { + return { date, netWorth, assets: 0, liabilities: 0, coveredAccounts: covered, totalAccounts: total }; +} + +describe("coverageBoundary", () => { + it("finds where full coverage begins in a partially covered series", () => { + const b = coverageBoundary([ + point("2026-06-01", -227156, 2, 10), + point("2026-07-01", -37898, 4, 10), + point("2026-08-28", 5294244, 10, 10), + point("2026-08-29", 5294244, 10, 10), + ]); + + expect(b.index).toBe(2); + expect(b.date).toBe("2026-08-28"); + expect(b.hasPartial).toBe(true); + expect(b.minCovered).toBe(2); + expect(b.maxPartialCovered).toBe(4); + expect(b.totalAccounts).toBe(10); + }); + + it("reports no partial span when every point is fully covered", () => { + const b = coverageBoundary([ + point("2026-06-01", 100, 3, 3), + point("2026-06-02", 200, 3, 3), + ]); + + expect(b.index).toBe(0); + expect(b.hasPartial).toBe(false); + expect(b.minCovered).toBeNull(); + }); + + it("reports no boundary when coverage is never complete", () => { + const b = coverageBoundary([ + point("2026-06-01", 100, 1, 4), + point("2026-06-02", 200, 2, 4), + ]); + + expect(b.index).toBe(-1); + expect(b.date).toBeNull(); + expect(b.hasPartial).toBe(true); + }); + + it("handles an empty series", () => { + const b = coverageBoundary([]); + expect(b.index).toBe(-1); + expect(b.hasPartial).toBe(false); + expect(b.totalAccounts).toBeNull(); + }); + + it("treats a series with no coverage data as fully covered", () => { + // Reports pass points without coverage fields; they must not be flagged. + const b = coverageBoundary([ + { date: "2026-06-01", netWorth: 100 }, + { date: "2026-06-02", netWorth: 200 }, + ]); + + expect(b.hasPartial).toBe(false); + expect(b.index).toBe(0); + }); +}); + +describe("coveredTrendDelta", () => { + it("withholds a delta measured across a coverage boundary", () => { + // The real bug: -$2,271 (2 of 10 accounts) → $52,942 (10 of 10) reported + // as +2430.7%, which measures accounts appearing, not money arriving. + const delta = coveredTrendDelta([ + point("2026-06-01", -227156, 2, 10), + point("2026-08-28", 5294244, 10, 10), + ]); + + expect(delta).toBeNull(); + }); + + it("measures across the fully covered span only", () => { + const delta = coveredTrendDelta([ + point("2026-06-01", -227156, 2, 10), + point("2026-08-28", 5000000, 10, 10), + point("2026-08-29", 5294244, 10, 10), + ]); + + expect(delta).not.toBeNull(); + expect(delta!.diff).toBe(294244); + }); + + it("returns null when only one fully covered point exists", () => { + expect( + coveredTrendDelta([ + point("2026-06-01", -227156, 2, 10), + point("2026-08-29", 5294244, 10, 10), + ]) + ).toBeNull(); + }); + + it("falls back to the whole series when no coverage data is present", () => { + const delta = coveredTrendDelta([ + { date: "2026-06-01", netWorth: 100 }, + { date: "2026-06-02", netWorth: 150 }, + ]); + + expect(delta!.diff).toBe(50); + }); +}); diff --git a/src/lib/net-worth-coverage.ts b/src/lib/net-worth-coverage.ts new file mode 100644 index 00000000..68a4dc45 --- /dev/null +++ b/src/lib/net-worth-coverage.ts @@ -0,0 +1,88 @@ +// Coverage-boundary helpers for the net-worth series. +// +// A net-worth point is only net worth when every tracked account has a known +// balance on that date. Balance history is uneven: carry-forward (#68) fills +// gaps *between* an account's snapshots, but an account whose first-ever +// snapshot lands mid-window contributes $0 to every earlier point, because +// there is nothing to carry backward. Summing anyway produces a partial sum +// that looks like net worth and moves like a cliff the moment the missing +// accounts appear. +// +// These helpers let the UI say which stretch of a series is a partial sum, and +// stop it computing a percentage across the boundary. + +import { trendDelta, type TrendDelta } from "./stat-delta"; + +export interface CoverageAware { + date: string; + netWorth: number; + coveredAccounts?: number; + totalAccounts?: number; +} + +export interface CoverageBoundary { + /** First index where every account is covered; -1 when that never happens. */ + index: number; + /** Date at that index, or null. */ + date: string | null; + /** True when at least one plotted point is a partial sum. */ + hasPartial: boolean; + /** Fewest accounts covered anywhere in the partial span; null when none. */ + minCovered: number | null; + /** Most accounts covered within the partial span; null when none. */ + maxPartialCovered: number | null; + /** Accounts tracked overall, from the last point that reports it. */ + totalAccounts: number | null; +} + +function isPartial(p: CoverageAware): boolean { + // Points without coverage data (the reports series) are taken at face value — + // absence of the field is not evidence of a gap. + return ( + p.coveredAccounts !== undefined && + p.totalAccounts !== undefined && + p.coveredAccounts < p.totalAccounts + ); +} + +export function coverageBoundary(points: CoverageAware[]): CoverageBoundary { + if (points.length === 0) { + return { index: -1, date: null, hasPartial: false, minCovered: null, maxPartialCovered: null, totalAccounts: null }; + } + + const partials = points.filter(isPartial); + const covereds = partials.map((p) => p.coveredAccounts!); + + const total = points.reduce( + (acc, p) => (p.totalAccounts !== undefined ? p.totalAccounts : acc), + null + ); + + if (partials.length === 0) { + return { index: 0, date: points[0].date, hasPartial: false, minCovered: null, maxPartialCovered: null, totalAccounts: total }; + } + + const index = points.findIndex((p) => !isPartial(p)); + + return { + index, + date: index === -1 ? null : points[index].date, + hasPartial: true, + minCovered: Math.min(...covereds), + maxPartialCovered: Math.max(...covereds), + totalAccounts: total, + }; +} + +/** + * Change across the fully covered span only. + * + * Null when fewer than two fully covered points exist — a delta measured from a + * partial baseline reports accounts appearing, not money arriving. That is + * exactly the "+2430.7% past 6 months" the dashboard used to show. + */ +export function coveredTrendDelta(points: CoverageAware[]): TrendDelta | null { + const { index } = coverageBoundary(points); + if (index === -1) return null; + return trendDelta(points.slice(index).map((p) => p.netWorth)); +} diff --git a/src/lib/plaid/sync.ts b/src/lib/plaid/sync.ts index 60194140..f3fda209 100644 --- a/src/lib/plaid/sync.ts +++ b/src/lib/plaid/sync.ts @@ -9,7 +9,7 @@ import { type PlaidTransaction, type PlaidRemovedTransaction, } from "./schemas"; -import { plaidAmountToCents, normalizeAmount } from "@/lib/money"; +import { plaidAmountToCents, plaidBalanceToCents, normalizeAmount } from "@/lib/money"; import { decrypt } from "@/lib/encryption"; import { getPlaidClient } from "./client"; import { @@ -257,7 +257,11 @@ async function applyToDb( return withHousehold(householdId, async (tx) => { // --- Build account lookup: plaid_account_id → internal account id --- const accountRows = await tx - .select({ id: accounts.id, externalAccountId: accounts.externalAccountId }) + .select({ + id: accounts.id, + externalAccountId: accounts.externalAccountId, + type: accounts.type, + }) .from(accounts) .where( and( @@ -268,7 +272,9 @@ async function applyToDb( ); const plaidToInternal = new Map(); + const typeByInternalId = new Map(); for (const row of accountRows) { + typeByInternalId.set(row.id, row.type); if (row.externalAccountId) { plaidToInternal.set(row.externalAccountId, row.id); } @@ -516,7 +522,12 @@ async function applyToDb( await tx.update(accounts) .set({ - currentBalance: plaidAmountToCents(ab.balances.current), + // Plaid reports credit/loan balances positive when owed; Ledgr + // stores owed as negative. See db/schema/accounts.ts. + currentBalance: plaidBalanceToCents( + ab.balances.current, + typeByInternalId.get(internalId) ?? "other", + ), availableBalance: plaidAmountToCents(ab.balances.available), creditLimit: plaidAmountToCents(ab.balances.limit), updatedAt: now, diff --git a/src/queries/accounts.ts b/src/queries/accounts.ts index d48a52ca..5f9e5859 100644 --- a/src/queries/accounts.ts +++ b/src/queries/accounts.ts @@ -134,7 +134,11 @@ export async function getAccountSummary( return { totalAssets, + // Negative, because owed money is stored negative. Callers that display a + // debt magnitude take Math.abs(). totalLiabilities, - netWorth: totalAssets - totalLiabilities, + // Plain sum, not `assets - liabilities`: the signs already carry the + // direction. Subtracting a negative liability would ADD the debt. + netWorth: totalAssets + totalLiabilities, }; } diff --git a/src/queries/dashboard.ts b/src/queries/dashboard.ts index e2224475..7841c7d8 100644 --- a/src/queries/dashboard.ts +++ b/src/queries/dashboard.ts @@ -108,7 +108,9 @@ export async function getDashboardSummary( const monthlyExpenses = totals?.expenses ?? 0; return { - netWorth: totalAssets - totalLiabilities, + // Plain sum — liability balances are already stored negative, so + // subtracting them would add the debt. See schema/accounts.ts. + netWorth: totalAssets + totalLiabilities, monthlyIncome, monthlyExpenses, monthlyNet: monthlyIncome - monthlyExpenses, @@ -117,13 +119,31 @@ export async function getDashboardSummary( // ─── getNetWorthHistory ───────────────────────────────────────────────────── -export interface NetWorthPoint { +/** + * A net-worth series point without coverage information. The reports series + * (getReportNetWorthHistory) produces this shape; it has the same leading-gap + * exposure as the dashboard, but reporting coverage there is separate work. + */ +export interface NetWorthSeriesPoint { date: string; assets: number; liabilities: number; netWorth: number; } +export interface NetWorthPoint extends NetWorthSeriesPoint { + /** + * How many tracked accounts had a known balance on this date, out of + * totalAccounts. Carry-forward fills gaps *between* snapshots, but an account + * whose first-ever snapshot lands mid-window contributes $0 to every earlier + * point — there is nothing to carry backward. Where covered < total this + * figure is a partial sum, not net worth, and callers must not present it (or + * a delta computed across it) as though it were. + */ + coveredAccounts: number; + totalAccounts: number; +} + export async function getNetWorthHistory( householdId: string, range: "1M" | "3M" | "6M" | "1Y" | "all" = "6M", @@ -218,7 +238,16 @@ export async function getNetWorthHistory( else liabilities += balance; } - result.push({ date, assets, liabilities, netWorth: assets - liabilities }); + result.push({ + date, + assets, + liabilities, + netWorth: assets + liabilities, + // lastBalanceByAccount only ever gains keys, so its size is exactly the + // number of accounts we have *any* balance for as of this date. + coveredAccounts: lastBalanceByAccount.size, + totalAccounts: accountIds.length, + }); } } @@ -248,7 +277,11 @@ export async function getNetWorthHistory( date: today, assets: todayAssets, liabilities: todayLiabilities, - netWorth: todayAssets - todayLiabilities, + netWorth: todayAssets + todayLiabilities, + // Today reads live balances, so coverage is simply how many accounts + // report one. An account with a null balance is still not covered. + coveredAccounts: accountsWithBalance.length, + totalAccounts: allAccounts.length, }); return withoutToday; diff --git a/src/queries/reports.ts b/src/queries/reports.ts index d58c974f..9f1b561d 100644 --- a/src/queries/reports.ts +++ b/src/queries/reports.ts @@ -392,7 +392,7 @@ export async function getReportNetWorthHistory( date, assets, liabilities, - netWorth: assets - liabilities, + netWorth: assets + liabilities, })); } diff --git a/tests/integration/accounts-queries.test.ts b/tests/integration/accounts-queries.test.ts index 73ebf952..f5b5fbf2 100644 --- a/tests/integration/accounts-queries.test.ts +++ b/tests/integration/accounts-queries.test.ts @@ -83,19 +83,36 @@ describe("account queries", () => { expect(manualGroup!.accounts).toHaveLength(1); }); - it("getAccountSummary computes assets - liabilities = net worth", async () => { + // Liability balances are stored negative (owed = negative), so net worth is + // the plain sum of every balance. See the currentBalance note in + // src/db/schema/accounts.ts. + it("getAccountSummary sums negative liability balances into net worth", async () => { const hh = await provisionHousehold("user-3", db); await insertAccount(db, hh, { name: "Checking", type: "checking", currentBalance: 500000 }); await insertAccount(db, hh, { name: "Savings", type: "savings", currentBalance: 1000000 }); - await insertAccount(db, hh, { name: "Credit Card", type: "credit", currentBalance: 50000 }); + await insertAccount(db, hh, { name: "Credit Card", type: "credit", currentBalance: -50000 }); const summary = await getAccountSummary(hh, db); expect(summary.totalAssets).toBe(1500000); - expect(summary.totalLiabilities).toBe(50000); + expect(summary.totalLiabilities).toBe(-50000); expect(summary.netWorth).toBe(1450000); }); + it("getAccountSummary subtracts debt rather than adding it", async () => { + // Regression for the inverted-sign bug: netWorth was assets - liabilities + // over negative-stored liabilities, which added the debt instead. + const hh = await provisionHousehold("user-3b", db); + + await insertAccount(db, hh, { name: "Checking", type: "checking", currentBalance: 6170000 }); + await insertAccount(db, hh, { name: "Everyday Card", type: "credit", currentBalance: -180000 }); + await insertAccount(db, hh, { name: "Car Loan", type: "loan", currentBalance: -820000 }); + + const summary = await getAccountSummary(hh, db); + expect(summary.netWorth).toBe(5170000); + expect(summary.netWorth).toBeLessThan(summary.totalAssets); + }); + it("getAccountSummary excludes null balances from sums", async () => { const hh = await provisionHousehold("user-4", db); diff --git a/tests/integration/dashboard-queries.test.ts b/tests/integration/dashboard-queries.test.ts index 0049cd3d..9ae9a1f3 100644 --- a/tests/integration/dashboard-queries.test.ts +++ b/tests/integration/dashboard-queries.test.ts @@ -49,7 +49,7 @@ describe("getDashboardSummary", () => { }); await insertAccount(db, householdId, { type: "credit", - currentBalance: 50000, + currentBalance: -50000, }); const thisMonth = new Date().toISOString().slice(0, 7); @@ -123,7 +123,7 @@ describe("getNetWorthHistory", () => { }); const { accountId: creditId } = await insertAccount(db, householdId, { type: "credit", - currentBalance: 20000, + currentBalance: -20000, }); // A historical date inside the 3M window but not today: first of last month. @@ -136,7 +136,7 @@ describe("getNetWorthHistory", () => { })(); await db.insert(balanceHistory).values({ id: uuid(), accountId: checkingId, date: histDate, balance: 70000 }); - await db.insert(balanceHistory).values({ id: uuid(), accountId: creditId, date: histDate, balance: 15000 }); + await db.insert(balanceHistory).values({ id: uuid(), accountId: creditId, date: histDate, balance: -15000 }); const result = await getNetWorthHistory(householdId, "3M", db); @@ -145,14 +145,14 @@ describe("getNetWorthHistory", () => { const historicalPoint = result.find((r) => r.date === histDate); expect(historicalPoint).toBeDefined(); expect(historicalPoint!.assets).toBe(70000); - expect(historicalPoint!.liabilities).toBe(15000); + expect(historicalPoint!.liabilities).toBe(-15000); expect(historicalPoint!.netWorth).toBe(55000); const today = todayDateString(); const todayPoint = result.find((r) => r.date === today); expect(todayPoint).toBeDefined(); expect(todayPoint!.assets).toBe(80000); - expect(todayPoint!.liabilities).toBe(20000); + expect(todayPoint!.liabilities).toBe(-20000); expect(todayPoint!.netWorth).toBe(60000); }); @@ -172,15 +172,15 @@ describe("getNetWorthHistory", () => { await db.insert(balanceHistory).values({ id: uuid(), accountId: checkingId, date: histDate, balance: 40000 }); await db.insert(balanceHistory).values({ id: uuid(), accountId: savingsId, date: histDate, balance: 60000 }); - await db.insert(balanceHistory).values({ id: uuid(), accountId: creditId, date: histDate, balance: 25000 }); - await db.insert(balanceHistory).values({ id: uuid(), accountId: loanId, date: histDate, balance: 5000 }); + await db.insert(balanceHistory).values({ id: uuid(), accountId: creditId, date: histDate, balance: -25000 }); + await db.insert(balanceHistory).values({ id: uuid(), accountId: loanId, date: histDate, balance: -5000 }); const result = await getNetWorthHistory(householdId, "3M", db); const point = result.find((r) => r.date === histDate)!; expect(point).toBeDefined(); expect(point.assets).toBe(100000); // checking + savings - expect(point.liabilities).toBe(30000); // credit + loan + expect(point.liabilities).toBe(-30000); // credit + loan, stored negative expect(point.netWorth).toBe(70000); }); @@ -516,3 +516,88 @@ describe("household isolation", () => { expect(recent.length).toBe(0); }); }); + +describe("getNetWorthHistory coverage reporting", () => { + // An account whose first-ever snapshot falls inside the window contributes + // $0 to every earlier point — carry-forward has nothing to carry backward. + // The series must say so rather than passing a partial sum off as net worth. + it("reports how many accounts each point actually covers", async () => { + const { householdId } = await insertHousehold(db); + const { accountId: cardId } = await insertAccount(db, householdId, { + type: "credit", + currentBalance: -20000, + }); + const { accountId: brokerageId } = await insertAccount(db, householdId, { + type: "investment", + currentBalance: 500000, + }); + + const early = (() => { + const d = new Date(); + d.setUTCDate(1); + d.setUTCMonth(d.getUTCMonth() - 2); + return d.toISOString().slice(0, 10); + })(); + const late = (() => { + const d = new Date(); + d.setUTCDate(1); + d.setUTCMonth(d.getUTCMonth() - 1); + return d.toISOString().slice(0, 10); + })(); + + // Only the card has history at `early`; the brokerage first appears at `late`. + await db.insert(balanceHistory).values({ id: uuid(), accountId: cardId, date: early, balance: -15000 }); + await db.insert(balanceHistory).values({ id: uuid(), accountId: brokerageId, date: late, balance: 400000 }); + + const result = await getNetWorthHistory(householdId, "3M", db); + + const earlyPoint = result.find((p) => p.date === early)!; + expect(earlyPoint.coveredAccounts).toBe(1); + expect(earlyPoint.totalAccounts).toBe(2); + + const latePoint = result.find((p) => p.date === late)!; + expect(latePoint.coveredAccounts).toBe(2); + expect(latePoint.totalAccounts).toBe(2); + }); + + it("marks the synthetic today point by how many accounts carry a balance", async () => { + const { householdId } = await insertHousehold(db); + await insertAccount(db, householdId, { type: "checking", currentBalance: 100000 }); + await insertAccount(db, householdId, { type: "investment", currentBalance: null }); + + const result = await getNetWorthHistory(householdId, "3M", db); + const today = result[result.length - 1]; + + expect(today.coveredAccounts).toBe(1); + expect(today.totalAccounts).toBe(2); + }); + + it("counts an account seeded from before the window as covered throughout", async () => { + const { householdId } = await insertHousehold(db); + const { accountId } = await insertAccount(db, householdId, { + type: "checking", + currentBalance: 100000, + }); + + // Snapshot predates the 1M window entirely. + const old = (() => { + const d = new Date(); + d.setUTCMonth(d.getUTCMonth() - 6); + return d.toISOString().slice(0, 10); + })(); + const inWindow = (() => { + const d = new Date(); + d.setUTCDate(d.getUTCDate() - 5); + return d.toISOString().slice(0, 10); + })(); + + await db.insert(balanceHistory).values({ id: uuid(), accountId, date: old, balance: 90000 }); + await db.insert(balanceHistory).values({ id: uuid(), accountId, date: inWindow, balance: 95000 }); + + const result = await getNetWorthHistory(householdId, "1M", db); + for (const point of result) { + expect(point.coveredAccounts).toBe(1); + expect(point.totalAccounts).toBe(1); + } + }); +}); diff --git a/tests/integration/mcp-net-worth-history.test.ts b/tests/integration/mcp-net-worth-history.test.ts index b908fb2c..92cabd1a 100644 --- a/tests/integration/mcp-net-worth-history.test.ts +++ b/tests/integration/mcp-net-worth-history.test.ts @@ -40,12 +40,12 @@ describe("get_net_worth_history tool data path", () => { }); const { accountId: creditId } = await insertAccount(db, householdId, { type: "credit", - currentBalance: 20000, + currentBalance: -20000, }); const histDate = firstOfLastMonth(); await db.insert(balanceHistory).values({ id: uuid(), accountId: checkingId, date: histDate, balance: 70000 }); - await db.insert(balanceHistory).values({ id: uuid(), accountId: creditId, date: histDate, balance: 15000 }); + await db.insert(balanceHistory).values({ id: uuid(), accountId: creditId, date: histDate, balance: -15000 }); const points = await getNetWorthHistory(householdId, "3M", db); const wire = formatNetWorthHistory(points); diff --git a/tests/integration/plaid-exchange.test.ts b/tests/integration/plaid-exchange.test.ts index e042245e..8e6fa6eb 100644 --- a/tests/integration/plaid-exchange.test.ts +++ b/tests/integration/plaid-exchange.test.ts @@ -78,9 +78,21 @@ describe("plaid exchange flow", () => { expect(checking.type).toBe("checking"); const credit = accts.find((a) => a.externalAccountId === "plaid-acc-credit")!; - expect(credit.currentBalance).toBe(45050); + // Plaid reports $450.50 owed as +45050; Ledgr stores owed as negative so + // net worth is the plain sum of balances. See db/schema/accounts.ts. + expect(credit.currentBalance).toBe(-45050); + // Credit limit is a capacity, not a debt — not sign-flipped. expect(credit.creditLimit).toBe(100000); expect(credit.type).toBe("credit"); + + // The same normalized value must reach balance_history, or the net-worth + // chart and the live figure would disagree by twice the debt. + const creditHistory = await db + .select() + .from(balanceHistory) + .where(eq(balanceHistory.accountId, credit.id)); + expect(creditHistory).toHaveLength(1); + expect(creditHistory[0].balance).toBe(-45050); }); it("stores null balances as null, not zero", async () => { diff --git a/tests/integration/report-queries.test.ts b/tests/integration/report-queries.test.ts index f11e2b68..57ff21f9 100644 --- a/tests/integration/report-queries.test.ts +++ b/tests/integration/report-queries.test.ts @@ -242,7 +242,7 @@ describe("getReportNetWorthHistory", () => { await insertBalance(accountId, "2026-04-01", 70000); await insertBalance(savingsId, "2026-04-01", 30000); - await insertBalance(creditId, "2026-04-01", 15000); + await insertBalance(creditId, "2026-04-01", -15000); await insertBalance(accountId, "2026-04-15", 80000); const { getReportNetWorthHistory } = await import("../../src/queries/reports"); @@ -252,7 +252,7 @@ describe("getReportNetWorthHistory", () => { const d1 = result.find((r) => r.date === "2026-04-01")!; expect(d1.assets).toBe(100000); // checking 70000 + savings 30000 - expect(d1.liabilities).toBe(15000); // credit + expect(d1.liabilities).toBe(-15000); // credit, stored negative expect(d1.netWorth).toBe(85000); const d2 = result.find((r) => r.date === "2026-04-15")!;