← Blog

Email automation

Aanmaningen-agent playbook: 1.420 dossiers, drie systemen

Een Antwerpse douane-expediteur met 1.420 openstaande facturen per week, een Cargonaut-feed uit 2007 en een Exact Online-grootboek. Dit is het playbook dat we leverden.

Jacob Molkenboer· Oprichter · A Brand New Company· 7 mei 2025· 9 min
Crèmekleurige envelop met groen lint op donkergroen leren onderlegger, messing paperclip en papieren bonnetje met touwtje ernaast.

Dinsdagochtend op het kantoor van de expediteur, vlak bij de Antwerpse Ring. Inge, de finance lead, opent haar week met hetzelfde getal dat ze al twee jaar elke dinsdag ziet: 1.420. Zoveel openstaande factuurdossiers moet het team voor vrijdag napluizen in drie systemen. Een gemiddelde DSO van zevenenveertig dagen. Een spreadsheet met zeven tabbladen. Twee parttime assistentes die de hele dag vrijwel niets anders doen dan vriendelijke herinnering in Outlook typen.

Het bedrijf is een douane- en logistiek expediteur van 23 mensen. Ze boeken containers, dienen aangiftes in via Cargonaut (de Nederlandse douaneknooppunt voor berichtenuitwisseling, in productie sinds 2007) en factureren klanten via Exact Online. Klantenservice draait op Freshdesk. Dezelfde klant kan tien betaalde facturen, drie openstaande en één open ticket over een ontbrekende CMR hebben. De assistentes weten niet altijd wat wat is. Als het systeem het misheeft, krijgt een klant die al ruzie maakt over een demurrage-regel van €4.200 die middag een derde herinnering voor diezelfde regel. De telefoon gaat. De relatie veroudert in tien minuten een jaar.

We hebben voor ze een aanmaningen-agent gebouwd. Dit is het playbook.

De drie systemen die de waarheid bezitten

Voordat je ook maar één regel agentcode schrijft, leg je vast welk systeem welk feit bezit. Doe je dat verkeerd, dan verzint de agent zijn eigen werkelijkheid op basis van oude data.

Voor deze expediteur:

  • Exact Online bezit het factuurgrootboek, de betaalstatus en het matchen van betalingen. Zegt Exact dat het betaald is, dan is het betaald.
  • Cargonaut bezit de status van de douaneaangifte en de CMR- en B/L-referenties die een zending koppelen aan een factuurregel.
  • Freshdesk bezit de status van disputen. Een ticket met de tag dispute, klacht of betwist tegen een factuurnummer is de enige waarheid voor "niet aanmanen".

De agent gokt nooit. Lukt het niet om een factuurregel aan een Cargonaut-referentie te koppelen, dan gaat het dossier naar een menselijke wachtrij. Kan hij niet vaststellen of Freshdesk een open dispuut heeft, dan verstuurt hij niets. Niets doen is een volwaardig resultaat, geen vangnet.

Kernpunt

Een aanmaningen-agent die weigert te handelen bij twijfel is meer waard dan een die zelfverzekerd het verkeerde doet. De foute herinnering kost je de klant, niet de factuur.

Een 19 jaar oude Cargonaut-feed lezen zonder hem te herschrijven

Cargonaut levert nog steeds EDIFACT-berichten via een SFTP-drop. De structuur is sinds halverwege de jaren 2000 niet wezenlijk veranderd. De expediteur had een eigen PHP-parser uit 2011 die niemand in het huidige team ooit had aangeraakt. Hij werkte. Hem vervangen stond niet op de agenda, en dat wilden wij ook niet.

We hebben hem met rust gelaten. De agent leest wat de parser al wegschrijft naar de MySQL-tabel shipments van de expediteur. De link tussen een Exact-factuurregel en een Cargonaut-aangifte zit in de kolom b_l_ref. Ongeveer 9% van de factuurregels had geen schone match, meestal omdat de referentie met de hand in de omschrijving van de Exact-regel was getypt. Die handelen we af met een kleine classifier die in de omschrijving zoekt naar tekenreeksen in B/L-vorm ([A-Z]{4}\d{7,10} dekt de meeste rederijen) en alleen koppelt als de carriercode klopt met de bekende rederij van de zending.

De match-stap, vereenvoudigd:

import re

BL_PATTERN = re.compile(r"[A-Z]{4}\d{7,10}")

def link_invoice_line(line, shipments):
    if line.bl_ref and line.bl_ref in shipments:
        return shipments[line.bl_ref], "exact"

    for ref in BL_PATTERN.findall(line.description or ""):
        ship = shipments.get(ref)
        if ship and ship.carrier_code == ref[:4]:
            return ship, "inferred"

    return None, "unmatched"

De inferred matches gaan door de agent heen, maar krijgen een confidence-tag in de audit log. De None matches worden nooit automatisch aangemaand. Die belanden op maandag in de reviewwachtrij van Inge. Daar werkt ze met koffie doorheen.

De Freshdesk-poort

Dit was de regel waar de klant het meest om gaf: stuur nooit een tweede herinnering naar een klant met een nog openstaand dispuut-ticket. Een eerste duwtje is prima. Een tweede, terwijl klantenservice nog actief onderhandelt, is wat een relatie kapotmaakt.

De poort is één call naar de Freshdesk API per kandidaat-dossier, gefilterd op het bedrijfs-ID van de klant en op tags dispute, klacht of betwist. We cachen het resultaat vijftien minuten, want de assistent van Inge tagt tickets niet realtime, en een extra Freshdesk-hit per herinnering is bij dit volume niet gratis. De cache-key bevat het klant-ID en het factuurnummer. Heeft de agent deze combinatie dit uur nog niet gezien, dan vraagt hij het op. Anders gebruikt hij de gecachte state.

def can_chase(customer_id, invoice_no, attempt, dispute_cache, freshdesk, log):
    if attempt == 1:
        return True  # First nudge is always allowed.

    key = (customer_id, invoice_no)
    state = dispute_cache.get(key)
    if state is None:
        state = freshdesk.has_open_dispute(customer_id, invoice_no)
        dispute_cache.set(key, state, ttl=900)

    if state.has_open_ticket:
        log.audit("blocked", customer_id, invoice_no, state.ticket_id)
        return False
    return True

De audit log doet er meer toe dan de code. Elke geblokkeerde verzending wordt vastgelegd met het ticket-ID dat hem blokkeerde. Vraagt een controller "waarom hebben we Hoyer vorige week niet aangemaand", dan is het antwoord één query.

Een korte state machine, geen lange prompt

De agent improviseert de aanmaning niet. Elke factuur heeft een state, en de agent schuift hem door een kleine machine:

due → reminder_1 → reminder_2 → final_notice → handover

De overgangen zijn kalendergestuurd, niet modelgestuurd. Reminder 1 vuurt op +3 werkdagen na de vervaldatum. Reminder 2 op +10. Final notice op +21. Overdracht naar de menselijke incassowachtrij op +35. Het model laten bepalen wanneer er aangemaand wordt, is precies waar dit soort projecten ontsporen. Kalenderlogica in Python. Taal in het model. Niet andersom.

Het taalmodel wordt pas aangeroepen bij het schrijven van de mailtekst, en alleen met een strak afgebakende prompt: de naam van de klant, het factuurnummer, de taalvoorkeur, de zendingsreferentie en het pogingsnummer. Het levert Nederlandse, Franse of Engelse tekst in de huisstijl van de expediteur. Het mag geen betaaltermijnen verzinnen, geen kortingen voorstellen en zich nergens aan committeren. De system prompt verbiedt elke zin die begint met "ik zal" of "we kunnen u aanbieden", en de output wordt voor verzending tegen die regex gecheckt.

Antwoorden afhandelen zonder een puinhoop te maken

De meeste verhalen over factuuragents stoppen bij het versturen. Het moeilijke deel is wat er gebeurt als de klant reageert. We zagen drie soorten antwoorden die samen ongeveer 85% van de inkomende mail dekten: "we hebben dit al betaald op datum X", "we betwisten dit om reden Y" en "graag het PO-nummer op de factuur aanpassen".

De reply handler is een aparte worker die de mailbox voor aanmaningen in de gaten houdt en sorteert in één van vier banen:

  1. Betaling geclaimd. Maak een reconciliatie-taak aan in Exact, hang de originele mail eraan, pauzeer de aanmaning voor deze factuur 5 werkdagen.
  2. Dispuut. Open een Freshdesk-ticket met tag dispute gekoppeld aan het factuurnummer, pauzeer de aanmaning voor onbepaalde tijd, informeer Inge.
  3. Administratieve correctie. Naar een mens, doe niets.
  4. Out-of-office of ruis. Weggooien, en de pogingenteller niet ophogen.

De classifier is een klein model. De gekozen baan wordt eerst een side-tag in Freshdesk, geen actie, totdat een mens de eerste honderd per type heeft goedgekeurd. Daarna draaien banen 1, 2 en 4 zelfstandig. Baan 3 vraagt altijd om een persoon. Inge tekende voor die autonomie-grens in week vier, op basis van een schone audit log.

Terugschrijven naar Exact Online zonder de audit trail te slopen

Exact Online heeft een prima werkbare REST API. De twee dingen om te weten zijn de OAuth-refresh dans (tokens verlopen na 10 minuten, refresh tokens roteren) en de rate limit per minuut per administratie. In de praktijk zijn die limieten strakker dan de publieke documentatie suggereert. We bucketen onze writes op 60 per minuut per administratie en zijn in productie nog nooit afgeknepen.

De agent wijzigt nooit een factuur. Hij voegt alleen een notitie toe en schakelt een custom field om, abn_last_reminder_at. Elke verstuurde herinnering wordt ook gelogd als een verzonden-mail-regel bij de klant in Exact, zodat controllers de aanmaningsgeschiedenis precies daar zien waar ze hem verwachten. De originele spreadsheet bestaat nog. Inge opent hem op maandag uit gewoonte. Hij wordt nu gegenereerd vanuit het Exact-customveld, niet meer andersom.

Wat er veranderde in de cijfers

Acht weken na go-live, vergeleken met de maand vóór de livegang:

  • DSO daalde van 47 naar 29 dagen. De CFO van de klant had 35 ingecalculeerd als het optimistische scenario.
  • De twee assistentes doen nu één dag per week factuuropvolging in plaats van vijf. De andere vier dagen zitten ze op douanepapieren, waar ze ook voor zijn aangenomen.
  • Nul geregistreerde incidenten waarbij een herinnering werd verstuurd terwijl er een open Freshdesk-dispuut lag. Vóór de livegang schatten de assistentes dat dit "vijf of zes keer per maand" gebeurde. De CFO vermoedde vaker.
  • 11% van de dossiers gaat nog steeds naar de menselijke wachtrij. We proberen dat percentage niet naar nul te krijgen. De twijfelgevallen verdienen echt een mens.

De DSO-daling is het opvallende getal. Het was niet het getal waar Inge het meeste om gaf. Zij gaf om het percentage botsingen met disputen. In week drie zei ze:

Ik heb deze maand geen enkel boos telefoontje gehad over een herinnering. Ik kan me niet herinneren wanneer dat voor het laatst zo was.

Finance lead, Antwerpse douane-expediteur, mei 2026

Wat we anders zouden doen

Eerlijk gezegd drie dingen.

We hebben onderschat hoeveel werk in het matchen ging zitten van Cargonaut-referenties die met de hand in Exact waren getypt. We dachten dat de lange staart 2% zou zijn. Het was 9%. De classifier vrat twee weken die we niet hadden ingepland.

We hebben de prompt voor het taalmodel in het begin overengineered. De eerste versie had een system prompt van vier alinea's met voorbeelden. De productieversie heeft een prompt van één alinea en een regex-gate op de output. De kortere prompt levert beter Nederlands op. Het model is niet de agent. De state machine is de agent.

We hadden de audit dashboard in week één moeten opleveren, niet in week vijf. De eerste maand werd elke "waarom hebben we wel of niet"-vraag een logduik van tien minuten. Zodra het dashboard stond, stopte Inge ons te vragen en ging ze het dashboard vragen.

Het kleinste dat je vandaag kunt doen

Open je laatste 100 verstuurde herinneringen. Leg ze naast je ticketsysteem voor klantenservice. Tel hoeveel er de deur uitgingen terwijl er bij dezelfde klant een open dispuut-ticket lag over dezelfde factuur. Dat getal is je bodem. Het is het minimum dat je aanmaningen-agent moet repareren voordat hij iets anders nuttigs kan doen.

Toen we deze aanmaningen-agent bouwden voor de Antwerpse expediteur, zat het moeilijke deel niet in het taalmodel. Het zat in de Freshdesk-poort en in het matchen van Cargonaut-referenties. De agent die overleeft is de agent die weet wanneer hij zijn mond moet houden. Heb je een probleem met dezelfde vorm, dan ligt onze aanpak van AI-agents voor finance operations grotendeels in de patronen hierboven.

Kern

De aanmaningen-agent die overleeft, is de agent die weet wanneer hij zijn mond moet houden. Kalenderlogica in code, taal in het model, dispuutstatus uit het ticketsysteem.

FAQ

Waarom laat je het model niet bepalen wanneer er een herinnering uitgaat?

Omdat de timingregels kalenderlogica zijn, geen taal. De state machine in code houden maakt elke verzending controleerbaar, voorspelbaar en goedkoop. Het model schrijft alleen de body van de mail.

Hoe voorkom je dat de agent een betwiste factuur aanmaant?

Voor elke tweede of latere herinnering draait een Freshdesk-lookup, gescoped op de klant en het factuurnummer. Staat er een ticket open met tag dispute, klacht of betwist, dan wordt de verzending geblokkeerd en gelogd met het ticket-ID.

Raakt de agent de originele Cargonaut-parser aan?

Nee. We lezen wat de bestaande PHP-parser al wegschrijft naar de MySQL-tabel shipments. Een EDIFACT-pijplijn van 19 jaar oud vervangen is een eigen project en stond bij deze opdracht niet op tafel.

Hoe snel kan zo'n project live?

Voor een expediteur van deze omvang met Exact Online, Freshdesk en een werkende douanefeed plannen we acht weken: twee voor read-only integratie, twee voor de gate-logica, twee voor gefaseerde uitrol, twee voor het audit dashboard en de reply handler.

Wat was de grootste enkele oorzaak van de DSO-daling?

Consistentie. De assistentes maanden zo'n 60% van de openstaande dossiers per week aan. De agent maant 100% van de in aanmerking komende dossiers aan op dag drie, elke keer opnieuw. De tekst van het model doet er nauwelijks toe.

email automationai agentsprocess automationintegrationscase studyoperations

Iets bouwen?

Start een project