diff --git a/.gitignore b/.gitignore index 9a3e7a1..7d1ce31 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,8 @@ deps deploy/db_key.env # Deploy secret (telegram bot token + chat id) — never commit deploy/telegram.env +# zenmoney API token, read by mavpoll (never in argv, never committed) +deploy/zenmoney.token # Temp files /tmp/ diff --git a/cmd/mavend/actions_money.go b/cmd/mavend/actions_money.go new file mode 100644 index 0000000..9bca68d --- /dev/null +++ b/cmd/mavend/actions_money.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "log" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/router" + "github.com/kami/maven/internal/zenmoney" +) + +// Money questions (Vikunja #125). +// +// This is the whole read side: mavpoll holds the zenmoney token and writes +// facts(kind=env, source=poll:zenmoney); core reads them back when he asks. +// Core never sees the token, never calls zenmoney, and has no rule on these +// keys — a total is never a reason for Maven to speak first. Maven is not a +// nag, least of all about his money. +// +// Nothing here can reach the external search capability: the figures are read +// from the store and rendered locally, and his financial data is never search +// input. + +// queryMoney — "сколько я потратил сегодня?", "покажи мои траты". +// +// Answers only from the latest fact the poller wrote. Three honest outcomes and +// no fourth: the figure, "the fact is old and here is its date", or "money +// tracking is not connected". It never computes, estimates or rounds a total of +// its own — an invented number about his money is the worst thing this could do. +func (h *reactiveHandler) queryMoney(ctx context.Context, t *queryTurn) (string, bool) { + window, ok := router.ParseMoneyQuery(t.dec.Utterance) + if !ok { + return "", false + } + key, phrase := zenmoney.KeySpentMonth, "в этом месяце" + if window == router.MoneyToday { + key, phrase = zenmoney.KeySpentToday, "сегодня" + } + fact, err := h.api.LatestFactBySource(ctx, key, zenmoney.Source) + if err != nil { + // No fact at all is the normal state when the capability is off. Claim + // the turn anyway: falling through to recall would answer a question + // about money with whatever note happens to be nearest. + if !isNoFactErr(err) { + log.Printf("voice: money fact: %v", err) + } + return "я не отслеживаю траты — не подключено.", true + } + val, err := zenmoney.ParseFactValue(fact.Value) + if err != nil { + log.Printf("voice: money fact: decode: %v", err) + return "не получилось прочитать траты.", true + } + reply := val.FormatRU(phrase) + if reply == "" { + return "по тратам пока нечего сказать.", true + } + // A stale fact is reported as stale rather than spoken as today's number. + if h.now().Sub(fact.Ts) > zenmoney.StaleAfter { + return "данные от " + fact.Ts.Local().Format("02.01") + ": " + reply, true + } + return reply, true +} + +// isNoFactErr — ErrNoFact survives the wire wrapped, so unwrap for it. +func isNoFactErr(err error) bool { + for e := err; e != nil; { + if e == ipc.ErrNoFact { + return true + } + u, ok := e.(interface{ Unwrap() error }) + if !ok { + return false + } + e = u.Unwrap() + } + return false +} diff --git a/cmd/mavend/actions_money_test.go b/cmd/mavend/actions_money_test.go new file mode 100644 index 0000000..4359f84 --- /dev/null +++ b/cmd/mavend/actions_money_test.go @@ -0,0 +1,139 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/router" + "github.com/kami/maven/internal/zenmoney" +) + +// moneyAPI answers only LatestFactBySource; everything else is unimplemented, +// which is the assertion that answering a money question costs no model call +// and reaches no network. +type moneyAPI struct { + ipc.UnimplementedCoreAPI + + fact ipc.Fact + err error + gotKey string + gotSrc string + callCnt int +} + +func (a *moneyAPI) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) { + a.gotKey, a.gotSrc = key, source + a.callCnt++ + return a.fact, a.err +} + +func moneyNow() time.Time { return time.Date(2026, 8, 15, 20, 0, 0, 0, time.UTC) } + +func moneyFact(ts time.Time, val string) ipc.Fact { + return ipc.Fact{Kind: "env", Key: zenmoney.KeySpentMonth, Value: val, Source: zenmoney.Source, Ts: ts} +} + +func TestQueryMoneyAnswersFromTheFact(t *testing.T) { + api := &moneyAPI{fact: moneyFact(moneyNow(), `{"spent":[{"currency":"RUB","amount":1749.5}],"count":3}`)} + h := &reactiveHandler{api: api, now: moneyNow} + reply, ok := h.queryMoney(context.Background(), &queryTurn{ + dec: router.Decision{Utterance: "сколько я потратил в этом месяце?"}, + }) + if !ok { + t.Fatal("the money source must claim a money question") + } + if api.gotKey != zenmoney.KeySpentMonth || api.gotSrc != zenmoney.Source { + t.Errorf("read %q/%q, want the month key from the poller's source", api.gotKey, api.gotSrc) + } + if !strings.Contains(reply, "1749.5") { + t.Errorf("reply = %q, want the exact figure", reply) + } + if !strings.Contains(reply, "в этом месяце") { + t.Errorf("reply = %q, want the window named", reply) + } +} + +func TestQueryMoneyPicksTodaysKey(t *testing.T) { + api := &moneyAPI{fact: moneyFact(moneyNow(), `{"spent":[{"currency":"RUB","amount":250}],"count":1}`)} + h := &reactiveHandler{api: api, now: moneyNow} + if _, ok := h.queryMoney(context.Background(), &queryTurn{ + dec: router.Decision{Utterance: "сколько я потратил сегодня?"}, + }); !ok { + t.Fatal("expected the source to claim it") + } + if api.gotKey != zenmoney.KeySpentToday { + t.Errorf("key = %q, want today's", api.gotKey) + } +} + +// The capability is off unless configured, and then there is no fact. She says +// so instead of letting the recall pass answer a money question from a note. +func TestQueryMoneySaysNotConnected(t *testing.T) { + h := &reactiveHandler{api: &moneyAPI{err: ipc.ErrNoFact}, now: moneyNow} + reply, ok := h.queryMoney(context.Background(), &queryTurn{ + dec: router.Decision{Utterance: "сколько я потратил?"}, + }) + if !ok { + t.Fatal("expected the source to claim it") + } + if !strings.Contains(reply, "не подключено") { + t.Errorf("reply = %q, want an honest 'not connected'", reply) + } + // No number of any kind in that answer. + for _, d := range []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"} { + if strings.Contains(reply, d) { + t.Errorf("reply %q contains a digit — nothing was read, so there is no figure", reply) + } + } +} + +// A fact older than the staleness bound is dated rather than spoken as if it +// were current: the poller can be down, and last week's total presented as +// today's is a lie by omission. +func TestQueryMoneyDatesAStaleFact(t *testing.T) { + old := moneyNow().Add(-72 * time.Hour) + api := &moneyAPI{fact: moneyFact(old, `{"spent":[{"currency":"RUB","amount":100}],"count":1}`)} + h := &reactiveHandler{api: api, now: moneyNow} + reply, _ := h.queryMoney(context.Background(), &queryTurn{ + dec: router.Decision{Utterance: "сколько я потратил?"}, + }) + if !strings.Contains(reply, "данные от") { + t.Errorf("reply = %q, want the stale fact dated", reply) + } +} + +func TestQueryMoneyPassesOtherQuestions(t *testing.T) { + api := &moneyAPI{} + h := &reactiveHandler{api: api, now: moneyNow} + for _, u := range []string{"какая погода?", "я потратил весь день на это", "какие у меня задачи?"} { + if _, ok := h.queryMoney(context.Background(), &queryTurn{dec: router.Decision{Utterance: u}}); ok { + t.Errorf("the money source claimed %q", u) + } + } + if api.callCnt != 0 { + t.Error("a non-money question must not read the money facts") + } +} + +// Money must be answered before the recall sources, or a question about +// spending gets answered by the nearest note. +func TestQuerySourcesOrderMoneyBeforeRecall(t *testing.T) { + moneyAt, notesAt := -1, -1 + for i, src := range querySources { + switch src.name { + case "money": + moneyAt = i + case "notes": + notesAt = i + } + } + if moneyAt < 0 || notesAt < 0 { + t.Fatalf("sources missing: money=%d notes=%d", moneyAt, notesAt) + } + if moneyAt > notesAt { + t.Errorf("money source at %d, after notes at %d", moneyAt, notesAt) + } +} diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index 5e27c58..69216ff 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -62,6 +62,11 @@ var querySources = []querySource{ // matcher requires a task noun or an explicit "что … сделать", so a // date-bearing question still reaches the calendar. {"tasks", (*reactiveHandler).queryTasks}, + // Before the recall sources too: "сколько я потратил?" is a question about + // the money facts the poller wrote, and the notes pass would otherwise + // answer it from whatever he once said about spending. Its matcher needs a + // money noun plus an actual ask, so "я потратил весь день" is untouched. + {"money", (*reactiveHandler).queryMoney}, {"calendar", (*reactiveHandler).queryCalendar}, {"weather", (*reactiveHandler).queryWeather}, {"embed", (*reactiveHandler).queryEmbed}, diff --git a/cmd/mavpoll/main.go b/cmd/mavpoll/main.go index d15ae89..83fd748 100644 --- a/cmd/mavpoll/main.go +++ b/cmd/mavpoll/main.go @@ -9,6 +9,12 @@ // Two sources, each its own provenance (the loop's rules trust source): // - netdata → poll:netdata resource alarms (disk/mem/cert/temp) // - kuma → poll:uptimekuma service up/down (the source of truth for it) +// - zenmoney → poll:zenmoney spending/income totals (Vikunja #125) +// +// The zenmoney source is why the token lives HERE and not in core: the poller +// already owns every other third-party credential, it holds no store key, and +// core never needs to know an account exists to answer a question about a fact +// the poller wrote. It is off unless -zenmoney-token-file is given. // // Netdata needs no auth over the wg-fronted net. Kuma's /metrics needs an API // key (basic-auth); without -kuma the whole kuma path is skipped (netdata-only @@ -37,6 +43,7 @@ import ( "time" "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/zenmoney" ) func main() { @@ -52,6 +59,9 @@ func run(args []string) error { netdataURL := fs.String("netdata", "http://127.0.0.1:19999", "netdata base URL ('' to disable)") kumaURL := fs.String("kuma", "", "uptime-kuma metrics URL, e.g. http://127.0.0.1:3001/metrics ('' to disable)") kumaKey := fs.String("kuma-key", "", "uptime-kuma API key (basic-auth username)") + zenTokenFile := fs.String("zenmoney-token-file", "", "file holding the zenmoney API token ('' disables money tracking)") + zenURL := fs.String("zenmoney-url", zenmoney.DefaultBaseURL, "zenmoney API base URL (tests/self-hosted proxies)") + zenInterval := fs.Duration("zenmoney-interval", time.Hour, "how often to read zenmoney (money does not move every minute)") wgIface := fs.String("wg", "", "wireguard interface for the presence signal, e.g. wg0 or 'all' ('' to disable)") wgCmd := fs.String("wg-cmd", "wg", "wg binary (use e.g. 'sudo wg' if the poller lacks CAP_NET_ADMIN)") interval := fs.Duration("interval", 60*time.Second, "poll cadence") @@ -62,8 +72,24 @@ func run(args []string) error { if *socket == "" { return fmt.Errorf("-socket is required") } - if *netdataURL == "" && *kumaURL == "" && *wgIface == "" { - return fmt.Errorf("nothing to poll: set -netdata, -kuma and/or -wg") + if *netdataURL == "" && *kumaURL == "" && *wgIface == "" && *zenTokenFile == "" { + return fmt.Errorf("nothing to poll: set -netdata, -kuma, -wg and/or -zenmoney-token-file") + } + + // The token is read from a file, never taken as a flag value: an argv token + // is visible in `ps` to every user on the box and lands in the compose file + // and the shell history. Read once at start — a rotated token means a + // restart, which is cheaper than re-reading his credential every hour. + var zen *zenmoney.Client + if *zenTokenFile != "" { + raw, err := os.ReadFile(*zenTokenFile) + if err != nil { + return fmt.Errorf("read zenmoney token: %w", err) + } + zen, err = zenmoney.New(strings.TrimSpace(string(raw)), *zenURL, *timeout*3) + if err != nil { + return err + } } ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) @@ -83,9 +109,13 @@ func run(args []string) error { kumaKey: *kumaKey, wgIface: *wgIface, wgCmd: *wgCmd, + zen: zen, + zenEvery: *zenInterval, } - log.Printf("mavpoll: polling every %s (netdata=%q kuma=%q wg=%q)", *interval, *netdataURL, *kumaURL, *wgIface) + // The token is never logged, not even its length. + log.Printf("mavpoll: polling every %s (netdata=%q kuma=%q wg=%q zenmoney=%v every %s)", + *interval, *netdataURL, *kumaURL, *wgIface, zen != nil, *zenInterval) p.pollOnce(ctx) // fire immediately; don't idle a full interval on start t := time.NewTicker(*interval) defer t.Stop() @@ -108,6 +138,12 @@ type poller struct { kumaKey string wgIface string wgCmd string + + // zen is nil unless a token file was configured — money tracking is a + // capability, off by default like weather and telegram. + zen *zenmoney.Client + zenEvery time.Duration + zenLast time.Time } // pollOnce — one sweep of both sources. A failure in one source logs and does @@ -129,6 +165,66 @@ func (p *poller) pollOnce(ctx context.Context) { log.Printf("mavpoll: wg: %v", err) } } + // Money on its own, much slower cadence: a bank feed that updates hourly + // polled every minute is 60 pointless reads of his financial history. + if p.zen != nil && now.Sub(p.zenLast) >= p.zenEvery { + p.zenLast = now + if err := p.pollZenmoney(ctx, now); err != nil { + log.Printf("mavpoll: zenmoney: %v", err) + } + } +} + +// ---- zenmoney: spending/income totals → money facts ------------------------ + +// pollZenmoney reads today's and this month's totals and writes them as +// facts(kind=env, source=poll:zenmoney) (Vikunja #125). +// +// Two properties this function exists to hold: +// +// - An empty or failed read writes NOTHING. zenmoney.Summary.Value() refuses +// to encode a summary built from zero transactions, so a poller that cannot +// reach the API leaves the last good fact in place rather than overwriting +// it with a zero Maven would then recite as fact. +// - Nothing about the money leaves the box except the diff request itself, to +// the service that already holds his bank sessions. The totals are written +// to the store and read back only when he asks; they are never search input +// and no tick rule fires on them. +// +// Both windows are read from one diff call each. Two calls an hour against an +// API whose whole job is this is not worth caching. +// moneyWindow — one fact key and the period it covers. +type moneyWindow struct { + key string + from, to time.Time +} + +func (p *poller) pollZenmoney(ctx context.Context, now time.Time) error { + dFrom, dTo := zenmoney.DayWindow(now) + mFrom, mTo := zenmoney.MonthWindow(now) + windows := []moneyWindow{ + {zenmoney.KeySpentToday, dFrom, dTo}, + {zenmoney.KeySpentMonth, mFrom, mTo}, + } + var firstErr error + for _, w := range windows { + sum, err := p.zen.Since(ctx, w.from, w.to) + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + val, ok := sum.Value() + if !ok { + // Nothing read. Silence, not a zero. + continue + } + if err := p.writeIfChangedRaw(ctx, w.key, zenmoney.Source, val, now); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr } // ---- wireguard: latest handshake → presence signal ------------------------- @@ -301,6 +397,31 @@ func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, no return nil } +// writeIfChangedRaw is writeIfChanged for values that are already JSON (the +// money facts store an object, not a string). Kept separate rather than +// generalising writeIfChanged, because the string-valued env facts encoding +// their own value is the convention the rules rely on. +// +// The log line names the key and the source, never the figures: mavpoll's log +// is not the place his spending ends up. +func (p *poller) writeIfChangedRaw(ctx context.Context, key, source, jsonVal string, now time.Time) error { + prev, err := p.core.LatestFactBySource(ctx, key, source) + switch { + case err == nil && prev.Value == jsonVal: + return nil + case err != nil && err != ipc.ErrNoFact && !isNoFact(err): + return fmt.Errorf("read %s: %w", key, err) + } + if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{ + Ts: now, Kind: "env", Key: key, Value: jsonVal, + Source: source, Confidence: 1.0, + }); err != nil { + return fmt.Errorf("write %s: %w", key, err) + } + log.Printf("mavpoll: %s updated (%s)", key, source) + return nil +} + // isNoFact — ErrNoFact rehydrated over the wire is wrapped (fmt.Errorf %w), so // errors.Is is the right check; keep a helper so the switch above reads clean. func isNoFact(err error) bool { diff --git a/cmd/mavpoll/main_test.go b/cmd/mavpoll/main_test.go index 2b79156..7004518 100644 --- a/cmd/mavpoll/main_test.go +++ b/cmd/mavpoll/main_test.go @@ -1,8 +1,17 @@ package main import ( + "context" "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" "testing" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/zenmoney" ) func TestMaxSeverity(t *testing.T) { @@ -62,3 +71,120 @@ func TestParseMaxHandshake(t *testing.T) { } } } + +// ---- zenmoney (Vikunja #125) ---------------------------------------------- + +// factCore records the facts the poller wrote and answers "no fact yet". +type factCore struct { + ipc.UnimplementedCoreAPI + + written []ipc.WriteFactReq + prev map[string]string +} + +func (c *factCore) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) { + if v, ok := c.prev[key+"|"+source]; ok { + return ipc.Fact{Key: key, Source: source, Value: v}, nil + } + return ipc.Fact{}, ipc.ErrNoFact +} + +func (c *factCore) WriteFact(_ context.Context, req ipc.WriteFactReq) (int64, error) { + c.written = append(c.written, req) + return int64(len(c.written)), nil +} + +func zenFixtureServer(t *testing.T, body []byte, status int) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if status != http.StatusOK { + w.WriteHeader(status) + return + } + w.Write(body) + })) +} + +func TestPollZenmoneyWritesMoneyFacts(t *testing.T) { + body, err := os.ReadFile("../../internal/zenmoney/testdata/diff.json") + if err != nil { + t.Fatal(err) + } + srv := zenFixtureServer(t, body, http.StatusOK) + defer srv.Close() + zen, err := zenmoney.New("tok", srv.URL, time.Second) + if err != nil { + t.Fatal(err) + } + core := &factCore{} + p := &poller{core: core, zen: zen} + now := time.Date(2026, 8, 1, 21, 0, 0, 0, time.UTC) + if err := p.pollZenmoney(context.Background(), now); err != nil { + t.Fatal(err) + } + if len(core.written) != 2 { + t.Fatalf("wrote %d facts, want today + month", len(core.written)) + } + for _, f := range core.written { + if f.Kind != "env" || f.Source != zenmoney.Source { + t.Errorf("fact = %+v, want kind=env source=%s", f, zenmoney.Source) + } + if _, err := zenmoney.ParseFactValue(f.Value); err != nil { + t.Errorf("fact value %q does not decode: %v", f.Value, err) + } + } +} + +// A read that returns nothing for the window writes NOTHING. Silence, not a +// zero: an invented 0 would be recited back to him as fact. +func TestPollZenmoneyWritesNothingWhenEmpty(t *testing.T) { + srv := zenFixtureServer(t, []byte(`{"serverTimestamp":1,"instrument":[],"transaction":[]}`), http.StatusOK) + defer srv.Close() + zen, _ := zenmoney.New("tok", srv.URL, time.Second) + core := &factCore{} + p := &poller{core: core, zen: zen} + if err := p.pollZenmoney(context.Background(), time.Now()); err != nil { + t.Fatal(err) + } + if len(core.written) != 0 { + t.Errorf("wrote %+v, want no fact at all", core.written) + } +} + +// An API failure must not overwrite the last good total either. +func TestPollZenmoneyFailureWritesNothing(t *testing.T) { + srv := zenFixtureServer(t, nil, http.StatusUnauthorized) + defer srv.Close() + zen, _ := zenmoney.New("bad", srv.URL, time.Second) + core := &factCore{} + p := &poller{core: core, zen: zen} + if err := p.pollZenmoney(context.Background(), time.Now()); err == nil { + t.Error("want the 401 reported") + } + if len(core.written) != 0 { + t.Errorf("wrote %+v on a failed read", core.written) + } +} + +// Unchanged totals do not churn the facts table. +func TestWriteIfChangedRawSkipsUnchanged(t *testing.T) { + core := &factCore{prev: map[string]string{ + zenmoney.KeySpentToday + "|" + zenmoney.Source: `{"count":1}`, + }} + p := &poller{core: core} + if err := p.writeIfChangedRaw(context.Background(), zenmoney.KeySpentToday, zenmoney.Source, `{"count":1}`, time.Now()); err != nil { + t.Fatal(err) + } + if len(core.written) != 0 { + t.Errorf("wrote %+v for an unchanged value", core.written) + } +} + +// Money tracking is off unless configured: no token file, no zenmoney client, +// and the poller still refuses to start with nothing at all to poll. +func TestRunRequiresSomethingToPoll(t *testing.T) { + err := run([]string{"-socket", "/tmp/nope.sock", "-netdata", "", "-kuma", "", "-wg", ""}) + if err == nil || !strings.Contains(err.Error(), "nothing to poll") { + t.Errorf("err = %v, want a 'nothing to poll' refusal", err) + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 0652d17..7ee23b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -101,9 +101,17 @@ services: "-netdata", "http://127.0.0.1:19999", "-kuma", "http://127.0.0.1:3001/metrics", "-kuma-key", "uk5_mavpoll-key"] + # Money tracking (Vikunja #125) is OFF: it needs a zenmoney token, + # which mavpoll reads from a FILE so it never appears in `ps`, in + # this file, or in shell history. To enable, mount the token and + # append: "-zenmoney-token-file", "/run/secrets/zenmoney.token" + # (optionally "-zenmoney-interval", "1h"). Core never sees the + # token — the poller writes facts(kind=env, source=poll:zenmoney) + # and mavend only reads those back when he asks. depends_on: [mavend] volumes: - sockets:/run/maven + # - ./deploy/zenmoney.token:/run/secrets/zenmoney.token:ro volumes: dbdata: diff --git a/internal/router/money.go b/internal/router/money.go new file mode 100644 index 0000000..5938adf --- /dev/null +++ b/internal/router/money.go @@ -0,0 +1,71 @@ +package router + +import "strings" + +// Money questions, matched deterministically (Vikunja #125). +// +// No new intent, for the same reason as tasks: the intent enum is a contract +// with the relabelling prompt. "сколько я потратил?" is a query; which figure +// it asks for is a lookup, not something to ask a 1.7B — and a model asked to +// invent a spending total will happily do it. + +// MoneyWindow — which period a money question asks about. +type MoneyWindow int + +const ( + MoneyNone MoneyWindow = iota + MoneyToday + MoneyMonth +) + +// moneyNouns — the words that make a question be about his money. +var moneyNouns = []string{ + "потратил", "потратила", "тратил", "траты", "трат", "расходы", "расходов", + "заработал", "потрачено", "денег", "spend", "spent", "expenses", +} + +// ParseMoneyQuery reports whether an utterance asks about spending or income, +// and over which window. Defaults to the month: "сколько я потратил?" without a +// period is the month-to-date question, which is the one worth answering. +// +// Narrow on purpose. A money noun alone is not enough — "я потратил весь день +// на это" is him talking about his day, so an amount word or an explicit +// question word has to be there too. +func ParseMoneyQuery(text string) (MoneyWindow, bool) { + toks := planTokens(text) + if len(toks) == 0 { + return MoneyNone, false + } + hasNoun := false + for _, t := range toks { + for _, n := range moneyNouns { + if t == n { + hasNoun = true + } + } + } + if !hasNoun { + return MoneyNone, false + } + // "весь день", "время", "силы" — spending that is not money. + for _, t := range toks { + switch t { + case "день", "дня", "время", "времени", "силы", "сил", "нервы": + return MoneyNone, false + } + } + asking := hasTok(toks, "сколько") || hasTok(toks, "какие") || hasTok(toks, "покажи") || + hasTok(toks, "how") || hasTok(toks, "much") || hasTok(toks, "my") || + hasTok(toks, "мои") || hasTok(toks, "траты") || hasTok(toks, "расходы") + if !asking { + return MoneyNone, false + } + lower := strings.ToLower(text) + switch { + case hasTok(toks, "сегодня") || strings.Contains(lower, "today"): + return MoneyToday, true + case hasTok(toks, "месяц") || hasTok(toks, "месяце") || strings.Contains(lower, "month"): + return MoneyMonth, true + } + return MoneyMonth, true +} diff --git a/internal/router/money_test.go b/internal/router/money_test.go new file mode 100644 index 0000000..f7ed6ea --- /dev/null +++ b/internal/router/money_test.go @@ -0,0 +1,31 @@ +package router + +import "testing" + +func TestParseMoneyQuery(t *testing.T) { + cases := []struct { + in string + window MoneyWindow + ok bool + }{ + {"сколько я потратил сегодня?", MoneyToday, true}, + {"сколько я потратил в этом месяце?", MoneyMonth, true}, + {"сколько я потратил?", MoneyMonth, true}, // month-to-date by default + {"покажи мои траты", MoneyMonth, true}, + {"какие у меня расходы за месяц", MoneyMonth, true}, + {"how much did I spend today", MoneyToday, true}, + {"сколько я заработал в этом месяце", MoneyMonth, true}, + // Not about money. + {"я потратил весь день на это", MoneyNone, false}, + {"потратил много сил", MoneyNone, false}, + {"какая погода?", MoneyNone, false}, + {"я купил молоко", MoneyNone, false}, + {"", MoneyNone, false}, + } + for _, c := range cases { + w, ok := ParseMoneyQuery(c.in) + if ok != c.ok || w != c.window { + t.Errorf("ParseMoneyQuery(%q) = (%v, %v), want (%v, %v)", c.in, w, ok, c.window, c.ok) + } + } +} diff --git a/internal/zenmoney/client.go b/internal/zenmoney/client.go new file mode 100644 index 0000000..34e458b --- /dev/null +++ b/internal/zenmoney/client.go @@ -0,0 +1,267 @@ +// 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) +} diff --git a/internal/zenmoney/client_test.go b/internal/zenmoney/client_test.go new file mode 100644 index 0000000..569e306 --- /dev/null +++ b/internal/zenmoney/client_test.go @@ -0,0 +1,178 @@ +package zenmoney + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" +) + +// fixtureServer replays testdata/diff.json and records the request, so the +// tests can assert the wire contract (Bearer token, POST, /v8/diff/) without a +// ZenMoney account. +func fixtureServer(t *testing.T, got *diffRequest, auth *string) *httptest.Server { + t.Helper() + body, err := os.ReadFile("testdata/diff.json") + if err != nil { + t.Fatal(err) + } + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + if r.URL.Path != "/v8/diff/" { + t.Errorf("path = %s, want /v8/diff/", r.URL.Path) + } + if auth != nil { + *auth = r.Header.Get("Authorization") + } + if got != nil { + if err := json.NewDecoder(r.Body).Decode(got); err != nil { + t.Errorf("decode request: %v", err) + } + } + w.Header().Set("Content-Type", "application/json") + w.Write(body) + })) +} + +func aug(day int) time.Time { return time.Date(2026, 8, day, 0, 0, 0, 0, time.UTC) } + +func TestSinceSumsSpendingPerCurrency(t *testing.T) { + var req diffRequest + var auth string + srv := fixtureServer(t, &req, &auth) + defer srv.Close() + + c, err := New("tok", srv.URL, time.Second) + if err != nil { + t.Fatal(err) + } + s, err := c.Since(context.Background(), aug(1), aug(6)) + if err != nil { + t.Fatal(err) + } + if auth != "Bearer tok" { + t.Errorf("Authorization = %q", auth) + } + if req.ServerTimestamp != aug(1).Unix() { + t.Errorf("serverTimestamp = %d, want the window start", req.ServerTimestamp) + } + // 1500 + 249.5 RUB spent, 12 EUR spent, 3000 RUB in. The transfer (t4), the + // deleted row (t6) and July's salary (t3) are all excluded. + want := map[string]float64{"RUB": 1749.5, "EUR": 12} + if len(s.Spent) != 2 { + t.Fatalf("spent = %+v, want two currencies", s.Spent) + } + for _, m := range s.Spent { + if want[m.Currency] != m.Amount { + t.Errorf("spent %s = %v, want %v", m.Currency, m.Amount, want[m.Currency]) + } + } + if len(s.Earned) != 1 || s.Earned[0].Amount != 3000 || s.Earned[0].Currency != "RUB" { + t.Errorf("earned = %+v, want 3000 RUB (July's salary is outside the window)", s.Earned) + } + if s.Count != 4 { + t.Errorf("count = %d, want 4 counted transactions", s.Count) + } + // Largest first, so the fact value does not churn between polls. + if s.Spent[0].Currency != "RUB" { + t.Errorf("spent order = %+v, want the largest amount first", s.Spent) + } +} + +// A window with nothing in it is NOT a zero. No transactions means no answer, +// and the caller must be able to tell the difference. +func TestSinceEmptyWindowIsNotAZero(t *testing.T) { + srv := fixtureServer(t, nil, nil) + defer srv.Close() + c, _ := New("tok", srv.URL, time.Second) + s, err := c.Since(context.Background(), time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 9, 30, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatal(err) + } + if !s.Empty() { + t.Fatalf("summary = %+v, want empty", s) + } + if _, ok := s.Value(); ok { + t.Error("an empty summary must not produce a fact value") + } +} + +func TestSinceReportsHTTPFailureWithoutTheBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"account":"acc-card","secret":"leaky"}`, http.StatusUnauthorized) + })) + defer srv.Close() + c, _ := New("tok", srv.URL, time.Second) + _, err := c.Since(context.Background(), aug(1), aug(6)) + if err == nil { + t.Fatal("want an error on 401") + } + if strings.Contains(err.Error(), "acc-card") || strings.Contains(err.Error(), "leaky") { + t.Errorf("error %q echoes the response body — it reaches the log", err) + } +} + +func TestNewRequiresAToken(t *testing.T) { + if _, err := New(" ", "", 0); err == nil { + t.Error("want an error for an empty token — the capability is off unless configured") + } +} + +func TestMonthAndDayWindows(t *testing.T) { + now := time.Date(2026, 8, 15, 21, 30, 0, 0, time.UTC) + from, to := MonthWindow(now) + if from != time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) || to != time.Date(2026, 8, 16, 0, 0, 0, 0, time.UTC) { + t.Errorf("month window = %v..%v", from, to) + } + from, to = DayWindow(now) + if from != time.Date(2026, 8, 15, 0, 0, 0, 0, time.UTC) || to != time.Date(2026, 8, 16, 0, 0, 0, 0, time.UTC) { + t.Errorf("day window = %v..%v", from, to) + } +} + +func TestFactValueRoundTripAndFormat(t *testing.T) { + s := Summary{Spent: []Money{{"RUB", 1749.5}}, Earned: []Money{{"RUB", 3000}}, Count: 3} + raw, ok := s.Value() + if !ok { + t.Fatal("want a fact value") + } + v, err := ParseFactValue(raw) + if err != nil { + t.Fatal(err) + } + got := v.FormatRU("в этом месяце") + if !strings.Contains(got, "1749.5 RUB") || !strings.Contains(got, "3000 RUB") { + t.Errorf("reply = %q, want the exact figures", got) + } + // Persona: informal, feminine, no commentary on his spending. + for _, bad := range []string{"вы", "ваш", "милый", "дорогой", "рад ", "слишком", "много"} { + if strings.Contains(got, bad) { + t.Errorf("reply %q contains %q", got, bad) + } + } + if strings.Contains(got, "он ") { + t.Errorf("reply %q talks about him in the third person", got) + } +} + +// An empty fact value renders to nothing, so a caller cannot accidentally +// speak a zero. +func TestFormatRUEmptyRendersNothing(t *testing.T) { + if got := (FactValue{}).FormatRU("сегодня"); got != "" { + t.Errorf("reply = %q, want empty", got) + } +} + +func TestFormatAmountKeepsTheTruth(t *testing.T) { + for in, want := range map[float64]string{1500: "1500", 249.5: "249.5", 0.99: "0.99", 1749.55: "1749.55"} { + if got := formatAmount(in); got != want { + t.Errorf("formatAmount(%v) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/zenmoney/render.go b/internal/zenmoney/render.go new file mode 100644 index 0000000..9ff4e99 --- /dev/null +++ b/internal/zenmoney/render.go @@ -0,0 +1,99 @@ +package zenmoney + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +// Fact keys the poller writes, all under source "poll:zenmoney". Two windows, +// because they are the two questions he actually asks; a per-category +// breakdown would mean storing what he bought, and the store is not a ledger. +const ( + KeySpentToday = "money_today" + KeySpentMonth = "money_month" +) + +// Source — the provenance every money fact carries. The loop's rules trust +// source, and nothing in Maven has a rule on these keys: they are read when he +// asks, never a reason to speak. Maven is not a nag, least of all about money. +const Source = "poll:zenmoney" + +// FactValue — the JSON stored in a money fact. A wire shape of its own rather +// than the Summary struct so From/To (which carry a timezone and a clock) stay +// out of the store; the key already says which window it is. +type FactValue struct { + Spent []Money `json:"spent"` + Earned []Money `json:"earned"` + Count int `json:"count"` +} + +// Value encodes the summary for the facts table. Returns ok=false for an empty +// summary: no transactions read means no fact written, so that a failed or +// empty poll can never be recited back to him as a zero. +func (s Summary) Value() (string, bool) { + if s.Empty() { + return "", false + } + b, err := json.Marshal(FactValue{Spent: s.Spent, Earned: s.Earned, Count: s.Count}) + if err != nil { + return "", false + } + return string(b), true +} + +// ParseFactValue decodes a stored money fact. +func ParseFactValue(raw string) (FactValue, error) { + var v FactValue + if err := json.Unmarshal([]byte(raw), &v); err != nil { + return FactValue{}, err + } + return v, nil +} + +// FormatRU renders a money fact the way Maven says it — feminine, informal, +// and only about numbers that came from ZenMoney. window is the Russian phrase +// for the period ("сегодня", "в этом месяце"). +// +// No commentary. She reports the figure and stops: an opinion about his +// spending is exactly the nagging Maven is not for. +func (v FactValue) FormatRU(window string) string { + if v.Count == 0 { + return "" + } + var parts []string + if len(v.Spent) > 0 { + parts = append(parts, "потратил "+joinMoney(v.Spent)) + } + if len(v.Earned) > 0 { + parts = append(parts, "получил "+joinMoney(v.Earned)) + } + if len(parts) == 0 { + return "" + } + return window + " ты " + strings.Join(parts, ", ") + "." +} + +func joinMoney(ms []Money) string { + parts := make([]string, 0, len(ms)) + for _, m := range ms { + parts = append(parts, fmt.Sprintf("%s %s", formatAmount(m.Amount), m.Currency)) + } + return strings.Join(parts, " и ") +} + +// formatAmount — whole units when the amount is whole, two decimals otherwise. +// Never rounded to something prettier than the truth. +func formatAmount(a float64) string { + if a == float64(int64(a)) { + return fmt.Sprintf("%d", int64(a)) + } + return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", a), "0"), ".") +} + +// StaleAfter — how old a money fact may be and still be worth reciting. The +// poller is off unless configured and can be down; answering with last week's +// total as if it were today's would be a lie by omission, so a stale fact is +// reported as stale. +const StaleAfter = 26 * time.Hour diff --git a/internal/zenmoney/testdata/diff.json b/internal/zenmoney/testdata/diff.json new file mode 100644 index 0000000..d6a9009 --- /dev/null +++ b/internal/zenmoney/testdata/diff.json @@ -0,0 +1,35 @@ +{ + "serverTimestamp": 1785312000, + "instrument": [ + {"id": 2, "title": "Российский рубль", "shortTitle": "RUB", "symbol": "₽", "rate": 1}, + {"id": 3, "title": "Евро", "shortTitle": "EUR", "symbol": "€", "rate": 100} + ], + "account": [ + {"id": "acc-card", "title": "карта", "instrument": 2}, + {"id": "acc-cash", "title": "наличные", "instrument": 2}, + {"id": "acc-eur", "title": "евро", "instrument": 3} + ], + "transaction": [ + {"id": "t1", "date": "2026-08-01", "changed": 1785300000, "income": 0, "outcome": 1500, + "incomeInstrument": 2, "outcomeInstrument": 2, "incomeAccount": "acc-card", "outcomeAccount": "acc-card", + "payee": "пятёрочка", "deleted": false}, + {"id": "t2", "date": "2026-08-01", "changed": 1785300001, "income": 0, "outcome": 249.5, + "incomeInstrument": 2, "outcomeInstrument": 2, "incomeAccount": "acc-card", "outcomeAccount": "acc-card", + "payee": "метро", "deleted": false}, + {"id": "t3", "date": "2026-07-20", "changed": 1785300002, "income": 120000, "outcome": 0, + "incomeInstrument": 2, "outcomeInstrument": 2, "incomeAccount": "acc-card", "outcomeAccount": "acc-card", + "payee": "зарплата", "deleted": false}, + {"id": "t4", "date": "2026-08-02", "changed": 1785300003, "income": 5000, "outcome": 5000, + "incomeInstrument": 2, "outcomeInstrument": 2, "incomeAccount": "acc-cash", "outcomeAccount": "acc-card", + "payee": "", "comment": "снял наличные", "deleted": false}, + {"id": "t5", "date": "2026-08-03", "changed": 1785300004, "income": 0, "outcome": 12, + "incomeInstrument": 3, "outcomeInstrument": 3, "incomeAccount": "acc-eur", "outcomeAccount": "acc-eur", + "payee": "hosting", "deleted": false}, + {"id": "t6", "date": "2026-08-04", "changed": 1785300005, "income": 0, "outcome": 999, + "incomeInstrument": 2, "outcomeInstrument": 2, "incomeAccount": "acc-card", "outcomeAccount": "acc-card", + "payee": "удалённая", "deleted": true}, + {"id": "t7", "date": "2026-08-05", "changed": 1785300006, "income": 3000, "outcome": 0, + "incomeInstrument": 2, "outcomeInstrument": 2, "incomeAccount": "acc-card", "outcomeAccount": "acc-card", + "payee": "возврат", "deleted": false} + ] +}