Files
Maven/internal/zenmoney/client.go
kami 6316354518 zenmoney: bound the day fact to its own day and stamp when it was read
The day total rolls over at midnight and the poller had nothing to write until
the first spend of the new day, so at 09:00 the latest money_today fact was
yesterday's spending and looked perfectly fresh. The value now carries the
first instant of the window it covers, and a today question that the stored
window does not cover is refused rather than answered with yesterday's number.
Staleness was measured off the fact timestamp, which only moved when the figure
moved, so a quiet month was reported as data from three days ago while being
current. The value now carries when it was last read and the poller writes on
every read.

Amounts in an instrument the window diff never named were spoken with a numeric
instrument id as the currency. Instruments are resolved from one cursor-zero
diff, cached for the process, and an amount still unnamed is dropped from
speech rather than recited wrongly. "сколько я потратил вчера" was answered
with the month total, a real number to a different question, and is now
refused by naming the two windows she keeps. Income questions led with the
spending.

Found in review of #62.
2026-08-01 14:16:56 +04:00

341 lines
12 KiB
Go

// Package zenmoney reads spending and income from ZenMoney's /v8/diff/ API
// (Vikunja #125).
//
// Trust boundary: ZenMoney, not Maven. They already hold his bank sessions —
// this package only reads back what they have, over a token that lives in the
// poller module and is never handed to core. Nothing here writes to ZenMoney;
// diff is called read-only (an empty change set in, a change set out).
//
// Two rules the code exists to enforce:
//
// - NEVER invent a number. Every figure in a Summary is a sum of amounts the
// API returned. A request that fails, or returns nothing, produces no
// summary and therefore no fact — silence, not a zero. A confidently wrong
// "ты потратил 0" is worse than no answer.
// - His money is never search input. This package holds no notes, no
// utterances and no persona text, and it has no path to the external search
// capability. The only thing that leaves the box here is the diff request
// itself, to the service that already has the data.
package zenmoney
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
"sync"
"time"
)
// DefaultBaseURL — ZenMoney's API root. Overridable so the tests can point at
// an httptest server replaying a recorded response.
const DefaultBaseURL = "https://api.zenmoney.ru"
// Client is a ZenMoney diff reader. The token is held here, in the poller's
// address space; core never receives it and never learns it exists.
type Client struct {
BaseURL string
Token string
HTTP *http.Client
// instruments — id → short title, fetched once from a cursor-zero diff and
// kept for the process lifetime. Currencies do not change; the reason this
// cache exists is that a windowed diff only returns objects changed since
// the cursor, so a day window almost never carries the instrument rows the
// transactions in it point at.
mu sync.Mutex
instruments map[int64]string
}
// New returns a client with a bounded HTTP timeout. An empty token is a
// programming error the caller must catch — the capability is off unless
// configured, so a client is only ever built when a token was supplied.
func New(token, baseURL string, timeout time.Duration) (*Client, error) {
if strings.TrimSpace(token) == "" {
return nil, fmt.Errorf("zenmoney: empty token")
}
if baseURL == "" {
baseURL = DefaultBaseURL
}
if timeout <= 0 {
timeout = 20 * time.Second
}
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
Token: token,
HTTP: &http.Client{Timeout: timeout},
}, nil
}
// diffRequest — the smallest body /v8/diff/ accepts. serverTimestamp is the
// incremental cursor: the server returns objects changed at or after it.
type diffRequest struct {
CurrentClientTimestamp int64 `json:"currentClientTimestamp"`
ServerTimestamp int64 `json:"serverTimestamp"`
}
// diffResponse — only the fields spending needs. ZenMoney returns a dozen more
// object types (tags, merchants, budgets, reminders); decoding them would mean
// holding more of his financial life in memory than the question needs.
type diffResponse struct {
ServerTimestamp int64 `json:"serverTimestamp"`
Instrument []instrument `json:"instrument"`
Transaction []transaction `json:"transaction"`
}
type instrument struct {
ID int64 `json:"id"`
ShortTitle string `json:"shortTitle"`
}
type transaction struct {
ID string `json:"id"`
Date string `json:"date"` // "2026-07-15"
Deleted bool `json:"deleted"`
Income float64 `json:"income"`
Outcome float64 `json:"outcome"`
IncomeInstrument int64 `json:"incomeInstrument"`
OutcomeInstrmnt int64 `json:"outcomeInstrument"`
IncomeAccount string `json:"incomeAccount"`
OutcomeAccount string `json:"outcomeAccount"`
}
// Money — an amount in one currency. Kept as the currency's own short title
// ("RUB", "EUR") rather than converted: ZenMoney's rates are a snapshot, and
// converting would turn a figure he can check against his bank into one he
// cannot.
type Money struct {
Currency string `json:"currency"`
Amount float64 `json:"amount"`
}
// Summary — what was spent and earned over a window, per currency, plus how
// many transactions it was computed from. Count is the honesty check: a
// summary built from zero transactions is not "you spent nothing", it is "there
// was nothing to read", and callers treat it as no answer.
type Summary struct {
From, To time.Time
Spent []Money `json:"spent"`
Earned []Money `json:"earned"`
Count int `json:"count"`
// ServerTimestamp — the cursor the API returned, for the caller to log or
// carry. Not used as an incremental cursor for summaries; see Since.
ServerTimestamp int64 `json:"-"`
}
// Since returns the summary of transactions dated in [from, to).
//
// The diff cursor is set to `from` so the server only sends objects changed
// since then, which for a "this month" window is everything filed this month.
// The caveat, deliberately accepted: a transaction he EDITED this month but
// dated last month arrives too, and is then excluded by date — so editing old
// records cannot inflate this month's total.
//
// The reverse case is real and undercounts: a transaction dated inside the
// window but last CHANGED before `from` never arrives. A planned transaction
// entered last month and dated this month is exactly that, and it goes missing
// from the total. Widening the cursor would mean pulling his whole history
// every poll, so the total is "what was filed or touched in the window", and
// that is the honest reading of it.
func (c *Client) Since(ctx context.Context, from, to time.Time) (Summary, error) {
resp, err := c.diff(ctx, from.Unix())
if err != nil {
return Summary{}, err
}
names := c.currencyNames(ctx, resp)
return summarize(resp, names, from, to), nil
}
// currencyNames resolves instrument ids to short titles. The window's own diff
// first, then — only if a transaction in it points at an instrument the window
// did not carry — one cursor-zero diff, cached for the process lifetime.
//
// A failed instrument fetch is not an error: the summary is still every number
// the API returned, and an amount whose currency cannot be named is dropped
// from the spoken string rather than read out as "1749.5 ?".
func (c *Client) currencyNames(ctx context.Context, resp diffResponse) map[int64]string {
names := map[int64]string{}
for _, in := range resp.Instrument {
names[in.ID] = in.ShortTitle
}
missing := false
for _, t := range resp.Transaction {
if t.Deleted {
continue
}
for _, id := range []int64{t.OutcomeInstrmnt, t.IncomeInstrument} {
if id != 0 && names[id] == "" {
missing = true
}
}
}
if !missing {
return names
}
for id, title := range c.allInstruments(ctx) {
if names[id] == "" {
names[id] = title
}
}
return names
}
// allInstruments fetches every instrument once, from a cursor-zero diff, and
// caches it. The response also carries transactions, which are decoded and
// dropped: this is the one call in the package that reads more of his financial
// life than the question needs, and it happens at most once per process.
func (c *Client) allInstruments(ctx context.Context) map[int64]string {
c.mu.Lock()
defer c.mu.Unlock()
if c.instruments != nil {
return c.instruments
}
resp, err := c.diff(ctx, 0)
if err != nil {
// Not cached: a network failure is not a fact about his currencies.
return nil
}
c.instruments = make(map[int64]string, len(resp.Instrument))
for _, in := range resp.Instrument {
c.instruments[in.ID] = in.ShortTitle
}
return c.instruments
}
func (c *Client) diff(ctx context.Context, serverTimestamp int64) (diffResponse, error) {
body, err := json.Marshal(diffRequest{
CurrentClientTimestamp: time.Now().Unix(),
ServerTimestamp: serverTimestamp,
})
if err != nil {
return diffResponse{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/v8/diff/", bytes.NewReader(body))
if err != nil {
return diffResponse{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.Token)
hc := c.HTTP
if hc == nil {
hc = &http.Client{Timeout: 20 * time.Second}
}
res, err := hc.Do(req)
if err != nil {
return diffResponse{}, err
}
defer res.Body.Close()
raw, err := io.ReadAll(io.LimitReader(res.Body, 32<<20))
if err != nil {
return diffResponse{}, err
}
if res.StatusCode != http.StatusOK {
// The status only. The body of a failed diff can echo account data, and
// this string reaches the log.
return diffResponse{}, fmt.Errorf("zenmoney diff: %s", res.Status)
}
var out diffResponse
if err := json.Unmarshal(raw, &out); err != nil {
return diffResponse{}, fmt.Errorf("zenmoney diff: decode: %w", err)
}
return out, nil
}
// summarize sums the transactions dated inside the window.
//
// Excluded, in order: deleted rows (ZenMoney tombstones rather than removes),
// transfers and currency exchanges (income and outcome both non-zero — moving
// his own money between his own accounts is not spending), and anything dated
// outside the window.
func summarize(resp diffResponse, cur map[int64]string, from, to time.Time) Summary {
spent := map[string]float64{}
earned := map[string]float64{}
count := 0
for _, t := range resp.Transaction {
if t.Deleted {
continue
}
d, err := time.ParseInLocation("2006-01-02", t.Date, from.Location())
if err != nil {
continue // an undated row is not a number we can place
}
if d.Before(from) || !d.Before(to) {
continue
}
if t.Income > 0 && t.Outcome > 0 {
continue // transfer / exchange
}
switch {
case t.Outcome > 0:
spent[currency(cur, t.OutcomeInstrmnt)] += t.Outcome
count++
case t.Income > 0:
earned[currency(cur, t.IncomeInstrument)] += t.Income
count++
}
}
return Summary{
From: from, To: to,
Spent: sortMoney(spent), Earned: sortMoney(earned),
Count: count, ServerTimestamp: resp.ServerTimestamp,
}
}
// UnknownCurrency — the label for an instrument id nothing could name. It
// survives into the Summary so a caller can see that a bucket exists; the
// renderer drops it rather than reading "?" aloud as a currency.
const UnknownCurrency = "?"
// currency names the instrument, or says it does not know. An unknown id keeps
// its bucket in the Summary rather than being folded into a named one: a sum is
// only checkable against his bank if every amount in it is in one currency.
func currency(names map[int64]string, id int64) string {
if s := names[id]; s != "" {
return s
}
return UnknownCurrency
}
// sortMoney gives the amounts a stable order (largest first) so the rendered
// string and the written fact do not churn between polls.
func sortMoney(m map[string]float64) []Money {
out := make([]Money, 0, len(m))
for c, a := range m {
out = append(out, Money{Currency: c, Amount: a})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Amount != out[j].Amount {
return out[i].Amount > out[j].Amount
}
return out[i].Currency < out[j].Currency
})
return out
}
// Empty reports whether the summary rests on no transactions at all. Callers
// must treat an empty summary as "nothing to say", never as a zero: the
// difference between "he spent nothing" and "the read returned nothing" is the
// difference between an answer and an invented one.
func (s Summary) Empty() bool { return s.Count == 0 }
// MonthWindow — the first instant of now's month, and now's own day-end
// exclusive bound, in now's location. The window a "сколько я потратил в этом
// месяце?" question means.
func MonthWindow(now time.Time) (from, to time.Time) {
loc := now.Location()
from = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc)
to = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, 1)
return from, to
}
// DayWindow — today, in now's location.
func DayWindow(now time.Time) (from, to time.Time) {
loc := now.Location()
from = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
return from, from.AddDate(0, 0, 1)
}