books/backend/Books.Api/Invoicing/Services/PaymentMatchingService.cs
Nicolaj Hartmann 8096a19081 Audit v4: VAT calc, SAF-T compliance, security hardening, frontend quality
Backend (17 files):
- VAT: REP 25% deductibility (§42), EU reverse charge double-entry (IEUV/IEUY/IVY),
  IVY rate 0%→25%, VatReport Box C/D populated, Basis1 from real revenue
- SAF-T: correct OECD namespace, closing balance net calc, zero-amount fallback,
  credit note auto-numbering (§52)
- Security: BankingController CSRF state token + company auth check,
  attachment canonical path traversal check, discount 0-100% validation,
  deactivated product/customer update guard
- Quality: redact bank API logs, remove dead code (VatCalcService,
  PaymentMatchingService), CompanyAggregate IEmit interfaces, fix URL encoding

Frontend (15 files):
- Fix double "kr." in AmountText and Dashboard Statistic components
- Fix UserSettings Switch defaultChecked desync with Form state
- Remove dual useCompany/useCompanyStore pattern (Dashboard, Moms, Bank)
- Correct SKAT VAT deadline calculation per period type
- Add half-yearly/yearly VAT period options
- Guard console.error with import.meta.env.DEV
- Use shared formatDate in BankConnectionsTab
- Remove dead NONE vatCode check, purge 7 legacy VAT codes from type union
- Migrate S25→U25, K25→I25 across all pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 01:38:52 +01:00

617 lines
24 KiB
C#

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;
/// <summary>
/// Implementation of payment matching service.
/// Uses multiple signals to suggest matches between bank transactions and invoices.
/// </summary>
public partial class PaymentMatchingService(
NpgsqlDataSource dataSource,
IBankTransactionRepository bankTransactionRepo,
IInvoiceRepository invoiceRepo,
ICustomerRepository customerRepo,
IInvoicePostingService invoicePostingService,
ILogger<PaymentMatchingService> 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<IReadOnlyList<SuggestedPaymentMatch>> 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<SuggestedPaymentMatch>();
foreach (var invoice in unpaidInvoices)
{
var reasons = new List<MatchReason>();
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<IReadOnlyList<SuggestedPaymentMatch>> SuggestMatchesForCompanyAsync(
string companyId,
CancellationToken cancellationToken = default)
{
// Get all pending bank transactions
var pendingTransactions = await bankTransactionRepo.GetPendingByCompanyIdAsync(companyId, cancellationToken);
var allSuggestions = new List<SuggestedPaymentMatch>();
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<PaymentAllocationResult> 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<bool> 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<PaymentAllocation>("""
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<IReadOnlyList<SuggestedPaymentMatch>> GetPendingSuggestionsAsync(
string companyId,
CancellationToken cancellationToken = default)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var results = await connection.QueryAsync<SuggestedPaymentMatchDto>("""
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<IReadOnlyList<PaymentAllocation>> GetAllocationsForInvoiceAsync(
string invoiceId,
CancellationToken cancellationToken = default)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var results = await connection.QueryAsync<PaymentAllocation>("""
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<SuggestedPaymentMatch> 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<List<MatchReason>>(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; }
}