Skip to main content
Glama
README.md
# finflow-mcp

Automatic personal finance tracker. It reads transaction notification emails from
Gmail, extracts the numbers, and appends them to a Google Sheets ledger — on a
schedule, without you opening anything.

```bash
finflow setup            # locale, timezone, base currency
finflow auth             # connect your own Google OAuth client
finflow sync             # creates the ledger, writes the first rows
finflow daemon install   # and then you can stop thinking about it
```

`finflow status` tells you whether it is still working.

> **Status: early development.** The core is built and tested; it has not yet run
> for weeks against a real mailbox. See [Honest limitations](#honest-limitations).

---

## Two ways to run it

**Autopilot (the point of the project).** A per-OS scheduled job runs
`finflow daemon run-once` and exits — launchd on macOS, a systemd user timer on
Linux, Task Scheduler on Windows. There is no long-lived background process,
which removes every memory-leak and stale-token class of bug and survives reboots
for free.

**MCP server.** Connect it to an AI client (Claude Desktop, Claude Code) and ask
about your spending, or drive a sync interactively. In this mode **the server
never calls an LLM** — the connected client does the extraction and posts results
back through the same validation and de-duplication the daemon uses.

```jsonc
// Claude Desktop config — check the current docs for the file location
{
  "mcpServers": {
    "finflow": { "command": "finflow-mcp" },
  },
}
```

---

## How an email becomes a row

```
Gmail search (short, language-agnostic query)
   └─ client-side scoring against every keyword pack   ← language support is free here
        └─ sanitise: mask cards/accounts, strip OTPs, truncate
             └─ extract
                  ├─ sender template        free, instant, offline, reproducible
                  ├─ Claude (optional)      opt-in, your own API key, cost-capped
                  └─ neither                parked in needsExtraction — never guessed
                       └─ validate (Zod) → categorise → de-duplicate → Sheets
```

Extraction is a strategy, so there is exactly **one** pipeline. The daemon and the
MCP server differ only in which extractor is plugged in — "validation is
identical on both paths" is a structural fact rather than something to keep
re-checking.

---

## Design commitments

**Read-only against Gmail.** Scopes are `gmail.readonly` and `drive.file`. No
modify, send or delete scope is ever requested, and `drive.file` cannot see any
file FinFlow did not create.

**Integer money.** Amounts are stored as integer minor units. There is no
floating-point arithmetic anywhere on the ledger path, currency exponents are an
explicit table (JPY is 0, KWD is 3), and an unknown currency is an error rather
than an assumption of 2.

**Ambiguity is refused, never guessed.** `Rp1.234` means different things in
different locales. When the text and the configured locale cannot settle it,
FinFlow reports `PARSE_AMBIGUOUS` instead of picking the likelier reading. In a
ledger, a wrong number recorded silently is worse than an email that fails loudly.

**Dates are Temporal, not milliseconds.** Month boundaries are computed as
`PlainDate → startOfDay(timezone)`. March 2026 is 744 hours in Jakarta, 743 in
New York and 745 in Berlin, and the tests assert exactly that.

**Language support is free where it can be.** Month names come from CLDR via a
build-time codegen (72 languages, committed so runtime never depends on the
user's ICU build); number separators come from `Intl`. Keyword scoring runs
client-side, so adding a language costs nothing in API quota or query length.

**Failures are visible.** The daemon writes a heartbeat on every run — success or
failure — and raises an OS notification once per failure streak, not once per
failure. Silent failure is the worst possible outcome for an autopilot: you go on
believing the ledger is complete while it quietly stops being so.

**Nothing leaves the machine uninvited.** Network egress is `googleapis.com`
only, plus `api.anthropic.com` if you explicitly enable the Claude extractor. In
that case only the sanitised body is sent — account numbers masked, one-time
codes removed, raw text never.

---

## Idempotency

The scheduler runs hourly over a 48-hour overlap window, so every email is seen
many times. Identity is deliberately split:

- `transactionId` = hash of `(source, sourceRef, occurrenceIndex)` — **which
  email**, and it never changes
- `contentHash` = hash of the extracted values — **what we read**, and it may

| Situation                         | What happens                                                                  |
| --------------------------------- | ----------------------------------------------------------------------------- |
| Same id, same hash                | Already recorded → skip                                                       |
| Same id, new hash                 | Re-read produced better values → **update the row**                           |
| New id, known hash within ±3 days | Possibly the same payment via a second email → record and **flag for review** |
| New id, new hash                  | Insert                                                                        |

The second row is why the two hashes are separate. Combined, a re-read that
produced any difference would get a new id, miss the lookup, and append a
duplicate — which with an hourly daemon is not a rare edge case.

Deleting a row in the sheet by hand is respected: a short ring of processed
email ids stops the next sync from helpfully putting it back.

---

## Configuration

`~/.finflow/config.json`, validated on every load.

| Key                               | Notes                                                                                                    |
| --------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `locale`, `timezone`, `dateOrder` | Detected from `Intl` but always confirmed — a wrong timezone files every transaction a day out, silently |
| `baseCurrency`                    | Totals are in this; other currencies are reported beside it, never converted                             |
| `numberFormat`                    | Optional override for banks that ignore your locale                                                      |
| `gmail.senderAllowlist`           | The most effective filter there is, and the only one that behaves the same in every language             |
| `extractor.enabled`               | Claude extraction. Off by default, with an explicit consent step                                         |
| `extractor.monthlyTokenBudget`    | Hard cap. Exceeding it stops the extractor, not the sync — templates keep working                        |
| `daemon.intervalMinutes`          | Default 60, with ±5 minutes of jitter                                                                    |

Secrets live in `~/.finflow/.env` (`0600`), never in `config.json`.

---

## Commands

| Command                                                                |                                                                                |
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `finflow setup`                                                        | Configure locale, timezone, currency, extraction                               |
| `finflow auth`                                                         | Connect Google (loopback + PKCE)                                               |
| `finflow sync [--dry-run]`                                             | Read new emails and record them. A dry run writes nothing, not even the cursor |
| `finflow daemon install \| uninstall \| status \| run-once \| preview` | The scheduled job. `run-once` is exactly what the scheduler invokes            |
| `finflow status`                                                       | Is it still working?                                                           |
| `finflow doctor`                                                       | Diagnose everything, including the 7-day OAuth trap                            |

---

## Honest limitations

- **Parsing quality depends on sender templates, not on language.** A BCA
  template does not help with Mandiri. The i18n work makes amounts and dates
  language-independent; it cannot make a bank's HTML layout universal. Expect the
  first week to lean on the Claude extractor if you enable it.
- **Not supported, by choice:** non-positional CJK numerals (二万五千), Japanese-era
  and Hijri calendars, currency conversion, and month names that are ambiguous
  across languages without a narrowing locale (`listopad` is November in Polish
  and October in Croatian). Each is reported, never guessed.
- **You will occasionally need to re-authenticate** — realistically only when you
  change your Google password, which revokes Gmail-scoped tokens and cannot be
  worked around.
- **Accuracy is not 100%.** The `needs_review` column and
  `extraction_confidence` exist so you can audit rather than trust blindly.

## Threat model, briefly

|                                   |                                                                                                                                                                               |
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Where secrets live**            | `~/.finflow/tokens/google.json` and `~/.finflow/.env`, mode `0600` in a `0700` directory. `finflow doctor` verifies and repairs the permissions                               |
| **What reaches logs**             | Everything passes through a redaction layer with two independent rules — by field name and by value shape. A test suite scans logger output for tokens, PANs and email bodies |
| **What reaches an external API**  | Nothing, unless you enable the Claude extractor. Then: the sanitised body only                                                                                                |
| **Blast radius if a token leaks** | Read access to your Gmail and to the one spreadsheet FinFlow created. Revoke at [myaccount.google.com/permissions](https://myaccount.google.com/permissions)                  |
| **What FinFlow cannot do**        | Send, modify or delete mail; read any other Drive file; move money                                                                                                            |

## Requirements

- Node.js >= 20
- Your own Google Cloud project — see [docs/google-setup.md](docs/google-setup.md)
- Optional: an Anthropic API key, only if you enable the Claude extractor

## Development

```bash
npm install
npm run typecheck && npm run lint && npm test
npm run gen:month-names   # regenerate the CLDR month table (output is committed)
```

## License

MIT

Maintenance

ActivityMaintained
ResponsivenessNo issues