zenmoney: bound the day fact to its own day and stamp when it was read
The day total rolls over at midnight and the poller had nothing to write until the first spend of the new day, so at 09:00 the latest money_today fact was yesterday's spending and looked perfectly fresh. The value now carries the first instant of the window it covers, and a today question that the stored window does not cover is refused rather than answered with yesterday's number. Staleness was measured off the fact timestamp, which only moved when the figure moved, so a quiet month was reported as data from three days ago while being current. The value now carries when it was last read and the poller writes on every read. Amounts in an instrument the window diff never named were spoken with a numeric instrument id as the currency. Instruments are resolved from one cursor-zero diff, cached for the process, and an amount still unnamed is dropped from speech rather than recited wrongly. "сколько я потратил вчера" was answered with the month total, a real number to a different question, and is now refused by naming the two windows she keeps. Income questions led with the spending. Found in review of #62.
This commit is contained in:
+31
-16
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+34
-16
@@ -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) {
|
||||
|
||||
+45
-12
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+84
-11
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+80
-14
@@ -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, " и ")
|
||||
|
||||
Reference in New Issue
Block a user