The month-end close is two exports side by side and a column of lookups. Most rows match. The rest are the reconciliation: a payout forty cents short, a payment the counterparty booked twice, a deposit with no reference, and a list of "unexplained" items somebody carries into next month. Teams that set out to automate reconciliation build the matching first, and in our view matching is the smaller half of the problem.

Matching is a function: lines in, pairs out. What it leaves behind is state. Every unmatched line (reconciliation teams call it a break) is an open question with an age, a probable cause and, ideally, an owner, and it has to survive the next run and the one after. A job that joins two files and prints the leftovers remembers none of that. It lists the same lines again every night with nothing to say when each first appeared, it cannot connect a statement line that arrives on Thursday to a ledger line it saw on Tuesday, and it can hide a duplicate completely.

This article sets out a design for the whole engine in .NET: a five-rung matching ladder ordered by how certain each match is (our own framing), tolerances that move a difference into a named account instead of deleting it, an open-items book that keeps age and cause across runs, and a proof total that shows no line has gone missing. We compiled and ran the code on .NET 10 against a small synthetic data set (35 lines, three runs), so the behaviours in the results below are ones we watched. What we say about Stripe's reporting and about .NET's rounding and decimal types comes from their documentation, linked where it appears. It is written for the CTO or engineering lead whose finance or operations team does this in a spreadsheet today.

What It Takes to Automate Reconciliation

Reconciliation compares two records of the same events, kept by different parties or systems, until every difference has an explanation: a ledger against a bank statement, a platform's sales against a processor's payouts, a vendor's feed against your holdings. The manual process behind our proxy voting case study included reconciling vendor data against holdings, which is the same shape. The differences come in a few forms, and the form decides the fix:

  • One side only. A ledger line with no statement line is often timing and sometimes a payment that never came. A statement line with no ledger line is an unbooked receipt, or a duplicate.
  • Same item, different amount. A fee netted off, a rounding difference, or an error.
  • Many to one. One payout settles a batch of sales. Stripe's payout reconciliation report exists to match each payout with the batch of transactions it settles, and it is only available with automatic payouts enabled (for platforms, on connected accounts that have them). For instant payouts the same page says Stripe cannot identify which transactions are included, and that you reconcile them against your own transaction history.
  • No shared key. The reference you send is not always the reference that comes back. The same page says most banks truncate the statement descriptor and show it inconsistently, and some do not show it at all.
  • Corrections after the fact. Stripe's reporting guide describes balance transactions as immutable, with a refund recorded as a new transaction that negates the original. In our reading, that means a correction arrives as a new line, not as an edit to an old one.

Stripe is one example; the same forms appear with any bank or processor. The design below has two parts that should be built and tested separately: a matcher, which turns lines into matches, and a book, which remembers everything that is still open, since when, and why.

The Naive Join, and What It Hid

The first version is a join on reference and amount, with the leftovers printed:

// NAIVE: join the two files on (reference, amount) and print whatever is left over.
public static NaiveResult Report(IEnumerable<Item> ours, IEnumerable<Item> theirs)
{
    var o = ours.Where(i => i.Reference is not null).ToList();
    var t = theirs.Where(i => i.Reference is not null).ToList();

    var pairs = o.Join(t,
        x => (x.Reference, x.Minor), y => (y.Reference, y.Minor),
        (x, y) => (O: x, T: y)).ToList();
    var oursLeft = ours.Except(pairs.Select(p => p.O)).ToList();
    var theirsLeft = theirs.Except(pairs.Select(p => p.T)).ToList();
    return new NaiveResult(pairs.Count, oursLeft, theirsLeft, pairs);
}

