Tutorial // Agents2026-07-2812 min read

agent-for-accounting: An Open-Source AI Agent for Bank-to-Ledger Reconciliation

We open-sourced an AI agent that reconciles a bank statement against a ledger, computes MACRS depreciation, and refuses to give tax advice. Here's what it actually does, and the double-counting bug fuzzing caught that reading never would have.

Varun Raj Manoharan
Varun Raj ManoharanFounder & Principal Engineer
AI AgentsOpen SourceAccountingFintechTesting

Key takeaways

  • reconcile() sorts candidate matches with a symmetric, content-based tie-break instead of a bank-first or ledger-first key, so reconciling A against B classifies every row identically to reconciling B against A.
  • The first version of split detection ran two directions over the same unmatched pool independently, so a single row could be consumed twice and a genuine $100 discrepancy could come back as needsHumanReview: 0, found by fuzzing rather than by reading, using an invariant that every input row must land in exactly one output bucket.
  • The permanent fix is a conservation invariant test asserting every input row lands in exactly one output bucket across 400 seeded runs, which is a structural property a spot test cannot satisfy by accident.
  • A skipped CSV row is treated as an incomplete reconciliation, not a clean one: parseWarnings and summary.skippedRows surface it, and a hard rule in agent/instructions.md requires the agent to say so before reporting results.

We open-sourced agent-for-accounting, an AI copilot for small-firm accountants, bookkeepers, and fractional CFOs. It reconciles a bank statement against a ledger, schedules MACRS depreciation, suggests transaction categories, tracks a month-end close checklist, and computes standard financial ratios. Eight tools total, all in agent/tools/, all backed by plain functions in agent/lib/ that run with zero API keys and zero network calls in tests.

This post is about the reconciliation tool specifically, because it's the one with the interesting engineering in it, and because the bug we found in it is worth teaching.

The problem

Reconciling a bank statement against a ledger is mechanical. You're matching two lists of transactions that should agree, and most of the time they do. A rent payment shows up on the bank statement and in the ledger with the same amount and the same date. Done. The part that actually costs a bookkeeper time isn't the matches. It's the small set of lines that genuinely disagree, plus the handful that look like they disagree but don't.

A cheque written on the 10th and cashed on the 14th is not an error. It's a timing difference: same amount, different date, within a normal clearing window. A $3,200 payroll debit that turns out to be two ledger entries, $2,600 in wages and $600 in employer taxes, isn't a discrepancy either. It's one bank line covering two ledger lines, and an accountant needs to see that as a hypothesis to confirm, not as two separate unmatched rows to puzzle over.

The job of a reconciliation tool isn't just "count what matches." It's collapsing five hundred lines down to the six or eight that actually need a human decision, without hiding the ones that do and without inventing certainty about the ones that don't. Get that wrong in either direction (bury a real discrepancy, or flag a clean timing difference as a problem) and the tool is worse than not having one, because now someone trusts a number that isn't right.

What the agent does

You give it two CSVs (a bank export and a ledger export) and it calls the reconcile tool, which classifies every row into one of six buckets: matched, timing differences, amount differences, same-side duplicates, possible splits, and unmatched.

Here's a real run. Not a hand-typed example: this is the tool's actual output, produced by calling runReconcile in agent/tools/reconcile.ts with two CSV strings:

CSV
# march-bank.csv
Date,Description,Amount
2026-03-01,Rent,-1200
2026-03-05,AWS,-59.99
2026-03-10,Client payment - Acme,5000
2026-03-14,Cheque 2004,-800
2026-03-15,Payroll debit,-3200
2026-03-18,Zoom,-45
2026-03-18,Zoom,-45
2026-03-22,Unknown debit,-340
CSV
# march-ledger.csv
Date,Description,Amount
2026-03-01,Rent expense,-1200
2026-03-05,Hosting,-59.99
2026-03-10,Invoice 1010 - Acme,5000
2026-03-10,Cheque 2004 issued,-800
2026-03-15,Payroll - wages,-2600
2026-03-15,Payroll - employer taxes,-600
2026-03-18,Zoom subscription,-45

The summary block the tool returns, from that actual run:

JSON
{
  "bankLines": 8,
  "ledgerLines": 7,
  "matched": 4,
  "timingDifferences": 1,
  "amountDifferences": 0,
  "duplicates": 1,
  "possibleSplits": 1,
  "skippedRows": 0,
  "needsHumanReview": 4
}

