Repair WooCommerce core: products and catalog

Expired sale prices never revert

The sale ended last week. The banner is gone, the email campaign is over, but the product still rings up at the sale price. Nobody changed anything. WooCommerce was supposed to clear it automatically, and most of the time it does, until the one scheduled task that clears it quietly stops firing. Here is why that happens and a small script that finds every product still stuck on an expired sale and reverts it in a safe way.

Python and Node.js Runs on a schedule Safe by default (dry run)
A lot of open brown boxes
Photo by Luke Heibert on Unsplash
The short answer

WooCommerce clears expired sale prices with a scheduled task called wc_scheduled_sales, which depends on WP-Cron firing at least once a day. If WP-Cron is disabled, the site gets no visits around midnight, or the event was lost during a migration or a cron manager swap, the sale price and the on-sale flag stay active forever. Run a small Python or Node.js script on a schedule that reads every product's sale_price_dates_to through the WooCommerce REST API, and for any product whose sale window has passed, clears the sale price and turns off the on-sale flag. Full code, tests, and a dry run guard are below.

The problem in plain words

When you schedule a sale in WooCommerce, you set a sale price, a start date, and an end date. The product itself does not know how to turn the sale off. WooCommerce depends on a background job, wc_scheduled_sales, that runs once a day and asks the database "which products have a sale end date in the past but still have a sale price set." Anything it finds gets its sale price cleared and its on-sale flag turned off.

That background job runs through WP-Cron, which is not a real system cron job by default. WordPress only checks whether a scheduled task is due when someone loads a page on the site. On a quiet store overnight, or a store that disabled WP-Cron in favor of a real system cron entry that was never set up correctly, the check for expired sales can simply never fire. The sale price then sits there, live, charging every customer the discounted amount until someone notices the numbers look wrong.

Sale scheduled with an end date End date passes sale_price_dates_to cron missed Sale price stuck still on sale Underselling every order
The sale window closes on paper, but nothing in WooCommerce checks the clock unless the scheduled task actually runs.

Why it happens

The WooCommerce core code for wc_scheduled_sales is straightforward. It is the trigger that is fragile, not the logic. A few common reasons the revert never happens:

This is reported often in the WooCommerce support forums as "sale price won't go away" or "product still shows sale badge after end date." The support docs are clear that this is a scheduling problem, not a pricing bug, so the fix is a script that does not depend on WP-Cron at all.

The key insight

The sale end date stored on the product is the source of truth, not whatever price is currently active. If sale_price_dates_to is in the past and the product still has an active sale price, the product is wrong, not the date. A reverter is a safety net that runs on its own schedule, reads the truth from each product, and clears the sale price WP-Cron missed.

The fix, as a flow

We do not touch WP-Cron and we do not try to diagnose why it stopped firing. We add a separate job that runs on a normal system cron, once an hour, and asks the store for every product that is currently on sale. For each one, we compare the stored sale end date to the current time. If the end date has passed and a sale price is still set, we clear the sale price and turn off the on-sale flag, the same way wc_scheduled_sales would have.

Scheduled job once an hour List products on_sale = true Read sale end date sale_price_dates_to End date has passed? yes no, skip Clear sale price turn off on-sale flag
The reverter reads the sale end date on each product and only clears the ones whose window has actually closed. Anything still within its sale dates is left alone.

Build it step by step

1

Get a WooCommerce REST API key

Create a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to products. Create it under WooCommerce, Settings, Advanced, REST API. This script does not need Stripe at all, since sale prices are a catalog problem, not a payment one. Keep every value in environment variables, never in the file.

setup (shell)
pip install requests

export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
npm install

export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export DRY_RUN="true"   // start safe, change to false to write
2

List every product currently flagged on sale

Ask the WooCommerce REST API for products with on_sale=true. We page through all of them. This is a small list on most stores, since it only ever contains products with an active sale price, not the whole catalog.

step2.py
import os, requests
from requests.auth import HTTPBasicAuth

WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])

