using System.Text.Json; using System.Text.RegularExpressions; using Books.Api.Banking; using Books.Api.EventFlow.Repositories; using Dapper; using Microsoft.Extensions.Logging; using Npgsql; namespace Books.Api.Invoicing.Services; /// /// Implementation of payment matching service. /// Uses multiple signals to suggest matches between bank transactions and invoices. /// public partial class PaymentMatchingService( NpgsqlDataSource dataSource, IBankTransactionRepository bankTransactionRepo, IInvoiceRepository invoiceRepo, ICustomerRepository customerRepo, IInvoicePostingService invoicePostingService, ILogger logger) : IPaymentMatchingService { // Confidence thresholds private const decimal AutoMatchThreshold = 0.95m; private const decimal HighConfidenceThreshold = 0.80m; // Scoring weights private const decimal ExactAmountScore = 0.50m; private const decimal CloseAmountScore = 0.30m; private const decimal ReferenceMatchScore = 0.40m; private const decimal NameMatchScore = 0.30m; private const decimal RecentInvoiceBonus = 0.10m; public async Task> SuggestMatchesAsync( string bankTransactionId, CancellationToken cancellationToken = default) { var transaction = await bankTransactionRepo.GetByIdAsync(bankTransactionId, cancellationToken); if (transaction == null) { logger.LogWarning("Bank transaction {TransactionId} not found", bankTransactionId); return []; } // Only match incoming payments (positive amounts) if (transaction.Amount <= 0) { return []; } // Get unpaid invoices for the company var unpaidInvoices = await invoiceRepo.GetUnpaidByCompanyIdAsync(transaction.CompanyId, cancellationToken); if (unpaidInvoices.Count == 0) { return []; } var suggestions = new List(); foreach (var invoice in unpaidInvoices) { var reasons = new List(); decimal totalScore = 0; // 1. Amount matching var amountScore = CalculateAmountScore(transaction.Amount, invoice.AmountRemaining); if (amountScore > 0) { reasons.Add(new MatchReason { Reason = amountScore >= ExactAmountScore ? "exact_amount" : "close_amount", Description = amountScore >= ExactAmountScore ? "Beløbet matcher præcist" : $"Beløbet er tæt på ({transaction.Amount:N2} vs {invoice.AmountRemaining:N2})", Score = amountScore }); totalScore += amountScore; } // 2. Reference matching (look for invoice number in transaction reference) var referenceScore = CalculateReferenceScore( transaction.Reference, transaction.Description, invoice.InvoiceNumber); if (referenceScore > 0) { reasons.Add(new MatchReason { Reason = "reference_match", Description = $"Fakturanummer '{invoice.InvoiceNumber}' fundet i reference", Score = referenceScore }); totalScore += referenceScore; } // 3. Name matching (compare counterparty to customer name) var customer = await customerRepo.GetByIdAsync(invoice.CustomerId, cancellationToken); if (customer != null) { var nameScore = CalculateNameScore(transaction.CounterpartyName, customer.Name); if (nameScore > 0) { reasons.Add(new MatchReason { Reason = "name_match", Description = $"Modpartsnavn ligner kundenavn", Score = nameScore }); totalScore += nameScore; } } // 4. Recent invoice bonus (invoices from last 30 days get a small boost) if (invoice.InvoiceDate.HasValue) { var invoiceDate = DateOnly.FromDateTime(invoice.InvoiceDate.Value); var daysSinceInvoice = (DateOnly.FromDateTime(DateTime.Today).DayNumber - invoiceDate.DayNumber); if (daysSinceInvoice <= 30) { reasons.Add(new MatchReason { Reason = "recent_invoice", Description = "Faktura udstedt inden for de sidste 30 dage", Score = RecentInvoiceBonus }); totalScore += RecentInvoiceBonus; } } // Only suggest if we have at least one matching signal if (reasons.Count > 0 && totalScore >= 0.20m) { // Cap confidence at 1.0 var confidence = Math.Min(totalScore, 1.0m); suggestions.Add(new SuggestedPaymentMatch { Id = Guid.NewGuid().ToString(), CompanyId = transaction.CompanyId, BankTransactionId = bankTransactionId, InvoiceId = invoice.Id, BankTransactionAmount = transaction.Amount, BankTransactionDate = DateOnly.FromDateTime(transaction.BookingDate ?? transaction.ValueDate ?? DateTime.Today), BankTransactionDescription = transaction.Description, BankTransactionCounterparty = transaction.CounterpartyName, InvoiceNumber = invoice.InvoiceNumber, CustomerName = invoice.CustomerName, InvoiceAmountRemaining = invoice.AmountRemaining, InvoiceDueDate = invoice.DueDate.HasValue ? DateOnly.FromDateTime(invoice.DueDate.Value) : null, Confidence = confidence, SuggestedAmount = Math.Min(transaction.Amount, invoice.AmountRemaining), MatchReasons = reasons, Status = "pending", CreatedAt = DateTimeOffset.UtcNow }); } } // Sort by confidence descending return suggestions.OrderByDescending(s => s.Confidence).ToList(); } public async Task> SuggestMatchesForCompanyAsync( string companyId, CancellationToken cancellationToken = default) { // Get all pending bank transactions var pendingTransactions = await bankTransactionRepo.GetPendingByCompanyIdAsync(companyId, cancellationToken); var allSuggestions = new List(); foreach (var transaction in pendingTransactions) { var suggestions = await SuggestMatchesAsync(transaction.Id, cancellationToken); allSuggestions.AddRange(suggestions); } // Sort by confidence and store in database var sortedSuggestions = allSuggestions .OrderByDescending(s => s.Confidence) .ToList(); // Store suggestions in database for later retrieval await StoreSuggestionsAsync(sortedSuggestions, cancellationToken); return sortedSuggestions; } public async Task ConfirmMatchAsync( string bankTransactionId, string invoiceId, decimal amount, string matchMethod, string userId, CancellationToken cancellationToken = default) { try { // Validate bank transaction var transaction = await bankTransactionRepo.GetByIdAsync(bankTransactionId, cancellationToken); if (transaction == null) { return PaymentAllocationResult.Failed("TRANSACTION_NOT_FOUND", "Bank transaction not found"); } if (transaction.Status != "pending") { return PaymentAllocationResult.Failed("TRANSACTION_NOT_PENDING", $"Bank transaction is not pending (status: {transaction.Status})"); } // Validate invoice var invoice = await invoiceRepo.GetByIdAsync(invoiceId, cancellationToken); if (invoice == null) { return PaymentAllocationResult.Failed("INVOICE_NOT_FOUND", "Invoice not found"); } if (invoice.Status != "sent" && invoice.Status != "partially_paid") { return PaymentAllocationResult.Failed("INVOICE_NOT_PAYABLE", $"Invoice cannot receive payment (status: {invoice.Status})"); } if (amount > invoice.AmountRemaining) { return PaymentAllocationResult.Failed("AMOUNT_EXCEEDS_REMAINING", $"Amount ({amount:N2}) exceeds remaining ({invoice.AmountRemaining:N2})"); } // Get bank account for posting var bankAccountId = transaction.BankAccountId; if (string.IsNullOrEmpty(bankAccountId)) { return PaymentAllocationResult.Failed("NO_BANK_ACCOUNT", "Bank transaction has no associated account"); } // Post payment to ledger var postingResult = await invoicePostingService.PostPaymentAsync( invoiceId, invoice.CustomerId, bankAccountId, amount, invoice.FiscalYearId!, transaction.Reference, cancellationToken); if (!postingResult.Success) { return PaymentAllocationResult.Failed( postingResult.ErrorCode ?? "POSTING_FAILED", postingResult.ErrorMessage ?? "Failed to post payment"); } // Create allocation record var allocationId = Guid.NewGuid().ToString(); await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); await connection.ExecuteAsync(""" INSERT INTO payment_allocations ( id, company_id, bank_transaction_id, invoice_id, amount, allocation_type, match_method, ledger_transaction_id, created_by ) VALUES ( @Id, @CompanyId, @BankTransactionId, @InvoiceId, @Amount, 'payment', @MatchMethod, @LedgerTransactionId, @CreatedBy ) """, new { Id = allocationId, CompanyId = transaction.CompanyId, BankTransactionId = bankTransactionId, InvoiceId = invoiceId, Amount = amount, MatchMethod = matchMethod, LedgerTransactionId = postingResult.TransactionId, CreatedBy = userId }); // Mark bank transaction as booked (with journal entry draft ID for tracking) await bankTransactionRepo.UpdateStatusAsync(bankTransactionId, "booked", null, cancellationToken); // Update suggestion status if exists await connection.ExecuteAsync(""" UPDATE suggested_payment_matches SET status = 'accepted', reviewed_at = NOW(), reviewed_by = @UserId WHERE bank_transaction_id = @BankTransactionId AND invoice_id = @InvoiceId AND status = 'pending' """, new { BankTransactionId = bankTransactionId, InvoiceId = invoiceId, UserId = userId }); logger.LogInformation( "Confirmed match: Bank transaction {TransactionId} -> Invoice {InvoiceId}, Amount: {Amount:N2}", bankTransactionId, invoiceId, amount); return PaymentAllocationResult.Succeeded(new PaymentAllocation { Id = allocationId, CompanyId = transaction.CompanyId, BankTransactionId = bankTransactionId, InvoiceId = invoiceId, Amount = amount, AllocationType = "payment", MatchMethod = matchMethod, LedgerTransactionId = postingResult.TransactionId, CreatedAt = DateTimeOffset.UtcNow, CreatedBy = userId }); } catch (Exception ex) { logger.LogError(ex, "Error confirming match for transaction {TransactionId}", bankTransactionId); return PaymentAllocationResult.Failed("CONFIRM_FAILED", ex.Message); } } public async Task RejectMatchAsync( string bankTransactionId, string invoiceId, string userId, CancellationToken cancellationToken = default) { await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); await connection.ExecuteAsync(""" UPDATE suggested_payment_matches SET status = 'rejected', reviewed_at = NOW(), reviewed_by = @UserId WHERE bank_transaction_id = @BankTransactionId AND invoice_id = @InvoiceId AND status = 'pending' """, new { BankTransactionId = bankTransactionId, InvoiceId = invoiceId, UserId = userId }); logger.LogInformation( "Rejected match: Bank transaction {TransactionId} -> Invoice {InvoiceId}", bankTransactionId, invoiceId); } public async Task RemoveAllocationAsync( string allocationId, string userId, CancellationToken cancellationToken = default) { await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); // Get allocation details first var allocation = await connection.QuerySingleOrDefaultAsync(""" SELECT id AS Id, company_id AS CompanyId, bank_transaction_id AS BankTransactionId, invoice_id AS InvoiceId, amount AS Amount FROM payment_allocations WHERE id = @AllocationId """, new { AllocationId = allocationId }); if (allocation == null) { return false; } // Delete allocation await connection.ExecuteAsync( "DELETE FROM payment_allocations WHERE id = @AllocationId", new { AllocationId = allocationId }); // Mark bank transaction as pending again if (!string.IsNullOrEmpty(allocation.BankTransactionId)) { await bankTransactionRepo.UpdateStatusAsync(allocation.BankTransactionId, "pending", null, cancellationToken); } logger.LogInformation( "Removed allocation {AllocationId}: Bank transaction {TransactionId} -> Invoice {InvoiceId}", allocationId, allocation.BankTransactionId, allocation.InvoiceId); return true; } public async Task> GetPendingSuggestionsAsync( string companyId, CancellationToken cancellationToken = default) { await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); var results = await connection.QueryAsync(""" SELECT spm.id, spm.company_id, spm.bank_transaction_id, spm.invoice_id, spm.confidence, spm.match_reasons, spm.suggested_amount, spm.status, spm.created_at, bt.amount AS bank_amount, bt.booking_date AS bank_date, bt.description AS bank_description, bt.counterparty_name AS bank_counterparty, inv.invoice_number, inv.customer_name, inv.amount_remaining AS invoice_remaining, inv.due_date AS invoice_due_date FROM suggested_payment_matches spm JOIN bank_transactions bt ON bt.id = spm.bank_transaction_id JOIN invoice_read_models inv ON inv.aggregate_id = spm.invoice_id WHERE spm.company_id = @CompanyId AND spm.status = 'pending' ORDER BY spm.confidence DESC """, new { CompanyId = companyId }); return results.Select(MapToSuggestedMatch).ToList(); } public async Task> GetAllocationsForInvoiceAsync( string invoiceId, CancellationToken cancellationToken = default) { await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); var results = await connection.QueryAsync(""" SELECT id AS Id, company_id AS CompanyId, bank_transaction_id AS BankTransactionId, invoice_id AS InvoiceId, credit_note_id AS CreditNoteId, amount AS Amount, allocation_type AS AllocationType, match_method AS MatchMethod, match_confidence AS MatchConfidence, ledger_transaction_id AS LedgerTransactionId, created_at AS CreatedAt, created_by AS CreatedBy FROM payment_allocations WHERE invoice_id = @InvoiceId ORDER BY created_at DESC """, new { InvoiceId = invoiceId }); return results.ToList(); } #region Private Methods private static decimal CalculateAmountScore(decimal transactionAmount, decimal invoiceRemaining) { if (transactionAmount == invoiceRemaining) { return ExactAmountScore; } // Allow 5% tolerance for close matches var tolerance = invoiceRemaining * 0.05m; if (Math.Abs(transactionAmount - invoiceRemaining) <= tolerance) { return CloseAmountScore; } // Partial payment matching if (transactionAmount < invoiceRemaining && transactionAmount > 0) { return CloseAmountScore * 0.5m; // Lower score for partial } return 0; } private decimal CalculateReferenceScore(string? reference, string? description, string invoiceNumber) { if (string.IsNullOrWhiteSpace(invoiceNumber)) return 0; var searchText = $"{reference} {description}".ToUpperInvariant(); // Look for exact invoice number if (searchText.Contains(invoiceNumber.ToUpperInvariant())) { return ReferenceMatchScore; } // Look for invoice number without prefix (e.g., "2024-0001" instead of "INV-2024-0001") var numberMatch = InvoiceNumberRegex().Match(invoiceNumber); if (numberMatch.Success) { var shortNumber = numberMatch.Groups[1].Value; if (searchText.Contains(shortNumber)) { return ReferenceMatchScore * 0.8m; } } return 0; } private static decimal CalculateNameScore(string? counterpartyName, string customerName) { if (string.IsNullOrWhiteSpace(counterpartyName) || string.IsNullOrWhiteSpace(customerName)) return 0; var normalizedCounterparty = NormalizeName(counterpartyName); var normalizedCustomer = NormalizeName(customerName); // Exact match if (normalizedCounterparty == normalizedCustomer) { return NameMatchScore; } // Contains match if (normalizedCounterparty.Contains(normalizedCustomer) || normalizedCustomer.Contains(normalizedCounterparty)) { return NameMatchScore * 0.8m; } // Word overlap var counterpartyWords = normalizedCounterparty.Split(' ', StringSplitOptions.RemoveEmptyEntries); var customerWords = normalizedCustomer.Split(' ', StringSplitOptions.RemoveEmptyEntries); var overlap = counterpartyWords.Intersect(customerWords).Count(); if (overlap > 0) { var overlapRatio = (decimal)overlap / Math.Max(counterpartyWords.Length, customerWords.Length); return NameMatchScore * overlapRatio * 0.6m; } return 0; } private static string NormalizeName(string name) { // Remove common suffixes and normalize return name .ToUpperInvariant() .Replace("APS", "") .Replace("A/S", "") .Replace("IVS", "") .Replace("K/S", "") .Trim(); } private async Task StoreSuggestionsAsync( IReadOnlyList suggestions, CancellationToken cancellationToken) { if (suggestions.Count == 0) return; await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); foreach (var suggestion in suggestions) { await connection.ExecuteAsync(""" INSERT INTO suggested_payment_matches ( id, company_id, bank_transaction_id, invoice_id, confidence, match_reasons, suggested_amount, status ) VALUES ( @Id, @CompanyId, @BankTransactionId, @InvoiceId, @Confidence, @MatchReasons::jsonb, @SuggestedAmount, 'pending' ) ON CONFLICT (bank_transaction_id, invoice_id) WHERE status = 'pending' DO UPDATE SET confidence = EXCLUDED.confidence, match_reasons = EXCLUDED.match_reasons, suggested_amount = EXCLUDED.suggested_amount """, new { suggestion.Id, suggestion.CompanyId, suggestion.BankTransactionId, suggestion.InvoiceId, suggestion.Confidence, MatchReasons = JsonSerializer.Serialize(suggestion.MatchReasons), suggestion.SuggestedAmount }); } } private static SuggestedPaymentMatch MapToSuggestedMatch(SuggestedPaymentMatchDto dto) { var reasons = string.IsNullOrEmpty(dto.MatchReasons) ? [] : JsonSerializer.Deserialize>(dto.MatchReasons) ?? []; return new SuggestedPaymentMatch { Id = dto.Id, CompanyId = dto.CompanyId, BankTransactionId = dto.BankTransactionId, InvoiceId = dto.InvoiceId, BankTransactionAmount = dto.BankAmount, BankTransactionDate = dto.BankDate.HasValue ? DateOnly.FromDateTime(dto.BankDate.Value) : DateOnly.FromDateTime(DateTime.Today), BankTransactionDescription = dto.BankDescription, BankTransactionCounterparty = dto.BankCounterparty, InvoiceNumber = dto.InvoiceNumber, CustomerName = dto.CustomerName, InvoiceAmountRemaining = dto.InvoiceRemaining, InvoiceDueDate = dto.InvoiceDueDate.HasValue ? DateOnly.FromDateTime(dto.InvoiceDueDate.Value) : null, Confidence = dto.Confidence, SuggestedAmount = dto.SuggestedAmount, MatchReasons = reasons, Status = dto.Status, CreatedAt = dto.CreatedAt }; } [GeneratedRegex(@"(\d{4}-\d+)")] private static partial Regex InvoiceNumberRegex(); #endregion } internal class SuggestedPaymentMatchDto { public string Id { get; set; } = string.Empty; public string CompanyId { get; set; } = string.Empty; public string BankTransactionId { get; set; } = string.Empty; public string InvoiceId { get; set; } = string.Empty; public decimal Confidence { get; set; } public string? MatchReasons { get; set; } public decimal SuggestedAmount { get; set; } public string Status { get; set; } = "pending"; public DateTimeOffset CreatedAt { get; set; } // Bank transaction fields public decimal BankAmount { get; set; } public DateTime? BankDate { get; set; } public string? BankDescription { get; set; } public string? BankCounterparty { get; set; } // Invoice fields public string InvoiceNumber { get; set; } = string.Empty; public string CustomerName { get; set; } = string.Empty; public decimal InvoiceRemaining { get; set; } public DateTime? InvoiceDueDate { get; set; } }