Add atomic bulk transaction editing with opt-in field changes

This commit is contained in:
Lars Nolden
2026-09-19 13:01:43 +02:00
parent 9cc3130b4f
commit d787150f2d
6 changed files with 966 additions and 105 deletions
+239
View File
@@ -13,9 +13,11 @@ import (
"net/http/httptest"
"net/url"
"reflect"
"slices"
"strings"
"testing"
"testing/fstest"
"time"
"finance-duck/internal/analytics"
"finance-duck/internal/app"
@@ -515,3 +517,240 @@ func TestDashboardRepeatedTagFiltersOverHTTP(t *testing.T) {
})
}
}
func TestTransactionsBulkOverHTTP(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "")
t.Setenv("ENABLEBANKING_APP_ID", "")
t.Setenv("ENABLEBANKING_KEY_FILE", "")
t.Setenv("ENABLEBANKING_REDIRECT_URL", "")
a, err := app.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer a.Close()
state, err := a.Snapshot(context.Background())
if err != nil {
t.Fatal(err)
}
_, err = a.Mutate(context.Background(), state.Revision, func(d *domain.Dataset) error {
d.Accounts = []domain.Account{
{ID: "acc_current", DisplayName: "Current", Currency: "EUR", Active: true},
{ID: "acc_savings", DisplayName: "Savings", Currency: "EUR", Active: true},
{ID: "acc_broker", DisplayName: "Broker", Currency: "EUR", Kind: domain.AccountInvestment, Active: true},
}
d.Categories = append(d.Categories, domain.Category{ID: "cat_food", Name: "Food", ParentID: "cat_expenses", Kind: "expense"})
d.Tags = []domain.Tag{{ID: "tag_keep", Name: "Keep"}, {ID: "tag_remove", Name: "Remove"}, {ID: "tag_add", Name: "Add"}, {ID: "tag_absent", Name: "Absent"}}
d.Merchants = []domain.Merchant{{ID: "mer_old", Name: "Previous merchant"}, {ID: "mer_new", Name: "New merchant"}}
for _, item := range []struct {
id, account, kind, category, merchant, peer, counterparty string
amount domain.Money
tags []string
investment *domain.Investment
}{
{"tx_a", "acc_current", "expense", domain.ExpenseFallback, "mer_old", "", "Corner Bakery", "-10.0000", []string{"tag_keep", "tag_remove"}, nil},
{"tx_b", "acc_current", "expense", domain.ExpenseFallback, "mer_old", "", "Market Hall", "-20.0000", []string{"tag_add", "tag_keep"}, nil},
{"tx_untouched", "acc_current", "expense", domain.ExpenseFallback, "mer_old", "", "Station Kiosk", "-3.0000", []string{"tag_remove"}, nil},
{"tx_income", "acc_current", "income", domain.IncomeFallback, "", "", "Employer", "100.0000", []string{"tag_remove"}, nil},
{"tx_out", "acc_current", "transfer", "", "", "tx_in", "Savings", "-25.0000", []string{"tag_keep"}, nil},
{"tx_in", "acc_savings", "transfer", "", "", "tx_out", "Current", "25.0000", []string{}, nil},
{"tx_investment", "acc_broker", domain.KindInvestment, "", "", "", "Deposit", "30.0000", []string{"tag_keep"}, &domain.Investment{Event: domain.EventDeposit}},
} {
d.Transactions = append(d.Transactions, domain.Transaction{
Facts: domain.Facts{
ID: item.id, Source: "test", AccountID: item.account, BookingDate: "2026-02-10", ValueDate: "2026-02-11",
Amount: item.amount, Currency: "EUR", RawDescription: "Bank description " + item.id,
ExternalID: "external_" + item.id, Fingerprint: item.id, Counterparty: item.counterparty,
CounterpartyIBAN: "DE89370400440532013000", Investment: item.investment,
},
Enrichment: domain.Enrichment{
Kind: item.kind, CategoryID: item.category, MerchantID: item.merchant, TagIDs: item.tags,
TransferPeerID: item.peer, Classification: domain.Provenance{Source: "rules"},
},
})
}
return nil
})
if err != nil {
t.Fatal(err)
}
h, err := New(a, fstest.MapFS{}, "")
if err != nil {
t.Fatal(err)
}
snapshot := func() app.State {
t.Helper()
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "http://localhost:8080/api/state", nil))
if w.Code != http.StatusOK {
t.Fatalf("GET state: %d: %s", w.Code, w.Body.String())
}
var result app.State
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatal(err)
}
return result
}
post := func(body map[string]any, status int) app.State {
t.Helper()
if _, ok := body["revision"]; !ok {
body["revision"] = snapshot().Revision
}
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
r := httptest.NewRequest(http.MethodPost, "http://localhost:8080/api/transactions/bulk", strings.NewReader(string(raw)))
r.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != status {
t.Fatalf("POST bulk: got %d, want %d: %s", w.Code, status, w.Body.String())
}
var result app.State
if status == http.StatusOK {
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatal(err)
}
persisted := snapshot()
if result.Revision != persisted.Revision || !reflect.DeepEqual(result.Data, persisted.Data) {
t.Fatal("bulk response differs from persisted state")
}
}
return result
}
transaction := func(s app.State, id string) domain.Transaction {
t.Helper()
for _, tx := range s.Data.Transactions {
if tx.Facts.ID == id {
return tx
}
}
t.Fatalf("missing transaction %s", id)
return domain.Transaction{}
}
for _, tt := range []struct {
name string
body map[string]any
}{
{"empty selection", map[string]any{"transaction_ids": []string{}, "add_tag_ids": []string{"tag_add"}}},
{"empty transaction ID", map[string]any{"transaction_ids": []string{"tx_a", ""}, "add_tag_ids": []string{"tag_add"}}},
{"duplicate transaction ID", map[string]any{"transaction_ids": []string{"tx_a", "tx_a"}, "add_tag_ids": []string{"tag_add"}}},
{"missing transaction rolls back category merchant tags and aliases", map[string]any{"transaction_ids": []string{"tx_a", "tx_missing"}, "category_id": "cat_food", "merchant_id": "mer_new", "add_tag_ids": []string{"tag_add"}}},
{"no operations", map[string]any{"transaction_ids": []string{"tx_a"}, "add_tag_ids": []string{}, "remove_tag_ids": []string{}}},
{"unknown added tag", map[string]any{"transaction_ids": []string{"tx_a"}, "add_tag_ids": []string{"tag_missing"}}},
{"unknown removed tag", map[string]any{"transaction_ids": []string{"tx_a"}, "remove_tag_ids": []string{"tag_missing"}}},
{"duplicate added tag", map[string]any{"transaction_ids": []string{"tx_a"}, "add_tag_ids": []string{"tag_add", "tag_add"}}},
{"duplicate removed tag", map[string]any{"transaction_ids": []string{"tx_a"}, "remove_tag_ids": []string{"tag_remove", "tag_remove"}}},
{"overlapping tag operations", map[string]any{"transaction_ids": []string{"tx_a"}, "add_tag_ids": []string{"tag_add"}, "remove_tag_ids": []string{"tag_add"}}},
{"nonleaf category", map[string]any{"transaction_ids": []string{"tx_a", "tx_b"}, "category_id": "cat_expenses", "add_tag_ids": []string{"tag_add"}}},
{"unknown category", map[string]any{"transaction_ids": []string{"tx_a", "tx_b"}, "category_id": "cat_missing", "merchant_id": "mer_new"}},
{"category cannot be cleared", map[string]any{"transaction_ids": []string{"tx_a"}, "category_id": ""}},
{"incompatible category rolls back entire batch", map[string]any{"transaction_ids": []string{"tx_a", "tx_income"}, "category_id": "cat_food", "merchant_id": "mer_new", "add_tag_ids": []string{"tag_add"}}},
{"unknown merchant", map[string]any{"transaction_ids": []string{"tx_a", "tx_b"}, "merchant_id": "mer_missing"}},
{"transfer category edit", map[string]any{"transaction_ids": []string{"tx_a", "tx_out"}, "category_id": "cat_food"}},
{"transfer merchant clear", map[string]any{"transaction_ids": []string{"tx_a", "tx_out"}, "merchant_id": ""}},
{"investment category edit", map[string]any{"transaction_ids": []string{"tx_a", "tx_investment"}, "category_id": "cat_food"}},
{"investment merchant clear", map[string]any{"transaction_ids": []string{"tx_a", "tx_investment"}, "merchant_id": ""}},
{"bank facts cannot be edited", map[string]any{"transaction_ids": []string{"tx_a"}, "amount": "1.0000", "add_tag_ids": []string{"tag_add"}}},
{"kind cannot be edited", map[string]any{"transaction_ids": []string{"tx_a"}, "kind": "income", "add_tag_ids": []string{"tag_add"}}},
{"transfer links cannot be edited", map[string]any{"transaction_ids": []string{"tx_out"}, "transfer_peer_id": "", "add_tag_ids": []string{"tag_add"}}},
} {
t.Run(tt.name, func(t *testing.T) {
before := snapshot()
post(tt.body, http.StatusBadRequest)
after := snapshot()
if before.Revision != after.Revision || !reflect.DeepEqual(before.Data, after.Data) {
t.Fatal("rejected batch changed persisted data or revision")
}
})
}
t.Run("multi row edit preserves facts unrelated tags and unselected rows", func(t *testing.T) {
before := snapshot()
after := post(map[string]any{
"transaction_ids": []string{"tx_a", "tx_b"}, "category_id": "cat_food", "merchant_id": "mer_new",
"add_tag_ids": []string{"tag_add"}, "remove_tag_ids": []string{"tag_remove", "tag_absent"},
}, http.StatusOK)
for _, old := range before.Data.Transactions {
got := transaction(after, old.Facts.ID)
if old.Facts.ID != "tx_a" && old.Facts.ID != "tx_b" {
if !reflect.DeepEqual(old, got) {
t.Fatalf("unselected transaction changed: %s", old.Facts.ID)
}
continue
}
tags := slices.Clone(got.Enrichment.TagIDs)
slices.Sort(tags)
if !reflect.DeepEqual(tags, []string{"tag_add", "tag_keep"}) || got.Enrichment.CategoryID != "cat_food" || got.Enrichment.MerchantID != "mer_new" {
t.Fatalf("bulk changes not applied: %+v", got.Enrichment)
}
if !reflect.DeepEqual(old.Facts, got.Facts) || got.Enrichment.Kind != old.Enrichment.Kind || got.Enrichment.TransferPeerID != old.Enrichment.TransferPeerID {
t.Fatalf("immutable transaction fields changed: %s", old.Facts.ID)
}
if got.Enrichment.Classification.Source != "manual" {
t.Fatalf("missing manual provenance: %+v", got.Enrichment.Classification)
}
if _, err := time.Parse(time.RFC3339, got.Enrichment.Classification.Timestamp); err != nil {
t.Fatalf("invalid manual timestamp: %v", err)
}
}
for _, merchant := range after.Data.Merchants {
if merchant.ID == "mer_new" && (!slices.Contains(merchant.Aliases, "Corner Bakery") || !slices.Contains(merchant.Aliases, "Market Hall")) {
t.Fatalf("explicit merchant assignment did not learn aliases: %+v", merchant)
}
}
post(map[string]any{"revision": before.Revision, "transaction_ids": []string{"tx_a", "tx_b"}, "merchant_id": ""}, http.StatusConflict)
unchanged := snapshot()
if unchanged.Revision != after.Revision || !reflect.DeepEqual(unchanged.Data, after.Data) {
t.Fatal("stale batch overwrote the successful edit")
}
})
t.Run("tag-only edits preserve individual categories merchants and transfer links", func(t *testing.T) {
before := snapshot()
after := post(map[string]any{
"transaction_ids": []string{"tx_a", "tx_untouched", "tx_income", "tx_out", "tx_investment"},
"add_tag_ids": []string{"tag_add"}, "remove_tag_ids": []string{"tag_remove"},
}, http.StatusOK)
for _, id := range []string{"tx_a", "tx_untouched", "tx_income", "tx_out", "tx_investment"} {
old, got := transaction(before, id), transaction(after, id)
if !slices.Contains(got.Enrichment.TagIDs, "tag_add") || slices.Contains(got.Enrichment.TagIDs, "tag_remove") {
t.Fatalf("tags not updated on %s: %+v", id, got.Enrichment)
}
if id == "tx_out" || id == "tx_investment" {
if !slices.Contains(got.Enrichment.TagIDs, "tag_keep") {
t.Fatalf("unrelated tag removed from %s", id)
}
}
if !reflect.DeepEqual(old.Facts, got.Facts) || got.Enrichment.Kind != old.Enrichment.Kind ||
got.Enrichment.CategoryID != old.Enrichment.CategoryID || got.Enrichment.MerchantID != old.Enrichment.MerchantID ||
got.Enrichment.TransferPeerID != old.Enrichment.TransferPeerID || got.Enrichment.Classification.Source != "manual" {
t.Fatalf("tag edit changed other fields or omitted manual provenance on %s: %+v", id, got)
}
}
if !reflect.DeepEqual(transaction(before, "tx_in"), transaction(after, "tx_in")) {
t.Fatal("tag edit changed unselected transfer counterpart")
}
if !reflect.DeepEqual(before.Data.Merchants, after.Data.Merchants) {
t.Fatal("tag-only edits learned merchant aliases")
}
})
t.Run("merchant clearing preserves category and tags and fallback remains selectable", func(t *testing.T) {
before := snapshot()
cleared := post(map[string]any{"transaction_ids": []string{"tx_a", "tx_b"}, "merchant_id": ""}, http.StatusOK)
for _, id := range []string{"tx_a", "tx_b"} {
old, got := transaction(before, id), transaction(cleared, id)
if got.Enrichment.MerchantID != "" || old.Enrichment.CategoryID != got.Enrichment.CategoryID ||
!reflect.DeepEqual(old.Enrichment.TagIDs, got.Enrichment.TagIDs) || !reflect.DeepEqual(old.Facts, got.Facts) {
t.Fatalf("merchant clear changed unrelated fields: %+v", got)
}
}
if !reflect.DeepEqual(before.Data.Merchants, cleared.Data.Merchants) {
t.Fatal("merchant clearing changed aliases")
}
fallback := post(map[string]any{"transaction_ids": []string{"tx_a", "tx_b"}, "category_id": domain.ExpenseFallback}, http.StatusOK)
for _, id := range []string{"tx_a", "tx_b"} {
if transaction(fallback, id).Enrichment.CategoryID != domain.ExpenseFallback {
t.Fatalf("fallback category was not assigned to %s", id)
}
}
})
}