Recon — The Reconciliation Engine
Every payments company in India wakes up to the same question at 7am: does the bank’s record of yesterday match ours, to the paisa? When it doesn’t, the gap is real money someone must explain before the day’s first standup. Recon is the engine that answers that question — and building it is your first portfolio project that solves a problem a company literally pays a team to solve every single morning.
The Real Problem
A payment aggregator processes 80,000 transactions on a normal Tuesday. The bank sends back a settlement file the next morning — a CSV with its own column names, its own date format, amounts in rupees-with-decimals. The aggregator’s own ledger sits in PostgreSQL, in paise. Finance needs to know, before 9am: which transactions match, which the bank has but the ledger doesn’t (money received but not recorded), which the ledger has but the bank doesn’t (recorded but never settled), which got recorded twice, and which match by id but differ by amount — even by one paisa.
Get this wrong and the company either loses money it can’t trace or reports numbers it can’t defend to an auditor. The people who own this are titled Reconciliation Engineer, Finance Engineering, or just Backend Engineer on a payments team — and “I built a reconciliation engine” is a sentence that makes a fintech recruiter lean forward, because it is the unglamorous core of the business.
What You Are Building
A batch engine — Java 21, Spring Boot, PostgreSQL, the splitease-api stack — that ingests two transaction feeds, matches them, classifies every discrepancy, and produces a report finance can act on. It runs from the command line (or a single endpoint), is safe to re-run, and is fast enough to chew through six figures of rows without flinching.
| # | Requirement | Example |
|---|---|---|
| R1 | Ingest a bank statement CSV — messy real-world shape: arbitrary column names, mixed date formats, rupee decimals | TXN_REF, AMT, VALUE_DATE with 1,250.00 and 15-06-2026 |
| R2 | Ingest the internal ledger from PostgreSQL — clean, paise as BIGINT | reference, amount_paise, created_at |
| R3 | Match records on reference id + amount | RZP1042 ↔ RZP1042, 125000 paise |
| R4 | Classify every record: matched, missing-in-bank, missing-in-ledger, duplicate, amount-mismatch | a 5-way bucket per row |
| R5 | Produce an exceptions report (CSV out) plus a summary table to the console | exceptions_2026-06-15.csv + counts |
| R6 | Re-running the same inputs is idempotent — same result, no duplicate run records | run keyed by file content hash |
Non-goals — write these down, they are half the design. No UI: finance consumes a CSV and a summary, nothing more. No real bank API: you feed it files, which is exactly how real recon runs. No auto-fixing: Recon finds and classifies discrepancies; deciding what to do about each is a human (or a downstream) call. A tool that silently “corrects” a money mismatch is a liability, not a feature — and saying that in an interview signals you understand the domain.
Why This Lands Jobs
- A hiring manager reads “reconciliation engine” and sees someone who has touched the actual problem their company is built around — not another CRUD app.
- It feeds the data-handling interview round (“how would you process a messy 1M-row file?”), the complexity round (the matching story below), and the correctness round (“how do you make a batch job safe to re-run?”).
- The proof artifact is concrete: a measured throughput number and a sample exceptions report you can show on screen.
- Honest limits: this is batch, single-machine, files-on-disk. It does not prove streaming, distributed processing, or real bank-integration quirks. Say “I built the core correctly and I know where it gets harder at scale” — that is the stronger claim.
Concepts It Cements
This project is the exam for half the site, and it cashes in the parts about handling data correctly.
| Concept | Where you learned it | Where it bites in this project |
|---|---|---|
| Streams and file processing | Core Java — Lambdas & Streams | Parsing and transforming the bank CSV row by row without loading nonsense into memory |
| Hash-map matching | DSA — Arrays & Hashing | The whole engine: build a map from one feed keyed by reference, stream the other against it — O(n) instead of O(n²) |
| Big-O judgment | DSA — Big-O Thinking | Why a nested-loop match dies at 80k×80k and a map does not |
| SQL aggregation | SQL — Aggregation | The summary counts and per-bucket totals are one GROUP BY query over the results table |
| Generating test data | SQL — Indexes | generate_series to build a realistic 100k-row ledger and a matching bank file to measure throughput |
| Paise-exact money | Core Java — Capstone SplitEase | Amounts are BIGINT paise end to end; the normalizer converts 1,250.00 to 125000 exactly, never via double |
| Idempotent batch design | Spring Boot — Services & Transactions | A run keyed by input hash, so re-running the same files cannot double-record results |
Design It Yourself First
Answer these in writing before reading the reference design. This is the part AI cannot do for you, and the part interviews actually test.
- What is your match key when a bank row’s reference is blank or malformed? Do you drop it, bucket it as unmatchable, or fuzzy-match?
- One pass or two? Do you load both feeds fully, or stream one against a map of the other — and which feed becomes the map?
- Where do duplicates hide? The same reference can appear twice in the bank file, twice in the ledger, or once in each. Are those the same case or three different ones?
- What exactly makes a re-run idempotent? What do you key a “run” on so the same files produce no new state?
- Can you hold 1,000,000 rows in a
HashMap? Roughly how much memory is that, and what is your plan when one feed is too big to hold? - An amount-mismatch of 1 paisa on a matched reference — is that a “miss” or a “match with a warning”? Your answer is a product decision; state it.
Do not scroll past this without written answers. The reference design is here to check your thinking, not replace it.
The Reference Design
The pipeline is four honest stages, and the second one is the stage beginners skip and seniors obsess over.
flowchart TD
A["bank CSV"] --> N["normalize: dates, amounts, encoding"]
L["ledger rows from PostgreSQL"] --> N
N --> M["match: build map from smaller feed, stream the larger"]
M --> C["classify into 5 buckets"]
C --> R["report: exceptions CSV plus summary"]
C --> P["persist run, keyed by input hash"]
| Decision | The call | Why |
|---|---|---|
| Normalization is its own layer | Convert both feeds to one clean internal shape before matching | Mixing parsing with matching is how bugs hide; the messy-input handling is the real work |
| Which feed becomes the map | The smaller one | Less memory; you stream the larger feed past it once |
| Match key | reference exact, then compare amount | References are the identity; amount difference is a classification, not a match failure |
| Run identity | A hash of the input file contents | Same bytes in → same run; re-running is a no-op, which is the definition of idempotent here |
Data model sketch:
CREATE TABLE recon_run (
id BIGSERIAL PRIMARY KEY,
input_hash TEXT NOT NULL UNIQUE, -- idempotency lives here
ran_at TIMESTAMPTZ NOT NULL DEFAULT now(),
matched_count INT, exception_count INT
);
CREATE TABLE recon_exception (
run_id BIGINT NOT NULL REFERENCES recon_run(id),
reference TEXT,
bucket TEXT NOT NULL, -- missing_in_bank | missing_in_ledger | duplicate | amount_mismatch
bank_paise BIGINT, ledger_paise BIGINT
);
The two hardest correctness problems, called out:
- Duplicates on both sides. A reference appearing twice in each feed is matched (pair them off), but twice in only one feed is a
duplicateexception. Decide the pairing rule explicitly — first-to-first by timestamp is defensible. - Amount drift. Same reference, amounts differ by a paisa. This is its own bucket (
amount_mismatch), not a miss — because the transaction happened, the recording is just wrong, and that is a completely different investigation for finance.
Build Plan
- Schema + walking skeleton. Create the two tables; a
mainthat reads a tiny hand-made bank CSV and a seeded ledger and prints “matched N”. Done when: it runs end to end on 5 rows. - The normalizer, with ugly inputs. Parse mixed date formats and
1,250.00-style amounts to paiseBIGINT; write tests for the nasty cases (trailing spaces, empty reference, negative amounts). Done when: every ugly-input test passes. - The matcher at scale. Build the map-and-stream match; seed a 100k-row ledger with
generate_series, export a matching bank CSV, and measure the match time — your first resume number. Done when: 100k×100k matches in seconds, timed withSystem.nanoTime. - The classifier + exceptions CSV. Implement all five buckets including the duplicate and amount-drift edge cases; write the exceptions CSV. Done when: a deliberately corrupted bank file produces the right counts in each bucket.
- Idempotent runs + summary. Hash the input, skip or no-op on a repeat, persist the run and exceptions, print the summary table. Done when: running the same files twice creates exactly one
recon_run. - Package + document. A runnable CLI/job, a README that takes a stranger from clone to a real run, and a demo script. Done when: the README alone gets someone to a clean run.
Definition of Done
- Handles a genuinely messy bank CSV (mixed dates, comma amounts, blank fields) without crashing
- All five buckets correct, including duplicates-both-sides and 1-paisa drift
- Money is
BIGINTpaise everywhere; the normalizer never touchesdouble - Re-running identical inputs creates no new state (one
recon_run) - A measured throughput number (rows matched per second) from a real 100k run
- README with a demo: run it, show the summary and a sample exceptions CSV
Stretch Goals
- Fuzzy matching when references are missing — match on amount + date window (forces a careful precision/recall conversation).
- A web view of the latest run’s exceptions (forces a read-only endpoint and pagination — SQL capstone patterns).
- Multi-day runs with a trend of exception counts over time (forces
date_truncaggregation).
Resume Bullets It Generates
Fill the placeholders from your own measured runs — never claim a number you did not measure.
- Built a reconciliation engine in Java/Spring Boot that matches and classifies N transactions across two feeds in M seconds, using an O(n) hash-map match in place of a naive O(n²) scan.
- Implemented a five-way discrepancy classifier (missing, duplicate, amount-drift) with paise-exact
BIGINTarithmetic, producing an auditable exceptions report. - Designed the batch to be idempotent via input-content hashing, making re-runs safe — a correctness property finance teams depend on.
- Handled real-world messy input (mixed date formats, comma-separated amounts, malformed references) through a dedicated normalization layer with full test coverage.
Interview Stories It Seeds
- “Tell me about handling messy data.” → Situation: bank files with inconsistent formats. Action: a dedicated normalization layer converting everything to one internal shape before any matching, tested against the ugly cases. Result: the matcher stayed simple because the mess was contained upstream.
- “How did you make a batch job safe to re-run?” → keyed each run on a hash of the input bytes; a repeat is a no-op. The honest framing: at-least-once execution is a fact of life, so the job must be idempotent.
- “You’re matching 100k against 100k — walk me through the complexity.” → the O(n²) nested-loop version and why it dies; building a
HashMapfrom the smaller feed and streaming the larger for O(n); the memory trade-off and what you’d do if neither feed fit in memory.
Project started? Add it to today’s tracker