DEV Community

Cover image for Reconciliation in NestJS, How to Make Sure Your Ledger and the Bank Never Quietly Disagree
Peace Melodi
Peace Melodi

Posted on

Reconciliation in NestJS, How to Make Sure Your Ledger and the Bank Never Quietly Disagree

A customer contacts support convinced they were charged twice. Your internal ledger shows one charge. The bank's records, when you finally get someone to check, show two. Nobody wrote a bug that caused this directly. It happened slowly, over weeks, through a small gap between what your system recorded and what actually happened at the bank, a gap nobody was watching closely enough to catch early.

This is the problem reconciliation exists to solve. Once you are pulling in external transaction data and guarding against duplicates, the next real challenge is making sure your internal records and the bank's records actually agree with each other over time, not just at the moment a transaction first arrives.

Why agreement at one moment is not enough

Ingestion and idempotency solve the problem of accepting external data correctly the first time. Reconciliation solves a different problem entirely, confirming that what you recorded still matches reality some time later.

These two things can drift apart even when nothing appears broken. A transaction can be recorded as pending and never followed up on. A refund can happen directly at the bank without your system being notified in real time. A batch pull can silently fail for one account while succeeding for everyone else. None of these show up as an error in your logs. They show up as a quiet disagreement that only becomes visible once someone actually compares the two sides.

Structuring reconciliation as its own process

Reconciliation should not live inside your regular transaction processing logic. It works better as a separate, scheduled process whose only job is comparing what you have against what the bank says, and flagging anything that does not line up.

@Injectable()
export class ReconciliationService {
  constructor(
    @InjectRepository(LedgerEntry)
    private readonly ledgerRepo: Repository<LedgerEntry>,
    @InjectRepository(ReconciliationFlag)
    private readonly flagRepo: Repository<ReconciliationFlag>,
    private readonly bankApiService: BankApiService,
  ) {}

