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"`
|
||||
|
||||
Reference in New Issue
Block a user