Frequently bought together from WooCommerce order history: confidence, lift and refunds

How DEV3 AI Recommendations & Upsells counts co-purchases from WooCommerce orders with HPOS-safe incremental counters, scores pairs by confidence weighted by lift, reverses refunds exactly once and keeps the storefront query-free.

DSDilawar Singh 12 min read 1 views
Frequently bought together from WooCommerce order history: confidence, lift and refunds

"Frequently bought together" looks like the simplest recommendation there is: count which products appear in the same orders and show the top ones. In practice, the naive version recommends your best-selling tote bag next to every product, turns one odd order into a permanent "bundle", keeps recommending products from orders that were refunded weeks ago, and, if it runs at page load, scans the order history on every product view. When we built DEV3 AI Recommendations & Upsells for WooCommerce, the co-purchase engine was the part we cared most about getting right, because every placement depends on it: bundle boxes, cart widgets, checkout order bumps, thank-you offers and the email section. This post covers what the plugin does and how to use it, then walks through how the engine counts, scores and stays correct as orders change status.

What it does and who it's for

The plugin puts recommendations everywhere a WooCommerce store sells, built from the store's own orders rather than hand-picked lists:

  • Stores with repeat combinations (coffee and filters, cameras and memory cards, skincare routines) get a "frequently bought together" bundle box with checkboxes, a live total and an optional bundle discount.
  • Stores that want a bigger basket add a checkout order bump, in both the classic checkout and the block checkout's order summary.
  • Stores with offline payments (bank transfer, cheque, cash on delivery) can add a thank-you-page offer straight to the order just placed; other payments use a short express second checkout.
  • Merchants who want proof get revenue attribution per widget and A/B tests with a significance check, instead of guessing.
  • New catalogues with little order history still get sensible picks from similarity, shared categories and DEV3 AI complementary suggestions.

Bundle box on a product page A bundle box built from co-purchase statistics, with a live total and a 10% bundle discount.

How to set it up

  1. Install and activate the plugin (WooCommerce 8.0+, WordPress 6.0+, PHP 7.4+).
  2. Optionally add a Google Gemini API key in API Config for similarity and AI copy. Everything else works without one.
  3. In Engine, click Rebuild stats to analyse the last 365 days of paid orders. With a key, click Build index too.
  4. Review the six default widgets in Widgets (product page, cart, checkout, thank-you page, emails) and edit, pause or resume each one.

How to use it

Tune a widget. Open it in Widgets, pick the position, strategy, layout and discount, and check the live preview for a few products. Write with DEV3 AI drafts the headline, subtitle and button label; nothing is saved until you click Save changes.

Fix one product. In Product explorer, see each strategy's picks with their reasons and scores, plus the orders, confidence and lift behind "bought together". Pin or exclude products, or ask DEV3 AI for complementary picks when a product has no history yet.

Order bump in the block checkout The checkout order bump in the block checkout's order summary.

Test and keep the winner. Give a widget a variant B, set the traffic split, and start the test in A/B tests. When one variant is clearly ahead, Declare winner copies it into the widget.

A/B test results A/B test results with demo data: per-variant orders and revenue, and confidence from a two-proportion z-test.

Two tables instead of a scan

The engine keeps two small tables, created with dbDelta alongside the plugin's widget and stats tables:

  • dev3ru_product_stats: one row per product with orders, units, revenue, orders_30d and last_order_at.
  • dev3ru_pairs: one row per ordered pair (product_id, related_id) with orders and last_order_at, primary key on the pair and a secondary index on (product_id, orders).

Pairs are stored in both directions. That doubles the row count, but the only query the storefront path ever needs is "the strongest partners of product A", and with both directions stored it is a single index range scan on product_id, already ordered by orders. No OR, no UNION, no self-join.

A third number, the total count of orders analysed, lives in an option. Those three counts are all the statistics need.

Reading orders without touching wp_posts