  async reconcileAccount(accountId: string, date: string): Promise<void> {
    const bankRecords = await this.bankApiService.getStatement(accountId, date);
    const ledgerEntries = await this.ledgerRepo.find({
      where: { accountId, date },
    });

    const ledgerByRef = new Map(ledgerEntries.map((entry) => [entry.providerRef, entry]));

    for (const bankRecord of bankRecords) {
      const matchingEntry = ledgerByRef.get(bankRecord.reference);

      if (!matchingEntry) {
        await this.flagRepo.save({
          accountId,
          type: 'missing_in_ledger',
          providerRef: bankRecord.reference,
          amount: bankRecord.amount,
        });
        continue;
      }

      if (matchingEntry.amount !== bankRecord.amount) {
        await this.flagRepo.save({
          accountId,
          type: 'amount_mismatch',
          providerRef: bankRecord.reference,
          ledgerAmount: matchingEntry.amount,
          bankAmount: bankRecord.amount,
        });
      }

      ledgerByRef.delete(bankRecord.reference);
    }

    for (const remaining of ledgerByRef.values()) {
      await this.flagRepo.save({
        accountId,
        type: 'missing_at_bank',
        providerRef: remaining.providerRef,
        amount: remaining.amount,
      });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The important structural choice here is that reconciliation never silently corrects anything on its own. It flags disagreements for review, rather than assuming either side is automatically correct. A mismatch might mean your system missed something, or it might mean the bank's data changed after the fact, and that distinction usually needs a human, or at least a separate resolution process, to sort out properly.

Running reconciliation on a schedule, not just on demand

Reconciliation is far more useful when it runs consistently on its own, rather than only when someone happens to remember to trigger it. NestJS's scheduling module makes this straightforward to set up as an ongoing background process.

@Injectable()
export class ReconciliationScheduler {
  constructor(
    private readonly reconciliationService: ReconciliationService,
    @InjectRepository(Account)
    private readonly accountRepo: Repository<Account>,
  ) {}

  @Cron('0 3 * * *')
  async runDailyReconciliation(): Promise<void> {
    const accounts = await this.accountRepo.find({ where: { active: true } });
    const yesterday = this.getYesterdayDateString();

    for (const account of accounts) {
      await this.reconciliationService.reconcileAccount(account.id, yesterday);
    }
  }

  private getYesterdayDateString(): string {
    const date = new Date();
    date.setDate(date.getDate() - 1);
    return date.toISOString().split('T')[0];
  }
}
Enter fullscreen mode Exit fullscreen mode

Running this daily, quietly, in the background, means disagreements get caught within a day of happening, not weeks later when a customer notices and support has to dig through records manually to figure out what actually went wrong.

Making disagreements visible, not just logged

A flag sitting quietly in a database table does not help anyone if nobody ever looks at it. It is worth exposing flagged mismatches through a simple endpoint or dashboard so they actually get reviewed, rather than accumulating unseen.

@Controller('reconciliation')
export class ReconciliationController {
  constructor(
    @InjectRepository(ReconciliationFlag)
    private readonly flagRepo: Repository<ReconciliationFlag>,
  ) {}

  @Get('flags')
  @UseGuards(AuthGuard, RolesGuard)
  @Roles('finance_ops')
  async getOpenFlags(): Promise<ReconciliationFlag[]> {
    return this.flagRepo.find({
      where: { resolved: false },
      order: { createdAt: 'DESC' },
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Guarding this endpoint properly matters here too, reconciliation data is sensitive, and only the people responsible for resolving these disagreements should be able to see them.

The bigger picture

NestJS will not decide what counts as an acceptable mismatch or how quickly one needs resolving, that depends entirely on your business and how much risk a small disagreement actually represents. What it gives you is a clean structure for running this process consistently, a dedicated service for comparing records, a scheduler for running it automatically, and a properly guarded way to surface what it finds.

The habit worth building here is treating reconciliation as an ongoing discipline, not a one time check. Ingestion and idempotency protect the moment data enters your system. Reconciliation protects everything that happens after that moment, quietly confirming, day after day, that your records and the bank's records still agree.

If you are running a system where your ledger absolutely needs to match external records over time, this is exactly the kind of process worth building properly and early, rather than discovering the gap through a customer complaint.

I am Peace Melodi, a backend software engineer. If you want your business to scale big, comfortably handling millions of users without breaking, with strong scalability and security in place, feel free to reach out.

LinkedIn: https://www.linkedin.com/in/melodi-peace-406494368
GitHub: https://github.com/PeaceMelodi

Top comments (1)

Collapse
 
johnfrandsen profile image
John Frandsen

Great framing on the "quiet disagreement" problem — the gap between ingestion-time correctness and ongoing agreement is where most real-world reconciliation pain lives, and it rarely gets the attention it deserves.

One reconciliation hazard that catches teams off-guard with bank APIs specifically is the pending-to-booked transition. A transaction arrives as pending during one ingestion cycle, gets recorded in the ledger, then the bank converts it to a booked transaction with a different internal reference (and sometimes a slightly different amount after fee adjustments). The original pending record may disappear entirely from the next API response, or it may linger alongside the booked version for a few hours. If your providerRef was keyed to the pending reference, you now have a phantom entry in your ledger that the bank no longer acknowledges, and a new booked entry that looks like it was never ingested.

The practical pattern we've settled on: treat the bank's running balance as an independent consistency checkpoint, separate from transaction-level matching. After reconciling individual transactions, compare your computed ledger balance against the bank's reported balance at the same point in time. If every transaction matches but the balances don't, you know something fell through the cracks at a structural level — a transaction that was reversed server-side without a corresponding API event, a currency conversion rounding gap, or a fee that was applied net rather than gross. Transaction-level reconciliation catches individual drift; balance-level reconciliation catches systemic drift that no amount of per-record matching will surface.

The scheduled approach you describe is exactly right for this. We run reconciliation as a daily batch with a 72-hour lookback window, because some banks back-post adjustments or corrections to transactions that are 1-2 days old. A same-day-only reconciliation window would miss these entirely.