← Blog

E-commerce

WooCommerce B2B btw-incident: de SQL-fix om middernacht

Het was 22:47 op een dinsdagavond toen de boekhouder zag dat elke B2B-order sinds 17:30 21% btw had op facturen die btw verlegd hadden moeten zijn.

Jacob Molkenboer· Oprichter · A Brand New Company· 27 apr 2024· 9 min
Pakje van kraftpapier met groen lakzegel naast een scheve koperen weegschaal met rood label op ivoor.

Het was 22:47 op een dinsdag. De telefoon trilde van een Slack-alert van de boekhouder van een klant: "De btw op order 18432 klopt niet. En 18433. En de volgende acht." Hun shop is een kleine Nederlandse B2B-leverancier van onderdelen, zo'n €4M omzet per jaar, vrijwel volledig aan andere btw-geregistreerde EU-bedrijven. 21% btw op een factuur met btw verlegd betekent dat de koper de leverancier betaalt en daarna maandenlang via verkeerde kanalen achter een teruggave aan moet. Geen bug die je tot de ochtend laat liggen.

Wij hadden die dag niets gedeployed. De klant ook niet. Het enige event in het WordPress-activiteitenlog was om 17:30 een automatische update van een EU-btw-plugin. Vier uur later zaten we aan de telefoon, in een staging-kloon en op een MySQL-prompt. Dit is wat er gebeurde, wat we draaiden, en wat we vooraf hadden willen hebben.

De setup waar het incident in viel

De shop draait WooCommerce 8.x op PHP 8.2 met MariaDB 10.6. Orders staan nog in de oude posts en postmeta, niet HPOS, omdat drie van hun plugins nog geen HPOS-support hebben. De B2B-afhandeling is verdeeld over twee extensies: één die een btw-nummerveld toevoegt aan de checkout en die valideert tegen de EU VIES-service, en één die _is_vat_exempt = 'yes' op de order zet zodra er een geldig nummer is. De tax-engine van WooCommerce ziet die exemption-flag en slaat de btw-berekening over. Standaard btw-verlegd-setup.

Die tweede plugin had om 17:30 een auto-update gekregen. In de release notes stond "verbeterde validatie-timing." Wat er werkelijk veranderd was: de validatie liep nu op woocommerce_checkout_create_order in plaats van woocommerce_checkout_update_order_meta. De nieuwe hook vuurt vóórdat de tax-totalen berekend worden, dus toen de plugin de exemption-flag zette, had de cart de btw al op 21% afgerond. De flag stond op de order, maar de line totals hadden de btw al ingebakken.

Een plugin die btw raakt en zichzelf bijwerkt is een productie-verandering die je niet hebt goedgekeurd. WooCommerce heeft een instelling om plugin-auto-updates shop-breed uit te zetten. Gebruik 'm.

Hoe de orders eruitzagen

Tussen 17:30 en 22:47 waren er elf B2B-orders binnengekomen voor in totaal €38.210. Alle elf hadden een geldig btw-nummer, de _is_vat_exempt-flag correct op yes gezet, en een tax total die €0,00 had moeten zijn. In plaats daarvan had elke order €6.632,43 aan btw verdeeld over de line items. De factuur-PDF was al automatisch gemaild naar negen van de elf kopers.

Eerste taak, vóór elke write: bepaal de blast radius. De query die we draaiden tegen de read replica:

SELECT p.ID                                       AS order_id,
       p.post_date,
       pm_email.meta_value                        AS billing_email,
       pm_vat.meta_value                          AS vat_number,
       pm_exempt.meta_value                       AS is_vat_exempt,
       CAST(pm_tax.meta_value AS DECIMAL(12,2))   AS order_tax,
       CAST(pm_total.meta_value AS DECIMAL(12,2)) AS order_total
FROM wp_posts p
JOIN wp_postmeta pm_email  ON pm_email.post_id  = p.ID AND pm_email.meta_key  = '_billing_email'
JOIN wp_postmeta pm_vat    ON pm_vat.post_id    = p.ID AND pm_vat.meta_key    = '_billing_vat_number'
JOIN wp_postmeta pm_exempt ON pm_exempt.post_id = p.ID AND pm_exempt.meta_key = '_is_vat_exempt'
JOIN wp_postmeta pm_tax    ON pm_tax.post_id    = p.ID AND pm_tax.meta_key    = '_order_tax'
JOIN wp_postmeta pm_total  ON pm_total.post_id  = p.ID AND pm_total.meta_key  = '_order_total'
WHERE p.post_type = 'shop_order'
  AND p.post_date >= '2026-06-02 17:30:00'
  AND pm_exempt.meta_value = 'yes'
  AND pm_vat.meta_value <> ''
  AND CAST(pm_tax.meta_value AS DECIMAL(12,2)) > 0
