Skip to main content
Glama
aroesec

moneybags

by aroesec
README.md
# Moneybags

A self-hosted personal finance ledger that you can talk to.

Import statements or sync your banks. Transactions get categorized by rules
first and a model second, and every correction you make teaches a rule so the
same merchant is never miscategorized twice. Then ask about your money in plain
language — Moneybags runs an MCP server, so Claude reads your ledger directly.

```
"How much did I spend on groceries in August?"
"I just bought coffee, about six dollars"
"That Venmo payment was for tree work, not uncategorized"
```

One deployment, one owner. Your transactions live in your database, your API
keys are yours, and there is no service in the middle.

---

## What this is, and what it isn't

**It is** a ledger for someone who wants their financial data in a database
they control, with a categorizer that can be corrected and a conversational
interface that isn't a chatbot bolted onto a dashboard.

**It isn't** a budgeting app with a mobile client and a support team. There is
no signup, no multi-tenancy, no hosted version. If you want an app your family
can log into on their phones, use Monarch or YNAB — genuinely, they are good at
that and this is not trying to be.

Running it costs whatever your database and API keys cost. For a personal
ledger on Neon's free tier with statement upload only, that is nothing.

## Why the design is the way it is

Nearly every hard decision in this codebase is about **not silently losing
money**. Not crashing — losing. A ledger that drops a category is annoying; a
ledger that drops $6,000 and still balances is dangerous, because it looks
correct.

These are the rules that follow from that:

**Money is integer cents.** `bigint` in the schema, `number` in TypeScript.
Floats appear only at the formatting boundary. Nothing sums a float, ever.

**Negative means money left.** Applied in parsing, storage, ledger math and UI,
so a period's net cashflow is a plain `SUM(amount_cents)` with no per-row
branching. An import adapter that gets this backwards produces a ledger that is
internally consistent and entirely wrong, which is why it is the one thing
adapters are told twice.

**`is_transfer` is not a category.** It means *this exact dollar is already
counted somewhere else in this ledger* — an internal transfer that names the
other account, or a credit card payment whose purchases are also imported. It
is emphatically not for Venmo, Zelle, Cash App, ATM withdrawals, or savings
contributions. Money that left is spending, whichever rail carried it.

**A payment rail is not a merchant.** "Venmo" tells you how money moved and
nothing about what it bought. Those rows are charged as spending immediately —
so an unanswered question never quietly reduces the month's total — and queued
for you to label. One answer teaches a rule keyed to the counterparty.

**Manual classifications are never overwritten.** Every automated pass filters
on `classification_source <> 'manual'`. Your answer outranks any rule and any
model.

**Dedupe is by fingerprint, not by statement.** `sha256(account, date, amount,
normalized description)` with a unique index. Upload overlapping statements in
any order; rows already present are skipped. The account is part of that
fingerprint, which is why an unfiled import is refused once you have more than
one account.

**Income can only be lost one way.** Totals split by sign, so a positive amount
counts as income whatever category it lands in — an imperfect category still
counts, and a classification failure costs nothing. `is_transfer` is the single
point of failure, so a rule may only set it on an inflow when the pattern names
a payment outright or names the other account. `pnpm db:audit-income` lists
every inflow and every rule currently able to exclude one.

**The classifier refuses to guess.** Rules run first. Whatever is left goes to
a model. Anything still unresolved lands in a review queue, not a confident
wrong answer — and descriptions that structurally cannot carry a purpose skip
the model entirely, because it would answer "unknown" every time at a cost.

## Setup

Node 20+, pnpm, and a Postgres database.

```bash
git clone https://github.com/YOUR-USERNAME/moneybags && cd moneybags
pnpm install
cp .env.example .env.local
```

Fill in three things:

```bash
# 1. Your database
DATABASE_URL="postgresql://..."

# 2. A session secret
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"

# 3. A password
pnpm auth:hash 'the password you want'      # prints APP_PASSWORD_HASH=...
```

Then:

```bash
pnpm db:migrate
pnpm db:seed        # idempotent; seeds the category taxonomy
pnpm dev
```

That is a working ledger with CSV statement import. Everything below is
optional and the app is honest about what each one adds.

### Optional: a model

Set `AI_API_KEY`. You get model-assisted categorization for merchants no rule
recognizes, written-up insights, and PDF/image statement reading.

