Three defects made new connections silently vanish while removed accounts returned: - A single shared account the journal cannot represent (securities or card entries without IBAN, stable identification or currency) aborted the entire consent. Usable accounts are now linked and the rest counted and reported. - A consent that linked nothing was stored, redirected as success and later reaped by session recovery. It now fails with the reason. - Callback failures rendered a bare JSON error page and were never logged. They now log and redirect into the app with the reason shown. - Deleting an account left its session binding, so the next connect or sync recovered the binding and re-added the account. Account deletion now releases bindings, consents and cursors before committing.
248 lines
7.1 KiB
Go
248 lines
7.1 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
|
|
"finance-duck/internal/banking"
|
|
"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
|
|
}
|
|
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
|
|
}
|