We ran it on a day-one pair of files (18 ledger lines, 13 statement lines) and again on day three's. Three things went wrong, and none of them is visible in its output:

  • A duplicate vanished. The ledger has one line for reference R-011 and the statement has two. The join paired the ledger line with both, so it reported four pairs where only three exact matches exist, and the double posting never appeared as a break.
  • Every break looks alike. A one-cent rounding difference, a five-cent mismatch and a settlement that is forty cents short all land in one flat list of unmatched lines, 15 on our side and 9 on theirs. A payout that matches its lines exactly sits in that list too, because the lines carry no shared key with it.
  • A late arrival is orphaned. On day three the statement delivered the line for R-010, whose ledger twin was in day one's file. Given day three's files alone, the job reports it as an unexplained statement line. Re-reading the whole history pairs them, but then it prints the full list again every night (15 ledger and 9 statement lines on day three) with nothing to say when any line first appeared.

The Matching Ladder: Five Rungs, Ordered by Certainty

Matching rules are not equal. A shared reference with an equal amount is strong evidence; "same amount, same week" is a guess. The ladder is our own framing, not a standard. It runs the rules from most certain to least, uses each line at most once, and records which rule and which version made every match.

RungWhat must agreeWho posts itWhat is recorded
1. Exact Account, currency, shared reference and amount The engine The rule and its version
2. Keyed, within tolerance Shared reference, and an amount inside the account's declared tolerance The engine The rule and version, and the difference, posted to a named variance account
3. Keyed group Our lines tagged with a settlement reference, and one counterparty line with that reference whose amount equals their sum The engine The rule and version, and every member line
4. Unique candidate No shared reference. Account, currency, amount and a date window, with exactly one candidate in each direction A person approves a proposal The rule, and the approver
5. Open item Everything else Nobody yet A cause class, the first-seen time, an age and an owner

The order rests on one asymmetry, which is our judgement. A match the engine misses stays on the open list, where somebody will eventually look at it. A match it gets wrong leaves the list, and takes with it the one signal that something was off. So the engine posts on its own only when a shared key is the evidence (rungs 1 to 3), and when it has only circumstantial evidence it proposes and waits for a person (rung 4). Rung 4 follows one rule: unique or nothing. If a statement line could belong to two ledger lines, or a ledger line to two statement lines, there is no proposal at all.

In our run, two ledger lines and two statement lines of 50.00 each, with no statement references and all within two days of each other, produced no proposal. The one unambiguous 412.50 pair produced a proposal, and the approval was recorded with the approver's name.

A further rung is tempting: search for any combination of ledger lines that sums to an unkeyed bank amount. That is the subset-sum problem, which is NP-complete in general (Cormen, Leiserson, Rivest and Stein, Introduction to Algorithms, 3rd ed., 2009). We did not build it. If you do, bound the search (one account, a date window, a cap on the number of lines) and apply the rule from rung 4: exactly one solution, or no proposal.

Tolerances: Move the Difference, Don't Delete It

Some differences are legitimate and permanent: a fee netted at settlement, a rounding convention. A tolerance is how rung 2 accepts them, and rounding is a common source. Microsoft's Math.Round documentation says that midpoint values round to the nearest even number by default. It calls that form the standard in financial and statistical operations, and describes rounding away from zero as the most widely known form. The two disagree on exact ties. We rounded 0.125, which is 5% of 2.50: the default gives 0.12 and MidpointRounding.AwayFromZero gives 0.13. If your fee calculation rounds one way and your counterparty's system rounds the other, the ties where they disagree differ by exactly one cent. Set the rounding mode explicitly, match the counterparty's published convention where there is one, and let a one-unit tolerance catch what remains.

Hold amounts in decimal or integer minor units (such as cents), never double. Microsoft's documentation for the floating-point types notes that a decimal can represent 0.1 exactly while no double can, and that unexpected rounding errors can occur when double or float hold decimal data. The sample stores signed long minor units, so amounts are exact and a tolerance is an integer.

