Let imports opt out of AI classification
Classification preferences gains "Classify newly imported transactions with AI", stored as classify_on_import in config.toml and on by default, so existing configurations keep their behaviour. It covers CSV imports and bank synchronization alike. With it off, no import path contacts the provider: classification falls to the new provider-free rules path, where an opted-in merchant rule still applies its category and tags, an alias match still attaches its merchant, and everything else arrives on the editable fallback without a provenance error that would suggest the provider had failed. Analyse remains available on demand.
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"finance-duck/internal/classification"
|
||||
@@ -17,6 +18,9 @@ const n26Statement = "Booking Date,Value Date,Partner Name,Partner IBAN,Type,Pay
|
||||
"2026-09-01,2026-08-31,Cafe,DE02120300000000202051,Card,Lunch,Main,-12.30\n" +
|
||||
"2026-09-02,2026-09-02,Employer,DE89370400440532013000,Transfer,Salary,Main,2400.00\n"
|
||||
|
||||
const laterStatement = "Booking Date,Value Date,Partner Name,Partner IBAN,Type,Payment Reference,Account Name,Amount (EUR)\n" +
|
||||
"2026-10-01,2026-10-01,Bakery,DE02120300000000202051,Card,Breakfast,Main,-4.50\n"
|
||||
|
||||
// unmappedStatement uses columns no preset recognizes, so it needs a proposed
|
||||
// mapping: German booking dates, split Soll/Haben money and a free-text note.
|
||||
const unmappedStatement = "Datum;Empfänger;Soll;Haben;Notiz\n" +
|
||||
@@ -300,3 +304,96 @@ func TestConfirmationRequiresTheReviewedJournalRevision(t *testing.T) {
|
||||
t.Fatalf("rejected confirmations imported %d transactions", len(current.Data.Transactions))
|
||||
}
|
||||
}
|
||||
|
||||
// Switching AI classification off for imports must stop every provider call
|
||||
// while leaving deterministic merchant rules, and the imported facts, intact.
|
||||
func TestAIClassificationOnImportCanBeSwitchedOff(t *testing.T) {
|
||||
a, s := testApp(t)
|
||||
var calls atomic.Int32
|
||||
mockClassifier(t, a, func(*http.Request) { calls.Add(1) })
|
||||
ctx := context.Background()
|
||||
s, err := a.Mutate(ctx, s.Revision, func(d *domain.Dataset) error {
|
||||
d.Merchants = append(d.Merchants, domain.Merchant{
|
||||
ID: "mer_cafe", Name: "Cafe", Aliases: []string{},
|
||||
DefaultCategoryID: "groceries", DefaultTagIDs: []string{"home"}, UseDefaults: true,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.Settings.ClassifyOnImport {
|
||||
t.Fatal("imports must classify by default")
|
||||
}
|
||||
s, err = a.SaveSettings(ctx, Settings{Model: "test/model", ClassifyOnImport: false})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Settings.ClassifyOnImport {
|
||||
t.Fatal("saved preference did not switch classification off")
|
||||
}
|
||||
prepared := prepare(t, a, s, n26Statement)
|
||||
result, err := a.ConfirmCSVImport(ctx, prepared.ID, prepared.Revision)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Imported != 2 || calls.Load() != 0 {
|
||||
t.Fatalf("import contacted the provider %d times with classification off", calls.Load())
|
||||
}
|
||||
for _, tx := range result.State.Data.Transactions {
|
||||
switch tx.Facts.RawDescription {
|
||||
case "Lunch":
|
||||
if tx.Enrichment.MerchantID != "mer_cafe" || tx.Enrichment.CategoryID != "groceries" ||
|
||||
!reflect.DeepEqual(tx.Enrichment.TagIDs, []string{"home"}) || tx.Enrichment.Classification.Source != "rule" {
|
||||
t.Fatalf("merchant rule did not apply without AI: %+v", tx.Enrichment)
|
||||
}
|
||||
default:
|
||||
// No rule and no AI is not a failure: the record stays editable
|
||||
// without a provenance error suggesting the provider failed.
|
||||
if tx.Enrichment.CategoryID != domain.IncomeFallback || tx.Enrichment.Classification.Source != "fallback" || tx.Enrichment.Classification.Error != "" {
|
||||
t.Fatalf("unclassified import reported a failure: %+v", tx.Enrichment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The preference is a stored preference, not a session flag.
|
||||
a = reopenBankingApp(t, a)
|
||||
mockClassifier(t, a, func(*http.Request) { calls.Add(1) })
|
||||
restarted, err := a.Snapshot(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if restarted.Settings.ClassifyOnImport || restarted.Settings.Model != "test/model" {
|
||||
t.Fatalf("preferences did not survive restart: %+v", restarted.Settings)
|
||||
}
|
||||
prepared = prepare(t, a, restarted, laterStatement)
|
||||
if _, err = a.ConfirmCSVImport(ctx, prepared.ID, prepared.Revision); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls.Load() != 0 {
|
||||
t.Fatalf("restarted import contacted the provider %d times", calls.Load())
|
||||
}
|
||||
|
||||
// Switching it back on classifies subsequent imports again.
|
||||
enabled, err := a.SaveSettings(ctx, Settings{Model: "test/model", ClassifyOnImport: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prepared = prepare(t, a, enabled, "Booking Date,Partner Name,Type,Payment Reference,Amount (EUR)\n2026-11-02,Market,Card,REWE Groceries,-21.40\n")
|
||||
final, err := a.ConfirmCSVImport(ctx, prepared.ID, prepared.Revision)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("re-enabled import made %d provider requests", calls.Load())
|
||||
}
|
||||
classified := false
|
||||
for _, tx := range final.State.Data.Transactions {
|
||||
if tx.Facts.RawDescription == "REWE Groceries" {
|
||||
classified = tx.Enrichment.Classification.Source == "openrouter" && tx.Enrichment.CategoryID == "groceries"
|
||||
}
|
||||
}
|
||||
if !classified {
|
||||
t.Fatal("re-enabled classification did not reach the new transaction")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user