// 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" "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 } // 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 (a transaction // dated this month, filed and last changed before `from`) cannot exist. 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 } return summarize(resp, from, to), nil } 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, from, to time.Time) Summary { cur := map[int64]string{} for _, in := range resp.Instrument { cur[in.ID] = in.ShortTitle } 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, } } // currency names the instrument, or says it does not know. An unknown id keeps // the amount rather than dropping it: a sum without a currency label is still // his money, and silently discarding it would understate the total. func currency(names map[int64]string, id int64) string { if s := names[id]; s != "" { return s } return "?" } // 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) }