Five rules for the tolerance itself, all ours:

  1. Declare it as data. A tolerance belongs per account and per item type, with a variance account and a version, not as a constant inside the matching loop.
  2. Keep it absolute and small. A percentage grows with the amount: 0.1% of 1,000,000 is 1,000.
  3. Post the difference. A rung-2 match with a difference writes that amount to a named variance account, so the books still tie.
  4. Watch gross variance as well as net. In our run three lines matched this way, at +0.01, +0.01 and −0.01. The net is +0.01 and the gross is 0.03. A net-only view lets offsetting differences cancel, the same trap as matching totals in a parallel run, which we describe in our article on automating a manual back-office process.
  5. Keep a difference beyond tolerance visible and linked. R-004 differed by 0.05 against a limit of 0.01. It was not matched, and both lines stayed open as one break reading "ours 31000, theirs 30995, difference -5" in minor units.

The Open-Items Book: What the First Version Skips

The book holds every line that is not yet matched, and it is what makes tonight's run smarter than last night's. It has six properties:

  1. Stable identity. A line's ID is its source plus its external ID, and a line already seen is ignored. In production that is a unique index, not an in-memory set. We delivered day one's file again the next day: 31 lines redelivered, none received, no open item changed, and no first-seen time changed.
  2. Match against everything open, not just today's file. A run matches the new lines together with every line still open. The ledger line for R-010 had been open since day one; when the statement line arrived on day three, the exact rung matched them across runs. The break had been open two days and closed itself.
  3. First seen is written once. If the list is rebuilt from scratch each night, age is always zero.
  4. Every break has a cause class. The third-party integration article sorts differences into three buckets: missing at the vendor, missing locally, and present in both but different (see why third-party API integrations fail in production). An engine can be more specific, because the class decides who acts:
Cause classWhat it usually meansWho acts (our reading)
OursOnly A ledger line with no statement line: timing, or a payment that did not arrive Wait out the clearing window, then chase the counterparty
TheirsOnly A statement line with no ledger line: an unbooked receipt, or a counterparty duplicate Finance books it or disputes it. In our run the second R-011 line landed here.
AmountMismatch Same reference, amounts differ beyond tolerance: a fee, a rounding rule or an error. Both lines are linked Investigate. The difference is already computed.
GroupDoesNotFoot Settlement lines do not add up to the payout. Every line and the payout carry the same difference Find the missing or extra line in the batch. In our run the difference was −0.40.
ProposedMatch A candidate exists, and no person has approved it Approve or reject
  1. Resolution needs a reason, and a write-off above a limit needs a second person. In our run, with a limit of 10.00 and a 50.00 item, a write-off with an empty reason was rejected. So was one approved by the person who made it, and one with no approver. It was accepted with a second person.
  2. A proof total. Every line received is in exactly one place: matched, resolved or open. So the statement-side total minus the ledger-side total must equal the variances posted, plus the resolved gap, plus the open gap. If the equation does not balance, a line has been lost or a variance mis-posted. This is our own control. It sits next to the completeness property in our guide to architecting auditable backend systems, and the same fail-closed stance appears in our 1099 compliance playbook.

The Engine in C#

The sample is a small console project of a few hundred lines with no framework. The listings are extracted from the compiled files. The exact and group rungs follow the same shape as the two rules shown, and they are not listed. First the types:

/// <summary>
/// One line from either side. Id is stable across deliveries (source + external id).
/// </summary>
public sealed record Item(
    string Id,
    Side Side,
    string Account,
    string Currency,
    long Minor,                   // signed minor units, same sign convention on both sides
    DateOnly ValueDate,
    string? Reference = null,     // the key both sides share, when one exists
    string? GroupRef = null);     // on our lines: the settlement (payout) the line belongs to

/// <summary>An unmatched line. FirstSeen is set once, when the line first reaches the book.</summary>
public sealed record OpenItem(
    Item Item, DateTimeOffset FirstSeen, BreakKind Kind, string? Detail = null);

/// <summary>
/// A set of lines one rule would put together. A line appears in at most one candidate.
/// </summary>
public sealed record Candidate(
    IReadOnlyList<Item> Ours,
    IReadOnlyList<Item> Theirs,
    long VarianceMinor = 0,
    string? VarianceAccount = null);

