AI agents
Codex brak onze btw-afronding: een Symfony 6 post-mortem
Om 02:14 zag een financieel controller in Breda zeven cent verschil op elke B2B-factuur boven €10.000. De Codex-agent die we lieten draaien, was al acht uur bezig.

Om 02:14 op een dinsdagochtend opende de financieel controller bij een groothandel in Breda haar inbox en zag een seintje van een van hun grootste B2B-klanten. De factuur voor de bestelling van die dag, €14.820,34, week zeven cent af van de inkooporder van de klant. Ze controleerde drie andere facturen boven de €10.000. Allemaal fout, allemaal met een paar centen, allemaal op B2B-orders.
De Codex-agent draaide al iets meer dan acht uur.
Het ticket dat we hem gaven
De klant draait een Symfony 6 backoffice die maandelijks zo'n 900 B2B-klanten factureert. Hun factuurmodule was het laatste verouderde eiland in de codebase: een service-klasse van 1.200 regels, geschreven volgens Symfony 4-conventies, vol met static utility-aanroepen, gemengde float- en integer-rekensommen, en een eigen btw-calculator waar niemand aan wilde komen. Het ticket was bescheiden. Til de module naar moderne Symfony 6-patronen, trek de calculator los in een eigen service, en breng 'm onder de bestaande test suite. We schatten vier uur engineering in. We lieten de orchestrator 's nachts draaien.
De orchestrator die we gebruiken heeft de saaie vorm die deze maand op Hacker News rondgaat: een planner die het ticket opdeelt, een implementer (Codex) die per bestand code schrijft, een reviewer die elke diff leest, en een test runner die merges afkeurt of doorlaat. De implementer is de enige agent die de working tree mag aanraken. Elke stap schrijft zijn redenering naar een journal, zodat een mens de volgende ochtend kan teruglezen wat er is gebeurd.
Wat Codex daadwerkelijk veranderde
De journal van de ochtend vertelde een coherent verhaal. De agent had de legacy-klasse geïnventariseerd, een refactor-plan geschreven, een VatCalculator-service geëxtraheerd, de static helpers vervangen door constructor-injected dependencies, en de factuur-aggregatie herschreven naar een Money value object. Hij had zelfs moneyphp/money als dependency binnengehaald, iets dat al op ons verlanglijstje stond voor die codebase. 1.193 regels herschreven over negen bestanden. Alle tests groen.
De bug zat in één bestand: VatCalculator::totalForLines().
Dit is wat de oude code deed, licht ingekort:
// Legacy: rondt btw per regel af, telt dan de afgeronde regeltotalen op.
public static function totalForLines(array $lines): int
{
$total = 0;
foreach ($lines as $line) {
$net = $line['qty'] * $line['unitPriceCents'];
$vat = (int) round(
$net * $line['vatRate'] / 100,
0,
PHP_ROUND_HALF_UP
);
$total += $net + $vat;
}
return $total;
}
En dit is wat Codex schreef:
public function totalForLines(array $lines): Money
{
$net = new Money(0, new Currency('EUR'));
foreach ($lines as $line) {
$net = $net->add(
Money::EUR($line->qty * $line->unitPriceCents)
);
}
$vat = $net->multiply(
$this->vatRate / 100,
Money::ROUND_HALF_EVEN
);
return $net->add($vat);
}
Twee wijzigingen, beide op zich verdedigbaar. De agent verplaatste de btw-berekening van per regel naar per factuur, en stapte over van PHP's standaard PHP_ROUND_HALF_UP naar de ROUND_HALF_EVEN uit de Money-library (banker's rounding). Op een factuur met twintig regels en een nettobedrag boven €10.000 leveren die twee keuzes samen ongeveer twee op de drie keer een ander centtotaal op dan de oude code.
Voor onze klant betekende dat dat hun ERP, dat totalen nog op de oude manier berekende voor het matchen van inkooporders, vanaf 19:30 op elke grote B2B-factuur de reconciliatie liet vallen.
Waarom de tests groen bleven
De bestaande suite had eenendertig tests voor de factuurmodule. Achtentwintig waren unit tests die de calculator afdekten met regeltotalen tussen €5 en €450. De andere drie waren integratietests die één kleine order door de hele pipeline lieten lopen. Geen ervan rekende ooit een factuur uit die groot genoeg was om de rounding mode te laten meetellen.
De Codex-implementer paste de bestaande tests aan toen hij de signatuur van de functie veranderde van int naar Money. Hij schreef geen nieuwe tests voor het nieuwe gedrag, want de planner had hem dat niet opgedragen: in het ticket stond "refactor", niet "voeg coverage toe". De reviewer-agent las de diff, zag de wijziging in de signatuur, en bevestigde dat de aangepaste tests nog steeds slaagden.
Geen enkele agent in de keten had de domeincontext om de vraag te stellen: is afronden per regel versus per factuur een keuze die de klant raakt? Want nergens in de repo stond dat het zo was.
Een agent-orchestrator beschermt je tegen syntactische regressies en kapotte tests. Hij beschermt je niet tegen semantische beslissingen die de oorspronkelijke auteur stilletjes heeft gemaakt en nooit heeft opgeschreven. Geldcode zit vol met dat soort beslissingen.
Hoe de review door een mens het miste
Ik wil hier eerlijk over zijn want dit is het deel dat ertoe doet. De review de volgende ochtend duurde twaalf minuten. Onze engineer las de samenvatting van de planner, scande de diff, zag een schone refactor met een verstandige adoptie van een Money-object, en mergede. Hij deed precies wat de orchestrator hem moest laten doen.
Twaalf minuten is wat we intern budgetteren voor een schone refactor-PR zonder gedragsnotities van de reviewer-agent. Dat werkt voor ongeveer 95% van wat de agent oplevert. De andere 5% is de reden dat posts zoals deze worden geschreven, en het is bijna altijd een semantische beslissing die wordt verkocht als een stilistische.
De journal markeerde de wijziging van de rounding mode in een voetnoot als een "design choice". Hij markeerde het niet als een fiscale beslissing. De PHP-documentatie zelf presenteert beide modes vrolijk als geldig; nergens op die pagina staat dat de Belastingdienst er een mening over heeft, of dat het ERP van je klant hardcoded de andere mode verwacht.
De fix van 47 minuten
Om 02:51 hebben we de merge teruggedraaid, de optelling per regel hersteld, en een hotfix uitgerold die de nieuwe Money-typed return value behield maar exact dezelfde rekensom deed als voorheen:
public function totalForLines(array $lines): Money
{
$total = Money::EUR(0);
foreach ($lines as $line) {
$net = Money::EUR(
$line->qty * $line->unitPriceCents
);
$vat = $net->multiply(
(string) ($line->vatRate / 100),
Money::ROUND_HALF_UP
);
$total = $total->add($net)->add($vat);
}
return $total;
}
Daarna schreven we elf nieuwe tests, allemaal op facturen tussen €9.800 en €18.000, met regelaantallen van drie tot tweeënveertig, in zowel het 21%- als het 9%-btw-tarief. Twee daarvan zouden tegen de versie van de agent gefaald hebben. Voor de lunch hadden we de klant een correctief herfactureringsscript gestuurd voor de zeventien getroffen orders. Niemand had nog betaald; er escaleerde niets.
We zetten ook de overnight agent-queue achtenveertig uur op pauze terwijl we de property-tests schreven die hieronder beschreven staan, waardoor drie ongerelateerde tickets een dag werden uitgesteld. Die afweging was om half vier 's ochtends makkelijk te maken.
Wat we veranderden in de orchestrator, niet in de agent
De verleiding na zo'n incident is om het model de schuld te geven. Codex deed precies wat we vroegen: een module refactoren, tests groen houden, idiomatische code schrijven. De fout zat stroomopwaarts van de agent, in de orchestrator en in het ticket.
De week erop gingen er vier wijzigingen in:
- Een money-domein checklist die aan elk ticket hangt dat facturatie, prijzen of btw raakt. Op die lijst staan de acht dingen die een refactor moet behouden: rounding mode, rounding granularity (per regel vs per factuur), valutaprecisie, opslag van regels inclusief vs exclusief btw, volgorde waarin korting wordt toegepast, timing van valutaconversie, omgang met negatieve bedragen, en creditnota-semantiek. De planner-agent leest 'm voordat hij decomponeert. We schreven de eerste versie in een gesprek van veertig minuten met het finance-team van de klant. Ze kenden elk van die invarianten uit hun hoofd en waren stilletjes verbaasd dat niemand het ze ooit had gevraagd op te schrijven.
- Een property-test stage voor elke module die de planner als fiscaal markeert. De test runner genereert 500 willekeurige facturen tussen €100 en €100.000 en vergelijkt de totalen met een bevroren referentie-implementatie. Eén cent verschil laat de build falen.
- Een reviewer-prompt met een auditor-framing. De reviewer-agent leest diffs niet langer als senior engineer. Hij leest ze als een belastinginspecteur wiens taak het is één getal te vinden dat is veranderd. Hetzelfde model, een andere briefing, een wezenlijk andere output.
- Een harde regel dat de implementer een return type niet van een primitive naar een value object mag veranderen binnen dezelfde PR als een gedragswijziging. Dat zijn voortaan altijd twee PR's, met een menselijke goedkeuring ertussen.
Geen van deze maatregelen is slim. Het zijn precies de guardrails waar een finance-team om gevraagd zou hebben als iemand ze had uitgenodigd voor het overleg waarin we het ticket schreven.
Wat dit betekent als je agents loslaat op business code
De Hacker News-thread over agent-orkestratie van deze week ging rond omdat de vorm die hij beschrijft echt begint aan te voelen: planner, implementer, reviewer, test gate, journal. Wij gebruiken exact die vorm. Hij werkt. En hij lekt op dezelfde plek waar elke orchestrator lekt: op de naad tussen syntactische correctheid en domein-betekenis.
Als je codebase iets van het volgende heeft, behandel het dan als fiscale module en zet het achter de zwaardere checklist voordat je een agent erbij toelaat: facturen en creditnota's, btw of sales-tax berekening, salarisadministratie, payout-verdelingen, terugbetaalflows, abonnement-pro rata, valutaconversie. De agent zal ze prachtig refactoren. Hij zal ook, als je 'm de kans geeft, stilletjes één constante veranderen die de oorspronkelijke auteur niet voor niets had gekozen.
Agents falen op de naad tussen syntactische correctheid en domein-betekenis. Zet de domeinregels in het ticket, niet in het hoofd van de reviewer.
De audit van vijf minuten die je vandaag kan doen
Open het bestand in je codebase dat factuurtotalen berekent. Zoek erin naar round(, floor(, ceil(, ->multiply(, en op elke rounding-mode-constante. Schrijf bij elke treffer één zin in een code-comment erboven die uitlegt welke keuze de oorspronkelijke auteur maakte en waarom. Kan je het "waarom" bij één ervan niet beantwoorden, dan heb je net de regel gevonden die je agent volgende maand stilletjes gaat herschrijven.
Toen we de factuurmodule van deze klant vier weken na het incident netjes hebben herbouwd, wilden we vooral een plek waar die zinnen konden landen op een manier dat de agent ze daadwerkelijk zou lezen. We zijn uitgekomen op een bestand docs/money-invariants.md dat de planner verplicht moet citeren in zijn decompositie. Wil je hulp met dit soort guardrails rond je eigen werk met AI-agents, dan is dat het gesprek dat we meestal voeren tijdens het eerste contact.
Kern
Agents falen op de naad tussen syntactische correctheid en domein-betekenis. Zet de domeinregels in het ticket, niet in het hoofd van de reviewer.
FAQ
Wat is een orchestrator in agent-engineering?
Een klein framework rond het model. Doorgaans een planner, een implementer, een reviewer en een test gate. De agent schrijft code; de orchestrator bepaalt wat hij ziet, wanneer, en wat er gemerged wordt.
Waarom breekt btw-afronding alleen op grote facturen?
Op kleine regeltotalen is het verschil tussen per regel en per factuur afronden meestal één cent en sommeert het naar nul. Boven €10.000 kan de drift oplopen tot enkele centen en breekt het de reconciliatie in je ERP.
Is afronden per regel of per factuur correct onder Nederlandse belastingwetgeving?
Beide zijn toegestaan. De Belastingdienst eist consistentie, geen specifieke methode. Het risico zit in het stilletjes wisselen van methode halverwege, en dat is precies wat hier gebeurde.
Hadden meer tests dit voor de merge kunnen vangen?
Ja. De suite had eenendertig tests maar geen ervan op facturen die groot genoeg waren om de drift bloot te leggen. Property-based tests op willekeurige bedragen tussen €100 en €100.000 zouden bij de eerste run gefaald hebben.
Moeten we stoppen met coding agents op legacy code?
Nee. De agent deed de refactor sneller en schoner dan een mens dat had gedaan. De les is om domein-invarianten in het ticket en in de briefing van de reviewer te zetten, niet om de agent te schrappen.