Import the longest bank-permitted history and surface real provider errors

Manual history imports failed opaquely once a bank capped lookback on an
established consent (N26 rejects date_from beyond ~90 days with
WRONG_TRANSACTIONS_PERIOD). Backfill now requests the documented longest
fetching strategy, reports the coverage the bank actually provided, and
non-2xx responses surface allowlisted documented error codes instead of a
generic fallback. Dead-session codes map to reconnection. Failed syncs
retry hourly so a stale sync banner no longer persists for a day.
This commit is contained in:
Lars Nolden
2026-09-10 22:58:03 +02:00
parent 4324660888
commit a8722d58c3
7 changed files with 213 additions and 36 deletions
+44 -10
View File
@@ -16,8 +16,13 @@ import (
)
type ImportResult struct {
Imported int `json:"imported"`
State State `json:"state"`
Imported int `json:"imported"`
// RequestedFrom and EarliestFetched describe manual history retrieval:
// the requested window start, and the oldest booking date the bank
// actually returned (empty when it returned nothing).
RequestedFrom string `json:"requested_from,omitempty"`
EarliestFetched string `json:"earliest_fetched,omitempty"`
State State `json:"state"`
}
func addProposal(d *domain.Dataset, p classification.Proposal) error {
@@ -147,13 +152,27 @@ func (a *App) Backfill(ctx context.Context, rev, accountID string, historyMonths
return ImportResult{}, banking.ErrReconnect
}
now := time.Now().UTC()
facts, err := a.bank.Transactions(ctx, account, now.AddDate(0, -historyMonths, 0).Format("2006-01-02"), now.Format("2006-01-02"))
from := now.AddDate(0, -historyMonths, 0).Format("2006-01-02")
// The longest fetching strategy imports whatever period the bank still
// permits: many banks cap history on an established consent instead of
// serving the full requested range.
facts, err := a.bank.Transactions(ctx, account, from, now.Format("2006-01-02"), true)
if err != nil {
return ImportResult{}, bankFailure(err, "transaction retrieval failed; retry importing history")
}
// Use normal import processing without changing sync cursors or saved consent
// settings, including when the requested range adds no transactions.
return a.importFacts(ctx, s, facts)
result, err := a.importFacts(ctx, s, facts)
if err != nil {
return ImportResult{}, err
}
result.RequestedFrom = from
for _, f := range facts {
if result.EarliestFetched == "" || f.BookingDate < result.EarliestFetched {
result.EarliestFetched = f.BookingDate
}
}
return result, nil
}
func (a *App) Authorize(ctx context.Context, institution, country string, historyMonths int) (string, error) {
a.mu.Lock()
@@ -310,6 +329,10 @@ func bankFailure(err error, fallback string) error {
if errors.Is(err, banking.ErrReconnect) {
return banking.ErrReconnect
}
var api *banking.APIError
if errors.As(err, &api) {
return api
}
return errors.New(fallback)
}
@@ -412,7 +435,7 @@ func (a *App) Sync(ctx context.Context) (State, error) {
months := a.ops.Consents[accountSession[account.ID]].historyMonths()
from = now.AddDate(0, -months, 0).Format("2006-01-02")
}
facts, e := a.bank.Transactions(ctx, account, from, to)
facts, e := a.bank.Transactions(ctx, account, from, to, false)
if e != nil {
meta := a.ops.Consents[sessionID]
meta.Error = bankFailure(e, "transaction retrieval failed; retry synchronization").Error()
@@ -457,13 +480,24 @@ func (a *App) RunScheduler(ctx context.Context) {
a.mu.Lock()
configured := a.bank != nil
last, err := time.Parse(time.RFC3339, a.ops.LastSync)
due := force || err != nil || time.Since(last) >= 24*time.Hour
failed := a.ops.SyncError != ""
a.mu.Unlock()
if configured && due {
a.Sync(ctx)
timer.Reset(24 * time.Hour)
} else {
due := force || err != nil || failed || time.Since(last) >= 24*time.Hour
if !configured || !due {
timer.Reset(time.Minute)
continue
}
a.Sync(ctx)
a.mu.Lock()
failed = a.ops.SyncError != ""
a.mu.Unlock()
if failed {
// A failed sync leaves its persisted error banner behind. Retry
// hourly so transient provider failures clear without waiting a
// day, while bounding unattended traffic toward the provider.
timer.Reset(time.Hour)
} else {
timer.Reset(24 * time.Hour)
}
}
}