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; }
+}