Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ec9976b80 | ||
|
|
ec8a844441 |
@@ -2,7 +2,8 @@
|
||||
|
||||
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, and browsed in a Bubble Tea TUI.
|
||||
SQLite index, categorised by ordered glob rules, and browsed in a web app
|
||||
(`money serve`, which is also the default command).
|
||||
|
||||
Read `README.md` first — it is the user-facing reference for every config key.
|
||||
This file covers what the code assumes and why.
|
||||
@@ -10,7 +11,7 @@ This file covers what the code assumes and why.
|
||||
## Layout
|
||||
|
||||
```
|
||||
cmd/money/main.go subcommands; the TUI is the default
|
||||
cmd/money/main.go subcommands; serve is the default
|
||||
internal/config rules.toml (rules + transfers), account.toml, XDG config, data-root resolution
|
||||
internal/model Account, Transaction, amount formatting, description normalisation
|
||||
internal/glob the `*` / `?` matcher used by rules (two-pointer, no exponential blowup)
|
||||
@@ -20,7 +21,6 @@ internal/importer directory walk, dedupe, balance checks
|
||||
internal/rules applies ordered rules, writing rule_tag
|
||||
internal/transfers pairs the two legs of a movement, writing the transfers table
|
||||
internal/report per-tag aggregation
|
||||
internal/tui Bubble Tea models
|
||||
internal/web `money serve`: JSON API + embedded single page (static/)
|
||||
```
|
||||
|
||||
@@ -59,7 +59,7 @@ than forgiven.** That is the condition on the tolerance existing at all: the
|
||||
pair leaves the report entirely, so a difference swallowed inside one would be
|
||||
spending that never appears anywhere. `Pair.Fee` is what left less what
|
||||
arrived, and `report.Excluded` carries it out per currency alongside the legs —
|
||||
named on the `money report` line and on its own row under the TUI's report.
|
||||
named on the `money report` line and on its own row under the web report.
|
||||
Nothing may pair on a mismatch without that difference reaching `Excluded`.
|
||||
It is a fee and not a tag: no rule produces it, `report.ByTag` never sees it,
|
||||
and it must not be turned into a synthetic transaction to make the total
|
||||
@@ -145,49 +145,41 @@ hand, while a narrower one is meant to take precedence and does.
|
||||
|
||||
**The report's period narrows the report and nothing else.** The screen opens
|
||||
on last month, and `←`/`→` step along `report.Periods` — the named windows,
|
||||
then the months the index holds. `reloadReport` therefore runs its *own* query
|
||||
rather than reusing the rows the transaction list is showing: the two share
|
||||
`m.filter` (account, search, untagged) 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. Covered by `TestReportPeriodLeavesTheTransactionListAlone`.
|
||||
then the months the index holds. The report and the transaction list share a
|
||||
scope (account, search, untagged), which `web.filterFrom` reads and
|
||||
`state.filter` holds in the page; the period is read by the report handler
|
||||
alone and kept in `state.report`, so opening on last month cannot hide the
|
||||
rest of the index from the list. Nothing may put the period into that shared
|
||||
scope, which is exactly what would make it leak. Covered by `TestReportPeriodLeavesTransactionsAlone`.
|
||||
|
||||
The axis is built from `store.Months` over the *whole* index, not from the rows
|
||||
in view, or it would grow and shrink as the account or search filter changed and
|
||||
move under the cursor. It is rebuilt on every `reload` (an import can reach
|
||||
further back) but keeps the window the user was on. 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. That is also why an empty period and an empty index
|
||||
give different messages — one asks for another period, the other for an import.
|
||||
move under the cursor. It is rebuilt on every request (an import can reach
|
||||
further back), and the page names the period by its label rather than its
|
||||
position, so a grown axis keeps the window the user was on. 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. That is also why an empty period and
|
||||
an empty index give different messages — one asks for another period, the
|
||||
other for an import.
|
||||
|
||||
**The report's sort rearranges rows; it never changes which rows there are.**
|
||||
`report.Order` is passed to `ByTag` and is deliberately *not* part of
|
||||
`store.Filter` or `m.filter` — the period decides what is counted, the order
|
||||
only how it is listed, and merging the two would make a sort able to hide a
|
||||
tag. Currency stays the outer sort key under every order, because there are no
|
||||
`store.Filter` or the shared scope — the period decides what is counted, the
|
||||
order only how it is listed, and merging the two would make a sort able to hide
|
||||
a tag. Currency stays the outer sort key under every order, because there are no
|
||||
exchange rates to compare two currencies by, and every order falls back to the
|
||||
tag so ties keep a fixed position instead of shuffling between reloads.
|
||||
`Order.Column` is what lets the TUI mark the sorted heading without keeping its
|
||||
`Order.Column` is what lets the page mark the sorted heading without keeping its
|
||||
own copy of that mapping; a new order needs a column of its own, which
|
||||
`TestOrderColumnsAreDistinct` checks.
|
||||
|
||||
**The builders are forms, so the global keymap must not apply there.**
|
||||
`Update` routes to `updateRules` / `updateTransfers` before `updateNormal`
|
||||
whenever the view is `viewRules` or `viewTransfers`, or typing `q` would quit
|
||||
and `i` would start an import. Any new full-screen input needs the same
|
||||
treatment. It cuts the other way too: a command *inside* a builder has to be a
|
||||
chord — `ctrl+s` re-sorts the preview — because every printable key belongs to
|
||||
the field being typed in.
|
||||
|
||||
**In the rule builder, `tab` completes first and moves focus second.** The
|
||||
account and tag fields use `textinput.ShowSuggestions`, whose own `AcceptSuggestion`
|
||||
key is `tab` and whose `NextSuggestion`/`PrevSuggestion` are `up`/`down` — all
|
||||
three already meant something here. So `updateRules` intercepts `tab` and calls
|
||||
`acceptCompletion` before falling back to `setRuleFocus`, keeps `up`/`down` on
|
||||
field navigation, and lets `ctrl+n` / `ctrl+p` through to the input for cycling.
|
||||
`SetValue` does not re-match the suggestion list, so `acceptCompletion` re-sets
|
||||
it afterwards or `ctrl+n` would offer candidates that no longer fit the value.
|
||||
**The single-key shortcuts must not fire while typing.** The page's `keydown`
|
||||
handler ignores them whenever the target is an input, or typing `i` in the tag
|
||||
field would start an import. A view's own `key` hook runs first and is told
|
||||
whether the user is typing, so a command *inside* a builder has to be a chord —
|
||||
`ctrl+s` re-sorts the preview — because every printable key belongs to the
|
||||
field being typed in.
|
||||
|
||||
**A rule's usage count is how many transactions it wins, not how many its glob
|
||||
could match** — `Engine.Usage` counts by `MatchIndex`, so a rule shadowed by a
|
||||
@@ -211,41 +203,51 @@ writer goes through `writeFileAtomic`, and both editors re-parse the result
|
||||
before replacing the file.
|
||||
|
||||
**An edit must not lose what the builder does not show.** The form has four
|
||||
fields and a `Rule` has five, so `saveRule` carries `Type` through from the rule
|
||||
being edited and the form says it is doing so. Nothing may round-trip a rule
|
||||
through those four fields alone — a pattern the user was never shown is not a
|
||||
pattern they chose to remove. A new field on `Rule` needs the same treatment or
|
||||
a field of its own. Covered by `TestRuleEditKeepsTypePattern`.
|
||||
fields and a `Rule` has five, so `editRule` takes `Type` from the rule on disk —
|
||||
never from the request — and the form says it is keeping it. Nothing may
|
||||
round-trip a rule through those four fields alone — a pattern the user was
|
||||
never shown is not a pattern they chose to remove. A new field on `Rule` needs
|
||||
the same treatment or a field of its own. Covered by `TestEditKeepsTypePattern`.
|
||||
|
||||
**The rule builder's preview is what the rule is judged against, which is not
|
||||
always "what is untagged".** For a new rule those are the same thing. For an
|
||||
edit they are not: the rule's own transactions are tagged, so an untagged-only
|
||||
preview would be empty for a rule that works. `reloadPreviewGroups` adds them
|
||||
back, and `refreshRulePreview` keeps the ones the new glob stops catching on
|
||||
screen marked `−` instead of dropping them silently, because giving one up is
|
||||
the decision being made.
|
||||
preview would be empty for a rule that works. `previewGroups` adds them back,
|
||||
and `rulePreview` keeps the ones the new glob stops catching on screen marked
|
||||
`−` instead of dropping them silently, because giving one up is the decision
|
||||
being made. Covered by `TestEditPreviewShowsWhatIsLetGo`.
|
||||
|
||||
**The web app is a second frontend, not a second implementation.**
|
||||
`internal/web` ports the TUI's screens over HTTP, and everything that decides
|
||||
something stays in Go: amounts are formatted server-side (`money` is text plus
|
||||
a sign, never a number the browser could add up), the rule builder's preview is
|
||||
matched by `glob.Match` on the server rather than re-implemented in JS, and the
|
||||
transfer preview runs `transfers.Analyze`. Keep it that way — a JS glob or a
|
||||
JS sum is a second copy of a rule that would drift from the first.
|
||||
**The web app is a frontend, not a second implementation.** Everything that
|
||||
decides something stays in Go: amounts are formatted server-side (`money` is
|
||||
text plus a sign, never a number the browser could add up), the rule builder's
|
||||
preview is matched by `glob.Match` on the server rather than re-implemented in
|
||||
JS, and the transfer preview runs `transfers.Analyze`. Keep it that way — a
|
||||
JS glob or a JS sum is a second copy of a rule that would drift from the
|
||||
first.
|
||||
`static/app.js` only lays out what the API returns.
|
||||
|
||||
The web server outlives edits to rules.toml in a way the TUI does not, so
|
||||
`/api/retag` and `/api/import` re-read it first (import also re-reads the
|
||||
The server outlives edits to rules.toml in a way a one-shot command does not,
|
||||
so `/api/retag` and `/api/import` re-read it first (import also re-reads the
|
||||
account folders), and `/api/overview` reports `stale` when the file on disk no
|
||||
longer equals what the engines hold. Every edit and delete sends the rule or
|
||||
transfer it showed alongside the position, and is refused with 409 unless
|
||||
rules.toml still holds exactly that there — a position from a stale page can
|
||||
otherwise name a different rule. An edit takes `Type` from the rule *on disk*,
|
||||
never from the request, which is the web side of `TestRuleEditKeepsTypePattern`.
|
||||
otherwise name a different rule. Covered by `TestStalePositionIsRefused`.
|
||||
|
||||
There is no auth by design (`--addr` defaults to loopback). Non-GET requests
|
||||
must be `application/json`, which is what keeps a cross-site form from posting
|
||||
to it; do not relax that without putting something else in its place.
|
||||
That is why `/api/upload` takes files base64 inside JSON rather than as
|
||||
multipart: multipart is precisely what a cross-site form can send.
|
||||
|
||||
**An upload only puts a file where `money import` looks.** It writes into an
|
||||
existing account folder and then runs the same import as `/api/import`, so the
|
||||
statements stay the source of truth and nothing reaches the index any other
|
||||
way. It never overwrites a statement (identical contents are a no-op, different
|
||||
ones a 409), refuses any name import would not read back — not a plain file
|
||||
name, a dotfile, `account.toml`, outside the account's `include` — and checks a
|
||||
whole batch before writing any of it. Covered by
|
||||
`TestUploadNeverReplacesAStatement` and `TestUploadRefusesBadNames`.
|
||||
|
||||
## Adding a bank parser
|
||||
|
||||
@@ -306,20 +308,18 @@ migrates it, so a demo root left over from an earlier build either carries dead
|
||||
columns or fails with `no such column`, depending on which way the schema
|
||||
moved.
|
||||
|
||||
### Driving the TUI in tests
|
||||
### Testing the web app
|
||||
|
||||
Prefer feeding `tea.Msg` values to `Model.Update` directly — that is how every
|
||||
existing TUI test works, and it covers the keymap without a terminal.
|
||||
Drive the API through `Server.Handler()` with `httptest` — `newTestServer` in
|
||||
`internal/web/server_test.go` builds a data root and an index tagged and paired
|
||||
as an import would leave them, with "today" fixed so the report's default
|
||||
window is predictable. That is how every web test works, and since the page
|
||||
only lays out what the API returns, it is where behaviour gets tested.
|
||||
|
||||
If a real terminal is genuinely needed, note that piping into `script` does not
|
||||
deliver keystrokes. Use a pty and answer the two capability queries Bubble Tea
|
||||
sends on startup, or the program blocks before its first render:
|
||||
|
||||
- `ESC]11;?` (background colour) → reply `ESC]11;rgb:1e1e/1e1e/1e1e ESC\`
|
||||
- `ESC[6n` (cursor position) → reply `ESC[1;1R`
|
||||
|
||||
Also delete the index first, or the import you are trying to observe will be
|
||||
skipped by checksum and finish instantly.
|
||||
For a look at the real page, run `money --root <demo> serve --addr
|
||||
127.0.0.1:<port>` against a throwaway root. Delete the index first if you want
|
||||
to watch an import do work, or it will be skipped by checksum and finish
|
||||
instantly.
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
@@ -84,8 +84,7 @@ only if you hand the binary to someone else.
|
||||
## Usage
|
||||
|
||||
```
|
||||
money # open the TUI (default)
|
||||
money serve # the same screens as a web app, on 127.0.0.1:8080
|
||||
money # the web app on 127.0.0.1:8080 (same as `money serve`)
|
||||
money serve --addr :8080 # listen on every interface (there is no login)
|
||||
money import # extract new transactions from every statement
|
||||
money import --force # re-parse statements even if unchanged
|
||||
@@ -103,7 +102,8 @@ money config # which data root is in use, and why
|
||||
|
||||
An account folder appears in the app only once its statements have been
|
||||
imported — creating an `account.toml` is not enough on its own. Run
|
||||
`money import` (or press `i` in the TUI) after adding one.
|
||||
`money import` (or press Import in the web app) after adding one, or add its
|
||||
first statements from the Accounts screen, which imports them as it saves them.
|
||||
|
||||
`--uniq` turns `ls` into a list of patterns still to write rather than a list of
|
||||
rows to read: one line per distinct description, since fifty visits to the same
|
||||
@@ -132,20 +132,74 @@ It composes with the other filters (`--account`, `--month`, `--search`), and
|
||||
`--limit` caps the rows printed, saying how many it held back. It cannot be
|
||||
combined with `--wide`, whose columns all belong to a single transaction.
|
||||
|
||||
### TUI keys
|
||||
## The web app
|
||||
|
||||
`money serve`, which is also what `money` runs with no command, puts seven
|
||||
screens in a browser: accounts, transactions, report, rule builder, rules,
|
||||
transfer builder and transfers. It runs the same code as the CLI: amounts are formatted,
|
||||
globs matched and transfers paired on the server, and the page only shows the
|
||||
answers. It is one binary with the page built in; nothing else to deploy.
|
||||
|
||||
```
|
||||
money # http://127.0.0.1:8080
|
||||
money serve --root /srv/money --addr 0.0.0.0:8080
|
||||
```
|
||||
|
||||
**There is no authentication.** Anyone who can reach the port can read every
|
||||
transaction, rewrite `rules.toml` and start an import, which is why it listens
|
||||
on loopback unless `--addr` says otherwise. To use it from elsewhere, put it
|
||||
behind a reverse proxy that does the logging in, or reach it over SSH / a VPN.
|
||||
Requests that change anything must be sent as JSON, so another website open in
|
||||
the same browser cannot post a form to it.
|
||||
|
||||
The server outlives hand edits to `rules.toml`, so it treats the file the way a
|
||||
fresh command would:
|
||||
|
||||
- **Retag and Import re-read `rules.toml`** (and Import the account folders)
|
||||
before running, exactly as `money retag` / `money import` would. Until then a
|
||||
banner says the file on disk no longer matches what the index was derived
|
||||
from.
|
||||
- **Edits and deletes check the file first.** They go by rule position, and the
|
||||
page also sends the rule it showed you; if `rules.toml` no longer holds that
|
||||
rule there (another tab, a hand edit) the change is refused and you are asked
|
||||
to reload, rather than editing whichever rule moved into its place.
|
||||
|
||||
Shift-click **Import** for `import --force`.
|
||||
|
||||
### Adding statements from the browser
|
||||
|
||||
The Accounts screen (`1`) has an **Add statements** panel: pick the account,
|
||||
drop files on it (or click to choose them) and press **Upload and import**. The
|
||||
files are saved into that account's folder, exactly where you would have copied
|
||||
them by hand, and then imported — the folder stays the source of truth, so
|
||||
deleting `index.db` and re-importing still gets everything back.
|
||||
|
||||
An upload never replaces a statement. A file whose name is already in the folder
|
||||
is skipped if its contents are identical and refused if they differ; rename it
|
||||
or remove the old one first. Names import would not read back — dotfiles,
|
||||
`account.toml`, anything outside an account's `include` patterns — are refused
|
||||
too, and a batch with one bad file writes none of them. The folder itself must
|
||||
already exist with an `account.toml`: an upload adds statements to an account,
|
||||
it does not create one.
|
||||
|
||||
### Keys
|
||||
|
||||
| Key | Action |
|
||||
| --- | --- |
|
||||
| `1` `2` `3` `4` `5` `6` `7` / `tab` | accounts · transactions · report · rule builder · rules · transfer builder · transfers |
|
||||
| `enter` | open the selected account (accounts view) |
|
||||
| `1` – `7` | accounts · transactions · report · rule builder · rules · transfer builder · transfers |
|
||||
| `/` | filter by description |
|
||||
| `u` | show only untagged transactions (matched transfer legs are not among them) |
|
||||
| `a` | clear the account filter |
|
||||
| `←` `→` | move the report's period (report view) |
|
||||
| `s` | change how the report is sorted (report view) |
|
||||
| `i` | import · `r` re-apply rules · `q` quit |
|
||||
| `←` `→` | move the report's period (report) |
|
||||
| `s` | change how the report is sorted (report) |
|
||||
| `ctrl+s` | sort the rule builder's preview by name / by count |
|
||||
| `i` | import · `r` retag |
|
||||
|
||||
There is no key that tags a transaction. Tags come from `rules.toml` and
|
||||
Inside a form every printable key belongs to the field you are typing in, so
|
||||
the single-letter keys only work outside one; that is why the builder's sort is
|
||||
a chord. Clicking an account on `1` filters the transaction list to it.
|
||||
|
||||
There is no control that tags a transaction. Tags come from `rules.toml` and
|
||||
nowhere else, so tagging what you are looking at means writing a rule for it on
|
||||
`4` — which is why that screen shows you what a glob catches before you save.
|
||||
|
||||
@@ -157,34 +211,23 @@ and the month that has just ended is the last one your statements can be
|
||||
complete for. If you have not downloaded that month yet, the screen says the
|
||||
period is empty rather than quietly showing you a different one.
|
||||
|
||||
`←` and `→` move along the time axis on the left of the totals, which runs from
|
||||
the widest window down to the oldest month in the index:
|
||||
|
||||
```
|
||||
Period Tag Cur Out In Net N
|
||||
all time groceries EUR 10.00 0.00 -10.00 1
|
||||
this year TOTAL EUR 10.00 0.00 -10.00
|
||||
last 12 months
|
||||
last 3 months
|
||||
this month
|
||||
▸ last month
|
||||
2026-05
|
||||
2026-04
|
||||
```
|
||||
The time axis beside the totals runs from the widest window down to the oldest
|
||||
month in the index — `all time`, `this year`, `last 12 months`, `last 3
|
||||
months`, `this month`, `last month`, then one entry per month. Click one, or
|
||||
step along it with `←` and `→`.
|
||||
|
||||
The three rolling windows run to the end of *this* month rather than to the
|
||||
last complete one — you ask for "last 3 months" to see what is happening now,
|
||||
and leaving out the days since the 1st would answer a different question. Each
|
||||
month below `last month` is a month the index actually holds; months that a
|
||||
named window above already covers are not repeated. The axis is hidden on a
|
||||
terminal too narrow for it and the totals both, where the title still names the
|
||||
period.
|
||||
named window above already covers are not repeated.
|
||||
|
||||
`s` cycles how the rows are arranged, and the marked column heading says which
|
||||
one they are arranged by: `Out ▾` largest spend first (the default question a
|
||||
spending report answers), then `In ▾`, `Net ▾` lowest first so the biggest
|
||||
losses lead, `N ▾` most transactions first, and `Tag ▴` A→Z. `money report`
|
||||
takes the same choice as `--sort out|in|net|count|tag`.
|
||||
Click a column heading, or press `s` to cycle, to change how the rows are
|
||||
arranged; the marked heading says which one they are arranged by: `Out ▾`
|
||||
largest spend first (the default question a spending report answers), then
|
||||
`In ▾`, `Net ▾` lowest first so the biggest losses lead, `N ▾` most
|
||||
transactions first, and `Tag ▴` A→Z. `money report` takes the same choice as
|
||||
`--sort out|in|net|count|tag`.
|
||||
|
||||
Currency is always the outer grouping and no sort changes that — there are no
|
||||
exchange rates here, so two currencies interleaved by amount would invite a
|
||||
@@ -192,10 +235,9 @@ comparison between numbers that cannot be compared. Rows that tie fall back to
|
||||
the tag, so they keep a fixed position rather than shuffling between reloads.
|
||||
The sort rearranges rows; it never changes which rows there are.
|
||||
|
||||
The period narrows the report and only the report. The account filter (`a`,
|
||||
`enter`), the search (`/`) and the untagged toggle (`u`) are shared with the
|
||||
transaction list as before, so opening on last month does not hide the rest of
|
||||
the index from `2`.
|
||||
The period narrows the report and only the report. The account, the search and
|
||||
the untagged toggle are shared with the transaction list, so opening on last
|
||||
month does not hide the rest of the index from `2`.
|
||||
|
||||
`money report` takes `--month` instead; there is no command-line equivalent of
|
||||
the wider windows.
|
||||
@@ -203,9 +245,10 @@ the wider windows.
|
||||
### Rule builder (`4`)
|
||||
|
||||
Writing rules by hand means guessing what a glob will catch. This screen shows
|
||||
the answer as you type: the form is on the left, and on the right the
|
||||
still-untagged descriptions the glob currently matches, marked `▸`, grouped by
|
||||
description, under a running "*n* of *m* descriptions match" count.
|
||||
the answer as you type: the form is on the left — glob, account, tag, note —
|
||||
and on the right the still-untagged descriptions the glob currently matches,
|
||||
marked `▸`, grouped by description, under a running "*n* of *m* descriptions
|
||||
match" count.
|
||||
|
||||
The list narrows as you type, so what is on screen is what the rule would
|
||||
claim — nothing else is left there to read past. The count keeps the context
|
||||
@@ -214,60 +257,26 @@ glob picked two descriptions out of seven, and `0 of 7` says the glob is wrong.
|
||||
With the glob still empty there is nothing to filter by, and the list is every
|
||||
untagged description in the data — which is the other question this screen
|
||||
answers, and where you go looking for the next thing to write a rule for.
|
||||
Clicking a description fills the glob with `*THAT DESCRIPTION*`, as a starting
|
||||
point to narrow down.
|
||||
|
||||
```
|
||||
glob Untagged description ↓ N
|
||||
╭────────────────────────────╮ ▸ LIDL SOFIA 4412 2
|
||||
│ *LIDL* │ ▸ LIDL VARNA 9911 1
|
||||
╰────────────────────────────╯
|
||||
vs. the description
|
||||
`enter` (or **Save rule**) appends the rule to `rules.toml` and retags
|
||||
immediately, so the rows it caught disappear from the list. The account stays
|
||||
filled in, since the next rule is usually for the same one.
|
||||
|
||||
account
|
||||
╭────────────────────────────╮
|
||||
│ blank = every account │
|
||||
╰────────────────────────────╯
|
||||
blank = all accounts
|
||||
|
||||
▸ tag
|
||||
╭────────────────────────────╮
|
||||
│ groceries │
|
||||
╰────────────────────────────╯
|
||||
tab completes · ctrl+n: 1 more
|
||||
|
||||
note
|
||||
╭────────────────────────────╮
|
||||
│ optional │
|
||||
╰────────────────────────────╯
|
||||
why this rule exists
|
||||
|
||||
2 of 7 descriptions match
|
||||
```
|
||||
|
||||
Only `gro` was typed in the tag field; `ceries` is the ghosted completion.
|
||||
|
||||
`tab` / `↑↓` move between the glob, account, tag and note fields, `pgup` /
|
||||
`pgdn` scroll the list, and `enter` appends the rule to `rules.toml` and retags
|
||||
immediately, so the rows it caught disappear from the list. `esc` goes back.
|
||||
On a short window the form gives up its spacing and then its hints, so all four
|
||||
fields stay on screen.
|
||||
|
||||
`ctrl+s` switches the list between by name and by count, most seen first — a
|
||||
chord rather than a letter, because every printable key belongs to the field you
|
||||
are typing in. The `↓` in the header says which column the order is read from.
|
||||
Clicking the column headings, or `ctrl+s`, switches the list between by name and
|
||||
by count, most seen first; the `↓` says which column the order is read from.
|
||||
The two answer different questions: by name finds the payee you are looking at,
|
||||
by count finds the rule worth writing next, since one pattern claiming forty
|
||||
rows is worth more than the first of forty claiming one. Ties keep the
|
||||
alphabetical order, so the list does not reshuffle under you, and the choice
|
||||
lasts until you change it.
|
||||
|
||||
The account and tag fields complete as you type: the rest of the match is
|
||||
ghosted in grey after the cursor, and `tab` (or `→` at the end of the line)
|
||||
takes it. When several candidates share the prefix, the hint under the box says
|
||||
how many, and `ctrl+n` / `ctrl+p` cycle through them. Nothing is committed until
|
||||
you accept it, so a new tag is still just typed out in full. Accounts come from
|
||||
The account and tag fields offer completions as you type. Accounts come from
|
||||
the folders on disk and the index; tags from every tag in use plus any named in
|
||||
`rules.toml`, so a tag is completable from the moment a rule mentions it —
|
||||
which is what stops `groceries` acquiring a `grocery` twin.
|
||||
which is what stops `groceries` acquiring a `grocery` twin. A new tag is just
|
||||
typed out in full.
|
||||
|
||||
Leaving the account blank applies the rule everywhere; filling it in also
|
||||
narrows the preview to that account. The preview lists only transactions no
|
||||
@@ -281,8 +290,8 @@ report leaves them out anyway.
|
||||
|
||||
#### Editing a rule
|
||||
|
||||
`e` on the rules screen (`5`) opens the selected rule in this same form with the
|
||||
fields filled in, and `enter` rewrites that rule where it sits instead of
|
||||
**Edit** on the rules screen (`5`) opens the rule in this same form with the
|
||||
fields filled in, and saving rewrites that rule where it sits instead of
|
||||
appending a new one. It keeps its position, since position still breaks ties
|
||||
between equally specific rules; `rules.toml` is edited textually, so the
|
||||
comments and formatting around it survive, exactly as when deleting.
|
||||
@@ -297,8 +306,8 @@ you are about to hand back to whatever rule catches it next.
|
||||
|
||||
The form has no `type` field, so a rule that sets one carries it through
|
||||
unchanged rather than losing it; it is shown under the glob as `+ type:… ·
|
||||
kept`. Saving lands back on the rules screen with the new counts, and `esc`
|
||||
leaves the rule as it was.
|
||||
kept`. Saving lands back on the rules screen with the new counts; **Cancel**,
|
||||
`esc`, or leaving the screen leaves the rule as it was.
|
||||
|
||||
### Rules (`5`)
|
||||
|
||||
@@ -307,7 +316,7 @@ are tried in — with the number of transactions it actually claims. Rules that
|
||||
claim none are marked `✗`.
|
||||
|
||||
```
|
||||
money · rules · 5 rules · 2 match nothing
|
||||
Rules · 5 rules · 2 match nothing
|
||||
|
||||
# Pattern Account Tag Txns Note
|
||||
1 *LIDL SOFIA* (all) groceries 3 the weekly shop
|
||||
@@ -326,80 +335,44 @@ deleting. Note that shadowing has nothing to do with the numbering: rule 2 would
|
||||
be just as dead written above rule 1, because precedence is decided by how
|
||||
specific a rule is and not by where it sits.
|
||||
|
||||
`e` opens the selected rule in the builder to edit it, `d` deletes it, and `p`
|
||||
deletes every rule marked `✗` at once; both deletions ask for a `y` first. `r`
|
||||
recounts against what is currently in the index, which is what you want after an
|
||||
import has added rows; `rules.toml` itself is read at startup and whenever you
|
||||
save a rule from the builder, so an edit made in another window needs a restart.
|
||||
Editing and deleting both work on `rules.toml` textually, so your comments,
|
||||
ordering and formatting survive. A comment sitting directly above a deleted rule
|
||||
goes with it, while one separated by a blank line is treated as a section
|
||||
heading and left alone; an edited rule keeps its comments, since they say why it
|
||||
is there and changing its glob rarely changes that.
|
||||
**Edit** opens a rule in the builder, **Delete** deletes it, and **Prune
|
||||
unused** deletes every rule marked `✗` at once; both deletions ask first.
|
||||
**Refresh counts** recounts against what is currently in the index. Editing and
|
||||
deleting both work on `rules.toml` textually, so your comments, ordering and
|
||||
formatting survive. A comment sitting directly above a deleted rule goes with
|
||||
it, while one separated by a blank line is treated as a section heading and
|
||||
left alone; an edited rule keeps its comments, since they say why it is there
|
||||
and changing its glob rarely changes that.
|
||||
|
||||
### Transfer builder (`6`)
|
||||
|
||||
Same idea as the rule builder, for money moved between your own accounts. Both
|
||||
sides are named, and the preview on the right shows what the pair would be:
|
||||
`▸` for a movement it matches end to end, `⚠` for a leg it catches on one side
|
||||
and cannot pair with anything on the other.
|
||||
Same idea as the rule builder, for money moved between your own accounts. The
|
||||
form names both sides — from account, from desc, to account, to desc — plus an
|
||||
optional tolerance and note, and the preview beside it shows what the pair
|
||||
would be: `▸` for a movement it matches end to end, `⚠` for a leg it catches on
|
||||
one side and cannot pair with anything on the other.
|
||||
|
||||
```
|
||||
from account Date Amount Movement Description
|
||||
╭────────────────────────────╮ ⚠ 2026-04-01 500.00 checking → ? TRANSFER TO SAVINGS
|
||||
│ checking │ ▸ 2026-03-01 500.00 checking → savings TRANSFER TO SAVINGS
|
||||
╰────────────────────────────╯
|
||||
money leaves here
|
||||
Date Amount Movement Description
|
||||
⚠ 2026-04-01 500.00 checking → ? TRANSFER TO SAVINGS
|
||||
▸ 2026-03-01 500.00 checking → savings TRANSFER TO SAVINGS
|
||||
|
||||
▸ from desc
|
||||
╭────────────────────────────╮
|
||||
│ *TO SAVINGS* │
|
||||
╰────────────────────────────╯
|
||||
glob vs. the leaving leg
|
||||
|
||||
to account
|
||||
╭────────────────────────────╮
|
||||
│ savings │
|
||||
╰────────────────────────────╯
|
||||
money arrives here
|
||||
|
||||
to desc
|
||||
╭────────────────────────────╮
|
||||
│ *FROM CHECKING* │
|
||||
╰────────────────────────────╯
|
||||
glob vs. the arriving leg
|
||||
|
||||
tolerance %
|
||||
╭────────────────────────────╮
|
||||
│ 0 │
|
||||
╰────────────────────────────╯
|
||||
0 = amounts must match exactly
|
||||
|
||||
note
|
||||
╭────────────────────────────╮
|
||||
│ optional │
|
||||
╰────────────────────────────╯
|
||||
why this transfer exists
|
||||
|
||||
1 pairs · 1 unpaired
|
||||
1 pairs · 1 unpaired
|
||||
```
|
||||
|
||||
The account fields complete exactly as the rule builder's do. `tab` / `↑↓` move
|
||||
between fields, `pgup` / `pgdn` scroll the list, `enter` appends the definition
|
||||
to `rules.toml` and re-pairs immediately, and `esc` goes back. Saving keeps both
|
||||
account names in place and clears the two globs, since the next transfer you
|
||||
write is usually the same route in the other direction. The tolerance is cleared
|
||||
with them: carried over silently it would loosen a route that never asked for
|
||||
one. Six fields need more room than the rule builder's four, so on a short
|
||||
window this form gives up its spacing, then its hints, then the borders on the
|
||||
fields you are not editing — every field stays on screen.
|
||||
The account fields complete exactly as the rule builder's do. `enter` (or
|
||||
**Save transfer**) appends the definition to `rules.toml` and re-pairs
|
||||
immediately. Saving keeps both account names in place and clears the two globs,
|
||||
since the next transfer you write is usually the same route in the other
|
||||
direction. The tolerance is cleared with them: carried over silently it would
|
||||
loosen a route that never asked for one.
|
||||
|
||||
`tolerance %` is the one field worth previewing before you save. Type a
|
||||
`Tolerance %` is the one field worth previewing before you save. Type a
|
||||
percentage and the pairs it buys appear immediately, showing both amounts —
|
||||
`500.00 → 495.00` — with what they cost summarised beside the counts:
|
||||
|
||||
```
|
||||
1 pairs · 0 unpaired · 5.00 in fees
|
||||
1 pairs · 0 unpaired · 5.00 in fees
|
||||
```
|
||||
|
||||
A half-written definition previews too: fill in one side and its legs show up as
|
||||
@@ -411,7 +384,7 @@ written the other half.
|
||||
Every definition in file order, with what it currently pairs.
|
||||
|
||||
```
|
||||
money · transfers · 4 definitions · 1 leg(s) unpaired
|
||||
Transfers · 4 definitions · 1 leg(s) unpaired
|
||||
|
||||
# From To Pairs Unpaired Tol Note
|
||||
1 ⚠ checking *TO SAVINGS* savings *FROM CHECKING* 11 1 monthly saving
|
||||
@@ -430,51 +403,10 @@ it: row 3's two pairs were matched on slack rather than on the amount agreeing.
|
||||
It is blank for every definition that requires the exact amount — which is the
|
||||
default, so an empty column means nothing here is pairing on a mismatch.
|
||||
|
||||
`d` deletes the selected definition and `p` deletes every `✗` one at once, both
|
||||
after a `y`. Definitions marked `⚠` are never pruned — they are doing something,
|
||||
just not finishing it, and deleting one would hide the problem rather than fix
|
||||
it. `r` re-pairs against what is currently in the index.
|
||||
|
||||
## Web app
|
||||
|
||||
`money serve` puts the TUI's seven screens in a browser — accounts,
|
||||
transactions, report, rule builder, rules, transfer builder, transfers — with
|
||||
the same behaviour, because it runs the same code: amounts are formatted, globs
|
||||
matched and transfers paired on the server, and the page only shows the
|
||||
answers. It is one binary with the page built in; nothing else to deploy.
|
||||
|
||||
```
|
||||
money serve # http://127.0.0.1:8080
|
||||
money --root /srv/money serve --addr 0.0.0.0:8080
|
||||
```
|
||||
|
||||
**There is no authentication.** Anyone who can reach the port can read every
|
||||
transaction, rewrite `rules.toml` and start an import, which is why it listens
|
||||
on loopback unless `--addr` says otherwise. To use it from elsewhere, put it
|
||||
behind a reverse proxy that does the logging in, or reach it over SSH / a VPN.
|
||||
Requests that change anything must be sent as JSON, so another website open in
|
||||
the same browser cannot post a form to it.
|
||||
|
||||
What differs from the TUI:
|
||||
|
||||
- **Retag and Import re-read `rules.toml`** (and Import the account folders)
|
||||
before running, exactly as a fresh `money retag` / `money import` would — the
|
||||
server outlives hand edits to both. Until then a banner says the file on disk
|
||||
no longer matches what the index was derived from.
|
||||
- **Edits and deletes check the file first.** They go by rule position, as in
|
||||
the TUI, but the page also sends the rule it showed you; if `rules.toml` no
|
||||
longer holds that rule there (another tab, a hand edit) the change is refused
|
||||
and you are asked to reload, rather than editing whichever rule moved into
|
||||
its place.
|
||||
- Clicking a description in the rule builder's preview fills the glob with
|
||||
`*THAT DESCRIPTION*`, as a starting point to narrow down.
|
||||
- Shift-click **Import** for `import --force`.
|
||||
|
||||
The keys still work where they do not fight the browser: `1`–`7` switch
|
||||
screens, `/` searches, `u` toggles untagged, `a` clears the account filter, `i`
|
||||
imports and `r` retags. On the report `←`/`→` move the period and `s` cycles
|
||||
the sort (or click a column heading); in the rule builder `ctrl+s` re-sorts the
|
||||
preview. Inside a form every printable key belongs to the field, as in the TUI.
|
||||
**Delete** deletes a definition and **Prune unmatched** deletes every `✗` one at
|
||||
once, both after asking. Definitions marked `⚠` are never pruned — they are
|
||||
doing something, just not finishing it, and deleting one would hide the problem
|
||||
rather than fix it. **Refresh pairing** re-reads what is currently in the index.
|
||||
|
||||
## rules.toml
|
||||
|
||||
@@ -597,7 +529,7 @@ amount wins.
|
||||
|
||||
**The difference is not forgiven, it is reported.** A pair leaves the report
|
||||
entirely, so a fee hidden inside one would be spending that appears nowhere at
|
||||
all. `money report` names it on the excluded line and the TUI's report gives it
|
||||
all. `money report` names it on the excluded line and the web app's report gives it
|
||||
its own row (see below). Across currencies there is no fee to compute — the two
|
||||
numbers are in different units — so a tolerance there means nothing and is
|
||||
ignored.
|
||||
|
||||
+6
-18
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// The data root holds one folder per account. Drop statements into those
|
||||
// folders, run `money import`, and tag what comes out with glob rules in
|
||||
// rules.toml. Running it with no subcommand opens the TUI.
|
||||
// rules.toml. Running it with no subcommand serves the web app.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -28,7 +28,6 @@ import (
|
||||
"git.petrovv.com/nikola/money/internal/rules"
|
||||
"git.petrovv.com/nikola/money/internal/store"
|
||||
"git.petrovv.com/nikola/money/internal/transfers"
|
||||
"git.petrovv.com/nikola/money/internal/tui"
|
||||
"git.petrovv.com/nikola/money/internal/web"
|
||||
)
|
||||
|
||||
@@ -37,9 +36,9 @@ const usage = `money - statement-driven personal finance tracker
|
||||
usage: money [--root DIR] <command> [flags] (--root may also follow the command)
|
||||
|
||||
commands:
|
||||
tui browse transactions, build the rules that tag them and the
|
||||
transfers that pair them across accounts (default)
|
||||
serve the same screens as a web app (--addr, default 127.0.0.1:8080)
|
||||
serve browse transactions, build the rules that tag them and the
|
||||
transfers that pair them across accounts, in a browser
|
||||
(--addr, default 127.0.0.1:8080; the default command)
|
||||
import extract transactions from every statement into the index
|
||||
retag re-apply rules.toml: retag everything and re-pair transfers
|
||||
ls list transactions
|
||||
@@ -83,7 +82,7 @@ func run(args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
cmd := "tui"
|
||||
cmd := "serve"
|
||||
if len(args) > 0 {
|
||||
cmd, args = args[0], args[1:]
|
||||
}
|
||||
@@ -120,8 +119,6 @@ func run(args []string) error {
|
||||
case "parsers":
|
||||
fmt.Println(strings.Join(parser.Names(), "\n"))
|
||||
return nil
|
||||
case "tui":
|
||||
return cmdTUI(abs, args)
|
||||
case "serve":
|
||||
return cmdServe(abs, args)
|
||||
case "help", "-h", "--help":
|
||||
@@ -488,15 +485,6 @@ func cmdAccounts(root string, _ []string) error {
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
func cmdTUI(root string, _ []string) error {
|
||||
o, err := open(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer o.db.Close()
|
||||
return tui.Run(root, o.db, o.accounts, o.engine, o.links)
|
||||
}
|
||||
|
||||
// takeRoot removes --root/-root (as `--root DIR` or `--root=DIR`) from a
|
||||
// subcommand's arguments, returning the last value given. It stops at `--`,
|
||||
// after which everything is positional.
|
||||
@@ -529,7 +517,7 @@ func takeRoot(args []string) (root string, rest []string, err error) {
|
||||
return root, rest, nil
|
||||
}
|
||||
|
||||
// cmdServe serves the TUI's screens over HTTP until interrupted. There is no
|
||||
// cmdServe serves the web app until interrupted. There is no
|
||||
// authentication, so it listens on loopback unless told otherwise: anyone who
|
||||
// can reach the port can rewrite rules.toml and start an import.
|
||||
func cmdServe(root string, args []string) error {
|
||||
|
||||
@@ -4,38 +4,16 @@ go 1.25.6
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
modernc.org/sqlite v1.56.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
modernc.org/libc v1.74.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
@@ -1,76 +1,25 @@
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||
|
||||
@@ -393,7 +393,7 @@ func (d *DB) Months() ([]string, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Tags lists every tag in use, for completion in the TUI.
|
||||
// Tags lists every tag in use, for completion in the rule builder.
|
||||
func (d *DB) Tags() ([]string, error) {
|
||||
rows, err := d.sql.Query(`
|
||||
SELECT DISTINCT rule_tag FROM transactions
|
||||
|
||||
-2483
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+179
-18
@@ -1,17 +1,20 @@
|
||||
// Package web serves the TUI's screens over HTTP: a JSON API over the index
|
||||
// Package web is money's interactive frontend: a JSON API over the index
|
||||
// and rules.toml, and a single page that drives it. It holds no logic of its
|
||||
// own about money — every number is formatted, every glob matched and every
|
||||
// pair decided by the same packages the TUI and the CLI use, so the browser
|
||||
// never does arithmetic on an amount or re-implements the matcher.
|
||||
// pair decided by the same packages the CLI uses, so the browser never does
|
||||
// arithmetic on an amount or re-implements the matcher.
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -32,9 +35,9 @@ import (
|
||||
//go:embed static
|
||||
var static embed.FS
|
||||
|
||||
// Server is the long-lived counterpart of the TUI's Model.
|
||||
// Server holds the index and the engines derived from rules.toml.
|
||||
//
|
||||
// Unlike the TUI it outlives any one edit of rules.toml by hand, so retag and
|
||||
// It outlives any one edit of rules.toml by hand, so retag and
|
||||
// import re-read that file (and import the account folders) before running,
|
||||
// exactly as a fresh `money retag` or `money import` would. Between those, the
|
||||
// engines are what the index was last derived from, which is what every screen
|
||||
@@ -84,6 +87,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /api/transfers/delete", s.write(s.deleteTransfers))
|
||||
|
||||
mux.HandleFunc("POST /api/import", s.write(s.runImport))
|
||||
mux.HandleFunc("POST /api/upload", s.write(s.upload))
|
||||
mux.HandleFunc("POST /api/retag", s.write(s.retag))
|
||||
return mux
|
||||
}
|
||||
@@ -159,7 +163,11 @@ func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
}
|
||||
|
||||
func decode(r *http.Request, v any) error {
|
||||
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
|
||||
return decodeLimit(r, v, 1<<20)
|
||||
}
|
||||
|
||||
func decodeLimit(r *http.Request, v any, limit int64) error {
|
||||
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, limit))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(v); err != nil {
|
||||
return badRequest("bad request body: %v", err)
|
||||
@@ -167,8 +175,8 @@ func decode(r *http.Request, v any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// status is the reply to every write: one line saying what happened, as the
|
||||
// TUI's status bar would.
|
||||
// status is the reply to every write: one line saying what happened, shown
|
||||
// in the page's status banner.
|
||||
type status struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
@@ -196,13 +204,17 @@ type accountRow struct {
|
||||
|
||||
type overview struct {
|
||||
Root string `json:"root"`
|
||||
// Configured is how many account folders are on disk, which the empty
|
||||
// accounts screen needs: an account.toml is not enough until an import.
|
||||
Configured int `json:"configured"`
|
||||
Accounts []accountRow `json:"accounts"`
|
||||
// AccountSlugs and Tags are what the builders' fields complete against.
|
||||
AccountSlugs []string `json:"accountSlugs"`
|
||||
Tags []string `json:"tags"`
|
||||
// Folders are the account folders on disk now — what an upload can go
|
||||
// into, and what the empty accounts screen counts, since an account.toml
|
||||
// is not enough to appear until an import. Read fresh, since a folder made
|
||||
// after startup is a valid target.
|
||||
// A broken account.toml is reported rather than failing the whole page.
|
||||
Folders []string `json:"folders"`
|
||||
FoldersErr string `json:"foldersError,omitempty"`
|
||||
// Stale reports that rules.toml on disk no longer says what the index was
|
||||
// derived from — it was edited by hand — so the page can offer a retag
|
||||
// instead of quietly describing rules that are not the ones in force.
|
||||
@@ -215,7 +227,7 @@ func (s *Server) overview(*http.Request) (any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := overview{Root: s.root, Configured: len(s.accounts), Accounts: []accountRow{}}
|
||||
out := overview{Root: s.root, Accounts: []accountRow{}}
|
||||
for _, a := range accounts {
|
||||
bal, err := s.db.Balance(a.ID)
|
||||
if err != nil {
|
||||
@@ -236,6 +248,14 @@ func (s *Server) overview(*http.Request) (any, error) {
|
||||
if out.Tags, err = s.knownTags(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Folders = []string{}
|
||||
if folders, err := config.LoadAccounts(s.root); err != nil {
|
||||
out.FoldersErr = err.Error()
|
||||
} else {
|
||||
for _, f := range folders {
|
||||
out.Folders = append(out.Folders, f.Slug)
|
||||
}
|
||||
}
|
||||
onDisk, err := config.LoadRules(s.root)
|
||||
if err != nil {
|
||||
out.Stale, out.RulesErr = true, err.Error()
|
||||
@@ -576,7 +596,7 @@ type descGroup struct {
|
||||
Claimed bool
|
||||
}
|
||||
|
||||
// rulePreview is the TUI builder's right-hand panel: what the glob would claim
|
||||
// rulePreview is the rule builder's right-hand panel: what the glob would claim
|
||||
// among everything still waiting for a rule, plus — when editing — what the
|
||||
// rule claims already, since those are tagged and an untagged-only preview
|
||||
// would be empty for a rule that works. Matching runs here rather than in the
|
||||
@@ -1119,22 +1139,26 @@ func (s *Server) runImport(r *http.Request) (any, error) {
|
||||
if err := decode(r, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.importAll(req.Force)
|
||||
}
|
||||
|
||||
func (s *Server) importAll(force bool) (importJSON, error) {
|
||||
accounts, err := config.LoadAccounts(s.root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return importJSON{}, err
|
||||
}
|
||||
if len(accounts) == 0 {
|
||||
return nil, badRequest("no accounts found in %s (an account is a folder containing %s)",
|
||||
return importJSON{}, badRequest("no accounts found in %s (an account is a folder containing %s)",
|
||||
s.root, config.AccountFile)
|
||||
}
|
||||
if err := s.reloadRules(); err != nil {
|
||||
return nil, err
|
||||
return importJSON{}, err
|
||||
}
|
||||
s.accounts = accounts
|
||||
|
||||
res, err := importer.Run(s.root, s.db, s.accounts, s.engine, s.links, importer.Options{Force: req.Force})
|
||||
res, err := importer.Run(s.root, s.db, s.accounts, s.engine, s.links, importer.Options{Force: force})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return importJSON{}, err
|
||||
}
|
||||
|
||||
_, added, skipped := res.Total()
|
||||
@@ -1158,6 +1182,143 @@ func (s *Server) runImport(r *http.Request) (any, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// uploadLimit caps an upload request. Statements are small — a year of PDF is
|
||||
// a few hundred kilobytes — and the body is base64, a third larger than the
|
||||
// files it carries.
|
||||
const uploadLimit = 64 << 20
|
||||
|
||||
type uploadFile struct {
|
||||
Name string `json:"name"`
|
||||
Data []byte `json:"data"` // base64 on the wire
|
||||
}
|
||||
|
||||
type uploadReq struct {
|
||||
Account string `json:"account"`
|
||||
Files []uploadFile `json:"files"`
|
||||
}
|
||||
|
||||
// upload saves statements into an account folder and imports them. The files
|
||||
// on disk are the source of truth and the index is derived from them, so an
|
||||
// upload is nothing more than putting a file where `money import` looks; the
|
||||
// import that follows is the one the Import button runs.
|
||||
//
|
||||
// Files arrive base64 inside JSON rather than as multipart: a multipart body
|
||||
// is exactly what a cross-site form can send, and the JSON-only rule is the
|
||||
// only thing standing between another page and this server.
|
||||
func (s *Server) upload(r *http.Request) (any, error) {
|
||||
var req uploadReq
|
||||
if err := decodeLimit(r, &req, uploadLimit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(req.Files) == 0 {
|
||||
return nil, badRequest("no files to upload")
|
||||
}
|
||||
accounts, err := config.LoadAccounts(s.root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var acc *config.Account
|
||||
for _, a := range accounts {
|
||||
if a.Slug == req.Account {
|
||||
acc = a
|
||||
}
|
||||
}
|
||||
if acc == nil {
|
||||
return nil, badRequest("no account folder %q in %s (an account is a folder containing %s)",
|
||||
req.Account, s.root, config.AccountFile)
|
||||
}
|
||||
|
||||
// Everything is checked before anything is written, so a bad file in a
|
||||
// batch leaves the folder as it was rather than half uploaded.
|
||||
var write []uploadFile
|
||||
var same []string
|
||||
seen := map[string]bool{}
|
||||
for _, f := range req.Files {
|
||||
if err := checkStatementName(acc, f.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if seen[f.Name] {
|
||||
return nil, badRequest("%s is in the upload twice", f.Name)
|
||||
}
|
||||
seen[f.Name] = true
|
||||
existing, err := os.ReadFile(filepath.Join(acc.Dir, f.Name))
|
||||
switch {
|
||||
case err == nil && bytes.Equal(existing, f.Data):
|
||||
same = append(same, f.Name)
|
||||
case err == nil:
|
||||
// A statement is the source of truth for what it already
|
||||
// imported; replacing it under the same name is not an upload's
|
||||
// decision to make.
|
||||
return nil, &apiError{http.StatusConflict, fmt.Sprintf(
|
||||
"%s/%s already exists with different contents; rename the file or remove the old one first",
|
||||
acc.Slug, f.Name)}
|
||||
case !errors.Is(err, fs.ErrNotExist):
|
||||
return nil, err
|
||||
default:
|
||||
write = append(write, f)
|
||||
}
|
||||
}
|
||||
for _, f := range write {
|
||||
if err := writeStatement(acc.Dir, f); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
out, err := s.importAll(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg := fmt.Sprintf("uploaded %d file(s) to %s", len(write), acc.Slug)
|
||||
if len(same) > 0 {
|
||||
msg += fmt.Sprintf(" (%d already there)", len(same))
|
||||
}
|
||||
out.Status = msg + " · " + out.Status
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// checkStatementName refuses any name the importer would not read back as a
|
||||
// statement of this account, and anything that is not a plain file name.
|
||||
func checkStatementName(acc *config.Account, name string) error {
|
||||
if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, "/\\\x00") ||
|
||||
name == "." || name == ".." {
|
||||
return badRequest("%q is not a plain file name", name)
|
||||
}
|
||||
if strings.HasPrefix(name, ".") || name == config.AccountFile {
|
||||
return badRequest("%s would be ignored by import (dotfiles and %s are not statements)",
|
||||
name, config.AccountFile)
|
||||
}
|
||||
if len(acc.Include) > 0 && !slices.ContainsFunc(acc.Include, func(p string) bool { return glob.Match(p, name) }) {
|
||||
return badRequest("%s does not match %s's include patterns (%s), so import would ignore it",
|
||||
name, acc.Slug, strings.Join(acc.Include, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeStatement writes through a dotfile and renames it into place, so a
|
||||
// concurrent `money import` never reads half a statement.
|
||||
func writeStatement(dir string, f uploadFile) error {
|
||||
path := filepath.Join(dir, f.Name)
|
||||
tmp, err := os.CreateTemp(dir, ".upload-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
defer os.Remove(tmp.Name()) // a no-op once renamed
|
||||
if _, err := tmp.Write(f.Data); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
if err := os.Chmod(tmp.Name(), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp.Name(), path); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// retag is `money retag`: both halves of what rules.toml decides, re-derived
|
||||
// from the file as it is on disk now.
|
||||
func (s *Server) retag(*http.Request) (any, error) {
|
||||
|
||||
@@ -386,3 +386,43 @@ func TestPageIsServed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A paired leg is spoken for, so it is neither listed as untagged nor offered
|
||||
// to the rule builder; an unpaired leg is untagged like anything else, which
|
||||
// is how it gets noticed.
|
||||
func TestPairedLegsAreNotUntagged(t *testing.T) {
|
||||
_, h := newTestServer(t, `
|
||||
[[transfer]]
|
||||
from_account = "checking"
|
||||
from_desc = "*TO SAVINGS*"
|
||||
to_account = "savings"
|
||||
to_desc = "*FROM CHECKING*"
|
||||
`,
|
||||
fixtureTxn{"checking", "2026-02-01", "TRANSFER TO SAVINGS", -1000},
|
||||
fixtureTxn{"savings", "2026-02-01", "TRANSFER FROM CHECKING", 1000},
|
||||
fixtureTxn{"checking", "2026-02-20", "TRANSFER TO SAVINGS", -2000},
|
||||
fixtureTxn{"checking", "2026-02-05", "LIDL SOFIA", -500},
|
||||
)
|
||||
|
||||
var txns struct{ Rows []txnRow }
|
||||
call(t, h, "GET", "/api/transactions?untagged=1", nil, http.StatusOK, &txns)
|
||||
var seen []string
|
||||
for _, r := range txns.Rows {
|
||||
seen = append(seen, r.Date+" "+r.Description)
|
||||
}
|
||||
want := "2026-02-20 TRANSFER TO SAVINGS|2026-02-05 LIDL SOFIA"
|
||||
if strings.Join(seen, "|") != want {
|
||||
t.Errorf("untagged = %v, want only the unpaired leg and the shopping", seen)
|
||||
}
|
||||
|
||||
var p rulePreviewJSON
|
||||
call(t, h, "POST", "/api/rules/preview", rulePreviewReq{Glob: "*"}, http.StatusOK, &p)
|
||||
for _, r := range p.Rows {
|
||||
if r.Description == "TRANSFER FROM CHECKING" {
|
||||
t.Error("the arriving leg of a matched transfer is still offered for tagging")
|
||||
}
|
||||
if r.Description == "TRANSFER TO SAVINGS" && r.Count != 1 {
|
||||
t.Errorf("departing leg offered %d times, want only the unpaired one", r.Count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+106
-15
@@ -140,8 +140,8 @@ async function loadOverview() {
|
||||
fillDatalists();
|
||||
}
|
||||
|
||||
// The account and tag fields complete natively, from the lists the TUI
|
||||
// completes against. (transfer) is never among the tags: it is a label.
|
||||
// The account and tag fields complete natively, from lists the server
|
||||
// builds. (transfer) is never among the tags: it is a label.
|
||||
function fillDatalists() {
|
||||
const o = state.overview;
|
||||
if (!o) return;
|
||||
@@ -186,8 +186,8 @@ function go(id) {
|
||||
function show() {
|
||||
const id = location.hash.slice(1) || 'accounts';
|
||||
const view = VIEWS.find((v) => v.id === id) || VIEWS[0];
|
||||
// Leaving the builder mid-edit abandons the edit, as esc does in the TUI: a
|
||||
// new rule must not start out as a copy of the one being edited.
|
||||
// Leaving the builder mid-edit abandons the edit, as esc does: a new rule
|
||||
// must not start out as a copy of the one being edited.
|
||||
if (view.id !== 'rule' && state.rule.editing) state.rule = blankRule();
|
||||
state.view = view.id;
|
||||
renderNav();
|
||||
@@ -203,22 +203,31 @@ async function refreshAll() {
|
||||
|
||||
// --- import and retag -----------------------------------------------------
|
||||
|
||||
async function runImport(force) {
|
||||
if (state.busy) return;
|
||||
function runImport(force) {
|
||||
return importing(() => api('POST', '/api/import', { force: !!force }));
|
||||
}
|
||||
|
||||
// importing runs a request that ends in an import — the Import button, or an
|
||||
// upload — and reports it the same way. It answers whether the request
|
||||
// succeeded; per-file failures inside the import still count as success.
|
||||
async function importing(request) {
|
||||
if (state.busy) return false;
|
||||
state.busy = true;
|
||||
const btn = document.getElementById('import');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Importing…';
|
||||
setStatus('importing… parsing PDF statements can take a while');
|
||||
try {
|
||||
const res = await api('POST', '/api/import', { force: !!force });
|
||||
const res = await request();
|
||||
state.status = res.status;
|
||||
state.error = res.failed ? `${res.failed} file(s) failed to import` : '';
|
||||
renderBanners();
|
||||
showImportDetails(res);
|
||||
await refreshAll();
|
||||
return true;
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
return false;
|
||||
} finally {
|
||||
state.busy = false;
|
||||
btn.disabled = false;
|
||||
@@ -255,17 +264,19 @@ async function retag() {
|
||||
|
||||
function mountAccounts(main) {
|
||||
const body = h('div');
|
||||
main.append(h('h2', {}, 'Accounts'), body);
|
||||
const upload = h('div');
|
||||
main.append(h('h2', {}, 'Accounts'), body, upload);
|
||||
|
||||
async function refresh() {
|
||||
if (!state.overview) await loadOverview();
|
||||
const o = state.overview;
|
||||
upload.replaceChildren(uploader(o));
|
||||
if (!o.accounts.length) {
|
||||
// An account.toml is not enough on its own: folders reach the index
|
||||
// only through an import, so say that rather than show nothing.
|
||||
body.replaceChildren(h('div', { class: 'panel empty' }, o.configured
|
||||
? `No accounts imported yet.\n\n${o.configured} account folder(s) configured in ${o.root}.\nPress Import to read their statements.`
|
||||
: `No account folders found in ${o.root}.\n\nAn account is a folder containing an account.toml.\nCreate one, drop statements in, then press Import.`));
|
||||
body.replaceChildren(h('div', { class: 'panel empty' }, o.folders.length
|
||||
? `No accounts imported yet.\n\n${o.folders.length} account folder(s) in ${o.root}.\nAdd statements below, or put them in the folders and press Import.`
|
||||
: `No account folders found in ${o.root}.\n\nAn account is a folder containing an account.toml.\nCreate one, then add statements to it here.`));
|
||||
return;
|
||||
}
|
||||
body.replaceChildren(h('div', { class: 'panel' }, h('table', {},
|
||||
@@ -282,6 +293,87 @@ function mountAccounts(main) {
|
||||
return { refresh };
|
||||
}
|
||||
|
||||
// uploader puts statement files into an account folder and imports them. The
|
||||
// folder is where they belong: the index is rebuilt from it, so a statement
|
||||
// that lived only in the index would be lost the next time it is deleted.
|
||||
function uploader(o) {
|
||||
const panel = h('div', { class: 'panel upload' }, h('h3', {}, 'Add statements'));
|
||||
if (!o.folders.length) {
|
||||
panel.append(h('div', { class: 'muted' }, o.foldersError
|
||||
? `Account folders do not load: ${o.foldersError}`
|
||||
: `There is no account folder to add statements to. Create one in ${o.root} with an account.toml.`));
|
||||
return panel;
|
||||
}
|
||||
|
||||
let files = [];
|
||||
const account = h('select', { 'aria-label': 'Account to add statements to' },
|
||||
o.folders.map((f) => h('option', { value: f }, f)));
|
||||
// Default to the account being browsed, since that is usually the one.
|
||||
account.value = o.folders.includes(state.filter.account) ? state.filter.account : o.folders[0];
|
||||
|
||||
const picker = h('input', {
|
||||
type: 'file', multiple: true, hidden: true,
|
||||
onchange: (e) => choose(e.target.files),
|
||||
});
|
||||
const chosen = h('div', { class: 'chosen muted' }, 'No files chosen');
|
||||
const send = h('button', { type: 'button', class: 'primary', disabled: true, onclick: submit }, 'Upload and import');
|
||||
const zone = h('div', {
|
||||
class: 'dropzone',
|
||||
tabindex: 0,
|
||||
role: 'button',
|
||||
onclick: () => picker.click(),
|
||||
onkeydown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); picker.click(); } },
|
||||
ondragover: (e) => { e.preventDefault(); zone.classList.add('over'); },
|
||||
ondragleave: () => zone.classList.remove('over'),
|
||||
ondrop: (e) => { e.preventDefault(); zone.classList.remove('over'); choose(e.dataTransfer.files); },
|
||||
}, 'Drop statement files here, or click to choose');
|
||||
|
||||
function choose(list) {
|
||||
files = Array.from(list || []);
|
||||
send.disabled = !files.length;
|
||||
chosen.textContent = files.length
|
||||
? files.map((f) => `${f.name} (${Math.ceil(f.size / 1024)} KB)`).join(' · ')
|
||||
: 'No files chosen';
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
send.disabled = true;
|
||||
try {
|
||||
const payload = {
|
||||
account: account.value,
|
||||
files: await Promise.all(files.map(async (f) => ({ name: f.name, data: await base64(f) }))),
|
||||
};
|
||||
if (await importing(() => api('POST', '/api/upload', payload))) {
|
||||
picker.value = '';
|
||||
choose([]);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
} finally {
|
||||
send.disabled = !files.length;
|
||||
}
|
||||
}
|
||||
|
||||
panel.append(
|
||||
h('div', { class: 'toolbar' }, h('label', {}, 'Account ', account), send),
|
||||
zone, picker, chosen,
|
||||
h('div', { class: 'hint muted' },
|
||||
'Files are saved into the account folder, next to the statements already there, and imported. ' +
|
||||
'A file with the same name and different contents is refused rather than replaced.'));
|
||||
return panel;
|
||||
}
|
||||
|
||||
// base64 encodes a file for the JSON body. Chunked, because spreading a whole
|
||||
// statement into String.fromCharCode overflows the call stack.
|
||||
async function base64(file) {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
|
||||
}
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
// --- shared scope toolbar ---------------------------------------------------
|
||||
|
||||
function scopeToolbar(onChange) {
|
||||
@@ -481,7 +573,7 @@ function textInput(id, value, placeholder, oninput, list) {
|
||||
function mountRuleBuilder(main) {
|
||||
const r = state.rule;
|
||||
const editing = r.editing;
|
||||
// A new rule starts on the account being browsed, as in the TUI.
|
||||
// A new rule starts on the account being browsed.
|
||||
if (!editing && !r.account && state.filter.account) r.account = state.filter.account;
|
||||
|
||||
const summary = h('div', { class: 'summary' });
|
||||
@@ -857,9 +949,8 @@ function mountTransfers(main) {
|
||||
|
||||
// --- keyboard -------------------------------------------------------------
|
||||
|
||||
// The TUI's keys, where they do not fight the browser. A builder is a form,
|
||||
// so outside of chords its keys belong to the field being typed in — exactly
|
||||
// the rule the TUI keeps.
|
||||
// Single-key shortcuts, where they do not fight the browser. A builder is a
|
||||
// form, so outside of chords its keys belong to the field being typed in.
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const t = e.target;
|
||||
const typing = t instanceof HTMLInputElement || t instanceof HTMLSelectElement || t instanceof HTMLTextAreaElement;
|
||||
|
||||
@@ -176,6 +176,26 @@ td.actions button { padding: 1px 8px; font-size: 12px; }
|
||||
.kept { font-size: 12px; color: var(--muted); font-family: var(--mono); }
|
||||
.preview { max-height: calc(100vh - 170px); }
|
||||
|
||||
.upload { margin-top: 16px; padding: 14px; overflow: visible; }
|
||||
.upload h3 { font-size: 14px; margin: 0 0 10px; }
|
||||
.upload .toolbar { margin-bottom: 10px; }
|
||||
.dropzone {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 22px 16px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.dropzone:hover, .dropzone:focus, .dropzone.over {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
outline: none;
|
||||
}
|
||||
.upload .chosen { margin-top: 8px; font-family: var(--mono); font-size: 12px; overflow-wrap: anywhere; }
|
||||
.upload .hint { margin-top: 8px; font-size: 12px; }
|
||||
|
||||
.import-files { margin-top: 6px; font-size: 13px; }
|
||||
.import-files li { font-family: var(--mono); }
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.petrovv.com/nikola/money/internal/config"
|
||||
"git.petrovv.com/nikola/money/internal/parser"
|
||||
"git.petrovv.com/nikola/money/internal/rules"
|
||||
"git.petrovv.com/nikola/money/internal/store"
|
||||
"git.petrovv.com/nikola/money/internal/transfers"
|
||||
)
|
||||
|
||||
// The upload tests are about where files land and what is refused, not about
|
||||
// any bank's layout, so they read "date,description,amount" with a header.
|
||||
func init() {
|
||||
parser.Register("webtest", func(acc *config.Account) (parser.Parser, error) {
|
||||
return csvParser{digits: acc.Digits()}, nil
|
||||
})
|
||||
}
|
||||
|
||||
type csvParser struct{ digits int }
|
||||
|
||||
func (p csvParser) Parse(path string, _ *config.Account) ([]parser.RawTxn, error) {
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var txns []parser.RawTxn
|
||||
for i, line := range strings.Split(strings.TrimSpace(string(body)), "\n")[1:] {
|
||||
f := strings.Split(line, ",")
|
||||
if len(f) != 3 {
|
||||
return nil, fmt.Errorf("row %d: want 3 fields", i+2)
|
||||
}
|
||||
amount, err := parser.ParseAmount(f[2], ".", "", p.digits)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
txns = append(txns, parser.RawTxn{Date: f[0], Description: f[1], AmountMinor: amount})
|
||||
}
|
||||
return txns, nil
|
||||
}
|
||||
|
||||
// newUploadServer builds a server over a data root with one empty account
|
||||
// folder, as `money serve` sees it before the first import.
|
||||
func newUploadServer(t *testing.T, accountTOML string) (string, http.Handler) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "checking")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, config.AccountFile), []byte(accountTOML), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := store.Open(config.IndexPath(root))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
accounts, err := config.LoadAccounts(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := config.LoadRules(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := New(root, db, accounts, rules.New(loaded), transfers.New(loaded))
|
||||
s.now = func() time.Time { return time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC) }
|
||||
return dir, s.Handler()
|
||||
}
|
||||
|
||||
const checkingTOML = "currency = \"EUR\"\nparser = \"webtest\"\n"
|
||||
|
||||
const statement = "date,description,amount\n2026-02-01,LIDL SOFIA,-12.50\n2026-02-03,SALARY,1000.00\n"
|
||||
|
||||
func TestUploadSavesAndImports(t *testing.T) {
|
||||
dir, h := newUploadServer(t, checkingTOML)
|
||||
req := uploadReq{Account: "checking", Files: []uploadFile{{Name: "2026-02.csv", Data: []byte(statement)}}}
|
||||
|
||||
var res importJSON
|
||||
call(t, h, "POST", "/api/upload", req, http.StatusOK, &res)
|
||||
if !strings.Contains(res.Status, "uploaded 1 file(s) to checking") || !strings.Contains(res.Status, "2 new") {
|
||||
t.Errorf("status = %q", res.Status)
|
||||
}
|
||||
if got, err := os.ReadFile(filepath.Join(dir, "2026-02.csv")); err != nil || string(got) != statement {
|
||||
t.Errorf("file on disk = %q, %v", got, err)
|
||||
}
|
||||
var txns struct{ Rows []txnRow }
|
||||
call(t, h, "GET", "/api/transactions", nil, http.StatusOK, &txns)
|
||||
if len(txns.Rows) != 2 {
|
||||
t.Errorf("index holds %d rows, want the 2 uploaded", len(txns.Rows))
|
||||
}
|
||||
|
||||
// The same file again is not an error, and adds nothing.
|
||||
call(t, h, "POST", "/api/upload", req, http.StatusOK, &res)
|
||||
if !strings.Contains(res.Status, "uploaded 0 file(s)") || !strings.Contains(res.Status, "1 already there") {
|
||||
t.Errorf("re-upload status = %q", res.Status)
|
||||
}
|
||||
call(t, h, "GET", "/api/transactions", nil, http.StatusOK, &txns)
|
||||
if len(txns.Rows) != 2 {
|
||||
t.Errorf("re-upload left %d rows, want 2", len(txns.Rows))
|
||||
}
|
||||
}
|
||||
|
||||
// A statement already in the folder is the source of truth for what it
|
||||
// imported, so an upload never replaces one with different contents.
|
||||
func TestUploadNeverReplacesAStatement(t *testing.T) {
|
||||
dir, h := newUploadServer(t, checkingTOML)
|
||||
path := filepath.Join(dir, "2026-02.csv")
|
||||
if err := os.WriteFile(path, []byte(statement), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := uploadReq{Account: "checking", Files: []uploadFile{{Name: "2026-02.csv", Data: []byte("date,description,amount\n")}}}
|
||||
call(t, h, "POST", "/api/upload", req, http.StatusConflict, nil)
|
||||
if got, _ := os.ReadFile(path); string(got) != statement {
|
||||
t.Errorf("statement was overwritten: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A batch is checked as a whole before anything is written, and nothing may
|
||||
// land outside the account folder or where import would not read it back.
|
||||
func TestUploadRefusesBadNames(t *testing.T) {
|
||||
dir, h := newUploadServer(t, checkingTOML+"include = [\"*.csv\"]\n")
|
||||
for _, bad := range []string{"../escape.csv", "sub/x.csv", ".hidden.csv", config.AccountFile, "notes.txt", ""} {
|
||||
req := uploadReq{Account: "checking", Files: []uploadFile{
|
||||
{Name: "good.csv", Data: []byte(statement)},
|
||||
{Name: bad, Data: []byte(statement)},
|
||||
}}
|
||||
call(t, h, "POST", "/api/upload", req, http.StatusBadRequest, nil)
|
||||
}
|
||||
entries, _ := os.ReadDir(dir)
|
||||
if len(entries) != 1 {
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
t.Errorf("folder holds %v, want only %s", names, config.AccountFile)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(filepath.Dir(dir), "escape.csv")); err == nil {
|
||||
t.Error("a file escaped the account folder")
|
||||
}
|
||||
|
||||
call(t, h, "POST", "/api/upload", uploadReq{Account: "nope", Files: []uploadFile{{Name: "a.csv"}}},
|
||||
http.StatusBadRequest, nil)
|
||||
}
|
||||
|
||||
// Multipart is what a cross-site form can send, so it is refused like any
|
||||
// other non-JSON write.
|
||||
func TestUploadRefusesMultipart(t *testing.T) {
|
||||
_, h := newUploadServer(t, checkingTOML)
|
||||
r := httptest.NewRequest("POST", "/api/upload", strings.NewReader("--x\r\n"))
|
||||
r.Header.Set("Content-Type", "multipart/form-data; boundary=x")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusUnsupportedMediaType {
|
||||
t.Errorf("status %d, want %d", w.Code, http.StatusUnsupportedMediaType)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user