Four lines matched cleanly. The cheque cleared four days late: a timingDifference, not a discrepancy. The payroll debit showed up as a possibleSplit against the two payroll ledger lines. The two identical $45 Zoom charges on the bank side became a duplicate. And the $340 debit with no ledger match at all stayed in unmatchedBank. needsHumanReview is 4: one for the split, one for the duplicate, two for the genuinely unmatched bank lines, computed by the tool, not by the model doing arithmetic in its head.

agent/instructions.md tells the model to lead with summary.needsHumanReview and use it exactly as returned, since it already accounts for unmatched lines on both sides plus splits, duplicates, and amount differences, not just a naive unmatched count. Eight bank lines, seven ledger lines, four items that need a look. That's the shape the tool is built to produce.

The design property that matters most: order-independence

Here's a property that's easy to skip and expensive to get wrong: reconciling the bank statement against the ledger has to classify every row exactly the same way as reconciling the ledger against the bank statement, just with the two sides swapped in the output. If it doesn't, an accountant who re-runs the same job on a re-sorted export gets a different answer for the same underlying data, and the tool stops being trustworthy the first time that happens.

It's a real risk in a naive implementation. A greedy matcher builds every candidate pair within tolerance, sorts them, and assigns the best ones first, skipping anything already claimed. If two candidates tie on amount difference and date difference (which happens constantly with round numbers and identical dates), the tie-break decides who wins, and it's tempting to break ties using something like array index or which side is "bank." That's a side-label-based tie-break, and it means the sort order depends on which array happened to be passed as bank.

reconcile() avoids this with a symmetric, content-based tie-break key:

TypeScript
/** A content-based key for an entry, independent of which array/side it came from. */
function entryContentKey(e: ClassifiedEntry): string {
  return `${e.date}|${e.amountDollars}|${normalizeDescription(e.description)}`;
}

/**
 * Symmetric sort key for a candidate (x, y) pair: identical regardless of
 * which of x/y is passed first.
 */
function symmetricKey(x: ClassifiedEntry, y: ClassifiedEntry): string {
  return [entryContentKey(x), entryContentKey(y)].sort().join("~");
}

The key is built from the content of both entries (date, amount, normalized description), sorted alphabetically before joining, so it doesn't matter which one is x and which is y. reconcile({bank: A, ledger: B}) and reconcile({bank: B, ledger: A}) build the identical candidate set (just enumerated in the other order) and sort it identically, so they make the same pairing decisions every time. tests/reconcile.test.ts has a test named exactly for this ("matching is order-independent: swapping bank and ledger mirrors the result") that checks it directly instead of trusting the argument.

The split detector uses the same trick for the same reason, which matters, because that's where the bug was.

The bug worth teaching

The first version of split detection ran in two separate passes: one direction checked every unmatched bank entry against pairs of unmatched ledger entries, and a second, independent call checked every unmatched ledger entry against pairs of unmatched bank entries. Each pass had its own "already claimed" bookkeeping. Neither pass knew what the other had touched.

That's the bug. A single row could be consumed twice in one reconcile() call (once as the single side of a split in one direction, and again as a group member of a different split in the other direction) because the two passes never talked to each other.

Here's the exact case that exposes it. Bank has two lines, ledger has three:

Shell
bank:   [$100, $50]
ledger: [$40, $60, $150]

Bank totals $150. Ledger totals $250. That's a real $100 discrepancy: a genuinely out-of-balance month. But look at what each direction sees in isolation:

  • Bank-single-vs-ledger-group: bank's $100 equals ledger's $40 + $60. Valid split.
  • Ledger-single-vs-bank-group: ledger's $150 equals bank's $100 + $50. Also a valid split.

Both use bank's $100 line: once as the "single," once as a member of the "group." Run independently, both split candidates fire, both claim the rows they need, and between them every bank row and every ledger row ends up marked accounted for. The result: unmatchedBank: [], unmatchedLedger: [], summary.needsHumanReview: 0. A clean month. The agent would tell an accountant to move on. The month does not reconcile.

It was found by fuzzing, not by reading: randomized reconciliations run against the old two-pass code and checked against a structural property rather than a specific expected answer. The hit rate depends on the input distribution rather than on the bug: one run turned up two cases in 399, a rerun with a different spread of amounts hit nine in 400. Each split-detection pass looks correct on its own. It's the interaction that fails, and that's exactly the kind of bug a human reading either function in isolation won't catch.