/// <summary>One rung of the ladder. Rungs run in order, most certain first.</summary>
public interface IMatchRule
{
    string Name { get; }
    int Version { get; }

    /// <summary>True: the match is posted without a person. False: it is only proposed.</summary>
    bool AutoPost { get; }

    IEnumerable<Candidate> Find(IReadOnlyList<Item> ours, IReadOnlyList<Item> theirs);
}

Minor is a signed long in minor units, with the same sign convention on both sides. GroupRef is set only on our settlement lines. FirstSeen lives on OpenItem, and nothing else writes it. Two rungs as rules:

/// <summary>
/// Rung 2: same key, amounts differ by no more than the account's declared tolerance.
/// The difference is posted as variance.
/// </summary>
public sealed class KeyedToleranceRule(IReadOnlyDictionary<string, Tolerance> byAccount)
    : IMatchRule
{
    public string Name => "keyed-tolerance";
    public int Version => 1;
    public bool AutoPost => true;

    public IEnumerable<Candidate> Find(IReadOnlyList<Item> ours, IReadOnlyList<Item> theirs)
    {
        var waiting = theirs.Where(t => t.Reference is not null)
            .GroupBy(t => (t.Account, t.Currency, t.Reference))
            .ToDictionary(g => g.Key, g => g.OrderBy(t => t.Id, StringComparer.Ordinal).ToList());

        foreach (var o in ours.Where(o => o.Reference is not null)
                              .OrderBy(o => o.Id, StringComparer.Ordinal))
        {
            if (!byAccount.TryGetValue(o.Account, out var tolerance)) continue;
            if (!waiting.TryGetValue((o.Account, o.Currency, o.Reference), out var open)) continue;

            var match = open.FirstOrDefault(
                x => Math.Abs(x.Minor - o.Minor) <= tolerance.MaxAbsMinor);
            if (match is null) continue;

            open.Remove(match);
            yield return new Candidate(
                [o], [match], match.Minor - o.Minor, tolerance.VarianceAccount);
        }
    }
}

/// <summary>
/// Rung 4: no shared key. Same account, currency and amount, dates within a window, and exactly one
/// candidate in each direction. Proposed for a person; never posted on its own.
/// </summary>
public sealed class UniqueCandidateRule(int windowDays) : IMatchRule
{
    public string Name => "unique-candidate";
    public int Version => 1;
    public bool AutoPost => false;

    public IEnumerable<Candidate> Find(IReadOnlyList<Item> ours, IReadOnlyList<Item> theirs)
    {
        bool Near(Item o, Item t) =>
            o.Account == t.Account && o.Currency == t.Currency && o.Minor == t.Minor
            && o.GroupRef is null                            // settlement lines: via the group
            && (o.Reference is null || t.Reference is null)  // two different keys: no candidate
            && Math.Abs(o.ValueDate.DayNumber - t.ValueDate.DayNumber) <= windowDays;

        foreach (var t in theirs.OrderBy(t => t.Id, StringComparer.Ordinal))
        {
            var forward = ours.Where(o => Near(o, t)).Take(2).ToList();
            if (forward.Count != 1) continue;                  // none, or ambiguous

            if (theirs.Count(x => Near(forward[0], x)) == 1)   // and unique the other way
                yield return new Candidate(forward, [t]);
        }
    }
}

Four details carry weight. The tolerance rule returns the difference and the account to post it to, so rung 2 cannot match without leaving a trace. The unique-candidate rule excludes lines that belong to a settlement, since those match through their settlement, and refuses a pair that carries two different references. And it checks uniqueness in both directions. Now the run:

