A position was a share count. An instrument now carries a market symbol and the last close fetched for it, so Wealth and the dashboard report cash plus market value instead of cash alone. The symbol is chosen by hand and never derived: one ISIN lists on several exchanges in different currencies, and a price from the wrong listing misstates wealth without failing any check. The refresh refuses a quote whose currency differs from the instrument's, keeps the previous quote when a symbol cannot be priced, and counts an instrument with no symbol as unpriced - naming it in a check and leaving it out of every total, because cost is not value. The quote belongs to the job: saving an instrument can neither set nor erase it, and changing the symbol discards it. Two things the provider forced. It answers HTTP 429 to every request whose User-Agent names a programming language, so the client identifies as a browser; without that header the first call of the day fails. Its closes are 32-bit floats widened to 64 - 165.26 arrives as 165.25999450683594 - so a figure is rounded to seven significant digits, which is what 24 mantissa bits carry; eight would have stored 165.25999 as a price. Accepted quotes are written in one commit against a revision re-read after the fetches, and nothing is committed when no quote changed. The automatic run starts shortly after launch and repeats daily on its own timer, so a sync backoff cannot delay it and prices arrive with no bank connected. Verified against live quotes end to end: 80 shares at 125.45 and 40 at 165.26 on 6000.00 cash report 22646.40 with one holding named as unpriced; giving that holding a symbol through the UI moves the figure to 23530.50, and a second refresh leaves the revision untouched.
403 lines
12 KiB
Go
403 lines
12 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"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
|
|
// 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)
|
|
}
|