Since WooCommerce moved to High-Performance Order Storage, any plugin that queries wp_posts for orders is reading a table that may no longer be authoritative. The engine reads orders only through WooCommerce's CRUD layer: wc_get_orders() for ids and WC_Order::get_items() for lines. The line reader also does the first piece of normalisation:

foreach ( $order->get_items( 'line_item' ) as $item ) {
	if ( ! $item instanceof \WC_Order_Item_Product ) {
		continue;
	}
	$id = (int) $item->get_product_id();
	if ( $id <= 0 ) {
		continue;
	}
	if ( ! isset( $out[ $id ] ) ) {
		$out[ $id ] = array( 'qty' => 0, 'total' => 0.0 );
	}
	$out[ $id ]['qty']   += max( 0, (int) $item->get_quantity() );
	$out[ $id ]['total'] += (float) $item->get_total();
}

(condensed from Orders::lines)

get_product_id() returns the parent for a variation, so a medium blue shirt and a large red one both count as "Shirt". Splitting counts across variations would push most pairs below the support threshold. Duplicate lines collapse too, so an order counts each product once.

One PHPUnit test turns HPOS on, asserts the order's data store really is OrdersTableDataStore, and checks the pair shows up; another orders a variation and asserts the parent is recommended.

Incremental counting that survives refunds

Orders flowing into the co-purchase engine Illustration: paid orders add co-purchase pairs; refunds and cancellations take them away again.

A full rebuild exists (more on that below), but the normal path is incremental. The engine hooks woocommerce_order_status_changed and only cares about one thing: did the order cross the boundary between "paid" and "not paid"?

public function status_changed( $order_id, $from, $to, $order = null ) {
	$order    = $order instanceof \WC_Order ? $order : wc_get_order( $order_id );
	$paid_now = Orders::is_paid_status( $to );
	$paid_was = Orders::is_paid_status( $from );
	if ( $paid_now && ! $paid_was ) {
		self::add_order( $order );
	} elseif ( ! $paid_now && $paid_was ) {
		self::remove_order( $order );
	}
}

(condensed from Copurchase::status_changed)

Paid statuses default to processing and completed and can be changed with a setting or the dev3ru_paid_statuses filter. Going from processing to completed is paid-to-paid, so nothing happens. Going to refunded or cancelled is paid-to-unpaid, so the order is subtracted.

Status hooks are not a reliable source of exactly-once events, though. Orders get re-saved, imported, edited by other plugins and moved back and forth by hand. So add_order and remove_order share one guarded function, and the guard is a flag on the order itself:

$counted = (bool) $order->get_meta( self::META_COUNTED );
if ( ( $sign > 0 && $counted && ! $force ) || ( $sign < 0 && ! $counted ) ) {
	return false;
}

(from Copurchase::apply)

An order is added only if it isn't already counted, and removed only if it is. The flag is order meta, so it goes through the same HPOS-aware CRUD as everything else. The tests cover both directions: processing then completed leaves the product at 1 order, and refunded followed by cancelled subtracts once and then does nothing.

The writes themselves

For each product in the order, the engine upserts the product's stats row and one pair row for every other product in the same order:

$wpdb->query( $wpdb->prepare(
	"INSERT INTO {$pairs} (product_id, related_id, orders, last_order_at) VALUES (%d, %d, 1, %s)
	 ON DUPLICATE KEY UPDATE orders = orders + 1, last_order_at = GREATEST(COALESCE(last_order_at, %s), %s)",
	$id, $other, $date, $date, $date
) );

(condensed from Copurchase::apply)

INSERT … ON DUPLICATE KEY UPDATE makes each write a single atomic statement, so two orders paid at the same moment can't lose an increment the way a read-modify-write in PHP could. GREATEST(…) keeps last_order_at correct even when an older order is counted late, for example during a rebuild.

