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
A payment system usually has at least three views of the same transaction:
- 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
This project exposes a few simple APIs:
- 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
The code accepts both CSV and XLSX files.
That sounds small, but it points to a real production problem: every external partner sends data in slightly different formats.
The system solves that by converting incoming rows into one shared structure:
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
One part I liked in this codebase is the duplicate-file handling.
Every uploaded file is saved, hashed with SHA-256, and checked against previous ingests using the checksum.
If the exact same file arrives again, the system does not ingest it twice.
That matters because settlement pipelines retry all the time:
- 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
The matching layer loads three datasets for the same provider and date range:
- 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
This repo classifies transactions into a few buckets:
matchedpartially_matchedmanual_review_requiredmissing_internal
That maps nicely to how operations teams think.
1. Matched
Internal, PSP, and bank records all line up.
This is the ideal case.
2. Partially Matched
Only one external side lines up with the internal record.
For example: internal exists, PSP exists, bank record is missing.
That usually means a delay, file issue, or settlement mismatch that still needs monitoring.
3. Manual Review Required
The internal record exists, but neither PSP nor bank records match it.
That is the kind of case that lands in an ops queue immediately.
4. Missing Internal
A PSP or bank record exists with no internal counterpart.
This is just as important.
Sometimes the absence is not outside your system. Sometimes your own system is the missing side.
The Demo Data Explains the Whole Story
The sample data in this repo is small, but it is enough to explain the full flow.
Internal payments: pay_1, pay_2.
PSP file: pay_1, pay_2, pay_3.
Bank file: pay_1, pay_4.
That means the run naturally produces all the interesting cases:
pay_1becomes a full matchpay_2becomes a partial match because the bank side is missingpay_3becomes an exception because PSP has a record that internal does notpay_4becomes an exception because bank has a record that internal does not
This is exactly why reconciliation systems exist.
Real systems are not failing in dramatic ways all the time. They are drifting in small, operationally expensive ways.
Why `amount_minor` Is the Right Choice
The code stores amounts as amount_minor using int64.
That means:
1000= Rs. 10.002000= Rs. 20.00
This is the same principle serious fintech systems use: money should be stored in the smallest unit, not as floating-point numbers.
Even in a reconciliation service, precision matters.
If matching logic compares inaccurate values, the exception queue becomes full of fake mismatches.
Exceptions Are a First-Class Output
One thing people miss when they first learn reconciliation is this:
the goal is not to eliminate exceptions.
the goal is to surface them clearly, classify them correctly, and make them resolvable.
This codebase stores exceptions separately with exception type, status, note, and resolved timestamp.
That is important because a reconciliation engine is only half the system.
The other half is the operational workflow around it.
A mismatch without a resolution path is just a log line with better branding.
A Clean Mental Model
If you want the entire system in a few lines, it looks like this:
- 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.
Final Thought
Payments are not trustworthy because the success screen looks clean.
They are trustworthy because somewhere behind the scenes, a boring reconciliation service is checking whether internal records, PSP reports, and bank statements all agree on the same money movement.
This repo captures that idea well.
It models the real backbone of payment operations:
- normalization
- idempotent ingestion
- multi-source matching
- exception management
Users never think about this layer.
Fintech teams cannot survive without it.

