Implement classification redesign

This commit is contained in:
Lars Nolden
2026-09-11 22:46:17 +02:00
parent cc43a2f9a7
commit 87f052a3ea
23 changed files with 1602 additions and 296 deletions
+27
View File
@@ -18,6 +18,7 @@ import (
"finance-duck/internal/analytics"
"finance-duck/internal/app"
"finance-duck/internal/banking"
"finance-duck/internal/classification"
"finance-duck/internal/domain"
)
@@ -69,6 +70,8 @@ func New(a *app.App, assets fs.FS, publicURL string) (http.Handler, error) {
s.mux.HandleFunc("POST /api/reclassify/preview", s.preview)
s.mux.HandleFunc("POST /api/reclassify/apply", s.apply)
s.mux.HandleFunc("POST /api/reclassify/cancel", s.cancel)
s.mux.HandleFunc("POST /api/taxonomy/propose", s.taxonomyPropose)
s.mux.HandleFunc("POST /api/taxonomy/apply", s.taxonomyApply)
s.mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
_, err := a.Snapshot(r.Context())
if err != nil {
@@ -319,6 +322,9 @@ func (s *Server) transaction(w http.ResponseWriter, r *http.Request) {
}
b.Enrichment.Classification = domain.Provenance{Source: "manual", Timestamp: time.Now().UTC().Format(time.RFC3339)}
d.Transactions[i].Enrichment = b.Enrichment
if b.Enrichment.MerchantID != "" {
app.LearnAlias(d, t.Facts, b.Enrichment.MerchantID)
}
return nil
}
}
@@ -509,3 +515,24 @@ func (s *Server) cancel(w http.ResponseWriter, r *http.Request) {
s.app.CancelPreview(b.ID)
respond(w, map[string]bool{"ok": true}, nil)
}
func (s *Server) taxonomyPropose(w http.ResponseWriter, r *http.Request) {
var b app.TaxonomyProposalRequest
if !decode(w, r, &b) {
return
}
v, e := s.app.ProposeTaxonomy(r.Context(), b)
respond(w, v, e)
}
func (s *Server) taxonomyApply(w http.ResponseWriter, r *http.Request) {
var b struct {
ID string `json:"id"`
Revision string `json:"revision"`
Approved classification.TaxonomyProposal `json:"approved"`
}
if !decode(w, r, &b) {
return
}
v, e := s.app.ApplyTaxonomy(r.Context(), b.ID, b.Revision, b.Approved)
respond(w, v, e)
}
+2 -2
View File
@@ -39,7 +39,7 @@ func TestOriginAndHostGuardProtectNoLoginService(t *testing.T) {
}{{"rebound host", "attacker.example", "", "application/json", 403}, {"cross origin", "localhost:8080", "https://attacker.example", "application/json", 403}, {"simple form CSRF", "localhost:8080", "", "text/plain", 415}, {"valid local mutation", "localhost:8080", "http://localhost:8080", "application/json", 200}}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "http://localhost:8080/api/settings", strings.NewReader(`{"model":"example/model","include_amount":false}`))
r := httptest.NewRequest(http.MethodPost, "http://localhost:8080/api/settings", strings.NewReader(`{"model":"example/model"}`))
r.Host = tt.host
r.Header.Set("Content-Type", tt.content)
r.Header.Set("Origin", tt.origin)
@@ -117,7 +117,7 @@ func TestOpenRouterKeyIsWriteOnlyAndRequiresExplicitRemoval(t *testing.T) {
configured(check("POST", endpoint, keyJSON, origin, http.StatusOK), true)
configured(check("GET", "/api/state", "", origin, http.StatusOK), true)
// Ordinary preference updates must not implicitly erase credentials.
configured(check("POST", "/api/settings", `{"model":"example/model","include_amount":false}`, origin, http.StatusOK), true)
configured(check("POST", "/api/settings", `{"model":"example/model"}`, origin, http.StatusOK), true)
for _, body := range []string{
`{}`,
`{"api_key":null}`,