Process automation
Bankreconciliatie automatiseren: 22 uur naar 90 minuten
Een boekhoudkantoor in Den Bosch verloor 22 uur per maand aan bankreconciliatie. Eén OCR-worker en een Postgres-ledger brachten het terug naar 90 minuten.

De tl-buizen brandden nog op een vrijdagavond in maart, half elf, bij een boekhoudkantoor met vier mensen in Den Bosch. Hun seniorboekhouder, die we hier Maaike noemen, zat op rij 814 van een spreadsheet die elke banklijn van één van hun grotere mkb-klanten ophaalde. Ze was er sinds na de lunch mee bezig. De klus was de maandelijkse reconciliatie: elke regel op het bankafschrift koppelen aan een factuur, een bonnetje, een salarisbatch of een memo. Het matchpercentage bleef op een goede maand rond de 70 procent hangen. De andere 30 procent betekende doorklikken naar Twinfield, de bijbehorende PDF openen in een tweede venster, en oordelen.
Het kantoor vertelde ons dat ze hier 22 uur per klant per maand aan kwijt waren, over hun hele klantportefeuille. Ze hadden elf klanten op een maandelijks ritme. Dat is 242 uur per maand aan vakbekwaam werk besteed aan wat, eerlijk gezegd, een dure opzoekactie is.
Toen ze ons om hulp vroegen, stelden we geen 'AI-boekhouder' voor, of iets in die richting. We bouwden twee kleine dingen: een OCR-worker die binnenkomende factuur-PDF's leest, en een Postgres-ledger die het matchen doet. Drie maanden later zit het reconciliatieblok per klant gemiddeld op 90 minuten. Ze hebben nog geen enkele match verloren aan een hallucinatie, omdat niets in de boekingspipeline hallucineert.
Zo hebben we het gebouwd.
Waar die 22 uur uit bestonden
Voordat er één regel code geschreven werd, zaten we twee middagen naast Maaike. Die 22 uur was niet één taak. Het waren zes taken op elkaar gestapeld, geen ervan op zichzelf pijnlijk:
- Het bankafschrift importeren uit het klantportaal als CAMT.053 XML-bestand, of voor één koppige Rabobank-export als CSV.
- Leveranciersfacturen importeren vanuit een gedeelde inbox.
- Het bedrag, de datum en het IBAN van elke PDF aflezen (met het oog).
- Opzoeken bij welke banklijn die factuur hoort.
- De match boeken in Twinfield met de juiste grootboekcode.
- De regels die niet matchen markeren zodat de klant ze kan toelichten.
Stap 3 en 4 vraten samen bijna twee derde van de uren. Stap 6, het nalopen van de restjes, vrat het grootste deel van de overgebleven derde. Stap 5, het stuk dat daadwerkelijk om een boekhouder vraagt, kostte minder dan 20 procent van de tijd. Het vakwerk werd verdrongen door het opzoeken.
De architectuur, op één pagina
We kwamen uit op een stack die klein genoeg is om in één alinea te passen. Een Python-worker bewaakt een IMAP-inbox en een SFTP-map. PDF's gaan naar OCR; bankexports worden native geparsed. Alles schrijft naar één Postgres-database met drie kerntabellen: bank_lines, documents en matches. Een nachtelijke job draait de matcher. Een kleine webweergave, geserveerd door FastAPI, toont de niet-gematchte regels en laat Maaike ze in één klik oplossen. Twinfield krijgt de geboekte regels eenmaal per dag via de SOAP-API.
Geen queue broker. Geen vector database. Geen taalmodel in de hot path. Postgres doet het matchen met pg_trgm en een paar generated columns. Het hele ding draait op een 4 vCPU-bak bij Hetzner die minder dan €30 per maand kost.
De OCR-worker
De eerste reflex, vanuit iedereen die dit jaar één AI-nieuwsbrief heeft gelezen, is om elke factuur door een vision model te halen en het netjes om JSON te vragen. Dat hebben we een week geprobeerd. Het werkte in 92 procent van de gevallen. De 8 procent die faalde, faalde op interessante en stille manieren: een omgewisseld cijfer in een bedrag, een factuurdatum die als vervaldatum werd gelezen, een leveranciersnaam die uit het briefhoofd kwam in plaats van de juridische entiteit onderaan.
Voor boekhouden is stille 8 procent onbruikbaar.
Dus gebruikten we Tesseract 5, met de Nederlandse en Engelse taalpacks, achter een layout-bewuste pre-processor. De meeste Nederlandse leveranciersfacturen volgen een handjevol layouts, en de velden waar wij om geven (totaal, BTW-nummer, IBAN, factuurnummer, factuurdatum) staan op voorspelbare posities. We schreven een kleine layout-matcher die, bij de eerste aanblik van een nieuwe leverancier, Maaike eenmalig vier bounding boxes laat tekenen. De volgende factuur van die leverancier wordt in 200 milliseconden gelezen, zonder ambiguïteit. Na een maand draaien stond het kantoor op 47 leverancierslayouts in de database en lag het auto-percentage op binnenkomende facturen op 96 procent.
We roepen wel een vision model aan, maar alleen als fallback wanneer de layout-matcher zich niet wil committeren, en alleen om veldposities voor handmatige review voor te stellen. Het model boekt nooit zelf iets. De Tesseract-documentatie houdt zich in 2026 nog prima staande als je hem schone input geeft; het meeste van ons werk zat in de pre-processor (deskew, binarize, watermerk eruit, grootste tabel op de pagina isoleren).
def extract_fields(pdf_path: Path, supplier_id: int | None) -> InvoiceFields:
image = render_first_page(pdf_path, dpi=300)
image = deskew(image)
image = binarize(image)
if supplier_id and (layout := load_layout(supplier_id)):
return read_with_layout(image, layout)
# Fallback: queue for human review with vision-model suggestions
suggestion = suggest_field_positions(image)
enqueue_for_review(pdf_path, suggestion)
raise NeedsReview(pdf_path)
De Postgres-ledger
De matching engine is het onderdeel dat de meeste teams te ingewikkeld maken. Wij in het begin ook.
Elke banklijn heeft een bedrag, een datum, een tegenpartij-IBAN en een vrije omschrijving. Elk document (een factuur, een bonnetje, een salarisbatch) heeft dezelfde vier dingen, plus een factuurnummer. De match is grotendeels triviaal:
CREATE TABLE bank_lines (
id bigserial PRIMARY KEY,
client_id int NOT NULL,
booked_on date NOT NULL,
amount_cents bigint NOT NULL,
counter_iban text,
description text,
description_norm text GENERATED ALWAYS AS
(lower(regexp_replace(description, '[^a-z0-9 ]', ' ', 'gi'))) STORED
);
CREATE INDEX idx_bank_amount_date ON bank_lines (client_id, amount_cents, booked_on);
CREATE INDEX idx_bank_desc_trgm ON bank_lines USING gin (description_norm gin_trgm_ops);
We gebruiken pg_trgm voor fuzzy description matching, dat al ruim tien jaar in Postgres zit en hier ruim voldoende is. De matcher draait in drie passes:
- Exact: zelfde bedrag, zelfde IBAN, datum binnen plus of min 3 werkdagen. Hier matcht ongeveer 71 procent van de regels.
- Sterk fuzzy: zelfde bedrag, geen IBAN-match maar omschrijvings-similarity boven 0,55, datum binnen plus of min 7 dagen. Voegt nog 18 procent toe.
- Zwak fuzzy: zelfde bedrag, breder datumvenster, omschrijvings-similarity boven 0,30, gemaximeerd op drie kandidaten per banklijn. Deze worden niet automatisch geboekt. Ze komen in de reviewwachtrij terecht met de suggesties op volgorde.
De drempel voor automatisch boeken staat bewust conservatief. Liever een mens iets vragen over 30 regels dan in stilte drie regels verkeerd boeken.
WITH candidates AS (
SELECT
d.id AS document_id,
b.id AS bank_line_id,
similarity(d.description_norm, b.description_norm) AS sim,
abs(d.issued_on - b.booked_on) AS day_gap
FROM bank_lines b
JOIN documents d
ON d.client_id = b.client_id
AND d.amount_cents = b.amount_cents
AND d.issued_on BETWEEN b.booked_on - 7 AND b.booked_on + 7
WHERE b.matched_at IS NULL
AND d.matched_at IS NULL
)
SELECT *
FROM candidates
WHERE sim > 0.55
ORDER BY sim DESC, day_gap ASC;
Voor CAMT.053 zitten de Nederlandse banken al jaren op de ISO 20022-standaard, dus de parser is één Python-class die <Ntry>-nodes naar rijen mapt. Die ene Rabobank CSV-export had wel zijn eigen parser nodig, want natuurlijk.
Wat als eerste brak
Drie dingen, op volgorde.
De eerste verrassing waren partiële betalingen. Een klant betaalde een factuur in twee overboekingen vanwege een kredietlimiet op hun zakelijke pas. Onze matcher zag een bedragsverschil op beide regels en gooide ze in de reviewwachtrij. We voegden een 'split-match'-modus toe: als de som van twee banklijnen van dezelfde tegenpartij in een venster van 5 dagen exact overeenkomt met één documentbedrag, stel dan de splitsing voor. Maaike bevestigt het nog steeds zelf, maar de suggestie klopt in 95 procent van de gevallen.
De tweede verrassing was afgeronde loonbetaling. Een salarisregel van €3.142,86 werd door één bank uitbetaald als €3.142,85, vanwege een tijdelijke afrondingsbug in hun exportpipeline. We voegden een tolerantie van één cent toe aan de bedragsmatch. We zijn er niet trots op, maar het werkt.
De derde was iets wat we hadden moeten zien aankomen. De gedeelde inbox kreeg een CC op een interne mailthread met een PDF-bijlage van een screenshot van een andere factuur. Onze worker boekte hem braaf. We vereisen nu een 'invoice intent'-gate: het zenderdomein moet op een lijst met bekende leveranciers staan, of het onderwerp moet een factuurachtige string bevatten, of een mens moet het bestand in de map 'Definitely an invoice' slepen.
Alles wat een inbox bewaakt, zal vroeg of laat de lunchbestelling proberen te boeken die iemand heeft doorgestuurd. Bouw een positieve intent-gate, geen negatief spamfilter. Lijsten van wat wel telt zijn makkelijker te beredeneren dan lijsten van wat niet telt.
De cijfers na drie maanden
We zijn voorzichtig met ronde claims, dus hier de werkelijke cijfers uit de eigen tijdregistratie van het kantoor, gemiddeld over april en mei 2026 over hun elf maandelijkse klanten:
- Reconciliatie-uren per klant: 22,3 (baseline januari-februari) naar 1,5.
- Auto-boek-percentage op matches tussen factuur en banklijn: 89 procent over de hele portefeuille, met als long tail de klanten waarvan de leveranciers wisselende factuurtemplates verzenden.
- Reviewwachtrij-items per klant per maand: ongeveer 30, in plaats van 'alles'.
- Tijd om een nieuwe leverancierslayout in te richten: ongeveer 4 minuten, eenmalig.
De vrijgekomen uren zijn niet uitgemond in ontslagen. Maaikes kantoor heeft in dezelfde periode drie extra klanten aangenomen zonder personeel uit te breiden, en zij besteedt haar vrijdagmiddagen nu aan adviserend werk dat tegen vier keer het tarief van reconciliatie wordt gefactureerd.
Waarom het model niet mag boeken
Op de voorpagina van Hacker News stond deze week een lange thread over recursieve zelfverbetering bij coderende agents. Een fascinerende onderzoeksrichting. Het is ook precies het tegenovergestelde van wat je wilt in een boekhoudpipeline. Boekhouden heeft een toezichthouder. Die toezichthouder heeft verwachtingen over wie welke knop heeft ingedrukt. We hebben er bewust voor gekozen dat het model voorstelt en de mens vastlegt. De Postgres-matcher is deterministisch; de OCR is auditeerbaar; de enige plek waar een probabilistisch systeem aan de boeken raakt, is in de reviewwachtrij, waar het een persoon helpt en geen persoon vervangt. Dat was ook de enige configuratie waar de compliance officer van het kantoor zijn handtekening onder wilde zetten.
Waar ABN binnenkomt
Toen we deze reconciliatiepipeline voor het kantoor in Den Bosch bouwden, liepen we vooral hard tegen het probleem van stille fouten in vision-model OCR aan. We hebben dat opgelost door de rol van het model terug te brengen tot 'stelt layouts voor bij de eerste aanblik, boekt nooit zelf', een patroon dat we nu standaard pakken bij iedere procesautomatisering waar aan het eind van de keten een toezichthouder of accountant zit. Het model is een onderzoeksassistent. Het deterministische systeem is de ledger of record.
Heb je ergens in je operatie een spreadsheet die maandelijks 22 uur opslokt? Het kleinste nuttige wat je vandaag kunt doen, is een middag naast de persoon zitten die het uitvoert en opschrijven welke subtaken écht de tijd opvreten. Het antwoord is bijna nooit hetzelfde als wat het team je in een vergadering zou geven.
Kern
Opzoekwerk vraagt niet om intelligentie. Een schone Postgres-index en een deterministische matchregel verslaan een vision model dat in 92 procent van de gevallen klopt.
FAQ
Werkt deze aanpak ook voor banken buiten Nederland?
Ja, overal waar de ISO 20022-standaard wordt gebruikt, en dat dekt het grootste deel van Europa. CAMT.053 is het formaat. Voor Amerikaanse banken zou je in plaats daarvan OFX of een vergelijkbare bankexport parsen.
Waarom niet één vision model end-to-end?
Dat faalt in stilte. Onze tests haalden 92 procent accuratesse, maar de fouten omvatten omgewisselde cijfers en verkeerde data. Voor een boekhouding met een toezichthouder aan het eind van de keten is dat onwerkbaar.
Hoe lang duurt het om een nieuwe klant te onboarden?
Ongeveer twee dagen. Eén dag om historische bankexports en leveranciersfacturen te importeren, één dag voor een mens om bounding boxes te tekenen op de eerste factuur van elke terugkerende leverancier.
Werkt dit ook met BTW-reconciliatie?
Indirect. De bankmatch lost de kasstroomkant op; BTW-codes komen nog steeds uit de leveranciers- en productmapping in het boekhoudpakket. De pipeline zorgt er alleen voor dat de juiste factuur bij de juiste banklijn terechtkomt.