Add atomic bulk transaction editing with opt-in field changes
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -47,6 +48,7 @@ func New(a *app.App, assets fs.FS, publicURL string) (http.Handler, error) {
|
||||
s.mux.HandleFunc("POST /api/instruments", s.instrument)
|
||||
s.mux.HandleFunc("POST /api/assets", s.asset)
|
||||
s.mux.HandleFunc("POST /api/transactions/{id}/transfer", s.transfer)
|
||||
s.mux.HandleFunc("POST /api/transactions/bulk", s.bulkTransactions)
|
||||
s.mux.HandleFunc("POST /api/transactions/{id}", s.transaction)
|
||||
s.mux.HandleFunc("POST /api/manage", s.manage)
|
||||
s.mux.HandleFunc("POST /api/import/prepare", s.importPrepare)
|
||||
@@ -347,6 +349,92 @@ func (s *Server) transaction(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
respond(w, v, e)
|
||||
}
|
||||
func (s *Server) bulkTransactions(w http.ResponseWriter, r *http.Request) {
|
||||
var b struct {
|
||||
Revision string `json:"revision"`
|
||||
TransactionIDs []string `json:"transaction_ids"`
|
||||
CategoryID *string `json:"category_id"`
|
||||
MerchantID *string `json:"merchant_id"`
|
||||
AddTagIDs []string `json:"add_tag_ids"`
|
||||
RemoveTagIDs []string `json:"remove_tag_ids"`
|
||||
}
|
||||
if !decode(w, r, &b) {
|
||||
return
|
||||
}
|
||||
v, e := s.app.Mutate(r.Context(), b.Revision, func(d *domain.Dataset) error {
|
||||
if len(b.TransactionIDs) == 0 {
|
||||
return errors.New("select at least one transaction")
|
||||
}
|
||||
if b.CategoryID == nil && b.MerchantID == nil && len(b.AddTagIDs) == 0 && len(b.RemoveTagIDs) == 0 {
|
||||
return errors.New("choose at least one bulk edit")
|
||||
}
|
||||
selected := make(map[string]bool, len(b.TransactionIDs))
|
||||
for _, id := range b.TransactionIDs {
|
||||
if id == "" || selected[id] {
|
||||
return errors.New("transaction IDs must be nonempty and unique")
|
||||
}
|
||||
selected[id] = true
|
||||
}
|
||||
var knownTags map[string]bool
|
||||
if len(b.AddTagIDs) > 0 || len(b.RemoveTagIDs) > 0 {
|
||||
knownTags = make(map[string]bool, len(d.Tags))
|
||||
for _, tag := range d.Tags {
|
||||
knownTags[tag.ID] = true
|
||||
}
|
||||
}
|
||||
addTags := make(map[string]bool, len(b.AddTagIDs))
|
||||
for _, id := range b.AddTagIDs {
|
||||
if !knownTags[id] || addTags[id] {
|
||||
return errors.New("added tag IDs must be known and unique")
|
||||
}
|
||||
addTags[id] = true
|
||||
}
|
||||
removeTags := make(map[string]bool, len(b.RemoveTagIDs))
|
||||
for _, id := range b.RemoveTagIDs {
|
||||
if !knownTags[id] || removeTags[id] || addTags[id] {
|
||||
return errors.New("removed tag IDs must be known, unique and not also added")
|
||||
}
|
||||
removeTags[id] = true
|
||||
}
|
||||
matched := 0
|
||||
provenance := domain.Provenance{Source: "manual", Timestamp: time.Now().UTC().Format(time.RFC3339)}
|
||||
for i := range d.Transactions {
|
||||
t := &d.Transactions[i]
|
||||
if !selected[t.Facts.ID] {
|
||||
continue
|
||||
}
|
||||
matched++
|
||||
if (b.CategoryID != nil || b.MerchantID != nil) && (t.Enrichment.Kind == "transfer" || t.Enrichment.Kind == domain.KindInvestment) {
|
||||
return errors.New("category and merchant cannot be edited on transfers or investments")
|
||||
}
|
||||
if b.CategoryID != nil {
|
||||
t.Enrichment.CategoryID = *b.CategoryID
|
||||
}
|
||||
if b.MerchantID != nil {
|
||||
t.Enrichment.MerchantID = *b.MerchantID
|
||||
if *b.MerchantID != "" {
|
||||
app.LearnAlias(d, t.Facts, *b.MerchantID)
|
||||
}
|
||||
}
|
||||
if len(removeTags) > 0 {
|
||||
t.Enrichment.TagIDs = slices.DeleteFunc(t.Enrichment.TagIDs, func(id string) bool { return removeTags[id] })
|
||||
}
|
||||
for _, id := range b.AddTagIDs {
|
||||
if !slices.Contains(t.Enrichment.TagIDs, id) {
|
||||
t.Enrichment.TagIDs = append(t.Enrichment.TagIDs, id)
|
||||
}
|
||||
}
|
||||
t.Enrichment.Classification = provenance
|
||||
}
|
||||
if matched != len(selected) {
|
||||
return errors.New("unknown transaction")
|
||||
}
|
||||
// Commit validates the complete dataset once, including category leaf/kind
|
||||
// compatibility and merchant references, before writing any journal files.
|
||||
return nil
|
||||
})
|
||||
respond(w, v, e)
|
||||
}
|
||||
func (s *Server) manage(w http.ResponseWriter, r *http.Request) {
|
||||
var b struct {
|
||||
Revision string `json:"revision"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user