Files
finance-duck/internal/app/banking_settings.go
T
Lars Nolden b3e1c65a82 Report a rate-limited bank sync as a wait, and name real failures
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.
2026-09-11 18:41:35 +02:00

205 lines
6.3 KiB
Go

package app
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"finance-duck/internal/banking"
"finance-duck/internal/domain"
)
// Scope binds operational sessions to an application generation. It is committed
// with credentials, so a crash before saving sync-state cannot revive old sessions.
type bankingSettings struct {
AppID string `json:"app_id"`
RedirectURL string `json:"redirect_url"`
PrivateKey string `json:"private_key"`
Disabled bool `json:"disabled"`
Scope string `json:"scope"`
}
func (a *App) clearBankingSessions(scope string) {
a.ops.Sessions = nil
a.ops.Consents = make(map[string]Consent)
a.ops.AccountSync = make(map[string]string)
a.ops.LastSync = ""
a.ops.SyncError = ""
a.ops.SyncRetryAt = ""
a.ops.BankingScope = scope
}
func (a *App) loadBankingSettings() error {
path := filepath.Join(a.dir, "state", "enablebanking.json")
f, err := os.Open(path)
var cfg bankingSettings
var provider *banking.EnableBanking
fromEnvironment := os.IsNotExist(err)
if fromEnvironment {
cfg.AppID = os.Getenv("ENABLEBANKING_APP_ID")
cfg.RedirectURL = os.Getenv("ENABLEBANKING_REDIRECT_URL")
keyFile := os.Getenv("ENABLEBANKING_KEY_FILE")
cfg.Disabled = cfg.AppID == "" && cfg.RedirectURL == "" && keyFile == ""
if !cfg.Disabled {
if cfg.AppID == "" || cfg.RedirectURL == "" || keyFile == "" {
return errors.New("Enable Banking requires APP_ID, KEY_FILE and REDIRECT_URL environment variables")
}
key, e := os.Open(keyFile)
if e != nil {
return errors.New("cannot read Enable Banking private key")
}
b, e := io.ReadAll(io.LimitReader(key, banking.MaxPrivateKeyPEM+1))
key.Close()
if e != nil {
return errors.New("cannot read Enable Banking private key")
}
cfg.PrivateKey = string(b)
}
// A stable environment identity detects app-ID changes on restart while
// retaining sessions through key or callback rotation of the same app.
hash := sha256.Sum256([]byte(cfg.AppID))
cfg.Scope = "env_" + hex.EncodeToString(hash[:])
} else {
if err != nil {
return errors.New("cannot read saved Enable Banking settings")
}
defer f.Close()
const limit = 256 * 1024 // Allows JSON escaping of a maximum-size PEM.
b, e := io.ReadAll(io.LimitReader(f, limit+1))
invalid := errors.New("invalid saved Enable Banking settings")
if e != nil || len(b) > limit {
return invalid
}
decoder := json.NewDecoder(bytes.NewReader(b))
if token, e := decoder.Token(); e != nil || token != json.Delim('{') {
return invalid
}
seen := make(map[string]bool, 5)
for decoder.More() {
token, e := decoder.Token()
name, ok := token.(string)
if e != nil || !ok || seen[name] {
return invalid
}
seen[name] = true
value, e := decoder.Token()
if e != nil {
return invalid
}
if name == "disabled" {
cfg.Disabled, ok = value.(bool)
} else {
var text string
text, ok = value.(string)
switch name {
case "app_id":
cfg.AppID = text
case "redirect_url":
cfg.RedirectURL = text
case "private_key":
cfg.PrivateKey = text
case "scope":
cfg.Scope = text
default:
return invalid
}
}
if !ok {
return invalid
}
}
if token, e := decoder.Token(); e != nil || token != json.Delim('}') {
return invalid
}
if _, e := decoder.Token(); e != io.EOF || len(seen) != 5 || cfg.Scope == "" || len(cfg.Scope) > 256 {
return invalid
}
if cfg.Disabled && (cfg.AppID != "" || cfg.RedirectURL != "" || cfg.PrivateKey != "") {
return invalid
}
}
if !cfg.Disabled {
provider, err = banking.NewEnableBanking(cfg.AppID, []byte(cfg.PrivateKey), cfg.RedirectURL)
if err != nil {
return errors.New("invalid Enable Banking settings")
}
}
if a.ops.BankingScope != cfg.Scope {
if a.ops.BankingScope == "" && fromEnvironment && !cfg.Disabled {
// Legacy sessions predate Settings and belong to the validated env app.
a.ops.BankingScope = cfg.Scope
} else {
a.clearBankingSessions(cfg.Scope)
}
// Fail closed if legacy binding or mismatch invalidation cannot persist.
if err = a.saveOps(); err != nil {
return errors.New("cannot bind Enable Banking sessions")
}
}
a.bankingSettings = cfg
a.callbackURL = cfg.RedirectURL
if provider != nil {
a.bank = provider
}
return nil
}
func (a *App) SaveBankingSettings(ctx context.Context, appID string, privateKey *string, redirectURL string) (State, error) {
a.mu.Lock()
defer a.mu.Unlock()
key := ""
if privateKey != nil {
key = *privateKey
} else if !a.bankingSettings.Disabled && appID == a.bankingSettings.AppID {
key = a.bankingSettings.PrivateKey
}
if key == "" {
return State{}, errors.New("an Enable Banking private key is required for this application")
}
provider, err := banking.NewEnableBanking(appID, []byte(key), redirectURL)
if err != nil {
return State{}, err
}
cfg := bankingSettings{AppID: appID, PrivateKey: key, RedirectURL: redirectURL, Scope: a.bankingSettings.Scope}
if a.bankingSettings.Disabled || appID != a.bankingSettings.AppID || cfg.Scope == "" {
cfg.Scope = domain.NewID("bank")
}
return a.persistBankingSettings(ctx, cfg, provider)
}
func (a *App) RemoveBankingSettings(ctx context.Context) (State, error) {
a.mu.Lock()
defer a.mu.Unlock()
return a.persistBankingSettings(ctx, bankingSettings{Disabled: true, Scope: domain.NewID("bank")}, nil)
}
// Caller holds mu. Only the credential file must commit: an older sync-state
// remains unusable because its scope differs. The next operational save or Open
// writes the cleared sessions, without a fallible two-file transaction here.
func (a *App) persistBankingSettings(ctx context.Context, cfg bankingSettings, provider *banking.EnableBanking) (State, error) {
b, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return State{}, errors.New("cannot encode Enable Banking settings")
}
if err = atomicFile(filepath.Join(a.dir, "state", "enablebanking.json"), append(b, '\n')); err != nil {
return State{}, errors.New("cannot save Enable Banking settings")
}
if cfg.Scope != a.ops.BankingScope {
a.clearBankingSessions(cfg.Scope)
}
a.authStates = make(map[string]authorization)
a.bankingSettings = cfg
a.callbackURL = cfg.RedirectURL
a.bank = nil
if provider != nil {
a.bank = provider
}
return a.snapshot(ctx)
}