def products_on_sale():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/products",
            params={"on_sale": "true", "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for product in batch:
            yield product
        page += 1
step2.js
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
  `${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");

async function woo(path, options = {}) {
  const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
    ...options,
    headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
  });
  if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

async function* productsOnSale() {
  let page = 1;
  while (true) {
    const batch = await woo(`/products?on_sale=true&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const product of batch) yield product;
    page++;
  }
}
3

Read the sale window off the product

The REST API returns date_on_sale_to (and date_on_sale_to_gmt) alongside sale_price and regular_price. A variable product carries this per variation, so we check both the parent product and, when present, its variations the same way.

step3.py
from datetime import datetime, timezone

def parse_gmt(value):
    if not value:
        return None
    return datetime.fromisoformat(value).replace(tzinfo=timezone.utc)

def sale_window_of(product):
    return {
        "sale_price": product.get("sale_price") or "",
        "regular_price": product.get("regular_price") or "",
        "ends_at": parse_gmt(product.get("date_on_sale_to_gmt")),
    }
step3.js
export function parseGmt(value) {
  if (!value) return null;
  return new Date(value.endsWith("Z") ? value : `${value}Z`);
}

export function saleWindowOf(product) {
  return {
    salePrice: product.sale_price || "",
    regularPrice: product.regular_price || "",
    endsAt: parseGmt(product.date_on_sale_to_gmt),
  };
}
4

Decide, with one pure function

Keep the decision in its own function that takes the sale window and the current time and returns an action. A pure function like this is easy to read and easy to test, which we do later. The rule is simple. No sale price means nothing to revert. No end date means it is an open-ended sale, so leave it alone. An end date in the future means the sale is still running. Only an end date in the past with a sale price still set gets reverted.

decide.py
def decide(sale_window, now):
    if not sale_window["sale_price"]:
        return ("skip", "no sale price set")
    if sale_window["ends_at"] is None:
        return ("skip", "open-ended sale, no end date")
    if sale_window["ends_at"] > now:
        return ("skip", "sale window still open")
    return ("revert", "sale end date has passed")
decide.js
export function decide(saleWindow, now) {
  if (!saleWindow.salePrice) return ["skip", "no sale price set"];
  if (!saleWindow.endsAt) return ["skip", "open-ended sale, no end date"];
  if (saleWindow.endsAt > now) return ["skip", "sale window still open"];
  return ["revert", "sale end date has passed"];
}
5

Revert the price the way the scheduled task would

When the action is revert, clear the sale price on the product and blank out the sale dates so a future sale can be scheduled cleanly. The regular price is never touched, so the product simply falls back to its normal price. This goes through the REST API, so it works the same with variable products and with HPOS enabled.

apply.py
def revert_sale(product_id):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/products/{product_id}",
        json={
            "sale_price": "",
            "date_on_sale_from": None,
            "date_on_sale_to": None,
        },
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function revertSale(productId) {
  await woo(`/products/${productId}`, {
    method: "PUT",
    body: JSON.stringify({
      sale_price: "",
      date_on_sale_from: null,
      date_on_sale_to: null,
    }),
  });
}
6

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports what it would revert. Read the output, trust it, then switch it off to let it write. Run it on a schedule with cron once an hour.

Run it safe

Always start with DRY_RUN=true. A reverter writes to real product prices, so you want to see its plan before it acts. Once the report looks right for a day, turn it off.

The full code

Here is the complete reverter in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it never touches a product whose sale window is still open or has no sale price at all.

View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.

revert_expired_sales.py
"""Revert WooCommerce sale prices whose sale window already ended.

WooCommerce normally clears these with the wc_scheduled_sales WP-Cron task.
When that task is missed, this script does the same job directly through the
REST API. Run on a schedule. Safe to run again and again.
"""
import os
import logging
from datetime import datetime, timezone
import requests
from requests.auth import HTTPBasicAuth

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("revert_expired_sales")

WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


def parse_gmt(value):
    if not value:
        return None
    return datetime.fromisoformat(value).replace(tzinfo=timezone.utc)


def sale_window_of(product):
    return {
        "sale_price": product.get("sale_price") or "",
        "regular_price": product.get("regular_price") or "",
        "ends_at": parse_gmt(product.get("date_on_sale_to_gmt")),
    }


