Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<!-- BEGIN:nextjs-agent-rules -->

# 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.

<!-- END:nextjs-agent-rules -->
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

<div align="center">
<img src="docs/images/budgets.jpeg" alt="Ledgr budgets screen with per-category spending progress" width="800" />
<br />
<em>Monthly budgets by category, tracked against real spending</em>
<br /><br />
<img src="docs/images/reports.jpeg" alt="Ledgr spending report with category breakdown" width="800" />
<br />
<em>Spending, income, cash flow, trends, and net worth reports</em>
</div>

## Quick Start

Requires [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/).
Expand Down
Binary file added SECURITY.pdf
Binary file not shown.
Binary file added docs/images/budgets.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/reports.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 6 additions & 3 deletions src/actions/plaid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down
79 changes: 71 additions & 8 deletions src/components/atoms/net-worth-area-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | number>;
type ChartDataPoint = Record<string, string | number | null>;

/** 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;
Expand All @@ -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 (
<div className="rounded-md border bg-popover px-3 py-2 text-sm shadow-md">
<p className="font-medium">{formatDateShort(label ?? "")}</p>
{payload.map((entry: TooltipEntry) => (
{entries.map((entry: TooltipEntry) => (
<p key={entry.name} className="tabular-nums" style={{ color: entry.color }}>
{entry.name}: {centsToDisplay(entry.value)}
</p>
Expand All @@ -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 (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data as ChartDataPoint[]} margin={{ top: 5, right: 5, bottom: 5, left: 5 }}>
<ComposedChart data={split} margin={{ top: 5, right: 5, bottom: 5, left: 5 }}>
<defs>
<linearGradient id="portfolioGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={POSITIVE_COLOR} stopOpacity={0.25} />
<stop offset="100%" stopColor={POSITIVE_COLOR} stopOpacity={0} />
</linearGradient>
<pattern id="uncoveredHatch" width={7} height={7} patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
<line x1="0" y1="0" x2="0" y2="7" stroke={UNCOVERED_COLOR} strokeWidth={1} opacity={0.28} />
</pattern>
</defs>
<CartesianGrid vertical={false} stroke="var(--border)" />
<XAxis dataKey="date" tickFormatter={formatDateShort} tick={AXIS_TICK} axisLine={false} tickLine={false} minTickGap={48} />
Expand All @@ -77,13 +117,36 @@ export function NetWorthAreaChart({ data, mode = "multi", seriesName = "Value" }
domain={["auto", "auto"]}
/>
<Tooltip content={<CustomTooltip />} />
{boundary.hasPartial && boundary.date && (
<ReferenceArea
x1={points[0].date}
x2={boundary.date}
fill="url(#uncoveredHatch)"
stroke="none"
ifOverflow="extendDomain"
/>
)}
{boundary.hasPartial && boundary.date && (
<ReferenceLine x={boundary.date} stroke={POSITIVE_COLOR} strokeWidth={1.5} />
)}
<Area
type="monotone"
dataKey="value"
dataKey="covered"
name={seriesName}
fill="url(#portfolioGradient)"
stroke={POSITIVE_COLOR}
strokeWidth={2}
connectNulls={false}
/>
<Line
type="monotone"
dataKey="partial"
name={boundary.hasPartial ? "Tracked accounts only" : seriesName}
stroke={boundary.hasPartial ? UNCOVERED_COLOR : POSITIVE_COLOR}
strokeWidth={boundary.hasPartial ? 1.75 : 2}
strokeDasharray={boundary.hasPartial ? "5 4" : undefined}
dot={false}
connectNulls={false}
/>
</ComposedChart>
</ResponsiveContainer>
Expand All @@ -92,7 +155,7 @@ export function NetWorthAreaChart({ data, mode = "multi", seriesName = "Value" }

return (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data as ChartDataPoint[]} margin={{ top: 5, right: 5, bottom: 5, left: 5 }}>
<ComposedChart data={data as unknown as ChartDataPoint[]} margin={{ top: 5, right: 5, bottom: 5, left: 5 }}>
<defs>
<linearGradient id="netWorthGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={POSITIVE_COLOR} stopOpacity={0.25} />
Expand Down
62 changes: 53 additions & 9 deletions src/components/organisms/net-worth-hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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) {
Expand All @@ -54,18 +59,36 @@ export function NetWorthHero({ netWorth, initialHistory, initialRange = "6M" }:
<span
className={cn(
"text-sm font-semibold rounded-full px-2.5 py-0.5 whitespace-nowrap",
delta.diff >= 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)}%)`}
</>
)}{" "}
<span className="font-medium opacity-75">
{RANGE_LABELS[range] ?? range.toLowerCase()}
{coverage.hasPartial
? `since ${formatDateShort(coverage.date ?? "")}`
: (RANGE_LABELS[range] ?? range.toLowerCase())}
</span>
</span>
)}
{!delta && coverage.hasPartial && (
<span className="text-sm font-medium rounded-full px-2.5 py-0.5 whitespace-nowrap text-muted-foreground bg-muted">
{coverage.date
? `full history since ${formatDateShort(coverage.date)}`
: "history incomplete"}
</span>
)}
</div>
</div>
<DateRangeSelector value={range} onChange={handleRangeChange} />
Expand All @@ -74,9 +97,30 @@ export function NetWorthHero({ netWorth, initialHistory, initialRange = "6M" }:
<NetWorthAreaChart
mode="single"
seriesName="Net worth"
data={history.map((p) => ({ date: p.date, value: p.netWorth }))}
data={history.map((p) => ({
date: p.date,
value: p.netWorth,
coveredAccounts: p.coveredAccounts,
totalAccounts: p.totalAccounts,
}))}
/>
</div>
{coverage.hasPartial && (
<p className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span
aria-hidden
className="inline-block h-0 w-5 shrink-0 border-t-2 border-dashed border-current"
/>
<span>
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.
</span>
</p>
)}
</section>
);
}
4 changes: 2 additions & 2 deletions src/components/organisms/report-net-worth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions src/components/organisms/report-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -40,7 +40,7 @@ interface ReportTabsProps {
incomeExpenseData?: IncomeExpenseRow[];
incomeExpenseCategoryData?: IncomeExpenseCategoryRow[];
trendsData?: CategoryTrendRow[];
netWorthData?: NetWorthPoint[];
netWorthData?: NetWorthSeriesPoint[];
sankeyNodes?: SankeyNode[];
sankeyLinks?: SankeyLink[];
cashFlowBarData?: IncomeExpenseRow[];
Expand Down
7 changes: 6 additions & 1 deletion src/components/organisms/simplefin-connect-flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
});
}
}
Expand Down
13 changes: 13 additions & 0 deletions src/db/schema/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading