← Blog

Email automation

Factuurherinneringen in 20 minuten: één agent, één outbox

De debiteuren-inbox bij een Amsterdamse expediteur slokte elke vrijdag een hele middag op. Eén email-agent en een Postgres outbox doen het werk nu in twintig minuten.

Jacob Molkenboer· Oprichter · A Brand New Company· 11 mei 2024· 9 min
Crème envelop met limoengroen lint op ivoren vloei, gevouwen facturen, messing presse-papier, donkergroene leren mat.

Vrijdag, 16:00 uur. Marleen heeft de facturenlijst open op het ene scherm en Exact Online op het andere. Achtenveertig facturen over de vervaldatum. Drie valuta. In kolom G staat wacht, dit is de andere BV naast een klant met twee rechtspersonen. Vóór zevenen vertrekt ze niet.

Zo zag het eruit bij een kleine Amsterdamse expediteur in februari. Hun debiteurenmedewerker stak er ruwweg zes uur per week in om geld binnen te halen dat al verschuldigd was. In april kostte hetzelfde werk nog twintig minuten menselijke aandacht, en de meeste weken raakte ze het niet meer aan.

Het werk wordt nu gedaan door een kleine email-agent en een Postgres outbox. Geen van beide is op zichzelf bijzonder slim. De combinatie, met de juiste noodknoppen, maakte het project saai genoeg om in productie te krijgen.

Debiteurenbeheer voordat we iets veranderden

De expediteur verwerkt ruwweg 280 facturen per maand, in EUR, USD en af en toe GBP. Net 30 is de standaard, maar expediteurs leven met rommeligere voorwaarden: sommige klanten betalen bij douaneklaring, andere op bewijs van aflevering, weer andere op een vaste kalenderdag per maand. Ongeveer 15% van de facturen heeft binnen de eerste 30 dagen een vriendelijk duwtje nodig, nog eens 8% vraagt om een echte herinnering tussen 30 en 60 dagen, en een staart van zo'n 3% schiet voorbij de 60 dagen en wordt een telefoontje.

Marleens proces was de spreadsheet, een lijst met opgeslagen antwoorden in Gmail, en een geheugen voor welke klant op welke toon reageert. Meestal effectief. Zeker traag. En kwetsbaar: zodra ze op vakantie was, bewoog er niets.

Waarom we geen chasing-SaaS hebben gekocht

We hebben gekeken. Chaser, Upflow, Numeral, een handvol nieuwere spelers. Elk lost een echt probleem op voor een typisch SaaS-bedrijf dat netjes maandelijks dezelfde factuur in één valuta stuurt. De expediteur heeft geen van die eigenschappen:

  • Drie valuta, met FX-gevoelige afronding in hun ERP.
  • Gemengde Nederlandse en Engelse correspondentie, vaak binnen dezelfde thread.
  • Twee rechtspersonen onder één handelsnaam, factureren aan verschillende klanten.
  • Een handvol 'nooit aanmanen, eerst even vragen'-relaties.

Elke tool die we bekeken was met genoeg mapping en uitzonderingsregels in deze vorm te buigen. Tegen de tijd dat we klaar waren met buigen, hadden we een slechtere versie van dezelfde code die we zelf hadden kunnen schrijven, plus een rekening per gebruiker. Dus we hebben hem zelf gebouwd.

Hoe de agent in elkaar zit

Elk uur trekt een kleine Node-worker openstaande facturen uit de Exact Online-tenant van de expediteur. Elke factuur loopt door een korte pipeline:

  1. Staat deze klant op de niet-aanmanen-lijst? Skip.
  2. Is de factuur recent al aangemaand? Lees het aanmaanlogboek.
  3. Bepaal de actie: niets, vriendelijk duwtje, stevige herinnering, laatste aanmaning, of escaleren naar een mens.
  4. Is de actie iets anders dan niets, genereer dan de e-mail en schrijf hem naar de outbox.

De beslissingsstap is grotendeels deterministisch. Dagen te laat, betalingsgeschiedenis van de klant, valuta en taalvoorkeur leveren een tier op. Het taalmodel schrijft de body van de e-mail met die tier als constraint, plus een korte stijlgids die we in een markdown-bestand bijhouden. We laten het model niet beslissen of er verzonden wordt.

Die splitsing telt. Het model schrijft; regels beslissen. Kiest het model de verkeerde toon, dan kunnen we de e-mail in de outbox lezen voordat hij eruit gaat. Hallucineert het model een factuurnummer, dan draagt de outbox-rij het echte factuur-ID en weigert de worker elke verzending waarbij die twee niet matchen.

Waarom een Postgres outbox

De naïeve versie ziet er aantrekkelijk uit: de agent besluit aan te manen, roept Postmark aan, markeert de factuur als aangemaand. Eén stap. Geen bewegende delen. We hebben het zo niet gebouwd, om dezelfde reden waarom de meeste productiesystemen dat ook niet doen: dual writes liegen.

