Private
Public Access
1
0

Implement Phase 6: automated bank sync (Plaid + SimpleFIN) & settings

Add automated transaction syncing via Plaid and SimpleFIN providers,
a Settings page for managing connections and account mappings,
background scheduled syncing, and sync history logging.

New backend: BankSync plugin project with PlaidSyncProvider and
SimpleFinSyncProvider, SyncConnection/LinkedAccount/SyncLog models,
AES-256 encryption service, BankSyncService orchestration,
BankSyncBackgroundService (15-min tick), and BankSyncController
(13 endpoints). EF migration creates sync_connections,
linked_accounts, and sync_logs tables.

New frontend: Settings view with 3 tabs (Profile, Bank Connections,
Sync History), bankSync API service, BankSyncComplete SignalR
notifications, and Settings nav item in sidebar + avatar dropdown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-08 15:08:23 -05:00
parent eb49147080
commit 3e0446c9be
34 changed files with 3956 additions and 8 deletions
+529
View File
@@ -0,0 +1,529 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Purrse.Api.Hubs;
using Purrse.Core.DTOs;
using Purrse.Core.Enums;
using Purrse.Core.Helpers;
using Purrse.Core.Interfaces.Services;
using Purrse.Core.Models;
using Purrse.Data;
using Purrse.Plugins.BankSync;
namespace Purrse.Api.Services;
public class BankSyncService : IBankSyncService
{
private readonly PurrseDbContext _db;
private readonly IEncryptionService _encryption;
private readonly PlaidSyncProvider _plaid;
private readonly SimpleFinSyncProvider _simpleFin;
private readonly IDuplicateDetectionService _duplicateService;
private readonly IPayeeService _payeeService;
private readonly IHubContext<NotificationHub> _hub;
private readonly ILogger<BankSyncService> _logger;
public BankSyncService(
PurrseDbContext db,
IEncryptionService encryption,
PlaidSyncProvider plaid,
SimpleFinSyncProvider simpleFin,
IDuplicateDetectionService duplicateService,
IPayeeService payeeService,
IHubContext<NotificationHub> hub,
ILogger<BankSyncService> logger)
{
_db = db;
_encryption = encryption;
_plaid = plaid;
_simpleFin = simpleFin;
_duplicateService = duplicateService;
_payeeService = payeeService;
_hub = hub;
_logger = logger;
}
public async Task<List<SyncConnectionResponse>> GetConnectionsAsync(Guid userId)
{
var connections = await _db.SyncConnections
.Include(c => c.LinkedAccounts).ThenInclude(la => la.Account)
.Where(c => c.UserId == userId)
.OrderByDescending(c => c.CreatedAt)
.ToListAsync();
return connections.Select(MapConnectionResponse).ToList();
}
public async Task<SyncConnectionResponse> GetConnectionAsync(Guid userId, Guid connectionId)
{
var connection = await _db.SyncConnections
.Include(c => c.LinkedAccounts).ThenInclude(la => la.Account)
.FirstOrDefaultAsync(c => c.Id == connectionId && c.UserId == userId)
?? throw new KeyNotFoundException("Connection not found");
return MapConnectionResponse(connection);
}
public async Task<SyncConnectionResponse> UpdateConnectionAsync(Guid userId, Guid connectionId, UpdateConnectionRequest request)
{
var connection = await _db.SyncConnections
.Include(c => c.LinkedAccounts).ThenInclude(la => la.Account)
.FirstOrDefaultAsync(c => c.Id == connectionId && c.UserId == userId)
?? throw new KeyNotFoundException("Connection not found");
connection.IsActive = request.IsActive;
connection.SyncIntervalMinutes = request.SyncIntervalMinutes;
connection.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return MapConnectionResponse(connection);
}
public async Task DeleteConnectionAsync(Guid userId, Guid connectionId)
{
var connection = await _db.SyncConnections
.FirstOrDefaultAsync(c => c.Id == connectionId && c.UserId == userId)
?? throw new KeyNotFoundException("Connection not found");
_db.SyncConnections.Remove(connection);
await _db.SaveChangesAsync();
}
public async Task<CreateLinkTokenResponse> CreatePlaidLinkTokenAsync(Guid userId)
{
var result = await _plaid.CreateLinkTokenAsync(userId.ToString());
return new CreateLinkTokenResponse(result.LinkToken, result.Expiration);
}
public async Task<SyncConnectionResponse> CompletePlaidLinkAsync(Guid userId, CreatePlaidConnectionRequest request)
{
var exchangeResult = await _plaid.ExchangePublicTokenAsync(request.PublicToken);
var connection = new SyncConnection
{
Id = Guid.NewGuid(),
UserId = userId,
Provider = SyncProvider.Plaid,
InstitutionName = exchangeResult.InstitutionName ?? "Unknown Institution",
InstitutionId = exchangeResult.InstitutionId,
EncryptedAccessToken = _encryption.Encrypt(exchangeResult.AccessToken),
PlaidItemId = exchangeResult.ItemId
};
_db.SyncConnections.Add(connection);
await _db.SaveChangesAsync();
// Discover accounts
await DiscoverAndCreateLinkedAccounts(connection, exchangeResult.AccessToken);
return await GetConnectionAsync(userId, connection.Id);
}
public async Task<SyncConnectionResponse> CreateSimpleFinConnectionAsync(Guid userId, CreateSimpleFinConnectionRequest request)
{
var accessUrl = await _simpleFin.ExchangeSetupTokenAsync(request.SetupToken);
// Get accounts to determine institution name
var config = new Dictionary<string, string> { ["AccessUrl"] = accessUrl };
var accounts = await _simpleFin.GetAccountsAsync(config);
var institutionName = accounts.FirstOrDefault()?.Institution ?? "SimpleFIN Connection";
var connection = new SyncConnection
{
Id = Guid.NewGuid(),
UserId = userId,
Provider = SyncProvider.SimpleFIN,
InstitutionName = institutionName,
EncryptedAccessToken = _encryption.Encrypt(accessUrl)
};
_db.SyncConnections.Add(connection);
// Create linked accounts from discovered accounts
foreach (var account in accounts)
{
_db.LinkedAccounts.Add(new LinkedAccount
{
Id = Guid.NewGuid(),
SyncConnectionId = connection.Id,
ExternalAccountId = account.ExternalId,
ExternalAccountName = account.Name,
ExternalAccountType = account.AccountType,
LastKnownBalance = account.Balance
});
}
await _db.SaveChangesAsync();
return await GetConnectionAsync(userId, connection.Id);
}
public async Task<List<DiscoveredAccountResponse>> DiscoverAccountsAsync(Guid userId, Guid connectionId)
{
var connection = await _db.SyncConnections
.FirstOrDefaultAsync(c => c.Id == connectionId && c.UserId == userId)
?? throw new KeyNotFoundException("Connection not found");
var accessToken = _encryption.Decrypt(connection.EncryptedAccessToken);
var config = BuildProviderConfig(connection, accessToken);
var accounts = connection.Provider == SyncProvider.Plaid
? await _plaid.GetAccountsAsync(config)
: await _simpleFin.GetAccountsAsync(config);
return accounts.Select(a => new DiscoveredAccountResponse(
a.ExternalId,
a.Name,
a.AccountType,
null,
a.Balance,
a.Currency
)).ToList();
}
public async Task<LinkedAccountResponse> LinkAccountAsync(Guid userId, Guid connectionId, LinkAccountRequest request)
{
var connection = await _db.SyncConnections
.FirstOrDefaultAsync(c => c.Id == connectionId && c.UserId == userId)
?? throw new KeyNotFoundException("Connection not found");
var linkedAccount = await _db.LinkedAccounts
.Include(la => la.Account)
.FirstOrDefaultAsync(la => la.Id == request.LinkedAccountId && la.SyncConnectionId == connectionId)
?? throw new KeyNotFoundException("Linked account not found");
if (request.AutoCreate && request.AccountId == null)
{
// Auto-create a Purrse account
var accountType = Enum.TryParse<AccountType>(linkedAccount.ExternalAccountType, true, out var at)
? at : AccountType.Checking;
var newAccount = new Account
{
Id = Guid.NewGuid(),
UserId = userId,
Name = linkedAccount.ExternalAccountName,
Type = accountType,
Institution = connection.InstitutionName,
Balance = linkedAccount.LastKnownBalance ?? 0
};
_db.Accounts.Add(newAccount);
linkedAccount.AccountId = newAccount.Id;
}
else
{
linkedAccount.AccountId = request.AccountId;
}
await _db.SaveChangesAsync();
// Reload with Account nav
linkedAccount = await _db.LinkedAccounts
.Include(la => la.Account)
.FirstAsync(la => la.Id == linkedAccount.Id);
return MapLinkedAccountResponse(linkedAccount);
}
public async Task<LinkedAccountResponse> UpdateLinkedAccountAsync(Guid userId, Guid linkedAccountId, UpdateLinkedAccountRequest request)
{
var linkedAccount = await _db.LinkedAccounts
.Include(la => la.SyncConnection)
.Include(la => la.Account)
.FirstOrDefaultAsync(la => la.Id == linkedAccountId && la.SyncConnection.UserId == userId)
?? throw new KeyNotFoundException("Linked account not found");
linkedAccount.AccountId = request.AccountId;
linkedAccount.IsEnabled = request.IsEnabled;
await _db.SaveChangesAsync();
return MapLinkedAccountResponse(linkedAccount);
}
public async Task<SyncResultResponse> SyncNowAsync(Guid userId, Guid connectionId)
{
var connection = await _db.SyncConnections
.Include(c => c.LinkedAccounts).ThenInclude(la => la.Account)
.FirstOrDefaultAsync(c => c.Id == connectionId && c.UserId == userId)
?? throw new KeyNotFoundException("Connection not found");
return await SyncConnectionInternal(userId, connection);
}
public async Task<SyncResultResponse> SyncAllAsync(Guid userId)
{
var connections = await _db.SyncConnections
.Include(c => c.LinkedAccounts).ThenInclude(la => la.Account)
.Where(c => c.UserId == userId && c.IsActive)
.ToListAsync();
var allResults = new List<SyncAccountResult>();
int totalFetched = 0, totalImported = 0, totalSkipped = 0, totalSynced = 0;
foreach (var connection in connections)
{
var result = await SyncConnectionInternal(userId, connection);
allResults.AddRange(result.AccountResults);
totalFetched += result.TransactionsFetched;
totalImported += result.TransactionsImported;
totalSkipped += result.TransactionsSkipped;
totalSynced += result.AccountsSynced;
}
return new SyncResultResponse(
allResults.Count,
totalSynced,
totalFetched,
totalImported,
totalSkipped,
allResults);
}
public async Task<List<SyncLogResponse>> GetSyncLogsAsync(Guid userId, Guid? connectionId = null, int limit = 50)
{
var query = _db.SyncLogs
.Include(l => l.SyncConnection)
.Include(l => l.LinkedAccount)
.Where(l => l.SyncConnection.UserId == userId);
if (connectionId.HasValue)
query = query.Where(l => l.SyncConnectionId == connectionId.Value);
return await query
.OrderByDescending(l => l.StartedAt)
.Take(limit)
.Select(l => new SyncLogResponse(
l.Id,
l.SyncConnectionId,
l.SyncConnection.InstitutionName,
l.LinkedAccountId,
l.LinkedAccount != null ? l.LinkedAccount.ExternalAccountName : null,
l.Status,
l.TransactionsFetched,
l.TransactionsImported,
l.TransactionsSkipped,
l.ErrorMessage,
l.StartedAt,
l.CompletedAt,
l.Duration))
.ToListAsync();
}
private async Task<SyncResultResponse> SyncConnectionInternal(Guid userId, SyncConnection connection)
{
var accessToken = _encryption.Decrypt(connection.EncryptedAccessToken);
var config = BuildProviderConfig(connection, accessToken);
var enabledAccounts = connection.LinkedAccounts.Where(la => la.IsEnabled && la.AccountId != null).ToList();
var accountResults = new List<SyncAccountResult>();
int totalFetched = 0, totalImported = 0, totalSkipped = 0;
foreach (var linkedAccount in enabledAccounts)
{
var log = new SyncLog
{
Id = Guid.NewGuid(),
SyncConnectionId = connection.Id,
LinkedAccountId = linkedAccount.Id,
Status = SyncStatus.Running,
StartedAt = DateTime.UtcNow
};
_db.SyncLogs.Add(log);
try
{
var endDate = DateTime.UtcNow;
var startDate = linkedAccount.LastSyncAt ?? endDate.AddDays(-90);
var provider = connection.Provider == SyncProvider.Plaid
? (Purrse.Plugins.Abstractions.IAccountSyncProvider)_plaid
: _simpleFin;
var parseResult = await provider.SyncTransactionsAsync(
linkedAccount.ExternalAccountId, startDate, endDate, config);
if (!parseResult.Success)
{
log.Status = SyncStatus.Failed;
log.ErrorMessage = parseResult.ErrorMessage;
log.CompletedAt = DateTime.UtcNow;
log.Duration = log.CompletedAt - log.StartedAt;
accountResults.Add(new SyncAccountResult(
linkedAccount.ExternalAccountName,
linkedAccount.Account?.Name,
0, 0, 0,
parseResult.ErrorMessage));
continue;
}
int fetched = parseResult.Transactions.Count;
int imported = 0;
int skipped = 0;
foreach (var parsed in parseResult.Transactions)
{
var duplicate = await _duplicateService.FindDuplicateAsync(
linkedAccount.AccountId!.Value, parsed.Date, parsed.Amount,
parsed.PayeeName, parsed.FitId, parsed.CheckNumber);
if (duplicate != null)
{
skipped++;
continue;
}
var fingerprint = TransactionFingerprint.Generate(
linkedAccount.AccountId!.Value, parsed.Date, parsed.Amount, parsed.FitId);
var matchedPayee = await _payeeService.MatchPayeeAsync(userId, parsed.PayeeName ?? string.Empty);
var transaction = new Transaction
{
Id = Guid.NewGuid(),
AccountId = linkedAccount.AccountId!.Value,
Date = parsed.Date,
Amount = parsed.Amount,
PayeeId = matchedPayee?.Id,
PayeeName = parsed.PayeeName,
CategoryId = matchedPayee?.DefaultCategoryId,
Memo = parsed.Memo,
ReferenceNumber = parsed.ReferenceNumber,
CheckNumber = parsed.CheckNumber,
FitId = parsed.FitId,
Fingerprint = fingerprint,
Type = parsed.Amount >= 0 ? TransactionType.Credit : TransactionType.Debit,
Status = TransactionStatus.Uncleared
};
_db.Transactions.Add(transaction);
imported++;
}
// Update account balance
if (linkedAccount.Account != null && parseResult.LedgerBalance.HasValue)
{
linkedAccount.Account.Balance = parseResult.LedgerBalance.Value;
linkedAccount.Account.UpdatedAt = DateTime.UtcNow;
}
linkedAccount.LastKnownBalance = parseResult.LedgerBalance ?? linkedAccount.LastKnownBalance;
linkedAccount.LastSyncAt = DateTime.UtcNow;
log.Status = SyncStatus.Success;
log.TransactionsFetched = fetched;
log.TransactionsImported = imported;
log.TransactionsSkipped = skipped;
log.CompletedAt = DateTime.UtcNow;
log.Duration = log.CompletedAt - log.StartedAt;
totalFetched += fetched;
totalImported += imported;
totalSkipped += skipped;
accountResults.Add(new SyncAccountResult(
linkedAccount.ExternalAccountName,
linkedAccount.Account?.Name,
fetched, imported, skipped, null));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to sync account {AccountId} for connection {ConnectionId}",
linkedAccount.ExternalAccountId, connection.Id);
log.Status = SyncStatus.Failed;
log.ErrorMessage = ex.Message;
log.CompletedAt = DateTime.UtcNow;
log.Duration = log.CompletedAt - log.StartedAt;
accountResults.Add(new SyncAccountResult(
linkedAccount.ExternalAccountName,
linkedAccount.Account?.Name,
0, 0, 0, ex.Message));
}
}
connection.LastSyncAt = DateTime.UtcNow;
connection.LastSyncError = accountResults.Any(r => r.Error != null)
? accountResults.First(r => r.Error != null).Error
: null;
connection.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
// Send SignalR notification
await _hub.Clients.Group(userId.ToString()).SendAsync("BankSyncComplete", new
{
connectionId = connection.Id,
institutionName = connection.InstitutionName,
transactionsImported = totalImported,
transactionsSkipped = totalSkipped,
status = accountResults.All(r => r.Error == null) ? "Success" : "PartialSuccess"
});
return new SyncResultResponse(
enabledAccounts.Count,
accountResults.Count(r => r.Error == null),
totalFetched,
totalImported,
totalSkipped,
accountResults);
}
private async Task DiscoverAndCreateLinkedAccounts(SyncConnection connection, string accessToken)
{
var config = BuildProviderConfig(connection, accessToken);
var accounts = await _plaid.GetAccountsAsync(config);
foreach (var account in accounts)
{
_db.LinkedAccounts.Add(new LinkedAccount
{
Id = Guid.NewGuid(),
SyncConnectionId = connection.Id,
ExternalAccountId = account.ExternalId,
ExternalAccountName = account.Name,
ExternalAccountType = account.AccountType,
LastKnownBalance = account.Balance
});
}
await _db.SaveChangesAsync();
}
private static Dictionary<string, string> BuildProviderConfig(SyncConnection connection, string accessToken)
{
return connection.Provider == SyncProvider.Plaid
? new Dictionary<string, string> { ["AccessToken"] = accessToken }
: new Dictionary<string, string> { ["AccessUrl"] = accessToken };
}
private static SyncConnectionResponse MapConnectionResponse(SyncConnection c)
{
return new SyncConnectionResponse(
c.Id,
c.Provider,
c.InstitutionName,
c.InstitutionId,
c.IsActive,
c.SyncIntervalMinutes,
c.LastSyncAt,
c.LastSyncError,
c.CreatedAt,
c.LinkedAccounts.Select(MapLinkedAccountResponse).ToList());
}
private static LinkedAccountResponse MapLinkedAccountResponse(LinkedAccount la)
{
return new LinkedAccountResponse(
la.Id,
la.ExternalAccountId,
la.ExternalAccountName,
la.ExternalAccountType,
la.ExternalAccountMask,
la.LastKnownBalance,
la.IsEnabled,
la.AccountId,
la.Account?.Name,
la.LastSyncAt);
}
}