public RunSummary Run(IEnumerable<Item> incoming, DateTimeOffset asOf)
{
    int received = 0, redelivered = 0;
    foreach (var item in incoming)
    {
        // A line already seen is ignored: redelivery changes nothing.
        if (!_items.TryAdd(item.Id, item)) { redelivered++; continue; }
        // FirstSeen is set here, once.
        _open[item.Id] = new OpenItem(item, asOf, BreakKind.OursOnly);
        received++;
    }

    var ours = Pool(Side.Ours);
    var theirs = Pool(Side.Theirs);
    var posted = ladder.ToDictionary(r => r.Name, _ => 0);

    // Proposals are derived data: rebuild them every run.
    _proposals.Clear();
    foreach (var rule in ladder)
    {
        foreach (var pair in rule.Find(ours, theirs).ToList())
        {
            if (!rule.AutoPost) { _proposals[KeyOf(pair)] = (rule, pair); continue; }

            Post(rule, pair, asOf, approvedBy: null);
            var used = pair.Ours.Concat(pair.Theirs).Select(i => i.Id).ToHashSet();
            ours.RemoveAll(i => used.Contains(i.Id));
            theirs.RemoveAll(i => used.Contains(i.Id));
            posted[rule.Name]++;
        }
    }

    Classify(ours, theirs);
    return new RunSummary(received, redelivered, posted, _proposals.Count, _open.Count);
}

Four choices in Run matter. TryAdd makes a redelivered line a no-op. The lists come from Pool, which reads the open items, so old breaks take part in every run. Proposals are cleared and rebuilt each run, and approving one checks that both lines are still open, so a proposal cannot outlive the facts that justified it. And whatever is left is classified into a cause class before the run returns. The proof is short:

/// <summary>Every received line is in exactly one place, and the sums prove it.</summary>
public sealed record Proof(long Difference, long Variances, long Resolved, long Open)
{
    public bool Balances => Difference == Variances + Resolved + Open;
}

public Proof Prove()
{
    static long Gap(IEnumerable<Item> items) =>
        items.Sum(i => i.Side == Side.Theirs ? i.Minor : -i.Minor);
    return new Proof(
        Difference: Gap(_items.Values),
        Variances: _matches.Sum(m => m.VarianceMinor),
        Resolved: Gap(_resolutions.Select(r => _items[r.ItemId])),
        Open: Gap(_open.Values.Select(o => o.Item)));
}

Aging: Two Clocks and One Number

Our article on third-party API integrations names the age of the oldest unreconciled item as the single metric that shows how long a silent failure can live before someone sees it. That holds, with one refinement: there are two clocks. Business age counts from the value date (the date the funds count from) and says how stale the money is. Seen age counts from FirstSeen and says how long nobody has had a chance to act. A third idea decides what is a problem: an expected clearing window per account, because a ledger line one day old with no statement line yet is normal.

/// <summary>
/// Two clocks. Business age counts from the value date (how stale the money is).
/// Seen age counts from FirstSeen (how long nobody has acted). Overdue means business
/// age beyond the account's expected clearing days.
/// </summary>
public static AgingReport Build(
    IEnumerable<OpenItem> open, DateTimeOffset asOf,
    IReadOnlyDictionary<string, int> clearingDays, int defaultDays = 2)
{
    var today = DateOnly.FromDateTime(asOf.UtcDateTime);
    int Business(OpenItem o) => today.DayNumber - o.Item.ValueDate.DayNumber;
    int Seen(OpenItem o) =>
        today.DayNumber - DateOnly.FromDateTime(o.FirstSeen.UtcDateTime).DayNumber;
    long Gap(IEnumerable<OpenItem> items) =>
        items.Sum(o => o.Item.Side == Side.Theirs ? o.Item.Minor : -o.Item.Minor);
    bool Overdue(OpenItem o) =>
        Business(o) > (clearingDays.TryGetValue(o.Item.Account, out var d) ? d : defaultDays);

    var list = open.ToList();
    (string Label, int From, int To)[] edges =
        [("0-2 days", 0, 2), ("3-7 days", 3, 7), ("8-30 days", 8, 30), ("31+ days", 31, int.MaxValue)];
    var buckets = edges
        .Select(e => list.Where(o => Business(o) >= e.From && Business(o) <= e.To).ToList())
        .Select((items, n) => new AgeBucket(edges[n].Label, items.Count, Gap(items)))
        .ToList();

    var overdue = list.Where(Overdue).ToList();
    var oldest = list.OrderByDescending(Business)
        .ThenBy(o => o.Item.Id, StringComparer.Ordinal).FirstOrDefault();
    return new AgingReport(buckets, overdue.Count, Gap(overdue), oldest,
        oldest is null ? 0 : Business(oldest), oldest is null ? 0 : Seen(oldest));
}