Slaagt de SMTP-call maar faalt de database-write, dan krijgt de klant twee aanmaningen. Slaagt de database-write maar faalt de SMTP-call, dan krijgt hij er geen en denken wij dat we er één hebben verstuurd. Het patroon dat dit oplost is de transactional outbox: schrijf de intentie naar een rij in dezelfde database-transactie als de state-verandering, en laat een aparte worker die rij in een echt side effect omzetten.

Schema, ruwweg:

create table email_outbox (
  id            bigserial primary key,
  invoice_id    text not null,
  to_address    text not null,
  cc_addresses  text[] not null default '{}',
  subject       text not null,
  body_text     text not null,
  body_html     text not null,
  language      text not null check (language in ('nl','en')),
  tier          text not null check (tier in ('gentle','firm','final','human')),
  dedupe_key    text not null unique,
  status        text not null default 'pending'
                 check (status in ('pending','holding','sent','failed','cancelled')),
  scheduled_at  timestamptz not null,
  sent_at       timestamptz,
  provider_id   text,
  attempts      int not null default 0,
  last_error    text,
  created_at    timestamptz not null default now()
);

create index email_outbox_ready
  on email_outbox (scheduled_at)
  where status = 'pending';

De dedupe_key is sha256(invoice_id || tier || iso_week). Een tweede run binnen dezelfde week, op dezelfde factuur, op dezelfde tier, is een no-op insert. De agent kan opnieuw geprobeerd, herstart of per ongeluk dubbel gedraaid worden, en de klant ziet nog steeds één e-mail.

De worker is met opzet saai. Hij pakt rijen op waar status = 'pending' en scheduled_at <= now(), verstuurt via Postmark, schrijft de message-id van de provider terug en markeert de rij als verzonden. Bij een fout verhoogt hij attempts, plant een backoff in, en laat de rij na drie pogingen opduiken in een dagelijkse Slack-samenvatting.

De aanmaanbeslissing, in code die op één scherm past

De feitelijke beslissingsfunctie is onromantisch, en dat is het hele punt. Het meeste van de logica zit in de regels; het taalmodel schrijft alleen proza.

type Tier = 'none' | 'gentle' | 'firm' | 'final' | 'human';

export function decideTier(inv: Invoice, cust: Customer, today: Date): Tier {
  if (cust.doNotChase) return 'none';
  if (inv.inDispute) return 'human';

  const daysOver = diffDays(today, inv.dueDate);
  if (daysOver < 3) return 'none';

  const recentChase = inv.lastChasedAt
    && diffDays(today, inv.lastChasedAt) < 7;
  if (recentChase) return 'none';

  if (daysOver < 14) return 'gentle';
  if (daysOver < 30) return 'firm';
  if (daysOver < 60) return cust.badPayer ? 'final' : 'firm';
  return 'human';
}

Het model krijgt de tier, de klantnaam, het factuurnummer en het bedrag, de vervaldatum, de voorkeurstaal en een korte stijlgids ('warm maar direct, nooit excuses voor het aanmanen, nooit dreigen, geen uitdrukkingen'). Het levert een onderwerp en een body. We renderen de e-mail, slaan zowel plain text als HTML op in de outbox-rij en gaan verder.

We vragen het model niet om ontvangers, datums of bedragen te kiezen. Die komen uit Exact Online. Verzint het model een getal, dan vergelijkt de worker dat met de gestructureerde factuurvelden en weigert te versturen. De meeste fouten van het model worden door die ene check opgevangen.

Antwoorden en de 'ik betaal dinsdag'-stapel

Versturen is de makkelijke helft. Antwoorden zijn de helft die echt tijd bespaart.

Een tweede worker haalt de gedeelde debiteuren-mailbox elke vijf minuten op via IMAP. Elk binnenkomend bericht wordt aan zijn outbox-rij gekoppeld via de Message-ID die we bij verzending hebben opgeslagen, teruggethread naar de factuur en geclassificeerd in een van vijf bakken:

  • Betaling toegezegd (met datum, als we die kunnen vinden).
  • Al betaald (met datum of referentie, als die genoemd wordt).
  • Vraag om een kopie van de factuur of een rekeningoverzicht.
  • Bezwaar of vraag.
  • Out of office en andere ruis.

De eerste drie lossen zichzelf op. 'Al betaald'-rijen worden vergeleken met de bankreconciliatie in het ERP en daarna gesloten of geëscaleerd. 'Betaling toegezegd'-rijen krijgen een follow-up ingepland op de toegezegde datum plus twee dagen. 'Stuur kopie'-rijen triggeren een eenmalige outbox-rij met de PDF erbij.

Bezwaren en vragen komen terecht in een klein Slack-kanaal met de oorspronkelijke factuur, het antwoord en een samenvatting van één zin. Marleen pakt ze in een paar minuten per dag op. Daar gaat het grootste deel van haar twintig minuten per week nu naartoe.

De noodknop als het hele product

