Files
money/cmd/money/main.go
T
nikolaandClaude Opus 5.5 ec8a844441 Remove the TUI; the web app is the frontend
money serve covers every screen the TUI had, so keeping both meant every
behaviour change landing twice. internal/tui goes, and with it bubbletea,
bubbles and lipgloss. `money` with no command now runs serve, the way it
used to open the TUI, and `money tui` is an unknown command.

Three invariants in CLAUDE.md were covered only by TUI tests. Two already
had web counterparts; TestPairedLegsAreNotUntagged is ported to the API:
a paired leg is neither listed as untagged nor offered to the rule
builder, while an unpaired one still is.

README's screen sections now describe the browser, which kept the
behaviour and changed only the controls. CLAUDE.md names the web
equivalents of the TUI functions its invariants pointed at, drops the tab
completion rule that only bubbles' textinput needed, and says how to test
the web app instead of how to drive a terminal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 18:51:54 +02:00

558 lines
16 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 serves the web app.
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"os/signal"
"sort"
"strings"
"syscall"
"text/tabwriter"
"time"
"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/web"
)
const usage = `money - statement-driven personal finance tracker
usage: money [--root DIR] <command> [flags] (--root may also follow the command)
commands:
serve browse transactions, build the rules that tag them and the
transfers that pair them across accounts, in a browser
(--addr, default 127.0.0.1:8080; the default command)
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 := "serve"
if len(args) > 0 {
cmd, args = args[0], args[1:]
}
// `money serve --root DIR` is too natural to reject, so --root is also
// accepted after the subcommand; each subcommand's own flag set never
// sees it.
after, args, err := takeRoot(args)
if err != nil {
return err
}
if after != "" {
root = after
}
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 "serve":
return cmdServe(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)
fmt.Fprintf(w, "pdftotext\t%s\n", parser.PdftotextSource())
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")
sortBy := fs.String("sort", report.OrderOut.String(), "order rows by: out, in, net, count, tag")
if err := fs.Parse(args); err != nil {
return err
}
order, err := report.ParseOrder(*sortBy)
if 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, order)
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()
}
// takeRoot removes --root/-root (as `--root DIR` or `--root=DIR`) from a
// subcommand's arguments, returning the last value given. It stops at `--`,
// after which everything is positional.
func takeRoot(args []string) (root string, rest []string, err error) {
rest = make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
a := args[i]
if a == "--" {
return root, append(rest, args[i:]...), nil
}
if !strings.HasPrefix(a, "-") {
rest = append(rest, a)
continue
}
name, inline, hasInline := strings.Cut(strings.TrimLeft(a, "-"), "=")
if name != "root" {
rest = append(rest, a)
continue
}
if hasInline {
root = inline
continue
}
if i+1 >= len(args) {
return "", nil, fmt.Errorf("--root needs a directory")
}
root = args[i+1]
i++
}
return root, rest, nil
}
// cmdServe serves the web app until interrupted. There is no
// authentication, so it listens on loopback unless told otherwise: anyone who
// can reach the port can rewrite rules.toml and start an import.
func cmdServe(root string, args []string) error {
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
addr := fs.String("addr", "127.0.0.1:8080", "address to listen on")
if err := fs.Parse(args); err != nil {
return err
}
o, err := open(root)
if err != nil {
return err
}
defer o.db.Close()
srv := &http.Server{
Addr: *addr,
Handler: web.New(root, o.db, o.accounts, o.engine, o.links).Handler(),
ReadHeaderTimeout: 10 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
<-ctx.Done()
// Let an import in flight finish writing the index before it closes.
shutdown, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
srv.Shutdown(shutdown)
}()
fmt.Printf("serving %s on http://%s\n", root, *addr)
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}