Contents
Share this article
Key Takeaways
At low transaction volumes, payment reconciliation stays manageable, and using spreadsheets and manual review is a viable option. A single PSP, a predictable settlement cycle, and a small operations team are more than enough to keep the books balanced.
But, as scale and complexity grow simultaneously, issues get introduced.
A second PSP with a different settlement cycle and identifier schema, a third with weekly batch settlements instead of daily files, refunds and chargebacks arriving weeks after the originating transaction, and FX transactions where the settled amount differs from the original due to currency conversion timing are some of the most common issues we see in production.
At $10 million in monthly payment volume, even a 1% mismatch rate produces $100,000 in unexplained gaps per settlement period, all of which require manual investigation. At scale, this isn’t possible.
The system that prevents these gaps is a reconciliation architecture that is built to automate the matching of internal ledgers, payment gateways, and bank statements.
If that system is built correctly, you ensure that every authorised transaction matches its settled amount and accounts for deductions like processing fees along the way.
Let’s look at all of the different components you need to keep in mind during payment reconciliation system development, so your system stays maintainable as PSP count grows, and compliant with the audit trail requirements that regulated fintechs face.
The best way to ensure a successful development project is to ensure you have the right people on your team. At Trio, we pre-vet our developers for fintech expertise, so you can match with the right talent in as little as 3-5 days, including senior payments and ledger engineers.
Most fintech teams that we partner with build their first reconciliation system reactively.
In these rudimentary systems, a PSP may start sending daily settlement CSV files, someone writes a script that reads the file and compares each row against the internal transaction record, and exceptions get flagged for manual review.
This works until one of three things happens:
A reconciliation system's canonical data model needs to represent the complete lifecycle of a payment transaction, from initiation through authorisation, capture, settlement, and payout.
Every PSP expresses this lifecycle differently, creating complications.
The canonical data model abstracts over those differences, so the matching engine reasons over a single, consistent structure. This happens regardless of which PSP was used to process the transaction.
Monetary precision becomes incredibly important here.
amount_minor_units stores amounts as integers in minor units (pence, cents, fils). Never FLOAT or DECIMAL. All arithmetic on financial amounts needs to use integer minor unit arithmetic
If your engineers don’t have any fintech exposure, this is an incredibly common data corruption failure that you need to watch for in any payment systems they build.
PaymentTransaction {
internal_transaction_id -- your system's identifier (UUID)
psp_id -- which PSP processed this
psp_transaction_id -- PSP's identifier (normalised from PSP-specific field)
psp_payment_intent_id -- for PSPs that expose intent-level identifiers (Stripe)
amount_minor_units -- integer in minor units (cents), NEVER FLOAT
currency -- ISO 4217 currency code
status -- ENUM: AUTHORISED / CAPTURED / SETTLED /
-- REFUNDED / DISPUTED / REVERSED
authorised_at -- timestamp
captured_at -- timestamp (nullable)
settled_at -- timestamp (nullable, set when settlement confirmed)
psp_settled_amount -- amount PSP reports as settled (may differ from
-- captured due to FX)
psp_settled_currency -- settlement currency (may differ from transaction currency)
psp_fee_amount -- fees deducted before settlement
bank_reference -- reference from bank statement (nullable, set when matched)
reconciliation_status -- ENUM: UNMATCHED / PSP_MATCHED / BANK_MATCHED /
-- FULLY_RECONCILED / EXCEPTION
}
SettlementFile {
file_id
psp_id
settlement_period_start
settlement_period_end
received_at
file_format -- CSV / XML / ISO20022 / API
total_transaction_count
total_settled_amount
total_fees
processing_status -- PENDING / NORMALISED / MATCHED / CLOSED
}
BankStatement {
statement_id
bank_account_id
statement_date
opening_balance
closing_balance
line_items[] -- each bank line: amount, reference, posting_date
}
ReconciliationException {
exception_id
transaction_id -- nullable (some exceptions have no matched transaction)
exception_type -- ENUM: AMOUNT_MISMATCH / TIMING_GAP /
-- MISSING_PSP_RECORD / MISSING_BANK_CREDIT /
-- FX_VARIANCE / DUPLICATE / UNKNOWN_TRANSACTION
exception_severity -- ENUM: AUTO_RESOLVE / REVIEW_REQUIRED / ESCALATE
detected_at
resolved_at
resolution_action -- what was done to close it
resolver_id -- who resolved it (for audit trail)
}
The reconciliation_status field on PaymentTransaction is what drives the matching engine's state machine.
For example, a transaction moves from UNMATCHED to PSP_MATCHED when the PSP settlement confirms it, then to BANK_MATCHED when the bank statement confirms the payout, then to FULLY_RECONCILED when all three layers agree.
In the event that any record is stuck at UNMATCHED or PSP_MATCHED beyond the expected settlement window, it generates an exception that requires manual review.