We hebben een status = 'holding'-waarde aan de outbox toegevoegd voordat we het versturen aansloten. Elke operator kan een enkele rij, of alle rijen voor één klant, op holding zetten. De worker negeert holding-rijen. Er is ook een globale pauze: één rij in een feature_flags-tabel die de worker bij elke loop controleert. Zet hem aan en er gaat niets de deur uit. We hebben hem in vier maanden twee keer gebruikt: één keer rond een fiscale jaarwissel, één keer omdat een klant was overgenomen en we wilden pauzeren tot het nieuwe AP-contact bevestigd was.

Dit deel is ongeglamoureus en waarschijnlijk de belangrijkste ontwerpkeuze die we hebben gemaakt. Het is ook waarom de expediteur het systeem vertrouwt. Het interessante werk zit niet in het model. Het zit in het oppervlak rond het model waarmee een mens het kan stoppen, auditten en opnieuw afspelen.

Waarschuwing

Kun je je agent niet stilzetten met één SQL-update, dan heb je geen agent. Dan heb je een storing die wacht op een rustig weekend.

Kosten en besparingen

Bouwen kostte drie weken van één engineer, plus twee korte calls met de finance-lead van de expediteur om toon en randgevallen vast te leggen. We draaien de worker op een kleine Hetzner-machine waar de expediteur toch al voor betaalde. Postmark en het taalmodel vallen binnen hun bestaande budget voor uitgaande mail en tooling; de modelaanroepen kosten bij het huidige volume zo'n EUR 6 per maand.

De meetbare verandering:

  • Tijd aan debiteurenbeheer: van zo'n zes uur per week naar zo'n twintig minuten.
  • Mediane days sales outstanding: van 41 naar 34 over het eerste volle kwartaal.
  • Facturen ouder dan 60 dagen: ruwweg gehalveerd, Q4 vorig jaar vergeleken met Q1 dit jaar.

DSO is het getal waar de bank van de expediteur om geeft. De twintig minuten zijn het getal waar Marleen om geeft. Beide bewogen.

Twee dingen die we anders zouden doen

Ten eerste zouden we de antwoord-classifier vóór de verzender bouwen, niet erna. We hebben eerst het versturen gebouwd, twee weken aangezien hoe de antwoorden zich opstapelden in de inbox, en daarna in allerijl moeten triagen. Waren we aan de antwoord-kant begonnen, dan was het ontwerp van de uitgaande e-mails gevormd door de antwoorden die we terug zouden krijgen, en niet door wat natuurlijk voelde om te versturen.

Ten tweede zouden we de stijlgids vanaf dag één in dezelfde repository als de code zetten, niet in een gedeeld document. Een model gedraagt zich nooit beter dan de prompt die het leest, en prompts die buiten version control leven, drijven af.

Eén kleine audit voor vanmiddag

Open je openstaande-postenlijst. Zoek de oudste factuur die je twee weken geleden had kunnen aanmanen en niet hebt aangemaand. Stuur die ene e-mail vanmiddag. Pak daarna een notitieblok en schrijf op wat je over een klant zou moeten weten om te beslissen welke van vier tonen je gebruikt. Die lijst is de spec voor de agent die je uiteindelijk gaat willen.

Toen wij de email-agent bouwden voor de expediteur, was het proza niet waar we het meest aan hebben moeten sleutelen. Dat waren de regels rond wanneer je juist niet moet versturen. We hebben het uiteindelijk opgelost door eerst de noodknop te schrijven en de aanmaanlogica daarna.

Kern

Het model schrijft de e-mail. Regels beslissen of er verzonden wordt. Een Postgres outbox maakt het side effect veilig om opnieuw te proberen, te auditten en met één SQL-update te pauzeren.

FAQ

Wat is een transactional outbox?

Een databasetabel die de intentie om een bericht te versturen vastlegt in dezelfde transactie als de business-verandering. Een aparte worker leest de tabel en voert het side effect uit, zodat de database en de buitenwereld niet uit de pas kunnen lopen.

Waarom laat je het model niet beslissen of er verzonden wordt?

Versturen is onomkeerbaar. Regels beslissen, het model schrijft het proza voor een gegeven tier. Door die splitsing blijven fouten controleerbaar en herstelbaar, en kan een mens elke outbox-rij nog nalezen voordat hij eruit gaat.

Hoe ga je om met antwoorden in een mix van Nederlands en Engels?

De classifier leest beide, threadt op Message-ID en stuurt betalingstoezeggingen naar een follow-up-schema en bezwaren naar een mens in Slack. De uitgaande toon volgt de voorkeurstaal van de klant.

Wat voorkomt dat de agent twee keer aanmaant op dezelfde factuur?

Een deterministische dedupe-key, opgebouwd uit factuur-id, tier en ISO-week, met een unique constraint op de outbox-tabel. Een tweede insert is een no-op, dus retries en restarts zijn veilig.

email automationai agentsautomationprocess automationcase studyoperations

Iets bouwen?

Start een project