Private
Public Access
1
0

Fix SimpleFIN re-sync timeout and 403 rate limiting

Cache SimpleFIN API responses for 60 seconds so multiple accounts in
the same sync cycle reuse a single HTTP call instead of hitting the
API per-account. Pre-load existing transactions by FitId in one batch
query to skip per-duplicate FindAsync calls during memo backfill.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-09 21:42:55 -05:00
parent f23bba53c0
commit 0562bb97b7
2 changed files with 65 additions and 14 deletions
+37 -12
View File
@@ -413,6 +413,17 @@ public class BankSyncService : IBankSyncService
var accountResults = new List<SyncAccountResult>();
int totalFetched = 0, totalImported = 0, totalSkipped = 0;
// For SimpleFIN, use the earliest start date across all accounts so the
// single API call (cached) returns transactions covering every account.
var endDate = DateTime.UtcNow;
DateTime? simpleFinStartDate = null;
if (connection.Provider == SyncProvider.SimpleFIN)
{
simpleFinStartDate = enabledAccounts
.Select(la => la.LastSyncAt ?? endDate.AddDays(-90))
.Min();
}
foreach (var linkedAccount in enabledAccounts)
{
var log = new SyncLog
@@ -427,8 +438,7 @@ public class BankSyncService : IBankSyncService
try
{
var endDate = DateTime.UtcNow;
var startDate = linkedAccount.LastSyncAt ?? endDate.AddDays(-90);
var startDate = simpleFinStartDate ?? linkedAccount.LastSyncAt ?? endDate.AddDays(-90);
var provider = connection.Provider == SyncProvider.Plaid
? (Purrse.Plugins.Abstractions.IAccountSyncProvider)_plaid
@@ -454,24 +464,39 @@ public class BankSyncService : IBankSyncService
int imported = 0;
int skipped = 0;
// Pre-load existing transactions for this account that have FitIds matching
// incoming data, so we can batch-update memos without per-row FindAsync calls
var incomingFitIds = parseResult.Transactions
.Where(t => !string.IsNullOrEmpty(t.FitId))
.Select(t => t.FitId!)
.ToHashSet();
var existingByFitId = incomingFitIds.Count > 0
? await _db.Transactions
.Where(t => t.AccountId == linkedAccount.AccountId!.Value
&& t.FitId != null && incomingFitIds.Contains(t.FitId))
.ToDictionaryAsync(t => t.FitId!, t => t)
: new Dictionary<string, Transaction>();
foreach (var parsed in parseResult.Transactions)
{
// Fast-path: if we already have a transaction with this FitId, skip
// and backfill memo if needed — no duplicate detection query required
if (!string.IsNullOrEmpty(parsed.FitId) && existingByFitId.TryGetValue(parsed.FitId, out var existingTx))
{
if (!string.IsNullOrEmpty(parsed.Memo) && string.IsNullOrEmpty(existingTx.Memo))
{
existingTx.Memo = parsed.Memo;
}
skipped++;
continue;
}
var duplicate = await _duplicateService.FindDuplicateAsync(
linkedAccount.AccountId!.Value, parsed.Date, parsed.Amount,
parsed.PayeeName, parsed.FitId, parsed.CheckNumber);
if (duplicate != null)
{
// Update memo if the existing transaction has no memo
if (!string.IsNullOrEmpty(parsed.Memo))
{
var existing = await _db.Transactions.FindAsync(duplicate.ExistingTransactionId);
if (existing != null && string.IsNullOrEmpty(existing.Memo))
{
existing.Memo = parsed.Memo;
}
}
skipped++;
continue;
}
@@ -11,6 +11,11 @@ public class SimpleFinSyncProvider : IAccountSyncProvider
{
private readonly HttpClient _httpClient;
// Short-lived cache to avoid hitting SimpleFIN multiple times in one sync cycle
private string? _cachedUrl;
private SimpleFinAccountSet? _cachedResponse;
private DateTime _cacheExpiry;
public string Id => "simplefin-sync";
public string Name => "SimpleFIN Bank Sync";
public string Version => "1.0.0";
@@ -63,8 +68,8 @@ public class SimpleFinSyncProvider : IAccountSyncProvider
var startUnix = new DateTimeOffset(startDate).ToUnixTimeSeconds();
var endUnix = new DateTimeOffset(endDate).ToUnixTimeSeconds();
var response = await GetWithAuthAsync(
$"{accessUrl}/accounts?start-date={startUnix}&end-date={endUnix}");
var url = $"{accessUrl}/accounts?start-date={startUnix}&end-date={endUnix}";
var response = await GetWithAuthCachedAsync(url);
if (response == null)
return new ParseResult { Success = false, ErrorMessage = "No response from SimpleFIN" };
@@ -93,6 +98,27 @@ public class SimpleFinSyncProvider : IAccountSyncProvider
return result;
}
/// <summary>
/// Returns a cached response if the same URL was fetched within the last 60 seconds.
/// SimpleFIN returns all accounts in a single response, so caching avoids redundant
/// API calls when syncing multiple accounts from the same connection.
/// </summary>
private async Task<SimpleFinAccountSet?> GetWithAuthCachedAsync(string url)
{
// Cache by base URL (without query params) so that calls for different
// date ranges within the same sync cycle reuse the first response.
// The first call uses the widest date window (earliest LastSyncAt).
var cacheKey = url.Contains('?') ? url[..url.IndexOf('?')] : url;
if (_cachedUrl == cacheKey && _cachedResponse != null && DateTime.UtcNow < _cacheExpiry)
return _cachedResponse;
var response = await GetWithAuthAsync(url);
_cachedUrl = cacheKey;
_cachedResponse = response;
_cacheExpiry = DateTime.UtcNow.AddSeconds(60);
return response;
}
/// <summary>
/// HttpClient does not extract Basic Auth credentials from URLs (user:pass@host).
/// This method parses the URI, builds the Authorization header, and strips credentials from the request URL.