Subtraction is an UPDATE with GREATEST(0, CAST(orders AS SIGNED) - 1); the cast avoids an out-of-range error on unsigned columns in strict SQL mode. Pair rows that fall to zero stay in place: the support threshold filters them out.

The engine counts at most 30 distinct products per order (MAX_LINES). Pairs grow quadratically, so a 30-product order already means 870 pair upserts, and wholesale orders with hundreds of lines say little about what goes together.

Scoring: why confidence alone isn't enough

With counts in place, the classic association-rule measures fall out directly. For a product A and a candidate B, over N counted orders:

  • Support is the number of orders containing both, n(A,B).
  • Confidence is P(B|A) = n(A,B) / n(A): of the orders with A, the share that also had B.
  • Lift is P(A,B) / (P(A)·P(B)) = n(A,B)·N / (n(A)·n(B)): how much more often they appear together than chance would predict.

Each measure fails on its own. Confidence rewards popularity: if 40% of all orders contain a tote bag, the tote bag has a confidence near 0.4 with almost everything, so it wins every "bought together" box. Lift fixes that, but rewards rarity: two products that each sold once, in the same order, have an enormous lift from a single data point.

The plugin uses confidence weighted by the logarithm of lift, plus a minimum support:

public static function score( $pair_orders, $a_orders, $b_orders, $total ) {
	$pair_orders = max( 0, (int) $pair_orders );
	$a_orders    = max( $pair_orders, (int) $a_orders );
	$b_orders    = max( $pair_orders, (int) $b_orders );
	$total       = max( $a_orders, $b_orders, (int) $total, 1 );
	if ( 0 === $pair_orders || 0 === $a_orders || 0 === $b_orders ) {
		return array( 'score' => 0.0, 'confidence' => 0.0, 'lift' => 0.0 );
	}
	$confidence = $pair_orders / $a_orders;
	$lift       = ( $pair_orders * $total ) / ( $a_orders * $b_orders );
	return array(
		'score'      => round( $confidence * log( 1 + $lift ), 6 ),
		'confidence' => round( $confidence, 4 ),
		'lift'       => round( $lift, 4 ),
	);
}

(from Copurchase::score)

log(1 + lift) behaves the way we want at both ends. A pair at chance level (lift 1) keeps about 69% of its confidence, a pair that never co-occurs more than chance is pulled down, and a pair with a very high lift gets a boost that grows slowly instead of linearly, so it can't drown out a strong, well-supported partner. Minimum support (2 orders by default, configurable from 1 to 100) removes the single-order flukes before scoring happens at all.

The first lines are defensive: the counts live in separately updated tables, so clamping each denominator to at least the pair count, and the total to at least the largest product count, means the function can never divide by zero or return a confidence above 1. The fixture test checks exactly this: 10 orders, A in 5, B in 4, both in 3 gives confidence 0.6, lift 1.5 and a score of 0.6 × ln 2.5; all-zero denominators return a positive score instead of an error.

The query that feeds this runs only when a neighbour list is computed, and it is bounded:

SELECT p.related_id, p.orders, COALESCE(s.orders, 0) AS b_orders
FROM {pairs} p LEFT JOIN {stats} s ON s.product_id = p.related_id
WHERE p.product_id = %d AND p.orders >= %d
ORDER BY p.orders DESC LIMIT 100

(from Copurchase::related)

The 100 strongest pairs by raw count are re-ranked by score in PHP.

Precompute, then cache the HTML too

Even one indexed query per widget adds up on a busy product page with several placements, so the storefront doesn't run it either. A background job walks the catalogue in chunks of 25 products and stores two packed lists in product meta: _dev3ru_fbt from the scoring above, and _dev3ru_similar from embeddings or, without them, shared categories and tags. Each list holds the top 24 [id, score] pairs. Product meta is loaded with WordPress's meta cache when the product is, so on the storefront the lists are effectively free.

The incremental path deletes _dev3ru_fbt only for the products in the order that changed. The next request that needs one of those lists recomputes it from the tables and stores it again, so updates are lazy and local instead of a catalogue-wide recompute after every sale.

