The most common failure in bank-export agents is asking a model to do work a parser should do. Bank files are semi-structured: column order shifts between exports, headers carry different names ("Description" versus "Memo" versus "Narrative"), and amount sign conventions differ between institutions. A model is bad at this and will occasionally hallucinate a column mapping that looks plausible. A parser is good at it and fails loudly when it cannot find a column.
The deterministic layer has four jobs, in order.
Read and normalize. Map every incoming column to a canonical set: txn_id, posted_date, amount, currency, description, counterparty_raw, account_id, balance_after. Coerce dates to ISO 8601, store amounts in integer minor units (cents), and record the source file name, the source row number, and a hash of the raw row. Storing the raw row hash means that when a human disputes a categorization six months later, you can show exactly what the bank sent.
Deduplicate on the bank's transaction ID. Banks reuse export windows: a user pulls 30 days on Monday and 45 days on Thursday, and the overlap arrives twice. Deduplicate on the bank's own ID, not on a hash of date plus amount plus description. Two legitimate identical charges from the same merchant on the same day are common (two coffees, two ad-platform threshold charges), and hashing on content will silently drop one. If the export has no ID column, synthesize a stable key from account_id, posted_date, amount, the ordinal position of that row within the day, and a hash of the description, and mark those rows as lower confidence in the parser.
Reconcile the balances. This is the control that catches almost everything else. Take the opening balance stated on the export, add every parsed amount, and compare the result to the stated closing balance. If they differ, stop. Do not call the model. A mismatch means a dropped row, a sign flip, a duplicated line, or a truncated file, and no amount of model cleverness fixes an arithmetic error. Treat reconciliation as a hard gate, the same way a reconciliation page insists the statement total equals the balance to reconcile before it allows a post in Business Central. Microsoft also notes there that unmatched lines left on the reconciliation page represent discrepancies you must resolve, which is exactly the posture the parser should take.
Emit a stable, typed row set. The parser's output is a JSON array of canonical rows keyed by txn_id. Everything downstream addresses rows by that key only.
Ad platforms make the same point from the other direction: threshold charges do not align to campaign dates or calendar months, and a single card transaction can span two campaigns and two reporting periods according to Opal's reconciliation glossary. That is a reason to treat the amount as the ground truth and the description as merely evidence, and to expect one deposit or charge to need splitting later.
A worked check, with numbers:
| Step | Value |
|---|---|
| Opening balance on the export | 48,210.55 |
| Sum of 312 parsed rows (net) | -11,904.12 |
| Opening plus sum | 36,306.43 |
| Closing balance stated by the bank | 36,306.43 |
| Difference | 0.00, parser passes |
Now suppose one row failed to parse because its description contained an embedded newline that broke a line-oriented reader. The sum falls short by that row's amount, the difference is nonzero, and the run halts with a parse error naming the source row number. That is the behavior you want: a loud, specific failure instead of a silent miscount that reaches the ledger.



