The listing keyed on account and description, so a payee seen on two accounts was two rows. The rule builder groups by description alone and showed one, and the two lists answer the same question -- what still needs a rule -- so the one run from the shell overstated the work left and disagreed with the one on screen. A rule matches on the description and only optionally narrows to an account, so one payee is one pattern to write however many accounts it turns up on. The account column goes with the key; --account is still how the listing is scoped to one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
472 lines
14 KiB
Go
472 lines
14 KiB
Go
// Command money is a statement-driven personal finance tracker.
|
|
//
|
|
// The data root holds one folder per account. Drop statements into those
|
|
// folders, run `money import`, and tag what comes out with glob rules in
|
|
// rules.toml. Running it with no subcommand opens the TUI.
|
|
package main
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"text/tabwriter"
|
|
|
|
"git.petrovv.com/nikola/money/internal/config"
|
|
"git.petrovv.com/nikola/money/internal/importer"
|
|
"git.petrovv.com/nikola/money/internal/model"
|
|
"git.petrovv.com/nikola/money/internal/parser"
|
|
"git.petrovv.com/nikola/money/internal/report"
|
|
"git.petrovv.com/nikola/money/internal/rules"
|
|
"git.petrovv.com/nikola/money/internal/store"
|
|
"git.petrovv.com/nikola/money/internal/transfers"
|
|
"git.petrovv.com/nikola/money/internal/tui"
|
|
)
|
|
|
|
const usage = `money - statement-driven personal finance tracker
|
|
|
|
usage: money [--root DIR] <command> [flags]
|
|
|
|
commands:
|
|
tui browse transactions, build the rules that tag them and the
|
|
transfers that pair them across accounts (default)
|
|
import extract transactions from every statement into the index
|
|
retag re-apply rules.toml: retag everything and re-pair transfers
|
|
ls list transactions
|
|
report spending by tag
|
|
accounts list accounts with balances
|
|
parsers list available statement parsers
|
|
config show which data root is in use and why
|
|
|
|
The data root is taken from --root, else $MONEY_ROOT, else the "root" key in
|
|
$XDG_CONFIG_HOME/money/config.toml (~/.config/money/config.toml), else ~/money.
|
|
`
|
|
|
|
func main() {
|
|
if err := run(os.Args[1:]); err != nil {
|
|
fmt.Fprintln(os.Stderr, "money:", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func run(args []string) error {
|
|
var root string // empty means "not given on the command line"
|
|
// A leading --root/-root applies to every subcommand.
|
|
for len(args) > 0 && strings.HasPrefix(args[0], "-") {
|
|
flagName := strings.TrimLeft(args[0], "-")
|
|
name, inline, hasInline := strings.Cut(flagName, "=")
|
|
switch name {
|
|
case "root":
|
|
if hasInline {
|
|
root, args = inline, args[1:]
|
|
continue
|
|
}
|
|
if len(args) < 2 {
|
|
return fmt.Errorf("--root needs a directory")
|
|
}
|
|
root, args = args[1], args[2:]
|
|
case "h", "help":
|
|
fmt.Print(usage)
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("unknown flag %q\n\n%s", args[0], usage)
|
|
}
|
|
}
|
|
|
|
cmd := "tui"
|
|
if len(args) > 0 {
|
|
cmd, args = args[0], args[1:]
|
|
}
|
|
|
|
abs, source, err := config.ResolveRoot(root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch cmd {
|
|
case "config":
|
|
return cmdConfig(abs, source)
|
|
case "import":
|
|
return cmdImport(abs, args)
|
|
case "retag":
|
|
return cmdRetag(abs, args)
|
|
case "ls":
|
|
return cmdLs(abs, args)
|
|
case "report":
|
|
return cmdReport(abs, args)
|
|
case "accounts":
|
|
return cmdAccounts(abs, args)
|
|
case "parsers":
|
|
fmt.Println(strings.Join(parser.Names(), "\n"))
|
|
return nil
|
|
case "tui":
|
|
return cmdTUI(abs, args)
|
|
case "help", "-h", "--help":
|
|
fmt.Print(usage)
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("unknown command %q\n\n%s", cmd, usage)
|
|
}
|
|
}
|
|
|
|
// cmdConfig explains which data root is in use and where that came from,
|
|
// which is the first thing to check when the tool looks at the wrong place.
|
|
func cmdConfig(root string, source config.RootSource) error {
|
|
path, err := config.UserConfigPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
exists := "not present"
|
|
if _, err := os.Stat(path); err == nil {
|
|
exists = "present"
|
|
}
|
|
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintf(w, "data root\t%s\n", root)
|
|
fmt.Fprintf(w, "chosen by\t%s\n", source)
|
|
fmt.Fprintf(w, "config file\t%s (%s)\n", path, exists)
|
|
if _, err := os.Stat(root); err != nil {
|
|
fmt.Fprintf(w, "status\tdoes not exist yet\n")
|
|
} else {
|
|
fmt.Fprintf(w, "index\t%s\n", config.IndexPath(root))
|
|
}
|
|
w.Flush()
|
|
|
|
if source != config.RootFromConfig && exists == "not present" {
|
|
fmt.Printf("\nTo point money at a different directory, create %s with:\n\n root = %q\n",
|
|
path, root)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// opened is everything a command needs: the index, the account folders on
|
|
// disk, and the two engines rules.toml describes.
|
|
type opened struct {
|
|
db *store.DB
|
|
accounts []*config.Account
|
|
engine *rules.Engine
|
|
links *transfers.Engine
|
|
}
|
|
|
|
// open loads the config and index that every command needs.
|
|
func open(root string) (*opened, error) {
|
|
if _, err := os.Stat(root); err != nil {
|
|
return nil, fmt.Errorf("data root %s is not readable: %w", root, err)
|
|
}
|
|
accounts, err := config.LoadAccounts(root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r, err := config.LoadRules(root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
db, err := store.Open(config.IndexPath(root))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &opened{db: db, accounts: accounts, engine: rules.New(r), links: transfers.New(r)}, nil
|
|
}
|
|
|
|
func cmdImport(root string, args []string) error {
|
|
fs := flag.NewFlagSet("import", flag.ContinueOnError)
|
|
force := fs.Bool("force", false, "reimport statements whose contents have not changed")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
|
|
o, err := open(root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer o.db.Close()
|
|
|
|
if len(o.accounts) == 0 {
|
|
return fmt.Errorf("no accounts found in %s (an account is a folder containing %s)", root, config.AccountFile)
|
|
}
|
|
|
|
res, err := importer.Run(root, o.db, o.accounts, o.engine, o.links, importer.Options{Force: *force})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
for _, f := range res.Files {
|
|
if f.Err != nil {
|
|
continue
|
|
}
|
|
if f.Parsed == 0 {
|
|
continue // unchanged since last import
|
|
}
|
|
fmt.Fprintf(w, "%s\t%d new\t%d duplicate\t(%d parsed)\n", f.Path, f.New, f.Skipped, f.Parsed)
|
|
}
|
|
w.Flush()
|
|
|
|
for _, f := range res.Files {
|
|
for _, warning := range f.Warnings {
|
|
fmt.Fprintf(os.Stderr, "warning: %s: %s\n", f.Path, warning)
|
|
}
|
|
}
|
|
|
|
parsed, added, skipped := res.Total()
|
|
fmt.Printf("\n%d new, %d duplicate, %d parsed; %d rows retagged\n", added, skipped, parsed, res.Retagged)
|
|
reportPairing(res.Paired, res.Unpaired)
|
|
|
|
failures := res.Errs()
|
|
for _, f := range failures {
|
|
fmt.Fprintf(os.Stderr, "error: %s: %v\n", f.Path, f.Err)
|
|
}
|
|
if len(failures) > 0 {
|
|
return fmt.Errorf("%d of %d files failed", len(failures), len(res.Files))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// cmdRetag re-derives everything rules.toml decides: the tags and the transfer
|
|
// pairing. They are one command because they are one file, and leaving half of
|
|
// the derived state stale would be worse than not offering it at all.
|
|
func cmdRetag(root string, _ []string) error {
|
|
o, err := open(root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer o.db.Close()
|
|
|
|
n, err := o.engine.Retag(o.db)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%d transactions retagged\n", n)
|
|
|
|
paired, unpaired, err := o.links.Link(o.db)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
reportPairing(paired, unpaired)
|
|
return nil
|
|
}
|
|
|
|
// reportPairing prints the state of the transfer pairing. Unmatched legs are
|
|
// sent to stderr: a leg that never found its other side is money that left an
|
|
// account and cannot be shown to have arrived, which is a warning, not a
|
|
// statistic.
|
|
func reportPairing(paired, unpaired int) {
|
|
if paired == 0 && unpaired == 0 {
|
|
return
|
|
}
|
|
fmt.Printf("%d transfers matched\n", paired)
|
|
if unpaired > 0 {
|
|
fmt.Fprintf(os.Stderr, "warning: %d transfer leg(s) have no counterpart\n", unpaired)
|
|
}
|
|
}
|
|
|
|
func cmdLs(root string, args []string) error {
|
|
fs := flag.NewFlagSet("ls", flag.ContinueOnError)
|
|
account := fs.String("account", "", "only this account slug")
|
|
month := fs.String("month", "", "only this month (YYYY-MM)")
|
|
search := fs.String("search", "", "only descriptions containing this text")
|
|
untagged := fs.Bool("untagged", false, "only transactions no rule tagged and no transfer claimed")
|
|
limit := fs.Int("limit", 0, "maximum rows (0 = no limit)")
|
|
wide := fs.Bool("wide", false, "also show type and reported balance")
|
|
uniq := fs.Bool("uniq", false, "one row per distinct description")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
if *uniq && *wide {
|
|
// --wide's columns all belong to a single transaction, and nothing
|
|
// sensible can be printed for a whole group of them.
|
|
return fmt.Errorf("--uniq and --wide cannot be combined")
|
|
}
|
|
|
|
o, err := open(root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer o.db.Close()
|
|
|
|
f := store.Filter{
|
|
AccountSlug: *account,
|
|
Month: *month,
|
|
Search: *search,
|
|
Untagged: *untagged,
|
|
Limit: *limit,
|
|
}
|
|
// Deduplicating first would make a limit mean "the distinct descriptions
|
|
// among the newest N rows", which is not what it says. Under --uniq it caps
|
|
// what is printed instead.
|
|
if *uniq {
|
|
f.Limit = 0
|
|
}
|
|
txns, err := o.db.Transactions(f)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if *uniq {
|
|
return printUniq(os.Stdout, txns, *limit)
|
|
}
|
|
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
if *wide {
|
|
fmt.Fprintln(w, "DATE\tACCOUNT\tAMOUNT\tCUR\tBALANCE\tTAG\tTYPE\tDESCRIPTION")
|
|
} else {
|
|
fmt.Fprintln(w, "DATE\tACCOUNT\tAMOUNT\tCUR\tTAG\tDESCRIPTION")
|
|
}
|
|
for _, t := range txns {
|
|
if *wide {
|
|
balance := ""
|
|
if t.BalanceMinor != nil {
|
|
balance = model.FormatMinor(*t.BalanceMinor, t.MinorDigits)
|
|
}
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
|
t.Date, t.AccountSlug, t.FormatAmount(), t.Currency, balance,
|
|
t.DisplayTag(), t.Type, t.Description)
|
|
continue
|
|
}
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
|
|
t.Date, t.AccountSlug, t.FormatAmount(), t.Currency, t.DisplayTag(), t.Description)
|
|
}
|
|
w.Flush()
|
|
fmt.Printf("\n%d transactions\n", len(txns))
|
|
return nil
|
|
}
|
|
|
|
// printUniq lists each description once, which is the shape of the question
|
|
// "what still needs a rule?" — fifty visits to one shop are one pattern to
|
|
// write, not fifty rows to read.
|
|
//
|
|
// The account is deliberately not part of the key. A rule matches on the
|
|
// description and only optionally narrows to an account, so the same payee
|
|
// seen on two accounts is still one pattern; splitting the row per account
|
|
// would overstate the work left and disagree with the rule builder's list,
|
|
// which groups the same way. Use --account to scope the listing instead.
|
|
//
|
|
// Descriptions are printed as rules.Engine matches them: normalised, since that
|
|
// is the string a glob is actually tested against, so a pattern written from
|
|
// this list behaves the way the list reads. It also means two statements that
|
|
// differ only in spacing or case collapse to the one row they deserve.
|
|
func printUniq(out io.Writer, txns []model.Transaction, limit int) error {
|
|
seen := map[string]bool{}
|
|
var rows []string
|
|
for _, t := range txns {
|
|
desc := model.NormalizeDescription(t.Description)
|
|
if seen[desc] {
|
|
continue
|
|
}
|
|
seen[desc] = true
|
|
rows = append(rows, desc)
|
|
}
|
|
// Alphabetical, so the same payee under slightly different wordings lands
|
|
// on adjacent lines, where one glob covering both is easy to see.
|
|
sort.Strings(rows)
|
|
|
|
total := len(rows)
|
|
if limit > 0 && len(rows) > limit {
|
|
rows = rows[:limit]
|
|
}
|
|
|
|
w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(w, "DESCRIPTION")
|
|
for _, desc := range rows {
|
|
fmt.Fprintln(w, desc)
|
|
}
|
|
w.Flush()
|
|
|
|
if len(rows) < total {
|
|
fmt.Fprintf(out, "\n%d of %d distinct descriptions\n", len(rows), total)
|
|
return nil
|
|
}
|
|
fmt.Fprintf(out, "\n%d distinct descriptions\n", total)
|
|
return nil
|
|
}
|
|
|
|
func cmdReport(root string, args []string) error {
|
|
fs := flag.NewFlagSet("report", flag.ContinueOnError)
|
|
month := fs.String("month", "", "only this month (YYYY-MM)")
|
|
account := fs.String("account", "", "only this account slug")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
|
|
o, err := open(root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer o.db.Close()
|
|
|
|
txns, err := o.db.Transactions(store.Filter{Month: *month, AccountSlug: *account})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows := report.ByTag(txns)
|
|
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(w, "TAG\tCUR\tOUT\tIN\tNET\tN")
|
|
for _, r := range rows {
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%d\n", r.Tag, r.Currency,
|
|
model.FormatMinor(r.Out, r.Digits), model.FormatMinor(r.In, r.Digits),
|
|
model.FormatMinor(r.Net(), r.Digits), r.Count)
|
|
}
|
|
for _, c := range report.Totals(rows) {
|
|
fmt.Fprintf(w, "TOTAL\t%s\t%s\t%s\t%s\t\n", c.Currency,
|
|
model.FormatMinor(c.Out, c.Digits), model.FormatMinor(c.In, c.Digits),
|
|
model.FormatMinor(c.Net(), c.Digits))
|
|
}
|
|
w.Flush()
|
|
|
|
// Say what was held out, or the report silently disagrees with the account
|
|
// balances by exactly the amount moved between accounts. Both directions
|
|
// are named: they came off the OUT and IN columns respectively, and an
|
|
// exchange puts only one of them in this currency.
|
|
for _, x := range report.Excluded(txns) {
|
|
line := fmt.Sprintf("\ntransfers excluded: %d legs in %s, %s out, %s in",
|
|
x.Legs, x.Currency,
|
|
model.FormatMinor(x.Out, x.Digits), model.FormatMinor(x.In, x.Digits))
|
|
// A fee went out with the pair that carried it, so unless it is named
|
|
// here it is money that left the report without ever being spent.
|
|
if x.Fee != 0 {
|
|
line += fmt.Sprintf(", %s in fees", model.FormatMinor(x.Fee, x.Digits))
|
|
}
|
|
fmt.Println(line)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func cmdAccounts(root string, _ []string) error {
|
|
o, err := open(root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer o.db.Close()
|
|
|
|
accounts, err := o.db.Accounts()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(w, "SLUG\tNAME\tBALANCE\tCUR\tTXNS")
|
|
for _, a := range accounts {
|
|
bal, err := o.db.Balance(a.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, err := o.db.Count(a.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\n", a.Slug, a.Name,
|
|
model.FormatMinor(bal, a.MinorDigits), a.Currency, n)
|
|
}
|
|
return w.Flush()
|
|
}
|
|
|
|
func cmdTUI(root string, _ []string) error {
|
|
o, err := open(root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer o.db.Close()
|
|
return tui.Run(root, o.db, o.accounts, o.engine, o.links)
|
|
}
|