What Actually Happens When Bookkeeping Software

What Actually Happens When Bookkeeping Software "Uses AI" to Categorize Transactions

2 8
calendar_todayschedule8 min read

Most founders I talk to treat bookkeeping software as a black box. Transactions go in, categories come out, accountant reviews the mess once a quarter. The marketing copy says "AI-powered" and everyone nods and moves on.

I wanted to understand what is actually running under the hood. Because I build backend systems, and "AI" in a fintech context usually means one of three things: a rules engine someone dressed up in a press release, a lightweight classifier trained on labeled bank data, or an actual ML pipeline with feedback loops and confidence scoring. These are very different things to operate, and they have very different failure modes.

This is what I found.

The Input Problem Nobody Talks About

Before you can categorize anything, you need structured transaction data. In the Netherlands, PSD2 bank feeds are the standard mechanism. The bank exposes a regulated API, the bookkeeping software connects via OAuth, and transactions stream in automatically.

Here is the part that matters for model quality: the transaction descriptions from Dutch banks are inconsistently formatted.

ABN AMRO, ING, and Rabobank each have their own field conventions. A payment to the same supplier can arrive with a different description string depending on which bank processed it. Some banks include counterparty IBANs in a structured field. Others append them to a free-text description. Some truncate long merchant names. Others do not.

This is not a Dutch-specific quirk either. A 2025 paper on open banking transaction classification notes that inconsistent description formats across banks are one of the primary reasons generic text classifiers underperform in production, and that preprocessing and normalization account for a significant share of the engineering effort in any real deployment.

If you are building a categorization model and treating description as a clean text feature, you are going to have a bad time on around 15-25% of your inputs. The good implementations normalize this in a preprocessing layer before anything touches the model.

What a Real Classification Pipeline Looks Like

The naive approach is to train a text classifier on transaction descriptions. Something like:

from sklearn.pipeline import Pipeline
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
 
pipeline = Pipeline([
    ('tfidf', TfidfVectorizer(ngram_range=(1, 2), max_features=10_000)),
    ('clf', LogisticRegression(max_iter=1000, C=5.0))
])
 
pipeline.fit(X_train_descriptions, y_train_categories)

This works well enough for common cases. Adobe, Spotify, GitHub, AWS -- these are high-frequency, consistent descriptions that any text classifier will handle after seeing a few examples.

The accuracy ceiling with text alone is somewhere around 78-82% depending on your dataset. A recent study on SME bank transaction categorization found that TF-IDF-based methods trailed fine-tuned domain-specific models by more than 23 percentage points, specifically because short, abbreviated transaction descriptions do not carry enough semantic signal on their own. To push past the TF-IDF ceiling, you need to bring in additional features.

import pandas as pd
import numpy as np
 
def build_feature_vector(transaction: dict) -> np.ndarray:
    """
    Combine text features with numerical and temporal signals.
    The model sees more than just the description.
    """
    text_features = tfidf.transform([transaction['description']])
 
    amount_log = np.log1p(abs(transaction['amount']))
    is_round_amount = int(transaction['amount'] % 1 == 0)
    day_of_week = transaction['date'].weekday()
    is_recurring = check_recurrence(transaction['iban'], transaction['amount'])
 
    numerical_features = np.array([[
        amount_log,
        is_round_amount,
        day_of_week / 6,
        int(is_recurring)
    ]])
 
    return np.hstack([
        text_features.toarray(),
        numerical_features
    ])

Amount and recurrence are particularly powerful signals. A payment of exactly 29.99 euros arriving on the same date each month from the same IBAN is almost certainly a SaaS subscription regardless of what the description says. The recurrence check adds a feature the text model cannot derive on its own.

Confidence Scoring and the Review Queue

The most important architectural decision in a production categorization system is not the model itself. It is the confidence threshold and what happens below it.

A softmax output gives you class probabilities. The question is what to do when the top prediction is, say, 73% confident. Is that good enough to auto-apply? Or should it go to a human?

def route_transaction(transaction, model, threshold=0.85):
    probs = model.predict_proba([build_feature_vector(transaction)])[0]
    top_class = np.argmax(probs)
    top_confidence = probs[top_class]
 
    if top_confidence >= threshold:
        return {
            'action': 'auto_categorize',
            'category': label_encoder.inverse_transform([top_class])[0],
            'confidence': top_confidence
        }
    else:
        # Send to human review queue with ranked suggestions
        top_k = np.argsort(probs)[-3:][::-1]
        return {
            'action': 'review',
            'suggestions': [
                {
                    'category': label_encoder.inverse_transform([i])[0],
                    'confidence': probs[i]
                }
                for i in top_k
            ]
        }

The threshold is a business decision with real consequences. Set it too high and you push too much to the review queue, which eliminates the efficiency gain. Set it too low and you auto-categorize transactions that should have had a human look at them, which creates compliance risk in a Dutch bookkeeping context where BTW codes directly affect quarterly VAT filings.

Most established platforms settle somewhere between 80% and 92% depending on the category. Subscription software can auto-apply at lower confidence than, say, restaurant expenses, where BTW reclaimability and mixed-use deductibility rules make errors genuinely expensive.

The Dutch-Specific Problem: BTW Codes

Generic ML classification gives you a ledger account. Dutch bookkeeping requires a second decision: which BTW treatment applies.

This is where a lot of international bookkeeping software falls down. A UK-trained model might correctly categorize a meal expense as "entertainment" without knowing that Dutch restaurant BTW is generally not reclaimable, and that the VPB deductibility of the same expense depends on circumstances the model cannot observe.

