Commit Graph
9 Commits
Author SHA1 Message Date
nikolaandClaude Opus 5 7d4c3eba17 Open the report on last month
The report answered for the whole index, which is the one window a spending
report is least often asked for: last January's groceries sat in the same total
as last week's, and nothing on screen said which was which. It now opens on the
month that has just ended -- the last one a statement can be complete for --
and left/right step along an axis beside the totals, from all time down through
the named windows to the oldest month the index holds.

The period narrows the report and nothing else. reloadReport runs its own query
rather than reusing the rows the transaction list is showing: the two share the
account, the search and the untagged toggle, and differ only in the date
bounds, so opening on last month must not hide the rest of the index from the
list beside it. Nothing may put the period into m.filter, which is exactly what
would make it leak.

The windows are relative to today, never to the newest statement. "Last month"
with nothing in it reports nothing and says so, because silently answering for
a month nobody asked for is worse than an empty screen; an empty period and an
empty index therefore give different messages, one asking for another period
and the other for an import. The rolling windows run to the end of this month
rather than to the last complete one -- "last 3 months" is asked in order to
see what is happening now, and leaving out the days since the 1st answers a
question nobody put. The axis is built over the whole index rather than the
rows in view, or it would grow and shrink as the account or search filter
changed and move under the cursor; a reload rebuilds it, since an import can
reach further back, but keeps the window the user was on.

s cycles how those rows are arranged: largest out first as before, then in, net
lowest first so the biggest losses lead, count, and the tag A to Z. A letter
rather than a chord because the report is not a form. The marked heading says
which column the rows are read from and the help names what the key does next,
as the rule builder's preview already does. The sort rearranges rows and never
changes which rows there are, so the order stays out of the filter for the same
reason the period does. Currency remains the outer key under every order --
there are no rates here, so two currencies interleaved by amount would invite a
comparison that cannot be made -- and every order falls back to the tag, so
ties keep a fixed position instead of reshuffling between reloads.

money report takes the same choice as --sort out|in|net|count|tag, and a
misspelt one is refused rather than silently reporting in the default order. It
keeps --month and has no equivalent of the wider windows.

store.Filter gains From and To, compared as strings because dates are stored
ISO-8601 and a string comparison is therefore a date comparison. store.Months
replaces report.Months, which nothing had ever called: the axis needs the
months of the whole index, not of a slice already in hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:45:23 +02:00
nikolaandClaude Opus 5 5289250400 Key ls --uniq on the description alone
The listing keyed on account and description, so a payee seen on two accounts
was two rows. The rule builder groups by description alone and showed one, and
the two lists answer the same question -- what still needs a rule -- so the one
run from the shell overstated the work left and disagreed with the one on
screen.

A rule matches on the description and only optionally narrows to an account, so
one payee is one pattern to write however many accounts it turns up on. The
account column goes with the key; --account is still how the listing is scoped
to one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 20:10:57 +02:00
nikolaandClaude Opus 5 ebf7770569 Pair transfers from rules.toml
The boolean transfer flag went two commits ago because a one-sided verdict let
half a movement vanish and left the report unbalanced. This is what replaces
it: a [[transfer]] block names both legs, and only a matched pair is dropped
from the report -- both legs together, never one.

Legs pair within five days, nearest date first, and a transaction belongs to at
most one transfer, so the first definition to claim a leg keeps it, exactly as
the first matching rule keeps a tag. The pairing is derived state like the tags:
Engine.Link rewrites the whole transfers table from rules.toml, which is why
retag re-derives both halves of what that file decides, and why it runs over
the whole index rather than a filtered view -- pairing inside one would let a
movement count as a transfer in one report and not in another. An unmatched leg
is not a transfer and keeps counting, surfaced as a warning instead.

Within one currency the amount is the evidence and must be the exact opposite.
Across currencies it is not checked at all: there are no rates here, so the two
numbers are unrelated and the dates carry the pairing alone.

tolerance_pct is the one exception, per definition, for a route where the bank
takes a fee and the two statements genuinely disagree. It defaults to zero and
belongs on the one definition that charges; a global or default tolerance would
loosen every route that does not. The difference it admits is not forgiven --
the pair leaves the report entirely, so a fee hidden inside one would be
spending that appears nowhere. Pair.Fee is what left less what arrived, and
report.Excluded carries it out per currency alongside the legs. It counts only
pairs whose legs are both in view, for the same reason it counts legs and not
transfers: half a pair cannot say what the other half received.

The screens:

- 6 builds a definition against the index as you type, showing the pairs it
  would form and the legs it would catch but leave unpaired. Six fields need
  more room than the rule builder's four, so the form sheds its spacing, then
  its hints, then the borders on unfocused fields.
- 7 lists every definition with what it pairs. Two counts, because they mean
  different things: an unpaired leg is a definition doing something and not
  finishing it, no pairs at all is dead weight. Tol names the tolerance, blank
  where amounts must agree.
- 3 grows a (transfers) row under TOTAL, and a fees row beneath it, or the
  report silently disagrees with the account balances.

Two things that are not part of transfers but are the same day's work:

- ls --uniq lists each account and description once, normalised the way a glob
  sees them, which is the shape of "what still needs a rule?" -- fifty visits
  to one shop are one pattern to write, not fifty rows to read.
- The rule builder's preview now filters to what the glob matches instead of
  marking matches in a full list. The count carries the context the rows no
  longer can: 2 of 7, measured against everything still in view.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 19:19:12 +02:00
