296 lines
8.8 KiB
Go
296 lines
8.8 KiB
Go
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))
|
|
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")
|
|
}
|
|
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
|
|
}
|