da647e87d0
The trust boundary is zenmoney, not maven — they already hold his bank sessions. So the poller reads /v8/diff/ and writes totals as facts(kind=env, source=poll:zenmoney); core reads those back when he asks and never sees the token. internal/zenmoney sums transactions per currency over a window, skipping tombstoned rows and transfers between his own accounts, and refuses to encode a summary built from zero transactions. That refusal is the whole design: a failed or empty read writes nothing and leaves the last good total alone, because a zero recited as fact is worse than silence. No currency conversion either — a figure he can check against his bank beats one he cannot. Off unless configured, and the token is read from a FILE rather than a flag so it never lands in `ps`, in docker-compose.yml, or in shell history. Nothing about the money is search input, no tick rule reads the keys, and the log lines name keys, never figures. The live-credential half is BLOCKED: there is no zenmoney account or token here, so everything is verified against a recorded diff fixture.
100 lines
3.2 KiB
Go
100 lines
3.2 KiB
Go
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
|