---
name: openclaw-finance
description: Use when working on the openclaw-finance repo — a personal Raspberry Pi service that polls SimpleFIN, triages transactions (rules → memory → LLM fallback), writes each one to a Notion Expenses database, and pings Discord for low-confidence rows. Covers the CLI, module layout, testing conventions, and the rules that must never drift.
---

# openclaw-finance

Personal finance tracker. Python 3.14, `uv`, `httpx`, `pydantic v2`, SQLite, `pytest` + `respx`, `ruff`, `mypy --strict`. Notion is the source of truth; SQLite (`~/.openclaw/finance.db` by default) is local-only bookkeeping.

See `CLAUDE.md` in the repo root for the full product spec. This skill is the shorter operator's manual.

## When to use

Apply this skill whenever the user is:

- Running the CLI: `migrate`, `poll-once`, `serve`, `replay <id>`, `ask "…"`.
- Debugging triage — why did a transaction end up `Needs review`, why did memory not fire, etc.
- Changing the Notion schema, adding a new Notion property, or touching `NotionExpenseRow` / `NotionBudgetRow`.
- Editing any file under `src/openclaw_finance/` or `tests/`.
- Adding a new phase-style feature described in `CLAUDE.md`.
- Deploying to the Pi (systemd units in `deploy/`).

Don't invoke this skill for generic Python questions unrelated to this repo.

## Quick orientation

```
src/openclaw_finance/
  cli.py                  # Typer entrypoint: migrate / poll-once / serve / replay / ask
  config.py               # Settings.load() reads .env
  models.py               # All pydantic contracts (NotionExpenseRow is law)
  ingress/
    simplefin.py          # poll_once(access_url, conn, dry_run=, now=) — applies normalize_merchant()
    health.py             # record_success / record_failure + Discord alert on streak
  triage/
    normalize.py          # normalize_merchant("SQ *FOO BAR 1234") -> "Foo Bar"
    rules.py              # CATEGORY_RULES list; match(merchant, description)
    memory.py             # get / upsert on merchant_memory (source: rule|user|llm)
    pipeline.py           # triage(conn, txn, llm=, confidence_threshold=): memory -> rules -> LLM -> fallback
    llm.py                # LLMClient Protocol + AnthropicLLMClient (haiku)
    budget_alerts.py      # check_and_alert — fires one Discord alert per (category, month, threshold)
  notion/
    client.py             # NotionClient(token): query_database / get_page / create_page / update_page
    expenses.py           # to_notion_properties / from_notion_page / find_by_simplefin_id / upsert_row / row_from_triage
    budgets.py            # list_active_budgets / compute_budget_status(month)
    trigger.py            # sweep_learning (poll-based, default) + handle_event (webhook path) — both share reconcile_row
  discord/
    card.py               # post_review_card — rich embed, no interactive buttons (webhook-only)
    handler.py            # apply_review_reply — manual category confirmation path
  recap/
    common.py             # fetch_expenses_in_range, totals_by_category, recap_runs idempotency
    weekly.py             # render_weekly / run_weekly (ISO week keyed)
    monthly.py            # render_monthly / run_monthly (YYYY-MM keyed, 3-mo trailing avg)
  ask/
    intents.py            # scoped Intent union: spend_in_category / top_merchants / subscriptions_over / budget_status / unknown
    plan.py               # AnthropicAskClient (default claude-sonnet-4-6) + parse_intent
    execute.py            # Intent -> Notion queries -> formatted answer
  storage/
    db.py                 # connect, run_migrations
    migrations/           # 001_init.sql, 002_poll_health.sql — additive only
```

## CLI

Always run through `uv`:

```bash
uv run openclaw-finance migrate                       # apply SQLite migrations
uv run openclaw-finance poll-once [--dry-run]         # SimpleFIN -> triage -> Notion upsert -> Discord card -> learning sweep
uv run openclaw-finance ask "am I over budget?"       # NL -> scoped intent -> Notion queries (Sonnet 4.6)
uv run openclaw-finance replay <simplefin_id>         # re-triage an existing row, print old vs new decision
```

There's no long-lived `serve` process. Scheduling is done by the systemd timer on the Pi (see `deploy/`), and the Notion learning loop runs inline at the end of each `poll-once` via `sweep_learning` — no webhook, no tunnel.

Before anything talks to Notion / SimpleFIN / Anthropic / Discord, the matching env var must be set. See `.env.example`.

## The rules that must not drift

These are non-negotiable. Breaking any of them costs data or correctness.

