Running the tool from anywhere required either a --root flag or an exported MONEY_ROOT. Read the data root from $XDG_CONFIG_HOME/money/config.toml (~/.config/money/config.toml) instead, so it can be configured once. Resolution order is --root, then $MONEY_ROOT, then the config file, then ~/money. A leading tilde in the config file is expanded, since no shell has done it for us, and relative paths are made absolute. The new "money config" subcommand prints the resolved root and which rule chose it, which is the first thing to check when the tool reads the wrong directory. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
359 lines
9.6 KiB
Go
359 lines
9.6 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"
|
|
"os"
|
|
"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/tui"
|
|
)
|
|
|
|
const usage = `money - statement-driven personal finance tracker
|
|
|
|
usage: money [--root DIR] <command> [flags]
|
|
|
|
commands:
|
|
tui browse and tag transactions (default)
|
|
import extract transactions from every statement into the index
|
|
retag re-apply rules.toml to everything already imported
|
|
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
|
|
}
|
|
|
|
// open loads the config and index that every command needs.
|
|
func open(root string) (*store.DB, []*config.Account, *rules.Engine, error) {
|
|
if _, err := os.Stat(root); err != nil {
|
|
return nil, nil, nil, fmt.Errorf("data root %s is not readable: %w", root, err)
|
|
}
|
|
accounts, err := config.LoadAccounts(root)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
r, err := config.LoadRules(root)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
db, err := store.Open(config.IndexPath(root))
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
return db, accounts, rules.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
|
|
}
|
|
|
|
db, accounts, engine, err := open(root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
|
|
if len(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, db, accounts, engine, 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)
|
|
|
|
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
|
|
}
|
|
|
|
func cmdRetag(root string, _ []string) error {
|
|
db, _, engine, err := open(root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
|
|
n, err := engine.Retag(db)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%d transactions retagged\n", n)
|
|
return nil
|
|
}
|
|
|
|
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 with no effective tag")
|
|
limit := fs.Int("limit", 0, "maximum rows (0 = no limit)")
|
|
wide := fs.Bool("wide", false, "also show type, counterparty and reported balance")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
|
|
db, _, _, err := open(root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
|
|
txns, err := db.Transactions(store.Filter{
|
|
AccountSlug: *account,
|
|
Month: *month,
|
|
Search: *search,
|
|
Untagged: *untagged,
|
|
Limit: *limit,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
if *wide {
|
|
fmt.Fprintln(w, "DATE\tACCOUNT\tAMOUNT\tCUR\tBALANCE\tTAG\tT\tTYPE\tCOUNTERPARTY\tDESCRIPTION")
|
|
} else {
|
|
fmt.Fprintln(w, "DATE\tACCOUNT\tAMOUNT\tCUR\tTAG\tT\tDESCRIPTION")
|
|
}
|
|
for _, t := range txns {
|
|
transfer := ""
|
|
if t.IsTransfer() {
|
|
transfer = "T"
|
|
}
|
|
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\t%s\t%s\n",
|
|
t.Date, t.AccountSlug, t.FormatAmount(), t.Currency, balance,
|
|
t.Tag(), transfer, t.Type, t.Counterparty, t.Description)
|
|
continue
|
|
}
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
|
t.Date, t.AccountSlug, t.FormatAmount(), t.Currency, t.Tag(), transfer, t.Description)
|
|
}
|
|
w.Flush()
|
|
fmt.Printf("\n%d transactions\n", len(txns))
|
|
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
|
|
}
|
|
|
|
db, _, _, err := open(root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
|
|
txns, err := 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()
|
|
fmt.Println("\ntransfers between own accounts are excluded")
|
|
return nil
|
|
}
|
|
|
|
func cmdAccounts(root string, _ []string) error {
|
|
db, _, _, err := open(root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
|
|
accounts, err := 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 := db.Balance(a.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, err := 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 {
|
|
db, accounts, engine, err := open(root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
return tui.Run(root, db, accounts, engine)
|
|
}
|