Field Fix

WooCommerce Revenue Does Not Match GA4

Some gap is normal and expected. The question is whether yours is the normal kind, and the answer is usually in tax handling or timezones rather than anything exotic.

Get My Free Audit →
Symptoms

Are you seeing this?

  • GA4 revenue is consistently lower or higher than your WooCommerce reports
  • The gap varies by market rather than staying proportional
  • Revenue matches on some days and diverges on others
  • Refunds appear in WooCommerce but never reduce GA4 revenue
  • Your finance team has stopped trusting the analytics numbers entirely

First: how much variance is normal?

A gap of roughly 10–20% with GA4 reporting lower than your store is expected on most European e-commerce sites. Ad blockers, consent denials, and cross-device journeys all remove events that genuinely happened. This is not a bug and cannot be fully eliminated.

What is not normal: GA4 reporting higher than your store, a gap that changes direction, a gap that differs sharply between markets, or a gap above roughly 30%. Those have causes you can find and fix.

Causes

Why it happens

Tax is handled inconsistently.

Revenue includes VAT in one storefront and excludes it in another, usually because the markets were configured by different people at different times. Every cross-market comparison in the property is then wrong, and nothing about the reports looks broken.

Timezones differ between GA4 and WooCommerce.

The GA4 property has its own timezone setting, independent of your WordPress configuration. If they differ, orders near midnight land on different days in each system. Daily comparisons diverge while monthly totals look roughly fine — which is a confusing signature to diagnose.

Refunds are never sent.

WooCommerce reduces net revenue when you refund. GA4 does not know a refund happened unless you send a refund event. Over a quarter, on a store with normal return rates, this alone produces a substantial permanent gap.

Shipping is included in one and not the other.

The value parameter includes shipping in your dataLayer but your store reporting excludes it, or the reverse. Small per order, systematic in aggregate.

Order statuses are counted differently.

GA4 records the purchase when the thank-you page loads. WooCommerce reporting typically counts completed orders. Failed payments, cancellations, and pending bank transfers appear in GA4 and never become revenue.

Multi-currency conversion is applied inconsistently.

Orders are recorded in local currency in one system and converted in the other, using a rate that may not match. The gap then tracks exchange rate movement, which makes it look random.

Diagnose

How to diagnose it

01

Step 01 — Confirm both systems use the same timezone.

GA4 Admin → Property Settings → Reporting time zone. Compare against WordPress Settings → General. If they differ, fix that before investigating anything else — it will distort every other comparison you attempt.

02

Step 02 — Compare a single day, not a month.

Monthly totals hide compensating errors. Pick one day, pull the order list from WooCommerce, pull transactions from GA4, and compare order by order. The pattern in which orders are missing tells you more than any aggregate.

03

Step 03 — Segment by market.

If you run multiple storefronts, calculate the gap per market. A gap that is proportionally identical across markets points at blockers and consent. A gap that varies sharply points at configuration — usually tax.

04

Step 04 — Test the tax question directly.

Take one order. Compare GA4's recorded value against the order total, the total excluding tax, and the total excluding shipping. One of them will match exactly, and that tells you what your dataLayer is actually sending.

05

Step 05 — Check whether refunds are firing.

Search GA4 for refund events over the last 90 days. If there are none and you have processed refunds, that is a confirmed and quantifiable part of your gap.

06

Step 06 — Check status handling.

Count orders in GA4 against all WooCommerce orders including failed and cancelled — not just completed. If GA4 matches the larger number, your gap is order status, not tracking.

Fix

How to fix it

Decide what value means and apply it everywhere. Most stores should send revenue excluding tax and excluding shipping, matching how finance reports it — but the decision matters less than the consistency.

PHP
/**
 * One definition of order value, used by every destination.
 * Excludes tax and shipping. Change once here, not per integration.
 */
function propulse_ga4_order_value( WC_Order $order ) {
	$value = $order->get_total()
		- $order->get_total_tax()
		- $order->get_shipping_total();

	return round( max( 0, (float) $value ), 2 );
}

