Add persistent multi-tag include and exclude filters

This commit is contained in:
Lars Nolden
2026-09-19 12:44:05 +02:00
parent 8aab21fe9e
commit 9cc3130b4f
11 changed files with 409 additions and 46 deletions
+18 -20
View File
@@ -15,13 +15,14 @@ import (
type Store struct{ db *sql.DB }
type Filter struct {
From string `json:"from"`
To string `json:"to"`
Currency string `json:"currency"`
AccountID string `json:"account_id"`
CategoryID string `json:"category_id"`
TagID string `json:"tag_id"`
MerchantID string `json:"merchant_id"`
From string `json:"from"`
To string `json:"to"`
Currency string `json:"currency"`
AccountID string `json:"account_id"`
CategoryID string `json:"category_id"`
TagIDs []string `json:"tag_ids"`
ExcludeTagIDs []string `json:"exclude_tag_ids"`
MerchantID string `json:"merchant_id"`
}
type Total struct {
@@ -272,21 +273,18 @@ func (f Filter) where() (string, []any) {
add("t.account_id = ?", f.AccountID)
add("t.merchant_id = ?", f.MerchantID)
add("EXISTS (SELECT 1 FROM category_ancestors ca WHERE ca.category_id = t.category_id AND ca.ancestor_id = ?)", f.CategoryID)
if f.TagID != "" {
ids := strings.Split(f.TagID, ",")
placeholders := make([]string, 0, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id != "" {
placeholders = append(placeholders, "?")
args = append(args, id)
}
addTags := func(predicate string, ids []string) {
if len(ids) == 0 {
return
}
if len(placeholders) == 0 {
clauses = append(clauses, "FALSE")
} else {
clauses = append(clauses, "EXISTS (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id AND tt.tag_id IN ("+strings.Join(placeholders, ",")+"))")
placeholders := make([]string, len(ids))
for i, id := range ids {
placeholders[i] = "?"
args = append(args, id)
}
clauses = append(clauses, predicate+" (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id AND tt.tag_id IN ("+strings.Join(placeholders, ",")+"))")
}
addTags("EXISTS", f.TagIDs)
addTags("NOT EXISTS", f.ExcludeTagIDs)
return strings.Join(clauses, " AND "), args
}
+118 -3
View File
@@ -132,7 +132,7 @@ func TestMonthlySplitsDirectionsAndRanksLargestPerCurrency(t *testing.T) {
func TestTagUnionNeverDuplicatesTransactions(t *testing.T) {
s := openFixture(t, fixture())
filter := Filter{From: "2026-02-01", To: "2026-02-28", Currency: "EUR", TagID: "tag_shared,tag_work,tag_shared"}
filter := Filter{From: "2026-02-01", To: "2026-02-28", Currency: "EUR", TagIDs: []string{"tag_shared", "tag_work", "tag_shared"}}
got := queryFixture(t, s, filter)
want := []Total{{Currency: "EUR", Expenses: "900719925474.1000", Income: "0.0000", Net: "-900719925474.1000"}}
if !reflect.DeepEqual(got.Totals, want) {
@@ -141,17 +141,132 @@ func TestTagUnionNeverDuplicatesTransactions(t *testing.T) {
if len(got.Monthly) != 1 || got.Monthly[0].Count != 2 {
t.Fatalf("tag union count: %#v", got.Monthly)
}
filter.TagID = "tag_shared"
filter.TagIDs = []string{"tag_shared"}
got = queryFixture(t, s, filter)
if len(got.Totals) != 1 || got.Totals[0].Expenses != "900719925474.0991" {
t.Fatalf("single tag filter: %#v", got.Totals)
}
filter.TagID = "tag_shared') OR TRUE --"
filter.TagIDs = []string{"tag_shared') OR TRUE --"}
if totals := queryFixture(t, s, filter).Totals; len(totals) != 0 {
t.Fatalf("tag input altered SQL predicate: %#v", totals)
}
}
func TestTagExclusionsAndComposition(t *testing.T) {
s := openFixture(t, fixture())
cases := []struct {
name string
include []string
exclude []string
totals []Total
count int64
tagIDs []string
}{
{
name: "excluded tag removes whole multi-tag transaction",
exclude: []string{"tag_shared"},
totals: []Total{{Currency: "EUR", Expenses: "0.0009", Income: "100.1235", Net: "100.1226"}},
count: 3,
tagIDs: []string{"tag_work"},
},
{
name: "any excluded tag removes transaction and untagged income survives",
exclude: []string{"tag_shared", "tag_work"},
totals: []Total{{Currency: "EUR", Expenses: "0.0000", Income: "100.1235", Net: "100.1235"}},
count: 2,
tagIDs: []string{},
},
{
name: "include union and exclusion intersect with exclusion winning overlap",
include: []string{"tag_shared", "tag_work"},
exclude: []string{"tag_shared"},
totals: []Total{{Currency: "EUR", Expenses: "0.0009", Income: "0.0000", Net: "-0.0009"}},
count: 1,
tagIDs: []string{"tag_work"},
},
{
name: "identical include and exclude match nothing",
include: []string{"tag_shared"},
exclude: []string{"tag_shared"},
totals: []Total{},
tagIDs: []string{},
},
{
name: "exclusion values cannot alter SQL",
exclude: []string{"tag_shared') OR TRUE --"},
totals: []Total{{Currency: "EUR", Expenses: "900719925474.1000", Income: "100.1235", Net: "-900719925373.9765"}},
count: 4,
tagIDs: []string{"tag_shared", "tag_work"},
},
{
name: "empty lists leave transactions unrestricted",
include: []string{},
exclude: []string{},
totals: []Total{{Currency: "EUR", Expenses: "900719925474.1000", Income: "100.1235", Net: "-900719925373.9765"}},
count: 4,
tagIDs: []string{"tag_shared", "tag_work"},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
got := queryFixture(t, s, Filter{From: "2026-02-01", To: "2026-02-28", Currency: "EUR", TagIDs: tt.include, ExcludeTagIDs: tt.exclude})
if !reflect.DeepEqual(got.Totals, tt.totals) {
t.Fatalf("totals: got %#v, want %#v", got.Totals, tt.totals)
}
var count int64
for _, month := range got.Monthly {
count += month.Count
}
if count != tt.count {
t.Fatalf("transaction count: got %d, want %d", count, tt.count)
}
tagIDs := make([]string, 0, len(got.Tags))
for _, tag := range got.Tags {
tagIDs = append(tagIDs, tag.ID)
}
if !reflect.DeepEqual(tagIDs, tt.tagIDs) {
t.Fatalf("tag groups: got %#v, want %#v", got.Tags, tt.tagIDs)
}
accounts := []Group{}
if len(tt.totals) != 0 {
accounts = append(accounts, Group{ID: "acc_eur", Name: "Current", Currency: "EUR", Amount: tt.totals[0].Net, Count: tt.count})
}
if !reflect.DeepEqual(got.Accounts, accounts) {
t.Fatalf("account groups: got %#v, want %#v", got.Accounts, accounts)
}
})
}
}
func TestTagFiltersApplyToPreviousPeriodAndCategoryRollups(t *testing.T) {
data := fixture()
// Mirror current transactions into the preceding month, including the
// untagged income and the multi-tag expense that must be excluded.
for _, transaction := range data.Transactions[:4] {
transaction.Facts.ID += "_previous"
transaction.Facts.Fingerprint += "_previous"
transaction.Facts.BookingDate = "2026-01-15"
data.Transactions = append(data.Transactions, transaction)
}
s := openFixture(t, data)
got := queryFixture(t, s, Filter{
From: "2026-02-01", To: "2026-02-28", Currency: "EUR",
TagIDs: []string{"tag_shared", "tag_work"}, ExcludeTagIDs: []string{"tag_shared"},
})
want := []Total{{Currency: "EUR", Expenses: "0.0009", Income: "0.0000", Net: "-0.0009"}}
if !reflect.DeepEqual(got.Totals, want) || !reflect.DeepEqual(got.Previous, want) {
t.Fatalf("period totals: current %#v, previous %#v, want %#v", got.Totals, got.Previous, want)
}
groups := []Group{
{ID: "cat_expenses", Name: "Expenses", Currency: "EUR", Amount: "-0.0009", Count: 1},
{ID: "cat_food", Name: "Food", Currency: "EUR", Amount: "-0.0009", Count: 1},
{ID: "cat_living", Name: "Living", Currency: "EUR", Amount: "-0.0009", Count: 1},
}
if !reflect.DeepEqual(got.Categories, groups) || !reflect.DeepEqual(got.PreviousCategories, groups) {
t.Fatalf("category rollups: current %#v, previous %#v, want %#v", got.Categories, got.PreviousCategories, groups)
}
}
func TestAncestorFilteringAndRollups(t *testing.T) {
s := openFixture(t, fixture())
filter := Filter{From: "2026-02-01", To: "2026-02-28", Currency: "EUR", CategoryID: "cat_living"}
+1 -1
View File
@@ -237,7 +237,7 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
respond(w, nil, errors.New("from must not exceed to"))
return
}
v, e := s.app.Dashboard(r.Context(), analytics.Filter{From: from, To: to, Currency: q.Get("currency"), AccountID: q.Get("account_id"), CategoryID: q.Get("category_id"), TagID: q.Get("tag_id"), MerchantID: q.Get("merchant_id")})
v, e := s.app.Dashboard(r.Context(), analytics.Filter{From: from, To: to, Currency: q.Get("currency"), AccountID: q.Get("account_id"), CategoryID: q.Get("category_id"), TagIDs: q["tag_ids"], ExcludeTagIDs: q["exclude_tag_ids"], MerchantID: q.Get("merchant_id")})
respond(w, v, e)
}
func (s *Server) account(w http.ResponseWriter, r *http.Request) {
+100
View File
@@ -1,6 +1,7 @@
package server
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
@@ -11,12 +12,15 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"strings"
"testing"
"testing/fstest"
"finance-duck/internal/analytics"
"finance-duck/internal/app"
"finance-duck/internal/banking"
"finance-duck/internal/domain"
)
func TestOriginAndHostGuardProtectNoLoginService(t *testing.T) {
@@ -415,3 +419,99 @@ func TestCSVImportOverHTTPImportsOnlyAfterConfirmation(t *testing.T) {
}
send("/api/import/confirm", "application/json", confirm, origin, http.StatusBadRequest)
}
func TestDashboardRepeatedTagFiltersOverHTTP(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(data *domain.Dataset) error {
data.Accounts = append(data.Accounts, domain.Account{ID: "acc_eur", DisplayName: "Current", Currency: "EUR", Active: true})
data.Tags = append(data.Tags, domain.Tag{ID: "tag_shared", Name: "Shared"}, domain.Tag{ID: "tag_work", Name: "Work"})
for _, item := range []struct {
id string
amount domain.Money
kind string
category string
tags []string
}{
{"tx_both", "-10.0000", "expense", domain.ExpenseFallback, []string{"tag_shared", "tag_work"}},
{"tx_work", "-20.0000", "expense", domain.ExpenseFallback, []string{"tag_work"}},
{"tx_income", "100.0000", "income", domain.IncomeFallback, []string{}},
} {
data.Transactions = append(data.Transactions, domain.Transaction{
Facts: domain.Facts{ID: item.id, Source: "test", AccountID: "acc_eur", BookingDate: "2026-02-10",
Amount: item.amount, Currency: "EUR", RawDescription: item.id, Fingerprint: item.id},
Enrichment: domain.Enrichment{Kind: item.kind, CategoryID: item.category, TagIDs: item.tags},
})
}
return nil
})
if err != nil {
t.Fatal(err)
}
h, err := New(a, fstest.MapFS{}, "")
if err != nil {
t.Fatal(err)
}
cases := []struct {
name string
include []string
exclude []string
want []analytics.Total
}{
{
name: "repeated includes use union without duplication",
include: []string{"tag_shared", "tag_work"},
want: []analytics.Total{{Currency: "EUR", Expenses: "30.0000", Income: "0.0000", Net: "-30.0000"}},
},
{
name: "repeated exclusions preserve untagged income",
exclude: []string{"tag_shared", "tag_work"},
want: []analytics.Total{{Currency: "EUR", Expenses: "0.0000", Income: "100.0000", Net: "100.0000"}},
},
{
name: "include and exclude compose with exclusion winning",
include: []string{"tag_shared", "tag_work"},
exclude: []string{"tag_missing", "tag_shared"},
want: []analytics.Total{{Currency: "EUR", Expenses: "20.0000", Income: "0.0000", Net: "-20.0000"}},
},
{
name: "comma separated values are not a list",
include: []string{"tag_shared,tag_work"},
want: []analytics.Total{},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
query := url.Values{"from": {"2026-02-01"}, "to": {"2026-02-28"}, "currency": {"EUR"}}
for _, id := range tt.include {
query.Add("tag_ids", id)
}
for _, id := range tt.exclude {
query.Add("exclude_tag_ids", id)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "http://localhost:8080/api/dashboard?"+query.Encode(), nil))
if w.Code != http.StatusOK {
t.Fatalf("GET dashboard: %d: %s", w.Code, w.Body.String())
}
var got analytics.Dashboard
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got.Totals, tt.want) {
t.Fatalf("totals: got %#v, want %#v", got.Totals, tt.want)
}
})
}
}