← Blog

Process automation

Retouren reconciliëren: een vier-ogen SEPA-draaiboek

Een fulfilment-SaaS uit Breda met 26 mensen verloor elke vrijdag uren aan refunds. Dit is de agent die het gat dichtte tussen WMS, Exact Online en de SEPA-cutoff.

Jacob Molkenboer· Oprichter · A Brand New Company· 24 nov 2025· 11 min
Ivoren vloeiblad met verzegelde envelop, groen lint, koperen paperclip op bonnen, grootboekstrook, koperen balans, lakzegel.

De klok boven het finance-bureau in Breda staat op 15:42. Mirella, de finance lead, heeft achttien minuten tot de SEPA-cutoff bij Rabobank om 16:00. Voor haar ligt een spreadsheet met 1.341 wekelijkse retourzendingen die nog gematcht moeten worden met Exact Online, een Excel-pivottabel die sinds dinsdag niet meer schoon opent, en een Slack-DM van de WMS-lead die vraagt waarom de warehouse 47 retouren laat zien die finance nog nooit heeft gezien.

Dit is een Nederlandse fulfilment-SaaS. 26 mensen. Ze verwerken 6.800 retouren per week voor een portfolio aan D2C-merken. Hun grootboek is een 12 jaar oude Exact Online-tenant met dat soort dichtgekoekte journaalposten waarop je elke accountant sinds 2014 terugziet. Het WMS is zelfgebouwd, geschreven door de oprichter en een inmiddels vertrokken contractor, en spreekt een platte JSON-structuur die niemand heeft gedocumenteerd.

De briefing van de CFO bestond uit drie zinnen. Stop de vrijdagavond-stress. Laat nooit een refund boven €120 weggaan zonder een tweede paar ogen. Mis de SEPA-poort van 16:00 niet.

Dit is het draaiboek dat we gebruikten.

De vrijdagmiddag-vorm van het probleem

Een retourzending in hun systeem heeft vier aanrakingspunten. Het WMS ontvangt het pakket en scant de barcode. Het WMS roept de order-API van de oorspronkelijke webshop aan om de regel als geretourneerd te markeren. Exact Online heeft een creditnota-journaalpost nodig tegen de originele factuur. En de bank heeft een SEPA pain.001-bestand nodig met de refund-betaling.

Totdat wij langskwamen, gingen de eerste twee vanzelf. De laatste twee waren een persoon.

Die persoon was Mirella, plus een tweede finance-medewerker die in maart vertrok, plus een parttimer die alleen op maandag werkte. Ze matchten door twee CSV's te exporteren, één uit het WMS en één uit Exact, die in een gedeeld werkboek te plakken, een VLOOKUP te draaien die brak zodra een klant bij aankoop en retour een net iets ander e-mailadres gebruikte, en daarna refunds één voor één in het Rabobank zakelijk-portaal in te tikken.

In een rustige week kostte dat zes uur. In een slechte week veertien, en schoven refunds door naar de dinsdag erop.

Twee systemen, één waarheid

Het matchingsprobleem ziet eruit als een join. Dat is het niet. Het is een reconciliation in de boekhoudkundige zin. Twee grootboeken die zouden moeten overeenkomen, met een gedocumenteerde audit trail als dat niet zo is.

We modelleerden het als vier states per retour.

class ReturnState(str, Enum):
    WMS_ONLY = "wms_only"        # scanned in warehouse, no Exact match
    EXACT_ONLY = "exact_only"    # credit note exists, no scan
    MATCHED = "matched"          # both sides agree
    DISPUTED = "disputed"        # both sides exist, amounts differ

Elke retour begint als WMS_ONLY zodra het magazijn het pakket scant. De agent draait elke vijftien minuten tegen de Exact Online REST API, haalt de creditnota's van de afgelopen 24 uur op, en probeert rijen uit WMS_ONLY te promoten naar MATCHED.

De match gaat niet op ordernummer. Hij gaat op een samengestelde sleutel.