ORDER BY p.post_date;

Elf rijen. Twee daarvan stonden nog op wc-pending omdat de koper de iDEAL-redirect niet had afgerond. Die konden we laten staan, die zouden óf opnieuw afgerond worden óf 's nachts in de prullenbak verdwijnen. Negen waren betaald en nog niet verzonden, omdat het magazijn om 18:00 sluit. Dat was belangrijk. Het betekende dat we de facturen konden corrigeren voordat er fysiek goederen verplaatst werden.

Waarom we de plugin niet eerst terugrolden

De reflex bij zo'n incident is altijd: draai de wijziging terug. Dat hebben we niet gedaan, om één reden: de plugin terugrollen zou de elf bestaande orders niet alsnog repareren. Hun btw zat al geserialiseerd in wp_woocommerce_order_itemmeta. Een rollback repareert order twaalf. Hij doet niets voor orders één tot en met elf, en hij maakt de database moeilijker te lezen omdat sommige rijen plugin-versie A weerspiegelen en andere plugin-versie B. Fix eerst de data, op de versie die ze geproduceerd heeft, en rol pas dan terug.

De fix is niet alleen de btw op nul zetten

WooCommerce slaat geen enkel tax-getal per order op. Tax wordt op vier lagen opgeslagen: de per-line tax op elke wp_woocommerce_order_itemmeta-rij, de per-line shipping tax, een geserialiseerde _line_tax_data-array die de btw uitsplitst per rate ID, en de totalen die als _order_tax, _order_shipping_tax en _order_total in postmeta staan. Zet je het order total op nul maar laat je de line-item meta intact, dan herberekent WooCommerce de volgende keer dat iemand een refund doet, de order aanpast of zelfs maar in admin opent, opnieuw uit de line items en overschrijft je fix.

De procedure is dus: zet eerst de tax op nul bij de bladeren, dan pas de stam. En raak _order_total niet aan totdat je elke regel hebt gecorrigeerd.

We zetten de elf getroffen order-IDs in een tijdelijke tabel zodat elke volgende statement precies op die rijen scope had. Dit is verreweg de belangrijkste gewoonte als je chirurgie pleegt op een live database. Schrijf nooit een WHERE-clausule meer dan één keer als één tabel de bron van waarheid kan vasthouden.

CREATE TEMPORARY TABLE _fix_orders (order_id BIGINT PRIMARY KEY);

INSERT INTO _fix_orders (order_id)
SELECT p.ID
FROM wp_posts p
JOIN wp_postmeta pm_exempt ON pm_exempt.post_id = p.ID AND pm_exempt.meta_key = '_is_vat_exempt'
JOIN wp_postmeta pm_vat    ON pm_vat.post_id    = p.ID AND pm_vat.meta_key    = '_billing_vat_number'
JOIN wp_postmeta pm_tax    ON pm_tax.post_id    = p.ID AND pm_tax.meta_key    = '_order_tax'
WHERE p.post_type = 'shop_order'
  AND p.post_status IN ('wc-processing', 'wc-on-hold')
  AND p.post_date >= '2026-06-02 17:30:00'
  AND pm_exempt.meta_value = 'yes'
  AND pm_vat.meta_value <> ''
  AND CAST(pm_tax.meta_value AS DECIMAL(12,2)) > 0;

Daarna een back-up. Altijd. Vóór elke UPDATE op een productietabel.

mysqldump --single-transaction --quick \
  --where="post_id IN (SELECT order_id FROM _fix_orders)" \
  shop_prod wp_postmeta > /backups/postmeta-2026-06-02-2305.sql

mysqldump --single-transaction --quick \
  --where="order_item_id IN (SELECT order_item_id FROM wp_woocommerce_order_items
                              WHERE order_id IN (SELECT order_id FROM _fix_orders))" \
  shop_prod wp_woocommerce_order_itemmeta > /backups/order_itemmeta-2026-06-02-2305.sql

Met back-ups op disk en de orderset vastgezet, is de correctie vier updates binnen één transactie.

START TRANSACTION;

-- 1. Zero per-line tax on each order item.
UPDATE wp_woocommerce_order_itemmeta oim
JOIN wp_woocommerce_order_items oi ON oi.order_item_id = oim.order_item_id
SET oim.meta_value = '0'
WHERE oi.order_id IN (SELECT order_id FROM _fix_orders)
  AND oim.meta_key IN ('_line_tax', '_line_subtotal_tax');

