// 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) }