def match_key(record: dict) -> str:
    # Customers reuse emails, change addresses, and webshops generate
    # new order_ids on partial returns. Hash the tuple that actually
    # identifies a refund event, not the one the upstream chose.
    return hashlib.sha256(
        b"|".join([
            record["customer_email"].lower().strip().encode(),
            record["original_order_id"].encode(),
            f"{record['amount_cents']:08d}".encode(),
            record["product_sku"].encode(),
        ])
    ).hexdigest()[:16]

De eerste week hebben we alleen op order_id gematcht. Dat miste 11 procent van de retouren, omdat de webshops bij een RMA een nieuw order_id genereren en Exact het originele bewaart. We probeerden de klantnaam toe te voegen. Dat miste nog eens 4 procent, omdat Exact alles in title-case zet en het WMS niet.

SKU plus original_order_id plus bedrag plus een genormaliseerd e-mailadres is de bodem waarop de false-positive rate onder de één op tienduizend bleef gedurende de acht weken dat we het hebben gemeten.

Dag één was geen greenfield. De agent erfde elf maanden aan niet-gematchte retouren die in het WMS stonden zonder bijbehorende creditnota in Exact. We draaiden een aparte one-shot backfill die 200 retouren tegelijk verwerkte, tussen batches pauzeerde om de gepubliceerde rate limit van Exact (60 requests per minuut per app) te respecteren, en elke match in een backfill_audit-tabel schreef zodat finance 50 willekeurige steekproeven kon controleren voordat we het resultaat vertrouwden. Drie van die 50 steekproeven brachten een dubbele creditnota uit 2024 boven water die niemand had opgemerkt. De backfill verdiende de bouw terug nog voordat de live agent één nieuwe retour had gematcht.

De vier-ogen wachtrij vanaf €120

De harde regel van de CFO was simpel. Niemand, ook zij niet, mag een refund boven €120 vrijgeven zonder een tweede goedkeurder. Dit is het vier-ogen-principe zoals het er in de dagelijkse praktijk uitziet. Een dual-control gate op alles wat materieel is, hetzelfde principe dat het Basel Committee documenteert voor operationeel risico in betalingen.

De vorm waar we op uitkwamen was een wachtrij met drie banen.

def route_refund(refund: Refund) -> Lane:
    if refund.state == ReturnState.DISPUTED:
        return Lane.MANUAL_INVESTIGATION
    if refund.amount_cents > 12_000:           # €120,00 in cents
        return Lane.FOUR_EYES
    if refund.fraud_score > 0.6:
        return Lane.FOUR_EYES
    return Lane.AUTO_RELEASE

Alles in AUTO_RELEASE stroomt direct door naar de volgende SEPA-batch. Alles in FOUR_EYES blijft in een Postgres-tabel staan met een approved_by_a en approved_by_b kolom, beide NULL totdat twee verschillende finance-gebruikers in het dashboard hebben afgetekend. We hebben er een constraint op gezet.

alter table refunds_pending
  add constraint two_distinct_approvers
  check (approved_by_a is null
         or approved_by_b is null
         or approved_by_a <> approved_by_b);

Het is een one-line check die in productie drie pogingen heeft tegengehouden waarbij Mirella, in tijdnood, de openstaande sessie van de parttimer op het tweede scherm wilde gebruiken. De database zei nee.

Het dashboard dat de goedkeurders zien past op één pagina. De wachtrij is gesorteerd op bedrag aflopend, zodat de grootste refunds bovenaan staan en de grootste blootstelling de meeste aandacht krijgt. Elke rij toont de laatste drie retouren van de klant uit de afgelopen 90 dagen, de WMS-scantijd, de Exact creditnota-ID, en eventuele verschillen tussen de twee kanten uitgeschreven in gewoon Nederlands. We hebben elk ander stuk chrome weggehaald. De eerste versie had een KPI-zijbalk met dagtotalen, week-op-week, batchgrootte en goedkeuringssnelheid. In week twee vertelde Mirella ons dat de zijbalk het meest rumoerige deel van haar scherm was en dat ze alleen de wachtrij wilde. Diezelfde middag hebben we hem verwijderd. Het dashboard heeft sindsdien geen enkele nieuwe widget bijgekregen.

Waarschuwing

