Read spending from zenmoney in the poller, answer it from facts (#125) #62

Closed
claude wants to merge 1 commits from overnight/money-zenmoney into overnight/task-priority
Contributor

What changed

Money tracking as the task body specifies it: the poller holds the token, core
never sees it, transactions become facts(kind='env', source='poll:zenmoney').

  • internal/zenmoney (new, pure Go): Client.Since(ctx, from, to) POSTs
    /v8/diff/ with a Bearer token and sums the transactions dated in the window,
    per currency. Excludes tombstoned rows (deleted), transfers/exchanges (income
    and outcome both non-zero — moving his own money is not spending), and anything
    outside the window. No currency conversion: amounts stay in their own currency's
    short title, because a figure he can check against his bank beats one he cannot.
    Summary.Value() refuses to encode a summary built from zero transactions.
    FormatRU renders the reply; StaleAfter = 26h.
  • cmd/mavpoll: new source alongside netdata/kuma/wg, on its own cadence
    (-zenmoney-interval, default 1h). writeIfChangedRaw keeps the existing
    no-churn discipline for JSON-valued facts. Keys: money_today, money_month.
  • internal/router/money.go + cmd/mavend/actions_money.go: no new intent.
    ParseMoneyQuery matches "сколько я потратил (сегодня|в этом месяце)?", "покажи
    мои траты", "how much did I spend today", and refuses "я потратил весь день".
    The money query source sits before the recall sources and answers only from the
    latest fact — three outcomes, no fourth: the figure, a dated stale figure, or
    «я не отслеживаю траты — не подключено.»
  • docker-compose.yml / .gitignore: the flags are documented and
    commented OUT, plus the token mount, and deploy/zenmoney.token is ignored.

Why

  • Off unless configured. No token file ⇒ no client ⇒ no source. Nothing in
    the tick loop reads these keys, so a total is never a reason to speak. Maven is
    not a nag, least of all about money.
  • The token is read from a file, not a flag. An argv token is visible in ps
    to every user on the box and ends up in the compose file and shell history.
    Read once at start; a rotated token means a restart.
  • Never invent a number. Every figure is a sum of amounts the API returned.
    An empty or failed read writes nothing and leaves the last good total alone —
    silence, not a zero, because a zero recited as fact is the worst failure here.
    A stale fact is spoken with its date rather than as today's number.
  • His financial data is never search input. The package holds no notes, no
    utterances and no persona text, and has no path to the external search
    capability. The only thing leaving the box is the diff request, to the service
    that already has the data. The error path deliberately reports the HTTP status
    only — a failed diff body can echo account data, and that string reaches the log.
    Log lines name keys, never figures.

BLOCKED — live credentials

There is no zenmoney account or token in this environment, so the live half is
unverified
: the real /v8/diff/ response shape beyond the fields decoded here,
real multi-currency data, and rate limits. Everything is tested against a recorded
fixture (internal/zenmoney/testdata/diff.json). To finish:

  1. Create the zenmoney OAuth2 app / obtain a token.
  2. Write it to deploy/zenmoney.token (gitignored, mode 0600).
  3. Uncomment the mount and append -zenmoney-token-file /run/secrets/zenmoney.token
    to the mavpoll command.
  4. Check docker compose logs mavpoll shows money_month updated (poll:zenmoney)
    and compare the figure against the zenmoney app for the same period.

One accepted caveat, documented at Client.Since: the diff cursor is the window
start, so a transaction he EDITED this month but DATED last month is fetched and
then excluded by date — editing old records cannot inflate the current total.

How verified

  • make build exit 0, make test exit 0.
  • internal/zenmoney/client_test.go — wire contract (POST /v8/diff/, Bearer,
    cursor = window start) against the recorded fixture; per-currency sums with
    transfers/deleted/out-of-window rows excluded; largest-first ordering; an empty
    window is not a zero and yields no fact value; a 401 error string does not echo
    the response body; New("") refuses; window helpers; fact value round-trip;
    persona and exact-figure checks on FormatRU.
  • cmd/mavpoll/main_test.go — two facts written with kind=env/source=poll:zenmoney
    and decodable values; nothing written on an empty read; nothing written on a 401;
    unchanged totals do not churn; run still refuses with nothing to poll.
  • cmd/mavend/actions_money_test.go — right key per window, exact figure in the
    reply, «не подключено» when there is no fact (asserted to contain no digit at
    all), stale facts dated, non-money questions passed through, and the query-source
    order guarded against the recall sources.

Vikunja #125

## What changed Money tracking as the task body specifies it: the poller holds the token, core never sees it, transactions become `facts(kind='env', source='poll:zenmoney')`. - **`internal/zenmoney`** (new, pure Go): `Client.Since(ctx, from, to)` POSTs `/v8/diff/` with a Bearer token and sums the transactions dated in the window, per currency. Excludes tombstoned rows (`deleted`), transfers/exchanges (income and outcome both non-zero — moving his own money is not spending), and anything outside the window. No currency conversion: amounts stay in their own currency's short title, because a figure he can check against his bank beats one he cannot. `Summary.Value()` refuses to encode a summary built from zero transactions. `FormatRU` renders the reply; `StaleAfter = 26h`. - **`cmd/mavpoll`**: new source alongside netdata/kuma/wg, on its own cadence (`-zenmoney-interval`, default 1h). `writeIfChangedRaw` keeps the existing no-churn discipline for JSON-valued facts. Keys: `money_today`, `money_month`. - **`internal/router/money.go`** + **`cmd/mavend/actions_money.go`**: no new intent. `ParseMoneyQuery` matches "сколько я потратил (сегодня|в этом месяце)?", "покажи мои траты", "how much did I spend today", and refuses "я потратил весь день". The `money` query source sits before the recall sources and answers only from the latest fact — three outcomes, no fourth: the figure, a dated stale figure, or «я не отслеживаю траты — не подключено.» - **`docker-compose.yml`** / **`.gitignore`**: the flags are documented and commented OUT, plus the token mount, and `deploy/zenmoney.token` is ignored. ## Why - **Off unless configured.** No token file ⇒ no client ⇒ no source. Nothing in the tick loop reads these keys, so a total is never a reason to speak. Maven is not a nag, least of all about money. - **The token is read from a file, not a flag.** An argv token is visible in `ps` to every user on the box and ends up in the compose file and shell history. Read once at start; a rotated token means a restart. - **Never invent a number.** Every figure is a sum of amounts the API returned. An empty or failed read writes nothing and leaves the last good total alone — silence, not a zero, because a zero recited as fact is the worst failure here. A stale fact is spoken with its date rather than as today's number. - **His financial data is never search input.** The package holds no notes, no utterances and no persona text, and has no path to the external search capability. The only thing leaving the box is the diff request, to the service that already has the data. The error path deliberately reports the HTTP status only — a failed diff body can echo account data, and that string reaches the log. Log lines name keys, never figures. ## BLOCKED — live credentials There is no zenmoney account or token in this environment, so **the live half is unverified**: the real `/v8/diff/` response shape beyond the fields decoded here, real multi-currency data, and rate limits. Everything is tested against a recorded fixture (`internal/zenmoney/testdata/diff.json`). To finish: 1. Create the zenmoney OAuth2 app / obtain a token. 2. Write it to `deploy/zenmoney.token` (gitignored, mode 0600). 3. Uncomment the mount and append `-zenmoney-token-file /run/secrets/zenmoney.token` to the mavpoll command. 4. Check `docker compose logs mavpoll` shows `money_month updated (poll:zenmoney)` and compare the figure against the zenmoney app for the same period. One accepted caveat, documented at `Client.Since`: the diff cursor is the window start, so a transaction he EDITED this month but DATED last month is fetched and then excluded by date — editing old records cannot inflate the current total. ## How verified - `make build` exit 0, `make test` exit 0. - `internal/zenmoney/client_test.go` — wire contract (POST `/v8/diff/`, `Bearer`, cursor = window start) against the recorded fixture; per-currency sums with transfers/deleted/out-of-window rows excluded; largest-first ordering; an empty window is not a zero and yields no fact value; a 401 error string does not echo the response body; `New("")` refuses; window helpers; fact value round-trip; persona and exact-figure checks on `FormatRU`. - `cmd/mavpoll/main_test.go` — two facts written with `kind=env`/`source=poll:zenmoney` and decodable values; nothing written on an empty read; nothing written on a 401; unchanged totals do not churn; `run` still refuses with nothing to poll. - `cmd/mavend/actions_money_test.go` — right key per window, exact figure in the reply, «не подключено» when there is no fact (asserted to contain no digit at all), stale facts dated, non-money questions passed through, and the query-source order guarded against the recall sources. Vikunja #125
claude added 1 commit 2026-08-01 00:50:57 +02:00
The trust boundary is zenmoney, not maven — they already hold his bank
sessions. So the poller reads /v8/diff/ and writes totals as
facts(kind=env, source=poll:zenmoney); core reads those back when he asks
and never sees the token.

internal/zenmoney sums transactions per currency over a window, skipping
tombstoned rows and transfers between his own accounts, and refuses to
encode a summary built from zero transactions. That refusal is the whole
design: a failed or empty read writes nothing and leaves the last good
total alone, because a zero recited as fact is worse than silence. No
currency conversion either — a figure he can check against his bank beats
one he cannot.

Off unless configured, and the token is read from a FILE rather than a
flag so it never lands in `ps`, in docker-compose.yml, or in shell
history. Nothing about the money is search input, no tick rule reads the
keys, and the log lines name keys, never figures.

The live-credential half is BLOCKED: there is no zenmoney account or token
here, so everything is verified against a recorded diff fixture.
claude reviewed 2026-08-01 11:29:07 +02:00
claude left a comment
Author
Contributor

The token boundary is the right one and it is drawn where the code can hold it. mavpoll reads the token from a file, so it never enters argv or compose. Core only ever calls LatestFactBySource. internal/zenmoney has no import path to anything outbound except its own diff call. Summary.Value() refuses to encode a zero-transaction read. That is the correct shape for "silence, not a zero". No tick rule fires on money_today, so a total never makes her speak first.

Three findings.

1. After midnight, yesterday's total is spoken as today's, and the staleness guard does not catch it.

pollZenmoney skips the write when the window read is empty. That is right in isolation. But the day window rolls over at midnight, and the first spend of a new day may be hours away. Walk it:

  • 01.08 22:00, poll writes money_today = 1749.5 RUB, Ts = 01.08 22:00.
  • 02.08 00:00 to 09:00, every poll reads an empty day window, Value() returns ok=false, nothing is written. The 01.08 fact is still the latest money_today.
  • 02.08 09:00 he asks "сколько я потратил сегодня?".
  • h.now().Sub(fact.Ts) is 11 hours, under StaleAfter of 26h, so no date prefix.
  • She answers сегодня ты потратил 1749.5 RUB.

That is a confidently wrong number about his money, which is the thing the package comment says is the worst outcome. The empty-read rule protects against writing a zero and correctly declines to overwrite. It does not protect against reading back a fact whose window has expired. The fix belongs in the key or in the read. Date-stamp the key (money_today:2026-08-02). Or store the window's From inside FactValue, and have queryMoney refuse a fact whose From is not today's midnight. The current Ts check cannot express this, because the fact is fresh and still wrong.

2. Staleness measures when the number last changed, not when it was last read.

writeIfChangedRaw returns early when prev.Value == jsonVal, so Ts only advances when the total moves. For money_month a quiet stretch is normal. Say he spends nothing for 27 hours. The month fact is byte-identical and never rewritten. queryMoney then prefixes данные от 30.07 to a figure that is current and correct. The reverse of finding 1, same root cause: Ts is being asked to mean "as of", and it means "last changed". Carry a read timestamp in FactValue, or write unconditionally for these two keys and let the value dedupe go.

3. The currency map depends on instruments arriving in the same delta, and nothing tests the case where they do not.

summarize builds cur from resp.Instrument only. The request sets serverTimestamp to the window start, so the server returns objects changed at or after it. An instrument row is not guaranteed to be in that set. For a day window with a cursor of 00:00 it often will not be. Then currency() returns "?" for every id and FormatRU produces сегодня ты потратил 1749.5 ?., which mavttsd then reads aloud. testdata/diff.json always ships both instruments, so every test takes the happy path and currency's fallback branch is never asserted. Fetch instruments with a separate cursor-zero diff and cache them for the process lifetime. Failing that, drop an unknown-currency amount from the spoken string rather than labelling it ?.

Smaller notes.

  • ParseMoneyQuery returns MoneyMonth for any unrecognised period, so "сколько я потратил вчера?" and "на прошлой неделе" both answer with the month-to-date total. Answering a different question than the one asked is worse here than declining. Recognise "вчера" and refuse the windows you do not store.
  • траты and расходы are in moneyNouns and also in the asking set, so either one alone satisfies both halves of the gate. "у меня в этом месяце большие траты" is a statement, and it gets answered with a figure. The two lists are meant to be independent evidence.
  • заработал is a money noun, but the key selection only ever picks a spending key. "сколько я заработал в этом месяце?" reads money_month and answers with the spend line first, income second. The window parse is doing double duty as an income/outcome parse and only has one of them.
  • isNoFactErr in actions_money.go hand-rolls the unwrap loop that errors.Is performs, and mavpoll's own isNoFact two files over already uses errors.Is. The hand-rolled version misses any error implementing Is(error) bool.
  • The comment above pollZenmoney runs straight into moneyWindow's doc comment with no blank line. The type declaration therefore absorbs the function's whole comment block.
  • Since's doc says a transaction dated in the window but last changed before from "cannot exist". A planned transaction entered last month and dated this month is exactly that case. It goes silently missing from the total.
The token boundary is the right one and it is drawn where the code can hold it. mavpoll reads the token from a file, so it never enters argv or compose. Core only ever calls `LatestFactBySource`. `internal/zenmoney` has no import path to anything outbound except its own diff call. `Summary.Value()` refuses to encode a zero-transaction read. That is the correct shape for "silence, not a zero". No tick rule fires on `money_today`, so a total never makes her speak first. Three findings. **1. After midnight, yesterday's total is spoken as today's, and the staleness guard does not catch it.** `pollZenmoney` skips the write when the window read is empty. That is right in isolation. But the day window rolls over at midnight, and the first spend of a new day may be hours away. Walk it: - 01.08 22:00, poll writes `money_today` = 1749.5 RUB, `Ts` = 01.08 22:00. - 02.08 00:00 to 09:00, every poll reads an empty day window, `Value()` returns ok=false, nothing is written. The 01.08 fact is still the latest `money_today`. - 02.08 09:00 he asks "сколько я потратил сегодня?". - `h.now().Sub(fact.Ts)` is 11 hours, under `StaleAfter` of 26h, so no date prefix. - She answers `сегодня ты потратил 1749.5 RUB.` That is a confidently wrong number about his money, which is the thing the package comment says is the worst outcome. The empty-read rule protects against writing a zero and correctly declines to overwrite. It does not protect against reading back a fact whose window has expired. The fix belongs in the key or in the read. Date-stamp the key (`money_today:2026-08-02`). Or store the window's `From` inside `FactValue`, and have `queryMoney` refuse a fact whose `From` is not today's midnight. The current `Ts` check cannot express this, because the fact is fresh and still wrong. **2. Staleness measures when the number last changed, not when it was last read.** `writeIfChangedRaw` returns early when `prev.Value == jsonVal`, so `Ts` only advances when the total moves. For `money_month` a quiet stretch is normal. Say he spends nothing for 27 hours. The month fact is byte-identical and never rewritten. `queryMoney` then prefixes `данные от 30.07` to a figure that is current and correct. The reverse of finding 1, same root cause: `Ts` is being asked to mean "as of", and it means "last changed". Carry a read timestamp in `FactValue`, or write unconditionally for these two keys and let the value dedupe go. **3. The currency map depends on instruments arriving in the same delta, and nothing tests the case where they do not.** `summarize` builds `cur` from `resp.Instrument` only. The request sets `serverTimestamp` to the window start, so the server returns objects changed at or after it. An instrument row is not guaranteed to be in that set. For a day window with a cursor of 00:00 it often will not be. Then `currency()` returns `"?"` for every id and `FormatRU` produces `сегодня ты потратил 1749.5 ?.`, which mavttsd then reads aloud. `testdata/diff.json` always ships both instruments, so every test takes the happy path and `currency`'s fallback branch is never asserted. Fetch instruments with a separate cursor-zero diff and cache them for the process lifetime. Failing that, drop an unknown-currency amount from the spoken string rather than labelling it `?`. Smaller notes. - `ParseMoneyQuery` returns `MoneyMonth` for any unrecognised period, so "сколько я потратил вчера?" and "на прошлой неделе" both answer with the month-to-date total. Answering a different question than the one asked is worse here than declining. Recognise "вчера" and refuse the windows you do not store. - `траты` and `расходы` are in `moneyNouns` and also in the `asking` set, so either one alone satisfies both halves of the gate. "у меня в этом месяце большие траты" is a statement, and it gets answered with a figure. The two lists are meant to be independent evidence. - `заработал` is a money noun, but the key selection only ever picks a spending key. "сколько я заработал в этом месяце?" reads `money_month` and answers with the spend line first, income second. The window parse is doing double duty as an income/outcome parse and only has one of them. - `isNoFactErr` in `actions_money.go` hand-rolls the unwrap loop that `errors.Is` performs, and mavpoll's own `isNoFact` two files over already uses `errors.Is`. The hand-rolled version misses any error implementing `Is(error) bool`. - The comment above `pollZenmoney` runs straight into `moneyWindow`'s doc comment with no blank line. The type declaration therefore absorbs the function's whole comment block. - `Since`'s doc says a transaction dated in the window but last changed before `from` "cannot exist". A planned transaction entered last month and dated this month is exactly that case. It goes silently missing from the total.
kami closed this pull request 2026-08-01 14:51:47 +02:00
Owner

Landed on master. The stack was one linear chain, so #84 carried every commit from #50 up, and master now contains this branch in full. Merging this PR on its own is an empty diff, so it is closed rather than merged. The review findings for it were fixed in the 2026-08-01 pass and are on master as commits on the stack tip, not on this branch.

Landed on master. The stack was one linear chain, so #84 carried every commit from #50 up, and master now contains this branch in full. Merging this PR on its own is an empty diff, so it is closed rather than merged. The review findings for it were fixed in the 2026-08-01 pass and are on master as commits on the stack tip, not on this branch.

Pull request closed

Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: kami/Maven#62