The fix is one shared pass instead of two independent ones, with a single candidate pool covering both directions, and one shared pair of consumed sets:

TypeScript
generate("bank", unmatchedBank, unmatchedLedger);
generate("ledger", unmatchedLedger, unmatchedBank);

candidates.sort((p, q) => {
  if (p.amountDiffCents !== q.amountDiffCents) return p.amountDiffCents - q.amountDiffCents;
  return p.sortKey < q.sortKey ? -1 : p.sortKey > q.sortKey ? 1 : 0;
});

for (const c of candidates) {
  const groupSide = c.singleSide === "bank" ? "ledger" : "bank";
  const singleConsumed = c.singleSide === "bank" ? consumedBankIdx : consumedLedgerIdx;
  const groupConsumed = groupSide === "bank" ? consumedBankIdx : consumedLedgerIdx;

  if (singleConsumed.has(c.single.index)) continue;
  if (groupConsumed.has(c.group[0].index) || groupConsumed.has(c.group[1].index)) continue;

  singleConsumed.add(c.single.index);
  groupConsumed.add(c.group[0].index);
  groupConsumed.add(c.group[1].index);
  // ...push the split
}

Once any row is claimed by any split, in either direction, it's off the table for every other candidate, structurally, not by convention. Running the fixed code on the exact repro case now produces this, verified by executing it:

JSON
{
  "matched": [],
  "unmatchedBank": [
    { "date": "2026-04-01", "amountDollars": 50, "description": "Bank tx B", "index": 1 }
  ],
  "unmatchedLedger": [
    { "date": "2026-04-01", "amountDollars": 150, "description": "Ledger tx E", "index": 2 }
  ],
  "possibleSplits": [
    {
      "side": "bank",
      "single": { "date": "2026-04-01", "amountDollars": 100, "description": "Bank tx A", "index": 0 },
      "group": [
        { "date": "2026-04-01", "amountDollars": 40, "description": "Ledger tx C", "index": 0 },
        { "date": "2026-04-01", "amountDollars": 60, "description": "Ledger tx D", "index": 1 }
      ],
      "amountDiffDollars": 0
    }
  ],
  "summary": { "possibleSplits": 1, "skippedRows": 0, "needsHumanReview": 3 }
}

One split fires, not two. Bank's $100 line is claimed by it. The $50 bank line and the $150 ledger line (the actual $100 hole) fall back to unmatchedBank and unmatchedLedger, exactly where a real discrepancy needs to stay visible. needsHumanReview: 3, not zero.

But the real fix isn't the shared candidate pool by itself. It's the test that makes the bug structurally impossible to reintroduce. tests/reconcile.test.ts has a fuzz test, "matching conserves every row exactly once," that generates 400 seeded random reconciliations and checks a single invariant on every one of them: every input row (bank or ledger) lands in exactly one of matched, timingDifferences, amountDifferences, a possibleSplits entry, or unmatched. Never zero buckets. Never two.

