da647e87d0
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.
268 lines
9.3 KiB
Go
268 lines
9.3 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"
|
|
"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)
|
|
}
|