Reconciling 8 Payment Providers Into One Accounting System: A Reconciliation Architecture
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.