Implementeer vier-ogen niet door je frontend een knop te laten disablen. De database is de enige eerlijke plek voor deze check. Session hijacking, browser-back-buttons en goedbedoelde sneltoetsen falen allemaal op het application-niveau.

De SEPA-batch sluiten voor 16:00

Same-day SEPA Credit Transfer op de Rabobank zakelijk-rail sluit om 16:00 Amsterdamse tijd voor bestanden die via het EBICS- of H2H-kanaal worden ingediend. Mis je dat met een minuut, dan settlet je batch de volgende werkdag, en dat betekent een boze klant-mail op zaterdagochtend.

Onze agent genereert elke werkdag om 15:50 een pain.001.001.03 XML. Tien minuten is een bewuste buffer. Voordat hij het bestand schrijft, draait hij drie checks.

def assemble_batch(now: datetime) -> Optional[SepaBatch]:
    pending = db.query(Refund).filter(
        Refund.state == ReturnState.MATCHED,
        Refund.lane == Lane.AUTO_RELEASE,
    ).all()

    pending += db.query(Refund).filter(
        Refund.lane == Lane.FOUR_EYES,
        Refund.approved_by_a.isnot(None),
        Refund.approved_by_b.isnot(None),
    ).all()

    total_cents = sum(r.amount_cents for r in pending)
    if total_cents > DAILY_CEILING_CENTS:
        alert_cfo(f"Batch would exceed daily ceiling: EUR {total_cents/100:.2f}")
        return None

    if any(r.iban is None or not iban_valid(r.iban) for r in pending):
        quarantine_invalid_ibans(pending)
        pending = [r for r in pending if r.iban and iban_valid(r.iban)]

    return SepaBatch(
        msg_id=f"BREDA-{now:%Y%m%d-%H%M}",
        execution_date=now.date(),
        payments=pending,
    )

Het dagplafond staat op €45.000. De eerste week dat we dit uitrolden, ging het plafond af bij €71.000 vanwege een dubbele refund-batch uit een hersteld webshop-incident. De CFO kreeg de melding om 15:51 en trok het bestand terug. We willen niet weten wat er was gebeurd op een systeem zonder die check.

Kernpunt

Een plafond-alert die afgaat op de abnormale dag is meer waard dan tien dashboards die er op een normale dag mooi uitzien. Bouw eerst de rem, dan pas de snelheidsmeter.

Wat als eerste brak

Vier dingen braken in de eerste twee maanden. Ze zijn alle vier het noemen waard.

De refresh-token van Exact Online verliep zonder waarschuwing om 14:30 op een woensdag. De agent had negen dagen lang netjes creditnota's opgehaald. We zagen het doordat de MATCHED-rate in één polling-interval kelderde van 94 procent naar nul. We refreshen de token nu op een rolling schema van dertig dagen en piepen de on-call op zodra de agent een HTTP 401 van Exact terugkrijgt.

Het WMS begon na een deploy bedragen op het ene endpoint als float in euro's terug te geven, en op het andere als integer in centen. Onze match_key normaliseerde alles naar centen, dus het brak stil. We hebben een sanity check toegevoegd. Elk los refund-bedrag dat binnenkwam als een float tussen 0,01 en 100.000,00 werd als verdacht gelogd en gepauzeerd voor review.

Het pain.001-bestand werd op een middag afgekeurd met een CDTR-001 fout van Rabobank, omdat twee betalingen exact dezelfde EndToEndId deelden. We hadden EndToEndId gegenereerd uit een hash van order_id, en de webshop had een order_id hergebruikt voor twee klanten. De fix was om de timestamp aan de ID toe te voegen. We leerden om de uniekheid van geen enkele identifier die van buiten ons systeem komt nog te vertrouwen.

