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. // // 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"` From time.Time `json:"from,omitempty"` AsOf time.Time `json:"as_of,omitempty"` } // 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, 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 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 { 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 "" } spent, earned := "", "" if len(v.Spent) > 0 { if s := joinMoney(v.Spent); s != "" { spent = "потратил " + s } } if len(v.Earned) > 0 { 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 "" } 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, " и ") } // 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