Above the neighbour lists sits a second layer: the rendered widget HTML, cached per widget, A/B variant, product or cart contents, currency, locale and tax display. Invalidating that correctly is the classic hard problem, and the plugin side-steps it with a generation counter:

public static function key( array $parts ) {
	$parts['gen']      = self::generation();
	$parts['currency'] = function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : '';
	$parts['locale']   = determine_locale();
	$parts['tax']      = get_option( 'woocommerce_tax_display_shop', 'excl' );
	$parts             = apply_filters( 'dev3ru_fragment_key_parts', $parts );
	return Cache::key( self::TASK, $parts );
}

(condensed from Fragment_Cache::key)

Every key contains the current generation. Counting an order, saving a product, a stock status change, a rebuild or a settings save calls bump(), which increments one autoloaded option. Every old fragment is now unreachable in O(1); the rows expire through their TTL (6 hours by default) and a daily prune. The cost is that one sale invalidates every fragment, not just the affected ones. We accepted that: a recompute reads meta that is already cached, while per-product invalidation would mean tracking which cart fragments mention which products.

Two rendering tests pin this down. One renders a widget twice and asserts one miss, then one hit, and zero outgoing HTTP requests: no AI call ever happens on page load. The other sets a recommended product out of stock and asserts the next render is a fresh miss that no longer contains it.

Cart context and blending

In the cart there are several anchors, weighted by line value so a 200-dollar grinder outweighs a pack of filters. Recommender::anchors() normalises the weights to sum to 1 and blend() adds each anchor's neighbour list times its weight; cart items are excluded and merchant pins go first.

When a product has fewer co-purchase partners than the widget wants, FBT falls back to the "goes well with" blend of co-purchase, embedding similarity (half weight within the same category, to favour complements over near-duplicates), WooCommerce cross-sells, taxonomy and popularity. That is how a brand-new product still gets a sensible bundle box.

Rebuilds, and the one counter that can't be incremental

A full rebuild is still needed after an import, a change of paid statuses or a change to the history window (365 days by default). It truncates the pairs table, zeroes the stats, collects paid order ids through wc_get_orders(), and counts them in a background job in chunks of 50 orders, using the same apply() with $force set so the counted flags are refreshed. When it finishes, it records the orders and pair counts on the Engine screen and queues the neighbour compute job. The test for it corrupts every pair count to 99 on purpose, runs the job, and checks that the real counts and the neighbour meta come back.

orders_30d, which drives the "trending" strategy, is the one number that can't be maintained incrementally. A counter can be incremented when an order arrives, but nothing happens on the day that order turns 31 days old. So a daily cron recounts it from the last 30 days of paid orders (capped at 3,000 orders) and overwrites the column. A test backdates an order by 60 days and checks that its products drop out of the trending list.

What it adds up to

The storefront does no heavy work, the numbers stay correct through refunds and status shuffling, and the scores resist both the "bestseller everywhere" and "one-order bundle" failures. Of the plugin's 79 PHPUnit tests, 9 cover the co-purchase engine and 10 the recommender on top of it. The AI features (offer copy, cold-start picks, bundle pitches, A/B insights) sit on top of this layer and were tested with mocked Google Gemini API responses.

The design decisions, the A/B testing and the post-purchase offers are covered in the case study. The plugin ships in the DEV3 AI WooCommerce Plugins Bundle on the DEV3 marketplace, alongside 20 other WooCommerce plugins, and is being prepared for CodeCanyon.

DS

Written by

Dilawar Singh

CTO, DEV3 Technologies · ClinicPro, Inventra & WorkPro

Dilawar Singh is the CTO of DEV3 Technologies. He leads the engineering of DEV3’s own products — ClinicPro, Inventra and WorkPro — which are built, sold and supported by the DEV3 team from Mohali, India.

Keep reading

All articles