Recon-It
A bilingual Icelandic and English reconciliation tool that matches bank and ledger lines, explains every match and proves the balance, now in open beta.
- Role
- Founder · product owner (built with an AI agent team)
- Timeline
- Sep–Oct 2026
- Status
- Open beta
- Live
- app.recon-it.app
- JavaScript (no framework, no bundler)
- Web Workers
- IndexedDB
- Cloudflare Pages Functions
- Cloudflare D1
- Node.js test suite
- GitHub Actions
- 305Commits, 30 Sep to 24 Oct 2026
- About 2,000Checks in the test suite
- 11Architecture decision records
- Over 99.9 %Auto-match precision, bank against books (synthetic)

The problem
Every month a small business or a bookkeeping firm has to show that the bank statement and the ledger agree. In Iceland it starts from exports: bank files in Windows-1252 or UTF-16, dates written with Icelandic month names, amounts in several decimal and grouping conventions, and creditor statements in no standard layout.
It is hard for two reasons. The input is messy and untrusted. And the cost of a mistake is lopsided: a wrong automatic match hides a difference that nobody was asked about. So the tool has to be quick on the obvious pairs and plainly unwilling to guess on the rest.
What I built
Recon-It is a reconciliation tool in Icelandic and English. It matches bank statements against the ledger, and creditor and customer statements against the books. Each proposed match carries its reasons, and the result is a proved-balance report with sign-off and an audit log. Sign-in is by e-mail code. It installs as an app and has a phone layout. Matching runs in the browser, and saved work lives in IndexedDB on the user’s machine.
It is in open beta at app.recon-it.app, and a sample loads without signing in.
Architecture
The matching engine is six script fragments, 1,310 lines, that run in the user’s browser. Past 4,000 lines a run moves into a Web Worker built from the engine’s own text, and falls back to running in the page when no worker is available. The server side is small: Pages Functions for sign-in and accounts on D1, and a separate Worker that sends the sign-in codes and clears expired ones each day.
Engineering highlights
The engine proposes, a person decides
An automatic match needs every condition in the excerpt, among them an exact amount, a date inside the window, no close rival, and some text or reference support. Settlement groups, where one payout is the net of many lines, and pairings remembered from earlier decisions are never automatic, whatever their score (decision record 0002). The cost is more lines to read in a busy month.
// prototype/src/engine/03-matching.js (lines 185-195, trimmed)
const ambiguous = alt >= c.s - 0.03;
const material = o.reviewAbove > 0n && abs(c.a.minor) >= o.reviewAbove;
/* Auto-accept only what a person would call obvious: exact amount, inside the date window, and some text or reference support. */
const supported = (c.sim >= 0.25 && c.textOk) || c.refMatch || c.num || c.ruleHit, inWindow = c.dd <= o.window;
matches.push({id: 'm' + (n++), aIds: [c.a.id], bIds: [c.b.id], score: c.s, kind: (c.s >= 0.9 && c.ad === 0n) ? 'exact' : 'fuzzy',
status: (c.s >= o.autoT - 1e-9 && !ambiguous && !material && c.ad === 0n && supported && inWindow && !c.nearMiss && !c.branch) ? 'auto' : 'suggested', reasons, variance: c.a.minor - c.b.minor});prototype/src/engine/03-matching.jsThe evidence is a quality budget in the test suite. Across 127 synthetic scenarios with a hidden answer key, the bank-against-books corpus has automatic precision over 99.9 % with 6 wrong automatic matches (swaps of identical lines from one counterparty are not counted), and the other two corpora have none. Recall is lower, about 86 % for bank against books, which is the price of refusing.
Refuse rather than guess
Money is BigInt minor units and dates are integer day numbers, so floating point never touches an amount: parseFloat appears twice in the engine, in column profiling and in reading a foreign-currency amount out of a line’s text. The amount parser throws on an ambiguous 1,234 unless it has a decimal-mark hint (01-money-dates-and-csv.js, line 47), and a value with too many decimals is an error, not a rounding (line 70). The one exception, aurar in an ISK file, is counted.
File import follows the same rule. Columns are found by their content as well as their headers, so a file whose headers say nothing still reads. A running balance proves which way amounts point, and a near-tie between two candidate columns is marked as a guess (02-smart-column-detection.js, lines 98 and 115). Encodings are detected (UTF-16 byte order mark, then UTF-8, then Windows-1252) so Þ and Æ survive (05-spreadsheets-and-encodings.js), and Icelandic month names are read (01, line 121). 40 fixture files cover these cases.
One page, built byte for byte
The page began as one 486 KB file that every commit touched. Decision record 0001 moved it to fragments joined as plain text, with no bundler and no renaming, and build.mjs --check fails the tests and CI if the committed page is not exactly what the fragments build. I ran it for this write-up and it passes.
The hosted build adds two things. The content security policy allows the two inline scripts by their hashes and loads nothing from a CDN (record 0009). The scripts are minified, about a quarter smaller, and tests/minified.test.mjs shows the minified page behaves the same (record 0011).
Sign-in without a third party
Record 0010 rules out a hosted sign-in provider: it would see every address, add an origin to the policy and add a bill before revenue. A six-digit code is stored as an HMAC and expires after 10 minutes. The excerpt shows the attempt counted before the comparison, so parallel guesses cannot beat the limit of 5, and the code deleted on use. Sessions are stored hashed, at most 10 per person. tests/auth.test.mjs covers the flow and its limits.
// prototype/pages/functions/_lib/auth-core.mjs (lines 136-141)
/* The attempt is counted before the comparison, in one statement, so parallel guesses cannot get past the limit. */
const att = await env.DB.prepare('UPDATE auth_codes SET attempts = attempts + 1 WHERE id = ?1 AND attempts < ?2').bind(row.id, MAX_ATTEMPTS).run();
if (!(att.meta && att.meta.changes)) return bad();
if (!safeEqual(row.code_hash, await mac(g.pepper, 'code|' + email, code))) return bad();
const used = await env.DB.prepare('DELETE FROM auth_codes WHERE id = ?1').bind(row.id).run(); /* single use, even when two requests race */
if (!(used.meta && used.meta.changes)) return bad();prototype/pages/functions/_lib/auth-core.mjsProcess & quality
The suite is 55 suites and about 2,000 checks, in 2 min 37 s, run by one command that CI also runs on every push. Thirteen of the suites are tester rounds: agents playing accountants on generated data. Every defect they find becomes a failing check on a known-open list, and a check that has been fixed but is still listed fails the run as stale. New behaviour is written test first; the settlement tests were written before the code.
Deploys go through a gate script: clean pushed commit, CI green, staging first, the suite, then a smoke check of the live address.
The studio has 12 agent roles, each owning named files, and the agents did the implementation: 289 of the 305 commits carry a Claude co-author line. By design the head-of-development agent owns architecture, the build and release pipeline and security, so records 0001, 0005 and 0009 are its decisions. I own the product, brand and legal sign-off. My decisions: the “engine proposes, a person decides” rule, name-neutral art, shipping minified scripts, choosing the visual direction from three boards, and the go-ahead to delete inactive accounts after a dry run. CI failed on every push from 23 October until a checkout setting was fixed on the 24th.
What I’d do differently
- Tighten the quality budget. It still allows 14 wrong automatic matches for bank against books while the suite now measures 6, and the test says so on every run.
- Settle the visual identity before building two. The app shipped a second style that the 24 October design round removed.
- Split the shared script scope into modules. The engine and app still share one global scope (record 0001), which blurs ownership boundaries.
- Renew the sign-in cookie when the session slides. Today people sign in again every 30 days.
Source available on request.
Gallery

A bank line and a ledger line with swapped digits, offered as a typing slip for a person to link. 
Every automatic match shows its confidence and reasons, and can be undone. 
The report states the proved balance and what is left open on each side. 
The audit log records each run with the rules version and tolerance settings. 
The same workbench on a phone. 
The marketing site explains how files are read. 
The marketing site home page.