1. **Notion property names live in `notion/expenses.py`** (`to_notion_properties` / `from_notion_page`). If you rename a Notion column, update both functions *and* the schema in the same change. The expected keys are enumerated in `CLAUDE.md` under "Typed contracts".
2. **Idempotency is keyed on `SimpleFIN ID`.** `upsert_row` in `notion/expenses.py` and `processed_events` in SQLite both rely on `f"{account_id}:{txn_id}"`. Never derive a second key.
3. **Dry-run must work fully offline.** `poll_once(..., dry_run=True)` rolls back SQLite writes and skips Notion. Any new side effect needs a dry-run guard or a fake-client test path.
4. **No live LLM in tests.** Use `FakeLLM` / fake `LLMClient` implementations (see `tests/test_llm.py`, `tests/test_ask.py`). LLM errors in the pipeline must fall through to the `Needs review` fallback — they never raise.
5. **Every triage decision writes `audit_log`.** `pipeline.triage` is the only writer; don't bypass it.
6. **Migrations are additive and SQL-only.** Add `NNN_name.sql` files to `storage/migrations/`; never edit a committed migration. `run_migrations` tracks applied files in `schema_migrations`.
7. **`merchant_memory` source column is `rule`, `user`, or `llm`** — enforced by a CHECK constraint. User confirmations via Discord or the Notion trigger always upsert with `source='user'` and `confidence=1.0`.
8. **Log `simplefin_id` in every error path.** It's the only key that lets `replay` reproduce the issue.
9. **Never commit `.env`.** Only `.env.example` is tracked.
10. **`merchant_normalized` is set in ingress, not later.** `ingress/simplefin.py::_to_normalized` calls `normalize_merchant(merchant_raw)`. Rule regexes and memory keys depend on that string — if you ever leave it equal to the raw value, rules like the coffee/streaming regexes will silently miss every real SimpleFIN payload.

## Testing discipline

- `uv run pytest` or `make check` must be green before declaring work done.
- Use `tmp_db` fixture (in `tests/conftest.py`) for anything touching SQLite. It applies all migrations into a fresh file.
- Use `respx` for HTTP. There is no integration test suite that hits real services; don't add one.
- Fixtures under `tests/fixtures/simplefin/` cover the shapes we care about: `sample.json`, `pending_then_posted.json`, `refund.json`, `fx.json`, `duplicate.json`. Reuse these before adding new ones. After the normalize step was wired into ingress, assertions on `merchant_normalized` should expect the titlecased stripped form (e.g. `"Blue Bottle Coff"`, not `"SQ *BLUE BOTTLE COFF"`).
- When a bug is suspected, write the failing test first using a fixture; fix until green.

## Common tasks

**Add a new triage rule:** extend `CATEGORY_RULES` in `triage/rules.py`, add a parametrized case in `tests/test_rules.py`. Rule confidence is `0.9`; don't drift that.

**Add a Notion property:** update `NotionExpenseRow` in `models.py`, update `to_notion_properties` + `from_notion_page`, update Notion's schema in the same PR. Add the property name to `tests/test_notion.py::test_to_notion_properties_shape` expected keys. Run `mypy` — it will flag missed assignments.

**Add a new Ask intent:** add the pydantic class in `ask/intents.py` (with a unique `kind` literal), extend the Intent union, add an `_answer_*` handler in `ask/execute.py`, add a case to the `match` in `execute`, update the `SYSTEM_PROMPT` in `ask/plan.py`, add parser + executor tests. The `match` is exhaustive — mypy will fail if you miss a case.

**Change LLM model:** edit `DEFAULT_MODEL` in `ask/plan.py` (Sonnet for planning) or `LLM_MODEL` env (Haiku for triage). Do not hardcode model names inside `pipeline.triage`.

**Write a txn to Notion:** use `row_from_triage(txn, result)` in `notion/expenses.py` — it's the single mapping from `NormalizedTxn` + `TriageResult` to a `NotionExpenseRow`. The `raw_payload` field is a `json.dumps(txn.raw)` string, which `replay` parses back out. Don't build rows by hand.

**Replay a txn:** the `replay` CLI reconstructs a `NormalizedTxn` from the Notion row (not from SQLite) and pulls `raw` from the `Raw payload` property. This is why the spec says "Notion is the source of truth" — `processed_events` only stores the id + URL, not the payload.

**Deploying to Pi:** see `deploy/README.md`. `.service` + `.timer` pairs are `@`-templated on the Unix user.

## Failure modes to watch for

- **Cursor drift on SimpleFIN:** `sync_cursor` only stores the highest `posted` per account. First poll looks back 7 days; later polls use `min(cursor) - 24h` to catch late-posting txns. Don't widen further or we spam duplicates.
- **Pending → posted transitions:** SimpleFIN keeps the same `id` when a txn moves from pending to posted. Our dedupe via `processed_events` covers this — but if you ever bypass it, you'll double-insert the row in Notion.
- **Budget alerts firing twice:** the `budget_alerts` table keys on `(category, month, threshold)`. Any change to the thresholds list must keep that key unique or idempotency breaks.
- **Recap timer double-fires:** `recap_runs` guards on `(period, period_key)`. `run_weekly`/`run_monthly` return `None` on replay — check for that before posting.

## Useful grep starting points

- "Why isn't this txn getting categorized?" → `triage/pipeline.py`, then `merchant_memory` in SQLite, then `audit_log`.
- "Where do Notion property names come from?" → `notion/expenses.py`.
- "Where does dry-run gate live?" → `ingress/simplefin.py::poll_once` and the `dry_run` parameter on `run_weekly` / `run_monthly`.
- "How does the Discord alert streak reset?" → `ingress/health.py::record_success`.
