books/backend/Books.Api/AiBookkeeper/BankTransactionMatcher.cs

97 lines
3.5 KiB
C#
Raw Normal View History

using Books.Api.EventFlow.ReadModels;
using Books.Api.EventFlow.Repositories;
namespace Books.Api.AiBookkeeper;
/// <summary>
/// Matches documents to pending bank transactions based on amount.
/// </summary>
public class BankTransactionMatcher(
IBankTransactionRepository transactionRepository,
ILogger<BankTransactionMatcher> logger) : IBankTransactionMatcher
{
public async Task<BankTransactionDto?> FindMatchingTransactionAsync(
string companyId,
decimal amount,
decimal tolerance = 0.01m,
CancellationToken cancellationToken = default)
{
// For invoice/expense: document shows positive amount, but bank transaction is negative (money leaving)
// We need to match on the absolute value
var targetAmount = amount;
logger.LogDebug(
"Searching for pending transaction matching amount {Amount} (±{Tolerance}) for company {CompanyId}",
targetAmount, tolerance, companyId);
var pendingTransactions = await transactionRepository.GetPendingByCompanyIdAsync(companyId, cancellationToken);
if (pendingTransactions.Count == 0)
{
logger.LogDebug("No pending transactions found for company {CompanyId}", companyId);
return null;
}
// Find transactions matching the amount within tolerance
// For expenses: document amount is positive, bank amount is negative
// So we compare: |bank amount| matches document amount
var matchingTransactions = pendingTransactions
.Where(t => IsAmountMatch(t.Amount, targetAmount, tolerance))
.OrderBy(t => t.TransactionDate) // Oldest first
.ThenBy(t => t.CreatedAt)
.ToList();
if (matchingTransactions.Count == 0)
{
logger.LogDebug(
"No matching transactions found for amount {Amount} (checked {Count} pending)",
targetAmount, pendingTransactions.Count);
return null;
}
var match = matchingTransactions.First();
logger.LogInformation(
"Found matching transaction {TransactionId}: {Amount} on {Date} ({Description})",
match.Id, match.Amount, match.TransactionDate.ToString("yyyy-MM-dd"), match.Description);
if (matchingTransactions.Count > 1)
{
logger.LogDebug(
"Multiple matches found ({Count}), returning oldest",
matchingTransactions.Count);
}
return match;
}
private static bool IsAmountMatch(decimal bankAmount, decimal documentAmount, decimal tolerance)
{
// For expenses: document amount is typically positive (e.g., invoice for 1000 DKK)
// Bank transaction is negative (e.g., -1000 DKK outgoing payment)
// So we need to check if the absolute values match
// Option 1: Exact match on absolute values (most common for invoice matching)
var absBank = Math.Abs(bankAmount);
var absDoc = Math.Abs(documentAmount);
if (Math.Abs(absBank - absDoc) <= tolerance)
{
return true;
}
// Option 2: Direct match (same sign and value)
if (Math.Abs(bankAmount - documentAmount) <= tolerance)
{
return true;
}
// Option 3: Opposite sign match (document positive, bank negative or vice versa)
if (Math.Abs(bankAmount + documentAmount) <= tolerance)
{
return true;
}
return false;
}
}