Private
Public Access
1
0
Files
Purrse/src/Purrse.Api/Services/BankSyncService.cs
T
Catherine Renelle 9d6a006045 Make Plaid credentials per-user, configurable via Settings UI
Plaid ClientId/Secret are now stored per-user in the database
(encrypted) instead of in appsettings.json. Users enter their own
Plaid developer credentials in the Settings > Bank Connections tab.

- New BankSyncSettings model (1:1 with User) for encrypted credentials
- PlaidSyncProvider is now stateless, receives credentials per-call
- Settings UI shows a credentials card with save form
- Connect via Plaid button disabled until credentials are configured
- Removed Plaid section from appsettings.json

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 16:50:48 -05:00

559 lines
22 KiB
C#

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;
using Purrse.Plugins.BankSync.Models;
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;
}
// --- Provider Credentials ---
public async Task<PlaidCredentialsResponse> GetPlaidCredentialsAsync(Guid userId)
{
var settings = await _db.BankSyncSettings.FirstOrDefaultAsync(s => s.UserId == userId);
if (settings == null || string.IsNullOrEmpty(settings.EncryptedPlaidClientId))
return new PlaidCredentialsResponse(false, "sandbox");
return new PlaidCredentialsResponse(true, settings.PlaidEnvironment);
}
public async Task<PlaidCredentialsResponse> SavePlaidCredentialsAsync(Guid userId, SavePlaidCredentialsRequest request)
{
var settings = await _db.BankSyncSettings.FirstOrDefaultAsync(s => s.UserId == userId);
if (settings == null)
{
settings = new BankSyncSettings
{
Id = Guid.NewGuid(),
UserId = userId
};
_db.BankSyncSettings.Add(settings);
}
settings.EncryptedPlaidClientId = _encryption.Encrypt(request.ClientId);
settings.EncryptedPlaidSecret = _encryption.Encrypt(request.Secret);
settings.PlaidEnvironment = request.Environment;
settings.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return new PlaidCredentialsResponse(true, settings.PlaidEnvironment);
}
// --- Connection CRUD ---
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();
}
// --- Plaid Flow ---
public async Task<CreateLinkTokenResponse> CreatePlaidLinkTokenAsync(Guid userId)
{
var credentials = await GetPlaidCredentialsInternal(userId);
var result = await _plaid.CreateLinkTokenAsync(userId.ToString(), credentials);
return new CreateLinkTokenResponse(result.LinkToken, result.Expiration);
}
public async Task<SyncConnectionResponse> CompletePlaidLinkAsync(Guid userId, CreatePlaidConnectionRequest request)
{
var credentials = await GetPlaidCredentialsInternal(userId);
var exchangeResult = await _plaid.ExchangePublicTokenAsync(request.PublicToken, credentials);
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
var config = BuildProviderConfig(connection, exchangeResult.AccessToken, credentials);
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();
return await GetConnectionAsync(userId, connection.Id);
}
// --- SimpleFIN Flow ---
public async Task<SyncConnectionResponse> CreateSimpleFinConnectionAsync(Guid userId, CreateSimpleFinConnectionRequest request)
{
var accessUrl = await _simpleFin.ExchangeSetupTokenAsync(request.SetupToken);
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);
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);
}
// --- Account Linking ---
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);
PlaidConfig? plaidCreds = connection.Provider == SyncProvider.Plaid
? await GetPlaidCredentialsInternal(userId) : null;
var config = BuildProviderConfig(connection, accessToken, plaidCreds);
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)
{
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();
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);
}
// --- Sync ---
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);
}
// --- Logs ---
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();
}
// --- Internal Helpers ---
private async Task<PlaidConfig> GetPlaidCredentialsInternal(Guid userId)
{
var settings = await _db.BankSyncSettings.FirstOrDefaultAsync(s => s.UserId == userId);
if (settings == null || string.IsNullOrEmpty(settings.EncryptedPlaidClientId) || string.IsNullOrEmpty(settings.EncryptedPlaidSecret))
throw new InvalidOperationException("Plaid credentials are not configured. Go to Settings to enter your Plaid API keys.");
return new PlaidConfig
{
ClientId = _encryption.Decrypt(settings.EncryptedPlaidClientId),
Secret = _encryption.Decrypt(settings.EncryptedPlaidSecret),
Environment = settings.PlaidEnvironment
};
}
private async Task<SyncResultResponse> SyncConnectionInternal(Guid userId, SyncConnection connection)
{
var accessToken = _encryption.Decrypt(connection.EncryptedAccessToken);
PlaidConfig? plaidCreds = connection.Provider == SyncProvider.Plaid
? await GetPlaidCredentialsInternal(userId) : null;
var config = BuildProviderConfig(connection, accessToken, plaidCreds);
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++;
}
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();
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 static Dictionary<string, string> BuildProviderConfig(SyncConnection connection, string accessToken, PlaidConfig? plaidCredentials)
{
if (connection.Provider == SyncProvider.Plaid && plaidCredentials != null)
{
return new Dictionary<string, string>
{
["AccessToken"] = accessToken,
["PlaidClientId"] = plaidCredentials.ClientId,
["PlaidSecret"] = plaidCredentials.Secret,
["PlaidEnvironment"] = plaidCredentials.Environment
};
}
return 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);
}
}