package app import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "strconv" "strings" "sync" "time" "unicode/utf8" "finance-duck/internal/analytics" "finance-duck/internal/banking" "finance-duck/internal/classification" "finance-duck/internal/domain" "finance-duck/internal/journal" "finance-duck/internal/quotes" ) // Settings holds preferences only, never credentials. ClassifyOnImport controls // whether newly imported transactions are sent to the model at all; merchant // rules always apply. PrivateNames is a semicolon-separated household redaction // list when persisted in config.toml. type Settings struct { Model string `json:"model"` ClassifyOnImport bool `json:"classify_on_import"` PrivateNames []string `json:"private_names"` } type Status struct { SyncError string `json:"sync_error"` IndexError string `json:"index_error"` LastSync string `json:"last_sync"` // SyncRetryAt is set only when every sync failure is a bank rate limit that // clears on its own; it is the earliest time an automatic retry is allowed. SyncRetryAt string `json:"sync_retry_at,omitempty"` BankingConfigured bool `json:"banking_configured"` AIConfigured bool `json:"ai_configured"` } type State struct { Data domain.Dataset `json:"data"` Revision string `json:"revision"` Status Status `json:"status"` Settings Settings `json:"settings"` Sessions []banking.Session `json:"sessions"` CallbackURL string `json:"callback_url"` BankingAppID string `json:"banking_app_id"` Connections []Connection `json:"connections"` } type operational struct { Sessions []banking.Session `json:"sessions"` LastSync string `json:"last_sync"` SyncError string `json:"sync_error"` SyncRetryAt string `json:"sync_retry_at,omitempty"` Consents map[string]Consent `json:"consents"` AccountSync map[string]string `json:"account_sync"` BankingScope string `json:"banking_scope"` } type App struct { mu sync.Mutex dir string journal *journal.Store index *analytics.Store indexed string indexError string settings Settings ops operational bank banking.Provider classifier classification.Client previews map[string]Preview previewRun *previewJob taxonomies map[string]TaxonomyPreview csvImports map[string]CSVImport authStates map[string]authorization callbackURL string bankingSettings bankingSettings verifiedModels []classification.VerifiedModel verifiedModelsAt time.Time // quotes needs no configuration: it reads a public endpoint, so its zero // value is the working client and tests replace it with a stub. quotes quotes.Client syncRequested chan struct{} } // Settings this application has retired. They are read and discarded: a // config.toml written by an older binary must never stop the new one from // starting, and the next SaveSettings rewrites the file without them. An // unrecognised key is still refused, so a typo cannot silently lose a // preference. var retiredSettings = map[string]bool{"include_amount": true} func Open(dir string) (*App, error) { j, err := journal.Open(dir) if err != nil { return nil, err } a := &App{dir: dir, journal: j, previews: make(map[string]Preview), taxonomies: make(map[string]TaxonomyPreview), 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) } if err = os.MkdirAll(filepath.Join(dir, "cache"), 0700); err != nil { return fail(err) } if b, e := os.ReadFile(filepath.Join(dir, "config.toml")); e == nil { for n, line := range strings.Split(string(b), "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } k, v, ok := strings.Cut(line, "=") if !ok { return fail(fmt.Errorf("config.toml:%d: expected key = value", n+1)) } k = strings.TrimSpace(k) v = strings.TrimSpace(v) switch k { case "classification_model": a.settings.Model, err = strconv.Unquote(v) case "private_names": a.settings.PrivateNames, err = parseNames(v) case "classify_on_import": a.settings.ClassifyOnImport, err = strconv.ParseBool(v) default: if !retiredSettings[k] { err = fmt.Errorf("unknown setting %q", k) } } if err != nil { return fail(fmt.Errorf("config.toml:%d: %w", n+1, err)) } } } else if !os.IsNotExist(e) { return fail(e) } if b, e := os.ReadFile(filepath.Join(dir, "state", "sync-state.json")); e == nil { if err = json.Unmarshal(b, &a.ops); err != nil { return fail(fmt.Errorf("sync state: %w", err)) } } else if !os.IsNotExist(e) { return fail(e) } if a.ops.Consents == nil { a.ops.Consents = make(map[string]Consent) } if a.ops.AccountSync == nil { a.ops.AccountSync = make(map[string]string) } apiKey, err := loadOpenRouterKey(filepath.Join(dir, "state", "openrouter.json")) if err != nil { return fail(err) } a.classifier = classification.Client{APIKey: apiKey, Model: a.settings.Model, PrivateNames: append([]string{}, a.settings.PrivateNames...)} if err = a.loadBankingSettings(); err != nil { return fail(err) } a.index, err = analytics.Open(filepath.Join(dir, "cache", "finance.duckdb")) if err != nil { return fail(err) } if _, err = a.snapshot(context.Background()); err != nil { a.index.Close() return fail(err) } return a, nil } func (a *App) Close() error { a.mu.Lock() defer a.mu.Unlock() return errors.Join(a.index.Close(), a.journal.Close()) } func (a *App) snapshot(ctx context.Context) (State, error) { d, rev, err := a.journal.Load() if err != nil { return State{}, err } if rev != a.indexed { if err = a.index.Rebuild(ctx, d); err != nil { a.indexError = err.Error() } else { a.indexed = rev a.indexError = "" } } status := Status{SyncError: a.ops.SyncError, SyncRetryAt: a.ops.SyncRetryAt, LastSync: a.ops.LastSync, IndexError: a.indexError, BankingConfigured: a.bank != nil, AIConfigured: a.classifier.APIKey != ""} return State{Data: d, Revision: rev, Settings: a.settings, Sessions: copySessions(a.ops.Sessions), CallbackURL: a.callbackURL, BankingAppID: a.bankingSettings.AppID, Connections: a.connections(d), Status: status}, nil } func (a *App) Snapshot(ctx context.Context) (State, error) { a.mu.Lock() defer a.mu.Unlock() return a.snapshot(ctx) } func (a *App) commit(ctx context.Context, rev string, d domain.Dataset) (State, error) { if rev == "" { return State{}, errors.New("revision is required") } if _, err := a.journal.Commit(rev, d); err != nil { return State{}, err } return a.snapshot(ctx) } func (a *App) Mutate(ctx context.Context, rev string, fn func(*domain.Dataset) error) (State, error) { a.mu.Lock() defer a.mu.Unlock() s, err := a.snapshot(ctx) if err != nil { return State{}, err } if rev != s.Revision { return State{}, errors.New("revision conflict: reload before editing") } if err = fn(&s.Data); err != nil { return State{}, err } return a.commit(ctx, rev, s.Data) } func (a *App) Dashboard(ctx context.Context, f analytics.Filter) (analytics.Dashboard, error) { a.mu.Lock() defer a.mu.Unlock() if _, err := a.snapshot(ctx); err != nil { return analytics.Dashboard{}, err } if a.indexError != "" { return analytics.Dashboard{}, errors.New(a.indexError) } return a.index.Query(ctx, f) } func (a *App) Rebuild(ctx context.Context) (State, error) { a.mu.Lock() defer a.mu.Unlock() a.indexed = "" return a.snapshot(ctx) } func atomicFile(path string, b []byte) error { f, err := os.CreateTemp(filepath.Dir(path), ".state-*") if err != nil { return err } name := f.Name() defer os.Remove(name) if err = f.Chmod(0600); err == nil { _, err = f.Write(b) } if err == nil { err = f.Sync() } err = errors.Join(err, f.Close()) if err != nil { return err } if err = os.Rename(name, path); err != nil { return err } dir, err := os.Open(filepath.Dir(path)) if err != nil { return err } defer dir.Close() return dir.Sync() } func (a *App) saveOps() error { b, err := json.MarshalIndent(a.ops, "", " ") if err != nil { return err } return atomicFile(filepath.Join(a.dir, "state", "sync-state.json"), append(b, '\n')) } func normalizeOpenRouterKey(key string) (string, error) { key = strings.TrimSpace(key) const invalid = "OpenRouter API key must be at most 4096 bytes and contain only non-whitespace ASCII characters" if len(key) > 4096 { return "", errors.New(invalid) } for i := range len(key) { if key[i] < 0x21 || key[i] > 0x7e { return "", errors.New(invalid) } } return key, nil } func normalizePrivateNames(values []string) ([]string, error) { out := make([]string, 0, len(values)) for _, raw := range values { if !utf8.ValidString(raw) { return nil, errors.New("private names must be valid UTF-8") } name := strings.Join(strings.Fields(raw), " ") if name == "" { continue } if utf8.RuneCountInString(name) > 200 || strings.ContainsRune(name, ';') { return nil, errors.New("private names must be at most 200 characters and cannot contain semicolons") } out = append(out, name) } return out, nil } func parseNames(v string) ([]string, error) { raw, err := strconv.Unquote(v) if err != nil { return nil, err } return normalizePrivateNames(strings.Split(raw, ";")) } func loadOpenRouterKey(path string) (string, error) { f, err := os.Open(path) if os.IsNotExist(err) { return normalizeOpenRouterKey(os.Getenv("OPENROUTER_API_KEY")) } if err != nil { return "", errors.New("cannot read saved OpenRouter credential") } defer f.Close() // Bound encoded storage too, allowing JSON escapes for a maximum-size key. b, err := io.ReadAll(io.LimitReader(f, 32*1024+1)) if err != nil { return "", errors.New("cannot read saved OpenRouter credential") } invalid := errors.New("invalid saved OpenRouter credential") if len(b) > 32*1024 { return "", invalid } // Require exactly one case-sensitive string field, rejecting duplicates, // unknown fields, null, and trailing JSON rather than silently disabling AI. dec := json.NewDecoder(bytes.NewReader(b)) if token, err := dec.Token(); err != nil || token != json.Delim('{') { return "", invalid } if token, err := dec.Token(); err != nil || token != "api_key" { return "", invalid } token, err := dec.Token() key, ok := token.(string) if err != nil || !ok { return "", invalid } if token, err := dec.Token(); err != nil || token != json.Delim('}') { return "", invalid } if _, err := dec.Token(); err != io.EOF { return "", invalid } key, err = normalizeOpenRouterKey(key) if err != nil { return "", invalid } return key, nil } func (a *App) SaveOpenRouterKey(ctx context.Context, key string) (State, error) { a.mu.Lock() defer a.mu.Unlock() key, err := normalizeOpenRouterKey(key) if err != nil { return State{}, err } b, err := json.Marshal(struct { APIKey string `json:"api_key"` }{APIKey: key}) if err != nil { return State{}, errors.New("cannot encode OpenRouter credential") } if err := atomicFile(filepath.Join(a.dir, "state", "openrouter.json"), append(b, '\n')); err != nil { return State{}, errors.New("cannot save OpenRouter credential") } a.classifier.APIKey = key return a.snapshot(ctx) } func (a *App) SaveSettings(ctx context.Context, s Settings) (State, error) { a.mu.Lock() defer a.mu.Unlock() s.Model = strings.TrimSpace(s.Model) if len(s.Model) > 200 { return State{}, errors.New("model name is too long") } names, err := normalizePrivateNames(s.PrivateNames) if err != nil { return State{}, err } s.PrivateNames = names b := []byte("# Preferences only. Manage secrets in Settings or environment variables, never this file.\n" + "classification_model = " + strconv.Quote(s.Model) + "\n" + "private_names = " + strconv.Quote(strings.Join(s.PrivateNames, "; ")) + "\n" + "classify_on_import = " + strconv.FormatBool(s.ClassifyOnImport) + "\n") if err := atomicFile(filepath.Join(a.dir, "config.toml"), b); err != nil { return State{}, err } a.settings = s a.classifier.Model = s.Model a.classifier.PrivateNames = append([]string{}, s.PrivateNames...) return a.snapshot(ctx) }