ug report: incorrect _woocs_order_rate persisted on orders, causing inflated WooCommerce Analytics totals
The support doesn work on Saturdays and Sundays, so some Friday requests can be answered on Monday. If you have problems with registration ask help on contact us page pleaseIf you not got email within 24~36 business hours, firstly check your spam box, and if no any email from the support there - back to the forum and read answer here. DO NOT ANSWER ON EMAILS [noreply@pluginus.net] FROM THE FORUM!! Emails are just for your info, all answers should be published only here.
The support doesn work on Saturdays and Sundays, so some Friday requests can be answered on Monday.
Quote from mai_studiopasacademy on May 4, 2026, 19:44Plugin version: 2.4.6 (latest, just installed; previously running 2.4.0)
WooCommerce version: running with HPOS-compatible build
Site: studiopasacademy.com (production)
1. Context
We have been running FOX – Currency Switcher Professional in production on this site since 2021, across several WooCommerce upgrades. The plugin has worked reliably for years. We are writing as long-time users, not as a complaint — we want to share a reproducible diagnosis that may help you and other customers.
Two contextual details that we believe are important:
- Update visibility issue: until today our WordPress admin was not surfacing any pending update for FOX, so we were still on version 2.4.0. We only discovered newer versions existed by visiting your changelog page directly. We were able to update to 2.4.6 manually. We mention this because it may be worth investigating the license/update-check flow on long-running installations.
- Payment gateway change: at the beginning of 2026 we added Stripe as our main international payment gateway (previously we used MercadoPago for CLP and a basic flow for other currencies). The bug we describe below started appearing exactly when Stripe traffic ramped up. Stripe's 3D Secure flow performs full off-site redirects that come back to the site, which we believe is the trigger for the underlying race condition (details below).
2. Symptom observed by the customer
Our client uses the WooCommerce Analytics dashboard daily. On 22 April 2026 she noticed the monthly total had jumped to ~107M CLP, with one specific day (12 April) showing 63,425,893 CLP instead of the expected ~2,540,000 CLP. Crucially, she had verified the same dashboard on 12 April and the total was correct then.
The same pattern repeated: on 4 May 2026 she reported that the 3 April total had also become inflated overnight, having been correct for the entire month of April.
So the key observation — and what makes this bug especially confusing — is that the orders themselves do not appear malformed: every order has the correct _order_currency and _order_total. The reports were correct for days or weeks, then silently desynchronised.
3. Root cause analysis
After investigating both the database and the plugin code (v2.4.6), we identified two compounding issues. We describe them in the order in which they happen at runtime.
3.1 Issue #1 — _woocs_order_rate is persisted from session, not from the order
Two functions persist _woocs_order_rate on the order:
classes/woocs.php L2913 (woocommerce_thankyou_order_id)
$order->update_meta_data('_woocs_order_rate', $currencies[$this->current_currency]['rate']);
classes/woocs.php L2938 (woocommerce_checkout_update_order_meta)
$order->update_meta_data('_woocs_order_rate', $currencies[$this->current_currency]['rate']);
Both rely on $this->current_currency, which is read from the storage layer (transient keyed by MD5(IP) by default; see classes/storage.php). This value is NOT guaranteed to reflect the order's actual currency, because:
- Stripe's 3D Secure flow redirects the customer off-site. On return, if the IP changed (mobile networks, carrier-grade NAT, corporate proxies) the transient key changes and current_currency falls back to the site default (CLP, rate=1).
- Two unrelated visitors sharing the same public IP (NAT, office, mobile carrier) collide on the same transient key. If visitor A is browsing in USD while visitor B places an order in ARS, B's rate gets overwritten with A's USD rate.
The fix in these two functions is small and the codebase already shows the correct pattern elsewhere. For example, in classes/woocs.php L5889 (woocommerce_new_order_item) the code does this correctly:
$order = wc_get_order($order_id);
$_order_currency = $order->get_currency();
if (isset($currencies[$_order_currency])) {
$this->set_currency($_order_currency);
}
Applying the same pattern to L2913 and L2938 would fix the source of the bad rate. Suggested change:
// Always trust the order's persisted currency, not the visitor session.
$order_currency = $order->get_currency();
if (!isset($currencies[$order_currency])) {
$order_currency = $this->current_currency; // safe fallback
}
$order->update_meta_data('_woocs_order_rate', $currencies[$order_currency]['rate']);
3.2 Issue #2 — recalculate_order_stats has no idempotency or sanity guard
This is the issue that explains the delayed corruption pattern observed by our customer. It is, in our opinion, the more serious of the two.
The relevant code is in classes/dashboard_stat.php:
add_action('woocommerce_analytics_update_order_stats', array($this, 'recalculate_order_stats'));
public function recalculate_order_stats($order_id) {
// ...
$order_rate = $order->get_meta('_woocs_order_rate', true);
$order_recalculated = $order->get_meta('woocs_order_stat_recalculated', true);
// ...
$order_data = $wpdb->get_row("SELECT total_sales,tax_total,shipping_total,net_total
FROM wp_wc_order_stats WHERE order_id = %d", ...);
foreach ($order_data as $key => $value) {
$order_data[$key] = $WOOCS->back_convert($value, $order_rate, $decimals);
}
$wpdb->update($this->orders_table, $order_data, ['order_id' => $order_id], ...);
$order->update_meta_data('woocs_order_stat_recalculated', true);
}
Two important properties of this code:
- It reads $order_recalculated but never uses it — there is no early return for already-recalculated orders. The flag is written but never honoured.
- It reads the current values from wp_wc_order_stats and writes back back_convert(value, rate). It does not validate that the rate is sane (i.e. matches the order's currency).
WooCommerce Admin re-imports orders into wp_wc_order_stats in many situations: Stripe webhooks, order updates, internal maintenance jobs, plugin upgrades, etc. Every re-import re-fires woocommerce_analytics_update_order_stats, which re-runs recalculate_order_stats, which silently re-applies back_convert with the (potentially wrong) stored rate.
4. Runtime evidence
We confirmed the diagnosis by inspecting wp_actionscheduler_actions for the affected orders. Both orders were correctly imported initially, then re-imported much later — and only the re-import produced the inflated value.
Order #81986 (ARS 68,651.95 → showed as 60,902,524 CLP):
- 12 Apr 23:51 — order created
- 13 Apr 03:51 — wc-admin_import_orders [81986] → stats correct (45,000 CLP)
- 22 Apr 15:29 — wc-admin_import_orders [81986] → stats become 60,902,524 CLP
Order #81419 (COP 155,455 → showed as 142,998,409 CLP):
- 03 Apr 17:38 — order created
- 03 Apr 20:38 — wc-admin_import_orders [81419] → stats correct
- 03 May 20:38 — wc-admin_import_orders [81419] → stats become 142,998,409 CLP (one month later)
Mathematical confirmation — the inflated value is exactly order_total × (1 / stored_rate):
#81986: 68,651.95 / 0.0011272431 = 60,902,524.04 ✓ matches stats exactly
#81419: 155,455 / 0.00108711 = 142,998,409.4 ✓ matches stats exactly0.0011272431 and 0.00108711 are the USD rates that were stored on these ARS and COP orders respectively — confirming the session-collision hypothesis from §3.1.
5. Impact in our database
Querying our wp_postmeta + wp_wc_order_stats since 1 January 2026:
- 4 critical orders have a _woocs_order_rate from a different currency entirely (USD or EUR rates stored on ARS/COP orders). Two of them already produced ~204M CLP of phantom revenue in Analytics. The other two haven't been re-imported yet — they will become incorrect as soon as they are.
- 368 orders have _woocs_order_rate = 1 (i.e. the session collapsed to the default currency at thank-you time). These produce smaller distortions per order, but together account for ~9.4M CLP of bad data. This represents roughly 15.7% of all Stripe orders placed in non-base currencies.
- 12 CLP orders have a foreign-currency rate stored, and 37 COP orders have an ARS rate stored — same root cause, smaller impact.
6. Reproduction conditions
We have not built a minimal reproduction in a clean environment, but the conditions in our production site that correlate strongly with the bug are:
- Storage type: transient (default).
- Multiple active currencies (CLP base + USD, EUR, ARS, COP, MXN).
- Stripe gateway with 3D Secure enabled (off-site redirect during checkout).
- WooCommerce Admin / Analytics with default Action Scheduler running normally.
- Significant traffic (a few hundred orders/day) so that IP collisions on the transient key become statistically likely.
Switching the storage option to 'session' or 'woocs_session' should reduce the frequency of Issue #1, but does not address Issue #2 — historical orders with a bad rate will still corrupt their analytics row the next time wc-admin_import_orders touches them.
7. Suggested fixes
- In woocommerce_thankyou_order_id and woocommerce_checkout_update_order_meta: derive the currency from $order->get_currency() before reading $currencies[...]['rate'], with $this->current_currency only as a fallback. This eliminates the source of incorrect rates.
- In recalculate_order_stats: before applying back_convert, validate that the stored rate is plausible for the order's currency (e.g. compare against the current rate in $currencies; reject values that differ by more than an order of magnitude). When the rate fails the sanity check, refuse to update the row and log a warning rather than silently corrupting data.
- Make recalculate_order_stats idempotent. Either persist the original native-currency value alongside the converted CLP value (so re-imports can recompute deterministically), or honour the woocs_order_stat_recalculated flag with an early return when the stored rate hasn't changed.
- Consider switching the default storage type away from transient-keyed-by-IP-hash, or at least adding a clear warning in the admin UI about the IP-collision risk on shared-IP environments.
8. Closing
We are happy to share additional data — anonymised exports of the affected orders, wp_actionscheduler_actions extracts, or our SQL diagnostic queries — if it helps you reproduce or verify the issue. We are also planning to ship a temporary mu-plugin patch on our side to stop new orders from being affected while waiting for an upstream fix; we'd be glad to share it for review.
Thank you for the years of solid work on this plugin. We hope this report is useful and will be happy to test any patch you'd like to validate before a public release.
Best regards,
Manuel Rojas — on behalf of Studio Pas Academy
Plugin version: 2.4.6 (latest, just installed; previously running 2.4.0)
WooCommerce version: running with HPOS-compatible build
Site: studiopasacademy.com (production)
1. Context
We have been running FOX – Currency Switcher Professional in production on this site since 2021, across several WooCommerce upgrades. The plugin has worked reliably for years. We are writing as long-time users, not as a complaint — we want to share a reproducible diagnosis that may help you and other customers.
Two contextual details that we believe are important:
- Update visibility issue: until today our WordPress admin was not surfacing any pending update for FOX, so we were still on version 2.4.0. We only discovered newer versions existed by visiting your changelog page directly. We were able to update to 2.4.6 manually. We mention this because it may be worth investigating the license/update-check flow on long-running installations.
- Payment gateway change: at the beginning of 2026 we added Stripe as our main international payment gateway (previously we used MercadoPago for CLP and a basic flow for other currencies). The bug we describe below started appearing exactly when Stripe traffic ramped up. Stripe's 3D Secure flow performs full off-site redirects that come back to the site, which we believe is the trigger for the underlying race condition (details below).
2. Symptom observed by the customer
Our client uses the WooCommerce Analytics dashboard daily. On 22 April 2026 she noticed the monthly total had jumped to ~107M CLP, with one specific day (12 April) showing 63,425,893 CLP instead of the expected ~2,540,000 CLP. Crucially, she had verified the same dashboard on 12 April and the total was correct then.
The same pattern repeated: on 4 May 2026 she reported that the 3 April total had also become inflated overnight, having been correct for the entire month of April.
So the key observation — and what makes this bug especially confusing — is that the orders themselves do not appear malformed: every order has the correct _order_currency and _order_total. The reports were correct for days or weeks, then silently desynchronised.
3. Root cause analysis
After investigating both the database and the plugin code (v2.4.6), we identified two compounding issues. We describe them in the order in which they happen at runtime.
3.1 Issue #1 — _woocs_order_rate is persisted from session, not from the order
Two functions persist _woocs_order_rate on the order:
classes/woocs.php L2913 (woocommerce_thankyou_order_id)
$order->update_meta_data('_woocs_order_rate', $currencies[$this->current_currency]['rate']);
classes/woocs.php L2938 (woocommerce_checkout_update_order_meta)
$order->update_meta_data('_woocs_order_rate', $currencies[$this->current_currency]['rate']);
Both rely on $this->current_currency, which is read from the storage layer (transient keyed by MD5(IP) by default; see classes/storage.php). This value is NOT guaranteed to reflect the order's actual currency, because:
- Stripe's 3D Secure flow redirects the customer off-site. On return, if the IP changed (mobile networks, carrier-grade NAT, corporate proxies) the transient key changes and current_currency falls back to the site default (CLP, rate=1).
- Two unrelated visitors sharing the same public IP (NAT, office, mobile carrier) collide on the same transient key. If visitor A is browsing in USD while visitor B places an order in ARS, B's rate gets overwritten with A's USD rate.
The fix in these two functions is small and the codebase already shows the correct pattern elsewhere. For example, in classes/woocs.php L5889 (woocommerce_new_order_item) the code does this correctly:
$order = wc_get_order($order_id);
$_order_currency = $order->get_currency();
if (isset($currencies[$_order_currency])) {
$this->set_currency($_order_currency);
}
Applying the same pattern to L2913 and L2938 would fix the source of the bad rate. Suggested change:
// Always trust the order's persisted currency, not the visitor session.
$order_currency = $order->get_currency();
if (!isset($currencies[$order_currency])) {
$order_currency = $this->current_currency; // safe fallback
}
$order->update_meta_data('_woocs_order_rate', $currencies[$order_currency]['rate']);
3.2 Issue #2 — recalculate_order_stats has no idempotency or sanity guard
This is the issue that explains the delayed corruption pattern observed by our customer. It is, in our opinion, the more serious of the two.
The relevant code is in classes/dashboard_stat.php:
add_action('woocommerce_analytics_update_order_stats', array($this, 'recalculate_order_stats'));
public function recalculate_order_stats($order_id) {
// ...
$order_rate = $order->get_meta('_woocs_order_rate', true);
$order_recalculated = $order->get_meta('woocs_order_stat_recalculated', true);
// ...
$order_data = $wpdb->get_row("SELECT total_sales,tax_total,shipping_total,net_total
FROM wp_wc_order_stats WHERE order_id = %d", ...);
foreach ($order_data as $key => $value) {
$order_data[$key] = $WOOCS->back_convert($value, $order_rate, $decimals);
}
$wpdb->update($this->orders_table, $order_data, ['order_id' => $order_id], ...);
$order->update_meta_data('woocs_order_stat_recalculated', true);
}
Two important properties of this code:
- It reads $order_recalculated but never uses it — there is no early return for already-recalculated orders. The flag is written but never honoured.
- It reads the current values from wp_wc_order_stats and writes back back_convert(value, rate). It does not validate that the rate is sane (i.e. matches the order's currency).
WooCommerce Admin re-imports orders into wp_wc_order_stats in many situations: Stripe webhooks, order updates, internal maintenance jobs, plugin upgrades, etc. Every re-import re-fires woocommerce_analytics_update_order_stats, which re-runs recalculate_order_stats, which silently re-applies back_convert with the (potentially wrong) stored rate.
4. Runtime evidence
We confirmed the diagnosis by inspecting wp_actionscheduler_actions for the affected orders. Both orders were correctly imported initially, then re-imported much later — and only the re-import produced the inflated value.
Order #81986 (ARS 68,651.95 → showed as 60,902,524 CLP):
- 12 Apr 23:51 — order created
- 13 Apr 03:51 — wc-admin_import_orders [81986] → stats correct (45,000 CLP)
- 22 Apr 15:29 — wc-admin_import_orders [81986] → stats become 60,902,524 CLP
Order #81419 (COP 155,455 → showed as 142,998,409 CLP):
- 03 Apr 17:38 — order created
- 03 Apr 20:38 — wc-admin_import_orders [81419] → stats correct
- 03 May 20:38 — wc-admin_import_orders [81419] → stats become 142,998,409 CLP (one month later)
Mathematical confirmation — the inflated value is exactly order_total × (1 / stored_rate):
#81986: 68,651.95 / 0.0011272431 = 60,902,524.04 ✓ matches stats exactly
#81419: 155,455 / 0.00108711 = 142,998,409.4 ✓ matches stats exactly
0.0011272431 and 0.00108711 are the USD rates that were stored on these ARS and COP orders respectively — confirming the session-collision hypothesis from §3.1.
5. Impact in our database
Querying our wp_postmeta + wp_wc_order_stats since 1 January 2026:
- 4 critical orders have a _woocs_order_rate from a different currency entirely (USD or EUR rates stored on ARS/COP orders). Two of them already produced ~204M CLP of phantom revenue in Analytics. The other two haven't been re-imported yet — they will become incorrect as soon as they are.
- 368 orders have _woocs_order_rate = 1 (i.e. the session collapsed to the default currency at thank-you time). These produce smaller distortions per order, but together account for ~9.4M CLP of bad data. This represents roughly 15.7% of all Stripe orders placed in non-base currencies.
- 12 CLP orders have a foreign-currency rate stored, and 37 COP orders have an ARS rate stored — same root cause, smaller impact.
6. Reproduction conditions
We have not built a minimal reproduction in a clean environment, but the conditions in our production site that correlate strongly with the bug are:
- Storage type: transient (default).
- Multiple active currencies (CLP base + USD, EUR, ARS, COP, MXN).
- Stripe gateway with 3D Secure enabled (off-site redirect during checkout).
- WooCommerce Admin / Analytics with default Action Scheduler running normally.
- Significant traffic (a few hundred orders/day) so that IP collisions on the transient key become statistically likely.
Switching the storage option to 'session' or 'woocs_session' should reduce the frequency of Issue #1, but does not address Issue #2 — historical orders with a bad rate will still corrupt their analytics row the next time wc-admin_import_orders touches them.
7. Suggested fixes
- In woocommerce_thankyou_order_id and woocommerce_checkout_update_order_meta: derive the currency from $order->get_currency() before reading $currencies[...]['rate'], with $this->current_currency only as a fallback. This eliminates the source of incorrect rates.
- In recalculate_order_stats: before applying back_convert, validate that the stored rate is plausible for the order's currency (e.g. compare against the current rate in $currencies; reject values that differ by more than an order of magnitude). When the rate fails the sanity check, refuse to update the row and log a warning rather than silently corrupting data.
- Make recalculate_order_stats idempotent. Either persist the original native-currency value alongside the converted CLP value (so re-imports can recompute deterministically), or honour the woocs_order_stat_recalculated flag with an early return when the stored rate hasn't changed.
- Consider switching the default storage type away from transient-keyed-by-IP-hash, or at least adding a clear warning in the admin UI about the IP-collision risk on shared-IP environments.
8. Closing
We are happy to share additional data — anonymised exports of the affected orders, wp_actionscheduler_actions extracts, or our SQL diagnostic queries — if it helps you reproduce or verify the issue. We are also planning to ship a temporary mu-plugin patch on our side to stop new orders from being affected while waiting for an upstream fix; we'd be glad to share it for review.
Thank you for the years of solid work on this plugin. We hope this report is useful and will be happy to test any patch you'd like to validate before a public release.
Best regards,
Manuel Rojas — on behalf of Studio Pas Academy
Quote from Alex Dovlatov on May 5, 2026, 09:48Hello Manuel
Thank you for the most thorough and well-documented bug report we have received in years. The mathematical proof, the Action Scheduler timeline, and the code references made it straightforward to locate and verify both issues in the source. This is exactly the kind of cooperation that helps us ship better software — we appreciate the effort you put into this.
We have reviewed the plugin code (v2.4.6) against your findings and can confirm both root causes. Below is our analysis and the specific changes we propose. Because a proper fix requires careful testing across several gateway configurations, we cannot push a quick release right now. Instead we would like to work with you directly in a shared branch so we can validate the patches on your real traffic before a public release.
FIX 1 — classes/woocs.php
Both affected functions already have the order object available, so the change is minimal.
Function woocommerce_thankyou_order_id, around line 2913.
Replace:$order->update_meta_data('_woocs_order_rate', $currencies[$this->current_currency]['rate']);
With:
// Derive rate from the order's own currency, not from the visitor session.
$order_currency = $order->get_currency();
$rate_currency = isset($currencies[$order_currency]) ? $order_currency : $this->current_currency;
$order->update_meta_data('_woocs_order_rate', $currencies[$rate_currency]['rate']);Apply the identical replacement in function woocommerce_checkout_update_order_meta, around line 2938, where the same pattern occurs.
This eliminates the source of incorrect rates for all new orders.
FIX 2 — classes/dashboard_stat.php, function recalculate_order_stats
We want to add a sanity check that refuses to apply back_convert when the stored rate does not match the order currency. We propose replacing the current function body with the following:
public function recalculate_order_stats($order_id) { global $wpdb; global $WOOCS; $order = wc_get_order($order_id); if (!$order) { return false; } $_order_currency = $order->get_currency(); // Skip base currency orders — nothing to convert. if ($_order_currency == $WOOCS->default_currency) { return false; } $currencies = $WOOCS->get_currencies(); $decimals = 2; if (isset($currencies[$_order_currency]) && array_key_exists('decimals', $currencies[$_order_currency])) { $decimals = intval($currencies[$_order_currency]['decimals']); } $order_rate = $order->get_meta('_woocs_order_rate', true); // Fallback: if no rate stored, use live rate for the order currency. if (!$order_rate) { if (isset($currencies[$_order_currency])) { $order_rate = $currencies[$_order_currency]['rate']; } else { return false; } } // Sanity check: compare stored rate against the live rate for the order currency. // A ratio above 10x almost certainly means the rate belongs to a different currency // (session collision). Refuse to update stats and log a warning instead. if (isset($currencies[$_order_currency])) { $live_rate = floatval($currencies[$_order_currency]['rate']); $stored_rate = floatval($order_rate); if ($live_rate > 0 && $stored_rate > 0) { $ratio = max($live_rate, $stored_rate) / min($live_rate, $stored_rate); if ($ratio > 10) { error_log(sprintf( 'WOOCS: recalculate_order_stats skipped for order #%d — stored rate %.8f does not match live rate %.8f for currency %s (ratio %.1f). Likely session collision.', $order_id, $stored_rate, $live_rate, $_order_currency, $ratio )); return false; } } } $order_data = $wpdb->get_row( $wpdb->prepare( "SELECT total_sales, tax_total, shipping_total, net_total FROM {$this->orders_table} WHERE order_id = %d", $order_id ), ARRAY_A ); if (!$order_data) { return false; } foreach ($order_data as $key => $value) { $order_data[$key] = $WOOCS->back_convert($value, $order_rate, $decimals); } $wpdb->update( $this->orders_table, $order_data, array('order_id' => $order_id), array('%f', '%f', '%f', '%f'), array('%d') ); $order->update_meta_data('woocs_order_stat_recalculated', true); $order->save(); }OPEN QUESTION — idempotency and storing original stats values
On the idempotency point you raised — we are still working through the right approach internally. The core question is whether WooCommerce ever fires the woocommerce_analytics_update_order_stats hook without first resetting the stats row to fresh native-currency values. If it always resets first, back_convert runs on clean input every time and there is no double-conversion risk. If there are cases where the hook fires on an already-converted row — for example a partial Stripe webhook that updates order status without triggering a full re-import — then we need a deeper fix.
You have more visibility into this than we do right now. In your Action Scheduler logs for the affected orders, did the April 22 re-import of order 81986 and the May 3 re-import of order 81419 come from a full wc-admin_import_orders job or from a Stripe webhook action? That single data point would tell us whether we are dealing with a full reset scenario or a partial update, and it would directly determine how we implement the idempotency guard.
NEXT STEPS
We suggest the following process:
1. We open a fix branch and apply Fix 1 and Fix 2 as described above.
2. We share the branch with you so you can apply it to a staging copy of studiopasacademy.com and run new Stripe 3DS orders through it.
3. You confirm that _woocs_order_rate is now set correctly on new orders and that the sanity check log entries appear as expected when you simulate a bad rate.
4. Once validated we merge and release.Regarding your existing corrupted orders — the 4 critical ones and the 368 with rate 1 — we will prepare a separate repair script once the fixes are confirmed. The script will need to reset _woocs_order_rate to the correct value, clear the woocs_order_stat_recalculated flag, and rely on you to trigger a WooCommerce Analytics re-import. Please note that historical exchange rates from April may differ from today's live rates, so the repaired figures will be close but not guaranteed to match the original transaction rates exactly unless you have archived rate data from that period.
Also noted: the update visibility issue on long-running installations. We will investigate the license/update-check flow separately.
Please let us know if you are happy to proceed on this basis and whether you can share the anonymised diagnostic SQL you mentioned — it would help us build a reliable test fixture.
Thank you again.
Hello Manuel
Thank you for the most thorough and well-documented bug report we have received in years. The mathematical proof, the Action Scheduler timeline, and the code references made it straightforward to locate and verify both issues in the source. This is exactly the kind of cooperation that helps us ship better software — we appreciate the effort you put into this.
We have reviewed the plugin code (v2.4.6) against your findings and can confirm both root causes. Below is our analysis and the specific changes we propose. Because a proper fix requires careful testing across several gateway configurations, we cannot push a quick release right now. Instead we would like to work with you directly in a shared branch so we can validate the patches on your real traffic before a public release.
FIX 1 — classes/woocs.php
Both affected functions already have the order object available, so the change is minimal.
Function woocommerce_thankyou_order_id, around line 2913.
Replace:
$order->update_meta_data('_woocs_order_rate', $currencies[$this->current_currency]['rate']);
With:
// Derive rate from the order's own currency, not from the visitor session.
$order_currency = $order->get_currency();
$rate_currency = isset($currencies[$order_currency]) ? $order_currency : $this->current_currency;
$order->update_meta_data('_woocs_order_rate', $currencies[$rate_currency]['rate']);
Apply the identical replacement in function woocommerce_checkout_update_order_meta, around line 2938, where the same pattern occurs.
This eliminates the source of incorrect rates for all new orders.
FIX 2 — classes/dashboard_stat.php, function recalculate_order_stats
We want to add a sanity check that refuses to apply back_convert when the stored rate does not match the order currency. We propose replacing the current function body with the following:
public function recalculate_order_stats($order_id) {
global $wpdb;
global $WOOCS;
$order = wc_get_order($order_id);
if (!$order) {
return false;
}
$_order_currency = $order->get_currency();
// Skip base currency orders — nothing to convert.
if ($_order_currency == $WOOCS->default_currency) {
return false;
}
$currencies = $WOOCS->get_currencies();
$decimals = 2;
if (isset($currencies[$_order_currency]) && array_key_exists('decimals', $currencies[$_order_currency])) {
$decimals = intval($currencies[$_order_currency]['decimals']);
}
$order_rate = $order->get_meta('_woocs_order_rate', true);
// Fallback: if no rate stored, use live rate for the order currency.
if (!$order_rate) {
if (isset($currencies[$_order_currency])) {
$order_rate = $currencies[$_order_currency]['rate'];
} else {
return false;
}
}
// Sanity check: compare stored rate against the live rate for the order currency.
// A ratio above 10x almost certainly means the rate belongs to a different currency
// (session collision). Refuse to update stats and log a warning instead.
if (isset($currencies[$_order_currency])) {
$live_rate = floatval($currencies[$_order_currency]['rate']);
$stored_rate = floatval($order_rate);
if ($live_rate > 0 && $stored_rate > 0) {
$ratio = max($live_rate, $stored_rate) / min($live_rate, $stored_rate);
if ($ratio > 10) {
error_log(sprintf(
'WOOCS: recalculate_order_stats skipped for order #%d — stored rate %.8f does not match live rate %.8f for currency %s (ratio %.1f). Likely session collision.',
$order_id, $stored_rate, $live_rate, $_order_currency, $ratio
));
return false;
}
}
}
$order_data = $wpdb->get_row(
$wpdb->prepare(
"SELECT total_sales, tax_total, shipping_total, net_total FROM {$this->orders_table} WHERE order_id = %d",
$order_id
),
ARRAY_A
);
if (!$order_data) {
return false;
}
foreach ($order_data as $key => $value) {
$order_data[$key] = $WOOCS->back_convert($value, $order_rate, $decimals);
}
$wpdb->update(
$this->orders_table,
$order_data,
array('order_id' => $order_id),
array('%f', '%f', '%f', '%f'),
array('%d')
);
$order->update_meta_data('woocs_order_stat_recalculated', true);
$order->save();
}
OPEN QUESTION — idempotency and storing original stats values
On the idempotency point you raised — we are still working through the right approach internally. The core question is whether WooCommerce ever fires the woocommerce_analytics_update_order_stats hook without first resetting the stats row to fresh native-currency values. If it always resets first, back_convert runs on clean input every time and there is no double-conversion risk. If there are cases where the hook fires on an already-converted row — for example a partial Stripe webhook that updates order status without triggering a full re-import — then we need a deeper fix.
You have more visibility into this than we do right now. In your Action Scheduler logs for the affected orders, did the April 22 re-import of order 81986 and the May 3 re-import of order 81419 come from a full wc-admin_import_orders job or from a Stripe webhook action? That single data point would tell us whether we are dealing with a full reset scenario or a partial update, and it would directly determine how we implement the idempotency guard.
NEXT STEPS
We suggest the following process:
1. We open a fix branch and apply Fix 1 and Fix 2 as described above.
2. We share the branch with you so you can apply it to a staging copy of studiopasacademy.com and run new Stripe 3DS orders through it.
3. You confirm that _woocs_order_rate is now set correctly on new orders and that the sanity check log entries appear as expected when you simulate a bad rate.
4. Once validated we merge and release.
Regarding your existing corrupted orders — the 4 critical ones and the 368 with rate 1 — we will prepare a separate repair script once the fixes are confirmed. The script will need to reset _woocs_order_rate to the correct value, clear the woocs_order_stat_recalculated flag, and rely on you to trigger a WooCommerce Analytics re-import. Please note that historical exchange rates from April may differ from today's live rates, so the repaired figures will be close but not guaranteed to match the original transaction rates exactly unless you have archived rate data from that period.
Also noted: the update visibility issue on long-running installations. We will investigate the license/update-check flow separately.
Please let us know if you are happy to proceed on this basis and whether you can share the anonymised diagnostic SQL you mentioned — it would help us build a reliable test fixture.
Thank you again.
Quote from mai_studiopasacademy on May 6, 2026, 12:23Re: Bug report — answers to your open questions and proposed historical-repair method
Hello,
Thank you very much for the detailed analysis and for confirming both root causes. The proposed patches for Fix 1 and Fix 2 look correct and we're glad to proceed on the basis you described (shared fix branch + staging validation against new Stripe 3DS traffic, then merge and release).
Below we answer your open question about the trigger of the late re-imports, and we share an additional finding from our Action Scheduler logs that we think changes the picture in a useful way. We also describe the historical-repair method we are planning on our side, in case it is useful input for your own repair script.
1. Answer to your open question (idempotency / partial vs full re-import)
Both late re-imports for the affected orders came from full wc-admin_import_orders jobs, not from partial Stripe webhook actions. The relevant Action Scheduler rows on our side:
action_id hook status scheduled_date_gmt args
789235 wc-admin_import_orders complete 2026-04-13 03:51:44 [81986]
789245 wc-admin_import_orders complete 2026-04-13 03:51:51 [81986]
798092 wc-admin_import_orders complete 2026-04-22 15:29:51 [81986]
774526 wc-admin_import_orders complete 2026-04-03 20:38:11 [81419]
807641 wc-admin_import_orders complete 2026-05-03 20:38:18 [81419]Both orders were re-imported via full wc-admin_import_orders (single-order args), not via a Stripe webhook payload. The arithmetic confirms it:
#81986: 68,651.95 / 0.0011272431 = 60,902,524.04 ← exactly the inflated value
#81419: 155,455 / 0.00108711 = 142,998,409.4 ← exactly the inflated valueIf a previous (correct) CLP value had been re-converted, the result would be in the 10^10–10^11 range. It isn't. So in our case at least, every re-import was applied on freshly-reset native-currency input — single conversion, wrong rate. This means a sanity check on the rate (your Fix 2) is sufficient to stop the corruption; you do not need to also defend against double-conversion in this particular path.
That said, we'd still recommend persisting the native-currency total as an explicit meta key (e.g. _woocs_order_total_native) on order creation — that way recalculate_order_stats can always recompute deterministically from a known-good source instead of trusting whatever happens to be in wp_wc_order_stats at read time. It would close the door on any future refactoring that breaks the reset-before-hook contract.
2. New finding — the trigger for late re-imports is plugin/core updates
Our customer pointed out that the visible date of each desync coincides with maintenance work on the site. We checked plugin folder mtimes against Action Scheduler bursts and the correlation is very strong:
Plugin updates (mass batch on 03 May 2026, 15:36–15:52 UTC-4):
wp-file-manager, wordpress-seo, woocommerce-gateway-stripe,
woocommerce-paypal-payments, query-monitor, customer-reviews-woocommerce,
facebook-for-woocommerce, woocommerce-mercadopago, members,
loco-translate, gdpr-cookie-compliance, custom-post-type-ui,
chaty, autocomplete-woocommerce-orders, wp-reviews-plugin-for-google
(15 plugins updated in 16 minutes)
wc-admin_import_orders backlog observed afterwards:
03 May 20:38 re-import [81419] ← desync surfaced to customer here
04 May evening ~25 imports/hour sustained
05 May 01:00–04:00 408 / 419 / 214 / 136 imports per hour (massive burst)The same pattern is visible around the earlier desync event:
WooCommerce core was updated on 10 April 2026.
On 22 April at 15:00 UTC-4 a burst of 87 wc-admin_import_orders was scheduled,
including [81986] at 15:29 — which is exactly when the customer noticed the inflated 12 April total.We believe what happens is: when WooCommerce or any plugin that touches order/analytics tables is updated, WC Admin marks orders as dirty and queues full wc-admin_import_orders re-imports. For sites with tens of thousands of orders this can take days to drain, and any order with a corrupt _woocs_order_rate gets its analytics row corrupted at the moment it's processed.
We're sharing this because we think it is the most likely reason your other customers will start reporting this issue without a clear trigger: it surfaces at the next maintenance window after deploying any plugin update on a site that has accumulated bad rates, often weeks or months after the original orders were placed. The customer perception is "my reports broke from nothing" even though the underlying defect has been latent for a long time.
3. Historical repair method on our side
On the historical-rate accuracy concern you raised — we have an approach that avoids the problem of approximating with today's live rates. Sharing it here in case it's useful for your own repair script.
The exchange rates applied at order time are stored in _woocs_order_rate on every order. For any single day, the rates that FOX applied are recorded across the orders that were placed correctly. We don't need an external archive of historical rates — the plugin's own data is the archive.
Concretely, our repair plan is:
- Build a per-(currency, date) map of correct rates by querying _woocs_order_rate across orders whose stored rate falls in the plausible range for their currency (e.g. ARS in [1.4, 1.7], COP in [3.5, 5.0], MXN in [0.015, 0.025], USD in [0.0008, 0.002], EUR in [0.0005, 0.002]). For each (currency, date) we take the median of the qualifying rates.
- For each affected order, look up the median rate for its currency on its order date. If no qualifying neighbours exist on that exact date, fall back to the closest date that has them.
- Update _woocs_order_rate in postmeta to the corrected value, clear woocs_order_stat_recalculated, and recompute the wp_wc_order_stats row directly from the order's native total using back_convert with the corrected rate.
- After the patched plugin is in place, leave the corrected orders alone. The next time WC Admin re-imports them they will be reprocessed with the correct rate and the sanity check will be a no-op.
This way the repaired figures are not approximations: they use the actual rate FOX was serving on the same day. For our dataset, every affected day has multiple correctly-rated neighbour orders, so this yields a high-confidence reconstruction. Sites with very low traffic in a given currency on a given day might need a wider fallback window, but the principle generalises.
We're going to keep this repair script private to our deployment for now. It's tightly coupled to our data and we'd rather not become a de facto support channel for other affected installations. If you want to reproduce the approach in your own repair tool, the pseudo-SQL above plus the rate-range table should be enough to implement it; we're happy to clarify any specific point.
4. Interim mitigation on our side
While waiting for the official fix, we're planning the following on our production install, in this order:
- Switch woocs_storage from transient to session or woocs_session (the FOX Session you introduced in 2.4.0). This won't eliminate Issue #1 but should reduce its frequency materially by removing the IP-hash collision vector.
- Run our historical repair so the existing 4 critical + 368 rate=1 orders stop being latent bombs.
- Optionally ship a tiny mu-plugin that mirrors your proposed Fix 1 and Fix 2 until you publish them. We'd remove the mu-plugin as soon as the official fix lands. We're happy to share a copy of the mu-plugin patch for your review before applying it, if that would be useful for your test fixture.
5. Next steps
Happy to proceed exactly as you proposed — open the fix branch, share the URL with us, and we'll wire up a staging copy of the production site and run real Stripe 3DS orders through it to validate _woocs_order_rate and the sanity-check log entries.
On the diagnostic SQL we offered in the original report: we'd prefer to keep our database extracts internal — they contain customer order data and we'd rather not move it outside our environment, even anonymised. Hopefully the structural details we shared (line numbers, function names, the action_id table above, the maths, and the trigger pattern in §2) are enough to build your test fixture. If you need any specific clarification we are very happy to answer.
Looking forward to validating the patches.
Best regards,
Manuel — on behalf of Studio Pas Academy
Re: Bug report — answers to your open questions and proposed historical-repair method
Hello,
Thank you very much for the detailed analysis and for confirming both root causes. The proposed patches for Fix 1 and Fix 2 look correct and we're glad to proceed on the basis you described (shared fix branch + staging validation against new Stripe 3DS traffic, then merge and release).
Below we answer your open question about the trigger of the late re-imports, and we share an additional finding from our Action Scheduler logs that we think changes the picture in a useful way. We also describe the historical-repair method we are planning on our side, in case it is useful input for your own repair script.
1. Answer to your open question (idempotency / partial vs full re-import)
Both late re-imports for the affected orders came from full wc-admin_import_orders jobs, not from partial Stripe webhook actions. The relevant Action Scheduler rows on our side:
action_id hook status scheduled_date_gmt args
789235 wc-admin_import_orders complete 2026-04-13 03:51:44 [81986]
789245 wc-admin_import_orders complete 2026-04-13 03:51:51 [81986]
798092 wc-admin_import_orders complete 2026-04-22 15:29:51 [81986]
774526 wc-admin_import_orders complete 2026-04-03 20:38:11 [81419]
807641 wc-admin_import_orders complete 2026-05-03 20:38:18 [81419]
Both orders were re-imported via full wc-admin_import_orders (single-order args), not via a Stripe webhook payload. The arithmetic confirms it:
#81986: 68,651.95 / 0.0011272431 = 60,902,524.04 ← exactly the inflated value
#81419: 155,455 / 0.00108711 = 142,998,409.4 ← exactly the inflated value
If a previous (correct) CLP value had been re-converted, the result would be in the 10^10–10^11 range. It isn't. So in our case at least, every re-import was applied on freshly-reset native-currency input — single conversion, wrong rate. This means a sanity check on the rate (your Fix 2) is sufficient to stop the corruption; you do not need to also defend against double-conversion in this particular path.
That said, we'd still recommend persisting the native-currency total as an explicit meta key (e.g. _woocs_order_total_native) on order creation — that way recalculate_order_stats can always recompute deterministically from a known-good source instead of trusting whatever happens to be in wp_wc_order_stats at read time. It would close the door on any future refactoring that breaks the reset-before-hook contract.
2. New finding — the trigger for late re-imports is plugin/core updates
Our customer pointed out that the visible date of each desync coincides with maintenance work on the site. We checked plugin folder mtimes against Action Scheduler bursts and the correlation is very strong:
Plugin updates (mass batch on 03 May 2026, 15:36–15:52 UTC-4):
wp-file-manager, wordpress-seo, woocommerce-gateway-stripe,
woocommerce-paypal-payments, query-monitor, customer-reviews-woocommerce,
facebook-for-woocommerce, woocommerce-mercadopago, members,
loco-translate, gdpr-cookie-compliance, custom-post-type-ui,
chaty, autocomplete-woocommerce-orders, wp-reviews-plugin-for-google
(15 plugins updated in 16 minutes)
wc-admin_import_orders backlog observed afterwards:
03 May 20:38 re-import [81419] ← desync surfaced to customer here
04 May evening ~25 imports/hour sustained
05 May 01:00–04:00 408 / 419 / 214 / 136 imports per hour (massive burst)
The same pattern is visible around the earlier desync event:
WooCommerce core was updated on 10 April 2026.
On 22 April at 15:00 UTC-4 a burst of 87 wc-admin_import_orders was scheduled,
including [81986] at 15:29 — which is exactly when the customer noticed the inflated 12 April total.
We believe what happens is: when WooCommerce or any plugin that touches order/analytics tables is updated, WC Admin marks orders as dirty and queues full wc-admin_import_orders re-imports. For sites with tens of thousands of orders this can take days to drain, and any order with a corrupt _woocs_order_rate gets its analytics row corrupted at the moment it's processed.
We're sharing this because we think it is the most likely reason your other customers will start reporting this issue without a clear trigger: it surfaces at the next maintenance window after deploying any plugin update on a site that has accumulated bad rates, often weeks or months after the original orders were placed. The customer perception is"my reports broke from nothing" even though the underlying defect has been latent for a long time.
3. Historical repair method on our side
On the historical-rate accuracy concern you raised — we have an approach that avoids the problem of approximating with today's live rates. Sharing it here in case it's useful for your own repair script.
The exchange rates applied at order time are stored in _woocs_order_rate on every order. For any single day, the rates that FOX applied are recorded across the orders that were placed correctly. We don't need an external archive of historical rates — the plugin's own data is the archive.
Concretely, our repair plan is:
- Build a per-(currency, date) map of correct rates by querying _woocs_order_rate across orders whose stored rate falls in the plausible range for their currency (e.g. ARS in [1.4, 1.7], COP in [3.5, 5.0], MXN in [0.015, 0.025], USD in [0.0008, 0.002], EUR in [0.0005, 0.002]). For each (currency, date) we take the median of the qualifying rates.
- For each affected order, look up the median rate for its currency on its order date. If no qualifying neighbours exist on that exact date, fall back to the closest date that has them.
- Update _woocs_order_rate in postmeta to the corrected value, clear woocs_order_stat_recalculated, and recompute the wp_wc_order_stats row directly from the order's native total using back_convert with the corrected rate.
- After the patched plugin is in place, leave the corrected orders alone. The next time WC Admin re-imports them they will be reprocessed with the correct rate and the sanity check will be a no-op.
This way the repaired figures are not approximations: they use the actual rate FOX was serving on the same day. For our dataset, every affected day has multiple correctly-rated neighbour orders, so this yields a high-confidence reconstruction. Sites with very low traffic in a given currency on a given day might need a wider fallback window, but the principle generalises.
We're going to keep this repair script private to our deployment for now. It's tightly coupled to our data and we'd rather not become a de facto support channel for other affected installations. If you want to reproduce the approach in your own repair tool, the pseudo-SQL above plus the rate-range table should be enough to implement it; we're happy to clarify any specific point.
4. Interim mitigation on our side
While waiting for the official fix, we're planning the following on our production install, in this order:
- Switch woocs_storage from transient to session or woocs_session (the FOX Session you introduced in 2.4.0). This won't eliminate Issue #1 but should reduce its frequency materially by removing the IP-hash collision vector.
- Run our historical repair so the existing 4 critical + 368 rate=1 orders stop being latent bombs.
- Optionally ship a tiny mu-plugin that mirrors your proposed Fix 1 and Fix 2 until you publish them. We'd remove the mu-plugin as soon as the official fix lands. We're happy to share a copy of the mu-plugin patch for your review before applying it, if that would be useful for your test fixture.
5. Next steps
Happy to proceed exactly as you proposed — open the fix branch, share the URL with us, and we'll wire up a staging copy of the production site and run real Stripe 3DS orders through it to validate _woocs_order_rate and the sanity-check log entries.
On the diagnostic SQL we offered in the original report: we'd prefer to keep our database extracts internal — they contain customer order data and we'd rather not move it outside our environment, even anonymised. Hopefully the structural details we shared (line numbers, function names, the action_id table above, the maths, and the trigger pattern in §2) are enough to build your test fixture. If you need any specific clarification we are very happy to answer.
Looking forward to validating the patches.
Best regards,
Manuel — on behalf of Studio Pas Academy
Quote from Alex Dovlatov on May 6, 2026, 18:01Hello Manuel
Thank you for confirming that the proposed fixes work correctly on your end and for the mu-plugin approach as a temporary measure. We fully understand the situation.
We will include Fix 1 and Fix 2 in the next WOOCS release once we have gathered all pending tasks together. No specific timeline commitment at this stage, but the fixes are confirmed and will be part of the upcoming update.
Thank you very much for your cooperation and for the exceptional quality of this bug report. It is genuinely one of the best we have received.
Hello Manuel
Thank you for confirming that the proposed fixes work correctly on your end and for the mu-plugin approach as a temporary measure. We fully understand the situation.
We will include Fix 1 and Fix 2 in the next WOOCS release once we have gathered all pending tasks together. No specific timeline commitment at this stage, but the fixes are confirmed and will be part of the upcoming update.
Thank you very much for your cooperation and for the exceptional quality of this bug report. It is genuinely one of the best we have received.
Quote from mai_studiopasacademy on May 13, 2026, 10:14Hello, as I can´t find the place to attach a document, I´ll give you access to a link so you can find Manuel´s answer and fix.
https://docs.google.com/document/d/1ltfEvIB8yQqPxqfCnrkO6vS1Ehx9jzc8/edit?usp=sharing&ouid=104143789313477203669&rtpof=true&sd=true
Hello, as I can´t find the place to attach a document, I´ll give you access to a link so you can find Manuel´s answer and fix.
Quote from Alex Dovlatov on May 14, 2026, 11:11Hello Manuel
Thank you very much for the complete code handover and the production validation data. The mu-plugin, the repair algorithm, and the additional findings (base-currency orders, the plugin-update trigger pattern) have all been noted and forwarded for review and testing.
The fixes will be included in the next FOX release. I cannot give a specific date right now as we have a few other things in the queue, but the task is logged and will not be lost.
Thanks again for the exceptional quality of this report and for the generosity in sharing the code. It is genuinely appreciated.
Hello Manuel
Thank you very much for the complete code handover and the production validation data. The mu-plugin, the repair algorithm, and the additional findings (base-currency orders, the plugin-update trigger pattern) have all been noted and forwarded for review and testing.
The fixes will be included in the next FOX release. I cannot give a specific date right now as we have a few other things in the queue, but the task is logged and will not be lost.
Thanks again for the exceptional quality of this report and for the generosity in sharing the code. It is genuinely appreciated.