-- 2. Empty the serialised tax-data array.
UPDATE wp_woocommerce_order_itemmeta oim
JOIN wp_woocommerce_order_items oi ON oi.order_item_id = oim.order_item_id
SET oim.meta_value = 'a:2:{s:5:"total";a:0:{}s:8:"subtotal";a:0:{}}'
WHERE oi.order_id IN (SELECT order_id FROM _fix_orders)
  AND oim.meta_key = '_line_tax_data';

-- 3. New order total = old total minus old tax.
UPDATE wp_postmeta pm_total
JOIN wp_postmeta pm_tax
  ON pm_tax.post_id = pm_total.post_id AND pm_tax.meta_key = '_order_tax'
SET pm_total.meta_value =
      CAST(pm_total.meta_value AS DECIMAL(12,2))
    - CAST(pm_tax.meta_value   AS DECIMAL(12,2))
WHERE pm_total.post_id IN (SELECT order_id FROM _fix_orders)
  AND pm_total.meta_key = '_order_total';

-- 4. Zero every tax bucket on the order.
UPDATE wp_postmeta
SET meta_value = '0'
WHERE post_id IN (SELECT order_id FROM _fix_orders)
  AND meta_key IN ('_order_tax', '_order_shipping_tax', '_cart_discount_tax');

-- Spot check before commit.
SELECT pm_total.post_id,
       CAST(pm_total.meta_value AS DECIMAL(12,2)) AS new_total,
       CAST(pm_tax.meta_value   AS DECIMAL(12,2)) AS new_tax
FROM wp_postmeta pm_total
JOIN wp_postmeta pm_tax ON pm_tax.post_id = pm_total.post_id AND pm_tax.meta_key = '_order_tax'
WHERE pm_total.post_id IN (SELECT order_id FROM _fix_orders)
  AND pm_total.meta_key = '_order_total';

COMMIT;

Negen nieuwe totalen, negen tax-waarden op nul, allemaal kloppend met de handmatige herberekening die we vijf minuten eerder in een spreadsheet hadden gedaan. We committen om 23:38.

De administratieve opruiming

De database corrigeren is de helft van het werk. De andere helft is papierwerk. Negen factuur-PDF's waren al de deur uit. De factuur-plugin cachet gegenereerde PDF's in een private uploads-map, dus die hebben we verwijderd, op die negen orders de factuurnummer-suffix opgehoogd van -A naar -B, en de generator opnieuw gedraaid. De plugin bewaart zijn nummerreeks in een options-rij, niet in postmeta, en dat soort details leer je alleen door om 23:50 in de source van de plugin te lezen.

Toen de e-mail. We schreven één korte boodschap in Nederlands en Engels, met de gecorrigeerde PDF als bijlage, legden uit dat de eerste PDF niet klopte en weggegooid kon worden, en stuurden hem om 00:14 vanuit de mailbox van de boekhouder, niet vanuit een no-reply. Niemand vertrouwt een no-reply als er geld in het spel is. Twee klanten hadden het verkeerde totaal al via SEPA betaald. Die kwamen op de refund-lijst voor de volgende dag, ongeveer €1.400 per stuk.

Pas daarna rolden we de plugin terug. De documentatie over order storage van WooCommerce is helder: writes van order-metadata tijdens checkout moeten klaar zijn voordat de tax-berekening loopt, en dat is precies het contract dat de plugin-update had gebroken. We pinden de vorige versie in composer.json en zetten plugin-auto-updates uit in wp-config.php:

define('WP_AUTO_UPDATE_CORE', 'minor');
add_filter('auto_update_plugin', '__return_false');

Wat we met de juiste alert in vijf minuten hadden gevangen

De fout stond vijf uur en twaalf minuten live. Elf orders. Negen die gecorrigeerd moesten worden. We vingen het omdat de boekhouder die avond toevallig keek. Had ze pas de volgende ochtend gekeken, dan had het magazijn naar negen bedrijven geleverd op een verkeerde factuur, en minstens twee daarvan waren niet meer volledig terug te draaien geweest zonder creditnota.

De detectiekosten waren één cron-gestuurde SQL-alert geweest. Iets in de buurt van de query bovenaan deze post, elke vijftien minuten gedraaid tegen de read replica, gepaged naar Slack op het moment dat hij een rijaantal boven nul teruggeeft. Bouwtijd in totaal, misschien negentig minuten. Hij had de regressie binnen het eerste venster gevangen.

