Files
money/internal/model/model.go
T
nikolaandClaude Opus 5 b0026c5a79 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>
2026-08-09 00:37:20 +02:00

100 lines
2.6 KiB
Go

// Package model holds the core domain types shared by every other package.
package model
import (
"fmt"
"strings"
)
// Account is one folder in the data root.
type Account struct {
ID int64
Slug string // folder name, unique
Name string // human readable, from account.toml
Currency string
MinorDigits int
}
// Transaction is a single line extracted from a statement, plus whatever the
// rules engine and the user have said about it.
//
// Amount is stored in minor units (cents) and signed: negative is an outflow.
type Transaction struct {
ID int64
AccountID int64
AccountSlug string
Currency string
MinorDigits int
Fingerprint string
Date string // YYYY-MM-DD
Description string
AmountMinor int64
SourceFileID int64
SourcePath string
// Counterparty is the other side's account number (an IBAN, where the
// statement gives one). Transfers between the user's own accounts are
// often only distinguishable by it.
Counterparty string
// Type is the bank's own classification, e.g. Revolut's CARD_PAYMENT.
Type string
// BalanceMinor is the running balance the statement reported after this
// transaction, when it reports one. Used to verify the balance chain.
BalanceMinor *int64
RuleTag string
ManualTag string
RuleTransfer bool
ManualTransfer *bool
}
// Tag is the effective category: a manual tag always beats a rule tag.
func (t Transaction) Tag() string {
if t.ManualTag != "" {
return t.ManualTag
}
return t.RuleTag
}
// IsTransfer reports whether this line moves money between the user's own
// accounts and so must not count as spending. A manual override wins.
func (t Transaction) IsTransfer() bool {
if t.ManualTransfer != nil {
return *t.ManualTransfer
}
return t.RuleTransfer
}
// FormatAmount renders the amount using the account's minor-unit scale.
func (t Transaction) FormatAmount() string {
return FormatMinor(t.AmountMinor, t.MinorDigits)
}
// FormatMinor renders minor units as a decimal string, e.g. -4520/2 -> "-45.20".
func FormatMinor(minor int64, digits int) string {
if digits <= 0 {
return fmt.Sprintf("%d", minor)
}
neg := minor < 0
if neg {
minor = -minor
}
scale := int64(1)
for i := 0; i < digits; i++ {
scale *= 10
}
whole, frac := minor/scale, minor%scale
s := fmt.Sprintf("%d.%0*d", whole, digits, frac)
if neg {
s = "-" + s
}
return s
}
// NormalizeDescription collapses whitespace and upper-cases, so that
// fingerprints and glob matching are insensitive to statement formatting noise.
func NormalizeDescription(s string) string {
return strings.ToUpper(strings.Join(strings.Fields(s), " "))
}