init
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func sampleDataset() Dataset {
|
||||
d := NewDataset()
|
||||
d.Accounts = []Account{{ID: "acc_main", DisplayName: "Main", Currency: "EUR", Active: true}, {ID: "acc_save", DisplayName: "Savings", Currency: "EUR", Active: true}}
|
||||
d.Categories = append(d.Categories, Category{ID: "cat_food", Name: "Food", ParentID: "cat_expenses", Kind: "expense"}, Category{ID: "cat_grocery", Name: "Groceries", ParentID: "cat_food", Kind: "expense"})
|
||||
d.Tags = []Tag{{ID: "tag_shared", Name: "Shared"}}
|
||||
d.Merchants = []Merchant{{ID: "mer_shop", Name: "Shop", Aliases: []string{"Shop GmbH"}, DefaultCategoryID: "cat_grocery", DefaultTagIDs: []string{"tag_shared"}, UseDefaults: true}}
|
||||
f := Facts{ID: "tx_one", Source: "csv", AccountID: "acc_main", BookingDate: "2026-01-01", Amount: "-12.3401", Currency: "EUR", RawDescription: "Shopping", Fingerprint: "fp_one"}
|
||||
d.Transactions = []Transaction{{Facts: f, Enrichment: Fallback(f)}}
|
||||
return d
|
||||
}
|
||||
func TestMoneyExactBoundaries(t *testing.T) {
|
||||
cases := []struct {
|
||||
input, canonical string
|
||||
minor int64
|
||||
}{{"0", "0.00", 0}, {"-0.0000", "0.00", 0}, {"12.3401", "12.3401", 123401}, {"-0.0001", "-0.0001", -1}, {"922337203685477.5807", "922337203685477.5807", math.MaxInt64}, {"-922337203685477.5808", "-922337203685477.5808", math.MinInt64}}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
m, err := ParseMoney(tc.input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.String() != tc.canonical {
|
||||
t.Fatalf("got %q, want %q", m.String(), tc.canonical)
|
||||
}
|
||||
n, err := m.Minor()
|
||||
if err != nil || n != tc.minor {
|
||||
t.Fatalf("minor = %d, %v", n, err)
|
||||
}
|
||||
round, err := ParseMoney(m.String())
|
||||
if err != nil || round != m {
|
||||
t.Fatalf("unstable money: %s, %v", round, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, s := range []string{"", "+1", " 1", "01", ".1", "1.", "1.00001", "1e2", "NaN", "922337203685477.5808", "-922337203685477.5809", "99999999999999999999999999999999999999", "1,25", "--1"} {
|
||||
t.Run("reject_"+s, func(t *testing.T) {
|
||||
if _, err := ParseMoney(s); err == nil {
|
||||
t.Fatalf("accepted %q", s)
|
||||
}
|
||||
if _, err := Money(s).Minor(); err == nil {
|
||||
t.Fatalf("Minor accepted %q", s)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestDomainRejectsBrokenReferencesAndTaxonomy(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*Dataset)
|
||||
}{
|
||||
{"cycle", func(d *Dataset) { d.Categories[4].ParentID = "cat_grocery" }},
|
||||
{"nonleaf assignment", func(d *Dataset) { d.Transactions[0].Enrichment.CategoryID = "cat_food" }},
|
||||
{"wrong category kind", func(d *Dataset) { d.Transactions[0].Enrichment.CategoryID = IncomeFallback }},
|
||||
{"remove fallback", func(d *Dataset) { d.Categories = append(d.Categories[:1], d.Categories[2:]...) }},
|
||||
{"move fallback", func(d *Dataset) { d.Categories[1].ParentID = "cat_food" }},
|
||||
{"fallback child", func(d *Dataset) { d.Categories[4].ParentID = ExpenseFallback }},
|
||||
{"missing account", func(d *Dataset) { d.Transactions[0].Facts.AccountID = "acc_missing" }},
|
||||
{"currency mismatch", func(d *Dataset) { d.Transactions[0].Facts.Currency = "USD" }},
|
||||
{"invalid date", func(d *Dataset) { d.Transactions[0].Facts.BookingDate = "2026-02-30" }},
|
||||
{"year zero cannot map to journal", func(d *Dataset) { d.Transactions[0].Facts.BookingDate = "0000-01-01" }},
|
||||
{"invalid UTF-8 facts", func(d *Dataset) { d.Transactions[0].Facts.RawDescription = string([]byte{0xff}) }},
|
||||
{"invalid money", func(d *Dataset) { d.Transactions[0].Facts.Amount = "1e2" }},
|
||||
{"unknown tag", func(d *Dataset) { d.Transactions[0].Enrichment.TagIDs = []string{"tag_missing"} }},
|
||||
{"duplicate tag", func(d *Dataset) { d.Transactions[0].Enrichment.TagIDs = []string{"tag_shared", "tag_shared"} }},
|
||||
{"unknown merchant", func(d *Dataset) { d.Transactions[0].Enrichment.MerchantID = "mer_missing" }},
|
||||
{"duplicate identity", func(d *Dataset) { d.Tags[0].ID = "acc_main" }},
|
||||
{"invalid provenance date", func(d *Dataset) { d.Transactions[0].Enrichment.Classification.Timestamp = "yesterday" }},
|
||||
{"nonleaf merchant default", func(d *Dataset) { d.Merchants[0].DefaultCategoryID = "cat_food" }},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
d := sampleDataset()
|
||||
tc.mutate(&d)
|
||||
if err := Validate(d); err == nil {
|
||||
t.Fatal("accepted invalid dataset")
|
||||
}
|
||||
})
|
||||
}
|
||||
d := sampleDataset()
|
||||
if err := Validate(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := CategoryPath(d, "cat_grocery"); got != "Expenses / Food / Groceries" {
|
||||
t.Fatalf("path: %s", got)
|
||||
}
|
||||
}
|
||||
func transferDataset() Dataset {
|
||||
d := sampleDataset()
|
||||
d.Transactions[0].Facts.Amount = "-10.00"
|
||||
peer := d.Transactions[0]
|
||||
peer.Facts.ID = "tx_two"
|
||||
peer.Facts.Fingerprint = "fp_two"
|
||||
peer.Facts.AccountID = "acc_save"
|
||||
peer.Facts.Amount = "10.00"
|
||||
d.Transactions[0].Enrichment = Enrichment{Kind: "transfer", TransferPeerID: "tx_two", TagIDs: []string{}, Classification: Provenance{Source: "manual"}}
|
||||
peer.Enrichment = Enrichment{Kind: "transfer", TransferPeerID: "tx_one", TagIDs: []string{}, Classification: Provenance{Source: "manual"}}
|
||||
d.Transactions = append(d.Transactions, peer)
|
||||
return d
|
||||
}
|
||||
func TestTransferRequiresReciprocalOppositeSameCurrencyAccounts(t *testing.T) {
|
||||
d := transferDataset()
|
||||
if err := Validate(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*Dataset)
|
||||
}{
|
||||
{"one-sided", func(d *Dataset) { d.Transactions[1].Enrichment = Fallback(d.Transactions[1].Facts) }},
|
||||
{"self", func(d *Dataset) { d.Transactions[0].Enrichment.TransferPeerID = "tx_one" }},
|
||||
{"same account", func(d *Dataset) { d.Transactions[1].Facts.AccountID = "acc_main" }},
|
||||
{"unequal", func(d *Dataset) { d.Transactions[1].Facts.Amount = "10.0001" }},
|
||||
{"unlike currencies", func(d *Dataset) { d.Accounts[1].Currency = "USD"; d.Transactions[1].Facts.Currency = "USD" }},
|
||||
{"AI", func(d *Dataset) { d.Transactions[0].Enrichment.Classification.Source = "ai" }},
|
||||
{"category", func(d *Dataset) { d.Transactions[0].Enrichment.CategoryID = ExpenseFallback }},
|
||||
{"zero", func(d *Dataset) { d.Transactions[0].Facts.Amount = "0"; d.Transactions[1].Facts.Amount = "0" }},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
d := transferDataset()
|
||||
tc.mutate(&d)
|
||||
if err := Validate(d); err == nil {
|
||||
t.Fatal("accepted invalid transfer")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestCloneOwnsNestedListsAndFallback(t *testing.T) {
|
||||
original := sampleDataset()
|
||||
original.Transactions[0].Enrichment.TagIDs = []string{"tag_shared"}
|
||||
copy := Clone(original)
|
||||
copy.Merchants[0].Aliases[0] = "Changed"
|
||||
copy.Merchants[0].DefaultTagIDs[0] = "other"
|
||||
copy.Transactions[0].Enrichment.TagIDs[0] = "other"
|
||||
copy.Categories[0].Name = "Changed"
|
||||
if original.Merchants[0].Aliases[0] != "Shop GmbH" || original.Merchants[0].DefaultTagIDs[0] != "tag_shared" || original.Transactions[0].Enrichment.TagIDs[0] != "tag_shared" || original.Categories[0].Name != "Expenses" {
|
||||
t.Fatal("clone shares mutable storage")
|
||||
}
|
||||
f := original.Transactions[0].Facts
|
||||
f.Amount = "1.00"
|
||||
if e := Fallback(f); e.Kind != "income" || e.CategoryID != IncomeFallback {
|
||||
t.Fatalf("income fallback: %#v", e)
|
||||
}
|
||||
f.Amount = "-1.00"
|
||||
if e := Fallback(f); e.Kind != "expense" || e.CategoryID != ExpenseFallback {
|
||||
t.Fatalf("expense fallback: %#v", e)
|
||||
}
|
||||
empty := Clone(Dataset{})
|
||||
raw, err := json.Marshal(empty)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "null") {
|
||||
t.Fatalf("nil public lists: %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package domain
|
||||
|
||||
// Money is an exact decimal string bounded to signed 64-bit ten-thousandths.
|
||||
type Money string
|
||||
|
||||
type Account struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Institution string `json:"institution"`
|
||||
Currency string `json:"currency"`
|
||||
ExternalAccountID string `json:"external_account_id,omitempty"`
|
||||
IBAN string `json:"iban,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
type Facts struct {
|
||||
ID string `json:"id"`
|
||||
Source string `json:"source"`
|
||||
AccountID string `json:"account_id"`
|
||||
BookingDate string `json:"booking_date"`
|
||||
ValueDate string `json:"value_date,omitempty"`
|
||||
Amount Money `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
RawDescription string `json:"raw_description"`
|
||||
ExternalID string `json:"external_id,omitempty"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Counterparty string `json:"counterparty,omitempty"`
|
||||
CounterpartyIBAN string `json:"counterparty_iban,omitempty"`
|
||||
}
|
||||
type Provenance struct {
|
||||
Source string `json:"source"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Timestamp string `json:"timestamp,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
type Enrichment struct {
|
||||
Kind string `json:"kind"`
|
||||
MerchantID string `json:"merchant_id,omitempty"`
|
||||
CategoryID string `json:"category_id,omitempty"`
|
||||
TagIDs []string `json:"tag_ids"`
|
||||
TransferPeerID string `json:"transfer_peer_id,omitempty"`
|
||||
Classification Provenance `json:"classification"`
|
||||
}
|
||||
type Transaction struct {
|
||||
Facts Facts `json:"facts"`
|
||||
Enrichment Enrichment `json:"enrichment"`
|
||||
}
|
||||
type Category struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
type Tag struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
type Merchant struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Aliases []string `json:"aliases"`
|
||||
DefaultCategoryID string `json:"default_category_id,omitempty"`
|
||||
DefaultTagIDs []string `json:"default_tag_ids"`
|
||||
UseDefaults bool `json:"use_defaults"`
|
||||
}
|
||||
type Dataset struct {
|
||||
Accounts []Account `json:"accounts"`
|
||||
Categories []Category `json:"categories"`
|
||||
Tags []Tag `json:"tags"`
|
||||
Merchants []Merchant `json:"merchants"`
|
||||
Transactions []Transaction `json:"transactions"`
|
||||
}
|
||||
|
||||
const ExpenseFallback = "cat_expenses_unclassified"
|
||||
const IncomeFallback = "cat_income_unclassified"
|
||||
Reference in New Issue
Block a user