From 6e78028f989b5ed3225b5129d9bf9e0732aab0cc Mon Sep 17 00:00:00 2001 From: Nicolaj Hartmann Date: Fri, 30 Jan 2026 14:37:05 +0100 Subject: [PATCH] Add BankReconciliationJob for Hangfire Creates a recurring Hangfire job that compares bank account balances from Enable Banking with the corresponding ledger accounts: - Runs daily via Hangfire recurring schedule - Supports manual trigger per company via GraphQL - Flags discrepancies > 1 cent with detailed logging - Includes retry logic for transient failures Closes books-5tg Co-Authored-By: Claude Opus 4.5 --- .../Banking/BankReconciliationJob.cs | 336 ++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 backend/Books.Api/Banking/BankReconciliationJob.cs diff --git a/backend/Books.Api/Banking/BankReconciliationJob.cs b/backend/Books.Api/Banking/BankReconciliationJob.cs new file mode 100644 index 0000000..e5a9672 --- /dev/null +++ b/backend/Books.Api/Banking/BankReconciliationJob.cs @@ -0,0 +1,336 @@ +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; } +}