That invariant is the actual lesson here, more than the fix itself. A spot test ("given this input, expect this output") can pass by coincidence; it only checks the cases someone thought to write down. A conservation invariant asserts a structural truth that has to hold across every possible run, seeded or not, and it cannot be satisfied by accident the way a spot test can. If you extend split detection to three-or-more-way splits (which this tool deliberately doesn't do, for combinatorial reasons), the invariant test is what has to keep passing, not any specific example.

Incomplete input is not a clean result

A row with an unparseable date or amount doesn't get coerced into a guess. It gets skipped, and the skip is recorded visibly, not just as a log line nobody reads.

JSON
{
  "parseWarnings": [
    "may-bank.csv row 3: unrecognised date \"not-a-date\", skipped."
  ],
  "summary": {
    "bankLines": 2,
    "ledgerLines": 2,
    "matched": 2,
    "skippedRows": 1,
    "needsHumanReview": 0
  }
}

That's from an actual run: a bank CSV with a bad date in row 3 alongside two otherwise-clean rows. Notice needsHumanReview: 0 sitting right next to skippedRows: 1. The reconciliation looks clean by the numbers it managed to compute, and it genuinely is clean, for the rows it saw. It never saw the third one.

That gap is exactly the failure mode this design exists to prevent, and it's why agent/instructions.md has a rule that's stricter than it might first look:

A skipped row is an incomplete reconciliation, not a clean one. If parseWarnings is non-empty (equivalently, summary.skippedRows is greater than 0), say so before reporting the reconciliation result: name how many rows were skipped and from which file, and state plainly that those transactions were never considered, not just unmatched.

The instruction to say it before reporting results is deliberate. A tidy needsHumanReview count that shows up first primes the reader to trust the rest of the answer; the incompleteness disclosure has to land before that trust forms, not as a footnote after.

MACRS depreciation

The depreciation_schedule tool computes a MACRS schedule under the half-year convention, using percentage tables from IRS Publication 946, Table A-1, encoded as literal data in agent/lib/depreciation.ts: no external lookup, no API call. Running it for a $40,000 asset placed in service in 2026 on a 5-year recovery period:

JSON
{
  "ok": true,
  "recoveryPeriod": 5,
  "convention": "half-year",
  "source": "IRS Publication 946, Table A-1 (half-year convention)",
  "tableVerifiedAgainstTaxYear": 2023,
  "schedule": [
    { "taxYear": 2026, "percentage": 20,    "depreciationDollars": 8000,  "bookValueDollars": 32000 },
    { "taxYear": 2027, "percentage": 32,    "depreciationDollars": 12800, "bookValueDollars": 19200 },
    { "taxYear": 2028, "percentage": 19.2,  "depreciationDollars": 7680,  "bookValueDollars": 11520 },
    { "taxYear": 2029, "percentage": 11.52, "depreciationDollars": 4608,  "bookValueDollars": 6912  },
    { "taxYear": 2030, "percentage": 11.52, "depreciationDollars": 4608,  "bookValueDollars": 2304  },
    { "taxYear": 2031, "percentage": 5.76,  "depreciationDollars": 2304,  "bookValueDollars": 0     }
  ]
}

Sum the six depreciationDollars values and you get exactly $40,000: no residual cent left over. That's not an accident of this particular number: the last year of every schedule absorbs whatever cent of rounding is left from applying the published percentages, so total depreciation always equals the asset's cost exactly, checked in tests/depreciation.test.ts across four recovery periods and five cost amounts chosen specifically because they don't divide evenly.

tableVerifiedAgainstTaxYear: 2023 is the detail worth noticing. The percentages themselves haven't changed since MACRS's 1986 enactment for these recovery periods, but the codebase says plainly which edition of Publication 946 it was checked against, on every single schedule it returns, not buried in a comment you'd have to go find. A stale table is visible in the tool's actual output, not just in source you'd have to go read to catch it.

Ask for mid-quarter or mid-month convention (required when more than 40% of a year's MACRS additions land in the last quarter, or for real property) and the tool returns ok: false with an explanation, rather than silently applying the half-year table to a case it doesn't cover.

Deploying it, and the refusal

Deployment is the whole distribution story: click Deploy with Vercel, paste an AI Gateway API key, pick a username and password for AGENT_BASIC_AUTH_USER / AGENT_BASIC_AUTH_PASSWORD. No account, no signup, no hosted service in the middle. It runs in your own Vercel project, and nothing leaves that deployment except the model calls you're paying for: client data goes to your model provider and nowhere else, which is a real constraint if a client's data-handling terms don't permit that provider, and one this repo's code can't route around for you.

The thing it will not do, on purpose, is give tax advice. agent/instructions.md hard-forbids answering "should I deduct this": that's the client's accountant's call, not the agent's. Depreciation schedules, categorization suggestions, and ratio output are preparation and flagging, never a filing position. categorize_transactions never asserts a category as certain: every suggestion carries a confidence and what would confirm it, and a low-confidence suggestion needs a client answer before it's posted anywhere. The refusal isn't a disclaimer bolted onto the output; it's a rule the model is instructed to enforce on itself, the same way the code refuses to guess at an unsupported depreciation convention instead of approximating one.

Try it

The repo is MIT-licensed: github.com/FoundrySoftHQ/agent-for-accounting. If you want to see the fuzz test that caught the split-detection bug, it's "matching conserves every row exactly once" in tests/reconcile.test.ts. If your firm works differently, the four skill files in agent/skills/ are plain markdown: edit them directly, no code required. And if you build on top of it, docs/ARCHITECTURE.md covers exactly what a new tool or skill needs, including the money-in-cents rule and the reasoning behind every deliberate limitation the reconciliation matcher has. Fork it, break it, and if you find another case where absent data comes back looking like a clean answer, that's worth a pull request more than almost anything else you could send.

Available for new projects

Let's build something great.

Have a project in mind? We are an elite software and AI development studio ready to bring your ideas to production. Let's talk about your roadmap.

See our work