books/backend/Books.Api/AiBookkeeper/BankTransactionMatcher.cs
Nicolaj Hartmann 1f75c5d791 Add all backend domain, commands, repositories, and tests
This commit includes all previously untracked backend files:

Domain:
- Accounts, Attachments, BankConnections, Customers
- FiscalYears, Invoices, JournalEntryDrafts
- Orders, Products, UserAccess

Commands & Handlers:
- Full CQRS command structure for all domains

Repositories:
- PostgreSQL repositories for all read models
- Bank transaction and ledger repositories

GraphQL:
- Input types, scalars, and types for all entities
- Mutations and queries

Infrastructure:
- Banking integration (Enable Banking client)
- File storage, Invoicing, Reporting, SAF-T export
- Database migrations (003-029)

Tests:
- Integration tests for GraphQL endpoints
- Domain tests
- Invoicing and reporting tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:19:42 +01:00

96 lines
3.5 KiB
C#

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