// Package report aggregates transactions for the summary views. package report import ( "sort" "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)" // ByTag returns totals sorted by currency, then by largest outflow first. // // 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) []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 { if out[i].Currency != out[j].Currency { return out[i].Currency < out[j].Currency } if out[i].Out != out[j].Out { return out[i].Out > out[j].Out } return out[i].Tag < out[j].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 } // Months lists the distinct YYYY-MM present in txns, most recent first. func Months(txns []model.Transaction) []string { seen := map[string]bool{} for _, t := range txns { if len(t.Date) >= 7 { seen[t.Date[:7]] = true } } out := make([]string, 0, len(seen)) for m := range seen { out = append(out, m) } sort.Sort(sort.Reverse(sort.StringSlice(out))) return out }