diff --git a/cmd/mavend/actions_money.go b/cmd/mavend/actions_money.go index 9bca68d..ec65ba8 100644 --- a/cmd/mavend/actions_money.go +++ b/cmd/mavend/actions_money.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "log" "github.com/kami/maven/internal/ipc" @@ -28,12 +29,18 @@ import ( // 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) + q, ok := router.ParseMoneyQuery(t.dec.Utterance) if !ok { return "", false } + if q.Window == router.MoneyUnsupported { + // Two windows are stored and no others. Answering "сколько я потратил + // вчера?" with the month-to-date total answers a different question + // with a real number, which is the shape of a lie he cannot spot. + return "я храню только сегодняшние траты и за этот месяц.", true + } key, phrase := zenmoney.KeySpentMonth, "в этом месяце" - if window == router.MoneyToday { + if q.Window == router.MoneyToday { key, phrase = zenmoney.KeySpentToday, "сегодня" } fact, err := h.api.LatestFactBySource(ctx, key, zenmoney.Source) @@ -51,28 +58,36 @@ func (h *reactiveHandler) queryMoney(ctx context.Context, t *queryTurn) (string, log.Printf("voice: money fact: decode: %v", err) return "не получилось прочитать траты.", true } + now := h.now() + if q.Window == router.MoneyToday && !val.CoversDay(now) { + // The day window rolled over and the poller had nothing to write, + // because he has not spent anything yet today. The fact is fresh by ts + // and covers yesterday, so no staleness check can catch it — only the + // window stamp inside the value can. + return "сегодня пока ничего не вижу.", true + } reply := val.FormatRU(phrase) + if q.Income { + reply = val.FormatIncomeRU(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 + // The age is measured from when the figure was last READ, not from when it + // last changed: a month with no spending in it does not go stale. + asOf := val.AsOf + if asOf.IsZero() { + asOf = fact.Ts + } + if now.Sub(asOf) > zenmoney.StaleAfter { + return "данные от " + asOf.Local().Format("02.01") + ": " + reply, true } return reply, true } -// isNoFactErr — ErrNoFact survives the wire wrapped, so unwrap for it. +// isNoFactErr — ErrNoFact survives the wire wrapped, so unwrap for it. The +// hand-rolled loop this replaces missed any error implementing Is(error) bool. 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 + return errors.Is(err, ipc.ErrNoFact) } diff --git a/cmd/mavend/actions_money_test.go b/cmd/mavend/actions_money_test.go index 4359f84..5f5657f 100644 --- a/cmd/mavend/actions_money_test.go +++ b/cmd/mavend/actions_money_test.go @@ -137,3 +137,92 @@ func TestQuerySourcesOrderMoneyBeforeRecall(t *testing.T) { t.Errorf("money source at %d, after notes at %d", moneyAt, notesAt) } } + +// The day window rolls over at midnight and the poller writes nothing until the +// first spend of the new day, so the last money_today fact is fresh by ts and +// covers yesterday. No staleness check can catch that. +func TestQueryMoneyRefusesYesterdaysDayTotal(t *testing.T) { + yesterday, _ := zenmoney.DayWindow(moneyNow().AddDate(0, 0, -1)) + sum := zenmoney.Summary{From: yesterday, Spent: []zenmoney.Money{{Currency: "RUB", Amount: 1749.5}}, Count: 3} + val, ok := sum.Value(moneyNow().AddDate(0, 0, -1).Add(2 * time.Hour)) + if !ok { + t.Fatal("want a fact value") + } + api := &moneyAPI{fact: ipc.Fact{ + Kind: "env", Key: zenmoney.KeySpentToday, Value: val, + Source: zenmoney.Source, Ts: moneyNow().Add(-11 * time.Hour), + }} + h := &reactiveHandler{api: api, now: moneyNow} + reply, claimed := h.queryMoney(context.Background(), &queryTurn{ + dec: router.Decision{Utterance: "сколько я потратил сегодня?"}, + }) + if !claimed { + t.Fatal("expected the source to claim it") + } + if strings.Contains(reply, "1749.5") { + t.Errorf("reply = %q — that is yesterday's spending spoken as today's", reply) + } +} + +// Ts advances only when the number moves, so a quiet month used to be reported +// as stale while being current. The read stamp inside the value is what the +// staleness check means. +func TestQueryMoneyMeasuresStalenessFromTheRead(t *testing.T) { + from, _ := zenmoney.MonthWindow(moneyNow()) + sum := zenmoney.Summary{From: from, Spent: []zenmoney.Money{{Currency: "RUB", Amount: 100}}, Count: 1} + val, _ := sum.Value(moneyNow().Add(-time.Hour)) + // The fact itself last CHANGED three days ago: nothing was spent since. + api := &moneyAPI{fact: ipc.Fact{ + Kind: "env", Key: zenmoney.KeySpentMonth, Value: val, + Source: zenmoney.Source, Ts: moneyNow().Add(-72 * time.Hour), + }} + h := &reactiveHandler{api: api, now: moneyNow} + reply, _ := h.queryMoney(context.Background(), &queryTurn{ + dec: router.Decision{Utterance: "сколько я потратил в этом месяце?"}, + }) + if strings.Contains(reply, "данные от") { + t.Errorf("reply = %q — the figure was read an hour ago and is current", reply) + } +} + +// Two windows are stored and no others. Answering "вчера" with the +// month-to-date total answers a different question with a real number. +func TestQueryMoneyRefusesWindowsItDoesNotKeep(t *testing.T) { + api := &moneyAPI{} + h := &reactiveHandler{api: api, now: moneyNow} + reply, ok := h.queryMoney(context.Background(), &queryTurn{ + dec: router.Decision{Utterance: "сколько я потратил вчера?"}, + }) + if !ok { + t.Fatal("a money question must be claimed, not passed to recall") + } + if !strings.Contains(reply, "только") { + t.Errorf("reply = %q, want her to say which windows she keeps", reply) + } + if api.callCnt != 0 { + t.Error("a window she does not keep must not read a fact") + } +} + +// "сколько я заработал" reads the same fact and must lead with the income. +func TestQueryMoneyLeadsWithIncomeWhenAsked(t *testing.T) { + from, _ := zenmoney.MonthWindow(moneyNow()) + sum := zenmoney.Summary{ + From: from, + Spent: []zenmoney.Money{{Currency: "RUB", Amount: 100}}, + Earned: []zenmoney.Money{{Currency: "RUB", Amount: 3000}}, + Count: 2, + } + val, _ := sum.Value(moneyNow()) + api := &moneyAPI{fact: ipc.Fact{ + Kind: "env", Key: zenmoney.KeySpentMonth, Value: val, + Source: zenmoney.Source, Ts: moneyNow(), + }} + h := &reactiveHandler{api: api, now: moneyNow} + reply, _ := h.queryMoney(context.Background(), &queryTurn{ + dec: router.Decision{Utterance: "сколько я заработал в этом месяце?"}, + }) + if strings.Index(reply, "3000") > strings.Index(reply, "100") { + t.Errorf("reply = %q, want the income he asked about first", reply) + } +} diff --git a/cmd/mavpoll/main.go b/cmd/mavpoll/main.go index 83fd748..69c9eb4 100644 --- a/cmd/mavpoll/main.go +++ b/cmd/mavpoll/main.go @@ -28,6 +28,7 @@ package main import ( "context" "encoding/json" + "errors" "flag" "fmt" "io" @@ -193,6 +194,14 @@ func (p *poller) pollOnce(ctx context.Context) { // // 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. +// +// The write is UNCONDITIONAL, unlike every other poll in this file. The +// value-dedupe in writeIfChangedRaw only advances ts when the number moves, and +// for money that made ts mean "last changed" while the reader was asking it "as +// of when". A quiet 27 hours had core prefixing "данные от 30.07" to a figure +// that was current. The value now carries its own read stamp, so it differs +// every poll anyway and there is nothing left for the dedupe to catch. + // moneyWindow — one fact key and the period it covers. type moneyWindow struct { key string @@ -215,12 +224,14 @@ func (p *poller) pollZenmoney(ctx context.Context, now time.Time) error { } continue } - val, ok := sum.Value() + val, ok := sum.Value(now) if !ok { - // Nothing read. Silence, not a zero. + // Nothing read. Silence, not a zero. The last good fact stays, and + // the window stamp inside it is what stops core reciting yesterday's + // day total as today's after midnight. continue } - if err := p.writeIfChangedRaw(ctx, w.key, zenmoney.Source, val, now); err != nil && firstErr == nil { + if err := p.writeMoneyFact(ctx, w.key, val, now); err != nil && firstErr == nil { firstErr = err } } @@ -422,20 +433,27 @@ func (p *poller) writeIfChangedRaw(ctx context.Context, key, source, jsonVal str 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 { - for e := err; e != nil; { - if e == ipc.ErrNoFact { - return true - } - u, ok := e.(interface{ Unwrap() error }) - if !ok { - return false - } - e = u.Unwrap() +// writeMoneyFact writes a money fact every poll, with no value comparison. See +// the comment above pollZenmoney for why this one does not go through +// writeIfChangedRaw. +// +// The log line names the key only, never the figures: mavpoll's log is not the +// place his spending ends up. +func (p *poller) writeMoneyFact(ctx context.Context, key, jsonVal string, now time.Time) error { + if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{ + Ts: now, Kind: "env", Key: key, Value: jsonVal, + Source: zenmoney.Source, Confidence: 1.0, + }); err != nil { + return fmt.Errorf("write %s: %w", key, err) } - return false + log.Printf("mavpoll: %s read (%s)", key, zenmoney.Source) + return nil +} + +// isNoFact — ErrNoFact rehydrated over the wire is wrapped (fmt.Errorf %w), so +// errors.Is is the right check. +func isNoFact(err error) bool { + return errors.Is(err, ipc.ErrNoFact) } func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error) { diff --git a/internal/router/money.go b/internal/router/money.go index 5938adf..6d5917a 100644 --- a/internal/router/money.go +++ b/internal/router/money.go @@ -16,12 +16,29 @@ const ( MoneyNone MoneyWindow = iota MoneyToday MoneyMonth + // MoneyUnsupported — a money question over a window nothing is stored for + // ("вчера", "на прошлой неделе"). Claimed, not answered: the poller keeps + // today and the month, and answering a question about yesterday with the + // month-to-date total is worse than saying she does not keep it. + MoneyUnsupported ) +// MoneyQuery — a parsed money question. Income is set when he asked what he +// EARNED rather than what he spent; the two read the same fact and differ only +// in which half of it leads the answer. +type MoneyQuery struct { + Window MoneyWindow + Income bool +} + +// incomeNouns — the words that make a money question be about income. +var incomeNouns = []string{"заработал", "заработала", "получил", "доход", "доходы", "earned", "income"} + // moneyNouns — the words that make a question be about his money. var moneyNouns = []string{ "потратил", "потратила", "тратил", "траты", "трат", "расходы", "расходов", - "заработал", "потрачено", "денег", "spend", "spent", "expenses", + "заработал", "заработала", "доход", "доходы", "потрачено", "денег", + "spend", "spent", "expenses", "earned", "income", } // ParseMoneyQuery reports whether an utterance asks about spending or income, @@ -30,11 +47,14 @@ var moneyNouns = []string{ // // 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) { +// question word has to be there too. The two evidence halves are INDEPENDENT: +// "траты" and "расходы" used to sit in both lists, so either word alone +// satisfied the whole gate and "у меня в этом месяце большие траты", a +// statement, came back with a figure. +func ParseMoneyQuery(text string) (MoneyQuery, bool) { toks := planTokens(text) if len(toks) == 0 { - return MoneyNone, false + return MoneyQuery{}, false } hasNoun := false for _, t := range toks { @@ -45,27 +65,40 @@ func ParseMoneyQuery(text string) (MoneyWindow, bool) { } } if !hasNoun { - return MoneyNone, false + return MoneyQuery{}, false } // "весь день", "время", "силы" — spending that is not money. for _, t := range toks { switch t { case "день", "дня", "время", "времени", "силы", "сил", "нервы": - return MoneyNone, false + return MoneyQuery{}, false } } asking := hasTok(toks, "сколько") || hasTok(toks, "какие") || hasTok(toks, "покажи") || hasTok(toks, "how") || hasTok(toks, "much") || hasTok(toks, "my") || - hasTok(toks, "мои") || hasTok(toks, "траты") || hasTok(toks, "расходы") + hasTok(toks, "мои") if !asking { - return MoneyNone, false + return MoneyQuery{}, false + } + income := false + for _, t := range toks { + for _, n := range incomeNouns { + if t == n { + income = true + } + } } lower := strings.ToLower(text) switch { + // Windows nothing is stored for, named explicitly so they are refused + // rather than silently answered with the month. + case hasTok(toks, "вчера") || hasTok(toks, "позавчера") || strings.Contains(lower, "yesterday"), + hasTok(toks, "неделю") || hasTok(toks, "неделе") || hasTok(toks, "неделя") || + strings.Contains(lower, "week"), + hasTok(toks, "год") || hasTok(toks, "году") || strings.Contains(lower, "year"): + return MoneyQuery{Window: MoneyUnsupported, Income: income}, true case hasTok(toks, "сегодня") || strings.Contains(lower, "today"): - return MoneyToday, true - case hasTok(toks, "месяц") || hasTok(toks, "месяце") || strings.Contains(lower, "month"): - return MoneyMonth, true + return MoneyQuery{Window: MoneyToday, Income: income}, true } - return MoneyMonth, true + return MoneyQuery{Window: MoneyMonth, Income: income}, true } diff --git a/internal/router/money_test.go b/internal/router/money_test.go index f7ed6ea..e063845 100644 --- a/internal/router/money_test.go +++ b/internal/router/money_test.go @@ -15,17 +15,25 @@ func TestParseMoneyQuery(t *testing.T) { {"какие у меня расходы за месяц", MoneyMonth, true}, {"how much did I spend today", MoneyToday, true}, {"сколько я заработал в этом месяце", MoneyMonth, true}, + // Windows nothing is stored for are claimed and refused, never answered + // with the month-to-date figure. + {"сколько я потратил вчера?", MoneyUnsupported, true}, + {"сколько я потратил на прошлой неделе?", MoneyUnsupported, true}, + {"how much did I spend yesterday", MoneyUnsupported, true}, // Not about money. {"я потратил весь день на это", MoneyNone, false}, + // A statement, not a question: the noun and the ask must be independent + // evidence, and "траты" used to satisfy both halves on its own. + {"у меня в этом месяце большие траты", 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) + q, ok := ParseMoneyQuery(c.in) + if ok != c.ok || q.Window != c.window { + t.Errorf("ParseMoneyQuery(%q) = (%v, %v), want (%v, %v)", c.in, q.Window, ok, c.window, c.ok) } } } diff --git a/internal/zenmoney/client.go b/internal/zenmoney/client.go index 34e458b..dcb6624 100644 --- a/internal/zenmoney/client.go +++ b/internal/zenmoney/client.go @@ -27,6 +27,7 @@ import ( "net/http" "sort" "strings" + "sync" "time" ) @@ -40,6 +41,14 @@ 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 @@ -124,14 +133,77 @@ type Summary struct { // 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. +// 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 } - return summarize(resp, from, to), nil + 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) { @@ -179,11 +251,7 @@ func (c *Client) diff(ctx context.Context, serverTimestamp int64) (diffResponse, // 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 - } +func summarize(resp diffResponse, cur map[int64]string, from, to time.Time) Summary { spent := map[string]float64{} earned := map[string]float64{} count := 0 @@ -217,14 +285,19 @@ func summarize(resp diffResponse, from, to time.Time) Summary { } } +// 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 -// the amount rather than dropping it: a sum without a currency label is still -// his money, and silently discarding it would understate the total. +// 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 "?" + return UnknownCurrency } // sortMoney gives the amounts a stable order (largest first) so the rendered diff --git a/internal/zenmoney/client_test.go b/internal/zenmoney/client_test.go index 569e306..a30e142 100644 --- a/internal/zenmoney/client_test.go +++ b/internal/zenmoney/client_test.go @@ -98,7 +98,7 @@ func TestSinceEmptyWindowIsNotAZero(t *testing.T) { if !s.Empty() { t.Fatalf("summary = %+v, want empty", s) } - if _, ok := s.Value(); ok { + if _, ok := s.Value(time.Now()); ok { t.Error("an empty summary must not produce a fact value") } } @@ -138,7 +138,7 @@ func TestMonthAndDayWindows(t *testing.T) { func TestFactValueRoundTripAndFormat(t *testing.T) { s := Summary{Spent: []Money{{"RUB", 1749.5}}, Earned: []Money{{"RUB", 3000}}, Count: 3} - raw, ok := s.Value() + raw, ok := s.Value(time.Date(2026, 8, 1, 22, 0, 0, 0, time.UTC)) if !ok { t.Fatal("want a fact value") } @@ -176,3 +176,99 @@ func TestFormatAmountKeepsTheTruth(t *testing.T) { } } } + +// The day window rolls over at midnight and the first spend of the new day may +// be hours away, so the last good money_today fact keeps a fresh ts while +// covering yesterday. Only the window stamp inside the value can tell. +func TestFactValueCoversDay(t *testing.T) { + from, _ := DayWindow(time.Date(2026, 8, 1, 22, 0, 0, 0, time.UTC)) + s := Summary{From: from, Spent: []Money{{"RUB", 1749.5}}, Count: 1} + raw, ok := s.Value(time.Date(2026, 8, 1, 22, 0, 0, 0, time.UTC)) + if !ok { + t.Fatal("want a fact value") + } + v, err := ParseFactValue(raw) + if err != nil { + t.Fatal(err) + } + if !v.CoversDay(time.Date(2026, 8, 1, 23, 59, 0, 0, time.UTC)) { + t.Error("the same day must be covered") + } + if v.CoversDay(time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC)) { + t.Error("yesterday's day total must not count as today's") + } + if (FactValue{}).CoversDay(time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC)) { + t.Error("a value with no window stamp must fail closed") + } + if v.AsOf.IsZero() { + t.Error("the value must carry when it was read, not only when it changed") + } +} + +// The instrument rows are only in the diff when they changed since the cursor, +// which for a day window they usually have not. An amount whose currency +// nothing could name is dropped from the spoken string rather than read out +// as "1749.5 ?". +func TestUnknownCurrencyIsNotSpoken(t *testing.T) { + v := FactValue{Spent: []Money{{UnknownCurrency, 1749.5}}, Count: 1} + if got := v.FormatRU("сегодня"); got != "" { + t.Errorf("reply = %q, want nothing said about an unlabelled amount", got) + } + v = FactValue{Spent: []Money{{"RUB", 100}, {UnknownCurrency, 1749.5}}, Count: 2} + got := v.FormatRU("сегодня") + if strings.Contains(got, UnknownCurrency) { + t.Errorf("reply = %q, want no %q currency", got, UnknownCurrency) + } + if !strings.Contains(got, "100 RUB") { + t.Errorf("reply = %q, want the amount that does have a currency", got) + } +} + +// A day diff cursored at midnight usually carries no instrument rows at all. +// The client fetches them once from a cursor-zero diff instead of labelling +// every amount "?". +func TestSinceResolvesCurrencyFromASeparateDiff(t *testing.T) { + body, err := os.ReadFile("testdata/diff.json") + if err != nil { + t.Fatal(err) + } + var full diffResponse + if err := json.Unmarshal(body, &full); err != nil { + t.Fatal(err) + } + windowed := diffResponse{ServerTimestamp: full.ServerTimestamp, Transaction: full.Transaction} + instrumentsOnly := diffResponse{ServerTimestamp: full.ServerTimestamp, Instrument: full.Instrument} + zeroCursorCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req diffRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("decode request: %v", err) + } + out := windowed + if req.ServerTimestamp == 0 { + zeroCursorCalls++ + out = instrumentsOnly + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(out) + })) + defer srv.Close() + + c, _ := New("tok", srv.URL, time.Second) + s, err := c.Since(context.Background(), aug(1), aug(6)) + if err != nil { + t.Fatal(err) + } + for _, m := range s.Spent { + if m.Currency == UnknownCurrency { + t.Fatalf("spent = %+v, want every amount named", s.Spent) + } + } + // Cached for the process lifetime: a second window does not refetch. + if _, err := c.Since(context.Background(), aug(1), aug(6)); err != nil { + t.Fatal(err) + } + if zeroCursorCalls != 1 { + t.Errorf("cursor-zero diffs = %d, want exactly 1", zeroCursorCalls) + } +} diff --git a/internal/zenmoney/render.go b/internal/zenmoney/render.go index 9ff4e99..5a991ef 100644 --- a/internal/zenmoney/render.go +++ b/internal/zenmoney/render.go @@ -20,29 +20,61 @@ const ( // 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. +// FactValue — the JSON stored in a money fact. +// +// From and AsOf are both here because the fact's own Ts can express neither. +// +// - From is the first instant of the window the figure covers. The day fact +// is only true for the day it was read on, and after midnight the poller +// has nothing new to write until the first spend of the new day, so the +// previous day's total sits there as the latest money_today looking +// perfectly fresh. Without From, "сколько я потратил сегодня?" at 09:00 +// answered with yesterday's spending. +// - AsOf is when the figure was last READ, not when it last changed. The +// poller used to skip a write when the value was byte-identical, so a quiet +// stretch left Ts pointing at the last time the number moved and the answer +// came back prefixed "данные от 30.07" while being current and correct. type FactValue struct { - Spent []Money `json:"spent"` - Earned []Money `json:"earned"` - Count int `json:"count"` + Spent []Money `json:"spent"` + Earned []Money `json:"earned"` + Count int `json:"count"` + From time.Time `json:"from,omitempty"` + AsOf time.Time `json:"as_of,omitempty"` } -// 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) { +// Value encodes the summary for the facts table, stamped with the instant it +// was read. 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(asOf time.Time) (string, bool) { if s.Empty() { return "", false } - b, err := json.Marshal(FactValue{Spent: s.Spent, Earned: s.Earned, Count: s.Count}) + b, err := json.Marshal(FactValue{ + Spent: s.Spent, Earned: s.Earned, Count: s.Count, + From: s.From, AsOf: asOf, + }) if err != nil { return "", false } return string(b), true } +// CoversDay reports whether this value's window starts at now's midnight, in +// now's location. A day total whose window has rolled over is not a stale +// figure to be prefixed with a date, it is an answer to a different question, +// and it must not be spoken as today's. +// +// A value written before From existed has a zero From and fails the check, +// which is the safe direction: the next poll rewrites it. +func (v FactValue) CoversDay(now time.Time) bool { + if v.From.IsZero() { + return false + } + f := v.From.In(now.Location()) + return f.Year() == now.Year() && f.Month() == now.Month() && f.Day() == now.Day() +} + // ParseFactValue decodes a stored money fact. func ParseFactValue(raw string) (FactValue, error) { var v FactValue @@ -59,15 +91,41 @@ func ParseFactValue(raw string) (FactValue, error) { // 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 { + return v.formatRU(window, false) +} + +// FormatIncomeRU is FormatRU with the income read first, for a question that +// asked about income ("сколько я заработал в этом месяце?"). Same figures, same +// refusal to comment; only the order of the two halves differs, so the number +// he asked for is the number she says first. +func (v FactValue) FormatIncomeRU(window string) string { + return v.formatRU(window, true) +} + +func (v FactValue) formatRU(window string, incomeFirst bool) string { if v.Count == 0 { return "" } - var parts []string + spent, earned := "", "" if len(v.Spent) > 0 { - parts = append(parts, "потратил "+joinMoney(v.Spent)) + if s := joinMoney(v.Spent); s != "" { + spent = "потратил " + s + } } if len(v.Earned) > 0 { - parts = append(parts, "получил "+joinMoney(v.Earned)) + if s := joinMoney(v.Earned); s != "" { + earned = "получил " + s + } + } + order := []string{spent, earned} + if incomeFirst { + order = []string{earned, spent} + } + var parts []string + for _, p := range order { + if p != "" { + parts = append(parts, p) + } } if len(parts) == 0 { return "" @@ -75,9 +133,17 @@ func (v FactValue) FormatRU(window string) string { return window + " ты " + strings.Join(parts, ", ") + "." } +// joinMoney renders the amounts, DROPPING any whose currency could not be +// named. "сегодня ты потратил 1749.5 ?." is not something to read aloud, and a +// figure with no currency on it is not a figure he can check against his bank. +// An amount silently missing is the lesser wrong: the alternative is speaking a +// number whose units Maven does not know. func joinMoney(ms []Money) string { parts := make([]string, 0, len(ms)) for _, m := range ms { + if m.Currency == UnknownCurrency || m.Currency == "" { + continue + } parts = append(parts, fmt.Sprintf("%s %s", formatAmount(m.Amount), m.Currency)) } return strings.Join(parts, " и ")