In our run, the oldest open item was a ledger line already 45 days old by value date when the book first received it, and the book had held it for two days. A report using seen age alone would call it fresh. A report using value date alone cannot say whether anyone has had time to act. With a two-day clearing window, the 13 open items on day three fell into one item at 0 to 2 days, ten at 3 to 7, one at 8 to 30 and one at 31 or more, and 12 of them were overdue, with a combined gap of −1,262.45. These bucket edges are our choice, not a standard. Alert on the age of the oldest overdue item, and track the daily count of new breaks against resolved ones. We did not build the last of those.

What We Watched It Do

Nine things we tried, with the engine configured as above (one-unit tolerance, two-day window, two-day clearing, a 10.00 write-off limit):

What we didWhat the run showed
Ran day one's files (31 lines) 7 matches posted (3 exact, 3 within tolerance, 1 settlement group), 1 proposal pending, 15 lines open, and the proof balanced
Delivered the same files again the next day 31 redelivered, none received, no open item changed, no first-seen time changed, and the proof was unchanged
Ran day three, when the statement line for R-010 arrived It matched at the exact rung against the ledger line open since day one, after two days. A ledger and statement pair that arrived the same day matched too.
Three rounding differences of one minor unit Matched at rung 2, with +0.01, +0.01 and −0.01 posted to the variance account: net +0.01, gross 0.03
A 0.05 difference against a 0.01 tolerance Not matched. One AmountMismatch break linked both lines, with the difference computed.
Two settlements: PO-77 (3 lines, 325.50) and PO-78 (2 lines, 250.00, against a payout of 249.60) PO-77 matched as a group. PO-78 stayed open as GroupDoesNotFoot, with a difference of −0.40 on all three lines.
Unkeyed lines: one 412.50 pair, and two identical pairs of 50.00 The 412.50 pair became a proposal, and approving it recorded the approver. The 50.00 pairs produced no proposal.
The statement posted R-011 twice One match. The second line stayed open as TheirsOnly. The naive join had paired it as well.
Resolved an item with no reason, with the same person approving, with no approver, and with a second person The first three were rejected and the fourth accepted. The proof balanced afterwards: −1,326.44 = 0.01 + (−50.00) + (−1,276.45).

These results come from synthetic data written to exercise each path. They show the mechanics. They do not show hit rates, and the settings are examples, not recommendations.

What This Design Doesn't Solve

  • One currency. There is no foreign-exchange handling. FX needs rates, rate dates and its own tolerance policy.
  • One payout line per settlement. The group rung needs exact sums and a single counterparty line. Partial settlements, a payout split across several lines, or fee lines in the batch need a richer rule. In our run the 0.40 shortfall stayed open for a person to read: the rule can say that a batch does not add up, not why.
  • No many-to-many search, and no partial payments. One invoice paid across several deposits is not handled.
  • State in memory. A production book lives in a database. It needs the unique index on item identity, posting a match and removing the open items in one transaction, and concurrency control so two runs cannot take the same line. We did not test that.
  • Scale. The unique-candidate rule as written compares every statement line with every ledger line. We ran 35 lines. At volume, partition by account and date window first.
  • Agreement is not truth. A reconciliation shows that two records agree. It cannot show that both are right, if they are wrong in the same way.

