How Payment Reconciliation Systems Actually Work Behind the Scenes
Most payment bugs show up after Pay: internal records, PSP files, and bank settlements must agree. Reconciliation is the boring backbone — normalize, dedupe, match on identifier sets, and route exceptions to ops.

Most payment bugs do not happen when the user taps Pay.
They happen later.
The app says the payment succeeded. The PSP has one record. The bank has another. Your internal system has its own event. Now someone has to answer the uncomfortable question:
Did the money actually settle correctly?
That is what reconciliation systems exist for.
I went through a small Go codebase that models this flow well. It does not try to be flashy. It does the important things:
stores internal payments
ingests PSP and bank files
normalizes records into one format
runs matching logic across all three systems
creates exceptions for anything that does not line up
This is the part of fintech that users never see, but operations teams live inside all day.
The Real Problem
your internal payment record
the PSP settlement record
the bank settlement record
If all three agree, life is easy.
If one is missing, delayed, duplicated, or inconsistent, someone has to investigate before money goes out of balance.
That is why reconciliation is not a reporting feature. It is a correctness system.
What This Codebase Is Building
create internal payments
upload settlement files
poll files from SFTP
start a reconciliation run
fetch results, exceptions, and daily summaries
The shape is practical.
First, internal transactions are written into internal_payments.
Then external files from PSPs and banks are ingested into file_ingests, stored on disk, parsed, and converted into normalized_records.
After that, a reconciliation run compares internal rows, PSP rows, and bank rows for a given provider and date range.
That comparison produces two outputs:
- reconciliation results
- reconciliation exceptions
That is the core loop.
Why Normalization Matters More Than Most People Think
merchant_idpayment_idmerchant_order_idgateway_txn_idprovider_txn_idbank_refrrn_or_utramount_minorpayment_statussettlement_statusevent_timesettlement_date
Once everything looks the same, matching becomes possible.
Without normalization, reconciliation logic turns into a mess of one-off parsers and special cases.
A Small Detail That Saves Real Trouble: File Idempotency
someone uploads the same file again manually
an SFTP poll picks up a file twice
an operator is unsure whether the first import worked
If you do not make file ingestion idempotent, your reconciliation results become noisy very quickly.
How the Matching Logic Works
internal payments
PSP normalized records
bank normalized records
Then it builds strong keys from transaction identifiers plus amount_minor.
For PSP matching, the code uses:
provider_txn_id + gateway_txn_id + merchant_order_id + amount_minor
For bank matching, it uses bank-side references:
rrn_or_utr + bank_ref + merchant_order_id + amount_minor
This is a good mental model for reconciliation systems:
you do not match on one ID, you match on a confidence set of identifiers
because no single external field is reliable enough all the time.
What a Reconciliation Run Produces
matchedpartially_matchedmanual_review_requiredmissing_internal
That maps nicely to how operations teams think.
1. Matched
2. Partially Matched
3. Manual Review Required
4. Missing Internal
The Demo Data Explains the Whole Story
Why `amount_minor` Is the Right Choice
Exceptions Are a First-Class Output
A Clean Mental Model
Internal payments enter the system.
PSP and bank files arrive through upload or SFTP.
Files are deduplicated by checksum.
Rows are normalized into one schema.
A run compares internal, PSP, and bank records using strong keys.
Matches are stored.
Mismatches become exceptions for manual or automated follow-up.
That is reconciliation.
Not glamorous.
But absolutely necessary.