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
+88
View File
@@ -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"`