Accounting and ERP software is getting large language models (LLMs) that read invoices, suggest accounts, and post entries. This has strong implications for the open source libraries that drive accounting and ERP systems. Will they reject the LLMs outright? What kinds of errors can these LLMs put into a business’s books? How do the systems handle those errors today? And what implications does this have for people building agents for enterprise?

Double entry checks are necessary but not sufficient — they don’t catch all the errors an LLM can make with money.

First, a short primer: what is double entry bookkeeping? This is the standard nearly every business uses to keep its books. Surviving ledgers show basic double-entry methods appearing in Italy around the late 1200s, though the origin could have predated the 13th century. Every transaction is recorded twice, as a debit in one account and an equal credit in another: a purchase of stock is a debit to inventory and a credit to cash. The total of all debits therefore always equals the total of all credits, and the trial balance is the report that adds them up and confirms it. That equality is the ledger’s one built-in check.

Some errors that fall beyond double entry checks are as follows. An error of omission is a transaction nobody recorded. An error of commission is the right amount in the wrong account of the same kind, such as a payment posted to the wrong vendor. An error of principle is the right amount in the wrong kind of account, such as an asset booked as an expense. An error of original entry is the wrong amount on both sides. A compensating error is two mistakes that cancel. A complete reversal swaps the debit and the credit. After each one, debits still equal credits. That is why the profession built controls that live outside the ledger: reconciliation against the bank, the match of an invoice to an order and a receipt, change control on vendor bank details, and a cutoff at period end. The ledger’s own rule can’t see any of the six.

A model that reads an invoice and posts a payment can produce the whole aforementioned list of errors. It can name the recipient instead of the sender, which is commission at the party level. It can record a payment that never arrived, which is a fictitious entry, the mirror of an omission. It can retry after a timeout and pay twice, which is an error of original entry made twice over. It reads 6.000000 as 6.00, and the ledger then hides the gap with a compensating entry. Each time, the double-entry check passes.

What follows is a walk through the source code of Odoo, ERPNext, GnuCash, beancount, Firefly III, Invoice Ninja, Kill Bill, SolidInvoice, and Ghostfolio, and the tools surrounding them. They split into two chief groups on this list. In some systems the document is the ledger entry. Those systems can enforce the first control on the list, no entry without a document, and they carry the matching and change-control machinery, usually switched off. In the others, the ledger is a running balance beside the invoice, and that control can’t be enforced at all. Where each codebase lets a model in follows the same split.

The Landscape · What a Transaction Is · What Reconciliation Proves · Repair · Where the Model Sits · What This Means for Builders

The Open Source Accounting Landscape

Several key libraries make up the open source accounting stack for modern businesses.

System What it is What a transaction is Check against the world Where a model can write Notable
Odoo ERP in Python. Accounting is the account addon. The invoice is the journal entry. Unbalanced moves are refused, closed periods are locked, and posted entries are hash-linked. Bank statement lines are journal entries, reconciled row by row. Changes to a vendor’s bank account are logged. Only through a community MCP server outside Odoo, which blocks direct create, write, and delete. The document and the entry are one object.
ERPNext ERP on the Frappe framework, in Python. GL entries can’t be created directly. They are a side effect of a submitted voucher, and cancellation writes mirror entries. The three-way match exists as two settings that default to No. Imbalances up to half a unit are booked to a Round Off account. frappe/mcp turns app functions into tools that run inside the framework’s own validation. Repairs an unbalanced entry silently, into a normal expense account.
GnuCash Desktop accounting application in C and C++, and the oldest of these. Each split carries a value in the transaction currency and an amount in the account commodity. Commodity fractions stop at a billionth. An unbalanced transaction is posted to a visible Imbalance account. None in the codebase. The only repair a person is guaranteed to see.
beancount Plain-text double-entry ledger in Python. Text, validated on load. A Balance directive asserts an account balance with a declared tolerance. The user’s own balance assertions, typed in from statements. A separate package, smart_importer, predicts accounts at import. The next assertion catches a wrong guess. Precision is a declared property of the claim, not of the column.
Firefly III Personal finance manager in PHP. A journal is one negative and one positive transaction. There is no chart of accounts, and a rule can delete a journal. Bank feeds live in a separate importer application. Refused outright. The FAQ calls it impossible to do reliably and accurately. Calls itself double entry and lets a rule delete an entry.
Invoice Ninja Invoicing and payments in PHP, with 25 payment gateway drivers. A running balance per client, appended by the invoice and payment services. One payment can apply across many invoices. Nothing checks the client ledger against a bank. None in the codebase. Marked a €0.10 subscription paid with no payment, from one integer cast.
Kill Bill Subscription billing engine in Java. A committed invoice can’t be changed. Every correction is a new adjustment item of a named kind. Payment state comes from gateway plugins. There is nothing behind the invoice to match against. None in the codebase. Immutability by construction, with typed adjustments.
SolidInvoice Invoicing in PHP on Symfony, with a workflow state machine. A document ledger. The invoice table is the books, and the state machine governs every transition. None. There is no statement import and no bank feed. The most complete write path of any system here. Payments and workflow transitions are MCP tools, each checked by the state machine. Can record a payment that never happened and has no way to find out.
Ghostfolio Portfolio tracker in TypeScript. A list of buy and sell activities, each with quantity, unit price, and fee stored as floats, valued at market. Market prices. Imports return IS_DUPLICATE per row. One write tool, which imports activities through the same service the UI uses, with a dry run. The only write path that returns an idempotency signal.

