using LedgerFlow.Core.Domain;
namespace LedgerFlow.Core.Matching;
///
/// The three-way match: reconcile an against its
/// (what we agreed to buy) and its s (what actually arrived). Anything
/// inside auto-posts; everything else becomes a typed exception for
/// a human. The method collects all problems rather than failing on the first, because a
/// reviewer wants the whole picture in one pass.
///
public sealed class ThreeWayMatcher
{
private readonly MatchTolerances _tolerances;
public ThreeWayMatcher(MatchTolerances? tolerances = null) =>
_tolerances = tolerances ?? MatchTolerances.Default;
public MatchResult Match(
Invoice invoice,
PurchaseOrder? purchaseOrder,
IReadOnlyCollection receipts,
IInvoiceHistory? history = null)
{
ArgumentNullException.ThrowIfNull(invoice);
ArgumentNullException.ThrowIfNull(receipts);
history ??= EmptyInvoiceHistory.Instance;
var exceptions = new List();
if (invoice.ExtractionConfidence < _tolerances.MinExtractionConfidence)
{
exceptions.Add(new MatchException(
ExceptionCode.LowConfidence,
$"Extraction confidence {invoice.ExtractionConfidence:P0} is below the " +
$"{_tolerances.MinExtractionConfidence:P0} threshold."));
}
if (history.HasSeen(invoice.DeduplicationKey))
{
exceptions.Add(new MatchException(
ExceptionCode.DuplicateInvoice,
$"Invoice {invoice.InvoiceNumber} from supplier {invoice.SupplierId} has already been posted."));
}
// Without a PO there is nothing to match against; stop here — the per-line checks below
// would all be noise on top of the real problem.
if (purchaseOrder is null)
{
exceptions.Add(new MatchException(
ExceptionCode.MissingPurchaseOrder,
invoice.PurchaseOrderNumber is null
? "Invoice carries no PO number."
: $"No open purchase order {invoice.PurchaseOrderNumber} was found."));
return MatchResult.NeedsReview(exceptions);
}
if (!string.Equals(invoice.SupplierId, purchaseOrder.SupplierId, StringComparison.OrdinalIgnoreCase))
{
exceptions.Add(new MatchException(
ExceptionCode.SupplierMismatch,
$"Invoice supplier {invoice.SupplierId} does not match PO supplier {purchaseOrder.SupplierId}."));
}
if (!string.Equals(invoice.Total.Currency, purchaseOrder.Currency, StringComparison.OrdinalIgnoreCase))
{
// A currency mismatch makes every downstream amount comparison meaningless, so return early.
exceptions.Add(new MatchException(
ExceptionCode.CurrencyMismatch,
$"Invoice currency {invoice.Total.Currency} does not match PO currency {purchaseOrder.Currency}."));
return MatchResult.NeedsReview(exceptions);
}
var receivedBySku = SumReceiptsBySku(receipts, purchaseOrder.PurchaseOrderNumber);
foreach (var line in invoice.Lines)
{
var poLine = purchaseOrder.FindLine(line.Sku);
if (poLine is null)
{
exceptions.Add(new MatchException(
ExceptionCode.UnorderedItem,
$"SKU {line.Sku} is on the invoice but not on the purchase order.",
line.Sku));
continue;
}
CheckPrice(line, poLine, exceptions);
CheckQuantityAgainstReceipts(line, receivedBySku, exceptions);
}
CheckTotals(invoice, exceptions);
return exceptions.Count == 0
? MatchResult.Clean()
: MatchResult.NeedsReview(exceptions);
}
private void CheckPrice(InvoiceLine line, PurchaseOrderLine poLine, List exceptions)
{
var invoicePrice = line.UnitPrice.Amount;
var poPrice = poLine.UnitPrice.Amount;
var allowed = poPrice * _tolerances.UnitPriceRelativeTolerance + _tolerances.UnitPriceAbsoluteTolerance;
if (Math.Abs(invoicePrice - poPrice) > allowed)
{
exceptions.Add(new MatchException(
ExceptionCode.PriceOverTolerance,
$"SKU {line.Sku}: invoiced {line.UnitPrice} vs PO {poLine.UnitPrice} exceeds tolerance.",
line.Sku));
}
}
private void CheckQuantityAgainstReceipts(
InvoiceLine line,
IReadOnlyDictionary receivedBySku,
List exceptions)
{
if (!receivedBySku.TryGetValue(Normalise(line.Sku), out var received) || received <= 0m)
{
exceptions.Add(new MatchException(
ExceptionCode.NoGoodsReceipt,
$"SKU {line.Sku}: invoiced {line.Quantity} but nothing has been received yet.",
line.Sku));
return;
}
var allowed = received * (1m + _tolerances.QuantityOverReceiptTolerance);
if (line.Quantity > allowed)
{
exceptions.Add(new MatchException(
ExceptionCode.QuantityOverReceipt,
$"SKU {line.Sku}: invoiced {line.Quantity} exceeds received {received}.",
line.Sku));
}
}
private static void CheckTotals(Invoice invoice, List exceptions)
{
var lineSum = invoice.Lines.Aggregate(
Money.Zero(invoice.Total.Currency),
(acc, l) => acc + l.LineTotal);
var expected = invoice.Tax is { } tax ? lineSum + tax : lineSum;
// Allow a cent of rounding drift between summed lines (+ tax) and the stated total.
if (Money.AbsoluteDifference(expected, invoice.Total).Amount > 0.01m)
{
exceptions.Add(new MatchException(
ExceptionCode.TotalsMismatch,
$"Line items{(invoice.Tax is null ? "" : " + tax")} total {expected} but the invoice states {invoice.Total}."));
}
}
private static IReadOnlyDictionary SumReceiptsBySku(
IEnumerable receipts,
string purchaseOrderNumber)
{
var totals = new Dictionary(StringComparer.Ordinal);
foreach (var receipt in receipts)
{
if (!string.Equals(receipt.PurchaseOrderNumber, purchaseOrderNumber, StringComparison.OrdinalIgnoreCase))
{
continue;
}
foreach (var line in receipt.Lines)
{
var key = Normalise(line.Sku);
totals[key] = totals.GetValueOrDefault(key) + line.ReceivedQuantity;
}
}
return totals;
}
private static string Normalise(string sku) => sku.Trim().ToUpperInvariant();
}