Het juridische gewicht om dit goed te doen ligt in Artikel 196 van Richtlijn 2006/112/EG, de bron waar het EU-btw-verlegd-mechanisme op steunt. Die richtlijn trekt zich niets aan van een plugin-update. Hij eist alleen dat de factuur klopt op de dag van de levering.

Takeaway

Als een regressie in je checkout meer kost om op te ruimen dan de alert kost om te bouwen, heb je geen engineering-probleem. Dan heb je een ontbrekende alert.

De preventie-checklist die we hebben achtergelaten

De dag erna hebben we voor de klant een korte vaste playbook geschreven. Zes punten.

  1. Zet plugin- en theme-auto-updates uit op productie. Voer updates eerst handmatig door op staging, met een smoke-order door de checkout.
  2. Draai een SQL-alert van vijftien minuten die B2B-orders telt met een btw-nummer en een tax-waarde boven nul. Page bij elke telling boven nul.
  3. Maak dagelijks een diff van wp_options-rijen waarvan de option_name matcht met %tax% of %vat%. Plugin-updates schrijven vaak nieuwe option-keys die jij niet hebt goedgekeurd.
  4. Pin elke plugin rond payments, tax of orders op een exacte versie in composer.json. Geen version ranges voor deze categorie.
  5. Maak een back-up van wp_postmeta en wp_woocommerce_order_itemmeta vóór elke UPDATE, elke keer. Gebruik een tijdelijke tabel om writes te scopen.
  6. Houd één PHP-script in de repo dat de btw van een order opnieuw berekent vanuit de line items en terugschrijft, zodat het volgende incident een one-liner is, geen uur SQL.

Deze klant heeft nu alle zes. De cron-alert is in de vier maanden sindsdien twee keer afgegaan, beide keren ving hij een echte misconfiguratie binnen het venster van vijftien minuten. De bouwkosten waren drie uur. De kosten van het incident, inclusief onze tijd om middernacht en de twee SEPA-refunds, lagen dichter bij twaalf.

Het werk, niet de pitch

Toen we de order-monitor voor deze klant bouwden, was wat ons verbaasde hoeveel van de categorie WooCommerce-stuk eigenlijk plugin-contracten zijn die bij een update tegen elkaar stuk gaan, niet WooCommerce zelf. Onze vaste checklist voor elke WordPress- en WooCommerce-rescue begint nu met gepinde versies en een SQL-canary, vóór alles wat ambitieuzer is. Run je een B2B-shop op WooCommerce en steunt je btw op drie plugins die het met elkaar eens moeten zijn, draai dan vanavond de query bovenaan deze post tegen je eigen database. Komen er rijen terug, dan heb je huiswerk vóór de ochtend.

Kern

Een plugin-auto-update kan de btw op B2B-orders in minuten breken. Pin elke plugin die tax raakt, draai een SQL-canary van vijftien minuten, en maak een back-up voor elke UPDATE.

FAQ

Waarom niet gewoon later de verkeerde btw aan de koper terugbetalen?

Omdat de oorspronkelijke factuur het juridische document is onder de EU-btw-regels. Een refund corrigeert de factuur niet. Je geeft een creditnota plus een gecorrigeerde factuur uit, en dat is opruimwerk dat de boekhouder van de koper ook moet verwerken.

Kan WooCommerce de order-btw herberekenen vanuit de admin in plaats van SQL te draaien?

Ja, de Recalculate-knop op elke order werkt, maar bij elf orders zijn dat elf handmatige klikken zonder audit trail. Bij een incident wil je één transactie, een back-up, en de getroffen IDs gepinned in een tijdelijke tabel.

Geldt dit ook voor shops op HPOS in plaats van de oude order-tabellen?

De vorm van de fix is hetzelfde, maar de tabellen veranderen. Reads gaan tegen wp_wc_orders en wp_wc_orders_meta. De line tax blijft op wp_woocommerce_order_itemmeta staan. Test de queries op een staging-kloon voordat je iets draait.

Hoe bepaal je of je eerst de data fixt of eerst de plugin terugrolt?

Heeft de stukke versie de rijen geproduceerd die je moet repareren, fix dan de data op die versie. Te vroeg terugrollen betekent dat je over rijen in twee verschillende schema's moet nadenken. Stabiliseer, repareer, rol dan terug.

e-commercewordpressmysqlphpcase studyoperations

Iets bouwen?

Start een project