From Debugging to Design: How Thinking in Flows Makes You a Better Payments Engineer
A mental model framework for understanding any payment system
This is the second in a series on payment systems engineering.
- This Map is Not the Territory: Why documentation and reality diverge
- From Debugging to Design: Mental models for understanding any payment flow
- Building for the 1%: Engineering exception handling as first-class work
- The FinTech Staff Engineer: Translating technical reality into business decisions
Each piece stands alone. Together, they form a framework for building reliable payment infrastructure at scale.
Introduction: Why Code Paths Are the Wrong Abstraction
Early in my career, I approached payment systems the way most engineers do: as a collection of APIs to integrate, databases to query, and edge cases to handle. I'd debug by tracing code paths, reading logs, and fixing the immediate problem.
It worked. Until it didn't.
The turning point came during development of TryGrip App. We had integrated multiple Nigerian financial providers, using an aggregator for bank connections and direct API integrations for wallet providers and fintechs. Each had different response formats and timing characteristics. Provider A returned authorizations in 2 seconds. Provider B took 30. Provider C's "success" response sometimes meant "we'll try to settle this." Provider D's settlement file arrived at 4 AM or 4 PM with no predictable schedule.
I couldn't hold this complexity in my head by thinking about code paths. There were too many exceptions and one-off behaviors masquerading as standard implementations.
I needed a different mental model. I found it by tracing flows instead of paths.
Money in payment systems if conserved. It doesn't vanish; it moves somewhere you haven't looked yet. Your job is to find where.
This framework; tracing funds flow, data flow and reconciliation flow, has become my primary tool for understanding and debugging payment systems, and for designing new ones. It works whether you're integrating a new provider, designing a ledger system or debugging why money disappeared between two systems.
Let me show you how it works.
The Three Flows Framework
Every payment system, no matter how complex, can be understood through three distinct flows:
actually move?"] DF --> DFQ["What information
moves and when?"] RF --> RFQ["How do we verify
what happened?"]
Funds Flow: The actual movement of money between accounts, ledgers and institutions.
Data Flow: The information about payments; the requests, responses, status updates, notifications
Reconciliation Flow: The process of verifying that funds flow and data flow tell the same story
When these three flows align, your system is healthy. When they diverge, you have bugs, missing money or angry customers.
Part 1: Funds Flow - The GPS Tracker Question
The Question
"If I could attach a GPS tracker to this money, where would it go and when?"
Mapping Funds Flow
For any payment operation, draw the actual movement of value; not the API calls, DB Updates or status changes but the money itself.
Example: Card Payment to Merchant
Balance: $1,000"] -->|"1. Authorization
$100 hold placed
(Available: $900)"| B["Issuing Bank
Suspense/Hold Account"] B -->|"2. Settlement T+1
$100 moves"| C["Acquiring Bank
Merchant Pool"] C -->|"3. Payout T+1 to T+3
$97 (fees deducted)"| D["Merchant's Bank Account
Received: $97"]
Cross-Border Funds Flow Example
$1,000 USD] -->|Debit| B[Sending Bank
USD Ledger] B -->|MT103| C[Correspondent 1
USD Nostro] C -->|FX Conversion
1 = ₦1,550| D[Correspondent 2
NGN Nostro] D -->|MT103| E[Receiving Bank
Nigeria] E -->|Credit
₦1,524,000
after fees| F[Beneficiary Account] style C fill:#fff3cd style D fill:#fff3cd
Each correspondent deducts fees from the principal. The customer sent $1,000; the beneficiary receives ₦1,524,000 instead of the expected ₦1,550,000.
Key Questions for Funds Flow
When mapping any payment system, ask:
- Where does value rest at each stage?
- Is it in a customer account, a suspense account, or a pooled account?
- Who has legal ownership? Who has operational control?
- When does ownership transfer?
- Authorization doesn't transfer ownership; settlement does
- The moment of ownership transfer has legal and accounting implications, and is often the root of reconciliation disputes
- Who bears risk at each stage?
- Before settlement: issuer risk (authorization might not settle)
- After settlement but before payout: acquirer risk; after payout: merchant risk (chargebacks)
- Understanding risk assignment explains why systems are designed the way they are
- What triggers movement to the next stage?
- Time-based? (Settlement batch runs at midnight)
- Event-based? (Capture request from merchant)
Some systems also use threshold triggers (payout when balance exceeds $100).
Funds Flow Debugging Pattern
When money goes missing:
1. Identify the last known location
- "Customer account was debited at 14:32"
- Evidence: bank statement, provider settlement file
2. Identify where it should have arrived
- "Merchant account should have credited by 09:00 next day"
- Evidence: merchant payout report, bank statement
3. Trace the path between them
- Did it leave the issuer? (Check settlement file from acquirer)
- Did it arrive at acquirer and reach the merchant's payout batch? (Check acquirer settlement report and payout records)
4. Find the divergence point
- "It reached the acquirer but wasn't included in merchant payout"
- Root cause: Merchant payout account details were invalid
- Fix: Notification system for payout failures, not silent skipsI once spent 8 hours tracing missing funds that had actually arrived but our payout report column headers had shifted, and our parser was reading the wrong column as "amount." The money was there. Our belief system said it wasn't. The divergence wasn't in funds flow; it was in data flow. This is why you must trace both.
Part 2: Data Flow — The Belief System
The Question
"What information moves through the system, and does it accurately represent the funds flow?"
Why Data Flow Diverges from Funds Flow
Data and money move differently
| Characteristic | Funds Flow | Data Flow |
|-----------------|-----------------|-------------------------------|
| Speed | Slow (days) | Fast (seconds) |
| Granularity | Batched | Real-time |
| Direction | One-way | Request-response |
| Reversibility | Difficult | Easy (messages can be resent) |
| Source of truth | Settlement file | API response |This is not a bug. It's the fundamental constraint of payment systems. Your architecture must acknowledge it.
Mapping Data Flow
For the same card payment:
Real-Time Data Flow (Authorization):
Batch Data Flow (Settlement T+1):
Your system "knows" about authorization instantly, but funds don't actually move until the overnight batch. The gap between belief and reality is where bugs hide.
The Data-Funds Timing Gap
| Event | Funds Flow Timing | Data Flow Timing | Gap Risk |
|---|---|---|---|
| Authorization | Hold placed instantly | Response in ~2 seconds | None (tightly coupled) |
| Settlement | Funds move at batch time | File available next morning | 12–24 hours |
| Reversal | Funds returned in batch | Notification within hours/days | Merchant may have shipped |
| Chargeback | Funds pulled back | Notification within days (dispute window up to 120 days) | Customer has product |
Critical Insight: Your system knows about things before they happen (authorization before settlement) and after they happen (settlement file after funds moved). Rarely at the exact moment.
Data Flow Design Patterns
Pattern 1: Event Sourcing for Payment State
Instead of storing current state, store the sequence of data flow events:
public class PaymentEventStore
{
public async Task AppendEvent(PaymentEvent @event)
{
await _store.Append(@event);
}
public async Task<Payment> Reconstitute(Guid paymentId)
{
var events = await _store.GetEvents(paymentId);
var payment = new Payment();
foreach (var @event in events)
{
payment.Apply(@event);
}
return payment;
}
}
// Events model data flow, not funds flow
public record AuthorizationRequested(Guid PaymentId, Money Amount, DateTime Timestamp);
public record AuthorizationApproved(Guid PaymentId, string AuthCode, DateTime Timestamp);
public record SettlementReceived(Guid PaymentId, Money SettledAmount, DateTime Timestamp);
public record ReconciliationCompleted(Guid PaymentId, bool Matched, DateTime Timestamp);Pattern 2: Status vs. Substatus
Your data flow captures more nuance than "success/failure":
public enum PaymentStatus
{
Pending,
Authorized,
Captured,
Settled,
Completed,
Failed,
Refunded
}
public enum PaymentSubstatus
{
// Pending substatus
AwaitingProviderResponse,
ProviderTimeout,
RetryScheduled,
// Authorized substatus
AwaitingCapture,
PartiallyAuthorized,
// Settled substatus
AwaitingReconciliation,
ReconciliationMismatch,
// Failed substatus
InsufficientFunds,
CardExpired,
FraudSuspected,
ProviderError
}Data Flow Debugging Pattern
When a payment shows wrong status:
1. List all events for this payment in order
- Authorization requested at 14:32:01
- Authorization approved at 14:32:03
- Void requested at 14:32:45
- Void timeout at 14:33:45
- Settlement file shows settled (received next day 09:00)
2. Identify the divergence
- Customer thinks payment was cancelled (void requested)
- System also thinks payment was cancelled (void timeout = assumed success?)
- But reality: void failed, payment settled anyway
3. Trace root cause
- Void timeout was treated as success
- Should have been treated as "unknown" requiring reconciliationPart 3: Reconciliation Flow - Updating Your Beliefs
The Question
"How do we know what we think happened actually happened?"
Why Reconciliation is Non-Negotiable
Your database contains your belief about what happened. The providers settlement file contains reality. Reconciliation is how you update your beliefs to match.
Settlement files, webhooks, API polling
100% of transactions"] MA["MATCHING
Automated ID/composite matching
95%+ auto-resolved"] DD["DISCREPANCY DETECTION
Automated rules flag anomalies
4% need review"] EI["EXCEPTION INVESTIGATION
Manual review, alerts
1% require human judgment"] end DI --> MA --> DD --> EI style DI fill:#d4edda style MA fill:#d4edda style DD fill:#fff3cd style EI fill:#f8d7da
The pyramid represents both volume and effort: most transactions auto-reconcile at the base, but the exceptions at the top consume disproportionate engineering time.
Ingestion: The Hidden Complexity
For TryGrip app, we maintained parsers for multiple settlement file formats. Fixed-width positional files. CSV with different column orders across providers. ISO 20022 XML. Excel files with merged cells. Each provider was certain their format was "standard." None of them were wrong. All of them were different.
public interface ISettlementFileParser
{
bool CanParse(string fileName, byte[] content);
Task<IEnumerable<SettlementRecord>> Parse(Stream file);
}
// We had multiple implementations
public class Iso20022Parser : ISettlementFileParser { /* XML parsing with namespace handling */ }
public class FixedWidthParser : ISettlementFileParser { /* ~300 lines pulling fields by position */ }
public class GenericCsvParser : ISettlementFileParser { /* ~200 lines with column inference */ }Matching: Exact, Composite & Fuzzy
Not all records match cleanly on ID. Real reconciliation requires multiple strategies:
public class MatchingEngine
{
public MatchResult Match(
IEnumerable<InternalRecord> internal,
IEnumerable<ExternalRecord> external)
{
var result = new MatchResult();
foreach (var internalRecord in internal)
{
// Strategy 1: Exact ID match
var exactMatch = external.FirstOrDefault(e =>
e.TransactionId == internalRecord.ProviderTransactionId);
if (exactMatch != null)
{
result.AddMatch(internalRecord, exactMatch, MatchType.ExactId);
continue;
}
// Strategy 2: Composite key match (amount + date + reference)
var compositeMatch = external.FirstOrDefault(e =>
e.Amount == internalRecord.Amount &&
e.TransactionDate.Date == internalRecord.TransactionDate.Date &&
e.Reference.Contains(internalRecord.OrderReference));
if (compositeMatch != null)
{
result.AddMatch(internalRecord, compositeMatch, MatchType.Composite);
continue;
}
// Strategy 3: Fuzzy match with confidence score
var fuzzyMatches = external
.Select(e => new { Record = e, Score = CalculateFuzzyScore(internalRecord, e) })
.Where(x => x.Score > 0.8)
.OrderByDescending(x => x.Score)
.ToList();
if (fuzzyMatches.Any())
{
result.AddPotentialMatch(internalRecord, fuzzyMatches.First().Record,
fuzzyMatches.First().Score, MatchType.Fuzzy);
continue;
}
// No match found
result.AddUnmatchedInternal(internalRecord);
}
return result;
}
}Discrepancy Types
public enum DiscrepancyType
{
// Amount discrepancies
AmountMismatch, // Different amount (common with fee deductions)
CurrencyMismatch, // Different currency (FX issues)
// Status discrepancies
InternalSuccessExternalFailed, // We think it worked, it didn't
InternalFailedExternalSuccess, // We think it failed, it worked (worst case)
// Existence discrepancies
MissingInExternal, // We have it, they don't (concerning)
MissingInInternal, // They have it, we don't (orphan transaction)
// Timing discrepancies
SettlementDateMismatch, // Settled on different date than expected
DuplicateInExternal // Provider shows it twice
}Part 4: Applying the Framework
Use Case 1: Debugging a "Missing Money" Report
Symptom: Customer claims they were charged but didn't receive the product.
Using the framework:
FUNDS FLOW ANALYSIS
├── Check: Was customer's account debited?
│ └── Yes, bank statement shows debit
├── Check: Did funds reach our account?
│ └── Check settlement file... Yes, included in yesterday's settlement
└── Conclusion: Funds flow completed correctly
DATA FLOW ANALYSIS
├── Check: What does our system show?
│ └── Status: "Pending" (should be "Completed")
├── Check: Did we receive settlement confirmation?
│ └── Webhook log shows settlement received
├── Check: Did status update trigger?
│ └── Error in log: "Order not found for payment ID"
└── Conclusion: Data flow broken at order-payment linkage
RECONCILIATION FLOW ANALYSIS
├── Check: Did reconciliation flag this?
│ └── No—reconciliation matched payment correctly
├── Check: Why didn't it catch the order issue?
│ └── Reconciliation only checks payment status, not order fulfillment
└── Conclusion: Reconciliation scope too narrow
ROOT CAUSE: Payment completed but order status wasn't updated due to
missing order-payment link. Reconciliation didn't catch it because
it only validates payment data, not fulfillment data.
FIX: Extend reconciliation to include order-payment linkage validation.Use Case 2: Designing a New Payment Integration
Before writing code, map the three flows:
NEW PROVIDER: Mobile Money Integration
FUNDS FLOW
├── Customer balance → Provider float account (instant)
├── Provider float → Our settlement account (T+1 batch, 4 PM cutoff)
├── Questions:
│ ├── What happens if provider float is insufficient at cutoff?
│ │ → Rollover to next day? Partial settlement? Error?
│ ├── Is there a reserve/hold mechanism?
│ └── Who bears FX risk if settlement currency differs?
DATA FLOW
├── Real-time: Webhook for each transaction (claimed 99.9% delivery)
├── Batch: Daily settlement report via SFTP (format: ISO 20022)
├── Questions:
│ ├── Is the webhook reliable enough to be source of truth?
│ │ → Add polling backup every 4 hours for unconfirmed transactions
│ └── What's the mapping from their statuses to our state machine?
RECONCILIATION FLOW
├── Match our records against daily settlement report
├── Expected discrepancy types:
│ ├── Amount mismatch (check: do they deduct fees before reporting?)
│ └── Missing/extra in settlement (webhook and settlement file disagree)
└── SLA: Reconciliation must complete before T+2 payout cutoffPart 5: Using Flows for Provider Evaluation
Senior Engineer's LENS
When evaluating a new payment provider, most engineers compare API documentation. This is a trap. API documentation is marketing.
Instead, use the Three Flows framework as your evaluation scorecard:
| Question | What You Learn |
|---|---|
| Funds Flow | |
| "Where does money rest between customer payment and our settlement?" | Counterparty risk, float requirements |
| "What is the settlement schedule? Cutoff times?" | Working capital implications |
| "What happens if you become insolvent mid-cycle?" | Legal/operational risk |
| "Who bears FX risk at each stage?" | Economic exposure |
| Data Flow | |
| "What is your 95th percentile authorization latency?" | Not average—tail risk |
| "Do your webhooks have delivery guarantees? Retry policies?" | Reliability for state transitions |
| "How do your statuses map to our state machine?" | Semantic compatibility |
| "What data do you provide for reconciliation?" | Matching fidelity |
| Reconciliation Flow | |
| "What format are settlement files? Sample available before contract?" | Integration complexity |
| "When are files available relative to settlement?" | Detection latency |
| "What is your dispute/chargeback process and timeline?" | Exception handling cost |
This framework saved us from signing with a provider whose API was beautiful but whose settlement file was a PDF emailed to an unmonitored inbox. We learned this in evaluation, not production.
Conclusion: From Debugging to Design
The Three Flows Framework shifts your thinking:
| Before | After |
|---|---|
| "What API endpoint do I call?" | "Where does the money go when I call it?" |
| "What status does this response return?" | "Does this status reflect funds reality?" |
| "How do I handle this error?" | "What flow divergence caused this error?" |
| "Why is this payment stuck?" | "Which flow is misaligned?" |
The next time you're staring at a payment bug, don't trace code paths. Follow the money and follow the data. Then verify they tell the same story.
That's how you become a better payments engineer.
That's how you move from debugging to design.
What mental models do you use for understanding payment systems? I'm especially interested in frameworks that have helped you evaluate providers or debug cross-border flows.