using Books.Api.EventFlow.ReadModels; using Books.Api.EventFlow.Repositories; using Hangfire; namespace Books.Api.Banking; /// /// Hangfire job for syncing bank transactions from Enable Banking. /// Runs every 30 minutes to fetch new transactions for all active bank connections. /// public class BankTransactionSyncJob( IBankConnectionRepository connectionRepository, IBankTransactionRepository transactionRepository, IEnableBankingClient bankingClient, ILogger logger) { /// /// Sync all active bank connections for all companies. /// Called by Hangfire recurring job. /// [DisableConcurrentExecution(timeoutInSeconds: 300)] [AutomaticRetry(Attempts = 3, DelaysInSeconds = [60, 120, 300])] public async Task SyncAllActiveConnectionsAsync(CancellationToken cancellationToken = default) { logger.LogInformation("Starting bank transaction sync for all active connections"); var result = new BankTransactionSyncResult(); try { // Get all companies with active connections // Note: This is a simplified approach - in production you might want to // iterate through companies more efficiently var connections = await GetAllActiveConnectionsAsync(cancellationToken); result.TotalConnections = connections.Count; foreach (var connection in connections) { try { var connectionResult = await SyncConnectionAsync(connection, cancellationToken); result.TotalAccounts += connectionResult.TotalAccounts; result.NewTransactions += connectionResult.NewTransactions; result.SkippedDuplicates += connectionResult.SkippedDuplicates; } catch (Exception ex) { logger.LogError(ex, "Error syncing connection {ConnectionId}", connection.Id); result.Errors++; result.ErrorMessages.Add($"Connection {connection.Id}: {ex.Message}"); } } logger.LogInformation( "Bank transaction sync completed: {Connections} connections, {Accounts} accounts, " + "{New} new transactions, {Skipped} duplicates, {Errors} errors", result.TotalConnections, result.TotalAccounts, result.NewTransactions, result.SkippedDuplicates, result.Errors); } catch (Exception ex) { logger.LogError(ex, "Fatal error during bank transaction sync"); throw; } } /// /// Sync transactions for a specific company (manual trigger from UI). /// public async Task SyncForCompanyAsync( string companyId, CancellationToken cancellationToken = default) { logger.LogInformation("Starting manual bank transaction sync for company {CompanyId}", companyId); var result = new BankTransactionSyncResult(); var connections = await connectionRepository.GetActiveByCompanyIdAsync(companyId, cancellationToken); result.TotalConnections = connections.Count; foreach (var connection in connections) { try { var connectionResult = await SyncConnectionAsync(connection, cancellationToken); result.TotalAccounts += connectionResult.TotalAccounts; result.NewTransactions += connectionResult.NewTransactions; result.SkippedDuplicates += connectionResult.SkippedDuplicates; } catch (Exception ex) { logger.LogError(ex, "Error syncing connection {ConnectionId}", connection.Id); result.Errors++; result.ErrorMessages.Add($"Connection {connection.Id}: {ex.Message}"); } } logger.LogInformation( "Manual sync for company {CompanyId} completed: {New} new transactions", companyId, result.NewTransactions); return result; } private async Task SyncConnectionAsync( BankConnectionReadModelDto connection, CancellationToken cancellationToken) { var result = new BankTransactionSyncResult(); if (string.IsNullOrEmpty(connection.SessionId)) { logger.LogWarning("Connection {ConnectionId} has no session ID", connection.Id); return result; } if (connection.Accounts == null || connection.Accounts.Count == 0) { logger.LogWarning("Connection {ConnectionId} has no accounts", connection.Id); return result; } result.TotalAccounts = connection.Accounts.Count; var dateTo = DateOnly.FromDateTime(DateTime.UtcNow); foreach (var account in connection.Accounts) { try { // Check if we have any transactions for this account var hasTransactions = await transactionRepository.HasAnyAsync(account.AccountId, cancellationToken); // If new account, fetch 1 year back. If existing, just refresh last 7 days. var dateFrom = hasTransactions ? dateTo.AddDays(-7) : dateTo.AddDays(-365); logger.LogInformation( "Fetching transactions for account {AccountId} from {DateFrom} to {DateTo} (hasExisting: {HasExisting})", account.AccountId, dateFrom, dateTo, hasTransactions); var response = await bankingClient.GetTransactionsAsync( connection.SessionId, account.AccountId, dateFrom, dateTo, cancellationToken); logger.LogInformation( "Enable Banking returned {Count} transactions for account {AccountId}", response.Transactions.Count, account.AccountId); if (response.Transactions.Count == 0) { continue; } var transactionsToUpsert = new List(); foreach (var tx in response.Transactions) { var dto = MapToDto(tx, connection, account.AccountId); transactionsToUpsert.Add(dto); } if (transactionsToUpsert.Count > 0) { await transactionRepository.InsertBatchAsync(transactionsToUpsert, cancellationToken); result.NewTransactions += transactionsToUpsert.Count; // This counts all upserts (inserts + updates) logger.LogInformation( "Synced {Count} transactions for account {AccountId} (from {From} to {To})", transactionsToUpsert.Count, account.AccountId, dateFrom, dateTo); } } catch (Exception ex) { logger.LogError(ex, "Error fetching transactions for account {AccountId}", account.AccountId); result.Errors++; result.ErrorMessages.Add($"Account {account.AccountId}: {ex.Message}"); } } return result; } private async Task> GetAllActiveConnectionsAsync( CancellationToken cancellationToken) { return await connectionRepository.GetAllActiveAsync(cancellationToken); } private static BankTransactionDto MapToDto( Transaction tx, BankConnectionReadModelDto connection, string bankAccountId) { var now = DateTime.UtcNow; return new BankTransactionDto { Id = $"banktx-{Guid.NewGuid()}", CompanyId = connection.CompanyId, BankConnectionId = connection.Id, BankAccountId = bankAccountId, ExternalId = tx.TransactionId, Amount = tx.Amount, Currency = tx.Currency, TransactionDate = tx.BookingDate.ToDateTime(TimeOnly.MinValue), BookingDate = tx.BookingDate.ToDateTime(TimeOnly.MinValue), ValueDate = tx.ValueDate?.ToDateTime(TimeOnly.MinValue), Description = tx.RemittanceInformation, CounterpartyName = tx.CreditorName ?? tx.DebtorName, CreditorName = tx.CreditorName, DebtorName = tx.DebtorName, Reference = tx.EndToEndId, Status = "pending", CreatedAt = now, UpdatedAt = now }; } }