Compare commits
15
Commits
a4ac034ea0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a086f9242d | ||
|
|
c271cf2c8f | ||
|
|
3d72a54be1 | ||
|
|
ba7e0e2b42 | ||
|
|
ed52be3f1d | ||
|
|
bd406a110d | ||
|
|
a5f7541980 | ||
|
|
9ad6c05f9a | ||
|
|
7f5b4846ef | ||
|
|
17428a4f51 | ||
|
|
7ec9976b80 | ||
|
|
ec8a844441 | ||
|
|
8e4df08f43 | ||
|
|
5847245638 | ||
|
|
6faf99719a |
@@ -3,3 +3,7 @@
|
|||||||
|
|
||||||
# Never commit a data root that happens to live inside the repo.
|
# Never commit a data root that happens to live inside the repo.
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
|
# The static pdftotext and the binaries scripts/build-bundled.sh makes from it.
|
||||||
|
/internal/parser/bundled/
|
||||||
|
/dist/
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
Statement-driven personal finance tracker. A data directory holds one folder per
|
Statement-driven personal finance tracker. A data directory holds one folder per
|
||||||
account; statements dropped into those folders are parsed into a rebuildable
|
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.
|
Read `README.md` first — it is the user-facing reference for every config key.
|
||||||
This file covers what the code assumes and why.
|
This file covers what the code assumes and why.
|
||||||
@@ -10,7 +11,7 @@ This file covers what the code assumes and why.
|
|||||||
## Layout
|
## 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/config rules.toml (rules + transfers), account.toml, XDG config, data-root resolution
|
||||||
internal/model Account, Transaction, amount formatting, description normalisation
|
internal/model Account, Transaction, amount formatting, description normalisation
|
||||||
internal/glob the `*` / `?` matcher used by rules (two-pointer, no exponential blowup)
|
internal/glob the `*` / `?` matcher used by rules (two-pointer, no exponential blowup)
|
||||||
@@ -20,7 +21,7 @@ internal/importer directory walk, dedupe, balance checks
|
|||||||
internal/rules applies ordered rules, writing rule_tag
|
internal/rules applies ordered rules, writing rule_tag
|
||||||
internal/transfers pairs the two legs of a movement, writing the transfers table
|
internal/transfers pairs the two legs of a movement, writing the transfers table
|
||||||
internal/report per-tag aggregation
|
internal/report per-tag aggregation
|
||||||
internal/tui Bubble Tea models
|
internal/web `money serve`: JSON API + embedded single page (static/)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
@@ -58,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
|
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
|
spending that never appears anywhere. `Pair.Fee` is what left less what
|
||||||
arrived, and `report.Excluded` carries it out per currency alongside the legs —
|
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`.
|
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,
|
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
|
and it must not be turned into a synthetic transaction to make the total
|
||||||
@@ -142,51 +143,43 @@ Anything new that reports a rule must report its file position too.
|
|||||||
settles ties: a saved rule cannot displace an equally specific one written by
|
settles ties: a saved rule cannot displace an equally specific one written by
|
||||||
hand, while a narrower one is meant to take precedence and does.
|
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
|
**The report's period narrows the report and nothing else.** The screen opens on
|
||||||
on last month, and `←`/`→` step along `report.Periods` — the named windows,
|
last month, and `←`/`→` step along `report.Periods` — the named windows, then
|
||||||
then the months the index holds. `reloadReport` therefore runs its *own* query
|
the months the index holds. The report and the transaction list share a scope
|
||||||
rather than reusing the rows the transaction list is showing: the two share
|
(account, search, untagged), which `web.filterFrom` reads and `state.filter`
|
||||||
`m.filter` (account, search, untagged) and differ only in the date bounds, so
|
holds in the page; the period is read by the report handler alone and kept in
|
||||||
opening on last month must not hide the rest of the index from the list beside
|
`state.report`, so opening on last month cannot hide the rest of the index from
|
||||||
it. Nothing may put the period into `m.filter`, which is exactly what would
|
the list. Nothing may put the period into that shared scope, which is exactly
|
||||||
make it leak. Covered by `TestReportPeriodLeavesTheTransactionListAlone`.
|
what would make it leak. Covered by `TestReportPeriodLeavesTransactionsAlone`.
|
||||||
|
|
||||||
The axis is built from `store.Months` over the *whole* index, not from the rows
|
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
|
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
|
move under the cursor. It is rebuilt on every request (an import can reach
|
||||||
further back) but keeps the window the user was on. The windows are relative to
|
further back), and the page names the period by its label rather than its
|
||||||
today, never to the newest statement: "last month" with nothing in it reports
|
position, so a grown axis keeps the window the user was on. The windows are
|
||||||
nothing and says so, because silently answering for a month nobody asked for is
|
relative to today, never to the newest statement: "last month" with nothing in
|
||||||
worse than an empty screen. That is also why an empty period and an empty index
|
it reports nothing and says so, because silently answering for a month nobody
|
||||||
give different messages — one asks for another period, the other for an import.
|
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.**
|
**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
|
`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
|
`store.Filter` or the shared scope — the period decides what is counted, the
|
||||||
only how it is listed, and merging the two would make a sort able to hide a
|
order only how it is listed, and merging the two would make a sort able to hide
|
||||||
tag. Currency stays the outer sort key under every order, because there are no
|
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
|
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.
|
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
|
own copy of that mapping; a new order needs a column of its own, which
|
||||||
`TestOrderColumnsAreDistinct` checks.
|
`TestOrderColumnsAreDistinct` checks.
|
||||||
|
|
||||||
**The builders are forms, so the global keymap must not apply there.**
|
**There are no keyboard shortcuts.** The page used to carry the TUI's keymap —
|
||||||
`Update` routes to `updateRules` / `updateTransfers` before `updateNormal`
|
screens on `1`–`8`, `u`, `/`, `i`, `r`, and chords inside the builders — and it
|
||||||
whenever the view is `viewRules` or `viewTransfers`, or typing `q` would quit
|
was removed on purpose: every action is a link or a button. Do not add a global
|
||||||
and `i` would start an import. Any new full-screen input needs the same
|
`keydown` handler back without being asked; a single-letter key fires while
|
||||||
treatment. It cuts the other way too: a command *inside* a builder has to be a
|
typing in a field unless every one of them is guarded, which is what the old one
|
||||||
chord — `ctrl+s` re-sorts the preview — because every printable key belongs to
|
existed to do.
|
||||||
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.
|
|
||||||
|
|
||||||
**A rule's usage count is how many transactions it wins, not how many its glob
|
**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
|
could match** — `Engine.Usage` counts by `MatchIndex`, so a rule shadowed by a
|
||||||
@@ -210,19 +203,76 @@ writer goes through `writeFileAtomic`, and both editors re-parse the result
|
|||||||
before replacing the file.
|
before replacing the file.
|
||||||
|
|
||||||
**An edit must not lose what the builder does not show.** The form has four
|
**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
|
fields and a `Rule` has five, so `editRule` takes `Type` from the rule on disk —
|
||||||
being edited and the form says it is doing so. Nothing may round-trip a rule
|
never from the request — and the form says it is keeping it. Nothing may
|
||||||
through those four fields alone — a pattern the user was never shown is not a
|
round-trip a rule through those four fields alone — a pattern the user was
|
||||||
pattern they chose to remove. A new field on `Rule` needs the same treatment or
|
never shown is not a pattern they chose to remove. A new field on `Rule` needs
|
||||||
a field of its own. Covered by `TestRuleEditKeepsTypePattern`.
|
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
|
**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
|
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
|
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
|
preview would be empty for a rule that works. `previewGroups` adds them back,
|
||||||
back, and `refreshRulePreview` keeps the ones the new glob stops catching on
|
and `rulePreview` keeps the ones the new glob stops catching on screen marked
|
||||||
screen marked `−` instead of dropping them silently, because giving one up is
|
`−` instead of dropping them silently, because giving one up is the decision
|
||||||
the decision being made.
|
being made. Covered by `TestEditPreviewShowsWhatIsLetGo`.
|
||||||
|
|
||||||
|
**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 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. 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 stops there: importing stays the user's call, made
|
||||||
|
with the Import button, 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 `TestUploadSavesWithoutImporting`, `TestUploadNeverReplacesAStatement` and
|
||||||
|
`TestUploadRefusesBadNames`.
|
||||||
|
|
||||||
|
**The statements list is read from the folders, not the index.** `/api/files`
|
||||||
|
walks `importer.StatementFiles` — the same list import reads — and only then
|
||||||
|
looks each file up in `store.SourceFiles`, comparing `importer.Checksum`, so a
|
||||||
|
file is listed exactly when import would read it and a file the index remembers
|
||||||
|
but the disk lost shows as `missing` instead of vanishing.
|
||||||
|
`/api/files/{account}/{name}` serves a file only by finding it in that list,
|
||||||
|
never by joining the name onto a path. A statement is served from the app's
|
||||||
|
origin, where a script could drive the API, so nothing is ever rendered as a
|
||||||
|
page: text is `text/plain` under `CSP: sandbox`, anything not text or PDF is a
|
||||||
|
sandboxed download, and PDFs — whose viewers refuse a sandbox — open in the
|
||||||
|
browser's own isolated viewer. Covered by `TestServeFileServesOnlyStatements`.
|
||||||
|
|
||||||
|
**Deleting a statement marks its neighbours to be re-read.** `/api/files/delete`
|
||||||
|
finds the file the same way `serveFile` does and deletes it — permanently, by
|
||||||
|
the user's choice; the page says so before it asks. `store.ForgetSourceFile`
|
||||||
|
then drops its transactions and their transfer rows, and `Link` re-pairs, which
|
||||||
|
reads no statement. It does not import, any more than an upload does. A
|
||||||
|
transaction two overlapping statements share is stored once, under the file
|
||||||
|
imported first, so it goes too; `ForgetSourceFile` therefore blanks the
|
||||||
|
checksums of the account's other statements, which show as `changed` until the
|
||||||
|
next Import re-reads them and restores what they hold. Without that the checksum
|
||||||
|
skip would leave those rows gone for good. Covered by
|
||||||
|
`TestRemoveFileKeepsWhatAnotherStatementHolds`.
|
||||||
|
|
||||||
## Adding a bank parser
|
## Adding a bank parser
|
||||||
|
|
||||||
@@ -238,6 +288,17 @@ Keep PDF text extraction separate from parsing: the bank parsers expose a pure
|
|||||||
a runtime dependency of `nlb` and `traderepublic`; no Go library reconstructs
|
a runtime dependency of `nlb` and `traderepublic`; no Go library reconstructs
|
||||||
column layout as well.
|
column layout as well.
|
||||||
|
|
||||||
|
It can be carried inside the binary instead: `scripts/build-bundled.sh` builds
|
||||||
|
a static musl `pdftotext` in a container and embeds it under `-tags bundled`,
|
||||||
|
and `pdftotextCommand` writes it to the user cache dir (named by content hash,
|
||||||
|
verified before reuse) and runs that. It is still the real executable run as a
|
||||||
|
subprocess, deliberately — not poppler linked in through cgo, which would cost
|
||||||
|
the pure-Go build, and not a different extraction API whose spacing might not
|
||||||
|
match what the parsers were tuned on. The tag is opt-in so `go build`/`go test`
|
||||||
|
never need the 5 MB file, which is gitignored rather than committed. Bumping
|
||||||
|
`POPPLER_VERSION` means updating its pinned SHA-256 too, and checking real
|
||||||
|
statements still parse — that output is what the parsers depend on.
|
||||||
|
|
||||||
Do not hardcode absolute column positions from a sample PDF. `pdftotext`
|
Do not hardcode absolute column positions from a sample PDF. `pdftotext`
|
||||||
compresses runs of spaces, so columns shift with font and page size — derive
|
compresses runs of spaces, so columns shift with font and page size — derive
|
||||||
positions from the header line (`traderepublic`) or from the line being parsed
|
positions from the header line (`traderepublic`) or from the line being parsed
|
||||||
@@ -252,6 +313,19 @@ description — at the end, and not in the position it held on the page, so an
|
|||||||
IBAN wrapped across continuation lines stays contiguous for a glob to match.
|
IBAN wrapped across continuation lines stays contiguous for a glob to match.
|
||||||
A new parser must do the same rather than reintroduce a structured field.
|
A new parser must do the same rather than reintroduce a structured field.
|
||||||
|
|
||||||
|
**A parser may name its statements, and only uploads use it.** `parser.Namer` is
|
||||||
|
optional, like `parser.Warner`: `nlb` names an izpisek `izpisek_YYYY-MM-DD`
|
||||||
|
after its *Datum izpiska*, ported from the `rename_izpiski.py` it replaced, and
|
||||||
|
`traderepublic` names a statement `traderepublic_YYYY_MM_DD_YYYY_MM_DD` after
|
||||||
|
the period in its header. A name is derived from what the statement says, never
|
||||||
|
from the upload's own name, and comes back without an extension; the upload's is
|
||||||
|
kept, lowercased. `/api/upload` stages each file as a dotfile — invisible to
|
||||||
|
import — so the parser can read it, then numbers a chosen name that is taken by
|
||||||
|
different contents (`_2`, as the script did) where a name the user gave is
|
||||||
|
refused with 409. Nothing renames a file already in a folder: `source_files`
|
||||||
|
records statements by path, so a rename would orphan its rows as `missing` until
|
||||||
|
the index is rebuilt. Covered by `TestUploadNamesStatementsTheParserCanName`.
|
||||||
|
|
||||||
## Verifying
|
## Verifying
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -272,20 +346,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
|
columns or fails with `no such column`, depending on which way the schema
|
||||||
moved.
|
moved.
|
||||||
|
|
||||||
### Driving the TUI in tests
|
### Testing the web app
|
||||||
|
|
||||||
Prefer feeding `tea.Msg` values to `Model.Update` directly — that is how every
|
Drive the API through `Server.Handler()` with `httptest` — `newTestServer` in
|
||||||
existing TUI test works, and it covers the keymap without a terminal.
|
`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
|
For a look at the real page, run `money --root <demo> serve --addr
|
||||||
deliver keystrokes. Use a pty and answer the two capability queries Bubble Tea
|
127.0.0.1:<port>` against a throwaway root. Delete the index first if you want
|
||||||
sends on startup, or the program blocks before its first render:
|
to watch an import do work, or it will be skipped by checksum and finish
|
||||||
|
instantly.
|
||||||
- `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.
|
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ build changes its shape, delete it and run `money import` again.
|
|||||||
|
|
||||||
Checked in order, first one wins:
|
Checked in order, first one wins:
|
||||||
|
|
||||||
1. `--root DIR` on the command line
|
1. `--root DIR` on the command line, before or after the command
|
||||||
2. `$MONEY_ROOT`
|
2. `$MONEY_ROOT`
|
||||||
3. the `root` key in `$XDG_CONFIG_HOME/money/config.toml`, which is
|
3. the `root` key in `$XDG_CONFIG_HOME/money/config.toml`, which is
|
||||||
`~/.config/money/config.toml` unless `XDG_CONFIG_HOME` says otherwise
|
`~/.config/money/config.toml` unless `XDG_CONFIG_HOME` says otherwise
|
||||||
@@ -56,10 +56,36 @@ Give it the package path, not `cmd/money/main.go`. Naming the file puts the go
|
|||||||
tool in file mode: it names the binary after the source file (`main`) and
|
tool in file mode: it names the binary after the source file (`main`) and
|
||||||
compiles only the files listed, which breaks the moment the package has two.
|
compiles only the files listed, which breaks the moment the package has two.
|
||||||
|
|
||||||
|
That build runs `pdftotext` from `PATH` for the PDF parsers, so the machine
|
||||||
|
needs poppler-utils. To deploy one file with nothing to install alongside it,
|
||||||
|
build with `pdftotext` inside:
|
||||||
|
|
||||||
|
```
|
||||||
|
scripts/build-bundled.sh # → dist/money-linux-amd64
|
||||||
|
scripts/build-bundled.sh arm64 # → dist/money-linux-arm64
|
||||||
|
```
|
||||||
|
|
||||||
|
It needs podman or docker: `pdftotext` is compiled in a container from a
|
||||||
|
checksum-pinned poppler release (`scripts/pdftotext.Containerfile`), as a fully
|
||||||
|
static musl executable with only what text extraction needs, then embedded with
|
||||||
|
`go build -tags bundled`. The result runs on any Linux of that architecture.
|
||||||
|
The first PDF import writes the embedded copy to `~/.cache/money/` (or
|
||||||
|
`$XDG_CACHE_HOME/money/`) and runs it from there; `money config` says which
|
||||||
|
`pdftotext` is in use. Only Linux on amd64 and arm64 is offered — elsewhere,
|
||||||
|
install poppler-utils.
|
||||||
|
|
||||||
|
Building arm64 from an x86 machine runs the container under emulation, so the
|
||||||
|
host needs qemu-user-static and the first build is slow; the executable is then
|
||||||
|
cached under `internal/parser/bundled/` and later builds only rebuild money.
|
||||||
|
|
||||||
|
Poppler is GPL, so a bundled binary is GPL-licensed as a whole. That matters
|
||||||
|
only if you hand the binary to someone else.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
money # open the TUI (default)
|
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 # extract new transactions from every statement
|
||||||
money import --force # re-parse statements even if unchanged
|
money import --force # re-parse statements even if unchanged
|
||||||
money retag # re-apply rules.toml: retag, and re-pair transfers
|
money retag # re-apply rules.toml: retag, and re-pair transfers
|
||||||
@@ -76,7 +102,7 @@ money config # which data root is in use, and why
|
|||||||
|
|
||||||
An account folder appears in the app only once its statements have been
|
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
|
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.
|
||||||
|
|
||||||
`--uniq` turns `ls` into a list of patterns still to write rather than a list of
|
`--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
|
rows to read: one line per distinct description, since fifty visits to the same
|
||||||
@@ -97,32 +123,125 @@ LIDL SOFIA 4412
|
|||||||
|
|
||||||
The account is not part of what makes a row distinct. A rule matches on the
|
The account is not part of what makes a row distinct. A rule matches on the
|
||||||
description and only optionally narrows to an account, so one payee seen on two
|
description and only optionally narrows to an account, so one payee seen on two
|
||||||
accounts is still one pattern to write — and it is one row in the rule builder
|
accounts is still one pattern to write — and it is one row in the rule builder,
|
||||||
on `4`, which groups the same way. Pass `--account` to scope the listing
|
which groups the same way. Pass `--account` to scope the listing instead.
|
||||||
instead.
|
|
||||||
|
|
||||||
It composes with the other filters (`--account`, `--month`, `--search`), and
|
It composes with the other filters (`--account`, `--month`, `--search`), and
|
||||||
`--limit` caps the rows printed, saying how many it held back. It cannot be
|
`--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.
|
combined with `--wide`, whose columns all belong to a single transaction.
|
||||||
|
|
||||||
### TUI keys
|
## The web app
|
||||||
|
|
||||||
| Key | Action |
|
`money serve`, which is also what `money` runs with no command, puts eight
|
||||||
|
screens in a browser: accounts, transactions, report, rule builder, rules,
|
||||||
|
transfer builder, transfers and tags. 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.
|
||||||
|
|
||||||
|
**Re-read all** is `import --force`: every statement is parsed again, including
|
||||||
|
the ones an Import skips because they have not changed. Use it after updating
|
||||||
|
money, when a parser has learned to read something it used to get wrong; rows
|
||||||
|
already in the index are recognised and not duplicated.
|
||||||
|
|
||||||
|
### Statements
|
||||||
|
|
||||||
|
The Accounts screen also lists every statement file in every account
|
||||||
|
folder, with what the index made of it:
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `1` `2` `3` `4` `5` `6` `7` / `tab` | accounts · transactions · report · rule builder · rules · transfer builder · transfers |
|
| `✓` imported | read, and unchanged since |
|
||||||
| `enter` | open the selected account (accounts view) |
|
| `⚠` changed | the file differs from what was imported; Import re-reads it |
|
||||||
| `/` | filter by description |
|
| `•` not imported | new since the last import — or an import tried and failed, in which case the error is in the import report |
|
||||||
| `u` | show only untagged transactions (matched transfer legs are not among them) |
|
| `✗` gone from disk | imported once, then removed; its transactions stay in the index only until it is rebuilt |
|
||||||
| `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 |
|
|
||||||
|
|
||||||
There is no key that tags a transaction. Tags come from `rules.toml` and
|
**Transactions** reads like `3 rows · 0 new`: how many transactions the file
|
||||||
nowhere else, so tagging what you are looking at means writing a rule for it on
|
holds, then how many of them it was the first to bring in. Statements that
|
||||||
`4` — which is why that screen shows you what a glob catches before you save.
|
overlap share rows, and a shared row is stored once, under whichever file was
|
||||||
|
imported first — so a later statement covering the same days shows fewer new
|
||||||
|
rows than it holds, down to `0 new` when an earlier one already had them all.
|
||||||
|
That is deduplication working, not a file that failed to parse.
|
||||||
|
|
||||||
### Report (`3`)
|
Click a file name to open it. PDFs and CSVs open in the browser; anything else
|
||||||
|
downloads. Only files import would read are listed or served — not
|
||||||
|
`account.toml`, dotfiles, or anything outside an account's `include` patterns.
|
||||||
|
|
||||||
|
**Delete** removes a statement from disk for good — there is no copy kept
|
||||||
|
and no undo, so keep the bank's original if you might want it back. Its
|
||||||
|
transactions leave the index, and transfers that used them lose their pairing:
|
||||||
|
the other leg shows as unpaired, which is the truth once one side is gone.
|
||||||
|
Deleting does not import. A row that an overlapping statement also contains goes
|
||||||
|
too, for now: the account's other statements are marked *changed*, and the next
|
||||||
|
**Import** re-reads them and puts it back. For a file already gone from disk the
|
||||||
|
button says **Forget** and only clears the index.
|
||||||
|
|
||||||
|
### Adding statements from the browser
|
||||||
|
|
||||||
|
The Accounts screen has an **Add statements** panel: pick the account,
|
||||||
|
drop files on it (or click to choose them) and press **Upload**. The files are
|
||||||
|
saved into that account's folder, exactly where you would have copied them by
|
||||||
|
hand — the folder stays the source of truth, so deleting `index.db` and
|
||||||
|
re-importing still gets everything back. Uploading does not import: the files
|
||||||
|
wait on the statements list as *not imported* until you press **Import**, so you
|
||||||
|
can put a batch together and look it over first.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Some banks name their downloads unhelpfully, so a parser may name the statement
|
||||||
|
instead — lowercase throughout, extension included — and the status line says
|
||||||
|
what each file became. An NLB izpisek is saved as `izpisek_YYYY-MM-DD.pdf` after
|
||||||
|
the *Datum izpiska* in its header, and a Trade Republic statement, which
|
||||||
|
downloads as `document-N.pdf`, as `traderepublic_YYYY_MM_DD_YYYY_MM_DD.pdf`
|
||||||
|
after the period on its first page (`DATE 01 May 2025 - 31 Jul 2026`). Either
|
||||||
|
way the folder sorts by date. Downloading the same statement twice then lands on
|
||||||
|
the same name and is recognised as already there; a different statement with the
|
||||||
|
same name (a reissue) is numbered `_2`, `_3` rather than refused. A statement
|
||||||
|
with no date it can find keeps its own name. Only uploads are named — files
|
||||||
|
already in a folder are never renamed, since the index records them by path.
|
||||||
|
|
||||||
|
### Navigating
|
||||||
|
|
||||||
|
Every screen is a link in the bar along the top, and every action is a button or
|
||||||
|
a click; there are no keyboard shortcuts. Clicking an account on the Accounts
|
||||||
|
screen 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 in
|
||||||
|
the rule builder — which is why it shows you what a glob catches before you
|
||||||
|
save.
|
||||||
|
|
||||||
|
### Report
|
||||||
|
|
||||||
The report opens on **last month**, not on everything you have ever imported:
|
The report opens on **last month**, not on everything you have ever imported:
|
||||||
an all-time total is the one number a spending report is least often asked for,
|
an all-time total is the one number a spending report is least often asked for,
|
||||||
@@ -130,34 +249,22 @@ 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
|
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.
|
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 time axis beside the totals runs from the widest window down to the oldest
|
||||||
the widest window down to the oldest month in the index:
|
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 to
|
||||||
```
|
report on it.
|
||||||
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 three rolling windows run to the end of *this* month rather than to the
|
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,
|
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
|
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
|
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
|
named window above already covers are not repeated.
|
||||||
terminal too narrow for it and the totals both, where the title still names the
|
|
||||||
period.
|
|
||||||
|
|
||||||
`s` cycles how the rows are arranged, and the marked column heading says which
|
Click a column heading to change how the rows are arranged; the marked heading
|
||||||
one they are arranged by: `Out ▾` largest spend first (the default question a
|
says which one they are arranged by: `Out ▾` largest spend first (the default
|
||||||
spending report answers), then `In ▾`, `Net ▾` lowest first so the biggest
|
question a spending report answers), then `In ▾`, `Net ▾` lowest first so the
|
||||||
losses lead, `N ▾` most transactions first, and `Tag ▴` A→Z. `money report`
|
biggest losses lead, `N ▾` most transactions first, and `Tag ▴` A→Z. `money
|
||||||
takes the same choice as `--sort out|in|net|count|tag`.
|
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
|
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
|
exchange rates here, so two currencies interleaved by amount would invite a
|
||||||
@@ -165,20 +272,20 @@ 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 tag, so they keep a fixed position rather than shuffling between reloads.
|
||||||
The sort rearranges rows; it never changes which rows there are.
|
The sort rearranges rows; it never changes which rows there are.
|
||||||
|
|
||||||
The period narrows the report and only the report. The account filter (`a`,
|
The period narrows the report and only the report. The account, the search and
|
||||||
`enter`), the search (`/`) and the untagged toggle (`u`) are shared with the
|
the untagged toggle are shared with the transaction list, so opening on last
|
||||||
transaction list as before, so opening on last month does not hide the rest of
|
month does not hide the rest of the index from the transaction list.
|
||||||
the index from `2`.
|
|
||||||
|
|
||||||
`money report` takes `--month` instead; there is no command-line equivalent of
|
`money report` takes `--month` instead; there is no command-line equivalent of
|
||||||
the wider windows.
|
the wider windows.
|
||||||
|
|
||||||
### Rule builder (`4`)
|
### Rule builder
|
||||||
|
|
||||||
Writing rules by hand means guessing what a glob will catch. This screen shows
|
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
|
the answer as you type: the form is on the left — glob, account, tag, note —
|
||||||
still-untagged descriptions the glob currently matches, marked `▸`, grouped by
|
and on the right the still-untagged descriptions the glob currently matches,
|
||||||
description, under a running "*n* of *m* descriptions match" count.
|
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
|
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
|
claim — nothing else is left there to read past. The count keeps the context
|
||||||
@@ -187,60 +294,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
|
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
|
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.
|
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.
|
||||||
|
|
||||||
```
|
`enter` (or **Save rule**) appends the rule to `rules.toml` and retags
|
||||||
glob Untagged description ↓ N
|
immediately, so the rows it caught disappear from the list. The account stays
|
||||||
╭────────────────────────────╮ ▸ LIDL SOFIA 4412 2
|
filled in, since the next rule is usually for the same one.
|
||||||
│ *LIDL* │ ▸ LIDL VARNA 9911 1
|
|
||||||
╰────────────────────────────╯
|
|
||||||
vs. the description
|
|
||||||
|
|
||||||
account
|
Clicking the column headings switches the list between by name and by count,
|
||||||
╭────────────────────────────╮
|
most seen first; the `↓` says which column the order is read from. The two
|
||||||
│ blank = every account │
|
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
|
||||||
blank = all accounts
|
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.
|
||||||
|
|
||||||
▸ tag
|
The account and tag fields offer completions as you type. Accounts come from
|
||||||
╭────────────────────────────╮
|
|
||||||
│ 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.
|
|
||||||
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 folders on disk and the index; tags from every tag in use plus any named in
|
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 —
|
`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
|
Leaving the account blank applies the rule everywhere; filling it in also
|
||||||
narrows the preview to that account. The preview lists only transactions no
|
narrows the preview to that account. The preview lists only transactions no
|
||||||
@@ -254,8 +327,8 @@ report leaves them out anyway.
|
|||||||
|
|
||||||
#### Editing a rule
|
#### Editing a rule
|
||||||
|
|
||||||
`e` on the rules screen (`5`) opens the selected rule in this same form with the
|
**Edit** on the rules screen opens the rule in this same form with the
|
||||||
fields filled in, and `enter` rewrites that rule where it sits instead of
|
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
|
appending a new one. It keeps its position, since position still breaks ties
|
||||||
between equally specific rules; `rules.toml` is edited textually, so the
|
between equally specific rules; `rules.toml` is edited textually, so the
|
||||||
comments and formatting around it survive, exactly as when deleting.
|
comments and formatting around it survive, exactly as when deleting.
|
||||||
@@ -270,17 +343,17 @@ 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
|
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:… ·
|
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`
|
kept`. Saving lands back on the rules screen with the new counts; **Cancel**, or
|
||||||
leaves the rule as it was.
|
leaving the screen leaves the rule as it was.
|
||||||
|
|
||||||
### Rules (`5`)
|
### Rules
|
||||||
|
|
||||||
Every rule in file order — the order they are written in, not the order they
|
Every rule in file order — the order they are written in, not the order they
|
||||||
are tried in — with the number of transactions it actually claims. Rules that
|
are tried in — with the number of transactions it actually claims. Rules that
|
||||||
claim none are marked `✗`.
|
claim none are marked `✗`.
|
||||||
|
|
||||||
```
|
```
|
||||||
money · rules · 5 rules · 2 match nothing
|
Rules · 5 rules · 2 match nothing
|
||||||
|
|
||||||
# Pattern Account Tag Txns Note
|
# Pattern Account Tag Txns Note
|
||||||
1 *LIDL SOFIA* (all) groceries 3 the weekly shop
|
1 *LIDL SOFIA* (all) groceries 3 the weekly shop
|
||||||
@@ -299,75 +372,39 @@ 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
|
be just as dead written above rule 1, because precedence is decided by how
|
||||||
specific a rule is and not by where it sits.
|
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`
|
**Edit** opens a rule in the builder, **Delete** deletes it, and **Prune
|
||||||
deletes every rule marked `✗` at once; both deletions ask for a `y` first. `r`
|
unused** deletes every rule marked `✗` at once; both deletions ask first.
|
||||||
recounts against what is currently in the index, which is what you want after an
|
**Refresh counts** recounts against what is currently in the index. Editing and
|
||||||
import has added rows; `rules.toml` itself is read at startup and whenever you
|
deleting both work on `rules.toml` textually, so your comments, ordering and
|
||||||
save a rule from the builder, so an edit made in another window needs a restart.
|
formatting survive. A comment sitting directly above a deleted rule goes with
|
||||||
Editing and deleting both work on `rules.toml` textually, so your comments,
|
it, while one separated by a blank line is treated as a section heading and
|
||||||
ordering and formatting survive. A comment sitting directly above a deleted rule
|
left alone; an edited rule keeps its comments, since they say why it is there
|
||||||
goes with it, while one separated by a blank line is treated as a section
|
and changing its glob rarely changes that.
|
||||||
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`)
|
### Transfer builder
|
||||||
|
|
||||||
Same idea as the rule builder, for money moved between your own accounts. Both
|
Same idea as the rule builder, for money moved between your own accounts. The
|
||||||
sides are named, and the preview on the right shows what the pair would be:
|
form names both sides — from account, from desc, to account, to desc — plus an
|
||||||
`▸` for a movement it matches end to end, `⚠` for a leg it catches on one side
|
optional tolerance and note, and the preview beside it shows what the pair
|
||||||
and cannot pair with anything on the other.
|
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
|
Date Amount Movement Description
|
||||||
╭────────────────────────────╮ ⚠ 2026-04-01 500.00 checking → ? TRANSFER TO SAVINGS
|
⚠ 2026-04-01 500.00 checking → ? TRANSFER TO SAVINGS
|
||||||
│ checking │ ▸ 2026-03-01 500.00 checking → savings TRANSFER TO SAVINGS
|
▸ 2026-03-01 500.00 checking → savings TRANSFER TO SAVINGS
|
||||||
╰────────────────────────────╯
|
|
||||||
money leaves here
|
|
||||||
|
|
||||||
▸ 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
|
The account fields complete exactly as the rule builder's do. `enter` (or
|
||||||
between fields, `pgup` / `pgdn` scroll the list, `enter` appends the definition
|
**Save transfer**) appends the definition to `rules.toml` and re-pairs
|
||||||
to `rules.toml` and re-pairs immediately, and `esc` goes back. Saving keeps both
|
immediately. Saving keeps both account names in place and clears the two globs,
|
||||||
account names in place and clears the two globs, since the next transfer you
|
since the next transfer you write is usually the same route in the other
|
||||||
write is usually the same route in the other direction. The tolerance is cleared
|
direction. The tolerance is cleared with them: carried over silently it would
|
||||||
with them: carried over silently it would loosen a route that never asked for
|
loosen a route that never asked for one.
|
||||||
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.
|
|
||||||
|
|
||||||
`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 —
|
percentage and the pairs it buys appear immediately, showing both amounts —
|
||||||
`500.00 → 495.00` — with what they cost summarised beside the counts:
|
`500.00 → 495.00` — with what they cost summarised beside the counts:
|
||||||
|
|
||||||
@@ -379,12 +416,12 @@ A half-written definition previews too: fill in one side and its legs show up as
|
|||||||
unpaired, which is the quickest way to see that a glob is wrong before you have
|
unpaired, which is the quickest way to see that a glob is wrong before you have
|
||||||
written the other half.
|
written the other half.
|
||||||
|
|
||||||
### Transfers (`7`)
|
### Transfers
|
||||||
|
|
||||||
Every definition in file order, with what it currently pairs.
|
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
|
# From To Pairs Unpaired Tol Note
|
||||||
1 ⚠ checking *TO SAVINGS* savings *FROM CHECKING* 11 1 monthly saving
|
1 ⚠ checking *TO SAVINGS* savings *FROM CHECKING* 11 1 monthly saving
|
||||||
@@ -398,15 +435,53 @@ definition catching legs it cannot complete: a wrong glob on the other side, a
|
|||||||
statement not imported yet, or a movement that genuinely went missing. `✗` is a
|
statement not imported yet, or a movement that genuinely went missing. `✗` is a
|
||||||
definition matching nothing at all, which is just dead weight.
|
definition matching nothing at all, which is just dead weight.
|
||||||
|
|
||||||
|
Click a `⚠` row to see the legs behind it, newest first. Each one says which
|
||||||
|
side is missing and what that side would have had to look like:
|
||||||
|
|
||||||
|
```
|
||||||
|
Date Amount Movement Missing Would pair with
|
||||||
|
2026-04-01 500.00 checking → ? arriving leg +500.00 in savings matching *FROM CHECKING*,
|
||||||
|
dated 2026-03-27 to 2026-04-06
|
||||||
|
```
|
||||||
|
|
||||||
|
That is the search to run on the other statement. If it is not there, the
|
||||||
|
statement probably is not imported yet — when nothing at all from that account
|
||||||
|
is, the line says so. If something close is there, the difference tells you
|
||||||
|
which condition failed: a date outside the window, an amount off by a fee (see
|
||||||
|
[When the bank takes a fee](#when-the-bank-takes-a-fee)), or a description the
|
||||||
|
glob does not match. Across currencies the line says *any amount*, since only
|
||||||
|
the dates are checked there.
|
||||||
|
|
||||||
`Tol` is the definition's `tolerance_pct`, and it qualifies the counts beside
|
`Tol` is the definition's `tolerance_pct`, and it qualifies the counts beside
|
||||||
it: row 3's two pairs were matched on slack rather than on the amount agreeing.
|
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
|
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.
|
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
|
**Delete** deletes a definition and **Prune unmatched** deletes every `✗` one at
|
||||||
after a `y`. Definitions marked `⚠` are never pruned — they are doing something,
|
once, both after asking. Definitions marked `⚠` are never pruned — they are
|
||||||
just not finishing it, and deleting one would hide the problem rather than fix
|
doing something, just not finishing it, and deleting one would hide the problem
|
||||||
it. `r` re-pairs against what is currently in the index.
|
rather than fix it. **Refresh pairing** re-reads what is currently in the index.
|
||||||
|
|
||||||
|
### Tags
|
||||||
|
|
||||||
|
Every distinct tag, A→Z: the ones your transactions carry and any `rules.toml`
|
||||||
|
names, with how many transactions carry each and the rules that write it.
|
||||||
|
|
||||||
|
```
|
||||||
|
Tags · 2 tags
|
||||||
|
|
||||||
|
Tag Txns Rules
|
||||||
|
clothes 0 #4 *ZARA* (0)
|
||||||
|
groceries 2 #1 *LIDL* (0) #2 *KAUFLAND* (1) #3 *LIDL SOFIA* (1)
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules are numbered by their position in `rules.toml`, as on the rules screen,
|
||||||
|
and the count after each is how many transactions it wins — so a tag's rules
|
||||||
|
add up to its transactions, and a rule at `(0)` is matching nothing or being
|
||||||
|
beaten by a more specific one. It is the place to spot near-duplicate tags
|
||||||
|
(`grocery` beside `groceries`) and tags that several rules feed. A tag no rule
|
||||||
|
names any more — `rules.toml` was edited since the last retag — says so; the
|
||||||
|
next Retag clears it. The transfer label is not a tag and never appears here.
|
||||||
|
|
||||||
## rules.toml
|
## rules.toml
|
||||||
|
|
||||||
@@ -463,7 +538,7 @@ tag = "groceries"
|
|||||||
Every tag comes from this file, so a tag is never something you have to keep
|
Every tag comes from this file, so a tag is never something you have to keep
|
||||||
safe: `money retag` recomputes all of them from the current rules and is safe
|
safe: `money retag` recomputes all of them from the current rules and is safe
|
||||||
to run whenever you change it. A transaction no rule matches simply stays
|
to run whenever you change it. A transaction no rule matches simply stays
|
||||||
untagged, and shows up under `money ls --untagged`, the `u` view, and
|
untagged, and shows up under `money ls --untagged`, **Untagged only**, and
|
||||||
`(untagged)` in the report — unless it is one leg of a matched transfer, which
|
`(untagged)` in the report — unless it is one leg of a matched transfer, which
|
||||||
is already spoken for and is left out of all three.
|
is already spoken for and is left out of all three.
|
||||||
|
|
||||||
@@ -529,7 +604,7 @@ amount wins.
|
|||||||
|
|
||||||
**The difference is not forgiven, it is reported.** A pair leaves the report
|
**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
|
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
|
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
|
numbers are in different units — so a tolerance there means nothing and is
|
||||||
ignored.
|
ignored.
|
||||||
@@ -565,7 +640,7 @@ that is the number your spending is short by:
|
|||||||
transfers excluded: 2 legs in EUR, 500.00 out, 495.00 in, 5.00 in fees
|
transfers excluded: 2 legs in EUR, 500.00 out, 495.00 in, 5.00 in fees
|
||||||
```
|
```
|
||||||
|
|
||||||
The report screen (`3`) shows the same thing under its `TOTAL`, the fee on its
|
The report screen shows the same thing under its `TOTAL`, the fee on its
|
||||||
own row (for whichever period it is on):
|
own row (for whichever period it is on):
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -591,7 +666,7 @@ An *unpaired* leg is not a transfer and still counts, tagged like anything
|
|||||||
else. That is deliberate: money that left an account and cannot be shown to
|
else. That is deliberate: money that left an account and cannot be shown to
|
||||||
have arrived is exactly what you want to see, not something to quietly drop.
|
have arrived is exactly what you want to see, not something to quietly drop.
|
||||||
`money import` and `money retag` report unpaired legs on stderr, and the
|
`money import` and `money retag` report unpaired legs on stderr, and the
|
||||||
transfers screen (`7`) shows which definition they belong to.
|
transfers screen shows which definition they belong to.
|
||||||
|
|
||||||
The pairing is derived state, like the tags: it lives in the index, is
|
The pairing is derived state, like the tags: it lives in the index, is
|
||||||
recomputed wholesale by `money retag` and after every import, and disappears
|
recomputed wholesale by `money retag` and after every import, and disappears
|
||||||
@@ -617,8 +692,8 @@ nothing else to configure — `parser` names one of these and that is all.
|
|||||||
|
|
||||||
| `parser` | Statement | Notes |
|
| `parser` | Statement | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `nlb` | NLB izpisek PDF | Wrapped descriptions are folded in from continuation lines, and the IBAN column is appended to the description. |
|
| `nlb` | NLB izpisek PDF | Wrapped descriptions are folded in from continuation lines, and the IBAN column is appended to the description. Uploads are saved as `izpisek_YYYY-MM-DD.pdf` after the statement date. |
|
||||||
| `traderepublic` | Trade Republic PDF | Handles both the single-line and the stacked layout by measuring column positions. |
|
| `traderepublic` | Trade Republic PDF | Handles both the single-line and the stacked layout by measuring column positions. Uploads are saved as `traderepublic_YYYY_MM_DD_YYYY_MM_DD.pdf` after the statement period. |
|
||||||
| `revolut` | `account-statement*.csv` | Skips non-COMPLETED rows, folds the fee into the amount. |
|
| `revolut` | `account-statement*.csv` | Skips non-COMPLETED rows, folds the fee into the amount. |
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
@@ -629,7 +704,8 @@ parser = "traderepublic"
|
|||||||
|
|
||||||
The two PDF parsers shell out to `pdftotext -layout` (poppler-utils), exactly
|
The two PDF parsers shell out to `pdftotext -layout` (poppler-utils), exactly
|
||||||
as the Python versions did; its layout reconstruction is what makes the
|
as the Python versions did; its layout reconstruction is what makes the
|
||||||
column-based parsing work.
|
column-based parsing work. A build made with `scripts/build-bundled.sh`
|
||||||
|
carries its own copy instead (see Install).
|
||||||
|
|
||||||
Amount parsing is shared and deliberately tolerant: `1.234,56`, `-45.20`,
|
Amount parsing is shared and deliberately tolerant: `1.234,56`, `-45.20`,
|
||||||
`45,20-`, `(45.20)` and `45.20 EUR` all work, whichever parser reads them.
|
`45,20-`, `(45.20)` and `45.20 EUR` all work, whichever parser reads them.
|
||||||
|
|||||||
+91
-10
@@ -2,17 +2,23 @@
|
|||||||
//
|
//
|
||||||
// The data root holds one folder per account. Drop statements into those
|
// 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
|
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.petrovv.com/nikola/money/internal/config"
|
"git.petrovv.com/nikola/money/internal/config"
|
||||||
"git.petrovv.com/nikola/money/internal/importer"
|
"git.petrovv.com/nikola/money/internal/importer"
|
||||||
@@ -22,16 +28,17 @@ import (
|
|||||||
"git.petrovv.com/nikola/money/internal/rules"
|
"git.petrovv.com/nikola/money/internal/rules"
|
||||||
"git.petrovv.com/nikola/money/internal/store"
|
"git.petrovv.com/nikola/money/internal/store"
|
||||||
"git.petrovv.com/nikola/money/internal/transfers"
|
"git.petrovv.com/nikola/money/internal/transfers"
|
||||||
"git.petrovv.com/nikola/money/internal/tui"
|
"git.petrovv.com/nikola/money/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
const usage = `money - statement-driven personal finance tracker
|
const usage = `money - statement-driven personal finance tracker
|
||||||
|
|
||||||
usage: money [--root DIR] <command> [flags]
|
usage: money [--root DIR] <command> [flags] (--root may also follow the command)
|
||||||
|
|
||||||
commands:
|
commands:
|
||||||
tui browse transactions, build the rules that tag them and the
|
serve browse transactions, build the rules that tag them and the
|
||||||
transfers that pair them across accounts (default)
|
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
|
import extract transactions from every statement into the index
|
||||||
retag re-apply rules.toml: retag everything and re-pair transfers
|
retag re-apply rules.toml: retag everything and re-pair transfers
|
||||||
ls list transactions
|
ls list transactions
|
||||||
@@ -75,11 +82,22 @@ func run(args []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := "tui"
|
cmd := "serve"
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
cmd, args = args[0], args[1:]
|
cmd, args = args[0], args[1:]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `money serve --root DIR` is too natural to reject, so --root is also
|
||||||
|
// accepted after the subcommand; each subcommand's own flag set never
|
||||||
|
// sees it.
|
||||||
|
after, args, err := takeRoot(args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if after != "" {
|
||||||
|
root = after
|
||||||
|
}
|
||||||
|
|
||||||
abs, source, err := config.ResolveRoot(root)
|
abs, source, err := config.ResolveRoot(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -101,8 +119,8 @@ func run(args []string) error {
|
|||||||
case "parsers":
|
case "parsers":
|
||||||
fmt.Println(strings.Join(parser.Names(), "\n"))
|
fmt.Println(strings.Join(parser.Names(), "\n"))
|
||||||
return nil
|
return nil
|
||||||
case "tui":
|
case "serve":
|
||||||
return cmdTUI(abs, args)
|
return cmdServe(abs, args)
|
||||||
case "help", "-h", "--help":
|
case "help", "-h", "--help":
|
||||||
fmt.Print(usage)
|
fmt.Print(usage)
|
||||||
return nil
|
return nil
|
||||||
@@ -127,6 +145,7 @@ func cmdConfig(root string, source config.RootSource) error {
|
|||||||
fmt.Fprintf(w, "data root\t%s\n", root)
|
fmt.Fprintf(w, "data root\t%s\n", root)
|
||||||
fmt.Fprintf(w, "chosen by\t%s\n", source)
|
fmt.Fprintf(w, "chosen by\t%s\n", source)
|
||||||
fmt.Fprintf(w, "config file\t%s (%s)\n", path, exists)
|
fmt.Fprintf(w, "config file\t%s (%s)\n", path, exists)
|
||||||
|
fmt.Fprintf(w, "pdftotext\t%s\n", parser.PdftotextSource())
|
||||||
if _, err := os.Stat(root); err != nil {
|
if _, err := os.Stat(root); err != nil {
|
||||||
fmt.Fprintf(w, "status\tdoes not exist yet\n")
|
fmt.Fprintf(w, "status\tdoes not exist yet\n")
|
||||||
} else {
|
} else {
|
||||||
@@ -466,11 +485,73 @@ func cmdAccounts(root string, _ []string) error {
|
|||||||
return w.Flush()
|
return w.Flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
func cmdTUI(root string, _ []string) error {
|
// 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.
|
||||||
|
func takeRoot(args []string) (root string, rest []string, err error) {
|
||||||
|
rest = make([]string, 0, len(args))
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
a := args[i]
|
||||||
|
if a == "--" {
|
||||||
|
return root, append(rest, args[i:]...), nil
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(a, "-") {
|
||||||
|
rest = append(rest, a)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name, inline, hasInline := strings.Cut(strings.TrimLeft(a, "-"), "=")
|
||||||
|
if name != "root" {
|
||||||
|
rest = append(rest, a)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if hasInline {
|
||||||
|
root = inline
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
return "", nil, fmt.Errorf("--root needs a directory")
|
||||||
|
}
|
||||||
|
root = args[i+1]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return root, rest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
||||||
|
addr := fs.String("addr", "127.0.0.1:8080", "address to listen on")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
o, err := open(root)
|
o, err := open(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer o.db.Close()
|
defer o.db.Close()
|
||||||
return tui.Run(root, o.db, o.accounts, o.engine, o.links)
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: *addr,
|
||||||
|
Handler: web.New(root, o.db, o.accounts, o.engine, o.links).Handler(),
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
// Let an import in flight finish writing the index before it closes.
|
||||||
|
shutdown, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
srv.Shutdown(shutdown)
|
||||||
|
}()
|
||||||
|
|
||||||
|
fmt.Printf("serving %s on http://%s\n", root, *addr)
|
||||||
|
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,3 +84,27 @@ func TestUniqLimitReportsWhatItHeldBack(t *testing.T) {
|
|||||||
t.Errorf("want the first row alphabetically:\n%s", out)
|
t.Errorf("want the first row alphabetically:\n%s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRootAfterTheSubcommand(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
args []string
|
||||||
|
root string
|
||||||
|
rest []string
|
||||||
|
}{
|
||||||
|
{[]string{"--root", "/r", "--addr", ":9"}, "/r", []string{"--addr", ":9"}},
|
||||||
|
{[]string{"--addr", ":9", "-root=/r"}, "/r", []string{"--addr", ":9"}},
|
||||||
|
{[]string{"--untagged"}, "", []string{"--untagged"}},
|
||||||
|
{[]string{"--", "--root", "/r"}, "", []string{"--", "--root", "/r"}},
|
||||||
|
} {
|
||||||
|
root, rest, err := takeRoot(tc.args)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%q: %v", tc.args, err)
|
||||||
|
}
|
||||||
|
if root != tc.root || strings.Join(rest, " ") != strings.Join(tc.rest, " ") {
|
||||||
|
t.Errorf("%q: got %q %q, want %q %q", tc.args, root, rest, tc.root, tc.rest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, _, err := takeRoot([]string{"--root"}); err == nil {
|
||||||
|
t.Error("--root without a directory was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,38 +4,16 @@ go 1.25.6
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.6.0
|
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
|
modernc.org/sqlite v1.56.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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/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/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-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/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.3.8 // indirect
|
|
||||||
modernc.org/libc v1.74.4 // indirect
|
modernc.org/libc v1.74.4 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // 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 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
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 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
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 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
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 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||||
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ func Run(root string, db *store.DB, accounts []*config.Account, engine *rules.En
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
files, err := statementFiles(acc)
|
files, err := StatementFiles(acc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
@@ -134,7 +134,7 @@ func importFile(root string, db *store.DB, acc *config.Account, accountID int64,
|
|||||||
}
|
}
|
||||||
fr := FileResult{Account: acc.Slug, Path: rel}
|
fr := FileResult{Account: acc.Slug, Path: rel}
|
||||||
|
|
||||||
sum, err := checksum(path)
|
sum, err := Checksum(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fr.Err = err
|
fr.Err = err
|
||||||
return fr
|
return fr
|
||||||
@@ -162,7 +162,7 @@ func importFile(root string, db *store.DB, acc *config.Account, accountID int64,
|
|||||||
}
|
}
|
||||||
fr.Warnings = append(fr.Warnings, checkBalances(txns, acc.Digits())...)
|
fr.Warnings = append(fr.Warnings, checkBalances(txns, acc.Digits())...)
|
||||||
|
|
||||||
sourceID, err := db.SourceFile(accountID, rel, sum, time.Now().UTC().Format(time.RFC3339))
|
sourceID, err := db.SourceFile(accountID, rel, sum, time.Now().UTC().Format(time.RFC3339), len(txns))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fr.Err = err
|
fr.Err = err
|
||||||
return fr
|
return fr
|
||||||
@@ -252,10 +252,11 @@ func fingerprint(key string, ordinal int) string {
|
|||||||
return hex.EncodeToString(sum[:])
|
return hex.EncodeToString(sum[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
// statementFiles lists the files in an account folder that should be parsed:
|
// StatementFiles lists the files in an account folder that should be parsed:
|
||||||
// every regular file except account.toml and dotfiles, narrowed by the
|
// every regular file except account.toml and dotfiles, narrowed by the
|
||||||
// account's optional include globs.
|
// account's optional include globs. It is also what the web app lists and
|
||||||
func statementFiles(acc *config.Account) ([]string, error) {
|
// serves, so a file is shown exactly when import would read it.
|
||||||
|
func StatementFiles(acc *config.Account) ([]string, error) {
|
||||||
entries, err := os.ReadDir(acc.Dir)
|
entries, err := os.ReadDir(acc.Dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read account dir %s: %w", acc.Dir, err)
|
return nil, fmt.Errorf("read account dir %s: %w", acc.Dir, err)
|
||||||
@@ -284,7 +285,9 @@ func matchAny(patterns []string, name string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func checksum(path string) (string, error) {
|
// Checksum is the sha256 a statement is recorded under; an import skips a file
|
||||||
|
// whose checksum has not changed.
|
||||||
|
func Checksum(path string) (string, error) {
|
||||||
f, err := os.Open(path)
|
f, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bundledPdftotext is a static pdftotext built into this binary, or empty
|
||||||
|
// when it was built without one. Only `go build -tags bundled` fills it (see
|
||||||
|
// scripts/build-bundled.sh), so an ordinary build stays pure Go and needs no
|
||||||
|
// 5 MB executable checked out next to it.
|
||||||
|
var bundledPdftotext []byte
|
||||||
|
|
||||||
|
var (
|
||||||
|
bundledOnce sync.Once
|
||||||
|
bundledPath string
|
||||||
|
bundledErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
// pdftotextCommand names the pdftotext to run: the bundled copy when there is
|
||||||
|
// one, otherwise whatever is on PATH. The bundled copy wins because it is the
|
||||||
|
// version the binary was built and checked with, which is the point of
|
||||||
|
// carrying it rather than trusting the host's.
|
||||||
|
func pdftotextCommand() (string, error) {
|
||||||
|
if len(bundledPdftotext) == 0 {
|
||||||
|
return "pdftotext", nil
|
||||||
|
}
|
||||||
|
bundledOnce.Do(func() { bundledPath, bundledErr = installBundled(bundledPdftotext) })
|
||||||
|
return bundledPath, bundledErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// PdftotextSource says which pdftotext the PDF parsers will run, for
|
||||||
|
// `money config`: a deployment that lacks one should find out before an
|
||||||
|
// import fails on it.
|
||||||
|
func PdftotextSource() string {
|
||||||
|
if len(bundledPdftotext) == 0 {
|
||||||
|
return "pdftotext from PATH (not bundled into this build)"
|
||||||
|
}
|
||||||
|
path, err := pdftotextCommand()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("bundled, but it cannot be installed: %v", err)
|
||||||
|
}
|
||||||
|
return "bundled, run from " + path
|
||||||
|
}
|
||||||
|
|
||||||
|
// installBundled writes the executable to the user's cache directory, where it
|
||||||
|
// can be exec'd, and returns its path. The name carries the content hash, so a
|
||||||
|
// newer build never runs an older build's copy, and a file already there is
|
||||||
|
// only reused once its contents check out — a truncated write from a killed
|
||||||
|
// process must not become the pdftotext every later run trusts.
|
||||||
|
func installBundled(bin []byte) (string, error) {
|
||||||
|
sum := sha256.Sum256(bin)
|
||||||
|
name := "pdftotext-" + hex.EncodeToString(sum[:8])
|
||||||
|
|
||||||
|
// The cache directory rather than /tmp: /tmp is often mounted noexec on
|
||||||
|
// servers, and the cache survives reboots, so this happens once per build.
|
||||||
|
dir, err := os.UserCacheDir()
|
||||||
|
if err != nil {
|
||||||
|
dir = os.TempDir()
|
||||||
|
}
|
||||||
|
dir = filepath.Join(dir, "money")
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
|
||||||
|
if same, err := hasContents(path, sum); err != nil {
|
||||||
|
return "", err
|
||||||
|
} else if same {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("install bundled pdftotext: %w", err)
|
||||||
|
}
|
||||||
|
// Written beside the target and renamed into place, so a concurrent run
|
||||||
|
// never execs a half-written file.
|
||||||
|
tmp, err := os.CreateTemp(dir, name+".*")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("install bundled pdftotext: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmp.Name()) // a no-op once renamed
|
||||||
|
if _, err := tmp.Write(bin); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return "", fmt.Errorf("install bundled pdftotext to %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
if err := tmp.Chmod(0o755); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return "", fmt.Errorf("install bundled pdftotext to %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return "", fmt.Errorf("install bundled pdftotext to %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp.Name(), path); err != nil {
|
||||||
|
return "", fmt.Errorf("install bundled pdftotext to %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasContents reports whether the file at path exists and hashes to sum.
|
||||||
|
func hasContents(path string, sum [sha256.Size]byte) (bool, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("check bundled pdftotext: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
h := sha256.New()
|
||||||
|
if _, err := io.Copy(h, f); err != nil {
|
||||||
|
return false, fmt.Errorf("check bundled pdftotext: %w", err)
|
||||||
|
}
|
||||||
|
return bytes.Equal(h.Sum(nil), sum[:]), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
//go:build bundled
|
||||||
|
|
||||||
|
package parser
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
// Built by scripts/build-bundled.sh amd64, which also runs the go build that
|
||||||
|
// needs it. The file is not checked in, so this only compiles after that.
|
||||||
|
//
|
||||||
|
//go:embed bundled/pdftotext-linux-amd64
|
||||||
|
var embeddedPdftotext []byte
|
||||||
|
|
||||||
|
func init() { bundledPdftotext = embeddedPdftotext }
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
//go:build bundled
|
||||||
|
|
||||||
|
package parser
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
// Built by scripts/build-bundled.sh arm64, which also runs the go build that
|
||||||
|
// needs it. The file is not checked in, so this only compiles after that.
|
||||||
|
//
|
||||||
|
//go:embed bundled/pdftotext-linux-arm64
|
||||||
|
var embeddedPdftotext []byte
|
||||||
|
|
||||||
|
func init() { bundledPdftotext = embeddedPdftotext }
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakePdftotext stands in for the real executable: what matters here is how it
|
||||||
|
// is put on disk, not what it does once there.
|
||||||
|
var fakePdftotext = []byte("#!/bin/sh\necho bundled\n")
|
||||||
|
|
||||||
|
// The bundled copy has to land somewhere it can be exec'd, and run.
|
||||||
|
func TestInstallBundledRuns(t *testing.T) {
|
||||||
|
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||||
|
path, err := installBundled(fakePdftotext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, err := exec.Command(path).Output()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if string(out) != "bundled\n" {
|
||||||
|
t.Errorf("output %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A copy already in place is reused, but only once its contents check out: a
|
||||||
|
// write cut short by a killed process must not become what every later run
|
||||||
|
// trusts.
|
||||||
|
func TestInstallBundledReplacesADamagedCopy(t *testing.T) {
|
||||||
|
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||||
|
path, err := installBundled(fakePdftotext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, fakePdftotext[:5], 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
again, err := installBundled(fakePdftotext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if again != path {
|
||||||
|
t.Errorf("reinstalled to %s, want %s", again, path)
|
||||||
|
}
|
||||||
|
if got, _ := os.ReadFile(path); string(got) != string(fakePdftotext) {
|
||||||
|
t.Errorf("damaged copy kept: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each build's copy has its own name, so a newer binary never runs an older
|
||||||
|
// one's pdftotext left in the same cache.
|
||||||
|
func TestInstallBundledNamesByContent(t *testing.T) {
|
||||||
|
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||||
|
a, err := installBundled(fakePdftotext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, err := installBundled(append([]byte(nil), "#!/bin/sh\necho newer\n"...))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if a == b || filepath.Dir(a) != filepath.Dir(b) {
|
||||||
|
t.Errorf("paths %s and %s, want two names in one directory", a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without -tags bundled, nothing is embedded and PATH decides, as before.
|
||||||
|
func TestUnbundledUsesPath(t *testing.T) {
|
||||||
|
if len(bundledPdftotext) != 0 {
|
||||||
|
t.Skip("built with -tags bundled")
|
||||||
|
}
|
||||||
|
if cmd, err := pdftotextCommand(); err != nil || cmd != "pdftotext" {
|
||||||
|
t.Errorf("command = %q, %v; want pdftotext from PATH", cmd, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
//go:build bundled && !(linux && (amd64 || arm64))
|
||||||
|
|
||||||
|
package parser
|
||||||
|
|
||||||
|
// pdftotext is only built for Linux, as a static musl executable; there is
|
||||||
|
// nothing to embed for this platform. Build without -tags bundled and install
|
||||||
|
// poppler-utils instead.
|
||||||
|
var _ = bundled_pdftotext_is_only_available_for_linux_amd64_and_linux_arm64
|
||||||
@@ -43,6 +43,10 @@ var (
|
|||||||
nlbAccount = regexp.MustCompile(`^SI\d{2}(?:\s?\d{4}){3}\s?\d{3}$`)
|
nlbAccount = regexp.MustCompile(`^SI\d{2}(?:\s?\d{4}){3}\s?\d{3}$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// nlbStatementDate is the issue date in an izpisek's header. It names the
|
||||||
|
// statement: NLB's downloads are not named for what they hold.
|
||||||
|
var nlbStatementDate = regexp.MustCompile(`Datum izpiska\s+(\d{2})\.(\d{2})\.(\d{4})`)
|
||||||
|
|
||||||
// nlbMinContinuationIndent is the shallowest indent a wrapped description line
|
// nlbMinContinuationIndent is the shallowest indent a wrapped description line
|
||||||
// may have. The real threshold is the description column of the transaction
|
// may have. The real threshold is the description column of the transaction
|
||||||
// the line belongs to, measured per line rather than hardcoded: pdftotext
|
// the line belongs to, measured per line rather than hardcoded: pdftotext
|
||||||
@@ -61,6 +65,24 @@ func (p *nlbParser) Parse(path string, acc *config.Account) ([]RawTxn, error) {
|
|||||||
return parseNLBText(text, p.digits)
|
return parseNLBText(text, p.digits)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StatementName names an izpisek after its statement date, izpisek_YYYY-MM-DD,
|
||||||
|
// so the folder sorts by date and two downloads of one statement collide.
|
||||||
|
func (p *nlbParser) StatementName(path string) (string, error) {
|
||||||
|
text, err := pdfToText(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return nlbStatementName(text), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nlbStatementName(text string) string {
|
||||||
|
m := nlbStatementDate.FindStringSubmatch(text)
|
||||||
|
if m == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("izpisek_%s-%s-%s", m[3], m[2], m[1])
|
||||||
|
}
|
||||||
|
|
||||||
// parseNLBText holds the whole parser, separated from PDF extraction so it can
|
// parseNLBText holds the whole parser, separated from PDF extraction so it can
|
||||||
// be tested against captured pdftotext output.
|
// be tested against captured pdftotext output.
|
||||||
func parseNLBText(text string, digits int) ([]RawTxn, error) {
|
func parseNLBText(text string, digits int) ([]RawTxn, error) {
|
||||||
|
|||||||
@@ -112,3 +112,15 @@ func TestParseNLBRejectsMalformedLine(t *testing.T) {
|
|||||||
t.Error("expected an error for a transaction line with no amount")
|
t.Error("expected an error for a transaction line with no amount")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An izpisek is named after its statement date, as rename_izpiski.py did.
|
||||||
|
func TestNLBStatementName(t *testing.T) {
|
||||||
|
header := " IZPISEK 001/2026\n" +
|
||||||
|
" Datum izpiska 31.01.2026 Stran 1\n"
|
||||||
|
if got := nlbStatementName(header + nlbPage); got != "izpisek_2026-01-31" {
|
||||||
|
t.Errorf("name = %q, want izpisek_2026-01-31", got)
|
||||||
|
}
|
||||||
|
if got := nlbStatementName(nlbPage); got != "" {
|
||||||
|
t.Errorf("name without a statement date = %q, want none", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,6 +43,17 @@ type Warner interface {
|
|||||||
Warnings() []string
|
Warnings() []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Namer is an optional interface for parsers whose bank names its downloads
|
||||||
|
// unhelpfully. StatementName reads the statement at path and returns the name,
|
||||||
|
// without extension, it should be stored under — the upload's extension is
|
||||||
|
// kept, lowercased — or "" when the statement does not say, in which case it
|
||||||
|
// keeps the name it came with. Only an upload uses
|
||||||
|
// it; files already in a folder are never renamed, since the index records
|
||||||
|
// them by path.
|
||||||
|
type Namer interface {
|
||||||
|
StatementName(path string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
// Factory builds a parser from an account's config, validating it up front so
|
// Factory builds a parser from an account's config, validating it up front so
|
||||||
// a bad account.toml fails before any file is read.
|
// a bad account.toml fails before any file is read.
|
||||||
type Factory func(acc *config.Account) (Parser, error)
|
type Factory func(acc *config.Account) (Parser, error)
|
||||||
|
|||||||
@@ -17,12 +17,18 @@ const pdfToTextTimeout = 2 * time.Minute
|
|||||||
//
|
//
|
||||||
// This shells out to poppler's pdftotext rather than decoding the PDF in Go:
|
// This shells out to poppler's pdftotext rather than decoding the PDF in Go:
|
||||||
// the layout reconstruction it does is the whole reason the column-based
|
// the layout reconstruction it does is the whole reason the column-based
|
||||||
// parsers work, and no Go library matches it.
|
// parsers work, and no Go library matches it. A build made with -tags bundled
|
||||||
|
// carries its own copy, so a server needs nothing installed.
|
||||||
func pdfToText(path string) (string, error) {
|
func pdfToText(path string) (string, error) {
|
||||||
|
bin, err := pdftotextCommand()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), pdfToTextTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), pdfToTextTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "pdftotext", "-layout", path, "-")
|
cmd := exec.CommandContext(ctx, bin, "-layout", path, "-")
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
cmd.Stdout = &stdout
|
cmd.Stdout = &stdout
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
@@ -34,6 +40,12 @@ func pdfToText(path string) (string, error) {
|
|||||||
if errors := strings.TrimSpace(stderr.String()); errors != "" {
|
if errors := strings.TrimSpace(stderr.String()); errors != "" {
|
||||||
return "", fmt.Errorf("pdftotext %s: %w: %s", path, err, errors)
|
return "", fmt.Errorf("pdftotext %s: %w: %s", path, err, errors)
|
||||||
}
|
}
|
||||||
|
if bin != "pdftotext" {
|
||||||
|
// The copy was installed, so failing to start it is about where it
|
||||||
|
// was put: a cache directory on a noexec mount is the usual cause.
|
||||||
|
return "", fmt.Errorf("bundled pdftotext at %s did not run (is that directory mounted noexec? "+
|
||||||
|
"point XDG_CACHE_HOME somewhere else): %w", bin, err)
|
||||||
|
}
|
||||||
if _, lookErr := exec.LookPath("pdftotext"); lookErr != nil {
|
if _, lookErr := exec.LookPath("pdftotext"); lookErr != nil {
|
||||||
return "", fmt.Errorf("pdftotext is not installed (it ships with poppler-utils): %w", lookErr)
|
return "", fmt.Errorf("pdftotext is not installed (it ships with poppler-utils): %w", lookErr)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ const trAmountSlack = 5
|
|||||||
// trDateLayout is the date Trade Republic prints, e.g. "05 Jan 2026".
|
// trDateLayout is the date Trade Republic prints, e.g. "05 Jan 2026".
|
||||||
const trDateLayout = "02 Jan 2006"
|
const trDateLayout = "02 Jan 2006"
|
||||||
|
|
||||||
|
// trPeriod is the statement period in the address block at the top of the
|
||||||
|
// first page, e.g. "DATE 01 May 2025 - 31 Jul 2026". It names the
|
||||||
|
// statement: Trade Republic's downloads are all called document-N.pdf. The
|
||||||
|
// transaction table's own DATE heading never matches, since no dates follow it.
|
||||||
|
var trPeriod = regexp.MustCompile(`\bDATE\s+(\d{2} [A-Z][a-z]{2} \d{4})\s*[-\x{2013}\x{2212}]\s*(\d{2} [A-Z][a-z]{2} \d{4})`)
|
||||||
|
|
||||||
func (p *tradeRepublicParser) Parse(path string, acc *config.Account) ([]RawTxn, error) {
|
func (p *tradeRepublicParser) Parse(path string, acc *config.Account) ([]RawTxn, error) {
|
||||||
text, err := pdfToText(path)
|
text, err := pdfToText(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -54,6 +60,33 @@ func (p *tradeRepublicParser) Parse(path string, acc *config.Account) ([]RawTxn,
|
|||||||
return parseTradeRepublicText(text, p.digits)
|
return parseTradeRepublicText(text, p.digits)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StatementName names a statement after its period,
|
||||||
|
// traderepublic_YYYY_MM_DD_YYYY_MM_DD, so the folder sorts by when it starts
|
||||||
|
// and two downloads of one statement collide.
|
||||||
|
func (p *tradeRepublicParser) StatementName(path string) (string, error) {
|
||||||
|
text, err := pdfToText(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return tradeRepublicStatementName(text), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tradeRepublicStatementName(text string) string {
|
||||||
|
m := trPeriod.FindStringSubmatch(text)
|
||||||
|
if m == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
from, err := time.Parse(trDateLayout, m[1])
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
to, err := time.Parse(trDateLayout, m[2])
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "traderepublic_" + from.Format("2006_01_02") + "_" + to.Format("2006_01_02")
|
||||||
|
}
|
||||||
|
|
||||||
// parseTradeRepublicText holds the whole parser, separated from PDF extraction
|
// parseTradeRepublicText holds the whole parser, separated from PDF extraction
|
||||||
// so it can be tested against captured pdftotext output.
|
// so it can be tested against captured pdftotext output.
|
||||||
func parseTradeRepublicText(text string, digits int) ([]RawTxn, error) {
|
func parseTradeRepublicText(text string, digits int) ([]RawTxn, error) {
|
||||||
|
|||||||
@@ -162,3 +162,19 @@ func TestParseTradeRepublicIgnoresAmountsInDescription(t *testing.T) {
|
|||||||
t.Errorf("description = %q, want the inline figure kept", txns[0].Description)
|
t.Errorf("description = %q, want the inline figure kept", txns[0].Description)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A statement is named after the period in its header; the transaction
|
||||||
|
// table's DATE heading, with no dates after it, is not mistaken for it.
|
||||||
|
func TestTradeRepublicStatementName(t *testing.T) {
|
||||||
|
header := "NIKOLA PETROV DATE 01 May 2025 - 31 Jul 2026\n" +
|
||||||
|
"Ulica 21 IBAN DE00000000000000000000\n\n" +
|
||||||
|
"DATE TYPE DESCRIPTION MONEY IN MONEY OUT BALANCE\n"
|
||||||
|
if got := tradeRepublicStatementName(header); got != "traderepublic_2025_05_01_2026_07_31" {
|
||||||
|
t.Errorf("name = %q, want traderepublic_2025_05_01_2026_07_31", got)
|
||||||
|
}
|
||||||
|
table := "DATE TYPE DESCRIPTION MONEY IN MONEY OUT BALANCE\n" +
|
||||||
|
"01 Jul 2026 Interest Interest payment €1.17 €671.58\n"
|
||||||
|
if got := tradeRepublicStatementName(table); got != "" {
|
||||||
|
t.Errorf("name without a period = %q, want none", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func seed(t *testing.T, db *store.DB, descriptions ...string) int64 {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
sourceID, err := db.SourceFile(accountID, "checking/st.csv", "sha", "2026-01-01T00:00:00Z")
|
sourceID, err := db.SourceFile(accountID, "checking/st.csv", "sha", "2026-01-01T00:00:00Z", 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
+99
-7
@@ -37,6 +37,9 @@ CREATE TABLE IF NOT EXISTS source_files (
|
|||||||
path TEXT NOT NULL,
|
path TEXT NOT NULL,
|
||||||
sha256 TEXT NOT NULL,
|
sha256 TEXT NOT NULL,
|
||||||
imported_at TEXT NOT NULL,
|
imported_at TEXT NOT NULL,
|
||||||
|
-- How many transactions the statement holds, as parsed. Not how many it
|
||||||
|
-- brought in: rows an overlapping statement already had are deduplicated.
|
||||||
|
rows INTEGER NOT NULL DEFAULT 0,
|
||||||
UNIQUE(account_id, path)
|
UNIQUE(account_id, path)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -130,15 +133,17 @@ func (d *DB) Accounts() ([]model.Account, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SourceFile records that a statement file was imported, returning its id.
|
// SourceFile records that a statement file was imported, holding rows
|
||||||
func (d *DB) SourceFile(accountID int64, path, sha, importedAt string) (int64, error) {
|
// transactions, and returns its id.
|
||||||
|
func (d *DB) SourceFile(accountID int64, path, sha, importedAt string, rows int) (int64, error) {
|
||||||
_, err := d.sql.Exec(`
|
_, err := d.sql.Exec(`
|
||||||
INSERT INTO source_files (account_id, path, sha256, imported_at)
|
INSERT INTO source_files (account_id, path, sha256, imported_at, rows)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(account_id, path) DO UPDATE SET
|
ON CONFLICT(account_id, path) DO UPDATE SET
|
||||||
sha256 = excluded.sha256,
|
sha256 = excluded.sha256,
|
||||||
imported_at = excluded.imported_at`,
|
imported_at = excluded.imported_at,
|
||||||
accountID, path, sha, importedAt)
|
rows = excluded.rows`,
|
||||||
|
accountID, path, sha, importedAt, rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("record source file %s: %w", path, err)
|
return 0, fmt.Errorf("record source file %s: %w", path, err)
|
||||||
}
|
}
|
||||||
@@ -393,7 +398,94 @@ func (d *DB) Months() ([]string, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tags lists every tag in use, for completion in the TUI.
|
// SourceFileInfo is what the index recorded about one imported statement.
|
||||||
|
type SourceFileInfo struct {
|
||||||
|
AccountSlug string
|
||||||
|
Path string // relative to the data root, as the importer records it
|
||||||
|
SHA256 string
|
||||||
|
ImportedAt string
|
||||||
|
// Rows is how many transactions the statement holds, as parsed.
|
||||||
|
Rows int
|
||||||
|
// Added counts the transactions this file introduced. A row seen again in
|
||||||
|
// an overlapping statement is deduplicated and stays with the file that
|
||||||
|
// brought it first, so this is not how many rows the file holds.
|
||||||
|
Added int
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceFiles lists every statement the index has imported.
|
||||||
|
func (d *DB) SourceFiles() ([]SourceFileInfo, error) {
|
||||||
|
rows, err := d.sql.Query(`
|
||||||
|
SELECT a.slug, s.path, s.sha256, s.imported_at, s.rows, COUNT(t.id)
|
||||||
|
FROM source_files s
|
||||||
|
JOIN accounts a ON a.id = s.account_id
|
||||||
|
LEFT JOIN transactions t ON t.source_file_id = s.id
|
||||||
|
GROUP BY s.id
|
||||||
|
ORDER BY a.slug, s.path`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list source files: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []SourceFileInfo
|
||||||
|
for rows.Next() {
|
||||||
|
var f SourceFileInfo
|
||||||
|
if err := rows.Scan(&f.AccountSlug, &f.Path, &f.SHA256, &f.ImportedAt, &f.Rows, &f.Added); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForgetSourceFile removes a statement from the index: its transactions, any
|
||||||
|
// transfer pairing that used them, and the record of it, returning how many
|
||||||
|
// transactions went. Pairing is derived state that the next Link rewrites, so
|
||||||
|
// dropping a pair here loses nothing.
|
||||||
|
//
|
||||||
|
// A transaction two overlapping statements share is stored once, under the
|
||||||
|
// file that brought it first, so removing that file can take rows another
|
||||||
|
// statement still holds. The account's other statements therefore forget
|
||||||
|
// their checksums, and the next import re-reads them and restores those rows.
|
||||||
|
func (d *DB) ForgetSourceFile(accountSlug, path string) (int, error) {
|
||||||
|
tx, err := d.sql.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
var id, accountID int64
|
||||||
|
err = tx.QueryRow(`
|
||||||
|
SELECT s.id, s.account_id FROM source_files s JOIN accounts a ON a.id = s.account_id
|
||||||
|
WHERE a.slug = ? AND s.path = ?`, accountSlug, path).Scan(&id, &accountID)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("find source file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`
|
||||||
|
DELETE FROM transfers WHERE
|
||||||
|
out_txn_id IN (SELECT id FROM transactions WHERE source_file_id = ?) OR
|
||||||
|
in_txn_id IN (SELECT id FROM transactions WHERE source_file_id = ?)`, id, id); err != nil {
|
||||||
|
return 0, fmt.Errorf("forget transfers from %s: %w", path, err)
|
||||||
|
}
|
||||||
|
res, err := tx.Exec(`DELETE FROM transactions WHERE source_file_id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("forget transactions from %s: %w", path, err)
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`DELETE FROM source_files WHERE id = ?`, id); err != nil {
|
||||||
|
return 0, fmt.Errorf("forget %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`UPDATE source_files SET sha256 = '' WHERE account_id = ?`, accountID); err != nil {
|
||||||
|
return 0, fmt.Errorf("mark %s's statements for re-reading: %w", accountSlug, err)
|
||||||
|
}
|
||||||
|
return int(n), tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags lists every tag in use, for completion in the rule builder.
|
||||||
func (d *DB) Tags() ([]string, error) {
|
func (d *DB) Tags() ([]string, error) {
|
||||||
rows, err := d.sql.Query(`
|
rows, err := d.sql.Query(`
|
||||||
SELECT DISTINCT rule_tag FROM transactions
|
SELECT DISTINCT rule_tag FROM transactions
|
||||||
|
|||||||
@@ -214,6 +214,41 @@ func bestCounterpart(out model.Transaction, ins []model.Transaction, used map[in
|
|||||||
return best
|
return best
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wanted describes the counterpart an unpaired leg is missing, in the terms
|
||||||
|
// bestCounterpart judges it by, so a person can go and look for it.
|
||||||
|
type Wanted struct {
|
||||||
|
Account string // where the other leg would be
|
||||||
|
Desc string // the glob it would have to match
|
||||||
|
From string // the window it would have to fall in, inclusive
|
||||||
|
To string
|
||||||
|
// Amount is the other leg's signed amount, exact unless TolerancePct
|
||||||
|
// widens it. AnyAmount is set across currencies, where the amount is not
|
||||||
|
// checked at all and the dates carry the pairing alone.
|
||||||
|
Amount int64
|
||||||
|
TolerancePct float64
|
||||||
|
AnyAmount bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Want says what would have paired l. currencyOf names an account's currency,
|
||||||
|
// or "" for one the index holds nothing for.
|
||||||
|
func (e *Engine) Want(l Leg, currencyOf func(slug string) string) Wanted {
|
||||||
|
def := e.defs[l.Def]
|
||||||
|
w := Wanted{Account: def.ToAccount, Desc: def.ToDesc, Amount: -l.Txn.AmountMinor}
|
||||||
|
if !l.Out {
|
||||||
|
w.Account, w.Desc = def.FromAccount, def.FromDesc
|
||||||
|
}
|
||||||
|
if c := currencyOf(w.Account); c != "" && c != l.Txn.Currency {
|
||||||
|
w.AnyAmount = true
|
||||||
|
} else {
|
||||||
|
w.TolerancePct = def.TolerancePct
|
||||||
|
}
|
||||||
|
if d, ok := day(l.Txn.Date); ok {
|
||||||
|
w.From = d.AddDate(0, 0, -WindowDays).Format("2006-01-02")
|
||||||
|
w.To = d.AddDate(0, 0, WindowDays).Format("2006-01-02")
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
// allowance is how far the arriving leg may miss the leaving one, in minor
|
// allowance is how far the arriving leg may miss the leaving one, in minor
|
||||||
// units. It is a share of the amount that left, not of the difference, so the
|
// units. It is a share of the amount that left, not of the difference, so the
|
||||||
// same percentage means the same thing on a large transfer as on a small one.
|
// same percentage means the same thing on a large transfer as on a small one.
|
||||||
|
|||||||
@@ -353,3 +353,40 @@ func TestEqualGapPrefersTheNearerAmount(t *testing.T) {
|
|||||||
t.Errorf("paired with %d, want 3: same gap, exact amount", res.Pairs[0].In.ID)
|
t.Errorf("paired with %d, want 3: same gap, exact amount", res.Pairs[0].In.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Want describes the missing side in the terms the pairing judges it by: the
|
||||||
|
// other definition half, the window, and the amount — exact by default,
|
||||||
|
// widened only by the definition's own tolerance, unchecked across currencies.
|
||||||
|
func TestWantDescribesTheMissingSide(t *testing.T) {
|
||||||
|
eur := func(string) string { return "EUR" }
|
||||||
|
|
||||||
|
out := Leg{Def: 0, Out: true, Txn: txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT", -50000)}
|
||||||
|
got := engine(topUp).Want(out, eur)
|
||||||
|
want := Wanted{Account: "revolut", Desc: "*FROM NLB*", From: "2026-03-01", To: "2026-03-11", Amount: 50000}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("arriving side = %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
in := Leg{Def: 0, Txn: txn(2, "revolut", "2026-03-09", "Top-up from NLB", 50000)}
|
||||||
|
got = engine(topUp).Want(in, eur)
|
||||||
|
want = Wanted{Account: "nlb", Desc: "*TO REVOLUT*", From: "2026-03-04", To: "2026-03-14", Amount: -50000}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("leaving side = %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
fee := topUp
|
||||||
|
fee.TolerancePct = 1.5
|
||||||
|
if got := engine(fee).Want(out, eur); got.TolerancePct != 1.5 || got.AnyAmount {
|
||||||
|
t.Errorf("tolerant = %+v, want the definition's 1.5%%", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
usd := func(slug string) string {
|
||||||
|
if slug == "revolut" {
|
||||||
|
return "USD"
|
||||||
|
}
|
||||||
|
return "EUR"
|
||||||
|
}
|
||||||
|
if got := engine(fee).Want(out, usd); !got.AnyAmount || got.TolerancePct != 0 {
|
||||||
|
t.Errorf("cross-currency = %+v, want any amount and no tolerance", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
-2483
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,516 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.petrovv.com/nikola/money/internal/config"
|
||||||
|
"git.petrovv.com/nikola/money/internal/model"
|
||||||
|
"git.petrovv.com/nikola/money/internal/rules"
|
||||||
|
"git.petrovv.com/nikola/money/internal/store"
|
||||||
|
"git.petrovv.com/nikola/money/internal/transfers"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fixtureTxn struct {
|
||||||
|
account, date, desc string
|
||||||
|
amount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestServer builds a server over a data root holding rules.toml and an
|
||||||
|
// index with the given transactions, tagged and paired as an import would
|
||||||
|
// leave them. "Today" is fixed so the report's default window is predictable.
|
||||||
|
func newTestServer(t *testing.T, rulesToml string, txns ...fixtureTxn) (*Server, http.Handler) {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(root, config.RulesFile), []byte(rulesToml), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
db, err := store.Open(config.IndexPath(root))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
|
||||||
|
ids := map[string][2]int64{}
|
||||||
|
for _, slug := range []string{"checking", "savings"} {
|
||||||
|
id, err := db.UpsertAccount(model.Account{Slug: slug, Name: slug, Currency: "EUR", MinorDigits: 2})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
src, err := db.SourceFile(id, slug+"/st.csv", "sha", "2026-01-01T00:00:00Z", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ids[slug] = [2]int64{id, src}
|
||||||
|
}
|
||||||
|
for i, x := range txns {
|
||||||
|
if _, err := db.InsertTransaction(model.Transaction{
|
||||||
|
AccountID: ids[x.account][0], SourceFileID: ids[x.account][1],
|
||||||
|
Fingerprint: x.desc + x.date + string(rune('a'+i)),
|
||||||
|
Date: x.date, Description: x.desc, AmountMinor: x.amount,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := config.LoadRules(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
engine, links := rules.New(loaded), transfers.New(loaded)
|
||||||
|
if _, err := engine.Retag(db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := links.Link(db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := New(root, db, nil, engine, links)
|
||||||
|
s.now = func() time.Time { return time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC) }
|
||||||
|
return s, s.Handler()
|
||||||
|
}
|
||||||
|
|
||||||
|
// call sends a request and decodes the JSON answer, failing unless the status
|
||||||
|
// is the one expected.
|
||||||
|
func call(t *testing.T, h http.Handler, method, path string, body any, want int, out any) {
|
||||||
|
t.Helper()
|
||||||
|
var r *http.Request
|
||||||
|
if body == nil {
|
||||||
|
r = httptest.NewRequest(method, path, nil)
|
||||||
|
} else {
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r = httptest.NewRequest(method, path, bytes.NewReader(b))
|
||||||
|
r.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
if w.Code != want {
|
||||||
|
t.Fatalf("%s %s: status %d, want %d: %s", method, path, w.Code, want, w.Body.String())
|
||||||
|
}
|
||||||
|
if out != nil {
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), out); err != nil {
|
||||||
|
t.Fatalf("%s %s: decode %q: %v", method, path, w.Body.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readRules(t *testing.T, s *Server) *config.Rules {
|
||||||
|
t.Helper()
|
||||||
|
r, err := config.LoadRules(s.root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
const lidlRule = `
|
||||||
|
[[rule]]
|
||||||
|
match = "*LIDL*"
|
||||||
|
tag = "groceries"
|
||||||
|
`
|
||||||
|
|
||||||
|
// The period narrows the report and nothing else: the report opens on last
|
||||||
|
// month while the transaction list still holds the whole index.
|
||||||
|
func TestReportPeriodLeavesTransactionsAlone(t *testing.T) {
|
||||||
|
_, h := newTestServer(t, lidlRule,
|
||||||
|
fixtureTxn{"checking", "2026-02-10", "LIDL SOFIA", -1000},
|
||||||
|
fixtureTxn{"checking", "2025-11-03", "LIDL VARNA", -500},
|
||||||
|
)
|
||||||
|
|
||||||
|
var rep reportJSON
|
||||||
|
call(t, h, "GET", "/api/report", nil, http.StatusOK, &rep)
|
||||||
|
if got := rep.Periods[rep.Period].Label; got != "last month" {
|
||||||
|
t.Fatalf("report opens on %q, want last month", got)
|
||||||
|
}
|
||||||
|
if len(rep.Rows) != 1 || rep.Rows[0].Out.Text != "10.00" {
|
||||||
|
t.Errorf("last month's report = %+v, want groceries 10.00 only", rep.Rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
var txns struct{ Rows []txnRow }
|
||||||
|
call(t, h, "GET", "/api/transactions", nil, http.StatusOK, &txns)
|
||||||
|
if len(txns.Rows) != 2 {
|
||||||
|
t.Errorf("transactions = %d rows, want both", len(txns.Rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
call(t, h, "GET", "/api/report?period=all+time", nil, http.StatusOK, &rep)
|
||||||
|
if len(rep.Rows) != 1 || rep.Rows[0].Out.Text != "15.00" {
|
||||||
|
t.Errorf("all-time report = %+v, want groceries 15.00", rep.Rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The edit form has no type field, so the type must come from the rule on
|
||||||
|
// disk; a pattern the user was never shown is not one they chose to remove.
|
||||||
|
func TestEditKeepsTypePattern(t *testing.T) {
|
||||||
|
s, h := newTestServer(t, `
|
||||||
|
[[rule]]
|
||||||
|
match = "*LIDL*"
|
||||||
|
type = "CARD_PAYMENT"
|
||||||
|
tag = "groceries"
|
||||||
|
`)
|
||||||
|
expect := ruleJSON{Match: "*LIDL*", Type: "CARD_PAYMENT", Tag: "groceries"}
|
||||||
|
call(t, h, "PUT", "/api/rules/0", editRuleReq{
|
||||||
|
Rule: ruleForm{Match: "*LIDL SOFIA*", Tag: "food"}, Expect: expect,
|
||||||
|
}, http.StatusOK, nil)
|
||||||
|
|
||||||
|
got := readRules(t, s).Rule[0]
|
||||||
|
if got.Type != "CARD_PAYMENT" || got.Match != "*LIDL SOFIA*" || got.Tag != "food" {
|
||||||
|
t.Errorf("edited rule = %+v, want the type kept", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Positions are what rules.toml is edited by, so a page that saw a different
|
||||||
|
// rule in that position must be refused rather than edit the wrong one.
|
||||||
|
func TestStalePositionIsRefused(t *testing.T) {
|
||||||
|
s, h := newTestServer(t, lidlRule)
|
||||||
|
before, _ := os.ReadFile(filepath.Join(s.root, config.RulesFile))
|
||||||
|
|
||||||
|
call(t, h, "POST", "/api/rules/delete", deleteRulesReq{
|
||||||
|
Positions: []int{0}, Expect: []ruleJSON{{Match: "*ZARA*", Tag: "clothes"}},
|
||||||
|
}, http.StatusConflict, nil)
|
||||||
|
call(t, h, "PUT", "/api/rules/0", editRuleReq{
|
||||||
|
Rule: ruleForm{Match: "*X*", Tag: "x"}, Expect: ruleJSON{Match: "*ZARA*", Tag: "clothes"},
|
||||||
|
}, http.StatusConflict, nil)
|
||||||
|
|
||||||
|
after, _ := os.ReadFile(filepath.Join(s.root, config.RulesFile))
|
||||||
|
if !bytes.Equal(before, after) {
|
||||||
|
t.Errorf("rules.toml changed on a refused request:\n%s", after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A body that is not JSON is refused before it reaches a handler: with no
|
||||||
|
// authentication, that is what stops a cross-site form posting to the server.
|
||||||
|
func TestFormPostsAreRefused(t *testing.T) {
|
||||||
|
s, h := newTestServer(t, "")
|
||||||
|
r := httptest.NewRequest("POST", "/api/rules", strings.NewReader("match=*&tag=x"))
|
||||||
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
if w.Code != http.StatusUnsupportedMediaType {
|
||||||
|
t.Fatalf("status %d, want 415", w.Code)
|
||||||
|
}
|
||||||
|
if n := len(readRules(t, s).Rule); n != 0 {
|
||||||
|
t.Errorf("%d rules written by a form post", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saving a rule writes rules.toml and retags, so the description it caught is
|
||||||
|
// no longer offered as untagged.
|
||||||
|
func TestCreateRuleRetags(t *testing.T) {
|
||||||
|
s, h := newTestServer(t, "",
|
||||||
|
fixtureTxn{"checking", "2026-02-10", "ZARA SOFIA", -1000},
|
||||||
|
)
|
||||||
|
var p rulePreviewJSON
|
||||||
|
call(t, h, "POST", "/api/rules/preview", rulePreviewReq{Glob: "*zara*"}, http.StatusOK, &p)
|
||||||
|
if p.Matches != 1 || p.Candidates != 1 || p.Rows[0].Marker != "▸" {
|
||||||
|
t.Fatalf("preview = %+v, want the one description matched", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
call(t, h, "POST", "/api/rules", ruleForm{Match: "*ZARA*", Tag: "clothes"}, http.StatusOK, nil)
|
||||||
|
if r := readRules(t, s).Rule; len(r) != 1 || r[0].Tag != "clothes" {
|
||||||
|
t.Fatalf("rules.toml = %+v", r)
|
||||||
|
}
|
||||||
|
call(t, h, "POST", "/api/rules/preview", rulePreviewReq{}, http.StatusOK, &p)
|
||||||
|
if p.Candidates != 0 {
|
||||||
|
t.Errorf("after saving, %d descriptions still untagged", p.Candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
var list struct{ Rules []ruleRow }
|
||||||
|
call(t, h, "GET", "/api/rules", nil, http.StatusOK, &list)
|
||||||
|
if list.Rules[0].Usage != 1 {
|
||||||
|
t.Errorf("usage = %d, want 1", list.Rules[0].Usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An edit's preview includes what the rule claims now, and keeps a description
|
||||||
|
// the new glob would let go of on screen, marked, instead of dropping it.
|
||||||
|
func TestEditPreviewShowsWhatIsLetGo(t *testing.T) {
|
||||||
|
_, h := newTestServer(t, lidlRule,
|
||||||
|
fixtureTxn{"checking", "2026-02-10", "LIDL SOFIA", -1000},
|
||||||
|
fixtureTxn{"checking", "2026-02-11", "LIDL VARNA", -1000},
|
||||||
|
)
|
||||||
|
edit := 0
|
||||||
|
var p rulePreviewJSON
|
||||||
|
call(t, h, "POST", "/api/rules/preview", rulePreviewReq{Glob: "*LIDL SOFIA*", Edit: &edit}, http.StatusOK, &p)
|
||||||
|
if p.Matches != 1 || p.Dropped != 1 {
|
||||||
|
t.Fatalf("preview = %+v, want 1 match and 1 dropped", p)
|
||||||
|
}
|
||||||
|
markers := map[string]string{}
|
||||||
|
for _, r := range p.Rows {
|
||||||
|
markers[r.Description] = r.Marker
|
||||||
|
}
|
||||||
|
if markers["LIDL SOFIA"] != "▸" || markers["LIDL VARNA"] != "−" {
|
||||||
|
t.Errorf("markers = %v", markers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A transfer is a pair or nothing: the preview shows a leg with no other side
|
||||||
|
// as unpaired, and only a matched pair leaves the report, with its fee named.
|
||||||
|
func TestTransferPairsLeaveTheReportWithTheirFee(t *testing.T) {
|
||||||
|
_, h := newTestServer(t, "",
|
||||||
|
fixtureTxn{"checking", "2026-02-01", "WIRE TO SAVINGS", -50000},
|
||||||
|
fixtureTxn{"savings", "2026-02-02", "WIRE FROM CHECKING", 49500},
|
||||||
|
fixtureTxn{"checking", "2026-02-20", "WIRE TO SAVINGS", -10000},
|
||||||
|
)
|
||||||
|
form := transferForm{
|
||||||
|
FromAccount: "checking", FromDesc: "*WIRE TO SAVINGS*",
|
||||||
|
ToAccount: "savings", ToDesc: "*WIRE FROM CHECKING*",
|
||||||
|
}
|
||||||
|
|
||||||
|
var p transferPreviewJSON
|
||||||
|
call(t, h, "POST", "/api/transfers/preview", form, http.StatusOK, &p)
|
||||||
|
if p.Pairs != 0 || p.Unpaired != 3 {
|
||||||
|
t.Fatalf("exact preview = %+v, want nothing paired on a mismatch", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
form.Tolerance = "1.5"
|
||||||
|
call(t, h, "POST", "/api/transfers/preview", form, http.StatusOK, &p)
|
||||||
|
if p.Pairs != 1 || p.Unpaired != 1 || p.Fees != "5.00" {
|
||||||
|
t.Fatalf("tolerant preview = %+v, want 1 pair, 1 unpaired, 5.00 in fees", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
call(t, h, "POST", "/api/transfers", form, http.StatusOK, nil)
|
||||||
|
|
||||||
|
var rep reportJSON
|
||||||
|
call(t, h, "GET", "/api/report?period=all+time", nil, http.StatusOK, &rep)
|
||||||
|
// The unpaired leg keeps counting; the pair is excluded with its fee.
|
||||||
|
if len(rep.Rows) != 1 || rep.Rows[0].Out.Text != "100.00" {
|
||||||
|
t.Errorf("report rows = %+v, want only the unpaired 100.00", rep.Rows)
|
||||||
|
}
|
||||||
|
if len(rep.Excluded) != 1 || rep.Excluded[0].Fee == nil || rep.Excluded[0].Fee.Text != "5.00" {
|
||||||
|
t.Errorf("excluded = %+v, want the 5.00 fee reported", rep.Excluded)
|
||||||
|
}
|
||||||
|
|
||||||
|
var list struct {
|
||||||
|
Transfers []transferRow
|
||||||
|
Unpaired int
|
||||||
|
}
|
||||||
|
call(t, h, "GET", "/api/transfers", nil, http.StatusOK, &list)
|
||||||
|
if list.Unpaired != 1 || list.Transfers[0].Paired != 1 || list.Transfers[0].Tolerance != "1.5%" {
|
||||||
|
t.Errorf("transfer list = %+v", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tolerance may be half typed while previewing, but saving refuses it
|
||||||
|
// rather than writing a silent zero.
|
||||||
|
func TestBadToleranceIsRefusedOnSave(t *testing.T) {
|
||||||
|
s, h := newTestServer(t, "")
|
||||||
|
call(t, h, "POST", "/api/transfers", transferForm{
|
||||||
|
FromAccount: "checking", FromDesc: "*A*", ToAccount: "savings", ToDesc: "*B*", Tolerance: "1,5",
|
||||||
|
}, http.StatusBadRequest, nil)
|
||||||
|
if n := len(readRules(t, s).Transfer); n != 0 {
|
||||||
|
t.Errorf("%d transfers written", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server outlives hand edits to rules.toml. The overview says when the
|
||||||
|
// file has moved on, and retag re-reads it, as a fresh `money retag` would.
|
||||||
|
func TestRetagRereadsRulesFromDisk(t *testing.T) {
|
||||||
|
s, h := newTestServer(t, "",
|
||||||
|
fixtureTxn{"checking", "2026-02-10", "ZARA", -1000},
|
||||||
|
)
|
||||||
|
var o overview
|
||||||
|
call(t, h, "GET", "/api/overview", nil, http.StatusOK, &o)
|
||||||
|
if o.Stale {
|
||||||
|
t.Fatal("stale before anything changed")
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(s.root, config.RulesFile)
|
||||||
|
if err := os.WriteFile(path, []byte("[[rule]]\nmatch = \"*ZARA*\"\ntag = \"clothes\"\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
call(t, h, "GET", "/api/overview", nil, http.StatusOK, &o)
|
||||||
|
if !o.Stale {
|
||||||
|
t.Fatal("a hand edit to rules.toml is not reported")
|
||||||
|
}
|
||||||
|
|
||||||
|
call(t, h, "POST", "/api/retag", struct{}{}, http.StatusOK, nil)
|
||||||
|
call(t, h, "GET", "/api/overview", nil, http.StatusOK, &o)
|
||||||
|
if o.Stale {
|
||||||
|
t.Error("still stale after retag")
|
||||||
|
}
|
||||||
|
var txns struct{ Rows []txnRow }
|
||||||
|
call(t, h, "GET", "/api/transactions", nil, http.StatusOK, &txns)
|
||||||
|
if txns.Rows[0].Tag != "clothes" {
|
||||||
|
t.Errorf("tag = %q after retag, want clothes", txns.Rows[0].Tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (transfer) is a label for a paired leg, never a tag: it is flagged as
|
||||||
|
// supplied, and never offered for completion.
|
||||||
|
func TestTransferLabelIsNotATag(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", "TO SAVINGS", -1000},
|
||||||
|
fixtureTxn{"savings", "2026-02-01", "FROM CHECKING", 1000},
|
||||||
|
)
|
||||||
|
var txns struct{ Rows []txnRow }
|
||||||
|
call(t, h, "GET", "/api/transactions", nil, http.StatusOK, &txns)
|
||||||
|
for _, r := range txns.Rows {
|
||||||
|
if r.Tag != model.TransferTag || !r.Supplied {
|
||||||
|
t.Errorf("row %+v, want a supplied transfer label", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
call(t, h, "GET", "/api/transactions?untagged=1", nil, http.StatusOK, &txns)
|
||||||
|
if len(txns.Rows) != 0 {
|
||||||
|
t.Errorf("paired legs listed as untagged: %+v", txns.Rows)
|
||||||
|
}
|
||||||
|
var o overview
|
||||||
|
call(t, h, "GET", "/api/overview", nil, http.StatusOK, &o)
|
||||||
|
if len(o.Tags) != 0 {
|
||||||
|
t.Errorf("tags offered = %v, want none", o.Tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPageIsServed(t *testing.T) {
|
||||||
|
_, h := newTestServer(t, "")
|
||||||
|
for _, path := range []string{"/", "/app.js", "/style.css"} {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
|
||||||
|
if w.Code != http.StatusOK || w.Body.Len() == 0 {
|
||||||
|
t.Errorf("GET %s: status %d, %d bytes", path, w.Code, w.Body.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A ⚠ on the transfers screen opens onto the legs behind it: which side each
|
||||||
|
// is missing, and what that side would have to be.
|
||||||
|
func TestTransferListNamesTheMissingLegs(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", "TO SAVINGS", -1000},
|
||||||
|
fixtureTxn{"savings", "2026-02-02", "FROM CHECKING", 1000},
|
||||||
|
fixtureTxn{"checking", "2026-02-20", "TO SAVINGS", -2500},
|
||||||
|
fixtureTxn{"savings", "2026-01-10", "FROM CHECKING", 700},
|
||||||
|
)
|
||||||
|
var list struct {
|
||||||
|
Transfers []transferRow
|
||||||
|
Unpaired int
|
||||||
|
}
|
||||||
|
call(t, h, "GET", "/api/transfers", nil, http.StatusOK, &list)
|
||||||
|
legs := list.Transfers[0].Legs
|
||||||
|
if list.Unpaired != 2 || len(legs) != 2 {
|
||||||
|
t.Fatalf("unpaired = %d, legs = %+v; want the two lone legs", list.Unpaired, legs)
|
||||||
|
}
|
||||||
|
newest, oldest := legs[0], legs[1]
|
||||||
|
if newest.Movement != "checking → ?" || newest.Missing != "arriving" ||
|
||||||
|
newest.Want != "+25.00 in savings matching *FROM CHECKING*, dated 2026-02-15 to 2026-02-25" {
|
||||||
|
t.Errorf("newest leg = %+v", newest)
|
||||||
|
}
|
||||||
|
if oldest.Movement != "? → savings" || oldest.Missing != "leaving" ||
|
||||||
|
oldest.Want != "-7.00 in checking matching *TO SAVINGS*, dated 2026-01-05 to 2026-01-15" {
|
||||||
|
t.Errorf("oldest leg = %+v", oldest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tags screen lists each tag once, with the transactions carrying it and
|
||||||
|
// every rule that writes it — a shadowed one at zero — and never the
|
||||||
|
// transfer label, which no rule wrote.
|
||||||
|
func TestTagListGroupsRulesByTag(t *testing.T) {
|
||||||
|
_, h := newTestServer(t, `
|
||||||
|
[[rule]]
|
||||||
|
match = "*LIDL*"
|
||||||
|
tag = "groceries"
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
match = "*KAUFLAND*"
|
||||||
|
tag = "groceries"
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
match = "*LIDL SOFIA*"
|
||||||
|
tag = "groceries"
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
match = "*ZARA*"
|
||||||
|
tag = "clothes"
|
||||||
|
|
||||||
|
[[transfer]]
|
||||||
|
from_account = "checking"
|
||||||
|
from_desc = "*TO SAVINGS*"
|
||||||
|
to_account = "savings"
|
||||||
|
to_desc = "*FROM CHECKING*"
|
||||||
|
`,
|
||||||
|
fixtureTxn{"checking", "2026-02-01", "LIDL SOFIA", -1000},
|
||||||
|
fixtureTxn{"checking", "2026-02-02", "KAUFLAND", -500},
|
||||||
|
fixtureTxn{"checking", "2026-02-03", "TO SAVINGS", -100},
|
||||||
|
fixtureTxn{"savings", "2026-02-03", "FROM CHECKING", 100},
|
||||||
|
)
|
||||||
|
var res struct{ Tags []tagEntry }
|
||||||
|
call(t, h, "GET", "/api/tags", nil, http.StatusOK, &res)
|
||||||
|
if len(res.Tags) != 2 || res.Tags[0].Tag != "clothes" || res.Tags[1].Tag != "groceries" {
|
||||||
|
t.Fatalf("tags = %+v, want clothes and groceries only", res.Tags)
|
||||||
|
}
|
||||||
|
clothes, groceries := res.Tags[0], res.Tags[1]
|
||||||
|
if clothes.Transactions != 0 || len(clothes.Rules) != 1 {
|
||||||
|
t.Errorf("clothes = %+v, want its rule and no transactions", clothes)
|
||||||
|
}
|
||||||
|
if groceries.Transactions != 2 || len(groceries.Rules) != 3 {
|
||||||
|
t.Fatalf("groceries = %+v, want 2 transactions from 3 rules", groceries)
|
||||||
|
}
|
||||||
|
usage := map[string]int{}
|
||||||
|
for _, r := range groceries.Rules {
|
||||||
|
usage[r.Pattern] = r.Usage
|
||||||
|
}
|
||||||
|
if usage["*LIDL*"] != 0 || usage["*LIDL SOFIA*"] != 1 || usage["*KAUFLAND*"] != 1 {
|
||||||
|
t.Errorf("usage = %v, want *LIDL* shadowed by *LIDL SOFIA*", usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="7" fill="#4b46c8"/>
|
||||||
|
<g fill="#fff">
|
||||||
|
<rect x="7" y="8" width="18" height="4" rx="2"/>
|
||||||
|
<rect x="7" y="14" width="11" height="4" rx="2"/>
|
||||||
|
<rect x="7" y="20" width="15" height="4" rx="2"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 309 B |
@@ -0,0 +1,24 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>money</title>
|
||||||
|
<link rel="icon" href="icon.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="top">
|
||||||
|
<a class="brand" href="#accounts">money</a>
|
||||||
|
<nav id="nav" aria-label="Screens"></nav>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" id="retag" title="Re-apply rules.toml: retag everything and re-pair transfers">Retag</button>
|
||||||
|
<button type="button" id="reread" title="Re-read every statement, including ones unchanged since they were imported">Re-read all</button>
|
||||||
|
<button type="button" id="import" class="primary" title="Import new statements from every account folder">Import</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div id="banners"></div>
|
||||||
|
<main id="main"></main>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f7f7f5;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--text: #1d1d1f;
|
||||||
|
--muted: #6b6b70;
|
||||||
|
--border: #e2e2de;
|
||||||
|
--accent: #4b46c8;
|
||||||
|
--accent-text: #ffffff;
|
||||||
|
--accent-soft: #ecebfb;
|
||||||
|
--negative: #b3261e;
|
||||||
|
--positive: #1d7a3a;
|
||||||
|
--warn: #9a5b00;
|
||||||
|
--warn-soft: #fff4e0;
|
||||||
|
--error-soft: #fdecea;
|
||||||
|
--row-hover: #f2f2ef;
|
||||||
|
--mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #161618;
|
||||||
|
--surface: #1e1e21;
|
||||||
|
--text: #e8e8ea;
|
||||||
|
--muted: #9a9aa2;
|
||||||
|
--border: #333338;
|
||||||
|
--accent: #8f8cf0;
|
||||||
|
--accent-text: #111114;
|
||||||
|
--accent-soft: #2a2950;
|
||||||
|
--negative: #f2867d;
|
||||||
|
--positive: #6fcf8e;
|
||||||
|
--warn: #f0b45a;
|
||||||
|
--warn-soft: #3a2c14;
|
||||||
|
--error-soft: #3d1c1a;
|
||||||
|
--row-hover: #26262a;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
button, input, select { font: inherit; color: inherit; }
|
||||||
|
|
||||||
|
.top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.brand { font-weight: 700; font-size: 16px; color: var(--text); text-decoration: none; }
|
||||||
|
#nav { display: flex; gap: 2px; flex-wrap: wrap; flex: 1; }
|
||||||
|
#nav a {
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
#nav a:hover { color: var(--text); background: var(--row-hover); }
|
||||||
|
#nav a.active { color: var(--accent); background: var(--accent-soft); font-weight: 600; }
|
||||||
|
.actions { display: flex; gap: 8px; }
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover:not(:disabled) { border-color: var(--muted); }
|
||||||
|
button:disabled { opacity: .5; cursor: default; }
|
||||||
|
button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
||||||
|
button.danger { color: var(--negative); }
|
||||||
|
button.link { border: none; background: none; padding: 0 4px; color: var(--accent); }
|
||||||
|
|
||||||
|
#banners { padding: 0 16px; }
|
||||||
|
.banner {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.banner.info { background: var(--accent-soft); }
|
||||||
|
.banner.warn { background: var(--warn-soft); color: var(--warn); }
|
||||||
|
.banner.error { background: var(--error-soft); color: var(--negative); }
|
||||||
|
.banner button { flex: none; }
|
||||||
|
|
||||||
|
main { padding: 16px; max-width: 1400px; }
|
||||||
|
h2 { font-size: 16px; margin: 0 0 12px; font-weight: 600; }
|
||||||
|
h2 .sub { color: var(--muted); font-weight: 400; }
|
||||||
|
|
||||||
|
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
|
||||||
|
.toolbar input[type=search] { min-width: 220px; }
|
||||||
|
input[type=text], input[type=search], select {
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: transparent; }
|
||||||
|
label.check { display: inline-flex; gap: 6px; align-items: center; cursor: pointer; }
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
table { border-collapse: collapse; width: 100%; }
|
||||||
|
th, td { padding: 5px 10px; text-align: left; white-space: nowrap; }
|
||||||
|
th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-weight: 600;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
tbody tr:hover { background: var(--row-hover); }
|
||||||
|
tbody tr.clickable { cursor: pointer; }
|
||||||
|
td.num, th.num { text-align: right; font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||||||
|
td.desc { white-space: normal; min-width: 240px; }
|
||||||
|
td.mark { width: 1em; text-align: center; padding-right: 0; }
|
||||||
|
.neg { color: var(--negative); }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.warn { color: var(--warn); }
|
||||||
|
th.sortable { cursor: pointer; user-select: none; }
|
||||||
|
th.sortable:hover { color: var(--accent); }
|
||||||
|
tr.total td { font-weight: 600; border-top: 1px solid var(--border); }
|
||||||
|
tr.excluded td { color: var(--muted); }
|
||||||
|
tr.excluded.first td { border-top: 1px dashed var(--border); }
|
||||||
|
td.actions { text-align: right; }
|
||||||
|
.mono { font-family: var(--mono); }
|
||||||
|
.tag-rule { display: inline-block; margin-right: 14px; white-space: nowrap; }
|
||||||
|
tr.legs > td { padding: 0 10px 10px 36px; background: var(--bg); }
|
||||||
|
tr.legs:hover { background: none; }
|
||||||
|
table.sub th { background: var(--bg); font-weight: 500; color: var(--muted); }
|
||||||
|
td.actions button { padding: 1px 8px; font-size: 12px; }
|
||||||
|
|
||||||
|
.empty { padding: 32px 16px; color: var(--muted); white-space: pre-line; text-align: center; }
|
||||||
|
.more { padding: 10px; text-align: center; }
|
||||||
|
|
||||||
|
.report { display: grid; grid-template-columns: 220px 1fr; gap: 12px; align-items: start; }
|
||||||
|
.periods { padding: 6px; }
|
||||||
|
.periods button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.periods button:hover { background: var(--row-hover); color: var(--text); }
|
||||||
|
.periods button.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||||
|
.periods .head { font-weight: 600; padding: 4px 8px; }
|
||||||
|
|
||||||
|
.builder { display: grid; grid-template-columns: 320px 1fr; gap: 16px; align-items: start; }
|
||||||
|
.form { padding: 14px; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.field label { display: block; font-weight: 600; margin-bottom: 3px; }
|
||||||
|
.field input { width: 100%; font-family: var(--mono); }
|
||||||
|
.field .hint { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
||||||
|
.form .summary { font-weight: 600; white-space: pre-line; }
|
||||||
|
.form .buttons { display: flex; gap: 8px; }
|
||||||
|
.kept { font-size: 12px; color: var(--muted); font-family: var(--mono); }
|
||||||
|
.preview { max-height: calc(100vh - 170px); }
|
||||||
|
|
||||||
|
h3.section { font-size: 14px; font-weight: 600; margin: 20px 0 8px; }
|
||||||
|
h3.section .sub { color: var(--muted); font-weight: 400; }
|
||||||
|
.files a { color: var(--accent); text-decoration: none; }
|
||||||
|
.files a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.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); }
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.report, .builder { grid-template-columns: 1fr; }
|
||||||
|
.periods { display: flex; flex-wrap: wrap; gap: 2px; }
|
||||||
|
.periods button { width: auto; }
|
||||||
|
.periods .head { width: 100%; }
|
||||||
|
.toolbar input[type=search] { min-width: 0; flex: 1; }
|
||||||
|
.preview { max-height: none; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"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
|
||||||
|
})
|
||||||
|
parser.Register("webnamed", func(acc *config.Account) (parser.Parser, error) {
|
||||||
|
return namingParser{csvParser{digits: acc.Digits()}}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type csvParser struct{ digits int }
|
||||||
|
|
||||||
|
// namingParser is csvParser for a bank whose downloads are named unhelpfully:
|
||||||
|
// it names a statement after its first date, as nlb does after the statement
|
||||||
|
// date, and says nothing for a statement with no rows.
|
||||||
|
type namingParser struct{ csvParser }
|
||||||
|
|
||||||
|
func (namingParser) StatementName(path string) (string, error) {
|
||||||
|
body, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(body)), "\n")
|
||||||
|
if len(lines) < 2 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return "stmt_" + strings.Split(lines[1], ",")[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
// An upload only saves: the file waits on the statements list as new until
|
||||||
|
// the user presses Import.
|
||||||
|
func TestUploadSavesWithoutImporting(t *testing.T) {
|
||||||
|
dir, h := newUploadServer(t, checkingTOML)
|
||||||
|
req := uploadReq{Account: "checking", Files: []uploadFile{{Name: "2026-02.csv", Data: []byte(statement)}}}
|
||||||
|
|
||||||
|
var res status
|
||||||
|
call(t, h, "POST", "/api/upload", req, http.StatusOK, &res)
|
||||||
|
if res.Status != "uploaded 1 file(s) to checking · press Import to read them" {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if got := descriptions(t, h); got != "" {
|
||||||
|
t.Errorf("upload imported %s; it should only save", got)
|
||||||
|
}
|
||||||
|
var files struct{ Files []fileRow }
|
||||||
|
call(t, h, "GET", "/api/files", nil, http.StatusOK, &files)
|
||||||
|
if len(files.Files) != 1 || files.Files[0].Status != "new" {
|
||||||
|
t.Errorf("files = %+v, want the upload waiting as new", files.Files)
|
||||||
|
}
|
||||||
|
|
||||||
|
call(t, h, "POST", "/api/import", importReq{}, http.StatusOK, nil)
|
||||||
|
if got := descriptions(t, h); got != "SALARY,LIDL SOFIA" {
|
||||||
|
t.Errorf("after Import the index holds %s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same file again is not an error, and saves nothing.
|
||||||
|
call(t, h, "POST", "/api/upload", req, http.StatusOK, &res)
|
||||||
|
if res.Status != "uploaded 0 file(s) to checking (1 already there)" {
|
||||||
|
t.Errorf("re-upload status = %q", res.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The statements list is the folders, each beside what the index recorded:
|
||||||
|
// imported, changed since, not imported yet, and gone from disk.
|
||||||
|
func TestFileListStatuses(t *testing.T) {
|
||||||
|
dir, h := newUploadServer(t, checkingTOML)
|
||||||
|
write := func(name, body string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write("a.csv", statement)
|
||||||
|
write("b.csv", "date,description,amount\n2026-03-01,ZARA,-40.00\n")
|
||||||
|
write("c.csv", "date,description,amount\n2026-04-01,KAUFLAND,-9.00\n")
|
||||||
|
call(t, h, "POST", "/api/import", importReq{}, http.StatusOK, nil)
|
||||||
|
|
||||||
|
write("b.csv", "date,description,amount\n2026-03-01,ZARA,-41.00\n")
|
||||||
|
if err := os.Remove(filepath.Join(dir, "c.csv")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
write("d.csv", statement)
|
||||||
|
write(".hidden.csv", statement)
|
||||||
|
|
||||||
|
var res struct{ Files []fileRow }
|
||||||
|
call(t, h, "GET", "/api/files", nil, http.StatusOK, &res)
|
||||||
|
got := map[string]fileRow{}
|
||||||
|
var names []string
|
||||||
|
for _, f := range res.Files {
|
||||||
|
got[f.Name] = f
|
||||||
|
names = append(names, f.Name)
|
||||||
|
}
|
||||||
|
if strings.Join(names, " ") != "a.csv b.csv c.csv d.csv" {
|
||||||
|
t.Fatalf("files = %v, want the four statements and nothing import would skip", names)
|
||||||
|
}
|
||||||
|
for name, want := range map[string]string{"a.csv": "imported", "b.csv": "changed", "c.csv": "missing", "d.csv": "new"} {
|
||||||
|
if got[name].Status != want {
|
||||||
|
t.Errorf("%s status = %q, want %q", name, got[name].Status, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got["a.csv"].Rows != 2 || got["a.csv"].Added != 2 || got["c.csv"].Rows != 1 || got["c.csv"].Added != 1 ||
|
||||||
|
got["a.csv"].ImportedAt == "" {
|
||||||
|
t.Errorf("a.csv = %+v, c.csv = %+v", got["a.csv"], got["c.csv"])
|
||||||
|
}
|
||||||
|
if got["a.csv"].Size != int64(len(statement)) {
|
||||||
|
t.Errorf("a.csv size = %d, want %d", got["a.csv"].Size, len(statement))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a file import would read is served, and never as a page.
|
||||||
|
func TestServeFileServesOnlyStatements(t *testing.T) {
|
||||||
|
dir, h := newUploadServer(t, checkingTOML)
|
||||||
|
for name, body := range map[string]string{"a.csv": statement, "page.html": "<script>alert(1)</script>", ".secret": "x"} {
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
get := func(path string) *httptest.ResponseRecorder {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
w := get("/api/files/checking/a.csv")
|
||||||
|
if w.Code != http.StatusOK || w.Body.String() != statement {
|
||||||
|
t.Fatalf("a.csv: %d %q", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if ct := w.Header().Get("Content-Type"); ct != "text/plain; charset=utf-8" {
|
||||||
|
t.Errorf("a.csv content type = %q", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
w = get("/api/files/checking/page.html")
|
||||||
|
if w.Code != http.StatusOK || w.Header().Get("Content-Type") != "application/octet-stream" ||
|
||||||
|
w.Header().Get("Content-Security-Policy") != "sandbox" ||
|
||||||
|
!strings.HasPrefix(w.Header().Get("Content-Disposition"), "attachment") {
|
||||||
|
t.Errorf("page.html served as %d %v, want a sandboxed download", w.Code, w.Header())
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range []string{
|
||||||
|
"/api/files/checking/" + config.AccountFile,
|
||||||
|
"/api/files/checking/.secret",
|
||||||
|
"/api/files/checking/..%2F" + config.RulesFile,
|
||||||
|
"/api/files/nope/a.csv",
|
||||||
|
} {
|
||||||
|
if w := get(path); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("%s: status %d, want 404", path, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A parser that names its statements decides the stored name, so the bank's
|
||||||
|
// "download (3).pdf" lands as something that sorts by date. A reissue of the
|
||||||
|
// same date is numbered rather than refused, and a statement that does not
|
||||||
|
// say keeps the name it came with.
|
||||||
|
func TestUploadNamesStatementsTheParserCanName(t *testing.T) {
|
||||||
|
dir, h := newUploadServer(t, "currency = \"EUR\"\nparser = \"webnamed\"\n")
|
||||||
|
upload := func(files ...uploadFile) string {
|
||||||
|
t.Helper()
|
||||||
|
var res status
|
||||||
|
call(t, h, "POST", "/api/upload", uploadReq{Account: "checking", Files: files}, http.StatusOK, &res)
|
||||||
|
return res.Status
|
||||||
|
}
|
||||||
|
reissue := "date,description,amount\n2026-02-01,LIDL SOFIA,-12.50\n2026-02-04,ZARA,-30.00\n"
|
||||||
|
other := "date,description,amount\n2026-03-01,KAUFLAND,-9.00\n"
|
||||||
|
|
||||||
|
status := upload(uploadFile{Name: "download (3).csv", Data: []byte(statement)})
|
||||||
|
if !strings.Contains(status, "download (3).csv → stmt_2026-02-01.csv") {
|
||||||
|
t.Errorf("status = %q, want the rename named", status)
|
||||||
|
}
|
||||||
|
// The same statement downloaded again is recognised under its new name.
|
||||||
|
if status := upload(uploadFile{Name: "download (4).csv", Data: []byte(statement)}); !strings.Contains(status, "1 already there") {
|
||||||
|
t.Errorf("second download: status = %q", status)
|
||||||
|
}
|
||||||
|
// A different statement of the same date, and two of one date in a batch.
|
||||||
|
upload(uploadFile{Name: "x.csv", Data: []byte(reissue)},
|
||||||
|
uploadFile{Name: "y.csv", Data: []byte(reissue + "2026-02-05,BOLT,-4.00\n")},
|
||||||
|
uploadFile{Name: "Z.CSV", Data: []byte(other)}, // a chosen name is lowercase
|
||||||
|
uploadFile{Name: "empty.csv", Data: []byte("date,description,amount\n")})
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, e := range entries {
|
||||||
|
names = append(names, e.Name())
|
||||||
|
}
|
||||||
|
want := config.AccountFile + " empty.csv stmt_2026-02-01.csv stmt_2026-02-01_2.csv stmt_2026-02-01_3.csv stmt_2026-03-01.csv"
|
||||||
|
if strings.Join(names, " ") != want {
|
||||||
|
t.Errorf("folder = %v, want %s", names, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(t *testing.T, path, body string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func descriptions(t *testing.T, h http.Handler) string {
|
||||||
|
t.Helper()
|
||||||
|
var txns struct{ Rows []txnRow }
|
||||||
|
call(t, h, "GET", "/api/transactions", nil, http.StatusOK, &txns)
|
||||||
|
var out []string
|
||||||
|
for _, r := range txns.Rows {
|
||||||
|
out = append(out, r.Description)
|
||||||
|
}
|
||||||
|
return strings.Join(out, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removing a statement deletes the file and takes out what it brought in. A
|
||||||
|
// row an overlapping statement also holds was stored under the file imported
|
||||||
|
// first; the next Import brings it back from the other one.
|
||||||
|
func TestRemoveFileKeepsWhatAnotherStatementHolds(t *testing.T) {
|
||||||
|
dir, h := newUploadServer(t, checkingTOML)
|
||||||
|
writeFile(t, filepath.Join(dir, "a.csv"), "date,description,amount\n2026-01-30,ONLY IN A,-1.00\n2026-02-01,SHARED,-2.00\n")
|
||||||
|
writeFile(t, filepath.Join(dir, "b.csv"), "date,description,amount\n2026-02-01,SHARED,-2.00\n2026-02-03,ONLY IN B,-3.00\n")
|
||||||
|
call(t, h, "POST", "/api/import", importReq{}, http.StatusOK, nil)
|
||||||
|
|
||||||
|
// Each holds two rows; b.csv was imported second, so its shared row
|
||||||
|
// stayed with a.csv and it brought in only one.
|
||||||
|
var before struct{ Files []fileRow }
|
||||||
|
call(t, h, "GET", "/api/files", nil, http.StatusOK, &before)
|
||||||
|
if f := before.Files; len(f) != 2 || f[0].Rows != 2 || f[0].Added != 2 || f[1].Rows != 2 || f[1].Added != 1 {
|
||||||
|
t.Errorf("files = %+v, want a.csv 2 rows · 2 new and b.csv 2 rows · 1 new", f)
|
||||||
|
}
|
||||||
|
|
||||||
|
var res status
|
||||||
|
call(t, h, "POST", "/api/files/delete", removeFileReq{Account: "checking", Name: "a.csv"}, http.StatusOK, &res)
|
||||||
|
if !strings.HasPrefix(res.Status, "deleted checking/a.csv, 2 transaction(s) dropped") {
|
||||||
|
t.Errorf("status = %q", res.Status)
|
||||||
|
}
|
||||||
|
// Deleting does not import, so the shared row is gone for now, and b.csv
|
||||||
|
// says it is due to be read again.
|
||||||
|
if got := descriptions(t, h); got != "ONLY IN B" {
|
||||||
|
t.Errorf("index holds %s right after the delete, want only b's own row", got)
|
||||||
|
}
|
||||||
|
var files struct{ Files []fileRow }
|
||||||
|
call(t, h, "GET", "/api/files", nil, http.StatusOK, &files)
|
||||||
|
if len(files.Files) != 1 || files.Files[0].Status != "changed" {
|
||||||
|
t.Errorf("files = %+v, want b.csv marked to be re-read", files.Files)
|
||||||
|
}
|
||||||
|
|
||||||
|
call(t, h, "POST", "/api/import", importReq{}, http.StatusOK, nil)
|
||||||
|
if got := descriptions(t, h); got != "ONLY IN B,SHARED" {
|
||||||
|
t.Errorf("after Import the index holds %s, want the shared row restored from b", got)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "a.csv")); !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
t.Errorf("a.csv is still on disk: %v", err)
|
||||||
|
}
|
||||||
|
if entries, _ := os.ReadDir(dir); len(entries) != 2 {
|
||||||
|
t.Errorf("folder holds %d entries, want account.toml and b.csv only", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
call(t, h, "GET", "/api/files", nil, http.StatusOK, &files)
|
||||||
|
if len(files.Files) != 1 || files.Files[0].Name != "b.csv" || files.Files[0].Status != "imported" {
|
||||||
|
t.Errorf("files = %+v, want only b.csv, imported", files.Files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A removed leg takes its transfer pairing with it, and the other leg is left
|
||||||
|
// unpaired — which is the truth once one side is gone.
|
||||||
|
func TestRemoveFileUnpairsItsTransfers(t *testing.T) {
|
||||||
|
dir, h := newUploadServer(t, checkingTOML)
|
||||||
|
writeFile(t, filepath.Join(filepath.Dir(dir), config.RulesFile), `
|
||||||
|
[[transfer]]
|
||||||
|
from_account = "checking"
|
||||||
|
from_desc = "*OUT*"
|
||||||
|
to_account = "checking"
|
||||||
|
to_desc = "*IN*"
|
||||||
|
`)
|
||||||
|
writeFile(t, filepath.Join(dir, "out.csv"), "date,description,amount\n2026-02-01,MOVE OUT,-5.00\n")
|
||||||
|
writeFile(t, filepath.Join(dir, "in.csv"), "date,description,amount\n2026-02-02,MOVE IN,5.00\n")
|
||||||
|
call(t, h, "POST", "/api/import", importReq{}, http.StatusOK, nil)
|
||||||
|
|
||||||
|
var res status
|
||||||
|
call(t, h, "POST", "/api/files/delete", removeFileReq{Account: "checking", Name: "in.csv"}, http.StatusOK, &res)
|
||||||
|
if !strings.Contains(res.Status, "1 transfer leg(s) unpaired") {
|
||||||
|
t.Errorf("status = %q, want the remaining leg reported unpaired", res.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file already gone from disk is forgotten by the index; a name the index
|
||||||
|
// never had is not found.
|
||||||
|
func TestRemoveFileForgetsAMissingOne(t *testing.T) {
|
||||||
|
dir, h := newUploadServer(t, checkingTOML)
|
||||||
|
writeFile(t, filepath.Join(dir, "a.csv"), statement)
|
||||||
|
call(t, h, "POST", "/api/import", importReq{}, http.StatusOK, nil)
|
||||||
|
if err := os.Remove(filepath.Join(dir, "a.csv")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var res status
|
||||||
|
call(t, h, "POST", "/api/files/delete", removeFileReq{Account: "checking", Name: "a.csv"}, http.StatusOK, &res)
|
||||||
|
if !strings.HasPrefix(res.Status, "forgot checking/a.csv, 2 transaction(s) dropped") {
|
||||||
|
t.Errorf("status = %q", res.Status)
|
||||||
|
}
|
||||||
|
if got := descriptions(t, h); got != "" {
|
||||||
|
t.Errorf("index still holds %s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
call(t, h, "POST", "/api/files/delete", removeFileReq{Account: "checking", Name: "a.csv"}, http.StatusNotFound, nil)
|
||||||
|
call(t, h, "POST", "/api/files/delete", removeFileReq{Account: "checking", Name: "../rules.toml"}, http.StatusBadRequest, nil)
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, config.AccountFile)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
call(t, h, "POST", "/api/files/delete", removeFileReq{Account: "checking", Name: config.AccountFile}, http.StatusNotFound, nil)
|
||||||
|
}
|
||||||
Executable
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Builds money with a static pdftotext inside it: one file to copy to a server,
|
||||||
|
# with nothing to install there — not even poppler-utils.
|
||||||
|
#
|
||||||
|
# scripts/build-bundled.sh # linux, this machine's architecture
|
||||||
|
# scripts/build-bundled.sh arm64 # linux/arm64 (podman/docker need qemu)
|
||||||
|
#
|
||||||
|
# pdftotext is built in a container from a checksum-pinned poppler release
|
||||||
|
# (scripts/pdftotext.Containerfile) and cached under internal/parser/bundled/,
|
||||||
|
# so later runs only rebuild money. Delete that file to rebuild it.
|
||||||
|
set -eu
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
arch=${1:-$(go env GOARCH)}
|
||||||
|
case $arch in
|
||||||
|
amd64 | arm64) ;;
|
||||||
|
*)
|
||||||
|
echo "build-bundled: no static pdftotext for linux/$arch; use amd64 or arm64" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
bin=internal/parser/bundled/pdftotext-linux-$arch
|
||||||
|
if [ ! -f "$bin" ] || [ scripts/pdftotext.Containerfile -nt "$bin" ]; then
|
||||||
|
engine=$(command -v podman || command -v docker) || {
|
||||||
|
echo "build-bundled: building pdftotext needs podman or docker" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
out=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$out"' EXIT
|
||||||
|
"$engine" build --platform "linux/$arch" -f scripts/pdftotext.Containerfile \
|
||||||
|
--output "type=local,dest=$out" scripts
|
||||||
|
mkdir -p internal/parser/bundled
|
||||||
|
mv "$out/pdftotext" "$bin"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p dist
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=$arch \
|
||||||
|
go build -tags bundled -trimpath -o "dist/money-linux-$arch" ./cmd/money
|
||||||
|
echo "dist/money-linux-$arch"
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Builds a fully static pdftotext for `go build -tags bundled`, so the money
|
||||||
|
# binary can carry it rather than depend on poppler-utils on the host.
|
||||||
|
#
|
||||||
|
# Only what text extraction needs is compiled in. Fonts are never rendered, so
|
||||||
|
# fontconfig, cairo and the image codecs (JPEG, JPEG 2000, TIFF) are left out,
|
||||||
|
# as are colour management, HTTP and signature support. Freetype and zlib are
|
||||||
|
# the two dependencies poppler will not build without.
|
||||||
|
#
|
||||||
|
# Built against musl because glibc cannot be linked statically in any way that
|
||||||
|
# survives a different host.
|
||||||
|
#
|
||||||
|
# Run it through scripts/build-bundled.sh rather than by hand.
|
||||||
|
|
||||||
|
FROM docker.io/library/alpine:3.22 AS build
|
||||||
|
|
||||||
|
RUN apk add --no-cache build-base cmake samurai pkgconf \
|
||||||
|
freetype-dev freetype-static zlib-dev zlib-static \
|
||||||
|
libpng-dev libpng-static bzip2-static brotli-static
|
||||||
|
|
||||||
|
# Pinned by checksum: this binary ends up inside money, so the source it is
|
||||||
|
# built from must be exactly the one that was reviewed.
|
||||||
|
ARG POPPLER_VERSION=26.09.0
|
||||||
|
ARG POPPLER_SHA256=8059eadb6805340768f138c465b57f8164c92b4a0773c37ef031ea6c0d987b2e
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
RUN wget -q "https://poppler.freedesktop.org/poppler-${POPPLER_VERSION}.tar.xz" \
|
||||||
|
&& echo "${POPPLER_SHA256} poppler-${POPPLER_VERSION}.tar.xz" | sha256sum -c - \
|
||||||
|
&& tar xf "poppler-${POPPLER_VERSION}.tar.xz" --strip-components=1 \
|
||||||
|
&& rm "poppler-${POPPLER_VERSION}.tar.xz"
|
||||||
|
|
||||||
|
# The find modules would otherwise settle on the shared libraries, and freetype's static
|
||||||
|
# archive does not carry its own dependencies (png, bzip2, brotli), so they are
|
||||||
|
# appended from what pkg-config says a static freetype needs.
|
||||||
|
RUN cmake -S . -B build -G Ninja \
|
||||||
|
-DCMAKE_BUILD_TYPE=Release \
|
||||||
|
-DBUILD_SHARED_LIBS=OFF \
|
||||||
|
-DFREETYPE_LIBRARY_RELEASE=/usr/lib/libfreetype.a \
|
||||||
|
-DPNG_LIBRARY_RELEASE=/usr/lib/libpng.a \
|
||||||
|
-DZLIB_LIBRARY_RELEASE=/usr/lib/libz.a \
|
||||||
|
-DCMAKE_EXE_LINKER_FLAGS=-static \
|
||||||
|
-DCMAKE_CXX_STANDARD_LIBRARIES="$(pkg-config --static --libs freetype2)" \
|
||||||
|
-DFONT_CONFIGURATION=generic \
|
||||||
|
-DENABLE_UTILS=ON \
|
||||||
|
-DENABLE_CPP=OFF \
|
||||||
|
-DENABLE_GLIB=OFF \
|
||||||
|
-DENABLE_GOBJECT_INTROSPECTION=OFF \
|
||||||
|
-DENABLE_QT5=OFF \
|
||||||
|
-DENABLE_QT6=OFF \
|
||||||
|
-DENABLE_BOOST=OFF \
|
||||||
|
-DENABLE_LIBOPENJPEG=OFF \
|
||||||
|
-DENABLE_LIBJPEG=OFF \
|
||||||
|
-DENABLE_LCMS=OFF \
|
||||||
|
-DENABLE_LIBCURL=OFF \
|
||||||
|
-DENABLE_LIBTIFF=OFF \
|
||||||
|
-DENABLE_NSS3=OFF \
|
||||||
|
-DENABLE_GPGME=OFF \
|
||||||
|
-DENABLE_HARFBUZZ=OFF \
|
||||||
|
-DBUILD_GTK_TESTS=OFF \
|
||||||
|
-DBUILD_QT5_TESTS=OFF \
|
||||||
|
-DBUILD_QT6_TESTS=OFF \
|
||||||
|
-DBUILD_CPP_TESTS=OFF \
|
||||||
|
-DBUILD_MANUAL_TESTS=OFF \
|
||||||
|
&& cmake --build build --target pdftotext \
|
||||||
|
&& strip build/utils/pdftotext \
|
||||||
|
# Refuse to hand over anything that still wants a dynamic loader.
|
||||||
|
&& ! readelf -l build/utils/pdftotext | grep -q INTERP \
|
||||||
|
&& build/utils/pdftotext -v
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
COPY --from=build /src/build/utils/pdftotext /pdftotext
|
||||||
Reference in New Issue
Block a user