package app import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "strconv" "strings" "sync" "finance-duck/internal/analytics" "finance-duck/internal/banking" "finance-duck/internal/classification" "finance-duck/internal/domain" "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"` ClassifyOnImport bool `json:"classify_on_import"` } type Status struct { SyncError string `json:"sync_error"` IndexError string `json:"index_error"` LastSync string `json:"last_sync"` 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"` 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 csvImports map[string]CSVImport authStates map[string]authorization callbackURL string bankingSettings bankingSettings syncRequested chan struct{} } 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), 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 "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) } 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, IncludeAmount: a.settings.IncludeAmount} 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 = "" } } 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{SyncError: a.ops.SyncError, LastSync: a.ops.LastSync, IndexError: a.indexError, BankingConfigured: a.bank != nil, AIConfigured: a.classifier.APIKey != ""}}, 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 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") } 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 } a.settings = s a.classifier.Model = s.Model a.classifier.IncludeAmount = s.IncludeAmount return a.snapshot(ctx) }