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.
205 lines
6.3 KiB
Go
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)
|
|
}
|