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>
617 lines
24 KiB
C#
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; }
|
|
}
|