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 }