The normalisation layer, as briefly mentioned, takes any PSP's settlement file or API response and produces a row in the canonical data model.
In doing so, it allows every PSP difference, including identifier naming, date formats, fee calculation methodology, file format, and currency representation, to be absorbed before proceeding.
If normalisation logic lives inside the matching engine, every PSP schema change requires matching engine changes.
That's a change to core financial logic in order to handle a CSV column rename.
A dedicated normalisation layer means schema updates get localised. All you need to do is update the adapter for the affected PSP, run the normalisation tests, and deploy. The matching engine doesn't change.
This architectural separation also means adding a new PSP requires only a new adapter, with no changes to the matching logic that other PSPs depend on.
At Trio, we place fintech engineers with production multi-PSP reconciliation experience in 3-5, at $40-$80/hr.
The hardest normalisation problem is identifier resolution. Each PSP surfaces the same underlying payment event under different identifiers, and the relationships between those identifiers are non-obvious.
With a normalisation adapter, all these become psp_transaction_id in the canonical model, with the PSP-specific raw identifier stored in a raw_identifiers JSONB column for audit traceability.
This makes the matching engine PSP-agnostic. In other words, it reasons over canonical identifiers and doesn't need to know how Stripe's identifier hierarchy differs from Adyen's.
PSPs apply FX at different points in the transaction lifecycle.
Stripe, for example, does it at capture, while Adyen has elected to apply it at settlement.
For transactions in a currency that differs from the settlement currency, the normalisation layer records both amount_minor_units (original transaction currency) and psp_settled_amount plus psp_settled_currency (post-FX settlement values).
Reconciling against the bank statement requires matching on settled currency amounts rather than transaction currency amounts. This is another common misstep that is made by engineers without production fintech experience.
Some PSPs report in UTC; others use their local timezone. Some report settlement timestamps; others report posting timestamps.
The normalisation layer converts everything to UTC. It then records both the raw timestamp and the normalised UTC value.
Cut-off time mismatches can generate temporary breaks. But, instead of flagging these instances as hard exceptions, the matching engine needs to carry forward to the next settlement period.
The matching engine handles the actual comparison logic across the canonical data model. Most naive reconciliation scripts handle only one pattern, as we have already mentioned.
However, a production engine needs to be able to handle four:
This is the simple case, and the only one most naive systems handle quite well.
In this pattern, a single captured transaction in the internal ledger matches a single line in the PSP settlement file, which matches a single credit in the bank statement.
A single bank credit represents a payout from a PSP aggregating hundreds of individual transactions.
Here, the matching engine needs to resolve the one-to-many relationship, or one bank statement line matched to many PSP settlement rows, each matched to their corresponding internal transaction records.
The payout only reaches FULLY_RECONCILED when all three layers agree.
The algorithm for the one-to-many pattern, like this, is to sum all PSP settlement rows for the payout period, verify that the sum matches the bank credit within fee tolerance, then individually match each PSP row to its internal transaction record.
A single internal transaction may appear across multiple PSP settlement batches when it was partially captured.
We commonly see this in B2B contexts where goods ship in multiple fulfilments.
The matching engine accumulates PSP rows over multiple settlement periods until the total captured amount equals the original authorisation amount.
A refund processed in September might relate to a capture from August.
The matching engine needs to be able to maintain linkage across settlement periods, connecting downstream events back to their originating transactions.
From what we have seen, this is where most reconciliation systems that are otherwise solid accumulate unresolvable exceptions.
The matching algorithm structure for each settlement period is:
1. Ingest and normalise all PSP settlement files
2. Run Phase 1 matching: exact one-to-one matches by PSP transaction ID
3. Run Phase 2 matching: batch aggregation matching (one-to-many by payout_id)
4. Run Phase 3 matching: cross-period linkage for refunds and chargebacks
5. Classify unmatched records as exceptions with specific exception_type
6. Trigger three-way reconciliation for all PSP_MATCHED records
Amount matching needs to accommodate legitimate variance that results from rounding in FX conversion, PSP fee calculation rounding, and timing differences in FX rate application.
A configurable tolerance, for example, ±0.01% or ±$0.05, whichever is smaller, prevents legitimate transactions from generating false exceptions while still letting you flag genuine discrepancies.
You don’t want this hardcoded in your matching logic, because you will want to adjust it for each PSP as fee structures change.
An exception is any transaction that fails to complete the full matching cycle within the expected settlement window.
Since there can be such a big variance in their cases, not all exceptions warrant the same response.
Here is a summary of how each case should be handled:
| Exception Type | Cause | Typical Resolution |
| TIMING_GAP | Authorised but not yet settled (T+1 or T+2 settlement lag) | Auto-resolve after the settlement window passes |
| AMOUNT_MISMATCH | PSP settled amount differs from the captured amount | Manual review: may indicate fee miscalculation or PSP error |
| FX_VARIANCE | FX rate difference between the internal record and the PSP settlement | Auto-resolve if within tolerance; escalate if above |
| MISSING_PSP_RECORD | Internal transaction not in PSP settlement file | Investigate: transaction may be pending, duplicated, or failed |
| MISSING_BANK_CREDIT | PSP settlement file shows payout; bank has no corresponding credit | Escalate: funds may be held, account details may be incorrect, or fraud |
| DUPLICATE | The same transaction ID appears in multiple settlement files | Auto-flag for PSP dispute; potential double-billing |
| UNKNOWN_TRANSACTION | The PSP settlement file contains a transaction with no internal record | Escalate immediately: may indicate fraudulent use of credentials |
TIMING_GAP exceptions below the PSP's stated settlement window (typically 24-72 hours) should resolve automatically, as they're expected and, most of the time, completely benign.
FX_VARIANCE exceptions below the configured tolerance should also auto-resolve, with the variance recorded for audit
Everything else requires human review, with MISSING_BANK_CREDIT and UNKNOWN_TRANSACTION triggering immediate escalation alerts.
The best way to route exceptions is to utilize resolution queues with explicit timers:
SLA breaches trigger escalation notifications.
Every exception resolution action needs to record the resolver's identity and the action taken, forming your audit trail.
Two-way reconciliation, matching the internal ledger against PSP settlement files, confirms that the PSP acknowledges the transactions, but does not confirm that the funds arrived in the bank account.
We have already mentioned that the solution to this is three-way reconciliation, but let’s look at this process in more detail:
ISO 20022 payment messages, relevant for Fedwire after July 2025 and SWIFT MX after November 2025, carry richer remittance information in structured fields than their MT-format predecessors.
For cross-border reconciliation specifically, teams should architect the normalisation layer to handle both legacy MT and ISO 20022 message formats from the outset, to help you avoid costly rework later.
Financial applications are incredibly regulated, and a reconciliation system is subject to the same audit trail requirements as the payment ledger it validates.
Three specific requirements shape the data architecture:
No reconciliation decision should overwrite a prior state. Every status transition on PaymentTransaction.reconciliation_status and every ReconciliationException record needs to be append-only.
In other words, the audit trail needs to record the entire sequence of state transitions.
Every exception resolution action needs to be recorded, along with information like who resolved it, when, and what action was taken, to satisfy the governance documentation requirements under SR 11-7 and equivalent frameworks.
The percentage of transactions achieving FULLY_RECONCILED status within the expected window is an incredibly valuable metric to review.
The system should produce a daily reconciliation summary report that includes total transactions processed, total FULLY_RECONCILED, total open exceptions by type, and exception resolution rate.
A production-grade payment reconciliation system requires three engineering disciplines that are frequently underrepresented at growth-stage fintechs because of how difficult these people are to find, and the rates that they charge.
Before you decide to hire a full internal team to build your payment reconciliation system from scratch, you should consider whether existing off-the-shelf reconciliation infrastructure covers your needs.
Platforms like Ledge and Stripe's reconciliation tooling can reduce custom engineering time substantially, getting your product on the market faster without sacrificing compliance.
This is the path we usually recommend for teams with standard PSP configurations and settlement patterns.
However, a custom build tends to win if you need highly custom legacy core banking integrations, multi-currency FX complexity, or compliance requirements that demand a specific audit trail structure that generic platforms don't support.
If this is the pathway you decide to go, finding the right developers quickly can make all the difference in your time-to-market. Hiring through traditional models can take as long as six months for the niche roles required.
And, if you hire the wrong person, you need to start the hiring process from scratch.
At Trio, our experts are pre-vetted by developers who have real production experience and know what to look for.
These developers can be placed in as little as 3-5 days. And, if you decide that you made a mistake, replacements happen quickly, without you needing to start the hiring process from scratch.
Multi-PSP reconciliation requires a normalisation layer that translates each PSP’s identifier scheme into a canonical model before matching. Stripe uses charge_id, payment_intent_id, balance_transaction_id, and payout_id linked in a hierarchy. Checkout.com uses payment_id with action_id on each modification. Adyen uses pspReference and originalReference. Braintree uses transaction_id and settlement_batch_id. The normalisation adapter for each PSP resolves these to a single canonical psp_transaction_id, storing the raw PSP-specific identifiers in a separate audit column.
Two-way payment reconciliation matches the internal payment ledger against PSP settlement files, confirming that the PSP acknowledges the same transactions the internal system records. Three-way payment reconciliation adds a third matching layer, confirming that PSP-reported payouts also appear as credits in the bank statement.
Payment reconciliation exceptions most often arise from timing gaps, amount mismatches, FX variance, missing PSP records, missing bank credits, and unknown transactions, each of which has a different resolution approach.
A payment reconciliation system matches financial records across the internal payment ledger, PSP settlement files, and bank statements. For each transaction, it confirms the internal record matches what the PSP reports as settled, then confirms that the settled funds actually arrived in the bank account.
Expertise
Subscribe to our newsletter
Related
Content
Continue Reading