What a Transaction Is

What a codebase can catch depends on what it thinks a transaction is. The aforementioned codebases give a variety of distinct answers.

The Document Is the Entry (Odoo and ERPNext)

In Odoo there is no invoice separate from its journal entry. The class is account.move. The field move_type says whether a move is a customer invoice, a vendor bill, a credit note, or a bare journal entry. The invoice lines are a filtered view of the accounting lines (odoo/addons/account/models/account_move.py:365):

invoice_line_ids = fields.One2many(  # /!\ invoice_line_ids is just a subset of line_ids.

To post an invoice is to book it. A vendor bill can’t exist in the books without its accounting lines, and accounting lines that describe a vendor bill can’t exist without the bill. That is what lets Odoo refuse an unbalanced move (_check_balanced, line 2764), refuse writes into a closed period (_check_fiscal_lock_dates, line 2805), and chain posted moves together (inalterable_hash and secure_sequence_number, lines 353 and 354). These checks protect the document and (by extension) the company record.

ERPNext arrives at the same place from the other side. The GL Entry DocType is declared is_submittable: 1 and in_create: 1, so nobody can create one directly. Entries appear as a side effect of submitting a voucher: a Sales Invoice, a Payment Entry, or a Journal Entry. Cancellation writes mirror entries flagged is_cancelled (erpnext/accounts/general_ledger.py:607). Amendment is a series, because a document can carry amended_from only if the original has docstatus 2, which means cancelled (frappe/model/document.py:873). The voucher is primary. The ledger is derived and never edited.

Both systems can therefore ask a question the others can’t: which document is this entry for? And both carry the controls that answer it. ERPNext Buying Settings has two switches, “Is Purchase Order required for Purchase Invoice & Receipt creation?” and “Is Purchase Receipt required for Purchase Invoice creation?” (po_required and pr_required in buying_settings.json), and both default to No. Every Purchase Invoice line carries purchase_order, po_detail, purchase_receipt, pr_detail, received_qty, and rejected_qty (purchase_invoice_item.json). That is the three-way match, the profession’s defense against fictitious and altered invoices, sitting in the schema as an option. Odoo logs every change to a vendor’s acc_number, acc_holder_name, and partner_id in the chatter (odoo/addons/account/models/res_partner_bank.py:37), which is change control on the vendor master, the defense against payee substitution. Neither control is about arithmetic. Both ask whether the entry corresponds to something real.

A Claim with a Tolerance (beancount)

A beancount transaction is text, and its guard is not a constraint but an assertion the user writes. The Balance directive (beancount/core/data.py:177) declares that an account “should have a known number of units of a particular currency.” It carries two fields the relational ledgers lack: tolerance, “the amount of tolerance to use in the verification,” and diff_amount, “None if the balance check succeeds.” Beancount infers the tolerance from how precisely the user writes numbers. The options documentation says the display precision “is normally inferred from statistics derived from the population of numbers seen in the ledger” (beancount/parser/options.py:459). A ledger written to the cent tolerates half a cent. A ledger written to six places tolerates half a millionth.

No other ledger here makes precision a declared property of the claim rather than a hidden property of the column. It is also why machine learning at the write boundary has been unremarkable here for years. beancount/smart_importer predicts accounts for imported rows, and the next balance assertion catches a wrong prediction, against a statement the user typed in, with a tolerance the user chose.

Two Numbers per Split (GnuCash)

A GnuCash split carries a value in the transaction’s currency and an amount in the account’s commodity (libgnucash/engine/Split.h), so every multicurrency transaction is two ledgers at once. Balance is checked on values. Amounts can be whatever the commodity’s fraction allows, and the engine caps that: “Max fraction is 10^9 because 10^10 would require changing it to an int64_t” (gnc-commodity.h:113). A commodity can be divided to a billionth and no further. When values don’t balance, xaccTransScrubImbalance posts the difference to an account named Imbalance-CUR instead of refusing.

A Signed Pair, or a Running Balance (Firefly III, Invoice Ninja, and Kill Bill)

A Firefly journal is one negative transaction on a source account and one positive on a destination (app/Factory/TransactionFactory.php:52 and 69, Steam::negative and Steam::positive). Firefly calls this double entry, and in one sense it is: money leaves one account and arrives in another. But there is no chart of accounts in the accounting sense, and a rule can delete a journal (app/TransactionRules/Actions/DeleteTransaction.php). The Invoice Ninja Paymentable pivot applies one payment across many invoices, with an amount and a refunded per pair (app/Models/Paymentable.php), and the CompanyLedger records the running result. Kill Bill goes the other way. A COMMITTED invoice can’t be changed (DefaultInvoiceUserApi.java:591 throws INVOICE_ALREADY_COMMITTED), and every later correction is a new item of a named kind: ITEM_ADJ, CREDIT_ADJ, or REPAIR_ADJ.

These are ledgers of obligations, invoices and the payments against them, and they answer “what is owed” well. They can’t ask which document an entry is for, because the entry is the document. There is nothing behind it to match.

What Reconciliation Proves

Every accounting system has one core check against the world: reconciliation against a bank. This is critical in ensuring integrity after any kind of integration.

In Odoo, a bank statement line is a journal entry. account.bank.statement.line declares _inherits = {'account.move': 'move_id'} (account_bank_statement_line.py:13): the bank’s record of a movement is imported as one of your own moves on the bank journal. Reconciliation is then account.partial.reconcile, a row linking a debit_move_id to a credit_move_id with an amount (account_partial_reconcile.py:15 to 48). What gets matched is your copy of the bank’s entry against your own entry. The check has force because the bank’s ledger was written by someone else, with the opposite sign, and the bank gains nothing by agreeing with you.

Reconciliation catches omission, where the bank shows a payment you never booked. It catches fictitious entries, where you booked a payment the bank never made, which is the Invoice Ninja case, and it would surface at month end. It catches errors of original entry on amount. It does not catch commission. A payment to the wrong vendor’s account reconciles perfectly, because the bank confirms that you paid exactly whom you told it to pay.

A shared onchain ledger changes this in a way that is easy to miss. On a chain, payer and payee read the same record. Reconciling your ledger against it proves that you did what you recorded, and nobody else is in the loop, because the counterparty’s ledger is your ledger. So the chain confirms your own record, but it isn’t an independent witness.

The controls that would catch commission were never reconciliation anyway: they are the three-way match and change control on the vendor master. The first asks whether an invoice corresponds to an order and a receipt. The second asks whether the account you are about to pay is the account you paid last time. The first exists as two ERPNext switches that default to No. The second exists as chatter tracking in Odoo. The OCA invoice importer has the opposite: a company setting that, when on, creates a vendor bank account from an IBAN read off a PDF (edi/account_invoice_import/wizard/account_invoice_import.py:346, create_if_not_found=company.invoice_import_create_bank_account). Unfortunately, the invoicing apps have neither.

Repair

Instead of refusing an unbalanced entry, some ledgers listed above fix it retroactively — which can be a useful approach to analyze.

The ERPNext function process_debit_credit_difference (erpnext/accounts/general_ledger.py:397) lets a Journal Entry or Payment Entry be out of balance by five units at the last decimal place. Any other voucher, including a Sales Invoice, can be out by half a unit of currency. Within the allowance it posts the difference to the company’s Round Off account and the entry balances. Beyond it, the message is “Debit and Credit not equal for {0} #{1}. Difference is {2}.” Now add a two-decimal currency, an ORM that casts every decimal column to float on read (frappe/database/postgres/database.py:38, DEC2FLOAT), and six-decimal settlement amounts. Every posting rounds, and the residual becomes a rounding expense. A thousand postings later, the Round Off account holds a thousand small entries whose only job is to make the trial balance close.

The GnuCash version is visible: Imbalance-USD appears in the account tree, and a person notices it on the next screen. The ERPNext version is not, because the Round Off account is a normal expense account with a normal balance.

The beancount tolerance is the explicit form of the same idea. It says, in the ledger, how much disagreement is acceptable, and fails the assertion when that is exceeded. Repair says nothing and passes. For a human bookkeeper, repair is a convenience, and the Round Off account gets reviewed at close. For an automated writer that never reviews anything, repair is how an error of original entry becomes a permanent compensating one.

Where the Model Sits

Seen this way, where each project lets an agent write is a consequence of its ledger, not a separate design choice. None of these systems puts a model inside the posting logic. One refuses it altogether, and the others admit it at the edges, where a document is read or an import is matched, or through a tool layer that runs the same validation as a person.

SolidInvoice, a document ledger with a Symfony state machine and no accounting behind it, has the most comprehensive agent write path we have seen. The record_payment tool (src/PaymentBundle/Mcp/PaymentWriteTools.php:61) requires a positive amount, the invoice’s currency, no overpayment, a workflow that allows pay, and a configured offline payment method, and then moves the invoice to paid. The apply_invoice_transition tool asks the workflow can() before apply() (src/InvoiceBundle/Mcp/InvoiceWriteTools.php:205). The state machine does all the work, and the state machine can’t know whether money arrived, because nothing in SolidInvoice can. There is no statement import and no bank feed. Nothing inside SolidInvoice can disprove a phantom payment.

Frappe went the other way and published frappe/mcp, a library that turns functions inside a Frappe app into Model Context Protocol (MCP) tools. A tool written that way runs inside the Frappe process, so a Sales Invoice it creates goes through the same validation, permissions, and docstatus lifecycle as one a person submits. The agent path adds nothing of its own. The guards are the ones the voucher model already had. The Odoo community server erpipe-org/mcp-odoo drives Odoo over its RPC interface with the user’s own rights. Outside Odoo, it adds what Odoo lacks for agents: “Direct create, write, and unlink are blocked; approved writes require live metadata, a same-session token, explicit confirmation, and an env gate.” The one write tool in Ghostfolio imports activities through the same service the UI uses. That service returns IS_DUPLICATE per row and supports a dry run (apps/api/src/app/import/import.service.ts:136 and 175).

The clearest case is the escrow sample Circle publishes. It holds a contractor’s payment in RefundProtocol.sol until the work is verified, and it verifies the work by sending an uploaded image to GPT-4o (arc-escrow/app/api/contracts/validate-work/route.ts:143). The release condition is two fields of the model’s JSON:

const isValid = parsedPromptAnswerContent.valid &&
                parsedPromptAnswerContent.confidence === "HIGH";
...
await circleDeveloperSdk.createContractExecutionTransaction({
  abiFunctionSignature: "withdraw(uint256[])",

The contract’s withdraw function (RefundProtocol.sol:199) checks that the caller is the payee, that the payment wasn’t refunded, and that the balance covers it. The releaseTimestamp stored on every payment is never read. The prompt tells the model which requirements to skip: “you can completely disregard any requirement below as long as it does not directly references qualities of the image being validated, for example, things that involve actions that need to be taken by one of the parties, or legal obligations mentioned as requirements.” The escrow’s own integrity holds: funds move only to the payee, only once, and only up to the balance. Whether the work was actually done is left to a three-value string from the model.

Firefly III refused the category outright. From the project FAQ: “Due to the hallucinatory nature of large language models it’s absolutely impossible to get this to work reliably and accurately. Which are the two things I want Firefly III to be.”

What This Means for Agent Builders

These insights have several consequences for agent builders exploring the edges of accounting, ERP, and LLMs:

  • Start from a ledger where the entry points at a document. If the document is the entry, the system can ask which document an entry is for, and the match and change-control checks have a place in the schema. If the ledger is a running balance, those checks have nowhere to attach, and a write tool adds risk with nothing to catch it.
  • Turn on the controls that already exist. The three-way match is two settings in ERPNext that default to No. Vendor bank change tracking is on by default in Odoo. Neither needs to be invented for agents. An agent’s checks should compare an entry to an order, a receipt, and the last account paid, not to the other side of the same entry.
  • Put a witness in front of every write path. A bank feed or statement import turns a phantom payment from something nobody can disprove into something that doesn’t reconcile. It is also worth noting that SolidInvoice and its peers can add a feed far more easily than a chart of accounts. A chain is a shared record, so reconciling against it proves consistency and not independence. Neither catches a wrong payee. No comparison of two ledgers can, which is why the document-side checks have to exist as well.
  • Make repair loud, or make it refuse. The GnuCash Imbalance account and the beancount tolerance both show the disagreement. The ERPNext half-unit allowance hides it in a normal expense account, and it was built for a reviewer who closes the month. The first system that posts machine-generated settlement into ERPNext at six decimals files that bug, and the fix is a setting that refuses instead of rounds.
  • Route agent writes through the code the UI already uses. Frappe and Ghostfolio do this, so the agent inherits validation, permissions, and lifecycle instead of a parallel set of checks. Return an idempotency signal per row and support a dry run, as the Ghostfolio import does.
  • Keep the model’s output as an input, never as the release condition. The Circle escrow releases funds on a confidence string and never reads the release timestamp it stores. Paperless-ngx, the document manager, keeps the correspondent its model predicts as a suggestion that a person confirms. The second design is the one to copy.

Double entry proves that two columns agree with each other, and every error on our list unfortunately passes that test. The controls that catch those errors compare an entry to something outside the ledger: a document, a receipt, a bank statement, the account paid last time. Those controls already exist in these systems (mostly as settings nobody turned on), and they are what an automated bookkeeper has to be measured against. A builder who turns them on, puts a witness in front of the write path, and treats the model’s answer as a suggestion can let software post to the ledger with heightened safety.