One condition where we would take a different route: standard bank and card statements across many accounts and currencies, a finance team that wants prebuilt connectors, and no unusual rules. Then a commercial reconciliation product is usually the faster route, and this design is a checklist for evaluating one. Building is worth it when the "statement" is not a bank file, as with a vendor's holdings feed or a partner's settlement export, when the rules are particular to your business, or when the resolution trail has to sit inside your own audit record. This is our judgement, not a survey.

A Drill Before You Retire the Spreadsheet

An engine nobody has compared with the old process is a hope. This drill is our own suggestion, and it reuses the side-by-side discipline from the back-office automation article:

  1. Take last month's two exports and the spreadsheet that was signed off, and run them through the engine.
  2. Compare item by item, not by totals. For every line the spreadsheet matched and the engine did not, or the reverse, decide whether it is an engine miss, a spreadsheet error or a missing rule.
  3. Check that the proof balances, and run the same files a second time. Nothing should change.
  4. Hold one statement line back by three days and confirm that it closes the old break on arrival, with its age intact.
  5. Switch one account at a time, and keep the spreadsheet running on that account until two clean cycles in a row.
Common Questions

Frequently Asked Questions

How do you automate reconciliation in .NET?
Build a matcher and a book as separate parts. The matcher applies rules in order from most to least certain: exact on a shared reference and amount, then the same reference within a declared tolerance, then grouped settlements, then proposals for a person to approve. The book keeps every unmatched line between runs with a stable identity and a first-seen time, matches new lines against everything still open, classifies each break by cause, and ends with a proof that the totals balance. We compiled and ran this design on .NET 10 against synthetic data.
What matching levels should a reconciliation engine have?
We use five rungs, ordered by certainty. One, an exact match on account, currency, shared reference and amount. Two, the same reference with an amount inside a declared tolerance, with the difference posted to a variance account. Three, a group of ledger lines against one settlement line whose amount equals their sum. Four, a proposal when there is no shared key but exactly one candidate in each direction, approved by a person. Five, an open item with a cause class and an age. The engine posts on its own only when a shared key is the evidence, because a wrong match removes a line from the open list and hides the problem.
How should tolerances work in automated reconciliation?
Declare them as data per account, keep them absolute and small, and post every difference to a named variance account so the books still tie. Track gross variance as well as net, because offsetting differences cancel in a net figure. A difference beyond the tolerance should stay open as a linked mismatch and not be dropped. In .NET, Math.Round rounds midpoint values to the nearest even number by default, which can differ by one cent from a counterparty that rounds away from zero, so set the rounding mode explicitly.
How do you track unmatched items and how long they have been open?
Keep an open-items book that survives between runs. Give each line a stable identity so that redelivery changes nothing, write the first-seen time once, and match each run against every open line so that a late counterpart closes an old break. Measure two ages: business age from the value date, and seen age from the first-seen time. Compare business age with an expected clearing window per account to decide what is overdue, and alert on the oldest overdue item. Classify each break by cause, and require a reason to resolve it.
Should we build a reconciliation engine or buy one?
It depends on the sources. For standard bank and card statements across many accounts and currencies, a commercial product with prebuilt connectors is usually faster, and this design works as a checklist for evaluating it. Building is worth considering when the other side is not a bank file, such as a vendor holdings feed or a partner settlement export, when the rules are particular to your business, or when the resolution trail must sit inside your own audit record. That is our judgement, not a survey.

When to Bring in External Help

You can build this yourself. The matcher is the smaller part; the time goes into the book, the controls and cleaning the data. Outside help pays off when the reconciliation is already late and the open list has no owner, when an audit or a close deadline sets the date, or when the sources are the kind that do not share a key.

If your backend shows any of the patterns above, our Discovery Sprint is a two-week diagnostic that tells you exactly what to fix and in what order. Our process automation work removes manual steps such as review, re-keying and reconciling.

Free Resource Not sure where your backend stands before you automate a finance process? Start with our free 12-Point Backend Health Checklist. It shows which risks a reviewer is likely to find first.
Back to Insights