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 <noreply@anthropic.com>
This commit is contained in:
parent
9b5ed27776
commit
6e78028f98
1 changed files with 336 additions and 0 deletions
336
backend/Books.Api/Banking/BankReconciliationJob.cs
Normal file
336
backend/Books.Api/Banking/BankReconciliationJob.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class BankReconciliationJob(
|
||||
IBankConnectionRepository connectionRepository,
|
||||
IAccountRepository accountRepository,
|
||||
IEnableBankingClient bankingClient,
|
||||
ILedgerService ledgerService,
|
||||
ILogger<BankReconciliationJob> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// Reconcile all linked bank accounts across all companies.
|
||||
/// Called by Hangfire recurring job daily.
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconcile bank accounts for a specific company (manual trigger from UI).
|
||||
/// </summary>
|
||||
public async Task<BankReconciliationResult> 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<BankReconciliationResult> 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<ReconciliationDiscrepancy?> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a bank reconciliation run.
|
||||
/// </summary>
|
||||
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<ReconciliationDiscrepancy> Discrepancies { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Details of a discrepancy between bank and ledger balances.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue