Files
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

113 lines
3.3 KiB
Go

package app
import (
"slices"
"time"
"finance-duck/internal/banking"
"finance-duck/internal/domain"
)
const defaultHistoryMonths = 12
type authorization struct {
Expires time.Time
Institution string
Country string
PSUType string
HistoryMonths int
}
type Consent struct {
Institution string `json:"institution"`
Country string `json:"country"`
// PSUType records the account-holder kind this consent was authorized for
// so reconnecting reuses it: a business account authorized as personal
// shares no accounts.
PSUType string `json:"psu_type,omitempty"`
HistoryMonths int `json:"history_months"`
Error string `json:"error,omitempty"`
NeedsReconnect bool `json:"needs_reconnect"`
// RetryAt is the bank's own retry time while it rate limits this consent.
RetryAt string `json:"retry_at,omitempty"`
}
type Connection struct {
AccountID string `json:"account_id"`
Institution string `json:"institution"`
Country string `json:"country"`
PSUType string `json:"psu_type"`
HistoryMonths int `json:"history_months"`
Status string `json:"status"`
ValidUntil string `json:"valid_until"`
Error string `json:"error"`
RetryAt string `json:"retry_at,omitempty"`
}
// psuType keeps legacy consents, which predate the choice, on the personal
// flow they were originally authorized with.
func (c Consent) psuType() string {
if !banking.ValidPSUType(c.PSUType) {
return banking.PSUPersonal
}
return c.PSUType
}
func (c Consent) historyMonths() int {
if c.HistoryMonths == 0 {
return defaultHistoryMonths
}
return c.HistoryMonths
}
func (a *App) connections(d domain.Dataset) []Connection {
out := make([]Connection, 0, len(d.Accounts))
for _, account := range d.Accounts {
c := Connection{AccountID: account.ID, Institution: account.Institution, Country: "DE", PSUType: banking.PSUPersonal, HistoryMonths: defaultHistoryMonths, Status: "local"}
if account.ExternalAccountID != "" {
c.Status = "reconnect_required"
c.Error = "No saved bank consent; reconnect this account"
}
for _, session := range a.ops.Sessions {
for _, linked := range session.Accounts {
if linked.ID != account.ID {
continue
}
meta := a.ops.Consents[session.ID]
c.HistoryMonths = meta.historyMonths()
c.PSUType = meta.psuType()
if meta.Institution != "" {
c.Institution = meta.Institution
}
if meta.Country != "" {
c.Country = meta.Country
}
c.ValidUntil = session.ValidUntil
c.Error = meta.Error
c.RetryAt = meta.RetryAt
c.Status = "connected"
expiry, err := time.Parse(time.RFC3339, session.ValidUntil)
if meta.NeedsReconnect || err != nil || !expiry.After(time.Now()) {
c.Status = "reconnect_required"
if c.Error == "" {
c.Error = "Bank consent expired; reconnect to resume automatic imports"
}
} else if meta.RetryAt != "" {
// A rate limit is the bank pacing us, not a broken connection.
c.Status = "rate_limited"
} else if meta.Error != "" {
c.Status = "error"
}
}
}
out = append(out, c)
}
return out
}
func copySessions(sessions []banking.Session) []banking.Session {
out := append([]banking.Session{}, sessions...)
for i := range out {
out[i].Accounts = slices.Clone(out[i].Accounts)
}
return out
}