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>
100 lines
2.6 KiB
Go
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), " "))
|
|
}
|