Two of three banks were only pacing us, yet the dashboard demanded attention, printed four nested wrappers and a nanosecond UTC deadline, and the scheduler retried hourly into a refusal whose end time the bank had already given. A rate limit now carries its retry time as data: Status.SyncRetryAt is set when every failure is self-clearing, the connection reports rate_limited with that deadline, the dashboard says synchronization resumes by itself and renders the time in the browser's zone, and the scheduler sleeps until the deadline instead of spending hourly session checks. Sync now still tries immediately. The third bank's "transaction retrieval failed" hid its cause. Provider failures Finance Duck determines itself are typed as banking.ProviderError, so an unreachable provider, a timeout or an unusable response, such as a booked transaction without a booking date, is reported instead of the opaque fallback. Provider response text still never reaches the message.
355 lines
11 KiB
Go
355 lines
11 KiB
Go
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"`
|
|
// 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
|
|
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 = ""
|
|
}
|
|
}
|
|
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 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)
|
|
}
|