Back to all posts

Reconciling 8 Payment Providers Into One Accounting System: A Reconciliation Architecture

September 11, 20263 min read
ASP.NET CoreBackground WorkersAccounting Automation

On an accounting system I built for resellers of in-app virtual coins, the hardest problem wasn't SignalR or the UI — it was turning data arriving from eight different external services (bank receipt scanners, multiple payment providers, e-invoicing) into a single, consistent reconciliation record. Each service had its own format, its own latency profile, and its own failure modes. Here's how I built that architecture.

Why a single "sync job" wasn't enough

My first approach was one scheduled job looping through all eight services in sequence. Two problems surfaced:

  • Domino effect: when one service (say, a payment provider's API) slowed down, processing for the remaining seven got delayed too.
  • Hard to track partial failures: if the job failed midway, it became unclear which services had been processed and which hadn't.

The fix was writing an independent BackgroundService worker for each external service — each running on its own polling interval, with its own error handling, unaware of the others:

public class BankReceiptWorker : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                var receipts = await _bankClient.FetchNewReceiptsAsync(stoppingToken);
                foreach (var receipt in receipts)
                {
                    await _reconciliationService.ProcessReceiptAsync(receipt, stoppingToken);
                }
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Bank receipt sync failed");
                // This worker's failure doesn't affect the others.
            }
 
            await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
        }
    }
}

Reconciliation: never processing the same payment twice

Data from the eight services sometimes reported the same real payment at different times under different identifiers — a bank receipt might show a payment at 14:03, while the payment provider's webhook reports it at 14:05. Processing the same payment twice would mean double-crediting a reseller's balance — an unforgivable class of bug in an accounting system.

The fix was processing every incoming record with an idempotency key derived from amount + time window + sender identifier:

var idempotencyKey = $"{amount:F2}_{senderIdentifier}_{timestamp:yyyyMMddHH}";
 
var exists = await _db.ReconciliationRecords
    .AnyAsync(r => r.IdempotencyKey == idempotencyKey, stoppingToken);
 
if (exists)
{
    continue; // Already processed — this is the same payment from another source.
}

I used an hour-level window (yyyyMMddHH) instead of minute-level precision on purpose: minute precision would be too strict given the clock drift between the bank and the payment provider, and could cause real matches to be missed; hour-level precision turned out to give enough separation in practice to avoid mistaking two genuinely different payments (same person, same amount, an hour apart) for the same one.

Partial matches: what falls to a human

Not every payment matches automatically — when the amount doesn't line up exactly (for example, a bank fee was deducted from the received amount), the system doesn't try to force an automatic match. Instead it drops the record into a "pending" queue and pushes a real-time notification to the accounting team over SignalR. The goal of the automation was never to eliminate 100% of human involvement — it was to automate ~90% and reduce the remaining ~10% to a clear queue a human can resolve quickly.

E-invoicing: queued, not synchronous

The e-invoicing provider's API occasionally took up to 30 seconds to respond. To process this without blocking the other workers, I routed it through a separate queue (a simple database table with a claim pattern similar to FOR UPDATE SKIP LOCKED); the e-invoicing worker drains that queue at its own pace, and the rest of the reconciliation flow never waits on it.

Result

Eight independent workers, an hour-windowed idempotency key, and a human-approval queue for partial matches — these three decisions became the foundation of a system that's stayed in production for a long time, now 67-plus migrations deep. You can find the rest of the system — BCrypt/role-based authorization, reporting — on the project page.

Related Project

Take a closer look at the project this post is about.

View Project