— Artikel — № 115

115 —WordPress

WooCommerce schrijft twee keer af: dubbele Stripe-hook

Twee payment intents, drie seconden uit elkaar, dezelfde order, dezelfde kaart. Een incident-walkthrough van een dubbele hook verstopt in mu-plugins.

Foto van boven op linnen: papieren Stripe webhook tijdlijn, manilla map, indexkaart, ruitjespapier, messing label, lakzegel.
Hero · gestileerd stilleven№ 115

Om 23:41 op een dinsdag kwam er een Loom binnen van een lead bij een agency in Utrecht. Onderwerp: 'Stripe heeft de klant twee keer afgeschreven en de klant ligt nog wakker.' De opname duurde twee minuten. Te zien waren een WooCommerce thank-you page (Order #48211, €148,50), een Stripe dashboard met twee succesvolle payment intents voor dezelfde order, en een Slack-thread met een vermoeide ops engineer die vroeg wat er als eerste terugbetaald moest worden. De dubbele afschrijving op WooCommerce bleek een dubbele hook-registratie te zijn. De trace duurde een uur; de les zat in mu-plugins.

De webshop draaide al zes jaar. De dubbele afschrijving was begonnen op de zondag ervoor. Op dinsdag had het al elf klanten geraakt en stonden er drie chargebacks open. Het agency had iedereen al twee keer terugbetaald (eenmaal via Stripe, eenmaal als store credit in de winkel), maar ze wisten nog steeds niet waarom de tweede afschrijving plaatsvond. Ze vroegen ons om mee te kijken omdat de verouderde site die ze hadden overgenomen van een vorige developer een kluwen aan plugins bevatte die nooit volledig in kaart was gebracht.

Dit artikel loopt door de trace die de oorzaak vond. De bug was een dubbele woocommerce_payment_complete hook die door twee aparte plugins werd geregistreerd, en het pad van 'twee afschrijvingen op een kaart' naar 'de regel in de plugin die het veroorzaakte' is leerzamer dan de fix zelf.

Twee intents, drie seconden uit elkaar

Beide payment intents hadden dezelfde metadata.order_id. Beide stonden op succeeded. De eerste werd om 21:14:08 UTC aangemaakt. De tweede om 21:14:11 UTC. Drie seconden ertussen. Zelfde kaart, zelfde bedrag, zelfde statement descriptor. Verschillende idempotency_key waarden. Dat laatste detail was de cruciale: als de twee requests dezelfde idempotency key hadden meegestuurd, had Stripe het cached antwoord teruggegeven en maar één keer afgeschreven. Twee keys betekenen twee aparte API-calls. Stripes idempotency-contract kijkt naar de header, niet naar het ordernummer, dus de WooCommerce-kant moet uniciteit zelf afdwingen.

De eerste intent werd aangemaakt door de officiële woocommerce-gateway-stripe plugin. Dat zagen we aan het description veld: het volgde het template van de gateway ('Order 48211 for store.example.com'). De tweede intent had een andere description ('Capture for order 48211 (renewal-ready)'). Die string kwam nergens voor in de broncode van de officiële gateway. Iemand anders had die geschreven, en die iemand draaide op dezelfde site.

De hook traceren

De WooCommerce payment-flow staat beschreven in de gateway API reference. De relevante action is woocommerce_payment_complete, die door WC_Order::payment_complete() wordt afgevuurd zodra een order naar processing of completed gaat. Plugins luisteren erop om downloads vrij te geven, subscriptions te verwerken, naar ERP's te pushen, Klaviyo-events te sturen, enzovoort. Alles met een side effect woont daar meestal.

We begonnen waar we altijd beginnen op een multi-plugin site: een hook dump. Op een staging-mirror van de productiedatabase voegden we het volgende toe aan wp-content/mu-plugins/000-pier-debug-hooks.php:

<?php
add_action('woocommerce_payment_complete', function ($order_id) {
    global $wp_filter;
    $callbacks = $wp_filter['woocommerce_payment_complete']->callbacks ?? [];
    error_log("[pier-trace] order=$order_id hook fired, registered callbacks:");
    foreach ($callbacks as $priority => $entries) {
        foreach ($entries as $key => $cb) {
            error_log("  priority=$priority callback=$key");
        }
    }
}, 1);

Priority 1 zodat het logt voordat iets anders afvuurt. Daarna speelden we de checkout opnieuw af met een Stripe test card (4242 4242 4242 4242) en tailden we wp-content/debug.log. De output was:

[pier-trace] order=48312 hook fired, registered callbacks:
  priority=10 callback=WC_Subscriptions_Manager::process_subscription_payments_on_order
  priority=10 callback=wc_paying_customer
  priority=20 callback=ace_stripe_capture_renewal_fee
  priority=20 callback=woocommerce_gateway_stripe_capture_postpay

Twee onbekende callbacks op priority 20. De eerste, ace_stripe_capture_renewal_fee, kwam uit een plugin genaamd ace-subscription-tools die het agency vier jaar geleden van een vorige developer had overgenomen. De tweede, woocommerce_gateway_stripe_capture_postpay, stond in wp-content/mu-plugins/stripe-postpay-helper.php. Een bestand waarvan niemand in het huidige team zich herinnerde dat het was geschreven of in deploy-diffs had gezien.

Beide riepen onafhankelijk van elkaar \Stripe\PaymentIntent::create() aan zodra de hook afvuurde. Beide slaagden. Vandaar de twee afschrijvingen.

De volgorde bevestigden we door een microseconde-precieze timestamp aan de trace toe te voegen en opnieuw te draaien. De ace_stripe_capture_renewal_fee callback vuurde ongeveer zes microseconden voor die van de mu-plugin. WordPress draait callbacks met dezelfde priority in volgorde van registratie, en dat kwam netjes overeen met de plugin load order: ace-subscription-tools registreerde zijn hook op plugins_loaded, de mu-plugin registreerde bij het includen van het bestand. Die volgorde vertelde ons welke intent we per order moesten terugbetalen en welke we in de backfill moesten behouden.

Herkomst van de mu-plugin

De mu-plugin was in 2022 toegevoegd om een eenmalige bug op te lossen waarbij een bepaald subscription-product zijn eerste factuur niet netjes captureerde. De fix was destijds om te luisteren op woocommerce_payment_complete en een capture te forceren als de order-metadata een specifieke flag bevatte. Zes maanden later haalde het agency die flag uit elk product, en de functie zou moeten short-circuiten. De originele guard clause was:

if (get_post_meta($order_id, '_needs_postpay_capture', true) === 'yes') {
    capture_now($order);
}

Zonder de flag had de functie vroegtijdig moeten terugkeren. Behalve dat afgelopen april iemand capture_now had gerefactord om de order zelf mee te krijgen, de meta-check tijdens het debuggen had verwijderd, en gecommit had zonder hem terug te zetten. De functie draaide vanaf dat moment op elke payment-complete event voor elke order. Het werd pas zichtbaar toen de ace-subscription-tools plugin afgelopen zondag een 4.1 update uitbracht die dezelfde live Stripe secret key ging gebruiken. Daarvoor faalde de mu-plugin stilletjes op een verouderde test key, dus de tweede afschrijving wierp een 401 en kwam nooit op de kaart terecht. De 4.1 update synchroniseerde de key over plugins heen. Die synchronisatie maakte van een sluimerende bug een actieve dubbele afschrijving.

De patch, in de volgorde die ertoe doet

De reflex bij een billing-incident is om het foute bestand te verwijderen en te reloaden. Dat hebben we niet gedaan. Er werden al negen dagen lang twee afschrijvingen per order verstuurd, wat betekende dat de mu-plugin de tweede schrijver was op enkele tientallen orders waarvan de processing-status impliciet aannam dat hij gedraaid had. Hem er zonder plan uittrekken zou die orders in een staat achterlaten die niemand had gemodelleerd.

De patch was drie stappen, in deze volgorde uitgevoerd:

  1. Stop de bloeding. Voeg een harde guard toe bovenaan de handler van de mu-plugin: if (defined('WC_PIER_DISABLE_POSTPAY') && WC_PIER_DISABLE_POSTPAY) return;. Zet de constante in wp-config.php. Nieuwe dubbele afschrijvingen stoppen binnen één deploy, en je kunt de constante terugflippen zonder code te hoeven herdeployen mocht de rollback iets anders blijken te breken.
  2. Maak de klanten heel. Vraag de Stripe API op voor elke payment_intent die de mu-plugin in de laatste veertien dagen heeft aangemaakt, groepeer op metadata.order_id, en betaal de tweede van elk paar terug. We pinden dit vast op de description-string die de mu-plugin had geschreven, want dat was het enige wat de twee intents onderscheidde:
$stripe = new \Stripe\StripeClient(STRIPE_SECRET_KEY);
$cursor = null;
do {
    $page = $stripe->paymentIntents->all([
        'limit'         => 100,
        'created'       => ['gte' => strtotime('-14 days')],
        'starting_after'=> $cursor,
    ]);
    foreach ($page->data as $pi) {
        if (strpos($pi->description ?? '', 'renewal-ready') === false) continue;
        $stripe->refunds->create([
            'payment_intent' => $pi->id,
            'reason'         => 'duplicate',
            'metadata'       => ['refunded_by' => 'pier-incident-2026-06'],
        ]);
        // log to wp_pier_refund_audit for finance reconciliation
    }
    $cursor = end($page->data)->id ?? null;
} while ($page->has_more);
  1. Maak de wond schoon. Pas daarna, met een refund-ledger in de hand, verwijder je de mu-plugin, update je de runbook, en schrijf je een regression check die de build laat falen als er een nieuwe callback op woocommerce_payment_complete wordt geregistreerd zonder expliciete allow-list entry.

Volgorde is belangrijk. Stap 1 stopt de bloeding. Stap 2 maakt de klant heel en levert een audit trail die finance kan afstemmen. Stap 3 is het opruimen. 1 en 3 omdraaien betekent dat je tijdens reconciliatie blijft afschrijven. Stap 2 overslaan betekent dat je geld schuldig bent dat je niet aan facturen kunt koppelen.

Hook-hygiëne die dit had voorkomen

Een paar gewoontes hadden de dubbele afschrijving eerder gevangen. Geen ervan is exotisch.

Behandel payment-complete als een write boundary

Alles wat op woocommerce_payment_complete wordt geregistreerd en met een payment processor praat, moet je behandelen als een schrijfactie naar de bankrekening van de klant. Twee schrijfacties vragen om één van drie guards: een idempotency key die is afgeleid van het order-ID, een database-level 'already-captured' flag die binnen een transactie wordt gecontroleerd, of een keiharde weigering om twee keer af te schrijven binnen dezelfde request-lifecycle. De officiële Stripe-gateway gebruikt de eerste. De meeste custom code gebruikt geen enkele.

Audit elke mu-plugin op overgenomen sites

Must-use plugins zijn onzichtbaar vanuit de WordPress-admin en laden bij elke request voor de gewone plugins. Elk agency dat een site overneemt, zou op dag één wp-content/mu-plugins/ moeten greppen en in gewone taal moeten opschrijven wat elk bestand doet. Als niemand het weet, is het antwoord 'kopieer het naar staging, comment de hook-registraties uit, kijk een week of er iets breekt, verwijder het dan in productie.' De kosten van een onverwachte mu-plugin die jaren blijft draaien, worden afgerekend in incidenten zoals deze.

Log hook-registraties bij elke deploy

Een script van twee regels dat bij elke deploy de callback-lijst van elke WooCommerce-action in een bestand onder versiebeheer dumpt, had deze botsing opgemerkt op het moment dat de tweede key-sync landde. De diff was één nieuwe regel onder woocommerce_payment_complete geweest, en iedereen die de deploy log las, had het opgemerkt. WP_Hook stelt de lijst beschikbaar als $wp_filter['woocommerce_payment_complete']->callbacks, en dat is alles wat je nodig hebt om doorheen te lopen.

Scope API-keys per integratie

De dubbele afschrijving hier sluimerde negen maanden omdat één plugin een verouderde test key had. Op het moment dat de ace-subscription-tools 4.1 release hem op de live key bracht, werd de bug zichtbaar. Als je elke plugin STRIPE_SECRET_KEY uit een gedeelde constante laat lezen, erf je elke aanname die de laatste release meeleverde. Werk liever met plugin-specifieke instellingen die een integrator expliciet moet invullen, en documenteer welke een gegeven codepath leest. De blast radius van een verkeerd geregistreerde hook wordt begrensd door welke keys hij kan bereiken.

De audit, geautomatiseerd

Toen we Pier bouwden, liepen we bij genoeg legacy WordPress-audits tegen precies deze categorie problemen aan dat we het niet meer ad-hoc behandelden. De manier waarop we het uiteindelijk aanpakten: Pier dockt met de FTP en MySQL van een WordPress-installatie, laat je in gewone taal vragen 'wat luistert er op woocommerce_payment_complete over alle plugins en mu-plugins heen', en houdt een versiegeschiedenis bij van elk bestand dat je aanraakt, zodat je een patch kunt terugdraaien op het moment dat iets er vreemd uitziet. De MySQL editor op dezelfde dock laat je de orders-tabel scannen op dubbele captures terwijl je toch bezig bent.

Als je vandaag één ding doet, grep dan je eigen wp-content/mu-plugins/ directory en lees elk bestand erin. De meeste zijn prima. Degene die dat niet is, vind je liever vóór de bank van een klant hem vindt.

— Vragen —

Waarom heeft Stripe de tweede afschrijving niet automatisch geblokkeerd?

Stripe blokkeert duplicates alleen als dezelfde idempotency key wordt hergebruikt in de request header. Twee aparte plugins genereerden twee verschillende keys, dus Stripe behandelde ze als losse intents en verwerkte beide.

Kan ik gewoon de tweede plugin uitschakelen en doorgaan?

Niet veilig. Als orders dagenlang dubbel zijn gecaptured, kan downstream state (subscriptions, fulfilment-flags) al afhankelijk zijn van beide runs. Stop eerst nieuwe afschrijvingen, betaal de duplicates terug, verwijder dan pas de code.

Hoe controleer ik of mijn eigen WooCommerce-site een dubbele hook heeft?

Voeg een kleine mu-plugin toe die $wp_filter['woocommerce_payment_complete']->callbacks logt op het moment dat de action afvuurt. Elke onbekende callback die de Stripe API aanroept, is degene die je moet onderzoeken.