Skip to content

Categorizing Bank Statement Transactions Into a Chart of Accounts

9 min read · updated August 11, 2026

Extraction and classification look like one job and behave like two. One is checkable arithmetic that runs once per statement; the other is a judgement that has to be re-runnable every time the ledger changes. Building them as one pipeline is what makes the whole thing brittle.

Why these are two stages

Extraction produces the transaction: date, description as printed, signed amount, running balance. It is verifiable against the balance identity, it depends only on the document, and once it is right it is right forever.

Classification produces an account code for that transaction. It has no internal check at all — nothing on the statement says whether a payment to a hardware store was tools, repairs or inventory — and it depends on things outside the document: the customer’s chart of accounts, their conventions, their history. Those change. A new account is added, an old one is merged, a bookkeeper decides that a category was being used wrongly, and every past transaction needs reclassifying.

So keep the extracted transaction immutable, and make classification a derived table with a foreign key to it, stamped with the model, the prompt version and the chart version that produced it. Then a reclassification run touches only the derived table, you can compare two classification versions over the same transactions, and a bad prompt release is revertible without going near the source documents. The alternative — one pass that writes a category onto the transaction row — means every chart change is a re-extraction, which is both expensive and a fresh chance to get the amounts wrong.

A chart of accounts is not a taxonomy

A model asked to categorise a transaction will return something like “Meals & Entertainment” or “Software Subscriptions”, because those are the categories that appear in its training data. The business you are working for has an account called 6420 Client meals and another called 6425 Staff meals, and the difference between them is a policy only they know.

Numbering conventions do not help as much as they appear to. Ranges like 1000s for assets, 2000s for liabilities, 4000s for revenue and 6000s for expenses are widespread convention, not a standard, and plenty of real charts do not follow them. You cannot infer meaning from a code; you have to be given the account list.

Which means the prompt is constructed at runtime from the customer’s own accounts, and the output is constrained to that set. Give the model the code, the name and — this is the part that carries most of the accuracy — a handful of the customer’s own previously classified descriptions per account. Their history is a far better description of what an account means than any prose definition, and it makes the classifier specific to them without any training.

Building it

  1. Extract and validate. Balance identity first. Do not classify a statement that does not foot; see extracting opening and closing balances.
  2. Normalise the descriptor with the deterministic rules from extracting recurring transactions, keeping the raw string. The normalised key is what you match against history.
  3. Try the memory first. If this exact normalised key was classified before and confirmed by a human, reuse that account and skip the model entirely. In a mature ledger this handles the large majority of lines, costs nothing, and is perfectly consistent — which matters more to a bookkeeper than being clever.
  4. Classify the remainder against the account enum, with the valid codes inlined and the output constrained to them.
  5. Detect transfers before posting anything — see below. A transfer misclassified as an expense corrupts two accounts at once.
  6. Auto-post above the per-account threshold, queue the rest with the top two candidate accounts and the reason, so the human is choosing rather than starting from nothing.

Transfers, refunds and the double count

Money moving between two accounts the business owns is not income and not expenditure, and it appears twice: as a debit on one statement and a credit on the other. Classified naively it inflates expenses on one side and revenue on the other, and both figures look plausible in isolation. Detect it structurally rather than from the description: matching absolute amount, opposite sign, dates within a couple of days, and both accounts in the set you hold. Then post it as a transfer pair and exclude it from the profit and loss.

  • Refunds and chargebacks are credits at a merchant that normally produces debits. They belong to the original expense account as a negative, not to a revenue account, and a classifier working from the description alone will get this wrong because the description is identical to the purchase.
  • Credit card payments are a transfer to a liability account, not an expense — the expenses are the individual charges on the card statement. Booking both is the most common double-count in small-business bookkeeping.
  • Owner’s draw and capital introduced are equity movements that look exactly like a personal transfer. They usually need a rule rather than a classifier.
  • Net payroll arrives as one debit covering wages, withheld taxes and deductions, which split across several accounts in proportions the statement does not contain. That split comes from the payroll register, not from the bank — see extracting gross pay, deductions and net pay.
  • Sales tax collected is a liability, not revenue, and a deposit from a payment processor is usually net of fees, so one credit represents gross sales minus fees and must be split.

Where the auto-post threshold goes

A single global confidence threshold is the wrong shape, because the cost of an error is wildly uneven across the chart. Misfiling a stationery purchase between two office expense accounts is a rounding error nobody will ever notice. Misfiling something into owner’s draw, a tax-relevant account, or a capital account is a mistake that reaches a tax return.

So set the threshold per account, and set it from consequence rather than from measured accuracy: high-consequence accounts require review regardless of score, low-consequence accounts auto-post freely, and anything the model has never seen for this customer goes to a human the first time and to memory thereafter. The general machinery for scoring, sampling and queueing is covered in per-field extraction confidence and does not need rebuilding here; what is specific to a ledger is that the threshold belongs to the destination account, not to the classifier.