Files
finance-duck/internal/app/manage.go
T
2026-09-10 12:30:42 +02:00

204 lines
5.5 KiB
Go

package app
import (
"errors"
"fmt"
"slices"
"strings"
"finance-duck/internal/domain"
)
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
}
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
}
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 "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
}