nikolaandClaude Opus 5 02814d09e5 Remove manual tagging
A tag could come from two places: rules.toml, or the TUI's t key, which wrote
manual_tag with COALESCE(manual_tag, rule_tag) deciding the winner. That split
paid for itself in the first invariant of the codebase, in ClearOverrides and
the c key, in the * marker on the tag column, and in the one exception to a
disposable index -- a tag set by hand was the only thing in index.db that the
statements could not reproduce.

Now rules.toml decides every tag. The index is derived entirely from the
statements plus that file, so deleting it and re-importing gets back exactly
what was there, and retag has nothing to be careful of. Tagging a one-off means
writing a narrow rule on screen 4, which previews what the glob catches before
it is saved.

A manual tag in an existing index is dropped along with the column the first
time this build opens it, and those rows read as whatever the rules say, or as
untagged. TestManualTagSurvivesRetag guarded the invariant that has just been
removed; TestRetagRewritesEveryTag replaces it with the one that took its
place, and keeps Retag itself covered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:19:24 +02:00
nikolaandClaude Opus 5 5ca6cff87b Remove the transfer flag
A transfer was a second verdict carried alongside the tag: a boolean set by
transfer = true in a rule or by x in the TUI, kept in its own pair of rule_
and manual_ columns, whose one real effect was to hold the row out of the
report. The rest of it was display -- a T column in the transaction list, in
the rules screen and in money ls.

Money moved between your own accounts is now tagged like anything else and
counts like anything else. The leg leaving checking is an outflow and the leg
arriving in savings is an inflow, so a report over the whole data root roughly
nets out while one scoped to a single account or month does not. That is the
price of one verdict per transaction instead of two.

A rule now needs a tag, and one that set only transfer = true is refused by
number on load. A leftover transfer key beside a tag is ignored, as unknown
TOML keys always were, and an index built by an older binary drops both
columns when it is opened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:07:47 +02:00
nikolaandClaude Opus 5 16c2585637 Drop counterparty, the generic parsers and .money/
counterparty was a structured field only nlb could fill honestly. revolut
and traderepublic invented one by running an IBAN-shaped regex over the
description they had just built, and the two spellings disagreed --
SI56 1234 5678 9012 345 against SI56123456789012345 -- so a literal rule
pattern that worked on one account silently matched nothing on another. It
is gone from the model, the index, the rule keys, ls --wide and the rules
screen. nlb now appends its IBAN column to the end of the description,
where the other two already keep theirs, so match = "*SI56*" works
everywhere. That changes those descriptions and with them their
fingerprints, so a statement overlapping an already-imported period will
re-add rather than dedupe those rows until the index is rebuilt. An index
built by an older binary drops the column when it is opened.

The index itself moves from .money/index.db up to index.db beside
rules.toml. Nothing looks in the old location, so an existing one has to be
moved by hand -- otherwise the tool quietly starts a fresh index and the
manual tags in the old file, the only thing statements cannot reproduce,
stay behind in it.

The csv and cmd parsers are gone along with the [csv] and [cmd] config they
carried. cmd shelled out to the Python extractors, which were ported to Go
and deleted, so it bridged to nothing; csv was a generic column-mapped
fallback that no account used, and between them they were the largest
configuration surface in the tool. A bank is now described in Go, where it
can be tested. The importer tests register their own three-column parser
rather than borrow a bank's, so they stay about the directory walk, dedupe
and per-file error reporting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:51:20 +02:00
nikolaandClaude Opus 5 3eca8f5376 Advertise the rule screens in the key help
The bottom help still described 4 as "rules" from before the rules list
existed, and never mentioned 5 at all, so the new screen was unreachable
unless you already knew about it.

The transactions line had also outgrown the window: at 137 characters it ran
past the edge of a normal terminal and "q quit" was simply gone. Help now
wraps to the window width instead of being cut off, and that line is shorter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 02:09:42 +02:00
nikolaandClaude Opus 5 cf1753fb0a Find the data root via the XDG config directory
Running the tool from anywhere required either a --root flag or an exported
MONEY_ROOT. Read the data root from $XDG_CONFIG_HOME/money/config.toml
(~/.config/money/config.toml) instead, so it can be configured once.

Resolution order is --root, then $MONEY_ROOT, then the config file, then
~/money. A leading tilde in the config file is expanded, since no shell has
done it for us, and relative paths are made absolute.

The new "money config" subcommand prints the resolved root and which rule
chose it, which is the first thing to check when the tool reads the wrong
directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 00:40:59 +02:00
nikolaandClaude Opus 5 b0026c5a79 Add money: statement-driven personal finance tracker
A data directory holds one folder per account. Statements dropped into
those folders are parsed into a rebuildable SQLite index, categorised by
ordered glob rules in rules.toml, and browsed or hand-tagged in a Bubble
Tea TUI. Movements between the user's own accounts are marked as
transfers by the same rules and excluded from spending totals.

Manual tags and transfer marks are stored separately from the rule-derived
ones and always win, so editing rules.toml and re-running retag never
destroys hand edits.

Parsers are pluggable. Three are ported from the Python extractors they
replace -- nlb and traderepublic read PDFs via pdftotext -layout, revolut
reads the CSV export -- alongside a configurable-column CSV parser and a
cmd parser that shells out to an external script.

Both ports fix two latent bugs in the originals: the sign character class
rejected the typographic minus U+2212 that some PDF fonts emit, and NLB's
hardcoded continuation indent broke when pdftotext compressed runs of
spaces, so the threshold is now measured from the description column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 00:37:20 +02:00