Add money: statement-driven personal finance tracker

A data directory holds one folder per account. Statements dropped into
those folders are parsed into a rebuildable SQLite index, categorised by
ordered glob rules in rules.toml, and browsed or hand-tagged in a Bubble
Tea TUI. Movements between the user's own accounts are marked as
transfers by the same rules and excluded from spending totals.

Manual tags and transfer marks are stored separately from the rule-derived
ones and always win, so editing rules.toml and re-running retag never
destroys hand edits.

Parsers are pluggable. Three are ported from the Python extractors they
replace -- nlb and traderepublic read PDFs via pdftotext -layout, revolut
reads the CSV export -- alongside a configurable-column CSV parser and a
cmd parser that shells out to an external script.

Both ports fix two latent bugs in the originals: the sign character class
rejected the typographic minus U+2212 that some PDF fonts emit, and NLB's
hardcoded continuation indent broke when pdftotext compressed runs of
spaces, so the threshold is now measured from the description column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 00:37:20 +02:00
co-authored by Claude Opus 5
commit b0026c5a79
30 changed files with 5048 additions and 0 deletions
+541
View File
@@ -0,0 +1,541 @@
// Package tui is the terminal interface: browse accounts, tag transactions,
// mark transfers by hand, and see where the money went.
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"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/report"
"git.petrovv.com/nikola/money/internal/rules"
"git.petrovv.com/nikola/money/internal/store"
)
type view int
const (
viewAccounts view = iota
viewTxns
viewReport
)
// input is the modal state: the transaction list is read-only until the user
// starts typing a tag or a search.
type input int
const (
inputNone input = iota
inputTag
inputSearch
)
// Model is the root Bubble Tea model.
type Model struct {
root string
db *store.DB
accounts []*config.Account
engine *rules.Engine
view view
input input
accountTable table.Model
txnTable table.Model
reportTable table.Model
text textinput.Model
txns []model.Transaction // rows currently shown in txnTable
filter store.Filter
onlyUntagged bool
status string
err error
width int
height int
}
var (
titleStyle = lipgloss.NewStyle().Bold(true).Padding(0, 1)
statusStyle = lipgloss.NewStyle().Faint(true).Padding(0, 1)
errStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9")).Padding(0, 1)
helpStyle = lipgloss.NewStyle().Faint(true).Padding(0, 1)
)
// Run starts the interface.
func Run(root string, db *store.DB, accounts []*config.Account, engine *rules.Engine) error {
m := New(root, db, accounts, engine)
if err := m.reload(); err != nil {
return err
}
_, err := tea.NewProgram(m, tea.WithAltScreen()).Run()
return err
}
// New builds the root model.
func New(root string, db *store.DB, accounts []*config.Account, engine *rules.Engine) *Model {
ti := textinput.New()
ti.Prompt = ""
ti.CharLimit = 64
styles := table.DefaultStyles()
styles.Header = styles.Header.Bold(true)
styles.Selected = styles.Selected.Bold(true).Foreground(lipgloss.Color("15")).Background(lipgloss.Color("62"))
newTable := func(cols []table.Column) table.Model {
return table.New(table.WithColumns(cols), table.WithFocused(true),
table.WithHeight(15), table.WithStyles(styles))
}
return &Model{
root: root,
db: db,
accounts: accounts,
engine: engine,
view: viewAccounts,
text: ti,
accountTable: newTable([]table.Column{
{Title: "Account", Width: 20},
{Title: "Balance", Width: 14},
{Title: "Cur", Width: 4},
{Title: "Txns", Width: 6},
}),
txnTable: newTable([]table.Column{
{Title: "Date", Width: 10},
{Title: "Account", Width: 12},
{Title: "Amount", Width: 12},
{Title: "Tag", Width: 14},
{Title: "T", Width: 1},
{Title: "Description", Width: 40},
}),
reportTable: newTable([]table.Column{
{Title: "Tag", Width: 20},
{Title: "Cur", Width: 4},
{Title: "Out", Width: 14},
{Title: "In", Width: 14},
{Title: "Net", Width: 14},
{Title: "N", Width: 5},
}),
}
}
// Init implements tea.Model.
func (m *Model) Init() tea.Cmd { return nil }
// reload refreshes whichever view is active from the index.
func (m *Model) reload() error {
if err := m.reloadAccounts(); err != nil {
return err
}
return m.reloadTxns()
}
func (m *Model) reloadAccounts() error {
accounts, err := m.db.Accounts()
if err != nil {
return err
}
rows := make([]table.Row, 0, len(accounts))
for _, a := range accounts {
bal, err := m.db.Balance(a.ID)
if err != nil {
return err
}
n, err := m.db.Count(a.ID)
if err != nil {
return err
}
rows = append(rows, table.Row{
a.Name, model.FormatMinor(bal, a.MinorDigits), a.Currency, fmt.Sprintf("%d", n),
})
}
m.accountTable.SetRows(rows)
return nil
}
func (m *Model) reloadTxns() error {
f := m.filter
f.Untagged = m.onlyUntagged
txns, err := m.db.Transactions(f)
if err != nil {
return err
}
m.txns = txns
rows := make([]table.Row, 0, len(txns))
for _, t := range txns {
transfer := ""
if t.IsTransfer() {
transfer = "T"
}
tag := t.Tag()
if tag != "" && t.ManualTag != "" {
tag += "*" // marks a manual override
}
rows = append(rows, table.Row{
t.Date, t.AccountSlug, t.FormatAmount(), tag, transfer, t.Description,
})
}
// Keep the cursor in range after the row count shrinks (e.g. tagging the
// last untagged row while the untagged filter is on).
cursor := m.txnTable.Cursor()
m.txnTable.SetRows(rows)
if cursor >= len(rows) {
cursor = len(rows) - 1
}
if cursor < 0 {
cursor = 0
}
m.txnTable.SetCursor(cursor)
m.reloadReport(txns)
return nil
}
func (m *Model) reloadReport(txns []model.Transaction) {
rows := report.ByTag(txns)
out := make([]table.Row, 0, len(rows)+2)
for _, r := range rows {
out = append(out, table.Row{
r.Tag, r.Currency,
model.FormatMinor(r.Out, r.Digits),
model.FormatMinor(r.In, r.Digits),
model.FormatMinor(r.Net(), r.Digits),
fmt.Sprintf("%d", r.Count),
})
}
for _, c := range report.Totals(rows) {
out = append(out, table.Row{
"TOTAL", c.Currency,
model.FormatMinor(c.Out, c.Digits),
model.FormatMinor(c.In, c.Digits),
model.FormatMinor(c.Net(), c.Digits),
"",
})
}
m.reportTable.SetRows(out)
}
// selected returns the transaction under the cursor, if any.
func (m *Model) selected() (model.Transaction, bool) {
i := m.txnTable.Cursor()
if i < 0 || i >= len(m.txns) {
return model.Transaction{}, false
}
return m.txns[i], true
}
// Update implements tea.Model.
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
m.resize()
return m, nil
case tea.KeyMsg:
if m.input != inputNone {
return m.updateInput(msg)
}
return m.updateNormal(msg)
}
return m, nil
}
func (m *Model) resize() {
h := m.height - 6 // title, status, help, padding
if h < 3 {
h = 3
}
m.accountTable.SetHeight(h)
m.txnTable.SetHeight(h)
m.reportTable.SetHeight(h)
// Give the description column whatever is left over.
if m.width > 0 {
cols := m.txnTable.Columns()
fixed := 0
for _, c := range cols[:len(cols)-1] {
fixed += c.Width + 2
}
desc := m.width - fixed - 4
if desc < 20 {
desc = 20
}
cols[len(cols)-1].Width = desc
m.txnTable.SetColumns(cols)
}
}
// updateInput handles typing into the tag or search prompt.
func (m *Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyEsc:
m.input = inputNone
m.text.Blur()
m.text.SetValue("")
return m, nil
case tea.KeyEnter:
value := strings.TrimSpace(m.text.Value())
mode := m.input
m.input = inputNone
m.text.Blur()
m.text.SetValue("")
switch mode {
case inputTag:
t, ok := m.selected()
if !ok {
return m, nil
}
if err := m.db.SetManualTag(t.ID, value); err != nil {
m.err = err
return m, nil
}
if value == "" {
m.status = "cleared manual tag"
} else {
m.status = "tagged as " + value
}
case inputSearch:
m.filter.Search = value
m.status = "search: " + value
if value == "" {
m.status = "search cleared"
}
}
m.err = m.reloadTxns()
return m, nil
}
var cmd tea.Cmd
m.text, cmd = m.text.Update(msg)
return m, cmd
}
// updateNormal handles the main keymap.
func (m *Model) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "1":
m.view = viewAccounts
return m, nil
case "2":
m.view = viewTxns
return m, nil
case "3":
m.view = viewReport
return m, nil
case "tab":
m.view = (m.view + 1) % 3
return m, nil
case "enter":
// From the account list, drill into that account's transactions.
if m.view == viewAccounts {
accounts, err := m.db.Accounts()
if err != nil {
m.err = err
return m, nil
}
i := m.accountTable.Cursor()
if i >= 0 && i < len(accounts) {
m.filter.AccountSlug = accounts[i].Slug
m.status = "account: " + accounts[i].Slug
m.view = viewTxns
m.err = m.reloadTxns()
}
}
return m, nil
case "a":
m.filter.AccountSlug = ""
m.status = "all accounts"
m.err = m.reloadTxns()
return m, nil
case "u":
m.onlyUntagged = !m.onlyUntagged
if m.onlyUntagged {
m.status = "showing untagged only"
m.view = viewTxns
} else {
m.status = "showing all transactions"
}
m.err = m.reloadTxns()
return m, nil
case "t":
if m.view != viewTxns {
return m, nil
}
t, ok := m.selected()
if !ok {
return m, nil
}
m.input = inputTag
m.text.SetValue(t.ManualTag)
m.text.CursorEnd()
m.text.Focus()
return m, nil
case "/":
m.view = viewTxns
m.input = inputSearch
m.text.SetValue(m.filter.Search)
m.text.CursorEnd()
m.text.Focus()
return m, nil
case "x":
// Toggle the manual transfer override away from whatever is in effect.
if m.view != viewTxns {
return m, nil
}
t, ok := m.selected()
if !ok {
return m, nil
}
v := !t.IsTransfer()
if err := m.db.SetManualTransfer(t.ID, &v); err != nil {
m.err = err
return m, nil
}
if v {
m.status = "marked as transfer"
} else {
m.status = "unmarked as transfer"
}
m.err = m.reloadTxns()
return m, nil
case "c":
// Drop manual overrides and fall back to what the rules say.
if m.view != viewTxns {
return m, nil
}
t, ok := m.selected()
if !ok {
return m, nil
}
if err := m.db.ClearOverrides(t.ID); err != nil {
m.err = err
return m, nil
}
m.status = "manual overrides cleared"
m.err = m.reloadTxns()
return m, nil
case "r":
n, err := m.engine.Retag(m.db)
if err != nil {
m.err = err
return m, nil
}
m.status = fmt.Sprintf("rules re-applied, %d rows changed", n)
m.err = m.reload()
return m, nil
case "i":
res, err := importer.Run(m.root, m.db, m.accounts, m.engine, importer.Options{})
if err != nil {
m.err = err
return m, nil
}
_, added, skipped := res.Total()
m.status = fmt.Sprintf("imported: %d new, %d duplicate", added, skipped)
if failures := res.Errs(); len(failures) > 0 {
m.err = fmt.Errorf("%s: %w", failures[0].Path, failures[0].Err)
}
m.err = m.reload()
return m, nil
}
var cmd tea.Cmd
switch m.view {
case viewAccounts:
m.accountTable, cmd = m.accountTable.Update(msg)
case viewTxns:
m.txnTable, cmd = m.txnTable.Update(msg)
case viewReport:
m.reportTable, cmd = m.reportTable.Update(msg)
}
return m, cmd
}
// View implements tea.Model.
func (m *Model) View() string {
var b strings.Builder
b.WriteString(titleStyle.Render(m.title()) + "\n")
switch m.view {
case viewAccounts:
b.WriteString(m.accountTable.View())
case viewTxns:
b.WriteString(m.txnTable.View())
case viewReport:
b.WriteString(m.reportTable.View())
}
b.WriteString("\n")
switch {
case m.input == inputTag:
b.WriteString(statusStyle.Render("tag: ") + m.text.View())
case m.input == inputSearch:
b.WriteString(statusStyle.Render("search: ") + m.text.View())
case m.err != nil:
b.WriteString(errStyle.Render("error: " + m.err.Error()))
default:
b.WriteString(statusStyle.Render(m.status))
}
b.WriteString("\n")
b.WriteString(helpStyle.Render(m.help()))
return b.String()
}
func (m *Model) title() string {
scope := "all accounts"
if m.filter.AccountSlug != "" {
scope = m.filter.AccountSlug
}
if m.onlyUntagged {
scope += " · untagged"
}
if m.filter.Search != "" {
scope += " · /" + m.filter.Search
}
switch m.view {
case viewAccounts:
return "money · accounts"
case viewReport:
return "money · report · " + scope + " · transfers excluded"
default:
return fmt.Sprintf("money · transactions · %s · %d rows", scope, len(m.txns))
}
}
func (m *Model) help() string {
if m.input != inputNone {
return "enter confirm · esc cancel"
}
switch m.view {
case viewAccounts:
return "enter open · 2 transactions · 3 report · i import · r retag · q quit"
case viewReport:
return "1 accounts · 2 transactions · u untagged · a all accounts · q quit"
default:
return "t tag · x transfer · c clear · / search · u untagged · a all · i import · r retag · 1 accounts · 3 report · q quit"
}
}
+273
View File
@@ -0,0 +1,273 @@
package tui
import (
"path/filepath"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"git.petrovv.com/nikola/money/internal/config"
"git.petrovv.com/nikola/money/internal/model"
"git.petrovv.com/nikola/money/internal/rules"
"git.petrovv.com/nikola/money/internal/store"
)
// newTestModel builds a model over an index holding two transactions, one of
// which the rules tag as groceries.
func newTestModel(t *testing.T) (*Model, *store.DB) {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "index.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
accountID, err := db.UpsertAccount(model.Account{
Slug: "checking", Name: "Checking", Currency: "EUR", MinorDigits: 2,
})
if err != nil {
t.Fatal(err)
}
sourceID, err := db.SourceFile(accountID, "checking/st.csv", "sha", "2026-01-01T00:00:00Z")
if err != nil {
t.Fatal(err)
}
for i, d := range []string{"LIDL SOFIA", "SOME RANDOM SHOP"} {
if _, err := db.InsertTransaction(model.Transaction{
AccountID: accountID,
SourceFileID: sourceID,
Fingerprint: d,
Date: "2026-01-0" + string(rune('1'+i)),
Description: d,
AmountMinor: -1000,
}); err != nil {
t.Fatal(err)
}
}
engine := rules.New(&config.Rules{Rule: []config.Rule{{Match: "*LIDL*", Tag: "groceries"}}})
if _, err := engine.Retag(db); err != nil {
t.Fatal(err)
}
m := New(t.TempDir(), db, nil, engine)
if err := m.reload(); err != nil {
t.Fatal(err)
}
m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
return m, db
}
// key sends a single keypress to the model.
func key(t *testing.T, m *Model, s string) {
t.Helper()
var msg tea.KeyMsg
switch s {
case "enter":
msg = tea.KeyMsg{Type: tea.KeyEnter}
case "esc":
msg = tea.KeyMsg{Type: tea.KeyEsc}
default:
msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
}
m.Update(msg)
if m.err != nil {
t.Fatalf("key %q produced an error: %v", s, m.err)
}
}
// typeText sends each character as its own keypress, as a terminal would.
func typeText(t *testing.T, m *Model, s string) {
t.Helper()
for _, r := range s {
m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
}
}
// cursorTo moves the transaction cursor onto the row with the given description.
func cursorTo(t *testing.T, m *Model, desc string) {
t.Helper()
for i, txn := range m.txns {
if txn.Description == desc {
m.txnTable.SetCursor(i)
return
}
}
t.Fatalf("no visible transaction with description %q", desc)
}
func TestTagFlow(t *testing.T) {
m, db := newTestModel(t)
key(t, m, "2") // transactions view
cursorTo(t, m, "SOME RANDOM SHOP")
key(t, m, "t") // open the tag prompt
if m.input != inputTag {
t.Fatal("expected the tag prompt to open")
}
typeText(t, m, "hobby")
key(t, m, "enter")
if m.input != inputNone {
t.Error("expected the prompt to close after enter")
}
txns, err := db.Transactions(store.Filter{})
if err != nil {
t.Fatal(err)
}
var found bool
for _, txn := range txns {
if txn.Description == "SOME RANDOM SHOP" {
found = true
if txn.ManualTag != "hobby" {
t.Errorf("manual_tag = %q, want %q", txn.ManualTag, "hobby")
}
}
}
if !found {
t.Fatal("transaction disappeared after tagging")
}
// The tag column marks manual overrides with a trailing asterisk.
if !strings.Contains(m.View(), "hobby*") {
t.Error("expected the tagged row to render with a manual-override marker")
}
}
func TestTagPromptCancel(t *testing.T) {
m, db := newTestModel(t)
key(t, m, "2")
cursorTo(t, m, "SOME RANDOM SHOP")
key(t, m, "t")
typeText(t, m, "nope")
key(t, m, "esc")
if m.input != inputNone {
t.Error("expected esc to close the prompt")
}
txns, _ := db.Transactions(store.Filter{Untagged: true})
if len(txns) != 1 {
t.Errorf("esc should not have tagged anything; untagged count = %d, want 1", len(txns))
}
}
func TestTransferToggleAndClear(t *testing.T) {
m, db := newTestModel(t)
key(t, m, "2")
cursorTo(t, m, "LIDL SOFIA")
key(t, m, "x")
txn := findTxn(t, db, "LIDL SOFIA")
if !txn.IsTransfer() {
t.Error("expected x to mark the row as a transfer")
}
key(t, m, "x")
if findTxn(t, db, "LIDL SOFIA").IsTransfer() {
t.Error("expected a second x to unmark it")
}
// c drops the manual override, restoring the rule's verdict.
key(t, m, "c")
txn = findTxn(t, db, "LIDL SOFIA")
if txn.ManualTransfer != nil || txn.ManualTag != "" {
t.Errorf("expected c to clear overrides, got manual tag %q transfer %v", txn.ManualTag, txn.ManualTransfer)
}
if txn.Tag() != "groceries" {
t.Errorf("tag after clearing = %q, want groceries from the rule", txn.Tag())
}
}
func TestUntaggedToggle(t *testing.T) {
m, _ := newTestModel(t)
key(t, m, "u")
if !m.onlyUntagged {
t.Fatal("expected u to enable the untagged filter")
}
if len(m.txns) != 1 || m.txns[0].Description != "SOME RANDOM SHOP" {
t.Fatalf("untagged view shows %+v, want only SOME RANDOM SHOP", m.txns)
}
// Tagging the last untagged row empties the view; the cursor must stay valid.
cursorTo(t, m, "SOME RANDOM SHOP")
key(t, m, "t")
typeText(t, m, "hobby")
key(t, m, "enter")
if len(m.txns) != 0 {
t.Errorf("expected the untagged view to be empty, got %d rows", len(m.txns))
}
if _, ok := m.selected(); ok {
t.Error("expected no selection in an empty view")
}
m.View() // must not panic on an empty table
key(t, m, "u")
if len(m.txns) != 2 {
t.Errorf("expected all rows back after toggling off, got %d", len(m.txns))
}
}
func TestSearchFilter(t *testing.T) {
m, _ := newTestModel(t)
key(t, m, "/")
if m.input != inputSearch {
t.Fatal("expected the search prompt to open")
}
typeText(t, m, "lidl")
key(t, m, "enter")
if len(m.txns) != 1 || m.txns[0].Description != "LIDL SOFIA" {
t.Fatalf("search results = %+v, want only LIDL SOFIA", m.txns)
}
// Reopening the prompt pre-fills the active query so it can be edited.
key(t, m, "/")
if got := m.text.Value(); got != "lidl" {
t.Errorf("search prompt pre-filled with %q, want %q", got, "lidl")
}
for range len("lidl") {
m.Update(tea.KeyMsg{Type: tea.KeyBackspace})
}
key(t, m, "enter") // an emptied query clears the filter
if len(m.txns) != 2 {
t.Errorf("expected the filter to clear, got %d rows", len(m.txns))
}
}
func TestReportViewExcludesTransfers(t *testing.T) {
m, _ := newTestModel(t)
key(t, m, "2")
cursorTo(t, m, "LIDL SOFIA")
key(t, m, "x") // mark as a transfer
key(t, m, "3") // report view
view := m.View()
if strings.Contains(view, "groceries") {
t.Error("a transfer must not appear in the report")
}
if !strings.Contains(view, "TOTAL") {
t.Error("expected a TOTAL row in the report")
}
}
func findTxn(t *testing.T, db *store.DB, desc string) model.Transaction {
t.Helper()
txns, err := db.Transactions(store.Filter{})
if err != nil {
t.Fatal(err)
}
for _, txn := range txns {
if txn.Description == desc {
return txn
}
}
t.Fatalf("no transaction with description %q", desc)
return model.Transaction{}
}