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>
96 lines
3.5 KiB
C#
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;
|
|
}
|
|
}
|