On this page

Reconciling invoices and payments

Reconciliation is a good test of readable business code: input errors, duplicate transactions, unmatched references and amount differences need different outcomes. A successful batch does not imply that every invoice was paid.

This example reconciles one in-memory batch in EUR. Amounts already contain at most two decimal places; it does not convert currencies, calculate tax, allocate a payment across invoices or write ledger entries. The result is an audit report for a later approval or posting step.

Input and audit models

Each invoice reference is unique within the batch. Payment identifiers identify transactions, so repeated identifiers must not be counted twice. A payment reference is matched using an explicit ordinal case-insensitive comparer. That data policy is separate from DirectSharp's case-insensitive source identifiers.

import Global.System
import Global.System.Collections.Generic

public class InvoiceEntry
    public Reference as String = ""
    public Amount as Decimal = 0
end class

public class PaymentEntry
    public Id as Guid = Guid.Empty
    public InvoiceReference as String = ""
    public Amount as Decimal = 0
end class

public enum ReconciliationState
    Unpaid = 0
    PartPaid = 1
    Balanced = 2
    Overpaid = 3
end enum

public class ReconciliationLine
    public Reference as String = ""
    public Expected as Decimal = 0
    public Received as Decimal = 0
    public State as ReconciliationState = ReconciliationState.Unpaid
end class

public class ReconciliationReport
    public Lines as List <of ReconciliationLine> = new List <of ReconciliationLine>()
    public Issues as List <of String> = new List <of String>()
end class

Zero is a useful default for a new amount, but this workflow requires positive invoices and payments. Credit notes, refunds and zero-value invoices need their own stated accounting policies.

Validating, grouping and comparing

Place the following class in InvoiceReconciler.ds. It uses only ordinary .NET collections and LINQ; there is no package-specific reconciliation API.

import Global.System
import Global.System.Collections.Generic
import Global.System.Linq

public class InvoiceReconciler
    public action Reconcile(
        invoices as List <of InvoiceEntry>,
        payments as List <of PaymentEntry>
    )
        returns ReconciliationReport
        require invoices is not nothing
        require payments is not nothing

        let report = new ReconciliationReport()
        let references = new HashSet <of String>(StringComparer.OrdinalIgnoreCase)
        for_each invoice in invoices
            require invoice is not nothing otherwise new ArgumentException("Invoice is required")
            require invoice.Reference is not whitespace otherwise new ArgumentException("Invoice reference is required")
            require invoice.Amount > 0 otherwise new ArgumentException("Invoice amount must be positive")
            require Decimal.Round(invoice.Amount, 2) = invoice.Amount otherwise new ArgumentException("Invoice amount has fractional cents")
            if not references.Add(invoice.Reference) then
                throw new ArgumentException("Duplicate invoice reference: {{invoice.Reference}}")
            end if
        end for_each

        let seenPayments = new HashSet <of Guid>()
        let accepted = new List <of PaymentEntry>()
        for_each payment in payments
            require payment is not nothing otherwise new ArgumentException("Payment is required")
            require payment.Id <> Guid.Empty otherwise new ArgumentException("Payment identifier is required")
            require payment.InvoiceReference is not whitespace otherwise new ArgumentException("Payment reference is required")
            require payment.Amount > 0 otherwise new ArgumentException("Payment amount must be positive")
            require Decimal.Round(payment.Amount, 2) = payment.Amount otherwise new ArgumentException("Payment amount has fractional cents")
            if not seenPayments.Add(payment.Id) then
                report.Issues.Add("Duplicate payment {{payment.Id}} ignored")
            elseif not references.Contains(payment.InvoiceReference) then
                report.Issues.Add("Payment {{payment.Id}} has unknown invoice {{payment.InvoiceReference}}")
            else
                accepted.Add(payment)
            end if
        end for_each

        let byInvoice = accepted.ToLookup(.InvoiceReference, StringComparer.OrdinalIgnoreCase)
        for_each invoice in invoices
            let received = byInvoice[invoice.Reference].Sum(.Amount)
            let line = new ReconciliationLine()
            line.Reference = invoice.Reference
            line.Expected = invoice.Amount
            line.Received = received
            if received = 0 then
                line.State = ReconciliationState.Unpaid
            elseif received < invoice.Amount then
                line.State = ReconciliationState.PartPaid
            elseif received = invoice.Amount then
                line.State = ReconciliationState.Balanced
            else
                line.State = ReconciliationState.Overpaid
            end if
            report.Lines.Add(line)
        end for_each
        return report
    end action
end class

Contracts reject malformed input because the calling boundary must supply a structurally valid batch. A repeated payment or an unknown invoice is an expected reconciliation issue, so it becomes a report entry. If an identifier appears twice with different details, the first structurally valid occurrence wins; later occurrences are reported, never added again. Even an unknown-reference occurrence reserves its identifier. A production import can instead quarantine the whole conflicting group before calling this action.

ToLookup builds an in-memory lookup once. An absent key produces an empty grouping, and the Decimal Sum overload returns zero for that grouping. .InvoiceReference and .Amount are typed selectors passed to normal LINQ APIs. No database query is involved. Decimal preserves decimal amounts; summation overflow propagates rather than manufacturing a balanced result.

References are not trimmed here. Normalize whitespace at the import boundary if the input format permits it. The explicit comparer makes INV-42 match inv-42, but does not make INV-42 the same reference. Output lines retain invoice input order, and issues retain payment input order. Callers must not mutate either list or its contained models during reconciliation.

Reading the report

public class ReconciliationPrinter
    public static action Print(report as ReconciliationReport)
        require report is not nothing
        for_each line in report.Lines
            Global.System.Console.WriteLine("{{line.Reference}}: {{line.State}}, expected {{line.Expected}}, received {{line.Received}} EUR")
        end for_each
        for_each issue in report.Issues
            Global.System.Console.WriteLine("Review: {{issue}}")
        end for_each
    end action
end class

This display uses the host's normal formatting culture. A machine-readable accounting export should select its formatting and schema explicitly. The report does not mark an invoice paid, consume a payment feed or create a database transaction.

Scenario Expected result
Invoice 100, no matching payment Unpaid, received 0
Invoice 100, distinct payments 30 and 70 Balanced, received 100
Invoice 100, payment 30 PartPaid, received 30
Invoice 100, payment 120 Overpaid, received 120
Same payment identifier appears twice One counted occurrence; duplicate issue
Payment refers to an unknown invoice Issue; payment excluded from invoice totals
Invoice references differ only by ASCII case Duplicate invoice input rejected
Null entry, empty identifier, nonpositive amount or fractional cent Argument failure; no returned report
Empty invoice and payment lists Present report with no lines or issues

Keep report tests separate from tests for any eventual posting transaction, import parser or currency conversion.