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:
Lars Nolden
2026-09-11 18:10:05 +02:00
parent dc767799bc
commit dece0d5b79
10 changed files with 217 additions and 30 deletions
+15 -3
View File
@@ -20,9 +20,13 @@ import (
"finance-duck/internal/journal"
)
// Settings holds preferences only, never credentials. ClassifyOnImport controls
// whether newly imported transactions are sent to the model at all; merchant
// rules always apply.
type Settings struct {
Model string `json:"model"`
IncludeAmount bool `json:"include_amount"`
Model string `json:"model"`
IncludeAmount bool `json:"include_amount"`
ClassifyOnImport bool `json:"classify_on_import"`
}
type Status struct {
SyncError string `json:"sync_error"`
@@ -74,6 +78,9 @@ func Open(dir string) (*App, error) {
return nil, err
}
a := &App{dir: dir, journal: j, previews: make(map[string]Preview), csvImports: make(map[string]CSVImport), authStates: make(map[string]authorization), syncRequested: make(chan struct{}, 1)}
// Configurations written before this preference existed keep classifying
// imports; only an explicit key switches it off.
a.settings.ClassifyOnImport = true
fail := func(e error) (*App, error) { j.Close(); return nil, e }
if err = os.MkdirAll(filepath.Join(dir, "state"), 0700); err != nil {
return fail(err)
@@ -98,6 +105,8 @@ func Open(dir string) (*App, error) {
a.settings.Model, err = strconv.Unquote(v)
case "include_amount":
a.settings.IncludeAmount, err = strconv.ParseBool(v)
case "classify_on_import":
a.settings.ClassifyOnImport, err = strconv.ParseBool(v)
default:
err = fmt.Errorf("unknown setting %q", k)
}
@@ -326,7 +335,10 @@ func (a *App) SaveSettings(ctx context.Context, s Settings) (State, error) {
if len(s.Model) > 200 {
return State{}, errors.New("model name is too long")
}
b := []byte("# Preferences only. Manage secrets in Settings or environment variables, never this file.\nclassification_model = " + strconv.Quote(s.Model) + "\ninclude_amount = " + strconv.FormatBool(s.IncludeAmount) + "\n")
b := []byte("# Preferences only. Manage secrets in Settings or environment variables, never this file.\n" +
"classification_model = " + strconv.Quote(s.Model) + "\n" +
"include_amount = " + strconv.FormatBool(s.IncludeAmount) + "\n" +
"classify_on_import = " + strconv.FormatBool(s.ClassifyOnImport) + "\n")
if err := atomicFile(filepath.Join(a.dir, "config.toml"), b); err != nil {
return State{}, err
}
+97
View File
@@ -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")
}
}
+6 -1
View File
@@ -58,7 +58,12 @@ func (a *App) importFacts(ctx context.Context, s State, facts []domain.Facts) (I
if !ids[t.Facts.ID] || t.Enrichment.Kind == "transfer" {
continue
}
p, e := a.classifier.Classify(ctx, t.Facts, s.Data, false)
// With AI classification off for imports, no provider is contacted at
// all: deterministic merchant rules still apply.
p, e := classification.Rules(t.Facts, s.Data)
if a.settings.ClassifyOnImport {
p, e = a.classifier.Classify(ctx, t.Facts, s.Data, false)
}
if e == nil {
e = addProposal(&s.Data, p)
}