The cleanest solution I have seen is a two-stage architecture:

  1. Stage 1 classifies into a ledger account (the ML model)
  2. Stage 2 maps ledger account to BTW treatment using a rule engine that encodes Dutch tax logic
    This separates the learned classification problem from the deterministic compliance problem. The ML model does not need to understand BTW. It just needs to get the account right. The rule engine handles the rest.
BTW_RULES = {
    'software_subscriptions': {'btw_code': 'hoog', 'rate': 0.21, 'reclaimable': True},
    'restaurant_expenses':    {'btw_code': 'hoog', 'rate': 0.21, 'reclaimable': False},
    'office_supplies':        {'btw_code': 'hoog', 'rate': 0.21, 'reclaimable': True},
    'public_transport':       {'btw_code': 'laag', 'rate': 0.09, 'reclaimable': True},
    'medical_expenses':       {'btw_code': 'vrijgesteld', 'rate': 0.00, 'reclaimable': False},
}
 
def apply_btw_rules(ledger_account: str) -> dict:
    rule = BTW_RULES.get(ledger_account)
    if rule is None:
        return {'action': 'manual_review', 'reason': 'No BTW rule defined'}
    return rule

The compliance logic stays explicit, auditable, and easy to update when tax law changes. The learning stays in the model where it belongs.

The Feedback Loop Is the Product

The ML model matters less than people think. The feedback loop is what separates a system that stays accurate from one that decays.

Every time a user corrects a categorization, that correction needs to become training data. The challenge is that online learning introduces instability. You cannot just fine-tune on individual corrections without risking catastrophic forgetting -- the tendency of neural networks to overwrite previously learned patterns when trained sequentially on new data.

The better implementations batch corrections and retrain periodically. Weekly or bi-weekly retraining cycles are common. Some platforms use human corrections to build category-specific confidence thresholds over time: if corrections cluster around a particular supplier, the system learns to hold that supplier for review rather than auto-applying.

This feedback loop is also why the first 90 days of any new administration have lower accuracy. The model has not seen enough of your specific transaction patterns. If you are setting up a new bookkeeping system for a Dutch BV, consistent early corrections pay compound returns later. The platforms that handle this well -- and this is covered in solid detail in Neno's breakdown of automated categorisation -- typically see auto-categorization rates climb from around 50% in the first quarter to 85-90% after 18 months of transaction history.

What Still Needs a Human

The genuinely hard cases for any ML categorization system are not the unfamiliar merchants. Those are easy: they fall below the confidence threshold and go to review. The hard cases are the ones where the model is confidently wrong.

In a Dutch context, the most common failure patterns I have seen are:

Mixed-use transactions. A meal that is partly business, partly personal. The model categorizes it as a business expense because the description is a restaurant name. The BTW and VPB treatment depend on context the transaction record does not contain.

Asset purchases. A one-time payment of 1,800 euros to a known supplier could be an equipment purchase requiring depreciation treatment, or it could be a large service invoice. The amount and counterparty alone are ambiguous.

Intercompany transactions. These look like any other bank transfer to a model that does not know your corporate structure.

The practical solution is not to try to solve these with ML. Route them to human review based on category type, regardless of confidence score. The efficiency gain comes from the 80%+ of transactions that are genuinely routine. Protecting the judgment-intensive cases from automation is what keeps the system compliant.

The Honest Performance Picture

After digging through the architecture, here is what realistic accuracy looks like:

Stage Auto-Categorization Rate Main Bottleneck
New administration (0-3 months) 40-60% No transaction history
Early stage (3-6 months) 60-75% Limited supplier patterns
Established (6-18 months) 75-88% New and irregular suppliers
Mature (18+ months) 85-92% Edge cases only

These numbers assume a Dutch BV with consistent transaction volume and a user who actually corrects mistakes. A user who ignores the review queue does not generate training data, and the model does not improve.

The ceiling for any individual business is also constrained by category diversity. A consulting BV with 10 recurring suppliers and predictable SaaS subscriptions will hit 90%+ faster than a business with constantly changing vendors and irregular expenses.

The Broader Pattern

What makes this interesting from a systems perspective is that bookkeeping software is a rare case where the user-facing product and the model training pipeline are deeply coupled. Every correction is a data point. Every data point changes future behavior. The interface design decisions -- what to show in the review queue, how to present suggestions, whether to require explicit confirmation -- directly affect model quality over time.

Most ML pipelines are decoupled from their users. You train on a dataset, ship a model, and retrain later. Transaction categorization done well is a continuously learning system where the users are unwitting annotators. That changes the product design problem significantly.

The best implementations understand this and design for it. The weakest ones ship a static classifier and wonder why accuracy degrades as the user's business evolves.


The mental model that holds up: ML categorization is not a feature you ship. It is a feedback loop you maintain. The model quality at month 18 is a function of the correction UX at month 1.


Part of an ongoing series on AI systems that actually work in production.

Part 2 of 3 in WebDev Related

1 Comment

0 votes
🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

Systems Thinking: Thriving in the Third Golden Age of Software

Tom Smithverified - Apr 15

The Sovereign Vault — A Comprehensive Guide to Protocol-Driven AI

Ken W. Algerverified - Jun 4

Split-Brain: Analyst-Grade Reasoning Without Raw Transactions on the Server

Pocket Portfolio - Apr 8

Corporate Amnesia: What Happens When Your Team Forgets How Its Own Code Works

Gavin Cettolo - May 14

Dog CT Scan Cost: What Pet Parents Need to Know

Huifer - Feb 6
chevron_left
207 Points10 Badges
Amsterdamneno.co
3Posts
0Comments
Backend and AI engineer building systems that actually survive production. I write about LLM architecture.

Related Jobs

View all jobs →

Commenters (This Week)

3 comments

Contribute meaningful comments to climb the leaderboard and earn badges!