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 IAiCategorizationService _aiCategorization; private readonly ILoanService _loanService; private readonly IHubContext _hub; private readonly ILogger _logger; public BankSyncService( PurrseDbContext db, IEncryptionService encryption, PlaidSyncProvider plaid, SimpleFinSyncProvider simpleFin, IDuplicateDetectionService duplicateService, IPayeeService payeeService, IAiCategorizationService aiCategorization, ILoanService loanService, IHubContext hub, ILogger logger) { _db = db; _encryption = encryption; _plaid = plaid; _simpleFin = simpleFin; _duplicateService = duplicateService; _payeeService = payeeService; _aiCategorization = aiCategorization; _loanService = loanService; _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 encryptedAccessUrl = _encryption.Encrypt(accessUrl); var groupedByInstitution = accounts.GroupBy(a => a.Institution ?? "SimpleFIN Connection"); var connectionIds = new List(); foreach (var group in groupedByInstitution) { var connection = new SyncConnection { Id = Guid.NewGuid(), UserId = userId, Provider = SyncProvider.SimpleFIN, InstitutionName = group.Key, EncryptedAccessToken = encryptedAccessUrl }; _db.SyncConnections.Add(connection); connectionIds.Add(connection.Id); foreach (var account in group) { _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(); var results = new List(); foreach (var id in connectionIds) { results.Add(await GetConnectionAsync(userId, id)); } return results; } // --- 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 ResetSyncDatesAsync(Guid userId, Guid connectionId) { var connection = await _db.SyncConnections .Include(c => c.LinkedAccounts) .FirstOrDefaultAsync(c => c.Id == connectionId && c.UserId == userId) ?? throw new KeyNotFoundException("Connection not found"); foreach (var la in connection.LinkedAccounts) { la.LastSyncAt = null; } connection.LastSyncAt = null; connection.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); } 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; // Try to auto-create loan details from Plaid liabilities data if (connection.Provider == SyncProvider.Plaid) { await TryCreateLoanDetailsFromPlaidAsync(userId, connection, config); } // 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 { Id = Guid.NewGuid(), SyncConnectionId = connection.Id, LinkedAccountId = linkedAccount.Id, Status = SyncStatus.Running, StartedAt = DateTime.UtcNow }; _db.SyncLogs.Add(log); try { var startDate = simpleFinStartDate ?? 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; // 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(); 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) { 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 = DateTime.SpecifyKind(parsed.Date, DateTimeKind.Utc), 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.Cleared }; _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)); } } // AI auto-categorization for uncategorized transactions try { var uncategorized = _db.ChangeTracker.Entries() .Where(e => e.State == EntityState.Added && e.Entity.CategoryId == null) .Select(e => e.Entity) .ToList(); if (uncategorized.Count > 0) { var classifications = await _aiCategorization.ClassifyTransactionsAsync(userId, uncategorized); foreach (var c in classifications) { if (c.CategoryId.HasValue && c.TransactionIndex >= 0 && c.TransactionIndex < uncategorized.Count) { uncategorized[c.TransactionIndex].CategoryId = c.CategoryId.Value; var txn = uncategorized[c.TransactionIndex]; if (txn.PayeeId.HasValue) { var payee = await _db.Payees.FindAsync(txn.PayeeId.Value); if (payee != null && payee.DefaultCategoryId == null) payee.DefaultCategoryId = c.CategoryId.Value; } } } } } catch (Exception ex) { _logger.LogWarning(ex, "AI categorization failed during sync, continuing without"); } 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 async Task TryCreateLoanDetailsFromPlaidAsync(Guid userId, SyncConnection connection, Dictionary config) { try { var liabilities = await _plaid.GetLiabilitiesAsync(config); if (liabilities.Count == 0) return; var linkedAccounts = connection.LinkedAccounts .Where(la => la.IsEnabled && la.AccountId != null) .ToList(); foreach (var liability in liabilities) { var linked = linkedAccounts.FirstOrDefault(la => la.ExternalAccountId == liability.ExternalAccountId); if (linked?.AccountId == null) continue; var accountId = linked.AccountId.Value; // Skip if loan detail already exists var existing = await _loanService.GetLoanDetailAsync(userId, accountId); if (existing != null) continue; // Update account interest rate from liabilities data if (liability.InterestRate.HasValue && linked.Account != null) { linked.Account.InterestRate = liability.InterestRate.Value; } var originalBalance = liability.OriginalBalance ?? Math.Abs(linked.LastKnownBalance ?? 0); var interestRate = liability.InterestRate ?? 0; var termMonths = liability.TermMonths ?? (liability.LoanType == "Mortgage" ? 360 : 60); // Plaid gives loan origination date; first payment is typically one month later var firstPaymentDate = liability.OriginationDate?.AddMonths(1) ?? DateTime.UtcNow; var escrowBalance = liability.EscrowBalance; // Compute monthly payment via amortization formula if not provided var monthlyPayment = liability.MonthlyPayment ?? ComputeMonthlyPayment(originalBalance, interestRate, termMonths); var maturityDate = liability.MaturityDate ?? firstPaymentDate.AddMonths(termMonths - 1); var request = new CreateLoanDetailRequest( originalBalance, interestRate, termMonths, monthlyPayment, DateTime.SpecifyKind(firstPaymentDate, DateTimeKind.Utc), DateTime.SpecifyKind(maturityDate, DateTimeKind.Utc), escrowBalance, null ); await _loanService.CreateLoanDetailAsync(userId, accountId, request); _logger.LogInformation("Auto-created loan details for account {AccountId} from Plaid liabilities", accountId); } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to fetch Plaid liabilities for connection {ConnectionId} - sync will continue without loan details", connection.Id); } } private static decimal ComputeMonthlyPayment(decimal principal, decimal annualRatePercent, int termMonths) { if (principal <= 0 || termMonths <= 0) return 0; if (annualRatePercent <= 0) return principal / termMonths; var monthlyRate = annualRatePercent / 100m / 12m; var factor = (double)monthlyRate; var pow = Math.Pow(1 + factor, termMonths); return principal * (decimal)(factor * pow / (pow - 1)); } 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); } }