De laatste was het meest pijnlijk. Daylight Saving Time pakte ons in week zeven. De agent bouwde op weekdagen om 15:50 Amsterdamse tijd de batch op. Op de laatste zondag van oktober, toen de klok terugging, draaide onze cron om 14:50 omdat de container met TZ unset draaide en standaard naar UTC viel, terwijl de scheduler die de job had geregistreerd de conversie bij deploy al stilletjes had gedaan. De batch van maandag ging een uur te vroeg de deur uit. Geen klant klaagde, maar de audit log zag er fout uit. We hebben het opgelost door TZ=Europe/Amsterdam in de container-image te pinnen en een smoke test toe te voegen die controleert of de bestandsnaam van de gebouwde batch het verwachte uur-token bevat voordat het bestand de host verlaat. Die smoke test draait nu op de laatste zondag van maart en oktober, op een aparte planning die geen mens kan vergeten.

De cijfers na acht weken

Na acht weken in productie veranderde het beeld van vorm.

  • Het finance-werk op vrijdagavond zakte van een bandbreedte van zes tot veertien uur naar 35 tot 50 minuten.
  • De MATCHED-rate staat op 96,4 procent na de eerste matching-pas, 99,1 procent na een tweede pas om 11:00 de volgende ochtend.
  • De FOUR_EYES-wachtrij telde gemiddeld 47 refunds per dag, met een mediaan van 22 minuten tussen goedkeuring en vrijgave.
  • Nul refunds boven €120 verlieten de bank zonder twee goedkeurders. De constraint deed zijn werk.
  • De SEPA-cutoff van 16:00 is twee keer gemist, beide keren tijdens een niet-gerelateerde Exact Online-storing in week drie.

De CFO werkt geen vrijdagen meer.

Wat we voor de volgende meenemen

Een reconciliation-agent is geen join. Het is een state machine met een menselijke wachtrij aan de voorkant, een harde constraint in het midden van de database, en een deadline aan het einde. Bouw het in die volgorde en je krijgt er geen spijt van.

De bewegende onderdelen zijn altijd dezelfde. Identifiers uit externe systemen liegen. Cutoffs zijn absoluut. Vier-ogen hoort in het schema, niet in de UI. Tijdzones zijn een constraint, geen configuratie-waarde. En de alert die de abnormale dag opvangt verdient zichzelf de eerste keer terug dat hij afgaat.

Toen we deze process automation voor het Breda-team uitrolden, was het ding waar we het hardst tegenaan liepen de EndToEndId-collision. We hebben hem opgelost door elke externe identifier als hint te behandelen, nooit als sleutel, en onze eigen interne ID's te genereren uit een salt plus een timestamp.

Als je iets wekelijks reconciliëert: het kleinste wat je vandaag kunt doen is één SQL-query draaien over je twee systemen. Hoeveel records bestaan er aan de ene kant zonder match aan de andere kant, deze week? Als dat aantal je verbaast, heb je je eerste ticket.

Kern

Reconciliation is een state machine met een menselijke wachtrij aan de voorkant, een database-constraint in het midden en een deadline aan het einde.

FAQ

Wat is het vier-ogen-principe in payment operations?

Het is het Nederlandse begrip voor het four-eyes principle: elke materiële financiële actie heeft twee verschillende goedkeurders nodig. Bij refunds betekent het dat een tweede persoon aftekent voordat het geld de rekening verlaat.

Waarom €120 als vier-ogen-drempel en geen rond getal?

Die drempel kwam uit het eigen verliestolerantie-rekenwerk van de klant. Onder €120 was de kosten van een tweede goedkeuring hoger dan de verwachte fraude-blootstelling. Kies je eigen drempel uit je eigen verlies-data, niet uit een benchmark.

Kun je reconciliation bouwen op de standaard-rapporten van Exact Online?

Bij lage volumes wel. Bij 6.800 retouren per week heb je de REST API, een interne state-tabel en een polling-agent nodig. De standaard-rapporten leveren niet de versheid of granulariteit die je daarvoor nodig hebt.

Wat als een refund sneller weg moet dan de SEPA-cutoff van 16:00?

Instant SEPA (SCT Inst) settlet binnen seconden, 24/7, maar kost meer per transactie en niet elk bankkanaal ondersteunt batches. Voor losse gevallen sturen we ze handmatig via het bankportaal door, met een Slack-ping.

process automationautomationintegrationsworkflowoperationscase study

Iets bouwen?

Start een project