def decide(sale_window, now):
    if not sale_window["sale_price"]:
        return ("skip", "no sale price set")
    if sale_window["ends_at"] is None:
        return ("skip", "open-ended sale, no end date")
    if sale_window["ends_at"] > now:
        return ("skip", "sale window still open")
    return ("revert", "sale end date has passed")


def products_on_sale():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/products",
            params={"on_sale": "true", "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for product in batch:
            yield product
        page += 1


def revert_sale(product_id):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/products/{product_id}",
        json={"sale_price": "", "date_on_sale_from": None, "date_on_sale_to": None},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    now = datetime.now(timezone.utc)
    reverted = 0
    for product in products_on_sale():
        window = sale_window_of(product)
        action, reason = decide(window, now)
        if action != "revert":
            continue
        log.info("Product %s: %s. %s", product["id"], reason, "would revert" if DRY_RUN else "reverting")
        if not DRY_RUN:
            revert_sale(product["id"])
        reverted += 1
    log.info("Done. %d product(s) %s.", reverted, "to revert" if DRY_RUN else "reverted")


if __name__ == "__main__":
    run()
revert-expired-sales.js
/**
 * Revert WooCommerce sale prices whose sale window already ended.
 *
 * WooCommerce normally clears these with the wc_scheduled_sales WP-Cron task.
 * When that task is missed, this script does the same job directly through
 * the REST API. Run on a schedule. Safe to run again and again.
 */
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
  `${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

async function woo(path, options = {}) {
  const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
    ...options,
    headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
  });
  if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

function parseGmt(value) {
  if (!value) return null;
  return new Date(value.endsWith("Z") ? value : `${value}Z`);
}

function saleWindowOf(product) {
  return {
    salePrice: product.sale_price || "",
    regularPrice: product.regular_price || "",
    endsAt: parseGmt(product.date_on_sale_to_gmt),
  };
}

function decide(saleWindow, now) {
  if (!saleWindow.salePrice) return ["skip", "no sale price set"];
  if (!saleWindow.endsAt) return ["skip", "open-ended sale, no end date"];
  if (saleWindow.endsAt > now) return ["skip", "sale window still open"];
  return ["revert", "sale end date has passed"];
}

async function* productsOnSale() {
  let page = 1;
  while (true) {
    const batch = await woo(`/products?on_sale=true&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const product of batch) yield product;
    page++;
  }
}

async function revertSale(productId) {
  await woo(`/products/${productId}`, {
    method: "PUT",
    body: JSON.stringify({ sale_price: "", date_on_sale_from: null, date_on_sale_to: null }),
  });
}

async function run() {
  const now = new Date();
  let reverted = 0;
  for await (const product of productsOnSale()) {
    const window = saleWindowOf(product);
    const [action, reason] = decide(window, now);
    if (action !== "revert") continue;
    console.log(`Product ${product.id}: ${reason}. ${DRY_RUN ? "would revert" : "reverting"}`);
    if (!DRY_RUN) await revertSale(product.id);
    reverted++;
  }
  console.log(`Done. ${reverted} product(s) ${DRY_RUN ? "to revert" : "reverted"}.`);
}

run().catch((err) => { console.error(err); process.exit(1); });

Add a test

The decision rule is the part most worth testing, because it decides whether real product prices get touched. Because we kept decide pure, the test needs no network and no live store. It just feeds in plain objects and a fixed point in time, then checks the action.

test_decide.py
from datetime import datetime, timezone
from revert_expired_sales import decide

NOW = datetime(2026, 7, 10, 12, 0, tzinfo=timezone.utc)


def window(**over):
    base = {"sale_price": "19.00", "regular_price": "29.00",
            "ends_at": datetime(2026, 7, 1, tzinfo=timezone.utc)}
    base.update(over)
    return base


def test_revert_when_end_date_passed():
    assert decide(window(), NOW)[0] == "revert"


def test_skip_when_no_sale_price():
    assert decide(window(sale_price=""), NOW)[0] == "skip"


def test_skip_when_no_end_date():
    assert decide(window(ends_at=None), NOW)[0] == "skip"


