Diagnostic WooCommerce core: coupons
Coupon expiry uses UTC not site time
A shop owner sets a coupon to expire on a certain date, expecting it to work through the end of that day in their own timezone. Instead it stops working hours early, or worse, on what looks like the wrong day entirely. Nothing is broken in the usual sense. WooCommerce is comparing a coupon's expiry against UTC time, not the store's local time, and the two clocks do not agree. Here is why it happens and a small script that finds every coupon expiring on the wrong day and fixes it.
WooCommerce stores a coupon's expiry date as date_expires_gmt, a UTC timestamp, and checks it against the current UTC time. When the admin picks a date, WooCommerce saves it as midnight on that date in UTC, not midnight in the store's own timezone. For any store behind UTC, that coupon dies hours before local midnight, and for some offsets it can even land on the wrong calendar day. Run a small Python or Node.js script that reads each coupon's expiry, works out its real moment in site time, and moves it to the end of the intended local day when it is wrong. Full code, tests, and a dry run guard are below.
The problem in plain words
A shop owner opens a coupon, sets "Expiry date" to July 10, and expects the discount to work for customers all day on July 10, their time. That is a reasonable thing to expect, and it is not what happens.
Behind the scenes, WooCommerce takes that date and saves it as 2026-07-10T00:00:00 in date_expires_gmt, treating it as a UTC instant. Every time a customer tries to use the coupon, WooCommerce compares that stored instant against the current UTC time, not the store's local time. If the store's clock is behind UTC, midnight UTC on July 10 has already passed by the time it is still the morning of July 10 locally. The coupon is dead before the day the owner meant it to work has even started to end.
Why it happens
WooCommerce's own documentation notes that a coupon's expiry check runs against current_time('timestamp', true), the true UTC timestamp, while most of the admin screens a shop owner looks at show times in site time. A few things make this worse in practice:
- The date picker in the coupon editor shows only a date, no time and no timezone, so there is nothing on screen to suggest a UTC conversion is happening underneath.
- Stores set up with a timezone far from UTC, common in Asia, Australia, and the Americas, see the biggest gap between the intended deadline and the real one.
- A coupon that "expires at midnight" reads as ambiguous even to a careful admin. Midnight at the start of the day and midnight at the end of the day are twenty four hours apart, and WooCommerce's stored value behaves like the start.
- Support tickets about this usually say the coupon "stopped working a day early" or "still works one day too long," because the actual drift is always some number of hours, and it only looks like a whole day when the offset pushes the instant across midnight in either direction.
This has been reported in the WooCommerce core tracker as coupons expiring earlier than the selected date for any site not on UTC. See the citations at the end for the exact thread.
WooCommerce is not comparing the wrong values, it is comparing the right stored value against the wrong assumption. date_expires_gmt genuinely is a UTC timestamp, and the current time check genuinely is UTC too, so the two agree with each other. The mismatch is between that UTC instant and what the shop owner meant when they picked a date in a timezone that is not UTC. A fix has to work out the store's real, intended deadline in local time and only then convert it back to UTC for storage.
The fix, as a flow
We do not touch checkout or the coupon validation code. We add a small script that reads a store's coupons through the REST API, works out what UTC instant would correspond to 23:59:59 on the intended local calendar day, and compares that to what is actually stored. When they disagree by more than a minute, the script corrects the stored value so the coupon lasts through the whole intended day in site time.
Build it step by step
Get access and know your store's UTC offset
You need a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to coupons. Create it under WooCommerce, Settings, Advanced, REST API. You also need the store's fixed UTC offset in minutes. Find it under WordPress admin, Settings, General, then multiply the timezone hours shown there by 60.
pip install requests
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export SITE_UTC_OFFSET_MINUTES="480" # e.g. 480 for UTC+8, -300 for UTC-5
export DRY_RUN="true" # start safe, change to false to write
npm install
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export SITE_UTC_OFFSET_MINUTES="480" // e.g. 480 for UTC+8, -300 for UTC-5
export DRY_RUN="true" // start safe, change to false to write
List every coupon that has an expiry date
Page through the WooCommerce REST API's coupon list and keep only the coupons where date_expires_gmt is set. Coupons with no expiry at all are not this bug, since there is no deadline to be wrong about.
import requests
from requests.auth import HTTPBasicAuth
WOO_URL = "https://yourstore.com"
AUTH = HTTPBasicAuth("ck_...", "cs_...")
def list_expiring_coupons():
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/coupons",
params={"per_page": 50, "page": page},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for coupon in batch:
if coupon.get("date_expires_gmt"):
yield coupon
page += 1
const WOO_URL = "https://yourstore.com";
const AUTH = "Basic " + Buffer.from("ck_...:cs_...").toString("base64");
async function woo(path) {
const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
headers: { Authorization: AUTH },
});
if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
return res.json();
}
async function* listExpiringCoupons() {
let page = 1;
while (true) {
const batch = await woo(`/coupons?per_page=50&page=${page}`);
if (!batch.length) return;
for (const coupon of batch) {
if (coupon.date_expires_gmt) yield coupon;
}
page++;
}
}
Work out the coupon's real deadline in site time
Take the stored UTC instant and shift it by the store's UTC offset to get the local moment it actually expires at. Then work out what UTC instant corresponds to 23:59:59 local on that same intended calendar date. That second value is what should have been stored all along.
from datetime import datetime, timedelta
def to_local(dt_utc, offset_minutes):
return dt_utc + timedelta(minutes=offset_minutes)
def end_of_local_day_in_utc(local_dt, offset_minutes):
end_of_day_local = local_dt.replace(hour=23, minute=59, second=59, microsecond=0)
return end_of_day_local - timedelta(minutes=offset_minutes)
def parse_woo_datetime(value):
if not value:
return None
return datetime.fromisoformat(value.replace("Z", ""))
const MINUTE_MS = 60 * 1000;
function parseWooDatetime(value) {
if (!value) return null;
const iso = value.endsWith("Z") ? value : `${value}Z`;
return Date.parse(iso);
}
function toLocalMs(utcMs, offsetMinutes) {
return utcMs + offsetMinutes * MINUTE_MS;
}
function endOfLocalDayInUtcMs(localMs, offsetMinutes) {
const local = new Date(localMs);
const endOfDayLocal = Date.UTC(
local.getUTCFullYear(), local.getUTCMonth(), local.getUTCDate(),
23, 59, 59, 0
);
return endOfDayLocal - offsetMinutes * MINUTE_MS;
}
Decide, with one pure function
Keep the decision in its own function that takes a coupon and a UTC offset 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. If there is no expiry, skip it. If the stored instant already lands within a minute of the end of the intended local day, it is fine. Otherwise, correct it.
def decide(coupon, site_utc_offset_minutes):
expires_gmt = coupon.get("date_expires_gmt")
if not expires_gmt:
return ("skip", "coupon has no expiry date", None)
expires_utc = parse_woo_datetime(expires_gmt)
intended_date = expires_utc.date()
local_expires = to_local(expires_utc, site_utc_offset_minutes)
intended_utc = end_of_local_day_in_utc(local_expires, site_utc_offset_minutes)
drift_seconds = abs((intended_utc - expires_utc).total_seconds())
if drift_seconds <= 60:
return ("ok", "expiry already lands at end of the local day", None)
if local_expires.date() != intended_date:
reason = "expiry crosses UTC midnight onto the wrong local calendar day"
else:
reason = "expiry is mid-day in site time, coupon dies hours early"
return ("correct", reason, intended_utc.isoformat())
export function decide(coupon, siteUtcOffsetMinutes) {
const expiresGmt = coupon.date_expires_gmt;
if (!expiresGmt) return ["skip", "coupon has no expiry date", null];
const expiresUtcMs = parseWooDatetime(expiresGmt);
const intendedDate = expiresGmt.slice(0, 10);
const localMs = toLocalMs(expiresUtcMs, siteUtcOffsetMinutes);
const intendedUtcMs = endOfLocalDayInUtcMs(localMs, siteUtcOffsetMinutes);
const driftSeconds = Math.abs((intendedUtcMs - expiresUtcMs) / 1000);
if (driftSeconds <= 60) {
return ["ok", "expiry already lands at end of the local day", null];
}
const localDate = new Date(localMs).toISOString().slice(0, 10);
const reason = localDate !== intendedDate
? "expiry crosses UTC midnight onto the wrong local calendar day"
: "expiry is mid-day in site time, coupon dies hours early";
return ["correct", reason, intendedUtcMs];
}
Write the corrected expiry back
When the action is correct, send a small PUT request to the coupon with only date_expires_gmt changed. Nothing else about the coupon moves. The calendar date the owner picked never changes, only the time of day it actually expires at.
def apply_fix(coupon_id, corrected_gmt_iso):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/coupons/{coupon_id}",
json={"date_expires_gmt": corrected_gmt_iso},
auth=AUTH, timeout=30,
).raise_for_status()
async function applyFix(couponId, correctedGmtIso) {
await woo(`/coupons/${couponId}`, {
method: "PUT",
body: JSON.stringify({ date_expires_gmt: correctedGmtIso }),
});
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first run, leave DRY_RUN on so the script only reports what it would change. Read the output, trust it, then switch it off to let it write. This is a one time cleanup for existing coupons, so most stores run it once, then again after adding new expiring coupons.
Always start with DRY_RUN=true. Double check SITE_UTC_OFFSET_MINUTES matches the timezone shown under WordPress admin, Settings, General, before you ever let the script write. A wrong offset would move coupon deadlines in the wrong direction.
The full code
Here is the complete script 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 changes a coupon that already expires at the end of its intended local day.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Detect and repair WooCommerce coupons that expire on the wrong local day
because date_expires is stored and compared in UTC, not site time.
WooCommerce saves a coupon's expiry as a UTC timestamp (date_expires_gmt) and
compares it against the current UTC time to decide whether the coupon is
still valid. The shop owner picks a date in the WordPress admin thinking in
site time (the store's local timezone). For any store west of UTC, midnight
UTC on the chosen date lands several hours BEFORE local midnight, so the
coupon dies on what the calendar still shows as the intended day. For stores
east of UTC, the coupon can also expire hours before end of day, or roll
onto the wrong local calendar date entirely.
This script asks the WooCommerce REST API for coupons with an expiry date,
works out the coupon's actual last valid moment in the store's local
timezone, and flags any coupon whose local expiry moment does not land at
the end of the calendar day the code implies (23:59:59 local). When it
finds one, it can rewrite date_expires_gmt so the coupon actually expires at
the end of the intended local day. Dry run by default. Safe to run again
and again, because a coupon that already expires at end of local day is
left alone.
"""
import os
import logging
from datetime import datetime, timedelta
import requests
from requests.auth import HTTPBasicAuth
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("fix_coupon_expiry_timezone")
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
# Store's UTC offset in minutes, e.g. -300 for America/New_York (EST), 480 for
# Asia/Manila. WordPress exposes this as gmt_offset (hours) under
# Settings, General. Multiply by 60 if you copy that value in.
SITE_UTC_OFFSET_MINUTES = int(os.environ.get("SITE_UTC_OFFSET_MINUTES", "0"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
def to_local(dt_utc, offset_minutes):
"""Convert a naive UTC datetime to a naive local datetime using a fixed
minute offset. WooCommerce dates are naive (no tzinfo), so we keep this
naive too and treat the offset as a simple, constant shift.
"""
return dt_utc + timedelta(minutes=offset_minutes)
def end_of_local_day_in_utc(local_dt, offset_minutes):
"""Given a naive local datetime, return the UTC instant that corresponds
to 23:59:59 local time on that same local calendar date.
"""
end_of_day_local = local_dt.replace(hour=23, minute=59, second=59, microsecond=0)
return end_of_day_local - timedelta(minutes=offset_minutes)
def parse_woo_datetime(value):
"""Parse a WooCommerce ISO-ish datetime string ("2026-07-10T00:00:00")
into a naive UTC datetime. Returns None for empty or missing values.
"""
if not value:
return None
return datetime.fromisoformat(value.replace("Z", ""))
def decide(coupon, site_utc_offset_minutes):
"""Pure decision: does this coupon's UTC expiry land on the wrong local
day (or the right day but not at the end of it), and if so what should
the corrected date_expires_gmt be.
coupon: a dict with "id", "code", and "date_expires_gmt" (a
WooCommerce-style ISO datetime string, or "" / None when the coupon
never expires).
site_utc_offset_minutes: the store's fixed UTC offset in minutes.
Returns a tuple of (action, reason, corrected_gmt_iso_or_None):
"skip" no expiry set, nothing to check
"ok" the expiry already lands at end of the intended local day
"correct" the expiry is off by more than a minute; corrected_gmt_iso
holds the ISO string to write back to date_expires_gmt
"""
expires_gmt = coupon.get("date_expires_gmt")
if not expires_gmt:
return ("skip", "coupon has no expiry date", None)
expires_utc = parse_woo_datetime(expires_gmt)
intended_date = expires_utc.date()
local_expires = to_local(expires_utc, site_utc_offset_minutes)
intended_utc = end_of_local_day_in_utc(local_expires, site_utc_offset_minutes)
drift_seconds = abs((intended_utc - expires_utc).total_seconds())
if drift_seconds <= 60:
return ("ok", "expiry already lands at end of the local day", None)
if local_expires.date() != intended_date:
reason = "expiry crosses UTC midnight onto the wrong local calendar day"
else:
reason = "expiry is mid-day in site time, coupon dies hours early"
return ("correct", reason, intended_utc.isoformat())
def list_expiring_coupons():
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/coupons",
params={"per_page": 50, "page": page},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for coupon in batch:
if coupon.get("date_expires_gmt"):
yield coupon
page += 1
def apply_fix(coupon_id, corrected_gmt_iso):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/coupons/{coupon_id}",
json={"date_expires_gmt": corrected_gmt_iso},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
fixed = 0
for coupon in list_expiring_coupons():
action, reason, corrected_gmt_iso = decide(coupon, SITE_UTC_OFFSET_MINUTES)
if action != "correct":
continue
log.info(
"Coupon %s (%s): %s. %s",
coupon["id"], coupon.get("code"), reason,
"would correct" if DRY_RUN else "correcting",
)
if not DRY_RUN:
apply_fix(coupon["id"], corrected_gmt_iso)
fixed += 1
log.info("Done. %d coupon(s) %s.", fixed, "to correct" if DRY_RUN else "corrected")
if __name__ == "__main__":
run()
/**
* Detect and repair WooCommerce coupons that expire on the wrong local day
* because date_expires is stored and compared in UTC, not site time.
*
* WooCommerce saves a coupon's expiry as a UTC timestamp (date_expires_gmt)
* and compares it against the current UTC time to decide whether the
* coupon is still valid. The shop owner picks a date in the WordPress admin
* thinking in site time (the store's local timezone). For any store west of
* UTC, midnight UTC on the chosen date lands several hours BEFORE local
* midnight, so the coupon dies on what the calendar still shows as the
* intended day. For stores east of UTC, the coupon can also expire hours
* before end of day, or roll onto the wrong local calendar date entirely.
*
* This script asks the WooCommerce REST API for coupons with an expiry
* date, works out the coupon's actual last valid moment in the store's
* local timezone, and flags any coupon whose local expiry moment does not
* land at the end of the calendar day the code implies (23:59:59 local).
* When it finds one, it can rewrite date_expires_gmt so the coupon
* actually expires at the end of the intended local day. Dry run by
* default. Safe to run again and again.
*/
import { pathToFileURL } from "node:url";
const WOO_URL = (process.env.WOO_STORE_URL || "https://example.com").replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
`${process.env.WOO_CONSUMER_KEY || "ck_dummy"}:${process.env.WOO_CONSUMER_SECRET || "cs_dummy"}`
).toString("base64");
// Store's UTC offset in minutes, e.g. -300 for America/New_York (EST), 480
// for Asia/Manila. WordPress exposes this as gmt_offset (hours) under
// Settings, General. Multiply by 60 if you copy that value in.
const SITE_UTC_OFFSET_MINUTES = Number(process.env.SITE_UTC_OFFSET_MINUTES || 0);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const MINUTE_MS = 60 * 1000;
export function parseWooDatetime(value) {
if (!value) return null;
const iso = value.endsWith("Z") ? value : `${value}Z`;
return Date.parse(iso);
}
export function toLocalMs(utcMs, offsetMinutes) {
return utcMs + offsetMinutes * MINUTE_MS;
}
export function endOfLocalDayInUtcMs(localMs, offsetMinutes) {
const local = new Date(localMs);
const endOfDayLocal = Date.UTC(
local.getUTCFullYear(), local.getUTCMonth(), local.getUTCDate(),
23, 59, 59, 0
);
return endOfDayLocal - offsetMinutes * MINUTE_MS;
}
function isoNoMillis(ms) {
return new Date(ms).toISOString().replace(/\.\d{3}Z$/, "");
}
export function decide(coupon, siteUtcOffsetMinutes) {
const expiresGmt = coupon.date_expires_gmt;
if (!expiresGmt) return ["skip", "coupon has no expiry date", null];
const expiresUtcMs = parseWooDatetime(expiresGmt);
const intendedDate = expiresGmt.slice(0, 10);
const localMs = toLocalMs(expiresUtcMs, siteUtcOffsetMinutes);
const intendedUtcMs = endOfLocalDayInUtcMs(localMs, siteUtcOffsetMinutes);
const driftSeconds = Math.abs((intendedUtcMs - expiresUtcMs) / 1000);
if (driftSeconds <= 60) {
return ["ok", "expiry already lands at end of the local day", null];
}
const localDate = new Date(localMs).toISOString().slice(0, 10);
const reason = localDate !== intendedDate
? "expiry crosses UTC midnight onto the wrong local calendar day"
: "expiry is mid-day in site time, coupon dies hours early";
return ["correct", reason, isoNoMillis(intendedUtcMs)];
}
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* listExpiringCoupons() {
let page = 1;
while (true) {
const batch = await woo(`/coupons?per_page=50&page=${page}`);
if (!batch.length) return;
for (const coupon of batch) {
if (coupon.date_expires_gmt) yield coupon;
}
page++;
}
}
async function applyFix(couponId, correctedGmtIso) {
await woo(`/coupons/${couponId}`, {
method: "PUT",
body: JSON.stringify({ date_expires_gmt: correctedGmtIso }),
});
}
export async function run() {
let fixed = 0;
for await (const coupon of listExpiringCoupons()) {
const [action, reason, correctedGmtIso] = decide(coupon, SITE_UTC_OFFSET_MINUTES);
if (action !== "correct") continue;
console.log(
`Coupon ${coupon.id} (${coupon.code}): ${reason}. ${DRY_RUN ? "would correct" : "correcting"}`
);
if (!DRY_RUN) await applyFix(coupon.id, correctedGmtIso);
fixed++;
}
console.log(`Done. ${fixed} coupon(s) ${DRY_RUN ? "to correct" : "corrected"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
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 coupon deadlines get rewritten. Because we kept decide pure, the test needs no network and no WooCommerce store. It just feeds in plain objects and offsets, and checks the action.
from fix_coupon_expiry_timezone import decide
def coupon(**over):
base = {"id": 1, "code": "SUMMER10", "date_expires_gmt": "2026-07-10T00:00:00"}
base.update(over)
return base
def test_skip_when_no_expiry():
assert decide(coupon(date_expires_gmt=""), 480)[0] == "skip"
def test_correct_when_positive_offset_expires_mid_day():
# Manila, UTC+8. Midnight UTC on 2026-07-10 is 08:00 local the same
# day, hours before end of day. Should be corrected forward.
action, reason, corrected = decide(coupon(), 480)
assert action == "correct"
assert corrected == "2026-07-10T15:59:59"
def test_correct_when_negative_offset_crosses_to_wrong_day():
# New York, UTC-5. Midnight UTC on 2026-07-10 is 19:00 local on
# 2026-07-09, an entirely different calendar day than intended.
action, reason, corrected = decide(coupon(), -300)
assert action == "correct"
assert "wrong local calendar day" in reason
def test_ok_when_already_end_of_local_day():
assert decide(coupon(date_expires_gmt="2026-07-10T15:59:59"), 480)[0] == "ok"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./fix-coupon-expiry-timezone.js";
const coupon = (over = {}) => ({
id: 1, code: "SUMMER10", date_expires_gmt: "2026-07-10T00:00:00", ...over,
});
test("skip when no expiry", () => {
assert.equal(decide(coupon({ date_expires_gmt: "" }), 480)[0], "skip");
});
test("correct when positive offset expires mid day", () => {
const [action, , corrected] = decide(coupon(), 480);
assert.equal(action, "correct");
assert.equal(corrected, "2026-07-10T15:59:59");
});
test("correct when negative offset crosses to wrong day", () => {
const [action, reason] = decide(coupon(), -300);
assert.equal(action, "correct");
assert.match(reason, /wrong local calendar day/);
});
test("ok when already end of local day", () => {
assert.equal(decide(coupon({ date_expires_gmt: "2026-07-10T15:59:59" }), 480)[0], "ok");
});
Case studies
The sale that ended eight hours early
A store in Manila (UTC+8) ran a one day flash sale with a coupon set to expire on the sale's last date. Customers browsing that evening, still well before local midnight, found the coupon already rejected at checkout. Support fielded a wave of "your discount code is broken" messages for hours before anyone realized the coupon had quietly died at 8am local time, when it hit midnight UTC.
Running the script in dry run showed the exact gap, eight hours short of the intended deadline. Once applied for real, the corrected coupon lasted the whole day the sale page promised.
The coupon that died the night before
A store on the US East Coast (UTC-5) set a members-only coupon to expire "July 10." Because midnight UTC on July 10 is 7pm on July 9 in New York, the coupon actually stopped working during the evening of July 9, a full calendar day before the date printed in the newsletter.
The script flagged this coupon as crossing UTC midnight onto the wrong local day, a clearer signal than a simple hours-early drift, and the fix moved it to the correct end of day in local time.
After this script runs, every coupon's stored expiry lands exactly at the end of the calendar day the shop owner picked, in the store's own timezone. New coupons created after a fix still need the same check, since WooCommerce's admin screen has not changed how it saves the date, so it is worth rerunning this after adding new time limited coupons rather than only once.
FAQ
Why does my WooCommerce coupon expire before the date I picked?
WooCommerce stores a coupon's expiry as a UTC timestamp and compares it against the current UTC time. If your store's timezone is behind UTC, midnight UTC on the date you picked lands earlier in your local day, so the coupon can die hours before local midnight or even on the previous local day.
Is it safe to change a coupon's expiry date with a script?
Yes, when the script only moves the expiry to the end of the same calendar day you already set, using your store's own UTC offset. It never changes which day the coupon is meant to expire on, only the time of day, and it leaves coupons that already expire correctly untouched. Start in dry run mode to review the list first.
How do I find my store's UTC offset?
Go to WordPress admin, Settings, General, and look at the timezone field. It shows the current UTC offset in hours. Multiply that number by 60 to get the minutes value the script expects, and use a negative number for timezones behind UTC.
Related field notes
Citations
On the problem:
- WooCommerce core tracker: coupons expire earlier than the selected date on sites not using UTC. github.com/woocommerce/woocommerce/issues
- WooCommerce docs: coupon management and expiry date behavior. woocommerce.com/document/coupon-management
- WordPress developer reference: current_time() and the difference between site time and true UTC time. developer.wordpress.org/reference/functions/current_time
On the solution:
- WooCommerce REST API: retrieve and update a coupon, including date_expires_gmt. woocommerce.github.io/woocommerce-rest-api-docs
- WordPress developer reference: wp_timezone and gmt_offset for reading a site's configured UTC offset. developer.wordpress.org/reference/functions/wp_timezone
- MDN: Date.UTC() and working with fixed UTC offsets in JavaScript. developer.mozilla.org/Date/UTC
Stuck on a tricky one?
If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this fix your coupon timing?
If this saved you a confusing round of "the coupon stopped working early" tickets, 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