using Books.Api.Domain.BankConnections; using Books.Api.EventFlow.ReadModels; using Books.Api.EventFlow.Repositories; using Hangfire; using Ledger.Core.Models; using Ledger.Core.Services; namespace Books.Api.Banking; /// /// Hangfire job for reconciling bank account balances with ledger accounts. /// Runs daily to compare balances from Enable Banking with the corresponding ledger accounts /// and flags any discrepancies. /// public class BankReconciliationJob( IBankConnectionRepository connectionRepository, IAccountRepository accountRepository, IEnableBankingClient bankingClient, ILedgerService ledgerService, ILogger logger) { /// /// Reconcile all linked bank accounts across all companies. /// Called by Hangfire recurring job daily. /// [DisableConcurrentExecution(timeoutInSeconds: 600)] [AutomaticRetry(Attempts = 2, DelaysInSeconds = [300, 600])] public async Task ReconcileAllAsync(CancellationToken cancellationToken = default) { logger.LogInformation("Starting bank reconciliation for all active connections"); var result = new BankReconciliationResult(); try { var connections = await connectionRepository.GetAllActiveAsync(cancellationToken); result.TotalConnections = connections.Count; foreach (var connection in connections) { try { var connectionResult = await ReconcileConnectionAsync(connection, cancellationToken); result.TotalAccountsChecked += connectionResult.TotalAccountsChecked; result.MatchedAccounts += connectionResult.MatchedAccounts; result.Discrepancies.AddRange(connectionResult.Discrepancies); } catch (Exception ex) { logger.LogError(ex, "Error reconciling connection {ConnectionId}", connection.Id); result.Errors++; } } LogReconciliationResult(result); } catch (Exception ex) { logger.LogError(ex, "Fatal error during bank reconciliation"); throw; } } /// /// Reconcile bank accounts for a specific company (manual trigger from UI). /// public async Task ReconcileForCompanyAsync( string companyId, CancellationToken cancellationToken = default) { logger.LogInformation("Starting manual bank reconciliation for company {CompanyId}", companyId); var result = new BankReconciliationResult(); var connections = await connectionRepository.GetActiveByCompanyIdAsync(companyId, cancellationToken); result.TotalConnections = connections.Count; foreach (var connection in connections) { try { var connectionResult = await ReconcileConnectionAsync(connection, cancellationToken); result.TotalAccountsChecked += connectionResult.TotalAccountsChecked; result.MatchedAccounts += connectionResult.MatchedAccounts; result.Discrepancies.AddRange(connectionResult.Discrepancies); } catch (Exception ex) { logger.LogError(ex, "Error reconciling connection {ConnectionId}", connection.Id); result.Errors++; } } LogReconciliationResult(result); return result; } private async Task ReconcileConnectionAsync( BankConnectionReadModelDto connection, CancellationToken cancellationToken) { var result = new BankReconciliationResult(); if (string.IsNullOrEmpty(connection.SessionId)) { logger.LogWarning("Connection {ConnectionId} has no session ID, skipping", connection.Id); return result; } if (connection.Accounts == null || connection.Accounts.Count == 0) { logger.LogWarning("Connection {ConnectionId} has no accounts, skipping", connection.Id); return result; } foreach (var bankAccount in connection.Accounts) { // Skip accounts that are not linked to a ledger account if (string.IsNullOrEmpty(bankAccount.LinkedAccountId)) { logger.LogDebug( "Bank account {BankAccountId} is not linked to a ledger account, skipping", bankAccount.AccountId); continue; } result.TotalAccountsChecked++; try { var discrepancy = await ReconcileAccountAsync( connection, bankAccount, cancellationToken); if (discrepancy != null) { result.Discrepancies.Add(discrepancy); logger.LogWarning( "Discrepancy found for account {AccountId}: Bank={BankBalance:N2}, Ledger={LedgerBalance:N2}, Diff={Difference:N2}", bankAccount.LinkedAccountId, discrepancy.BankBalance, discrepancy.LedgerBalance, discrepancy.Difference); } else { result.MatchedAccounts++; } } catch (Exception ex) { logger.LogError(ex, "Error reconciling bank account {BankAccountId} with ledger account {LedgerAccountId}", bankAccount.AccountId, bankAccount.LinkedAccountId); result.Errors++; } } return result; } private async Task ReconcileAccountAsync( BankConnectionReadModelDto connection, BankAccountInfo bankAccount, CancellationToken cancellationToken) { // 1. Get current balance from Enable Banking var bankBalances = await bankingClient.GetBalancesAsync( connection.SessionId!, bankAccount.AccountId, cancellationToken); // Use the "closingBooked" or "interimAvailable" balance type var bankBalance = bankBalances .Where(b => b.BalanceType is "closingBooked" or "interimAvailable" or "expected") .OrderByDescending(b => b.ReferenceDate) .FirstOrDefault(); if (bankBalance == null) { logger.LogWarning( "No suitable balance found for bank account {BankAccountId}. Available types: {Types}", bankAccount.AccountId, string.Join(", ", bankBalances.Select(b => b.BalanceType))); return null; } // 2. Get ledger account balance var ledgerAccountId = ParseAccountGuid(bankAccount.LinkedAccountId!); if (ledgerAccountId == null) { logger.LogWarning( "Could not parse ledger account ID: {LinkedAccountId}", bankAccount.LinkedAccountId); return null; } // Query aggregated balance up to now var query = new EntriesQuery { AccountIds = [ledgerAccountId.Value], To = DateTimeOffset.UtcNow, Aggregate = true }; var ledgerResult = await ledgerService.QueryEntriesAsync(query, cancellationToken); var ledgerAggregate = ledgerResult.Aggregates?.FirstOrDefault(); // Calculate net balance for the ledger account // For asset accounts (which bank accounts are), the balance is Debits - Credits var ledgerBalance = (ledgerAggregate?.TotalDebits ?? 0) - (ledgerAggregate?.TotalCredits ?? 0); // 3. Compare balances var difference = bankBalance.Amount - ledgerBalance; // Allow for small rounding differences (1 cent) if (Math.Abs(difference) <= 0.01m) { logger.LogDebug( "Account {LedgerAccountId} reconciled: Bank={BankBalance:N2}, Ledger={LedgerBalance:N2}", bankAccount.LinkedAccountId, bankBalance.Amount, ledgerBalance); return null; } // Get account details for the discrepancy report var account = await accountRepository.GetByIdAsync(bankAccount.LinkedAccountId!, cancellationToken); return new ReconciliationDiscrepancy { CompanyId = connection.CompanyId, BankConnectionId = connection.Id, BankAccountId = bankAccount.AccountId, BankAccountIban = bankAccount.Iban, LinkedAccountId = bankAccount.LinkedAccountId!, LinkedAccountNumber = account?.AccountNumber ?? "Unknown", LinkedAccountName = account?.Name ?? "Unknown", BankBalance = bankBalance.Amount, BankBalanceType = bankBalance.BalanceType, BankBalanceDate = bankBalance.ReferenceDate, LedgerBalance = ledgerBalance, Difference = difference, Currency = bankBalance.Currency, CheckedAt = DateTimeOffset.UtcNow }; } private void LogReconciliationResult(BankReconciliationResult result) { if (result.Discrepancies.Count == 0) { logger.LogInformation( "Bank reconciliation completed: {Connections} connections, {Checked} accounts checked, " + "all {Matched} accounts matched, {Errors} errors", result.TotalConnections, result.TotalAccountsChecked, result.MatchedAccounts, result.Errors); } else { logger.LogWarning( "Bank reconciliation completed with discrepancies: {Connections} connections, " + "{Checked} accounts checked, {Matched} matched, {Discrepancies} discrepancies, {Errors} errors", result.TotalConnections, result.TotalAccountsChecked, result.MatchedAccounts, result.Discrepancies.Count, result.Errors); foreach (var discrepancy in result.Discrepancies) { logger.LogWarning( "DISCREPANCY: Company={CompanyId}, Account={AccountNumber} ({AccountName}), " + "Bank={BankBalance:N2} {Currency}, Ledger={LedgerBalance:N2}, Diff={Difference:N2}", discrepancy.CompanyId, discrepancy.LinkedAccountNumber, discrepancy.LinkedAccountName, discrepancy.BankBalance, discrepancy.Currency, discrepancy.LedgerBalance, discrepancy.Difference); } } } private static Guid? ParseAccountGuid(string accountId) { if (accountId.StartsWith("account-", StringComparison.Ordinal)) { var guidString = accountId["account-".Length..]; if (Guid.TryParse(guidString, out var guid)) return guid; } else if (Guid.TryParse(accountId, out var guid)) { return guid; } return null; } } /// /// Result of a bank reconciliation run. /// public class BankReconciliationResult { public int TotalConnections { get; set; } public int TotalAccountsChecked { get; set; } public int MatchedAccounts { get; set; } public int Errors { get; set; } public List Discrepancies { get; set; } = []; } /// /// Details of a discrepancy between bank and ledger balances. /// public class ReconciliationDiscrepancy { public string CompanyId { get; set; } = string.Empty; public string BankConnectionId { get; set; } = string.Empty; public string BankAccountId { get; set; } = string.Empty; public string BankAccountIban { get; set; } = string.Empty; public string LinkedAccountId { get; set; } = string.Empty; public string LinkedAccountNumber { get; set; } = string.Empty; public string LinkedAccountName { get; set; } = string.Empty; public decimal BankBalance { get; set; } public string BankBalanceType { get; set; } = string.Empty; public DateTimeOffset? BankBalanceDate { get; set; } public decimal LedgerBalance { get; set; } public decimal Difference { get; set; } public string Currency { get; set; } = "DKK"; public DateTimeOffset CheckedAt { get; set; } }