def test_skip_when_end_date_in_future():
    future = datetime(2026, 8, 1, tzinfo=timezone.utc)
    assert decide(window(ends_at=future), NOW)[0] == "skip"
decide.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./decide.js";

const NOW = new Date("2026-07-10T12:00:00Z");

const win = (over = {}) => ({
  salePrice: "19.00",
  regularPrice: "29.00",
  endsAt: new Date("2026-07-01T00:00:00Z"),
  ...over,
});

test("revert when end date passed", () => {
  assert.equal(decide(win(), NOW)[0], "revert");
});

test("skip when no sale price", () => {
  assert.equal(decide(win({ salePrice: "" }), NOW)[0], "skip");
});

test("skip when no end date", () => {
  assert.equal(decide(win({ endsAt: null }), NOW)[0], "skip");
});

test("skip when end date in future", () => {
  assert.equal(decide(win({ endsAt: new Date("2026-08-01T00:00:00Z") }), NOW)[0], "skip");
});

Case studies

System cron migration

The store that turned off WP-Cron and forgot the rest

A developer added define('DISABLE_WP_CRON', true); to speed up page loads and set up a real system cron entry to call wp-cron.php. The entry pointed at the staging URL from testing, not the live one. Every WP-Cron task, including wc_scheduled_sales, silently stopped running.

Three finished sales stayed live for weeks. The hourly reverter caught all three the first time it ran and kept new ones from slipping through while the cron entry was fixed.

Database restore

The restore that brought sale dates back from the dead

A store restored a database backup after a failed plugin update. The backup was from three days before, while a weekend sale was still active. The restored products carried the old sale price and end date, but the scheduled task that should have cleared it had already fired once and would not fire again for those specific products until the next full day cycle.

Running the reverter in dry run showed the exact eleven products still on the stale sale price. The team confirmed the list, ran it for real, and normal pricing was back within minutes instead of waiting out the day.

What good looks like

After this runs on a schedule, a missed cron event is no longer a silent discount that lasts for days. The worst case becomes a delay of at most an hour before the reverter clears the stale sale price. Keep it running even after you fix WP-Cron, because a single missed schedule tick will always be possible.

FAQ

Why is my WooCommerce product still charging the sale price after the sale ended?

WooCommerce relies on a scheduled task called wc_scheduled_sales to clear expired sale prices at midnight site time. If that cron event is missed, disabled, or the site has no regular traffic to trigger WP-Cron, the sale price and the on-sale flag stay active even though the sale end date has passed. A script that checks each product's sale end date against the current time and clears the sale price when it has passed fixes it.

Is it safe to change product prices with a script?

Yes, when the script only reverts products whose sale_price_dates_to has actually passed and leaves the regular price untouched, and it skips products with no end date or a future end date. Start in dry run mode to review the list before it writes.

How often should the reverter run?

Once every hour is enough for most stores, since sale windows are usually set to the day, not the minute. Running it more often is harmless because it only acts on products whose sale has already expired.

Related field notes

Citations

On the problem:

  1. WooCommerce core source: the wc_scheduled_sales function that clears expired sale prices. github.com/woocommerce/woocommerce
  2. WordPress developer docs: how WP-Cron depends on page visits instead of a real system clock. developer.wordpress.org/plugins/cron
  3. WooCommerce support: reports of sale prices and the on-sale badge staying active after the scheduled end date. wordpress.org/support/plugin/woocommerce

On the solution:

  1. WordPress developer docs: setting up a real system cron and disabling WP-Cron correctly. developer.wordpress.org/plugins/cron
  2. WooCommerce REST API: list and update products, including sale price and sale date fields. woocommerce.github.io/woocommerce-rest-api-docs
  3. WooCommerce REST API: the on_sale filter for listing only products with an active sale price. woocommerce.github.io/woocommerce-rest-api-docs

Stuck on a tricky one?

If you have a bug in WooCommerce, WooCommerce Subscriptions, or catalog pricing that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this fix your stuck sale prices?

If this saved you from underselling your own catalog for another day, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all WooCommerce and Stripe field notes