The technical debt of custom WooCommerce order-completion code

Engineering
19 September 2025

There is a block of code in functions.php on most WooCommerce stores that handles virtual order completion. It was written in an afternoon, it works, and nobody touches it. Commented only with // Handles virtual orders, it has survived three major WooCommerce updates through luck, not design.

This is the canonical example of e-commerce technical debt: a trade-off that made sense when it was made and costs more every month it stays in production.

What the debt actually costs

The debt comes due in a few predictable ways.

Maintenance without context. The developer who wrote the snippet moves on. The comment says what it does but not why the specific conditions were chosen. A new developer inherits it, does not understand which edge cases it handles, and is afraid to change it. The code becomes untouchable.

Silent breakage. WooCommerce 8.x moved orders to HPOS. Any snippet that reads order data using get_post_meta() or WP_Query started returning empty results. The snippet kept running, logged no errors, and completed nothing. The first sign of the break was a support ticket.

Compounding complexity. The original snippet handled the simple case. Then subscriptions were added, and a conditional got bolted on. Then a high-value order category needed manual review, so another check was added. The snippet now has three authors, two commented-out branches, and no tests. Changing any part of it requires understanding all of it.

What the correct version looks like

The common snippet uses the thank-you page hook:

// Problematic: fires before the webhook may have arrived
add_action( 'woocommerce_thankyou', function ( $order_id ) {
    $order = wc_get_order( $order_id );
    if ( $order && $order->has_status( 'processing' ) ) {
        $order->update_status( 'completed' );
    }
} );

This runs on page load, before many gateways have sent their webhook. The order is still pending when the hook fires, so it does nothing. Then the webhook arrives, moves the order to processing, and nothing completes it.

A more correct version uses the status transition hook and checks product types:

add_action( 'woocommerce_order_status_processing', function ( $order_id ) {
    $order = wc_get_order( $order_id );
    if ( ! $order ) return;

    foreach ( $order->get_items() as $item ) {
        $product = $item->get_product();
        if ( $product && ! $product->is_virtual() && ! $product->is_downloadable() ) {
            return;
        }
    }

    $order->update_status( 'completed', 'Auto-completed: all items virtual.' );
} );

This is better. It uses the CRUD layer correctly (no get_post_meta()), fires at the right moment, and checks product types before acting.

It still does not handle subscription renewals, where woocommerce_order_status_processing fires the same as for a standard order but where your subscription plugin may expect to control the completion. It does not handle the case where you later add a physical product to your catalogue and forget the snippet exists. It requires a code deployment to change the logic, and it logs nothing about what it decided.

The maintenance trade-off

Writing the hook correctly is not the hard part. Maintaining the edge-case conditions as the store evolves is. Each new product type, each new gateway, each new plugin that touches the order lifecycle is a potential interaction with the snippet that nobody will think to test.

The professional path is not necessarily to stop writing custom code. It is to be precise about which problems justify a maintained code dependency and which ones have better solutions.

For auto-completing virtual orders, the rule is simple enough that a rule engine handles it without code: when status is processing, if all items are virtual or downloadable, complete the order. Order Daemon runs that check asynchronously, logs the result on the order timeline, and handles the subscription-renewal case as a condition rather than a code branch you maintain.

The lifecycle map covers the full set of hooks if you are building the custom solution. If you want the rule engine without the maintenance surface, install the free plugin.