This commit is contained in:
Lars Nolden
2026-09-10 12:30:42 +02:00
commit 9843fe0c50
79 changed files with 16318 additions and 0 deletions
+415
View File
@@ -0,0 +1,415 @@
package domain
import (
"crypto/rand"
"encoding/hex"
"fmt"
"math"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
)
var idPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]{0,127}$`)
var currencyPattern = regexp.MustCompile(`^[A-Z]{3}$`)
// ParseMoney accepts exact decimal values representable as signed 64-bit ten-thousandths.
// This intentionally bounds the otherwise larger DECIMAL(24,4) database domain.
func ParseMoney(s string) (Money, error) {
n, err := parseMinor(s)
if err != nil {
return "", err
}
return Money(formatMinor(n)), nil
}
func parseMinor(s string) (int64, error) {
invalid := func() (int64, error) {
return 0, fmt.Errorf("invalid or out-of-range money %q: require signed 64-bit ten-thousandths, at most four fractional digits", s)
}
if s == "" {
return invalid()
}
start := 0
negative := s[0] == '-'
if negative {
start = 1
}
if start == len(s) {
return invalid()
}
if s[start] < '0' || s[start] > '9' {
return invalid()
}
if s[start] == '0' && start+1 < len(s) && s[start+1] != '.' {
return invalid()
}
limit := uint64(math.MaxInt64)
if negative {
limit++
}
magnitude := uint64(0)
fraction := -1
for i := start; i < len(s); i++ {
c := s[i]
if c == '.' {
if fraction >= 0 || i == len(s)-1 {
return invalid()
}
fraction = 0
continue
}
if c < '0' || c > '9' {
return invalid()
}
if fraction >= 0 {
fraction++
if fraction > 4 {
return invalid()
}
}
digit := uint64(c - '0')
if magnitude > (limit-digit)/10 {
return invalid()
}
magnitude = magnitude*10 + digit
}
if fraction < 0 {
fraction = 0
}
for range 4 - fraction {
if magnitude > limit/10 {
return invalid()
}
magnitude *= 10
}
if negative {
if magnitude == uint64(math.MaxInt64)+1 {
return math.MinInt64, nil
}
return -int64(magnitude), nil
}
return int64(magnitude), nil
}
func formatMinor(n int64) string {
s := strconv.FormatInt(n, 10)
sign := ""
if strings.HasPrefix(s, "-") {
sign, s = "-", s[1:]
}
if len(s) < 5 {
s = strings.Repeat("0", 5-len(s)) + s
}
whole, fraction := s[:len(s)-4], strings.TrimRight(s[len(s)-4:], "0")
if len(fraction) < 2 {
fraction += strings.Repeat("0", 2-len(fraction))
}
return sign + whole + "." + fraction
}
func (m Money) Minor() (int64, error) { return parseMinor(string(m)) }
func (m Money) String() string {
parsed, err := ParseMoney(string(m))
if err != nil {
return string(m)
}
return string(parsed)
}
func NewID(prefix string) string {
if !idPattern.MatchString(prefix) || len(prefix) > 94 {
panic("invalid ID prefix")
}
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
panic(fmt.Errorf("secure identifier generation: %w", err))
}
return prefix + "_" + hex.EncodeToString(b[:])
}
func NewDataset() Dataset {
return Dataset{Accounts: []Account{}, Categories: []Category{
{ID: "cat_expenses", Name: "Expenses", Kind: "expense"}, {ID: ExpenseFallback, Name: "Unclassified", ParentID: "cat_expenses", Kind: "expense"},
{ID: "cat_income", Name: "Income", Kind: "income"}, {ID: IncomeFallback, Name: "Unclassified", ParentID: "cat_income", Kind: "income"},
}, Tags: []Tag{}, Merchants: []Merchant{}, Transactions: []Transaction{}}
}
func Clone(d Dataset) Dataset {
c := Dataset{Accounts: append([]Account{}, d.Accounts...), Categories: append([]Category{}, d.Categories...), Tags: append([]Tag{}, d.Tags...), Merchants: append([]Merchant{}, d.Merchants...), Transactions: append([]Transaction{}, d.Transactions...)}
for i := range c.Merchants {
c.Merchants[i].Aliases = append([]string{}, d.Merchants[i].Aliases...)
c.Merchants[i].DefaultTagIDs = append([]string{}, d.Merchants[i].DefaultTagIDs...)
}
for i := range c.Transactions {
c.Transactions[i].Enrichment.TagIDs = append([]string{}, d.Transactions[i].Enrichment.TagIDs...)
}
return c
}
func Fallback(f Facts) Enrichment {
kind, category := "expense", ExpenseFallback
n, err := f.Amount.Minor()
if err == nil && n > 0 {
kind, category = "income", IncomeFallback
}
return Enrichment{Kind: kind, CategoryID: category, TagIDs: []string{}, Classification: Provenance{Source: "fallback"}}
}
func CategoryPath(d Dataset, id string) string {
byID := map[string]Category{}
for _, c := range d.Categories {
byID[c.ID] = c
}
parts := []string{}
seen := map[string]bool{}
for id != "" {
c, ok := byID[id]
if !ok || seen[id] {
return ""
}
seen[id] = true
parts = append(parts, c.Name)
id = c.ParentID
}
for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 {
parts[i], parts[j] = parts[j], parts[i]
}
return strings.Join(parts, " / ")
}
func validDate(s string) bool {
t, err := time.Parse("2006-01-02", s)
return err == nil && t.Year() > 0 && t.Format("2006-01-02") == s
}
func nonblank(s string) bool { return utf8.ValidString(s) && strings.TrimSpace(s) != "" }
func validText(values ...string) bool {
for _, s := range values {
if !utf8.ValidString(s) {
return false
}
}
return true
}
func Validate(d Dataset) error {
ids := map[string]string{}
register := func(id, kind string) error {
if !idPattern.MatchString(id) {
return fmt.Errorf("%s %q: invalid ID", kind, id)
}
if old, ok := ids[id]; ok {
return fmt.Errorf("%s %q: duplicate ID (already %s)", kind, id, old)
}
ids[id] = kind
return nil
}
accounts := map[string]Account{}
categories := map[string]Category{}
children := map[string]bool{}
tags := map[string]bool{}
for _, a := range d.Accounts {
if err := register(a.ID, "account"); err != nil {
return err
}
if !nonblank(a.DisplayName) || !currencyPattern.MatchString(a.Currency) || !validText(a.Institution, a.ExternalAccountID, a.IBAN) {
return fmt.Errorf("account %q: valid UTF-8 name and three-letter uppercase currency required", a.ID)
}
accounts[a.ID] = a
}
for _, c := range d.Categories {
if err := register(c.ID, "category"); err != nil {
return err
}
if !nonblank(c.Name) || (c.Kind != "expense" && c.Kind != "income") {
return fmt.Errorf("category %q: invalid name or kind", c.ID)
}
categories[c.ID] = c
if c.ParentID != "" {
children[c.ParentID] = true
}
}
for _, c := range d.Categories {
seen := map[string]bool{c.ID: true}
for p := c.ParentID; p != ""; {
parent, ok := categories[p]
if !ok {
return fmt.Errorf("category %q: missing parent %q", c.ID, p)
}
if seen[p] {
return fmt.Errorf("category %q: taxonomy cycle", c.ID)
}
if parent.Kind != c.Kind {
return fmt.Errorf("category %q: parent kind differs", c.ID)
}
seen[p] = true
p = parent.ParentID
}
}
for _, spec := range []struct{ id, parent, kind string }{{"cat_expenses", "", "expense"}, {ExpenseFallback, "cat_expenses", "expense"}, {"cat_income", "", "income"}, {IncomeFallback, "cat_income", "income"}} {
c, ok := categories[spec.id]
if !ok || c.ParentID != spec.parent || c.Kind != spec.kind {
return fmt.Errorf("category %q: required fallback hierarchy cannot be removed or moved", spec.id)
}
}
if children[ExpenseFallback] || children[IncomeFallback] {
return fmt.Errorf("fallback categories must remain leaves")
}
for _, t := range d.Tags {
if err := register(t.ID, "tag"); err != nil {
return err
}
if !nonblank(t.Name) {
return fmt.Errorf("tag %q: name required", t.ID)
}
tags[t.ID] = true
}
for _, m := range d.Merchants {
if err := register(m.ID, "merchant"); err != nil {
return err
}
if !nonblank(m.Name) {
return fmt.Errorf("merchant %q: name required", m.ID)
}
if m.DefaultCategoryID != "" {
if _, ok := categories[m.DefaultCategoryID]; !ok || children[m.DefaultCategoryID] {
return fmt.Errorf("merchant %q: default category must be existing leaf", m.ID)
}
}
seen := map[string]bool{}
for _, id := range m.DefaultTagIDs {
if !tags[id] || seen[id] {
return fmt.Errorf("merchant %q: invalid or duplicate default tag %q", m.ID, id)
}
seen[id] = true
}
aliases := map[string]bool{}
for _, alias := range m.Aliases {
key := strings.ToLower(strings.TrimSpace(alias))
if !nonblank(alias) || aliases[key] {
return fmt.Errorf("merchant %q: invalid or duplicate alias", m.ID)
}
aliases[key] = true
}
}
for _, t := range d.Transactions {
f := t.Facts
if err := register(f.ID, "transaction"); err != nil {
return err
}
a, ok := accounts[f.AccountID]
if !ok {
return fmt.Errorf("transaction %q: unknown account %q", f.ID, f.AccountID)
}
if !currencyPattern.MatchString(f.Currency) || a.Currency != f.Currency {
return fmt.Errorf("transaction %q: currency differs from account", f.ID)
}
if _, err := f.Amount.Minor(); err != nil {
return fmt.Errorf("transaction %q: %w", f.ID, err)
}
if !validDate(f.BookingDate) || (f.ValueDate != "" && !validDate(f.ValueDate)) {
return fmt.Errorf("transaction %q: invalid booking/value date", f.ID)
}
if !nonblank(f.Source) || !nonblank(f.Fingerprint) {
return fmt.Errorf("transaction %q: source and fingerprint required", f.ID)
}
if !validText(f.RawDescription, f.ExternalID, f.Counterparty, f.CounterpartyIBAN) {
return fmt.Errorf("transaction %q: bank facts must be valid UTF-8", f.ID)
}
}
index := enrichmentIndex{categories: categories, children: children, tags: tags, merchants: map[string]bool{}, transactions: map[string]Transaction{}}
for _, m := range d.Merchants {
index.merchants[m.ID] = true
}
for _, t := range d.Transactions {
index.transactions[t.Facts.ID] = t
}
for _, t := range d.Transactions {
if err := index.validate(t.Facts, t.Enrichment); err != nil {
return fmt.Errorf("transaction %q: %w", t.Facts.ID, err)
}
}
return nil
}
type enrichmentIndex struct {
categories map[string]Category
children, tags, merchants map[string]bool
transactions map[string]Transaction
}
func ValidateEnrichment(d Dataset, f Facts, e Enrichment) error {
index := enrichmentIndex{categories: map[string]Category{}, children: map[string]bool{}, tags: map[string]bool{}, merchants: map[string]bool{}, transactions: map[string]Transaction{}}
for _, c := range d.Categories {
index.categories[c.ID] = c
if c.ParentID != "" {
index.children[c.ParentID] = true
}
}
for _, t := range d.Tags {
index.tags[t.ID] = true
}
for _, m := range d.Merchants {
index.merchants[m.ID] = true
}
for _, t := range d.Transactions {
index.transactions[t.Facts.ID] = t
}
return index.validate(f, e)
}
func (index enrichmentIndex) validate(f Facts, e Enrichment) error {
if e.Kind != "expense" && e.Kind != "income" && e.Kind != "transfer" {
return fmt.Errorf("invalid enrichment kind %q", e.Kind)
}
seen := map[string]bool{}
for _, id := range e.TagIDs {
if !index.tags[id] || seen[id] {
return fmt.Errorf("invalid or duplicate tag %q", id)
}
seen[id] = true
}
if e.MerchantID != "" && !index.merchants[e.MerchantID] {
return fmt.Errorf("unknown merchant %q", e.MerchantID)
}
if !validText(e.Classification.Source, e.Classification.Model, e.Classification.Error) {
return fmt.Errorf("classification metadata must be valid UTF-8")
}
if e.Classification.Timestamp != "" {
if _, err := time.Parse(time.RFC3339Nano, e.Classification.Timestamp); err != nil {
return fmt.Errorf("invalid classification timestamp")
}
}
if e.Kind == "transfer" {
if e.CategoryID != "" || e.MerchantID != "" {
return fmt.Errorf("transfer must not have category or merchant")
}
if e.Classification.Source == "ai" || e.Classification.Source == "openrouter" {
return fmt.Errorf("AI cannot classify transfers")
}
amount, err := f.Amount.Minor()
if err != nil {
return err
}
if amount == 0 || amount == math.MinInt64 {
return fmt.Errorf("transfer requires nonzero negatable amount")
}
if peer, ok := index.transactions[e.TransferPeerID]; ok && peer.Facts.ID != f.ID {
other, err := peer.Facts.Amount.Minor()
if err != nil {
return err
}
if peer.Facts.AccountID == f.AccountID || peer.Facts.Currency != f.Currency || other != -amount || peer.Enrichment.Kind != "transfer" || peer.Enrichment.TransferPeerID != f.ID {
return fmt.Errorf("transfer peer must be reciprocal, opposite, same-currency and different-account")
}
return nil
}
return fmt.Errorf("missing transfer peer %q", e.TransferPeerID)
}
if e.TransferPeerID != "" {
return fmt.Errorf("non-transfer cannot have transfer peer")
}
category, found := index.categories[e.CategoryID]
if !found {
return fmt.Errorf("unknown category %q", e.CategoryID)
}
if category.Kind != e.Kind {
return fmt.Errorf("category kind differs from enrichment")
}
if index.children[e.CategoryID] {
return fmt.Errorf("category %q is not a leaf", e.CategoryID)
}
return nil
}