Without it, rules still categorize, unmatched rows go to the review queue, and
CSV import is unaffected. That is a supported way to run this, not a broken
one.

Any provider works — Anthropic, OpenAI, OpenRouter, Groq, or a local Ollama or
LM Studio. See [`docs/ai.md`](docs/ai.md). PDF reading needs Anthropic; every
other feature works anywhere.

### Optional: bank syncing

Connect accounts through Plaid instead of uploading statements. Plaid's free
tier covers 10 connections and includes transactions.

**You do not need this.** Statement upload is a complete way to use the app,
and skipping Plaid means one less third party holding credentials to your
bank. See [`docs/plaid.md`](docs/plaid.md), which also explains the free-tier
trap worth knowing before you start.

### Optional: talking to it

Settings → issue an MCP token, then point any MCP client at
`https://your-host/api/mcp` with that bearer token. Fourteen tools for reading,
logging and correcting. There is deliberately **no delete tool** — a misheard
instruction must not be able to destroy a record.

## Deploying

Two documented paths, neither privileged over the other:

**Docker Compose** — app plus Postgres, nothing else needed:

```bash
cp .env.example .env    # set APP_PASSWORD_HASH and SESSION_SECRET
docker compose up -d
```

**Vercel + Neon** — free tier, no server to run.

Both are in [`docs/deploy.md`](docs/deploy.md), including the reverse-proxy
setup if you want it behind Authelia or Tailscale.

## Authentication

Three methods; configure at least one or the app refuses to start rather than
serving your finances to anyone who finds the URL.

| Method | For |
|---|---|
| **Password** | The default. Store a scrypt hash via `pnpm auth:hash`, not plaintext. |
| **OIDC** | Any standards-compliant provider — Google, Authentik, Keycloak, Zitadel, Okta. An allowlist is required; an empty one denies everyone. |
| **Trusted header** | Already behind Authelia, oauth2-proxy, Cloudflare Access or Tailscale. Only safe when the app is unreachable except through the proxy. |

Login is rate-limited, passwords are compared in constant time, sessions are
signed JWTs revocable in bulk via `SESSION_VERSION`, and Plaid access tokens
are AES-256-GCM encrypted at rest. See
[`docs/security.md`](docs/security.md) for the threat model and what it does
*not* protect against.

## Composability

Transactions enter through one boundary: `src/lib/sources`. Everything
downstream — dedupe, reconciliation, classification, the ledger — sees only
`ParsedTransaction[]` and cannot tell whether a row arrived by upload or sync.

Adding a bank, an aggregator, or an awkward CSV dialect is an adapter and
nothing else:

```ts
registerFileSource({
  id: "my-bank",
  label: "My Bank CSV",
  accepts: ({ filename }) => filename.startsWith("mybank-"),
  parse: ({ bytes }) => ({ transactions: parseMyBank(bytes), warnings: [] }),
});
```

The model provider is behind the same kind of seam — no vendor SDK is imported
outside `src/lib/ai`. [`docs/extending.md`](docs/extending.md) covers the
taxonomy, rules, sources, sync providers and MCP tools.

## Commands

| | |
|---|---|
| `pnpm dev` · `build` · `test` · `typecheck` | the usual |
| `pnpm auth:hash '<password>'` | generate `APP_PASSWORD_HASH` |
| `pnpm db:migrate` → `db:seed` | schema, then taxonomy |
| `pnpm db:reclassify` | re-run the pipeline over the ledger, skipping manual rows |
| `pnpm db:audit-income` | verify no inflow is being excluded from income |
| `pnpm db:plaid-status` | what is linked, and each account's sync boundary |

## Stack

Next.js 15 (App Router), Postgres via Drizzle, Tailwind. The Anthropic SDK and
Plaid SDK are both optional at runtime and isolated behind interfaces.

## Contributing

[`CLAUDE.md`](CLAUDE.md) documents why things are the way they are, usually
naming the bug that caused them. Read it before touching
`src/lib/classify` or `src/lib/reconcile` — several regressions are pinned by
tests, and the comments say what breaks if you undo them.

The general rule when changing the classifier: **prefer under-matching to
over-matching.** A rule that never fires again costs one re-correction. A rule
that over-matches silently rewrites history you already checked.

## License

MIT — see [LICENSE](LICENSE).

This handles real financial data. It comes with no warranty, and your
deployment, keys and backups are your responsibility.