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 _hub; private readonly ILogger _logger; public BankSyncService( PurrseDbContext db, IEncryptionService encryption, PlaidSyncProvider plaid, SimpleFinSyncProvider simpleFin, IDuplicateDetectionService duplicateService, IPayeeService payeeService, IHubContext hub, ILogger logger) { _db = db; _encryption = encryption; _plaid = plaid; _simpleFin = simpleFin; _duplicateService = duplicateService; _payeeService = payeeService; _hub = hub; _logger = logger; } // --- Provider Credentials --- public async Task 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 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> 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 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 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 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 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 CreateSimpleFinConnectionAsync(Guid userId, CreateSimpleFinConnectionRequest request) { var accessUrl = await _simpleFin.ExchangeSetupTokenAsync(request.SetupToken); var config = new Dictionary { ["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> 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 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(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 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 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 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(); 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> 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 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 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(); 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 BuildProviderConfig(SyncConnection connection, string accessToken, PlaidConfig? plaidCredentials) { if (connection.Provider == SyncProvider.Plaid && plaidCredentials != null) { return new Dictionary { ["AccessToken"] = accessToken, ["PlaidClientId"] = plaidCredentials.ClientId, ["PlaidSecret"] = plaidCredentials.Secret, ["PlaidEnvironment"] = plaidCredentials.Environment }; } return new Dictionary { ["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); } }