Then send refunds, which is the single largest recoverable gap on most stores:

PHP
add_action( 'woocommerce_order_refunded', 'propulse_ga4_refund_event', 10, 2 );

function propulse_ga4_refund_event( $order_id, $refund_id ) {
	$order  = wc_get_order( $order_id );
	$refund = wc_get_order( $refund_id );

	if ( ! $order || ! $refund ) {
		return;
	}

	$payload = array(
		'event'     => 'refund',
		'ecommerce' => array(
			'transaction_id' => $order->get_order_number(),
			'value'          => round( abs( (float) $refund->get_amount() ), 2 ),
			'currency'       => $order->get_currency(),
		),
	);

	// Refunds happen in wp-admin, not in the customer's browser —
	// this must go to GA4 server-side via the Measurement Protocol.
	propulse_send_measurement_protocol( $payload );
}
PHP
/**
 * Send a server-side event to GA4 via the Measurement Protocol.
 * Requires the same client_id the browser session used.
 */
function propulse_send_measurement_protocol( array $payload ) {
	$measurement_id = 'G-XXXXXXXXXX'; // Your GA4 measurement ID.
	$api_secret     = 'YOUR_API_SECRET'; // GA4 Admin → Data Streams → Measurement Protocol.

	if ( ! $measurement_id || ! $api_secret ) {
		return false;
	}

	$client_id = $payload['client_id'] ?? propulse_ga4_client_id_from_order( $payload );
	if ( ! $client_id ) {
		return false;
	}

	$params = array( 'engagement_time_msec' => 100 );
	if ( ! empty( $payload['ecommerce'] ) ) {
		foreach ( $payload['ecommerce'] as $key => $val ) {
			$params[ $key ] = $val;
		}
	}

	$body = array(
		'client_id' => $client_id,
		'events'    => array(
			array(
				'name'   => $payload['event'],
				'params' => $params,
			),
		),
	);

	$url = sprintf(
		'https://www.google-analytics.com/mp/collect?measurement_id=%s&api_secret=%s',
		rawurlencode( $measurement_id ),
		rawurlencode( $api_secret )
	);

	$response = wp_remote_post(
		$url,
		array(
			'body'    => wp_json_encode( $body ),
			'headers' => array( 'Content-Type' => 'application/json' ),
			'timeout' => 5,
		)
	);

	return ! is_wp_error( $response ) && (int) wp_remote_retrieve_response_code( $response ) < 400;
}

function propulse_ga4_client_id_from_order( array $payload ) {
	// Store _ga cookie value on the order at purchase time for server-side sends.
	return $payload['client_id'] ?? '';
}

The comment matters. Refunds are processed in the admin, where no customer browser exists to fire a dataLayer event. A refund implementation that relies on client-side tagging will never work, and this is why so many stores have no refund data at all.

For the timezone issue, set both systems to the same zone and note the change date — historical comparisons across that boundary will be slightly off and you should document why.

Verify

How to verify the fix worked

  1. Compare a single day's orders against GA4 transactions, order by order
  2. Confirm the value sent matches your chosen definition on a test order
  3. Process a test refund and confirm a refund event reaches GA4
  4. Recalculate the gap per market after seven days
  5. Expect to land at 10–20% under your store totals, not at zero — and treat a gap that has become proportionally consistent across markets as success
Escalation

When this needs more than a snippet

If the gap persists after tax, timezone, and refunds are corrected, the remaining causes usually need the full event flow traced — particularly on multi-market stores where each storefront may have drifted independently.

That reconciliation, market by market against your order table, is what our GA4 audit does.

Get Your Free Audit

Tell us about your setup. We respond within 24 hours.

FAQ

Common
Questions

Ready to Build Something That Works?

Book a free 30-minute strategy audit. No pitch deck, no pressure — just an honest look at your setup and what to fix first.

Get My Free Audit →