diff --git a/OPERATIONS.txt b/OPERATIONS.txt index 4ac44ee..54249d9 100644 --- a/OPERATIONS.txt +++ b/OPERATIONS.txt @@ -275,7 +275,8 @@ errors are displayed separately from expired consent. Only booked transactions are persisted. Each new account initially requests the selected number of calendar months (12 by default); the bank may provide less. -Daily sync deliberately overlaps each account's last successful sync by 14 days. +Automatic sync runs twice a day, every 12 hours from the last successful run, +and deliberately overlaps each account's last successful sync by 14 days. Per-account cursors prevent another account's recent sync from skipping a new account's history. Reconnection preserves the saved history choice and existing cursors. Changing the choice or reconnecting does not backfill already-synced diff --git a/README.md b/README.md index ae1d849..ee136b0 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ Open **Accounts → Connect your bank**: Finance Duck verifies the callback state, exchanges the returned code for a `session_id`, stores the session locally with restrictive permissions, and wakes the synchronization worker immediately. You do not need to copy the code or session ID manually. -Initial synchronization requests the selected number of **calendar months of booked transactions per account**, defaulting to **12 months**. The bank may provide less history. The choice is saved with the bank connection and reused on reconnection. Subsequent daily synchronization overlaps each account's last successful sync by **14 days**. **Sync now** starts a manual synchronization. Existing accounts keep their successful-sync cursors: changing the history choice or reconnecting does **not** backfill them. Older history can be imported with CSV. +Initial synchronization requests the selected number of **calendar months of booked transactions per account**, defaulting to **12 months**. The bank may provide less history. The choice is saved with the bank connection and reused on reconnection. Automatic synchronization then runs **twice a day**, every **12 hours** after the last successful run, overlapping each account's last successful sync by **14 days**. **Sync now** starts a manual synchronization at any time. Existing accounts keep their successful-sync cursors: changing the history choice or reconnecting does **not** backfill them. Older history can be imported with CSV. **HTTP 429 is a provider rate limit, not evidence that bank consent has expired.** Bank reads honor `Retry-After` and use bounded exponential retries. A longer or exhausted limit pauses further requests until the reported retry time; failed accounts keep their previous sync cursors and imported data. Session checks use the saved account metadata rather than fetching every account's details again. A failed session is reported once instead of also marking each of its accounts unavailable. After the cooldown, **Sync now** can retry; the warning clears after a successful sync. One-time authorization and code-exchange requests are never automatically replayed. diff --git a/internal/app/import.go b/internal/app/import.go index a1355cc..361d790 100644 --- a/internal/app/import.go +++ b/internal/app/import.go @@ -806,6 +806,12 @@ func (a *App) Sync(ctx context.Context) (State, error) { return a.snapshot(ctx) } +// syncInterval is how often connected accounts synchronize on their own. Twice +// a day halves how long a booking can sit unseen while staying inside Enable +// Banking's documented background allowance of roughly four fetches per day per +// account, which a failing sync's hourly retries also draw from. +const syncInterval = 12 * time.Hour + // syncSchedule decides whether an automatic sync may run now, and how long to // wait otherwise. While a bank has named its own retry time, waiting is the // only useful action: retrying earlier spends session-status calls on a refusal @@ -815,7 +821,7 @@ func syncSchedule(now time.Time, ops operational, force bool) (time.Duration, bo return min(retry.Sub(now)+time.Minute, time.Hour), false } last, err := time.Parse(time.RFC3339, ops.LastSync) - if force || err != nil || ops.SyncError != "" || now.Sub(last) >= 24*time.Hour { + if force || err != nil || ops.SyncError != "" || now.Sub(last) >= syncInterval { return 0, true } return time.Minute, false @@ -823,13 +829,14 @@ func syncSchedule(now time.Time, ops operational, force bool) (time.Duration, bo // syncBackoff spaces the next attempt after a sync. A failure with a known bank // retry time waits for it; other failures retry hourly so transient provider -// problems clear without waiting a day, while bounding unattended traffic. +// problems clear without waiting for the next scheduled run, while bounding +// unattended traffic. func syncBackoff(now time.Time, ops operational) time.Duration { if ops.SyncError == "" { - return 24 * time.Hour + return syncInterval } if retry, err := time.Parse(time.RFC3339, ops.SyncRetryAt); err == nil && now.Before(retry) { - return min(retry.Sub(now)+time.Minute, 24*time.Hour) + return min(retry.Sub(now)+time.Minute, syncInterval) } return time.Hour } diff --git a/internal/app/sync_test.go b/internal/app/sync_test.go index 0658c13..bca7ec4 100644 --- a/internal/app/sync_test.go +++ b/internal/app/sync_test.go @@ -371,6 +371,9 @@ func TestSyncSchedulingRespectsTheBanksOwnRetryTime(t *testing.T) { waiting := operational{LastSync: stale, SyncError: "N26: rate limited", SyncRetryAt: now.Add(3 * time.Hour).Format(time.RFC3339)} broken := operational{LastSync: stale, SyncError: "Trade Republic: retrieval failed"} healthy := operational{LastSync: now.Format(time.RFC3339)} + // Twice daily: still fresh at six hours, due again after thirteen. + fresh := operational{LastSync: now.Add(-6 * time.Hour).Format(time.RFC3339)} + overdue := operational{LastSync: now.Add(-13 * time.Hour).Format(time.RFC3339)} elapsed := operational{LastSync: stale, SyncError: waiting.SyncError, SyncRetryAt: now.Add(-time.Minute).Format(time.RFC3339)} cases := []struct { name string @@ -384,6 +387,8 @@ func TestSyncSchedulingRespectsTheBanksOwnRetryTime(t *testing.T) { {"deadline elapsed", elapsed, false, 0, true}, {"failure without a deadline", broken, false, 0, true}, {"recent success", healthy, false, time.Minute, false}, + {"halfway through the interval", fresh, false, time.Minute, false}, + {"interval elapsed", overdue, false, 0, true}, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { @@ -399,7 +404,7 @@ func TestSyncSchedulingRespectsTheBanksOwnRetryTime(t *testing.T) { if backoff := syncBackoff(now, broken); backoff != time.Hour { t.Fatalf("failure backoff = %s, want hourly retries", backoff) } - if backoff := syncBackoff(now, healthy); backoff != 24*time.Hour { - t.Fatalf("successful backoff = %s, want daily synchronization", backoff) + if backoff := syncBackoff(now, healthy); backoff != 12*time.Hour { + t.Fatalf("successful backoff = %s, want twice-daily synchronization", backoff) } }