package app import ( "context" "errors" "fmt" "slices" "strings" "finance-duck/internal/banking" "finance-duck/internal/classification" "finance-duck/internal/domain" ) // ManageRegistry applies registry management and, for account deletions, also // releases the account's bank bindings first: a deleted account must never be // resurrected by the session recovery that reconstructs interrupted connects. func (a *App) ManageRegistry(ctx context.Context, rev, entity, action, id, target string) (State, error) { a.mu.Lock() defer a.mu.Unlock() s, err := a.snapshot(ctx) if err != nil { return State{}, err } if rev != s.Revision { return State{}, errors.New("revision conflict: reload before editing") } if err = Manage(&s.Data, entity, action, id, target); err != nil { return State{}, err } // Prune bindings before the canonical commit: an interruption then leaves // an unbound local account rather than a resurrected bank connection. if entity == "account" { changed := false sessions := make([]banking.Session, 0, len(a.ops.Sessions)) for _, session := range a.ops.Sessions { accounts := slices.DeleteFunc(slices.Clone(session.Accounts), func(account domain.Account) bool { return account.ID == id }) changed = changed || len(accounts) != len(session.Accounts) session.Accounts = accounts if len(accounts) == 0 { delete(a.ops.Consents, session.ID) continue } sessions = append(sessions, session) } if _, tracked := a.ops.AccountSync[id]; tracked || changed { a.ops.Sessions = sessions delete(a.ops.AccountSync, id) if err = a.saveOps(); err != nil { return State{}, err } } } return a.commit(ctx, rev, s.Data) } func SaveAccount(d *domain.Dataset, v domain.Account) error { v.DisplayName = strings.TrimSpace(v.DisplayName) if v.ID == "" { v.ID = domain.NewID("acct") } for i, x := range d.Accounts { if x.ID == v.ID { d.Accounts[i] = v return nil } } d.Accounts = append(d.Accounts, v) return nil } // SaveInstrument registers or renames a security. The ISIN is the identity the // facts were imported under, so it cannot be changed: pointing an existing // instrument at a different security would silently relabel every trade that // references it. func SaveInstrument(d *domain.Dataset, v domain.Instrument) error { v.Name = strings.TrimSpace(v.Name) v.ISIN = strings.ToUpper(strings.Join(strings.Fields(v.ISIN), "")) v.Currency = strings.ToUpper(strings.TrimSpace(v.Currency)) v.Symbol = strings.TrimSpace(v.Symbol) // A quote belongs to the price job: this endpoint can neither set one nor // erase one. Changing the symbol does discard it, because a price from the // previous listing values the holding on the wrong market, and sometimes in // the wrong currency. v.Quote, v.QuotedAt = "", "" if v.ID == "" { if !domain.ValidISIN(v.ISIN) { return errors.New("an instrument needs a valid ISIN") } v.ID = domain.InstrumentID(v.ISIN) } for i, x := range d.Instruments { if x.ID == v.ID { if x.ISIN != v.ISIN { return errors.New("an instrument's ISIN is its identity; register the other security separately") } if x.Symbol == v.Symbol { v.Quote, v.QuotedAt = x.Quote, x.QuotedAt } d.Instruments[i] = v return nil } } d.Instruments = append(d.Instruments, v) return nil } func SaveCategory(d *domain.Dataset, v domain.Category) error { v.Name = strings.TrimSpace(v.Name) if v.ID == "" { v.ID = domain.NewID("cat") } for i, x := range d.Categories { if x.ID == v.ID { d.Categories[i] = v return nil } } d.Categories = append(d.Categories, v) return nil } // LearnAlias records the chosen counterparty as a merchant alias when the // classification matcher remains unambiguous. func LearnAlias(d *domain.Dataset, f domain.Facts, merchantID string) bool { return classification.LearnAlias(d, f, merchantID) } func SaveTag(d *domain.Dataset, v domain.Tag) error { v.Name = strings.TrimSpace(v.Name) if v.ID == "" { v.ID = domain.NewID("tag") } for i, x := range d.Tags { if x.ID == v.ID { d.Tags[i] = v return nil } } d.Tags = append(d.Tags, v) return nil } func SaveMerchant(d *domain.Dataset, v domain.Merchant) error { v.Name = strings.TrimSpace(v.Name) if v.ID == "" { v.ID = domain.NewID("merchant") } for i, x := range d.Merchants { if x.ID == v.ID { d.Merchants[i] = v return nil } } d.Merchants = append(d.Merchants, v) return nil } func replaceIDs(ids []string, from, to string) []string { out := make([]string, 0, len(ids)) for _, id := range ids { if id == from { id = to } if id != "" && !slices.Contains(out, id) { out = append(out, id) } } return out } func Manage(d *domain.Dataset, entity, action, id, target string) error { if id == "" || id == target { return errors.New("select distinct source and target") } if action != "delete" && action != "merge" { return errors.New("unknown management action") } if action == "merge" && target == "" { return errors.New("merge target required") } switch entity { case "account": if action != "delete" { return errors.New("account merging is not supported") } for _, t := range d.Transactions { if t.Facts.AccountID == id { return errors.New("account contains immutable financial records; deactivate it instead") } } n := len(d.Accounts) d.Accounts = slices.DeleteFunc(d.Accounts, func(v domain.Account) bool { return v.ID == id }) if n == len(d.Accounts) { return errors.New("unknown account") } case "instrument": if action != "delete" { return errors.New("instrument merging is not supported; an ISIN identifies exactly one security") } for _, t := range d.Transactions { if t.Facts.Investment != nil && t.Facts.Investment.InstrumentID == id { return errors.New("instrument is referenced by immutable financial records") } } n := len(d.Instruments) d.Instruments = slices.DeleteFunc(d.Instruments, func(v domain.Instrument) bool { return v.ID == id }) if n == len(d.Instruments) { return errors.New("unknown instrument") } case "tag": if !slices.ContainsFunc(d.Tags, func(v domain.Tag) bool { return v.ID == id }) { return errors.New("unknown tag") } if target != "" && !slices.ContainsFunc(d.Tags, func(v domain.Tag) bool { return v.ID == target }) { return errors.New("unknown target tag") } for i := range d.Transactions { d.Transactions[i].Enrichment.TagIDs = replaceIDs(d.Transactions[i].Enrichment.TagIDs, id, target) } for i := range d.Merchants { d.Merchants[i].DefaultTagIDs = replaceIDs(d.Merchants[i].DefaultTagIDs, id, target) } d.Tags = slices.DeleteFunc(d.Tags, func(v domain.Tag) bool { return v.ID == id }) case "merchant": source := -1 dest := -1 for i, v := range d.Merchants { if v.ID == id { source = i } if v.ID == target { dest = i } } if source < 0 { return errors.New("unknown merchant") } if target != "" && dest < 0 { return errors.New("unknown target merchant") } if dest >= 0 { for _, alias := range append(slices.Clone(d.Merchants[source].Aliases), d.Merchants[source].Name) { if !slices.Contains(d.Merchants[dest].Aliases, alias) { d.Merchants[dest].Aliases = append(d.Merchants[dest].Aliases, alias) } } } for i := range d.Transactions { if d.Transactions[i].Enrichment.MerchantID == id { d.Transactions[i].Enrichment.MerchantID = target } } d.Merchants = slices.DeleteFunc(d.Merchants, func(v domain.Merchant) bool { return v.ID == id }) case "category": if id == domain.ExpenseFallback || id == domain.IncomeFallback || id == "cat_expenses" || id == "cat_income" { return errors.New("built-in fallback categories and roots cannot be deleted or merged") } if !slices.ContainsFunc(d.Categories, func(v domain.Category) bool { return v.ID == id }) { return errors.New("unknown category") } removed := map[string]bool{id: true} for changed := true; changed; { changed = false for _, c := range d.Categories { if removed[c.ParentID] && !removed[c.ID] { removed[c.ID] = true changed = true } } } if action == "delete" && len(removed) > 1 { return errors.New("move or delete child categories first, or merge the subtree") } if removed[target] { return errors.New("cannot migrate into the removed subtree") } if target != "" { if !slices.ContainsFunc(d.Categories, func(v domain.Category) bool { return v.ID == target }) { return errors.New("unknown target category") } for _, c := range d.Categories { if c.ParentID == target { return errors.New("migration target must be a leaf category") } } } for i := range d.Transactions { if removed[d.Transactions[i].Enrichment.CategoryID] { if target == "" { return errors.New("category is referenced; select a migration target") } d.Transactions[i].Enrichment.CategoryID = target } } for i := range d.Merchants { if removed[d.Merchants[i].DefaultCategoryID] { if target == "" { return errors.New("merchant defaults reference this category; select a migration target") } d.Merchants[i].DefaultCategoryID = target } } d.Categories = slices.DeleteFunc(d.Categories, func(v domain.Category) bool { return removed[v.ID] }) default: return fmt.Errorf("unknown entity %q", entity) } return nil }