// Package report aggregates transactions for the summary views. package report import ( "fmt" "sort" "strings" "git.petrovv.com/nikola/money/internal/model" ) // TagTotal is one row of a spending breakdown. type TagTotal struct { Tag string Currency string In int64 // sum of inflows, positive Out int64 // sum of outflows, positive Count int Digits int } // Net is inflow minus outflow. func (t TagTotal) Net() int64 { return t.In - t.Out } // ByTag groups transactions by currency and effective tag. Untagged rows are // collected under "(untagged)" so they stay visible instead of vanishing. const Untagged = "(untagged)" // Order is how the rows of a breakdown are arranged within a currency. // // Currency is always the outer key and no order can change that: the tool holds // no exchange rates, so two currencies interleaved by amount would invite a // comparison between numbers that cannot be compared. type Order int const ( // OrderOut is the default, and the question a spending report is usually // opened to answer: where did the money go. OrderOut Order = iota // largest outflow first OrderIn // largest inflow first OrderNet // lowest net first, so the biggest losses lead OrderCount // most transactions first OrderTag // tag name, A→Z numOrders // how many there are, for cycling; not an order itself ) // Next is the order after o, wrapping round. There are too many for a toggle, // so a view offering the choice cycles through them. func (o Order) Next() Order { return (o + 1) % numOrders } // String is the short name the `--sort` flag takes, and ParseOrder accepts. func (o Order) String() string { switch o { case OrderIn: return "in" case OrderNet: return "net" case OrderCount: return "count" case OrderTag: return "tag" } return "out" } // Label says what the order actually does, for a view with room to say it. func (o Order) Label() string { switch o { case OrderIn: return "largest in first" case OrderNet: return "lowest net first" case OrderCount: return "most transactions first" case OrderTag: return "tag A→Z" } return "largest out first" } // Column is the heading the order sorts on, so a view can mark the column the // rows are arranged by without keeping its own copy of this mapping. func (o Order) Column() string { switch o { case OrderIn: return "In" case OrderNet: return "Net" case OrderCount: return "N" case OrderTag: return "Tag" } return "Out" } // Ascending reports which way the order runs. Only the tag reads naturally // upwards; every amount and count is asked for biggest-first. func (o Order) Ascending() bool { return o == OrderTag } // Orders is every order, in the cycle Next follows. func Orders() []Order { out := make([]Order, 0, numOrders) for o := Order(0); o < numOrders; o++ { out = append(out, o) } return out } // ParseOrder turns a short name back into an order, naming the alternatives // when it cannot, since a misspelt sort would otherwise silently report in an // order the caller did not ask for. func ParseOrder(s string) (Order, error) { for _, o := range Orders() { if o.String() == s { return o, nil } } var names []string for _, o := range Orders() { names = append(names, o.String()) } return OrderOut, fmt.Errorf("unknown sort %q: want one of %s", s, strings.Join(names, ", ")) } // ByTag returns totals grouped by currency and arranged within each by order. // // Matched transfer legs are left out entirely. Both legs of a transfer are // dropped together, so a report over everything is unchanged in total by money // the user moved between their own accounts; a report scoped to one account or // one month may see only one leg, and dropping it is still right, since the // money was not spent. What was left out is available from Excluded. // // A pair whose definition sets a tolerance_pct is dropped the same way, fee and // all — so with such a definition in play the report *is* short by the fee, and // callers are expected to show Excluded alongside it rather than leave that // difference unexplained. func ByTag(txns []model.Transaction, order Order) []TagTotal { type key struct{ currency, tag string } acc := map[key]*TagTotal{} for _, t := range txns { if t.IsTransferLeg() { continue } tag := t.RuleTag if tag == "" { tag = Untagged } k := key{t.Currency, tag} row, ok := acc[k] if !ok { row = &TagTotal{Tag: tag, Currency: t.Currency, Digits: t.MinorDigits} acc[k] = row } if t.AmountMinor < 0 { row.Out += -t.AmountMinor } else { row.In += t.AmountMinor } row.Count++ } out := make([]TagTotal, 0, len(acc)) for _, row := range acc { out = append(out, *row) } sort.Slice(out, func(i, j int) bool { a, b := out[i], out[j] if a.Currency != b.Currency { return a.Currency < b.Currency } switch order { case OrderIn: if a.In != b.In { return a.In > b.In } case OrderNet: if a.Net() != b.Net() { return a.Net() < b.Net() } case OrderCount: if a.Count != b.Count { return a.Count > b.Count } case OrderTag: // The tag is the whole order here, and also the tie-break below. default: if a.Out != b.Out { return a.Out > b.Out } } // Every order falls back to the tag, so rows that tie on the chosen // column keep a fixed position instead of shuffling between reloads. return a.Tag < b.Tag }) return out } // CurrencyTotal is the bottom line for one currency. type CurrencyTotal struct { Currency string In int64 Out int64 Digits int } // Net is inflow minus outflow. func (c CurrencyTotal) Net() int64 { return c.In - c.Out } // Totals sums the per-tag rows per currency. Currencies are never combined, // because the tool holds no exchange rates. func Totals(rows []TagTotal) []CurrencyTotal { acc := map[string]*CurrencyTotal{} for _, r := range rows { c, ok := acc[r.Currency] if !ok { c = &CurrencyTotal{Currency: r.Currency, Digits: r.Digits} acc[r.Currency] = c } c.In += r.In c.Out += r.Out } out := make([]CurrencyTotal, 0, len(acc)) for _, c := range acc { out = append(out, *c) } sort.Slice(out, func(i, j int) bool { return out[i].Currency < out[j].Currency }) return out } // TransferTotal is what ByTag left out for one currency. // // It counts legs rather than pairs on purpose: a report scoped to one account // or month usually holds only one side of a movement, and claiming a whole // transfer was excluded when only half of it was in view would be a lie. The // same goes for the two directions, which is why they are separate totals: an // exchange between currencies has its legs in different columns entirely, so // one currency can see only what left and the other only what arrived. type TransferTotal struct { Currency string Out int64 // sum of the leaving legs in view, positive In int64 // sum of the arriving legs in view Legs int Digits int // Fee is what those movements cost: the amount that left less the amount // that arrived, for pairs whose legs are *both* in view. A definition with a // tolerance_pct pairs legs that disagree, and since the pair leaves the // report entirely, this is the only place that difference is still money // rather than nothing. Zero when no definition allows a mismatch. Fee int64 // Pairs counts the movements Fee was computed from — complete pairs in this // currency, not legs. A report scoped to one account or month usually holds // one side of a movement, and half a pair cannot say what the other half // received, so it contributes to Legs and Out/In but not here. Pairs int } // Excluded summarises the transfer legs ByTag dropped, so a report can account // for the difference between its total and the account balances. func Excluded(txns []model.Transaction) []TransferTotal { // Both legs of a pair carry the same TransferID, so the ones whose partner // is also in view can be found without going back to the index. byID := map[int64][]model.Transaction{} for _, t := range txns { if t.IsTransferLeg() { byID[*t.TransferID] = append(byID[*t.TransferID], t) } } acc := map[string]*TransferTotal{} row := func(t model.Transaction) *TransferTotal { r, ok := acc[t.Currency] if !ok { r = &TransferTotal{Currency: t.Currency, Digits: t.MinorDigits} acc[t.Currency] = r } return r } for _, t := range txns { if !t.IsTransferLeg() { continue } r := row(t) r.Legs++ if t.AmountMinor < 0 { r.Out += -t.AmountMinor } else { r.In += t.AmountMinor } } for _, legs := range byID { // An exchange has its legs in two currencies, and there is no rate here // to subtract one from the other, so it has no fee to report. if len(legs) != 2 || legs[0].Currency != legs[1].Currency { continue } r := row(legs[0]) r.Pairs++ r.Fee -= legs[0].AmountMinor + legs[1].AmountMinor } out := make([]TransferTotal, 0, len(acc)) for _, r := range acc { out = append(out, *r) } sort.Slice(out, func(i, j int) bool { return out[i].Currency < out[j].Currency }) return out }