Skip to content

Diagnostic

Expired voucher still usable and left attached to orders

A customer adds a voucher a day before it expires, takes a slow trip through checkout, and pays after the code should have died. PrestaShop lets the discount through anyway, and it rides all the way into a paid order. Nothing about the order looks broken at a glance, but the discount shown no longer matches what a fresh entry of that same code would allow, and finance ends up looking at totals that do not add up. Here is why an expired voucher can stay attached through checkout and a script that finds every cart and order still carrying one.

Python and Node.js PrestaShop Webservice API Safe by default (report only)
A discount coupon
Photo by Tamanna Rumee on Unsplash
The short answer

CartRule::checkValidity() treats a voucher already sitting in the cart differently from one being newly entered. When the internal $alreadyInCart flag is true, the date_to expiry check is effectively bypassed, so a code added before it expired stays valid through checkout even if the customer actually pays after date_to has passed. Because the cart-to-order conversion copies the cart_rule association into order_cart_rule at payment time without re-validating dates, and nothing re-scans placed orders afterward, an expired discount can ride all the way into a paid order. Run a Python or Node.js script that pulls open carts and recent orders, reads each attached voucher's date_to from cart_rules, and flags any record where the order or cart date falls outside the voucher's validity window. This is a finance-correctness issue, so the default action is to report it for manual review, not auto-fix it. Full code, tests, and citations are below.

The problem in plain words

A voucher in PrestaShop is a cart_rule with a date_from and a date_to. When a customer types a code into the cart, PrestaShop checks whether today falls inside that window before it lets the discount apply. That part works exactly as you would expect.

The trouble starts once the voucher is already sitting in the cart. The customer added it on day one, when it was still valid, then wandered off, added another item, came back the next day, and finally paid, by which point date_to has already passed. PrestaShop does not re-check the expiry at that point the same way it did when the code was first typed in. The discount stays applied, the order gets placed, and the payment goes through with a voucher on it that should no longer exist.

Voucher entered while still valid, day 1 alreadyInCart = true checkValidity() skips date_to Customer pays, day 3 date_to already passed order_cart_rule copied no re-validation of dates Paid order with an expired voucher
The voucher was valid when it was typed in, but nothing re-checks date_to once it is already attached, so it rides straight into the paid order.

Why it happens

The root cause sits in how CartRule::checkValidity() branches on whether the rule is new to the cart or already there, and in what happens (or does not happen) when a cart becomes an order. Documented ways it shows up:

The result is an order whose displayed discount and whose actually charged total quietly disagree with what a store owner would expect if they looked the voucher up today. See the citations at the end for the exact issues and docs.

The key insight

A voucher that rode past its own expiry date into a paid order is not something a script should just delete or refund. It is a financial and discount-correctness issue, not a safely auto-correctable field. So the default action is to flag it for manual finance or merchant review, using the same signal PrestaShop itself should have used: comparing the order or cart date against the voucher's date_to and date_from, and treating a deleted=0 reference to an active=0 rule as a violation too.

The fix, as a flow

We do not edit any paid order. We add a job that lists open carts and recent orders, reads every attached voucher's date_to, date_from, and active flag from cart_rules, and runs each record through one pure check. Anything flagged becomes a report row for finance to review, and only a still-open cart, never a paid order, is ever eligible for an automated repair.

List carts + orders GET carts, GET orders Read cart_rules date_to, date_from, active is_voucher_expired_for_record record date outside window, or inactive Violation found? yes no, move on Report DRY_RUN Repair only for still-open carts: PUT the cart with the expired rule omitted. Paid orders untouched.
The job always reads and reports first. Removing an expired voucher only ever touches an open cart through a full resource PUT, never a paid order's order_cart_rules row.

Build it step by step

1

Enable the webservice and get a key

In the back office, go to Advanced Parameters, Webservice, and create a key with read access to carts, orders, order_cart_rules, and cart_rules, plus write access to carts if you plan to run the guarded repair. The key is sent as the HTTP Basic username with a blank password. Keep the shop URL and the key in environment variables, never in the file.

setup (shell)
pip install requests

export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true"   # start safe, only reports by default
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true"   // start safe, only reports by default
2

List candidate carts and recent orders

Call GET /api/carts?filter[id]=[...]&display=full&output_format=JSON for still-open carts, or iterate by date_upd to cover recent activity. Each cart's associations.cart_rules lists the id_cart_rule entries attached to it. Separately, call GET /api/orders?display=full&output_format=JSON for recent orders.

step2.py
import os, requests

PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
PRESTASHOP_WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
AUTH = (PRESTASHOP_WS_KEY, "")

def api_get(path, params=None):
    params = dict(params or {})
    params["output_format"] = "JSON"
    r = requests.get(f"{PRESTASHOP_URL}/api/{path}", params=params, auth=AUTH, timeout=30)
    r.raise_for_status()
    return r.json()

def open_carts(cart_ids):
    ids = ",".join(str(i) for i in cart_ids)
    data = api_get("carts", params={"filter[id]": f"[{ids}]", "display": "full"})
    return data.get("carts") or []

def recent_orders():
    data = api_get("orders", params={"display": "full"})
    return data.get("orders") or []
step2.js
const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "").replace(/\/+$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY;

function basicAuthHeader() {
  return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}

async function apiGet(path, params = {}) {
  const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
  url.searchParams.set("output_format", "JSON");
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url, { headers: { Authorization: basicAuthHeader() } });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on GET ${path}`);
  return res.json();
}

async function openCarts(cartIds) {
  const ids = cartIds.join(",");
  const data = await apiGet("carts", { "filter[id]": `[${ids}]`, display: "full" });
  return data.carts || [];
}

async function recentOrders() {
  const data = await apiGet("orders", { display: "full" });
  return data.orders || [];
}
3

Read the discount linkage and the voucher's own dates

For paid orders, the linkage is GET /api/order_cart_rules?filter[id_order]=[...]&display=full&output_format=JSON, which returns id, id_order, id_cart_rule, name, value, value_tax_excl, free_shipping, and deleted. For every distinct id_cart_rule found in either the cart associations or the order_cart_rules rows, call GET /api/cart_rules/{id}?output_format=JSON and read date_from, date_to, active, quantity, and quantity_per_user.

step3.py
def order_cart_rules_for(id_order):
    data = api_get("order_cart_rules", params={
        "filter[id_order]": id_order,
        "display": "full",
    })
    return data.get("order_cart_rules") or []

def cart_rule_detail(id_cart_rule):
    data = api_get(f"cart_rules/{id_cart_rule}")
    return data.get("cart_rule") or {}
step3.js
async function orderCartRulesFor(idOrder) {
  const data = await apiGet("order_cart_rules", {
    "filter[id_order]": idOrder,
    display: "full",
  });
  return data.order_cart_rules || [];
}

async function cartRuleDetail(idCartRule) {
  const data = await apiGet(`cart_rules/${idCartRule}`);
  return data.cart_rule || {};
}
4

Decide, with one pure function

Keep the decision in its own function that takes the record date (the order's date_add for a placed order, or the cart's date_upd for a still-open cart), the voucher's date_from and date_to, and its active flag, and returns whether that association is a violation. It flags anything inactive that is still referenced, and anything whose record date falls outside the inclusive validity window, so the boundary at exactly date_to stays valid and one second past it does not.

decide.py
def is_voucher_expired_for_record(record_date, date_from, date_to, active):
    """Pure decision function, no I/O.

    record_date is order.date_add for a placed order, or cart.date_upd for a still-open
    cart. Returns True (flag as violation) when active is False and the association
    still exists, or when record_date falls outside [date_from, date_to]. Returns False
    when record_date falls within the inclusive window and active is True.
    """
    if not active:
        return True
    if record_date > date_to:
        return True
    if record_date < date_from:
        return True
    return False
decide.js
/**
 * Pure decision function, no I/O.
 *
 * recordDate is order.date_add for a placed order, or cart.date_upd for a still-open
 * cart. Returns true (flag as violation) when active is false and the association still
 * exists, or when recordDate falls outside [dateFrom, dateTo]. Returns false when
 * recordDate falls within the inclusive window and active is true.
 */
export function isVoucherExpiredForRecord(recordDate, dateFrom, dateTo, active) {
  if (!active) return true;
  if (recordDate > dateTo) return true;
  if (recordDate < dateFrom) return true;
  return false;
}
5

Report first, repair only an open cart under a dry run

Financial correctness means the default action is a report, not a write. If a guarded repair is authorized for still-open, unpaid carts only, PrestaShop's webservice does not expose a direct cart-cart_rule delete endpoint, so the supported approach is a full resource PUT to /api/carts/{id} with the associations.cart_rules block omitting the expired id_cart_rule. Never touch an already-paid order's order_cart_rules rows directly, since that would retroactively alter invoiced totals. Always run with DRY_RUN=true first and log the intended PUT payload before executing it.

repair.py
def build_cart_put_payload(cart, expired_id_cart_rule):
    """Build the full-resource PUT payload with the expired rule omitted.

    Never called against a paid order. Only for a still-open cart, and only ever
    logged, not sent, unless DRY_RUN is explicitly off.
    """
    cart = dict(cart)
    associations = dict(cart.get("associations") or {})
    rules = associations.get("cart_rules") or []
    kept = [r for r in rules if str(r.get("id")) != str(expired_id_cart_rule)]
    associations["cart_rules"] = kept
    cart["associations"] = associations
    return cart
repair.js
/**
 * Build the full-resource PUT payload with the expired rule omitted.
 *
 * Never called against a paid order. Only for a still-open cart, and only ever
 * logged, not sent, unless DRY_RUN is explicitly off.
 */
export function buildCartPutPayload(cart, expiredIdCartRule) {
  const next = { ...cart };
  const associations = { ...(cart.associations || {}) };
  const rules = associations.cart_rules || [];
  associations.cart_rules = rules.filter((r) => String(r.id) !== String(expiredIdCartRule));
  next.associations = associations;
  return next;
}
6

Wire it together with a dry run guard

The loop ties every piece together: collect the distinct cart rule ids referenced by open carts and recent orders, fetch each rule's dates and active flag once, then run every association through is_voucher_expired_for_record and log a report row for each violation. DRY_RUN controls only whether the cart repair PUT actually executes, and it never touches a paid order. Run it on a schedule that matches how long your carts typically stay open, for example once a day.

Run it safe

Never edit an already-paid order's order_cart_rules rows directly, that would retroactively change invoiced totals and confuse accounting. Treat every flagged order as a lead for finance to review manually. Only ever attempt the automated repair against a still-open cart, in a non-final state before Payment accepted, and always start with DRY_RUN=true so you can read the intended PUT payload before anything is written.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, walks candidate carts and orders, reports every violation, and only ever writes to a still-open cart, and only when DRY_RUN is explicitly turned off.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 73 PrestaShop fixes, free and open source.
check_expired_voucher.py
"""Detect PrestaShop carts and orders still carrying an expired voucher.

CartRule::checkValidity() checks a voucher's date_to expiry differently depending on an
alreadyInCart flag. When a voucher is already sitting in the cart, that flag is true and
the expiry check is effectively bypassed, so a code added before its expiry date stays
valid through checkout even if the customer actually pays after date_to has passed.
Confirmed in PrestaShop/PrestaShop issues #26235 and #32303. Because the cart-to-order
conversion copies the cart_rule association into order_cart_rule at payment time without
re-validating dates, and nothing re-scans placed orders afterward, an expired discount
can ride all the way into a paid order, leaving the discount shown on the order out of
step with the amount actually charged, as reported in issue #34067 and the broader
"cart rules are a nest of cockroaches" bug collection in issue #28134.

This is a financial and discount-correctness issue, not a safely auto-correctable field,
so the default action is to flag every violation for manual finance or merchant review.
A DRY_RUN-guarded repair is available for still-open, unpaid carts only: PrestaShop's
webservice has no direct cart-cart_rule delete route, so the supported approach is a
full resource PUT to /api/carts/{id} with associations.cart_rules omitting the expired
id_cart_rule. Already-paid orders are never edited, since that would retroactively alter
invoiced totals.

Run on a schedule. Safe to run again and again.
"""
import os
import datetime
import logging
import requests

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

PRESTASHOP_URL = os.environ.get("PRESTASHOP_URL", "https://demo.example.com").rstrip("/")
PRESTASHOP_WS_KEY = os.environ.get("PRESTASHOP_WS_KEY", "WSKEYDUMMY")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
OPEN_CART_IDS = [c for c in os.environ.get("OPEN_CART_IDS", "").split(",") if c]
AUTH = (PRESTASHOP_WS_KEY, "")


def is_voucher_expired_for_record(record_date, date_from, date_to, active):
    """Pure decision function, no I/O.

    record_date is order.date_add for a placed order, or cart.date_upd for a still-open
    cart. Returns True (flag as violation) when active is False and the association
    still exists, or when record_date falls outside [date_from, date_to], i.e.
    record_date > date_to or record_date < date_from. Returns False when record_date
    falls within the inclusive validity window and active is True.
    """
    if not active:
        return True
    if record_date > date_to:
        return True
    if record_date < date_from:
        return True
    return False


def build_cart_put_payload(cart, expired_id_cart_rule):
    cart = dict(cart)
    associations = dict(cart.get("associations") or {})
    rules = associations.get("cart_rules") or []
    kept = [r for r in rules if str(r.get("id")) != str(expired_id_cart_rule)]
    associations["cart_rules"] = kept
    cart["associations"] = associations
    return cart


def api_get(path, params=None):
    params = dict(params or {})
    params["output_format"] = "JSON"
    r = requests.get(f"{PRESTASHOP_URL}/api/{path}", params=params, auth=AUTH, timeout=30)
    r.raise_for_status()
    return r.json()


def api_put(path, payload):
    r = requests.put(
        f"{PRESTASHOP_URL}/api/{path}",
        params={"output_format": "JSON"},
        json=payload,
        auth=AUTH,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def open_carts(cart_ids):
    if not cart_ids:
        return []
    ids = ",".join(str(i) for i in cart_ids)
    data = api_get("carts", params={"filter[id]": f"[{ids}]", "display": "full"})
    return data.get("carts") or []


def recent_orders():
    data = api_get("orders", params={"display": "full"})
    return data.get("orders") or []


def order_cart_rules_for(id_order):
    data = api_get("order_cart_rules", params={"filter[id_order]": id_order, "display": "full"})
    return data.get("order_cart_rules") or []


def cart_rule_detail(id_cart_rule):
    data = api_get(f"cart_rules/{id_cart_rule}")
    return data.get("cart_rule") or {}


def _to_epoch(value):
    return datetime.datetime.fromisoformat(str(value).replace(" ", "T")).timestamp()


def scan_orders():
    flagged = []
    for order in recent_orders():
        id_order = order["id"]
        order_date = order.get("date_add")
        if not order_date:
            continue
        for link in order_cart_rules_for(id_order):
            if str(link.get("deleted")) not in ("0", "False", "false"):
                continue
            rule = cart_rule_detail(link["id_cart_rule"])
            if not rule:
                continue
            violation = is_voucher_expired_for_record(
                _to_epoch(order_date),
                _to_epoch(rule["date_from"]),
                _to_epoch(rule["date_to"]),
                str(rule.get("active")) in ("1", "True", "true"),
            )
            if violation:
                flagged.append({
                    "id_order": id_order,
                    "id_cart_rule": link["id_cart_rule"],
                    "voucher_code": rule.get("code"),
                    "date_to": rule.get("date_to"),
                    "record_date": order_date,
                    "discount_value": link.get("value"),
                })
    return flagged


def scan_open_carts():
    flagged = []
    for cart in open_carts(OPEN_CART_IDS):
        cart_date = cart.get("date_upd")
        if not cart_date:
            continue
        rules = ((cart.get("associations") or {}).get("cart_rules")) or []
        for link in rules:
            rule = cart_rule_detail(link["id"])
            if not rule:
                continue
            violation = is_voucher_expired_for_record(
                _to_epoch(cart_date),
                _to_epoch(rule["date_from"]),
                _to_epoch(rule["date_to"]),
                str(rule.get("active")) in ("1", "True", "true"),
            )
            if violation:
                flagged.append({
                    "id_cart": cart["id"],
                    "id_cart_rule": link["id"],
                    "voucher_code": rule.get("code"),
                    "date_to": rule.get("date_to"),
                    "record_date": cart_date,
                    "cart": cart,
                })
    return flagged


def repair_open_cart(row):
    payload = build_cart_put_payload(row["cart"], row["id_cart_rule"])
    log.info(
        "%s cart %s: would PUT associations.cart_rules without id_cart_rule=%s",
        "DRY RUN" if DRY_RUN else "REPAIRING",
        row["id_cart"], row["id_cart_rule"],
    )
    if not DRY_RUN:
        api_put(f"carts/{row['id_cart']}", payload)


def run():
    order_violations = scan_orders()
    for row in order_violations:
        log.warning(
            "Expired voucher on PAID order (report only). id_order=%s id_cart_rule=%s "
            "code=%s date_to=%s order_date=%s discount_value=%s",
            row["id_order"], row["id_cart_rule"], row["voucher_code"],
            row["date_to"], row["record_date"], row["discount_value"],
        )

    cart_violations = scan_open_carts()
    for row in cart_violations:
        log.warning(
            "Expired voucher on OPEN cart. id_cart=%s id_cart_rule=%s code=%s "
            "date_to=%s cart_date=%s",
            row["id_cart"], row["id_cart_rule"], row["voucher_code"],
            row["date_to"], row["record_date"],
        )
        if OPEN_CART_IDS:
            repair_open_cart(row)

    log.info(
        "Done. %d paid order violation(s) flagged for finance review, %d open cart "
        "violation(s) found (%s).",
        len(order_violations), len(cart_violations),
        "would repair" if DRY_RUN else "repaired" if OPEN_CART_IDS else "report only",
    )


if __name__ == "__main__":
    run()
check-expired-voucher.js
/**
 * Detect PrestaShop carts and orders still carrying an expired voucher.
 *
 * CartRule::checkValidity() checks a voucher's date_to expiry differently depending on
 * an alreadyInCart flag. When a voucher is already sitting in the cart, that flag is
 * true and the expiry check is effectively bypassed, so a code added before its expiry
 * date stays valid through checkout even if the customer actually pays after date_to has
 * passed. Confirmed in PrestaShop/PrestaShop issues #26235 and #32303. Because the
 * cart-to-order conversion copies the cart_rule association into order_cart_rule at
 * payment time without re-validating dates, and nothing re-scans placed orders
 * afterward, an expired discount can ride all the way into a paid order, leaving the
 * discount shown on the order out of step with the amount actually charged, as reported
 * in issue #34067 and the broader "cart rules are a nest of cockroaches" bug collection
 * in issue #28134.
 *
 * This is a financial and discount-correctness issue, not a safely auto-correctable
 * field, so the default action is to flag every violation for manual finance or
 * merchant review. A DRY_RUN-guarded repair is available for still-open, unpaid carts
 * only: PrestaShop's webservice has no direct cart-cart_rule delete route, so the
 * supported approach is a full resource PUT to /api/carts/{id} with
 * associations.cart_rules omitting the expired id_cart_rule. Already-paid orders are
 * never edited, since that would retroactively alter invoiced totals.
 *
 * Guide: https://www.allanninal.dev/prestashop/expired-voucher-still-applied/
 */
import { pathToFileURL } from "node:url";

const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "https://demo.example.com").replace(/\/+$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY || "WSKEYDUMMY";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const OPEN_CART_IDS = (process.env.OPEN_CART_IDS || "").split(",").filter(Boolean);

function basicAuthHeader() {
  return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}

/**
 * Pure decision function, no I/O.
 *
 * recordDate is order.date_add for a placed order, or cart.date_upd for a still-open
 * cart. Returns true (flag as violation) when active is false and the association still
 * exists, or when recordDate falls outside [dateFrom, dateTo], i.e. recordDate > dateTo
 * or recordDate < dateFrom. Returns false when recordDate falls within the inclusive
 * validity window and active is true.
 */
export function isVoucherExpiredForRecord(recordDate, dateFrom, dateTo, active) {
  if (!active) return true;
  if (recordDate > dateTo) return true;
  if (recordDate < dateFrom) return true;
  return false;
}

export function buildCartPutPayload(cart, expiredIdCartRule) {
  const next = { ...cart };
  const associations = { ...(cart.associations || {}) };
  const rules = associations.cart_rules || [];
  associations.cart_rules = rules.filter((r) => String(r.id) !== String(expiredIdCartRule));
  next.associations = associations;
  return next;
}

async function apiGet(path, params = {}) {
  const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
  url.searchParams.set("output_format", "JSON");
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url, { headers: { Authorization: basicAuthHeader() } });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on GET ${path}`);
  return res.json();
}

async function apiPut(path, payload) {
  const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, {
    method: "PUT",
    headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT ${path}`);
  return res.json();
}

async function openCarts(cartIds) {
  if (!cartIds.length) return [];
  const ids = cartIds.join(",");
  const data = await apiGet("carts", { "filter[id]": `[${ids}]`, display: "full" });
  return data.carts || [];
}

async function recentOrders() {
  const data = await apiGet("orders", { display: "full" });
  return data.orders || [];
}

async function orderCartRulesFor(idOrder) {
  const data = await apiGet("order_cart_rules", { "filter[id_order]": idOrder, display: "full" });
  return data.order_cart_rules || [];
}

async function cartRuleDetail(idCartRule) {
  const data = await apiGet(`cart_rules/${idCartRule}`);
  return data.cart_rule || {};
}

function toEpoch(value) {
  return Date.parse(String(value).replace(" ", "T")) / 1000;
}

async function scanOrders() {
  const flagged = [];
  for (const order of await recentOrders()) {
    const idOrder = order.id;
    const orderDate = order.date_add;
    if (!orderDate) continue;
    for (const link of await orderCartRulesFor(idOrder)) {
      if (!["0", "False", "false"].includes(String(link.deleted))) continue;
      const rule = await cartRuleDetail(link.id_cart_rule);
      if (!rule || !rule.date_to) continue;
      const violation = isVoucherExpiredForRecord(
        toEpoch(orderDate),
        toEpoch(rule.date_from),
        toEpoch(rule.date_to),
        ["1", "True", "true"].includes(String(rule.active)),
      );
      if (violation) {
        flagged.push({
          id_order: idOrder,
          id_cart_rule: link.id_cart_rule,
          voucher_code: rule.code,
          date_to: rule.date_to,
          record_date: orderDate,
          discount_value: link.value,
        });
      }
    }
  }
  return flagged;
}

async function scanOpenCarts() {
  const flagged = [];
  for (const cart of await openCarts(OPEN_CART_IDS)) {
    const cartDate = cart.date_upd;
    if (!cartDate) continue;
    const rules = (cart.associations && cart.associations.cart_rules) || [];
    for (const link of rules) {
      const rule = await cartRuleDetail(link.id);
      if (!rule || !rule.date_to) continue;
      const violation = isVoucherExpiredForRecord(
        toEpoch(cartDate),
        toEpoch(rule.date_from),
        toEpoch(rule.date_to),
        ["1", "True", "true"].includes(String(rule.active)),
      );
      if (violation) {
        flagged.push({
          id_cart: cart.id,
          id_cart_rule: link.id,
          voucher_code: rule.code,
          date_to: rule.date_to,
          record_date: cartDate,
          cart,
        });
      }
    }
  }
  return flagged;
}

async function repairOpenCart(row) {
  const payload = buildCartPutPayload(row.cart, row.id_cart_rule);
  console.log(
    `${DRY_RUN ? "DRY RUN" : "REPAIRING"} cart ${row.id_cart}: would PUT associations.cart_rules without id_cart_rule=${row.id_cart_rule}`
  );
  if (!DRY_RUN) await apiPut(`carts/${row.id_cart}`, payload);
}

export async function run() {
  const orderViolations = await scanOrders();
  for (const row of orderViolations) {
    console.warn(
      `Expired voucher on PAID order (report only). id_order=${row.id_order} id_cart_rule=${row.id_cart_rule} ` +
        `code=${row.voucher_code} date_to=${row.date_to} order_date=${row.record_date} discount_value=${row.discount_value}`
    );
  }

  const cartViolations = await scanOpenCarts();
  for (const row of cartViolations) {
    console.warn(
      `Expired voucher on OPEN cart. id_cart=${row.id_cart} id_cart_rule=${row.id_cart_rule} ` +
        `code=${row.voucher_code} date_to=${row.date_to} cart_date=${row.record_date}`
    );
    if (OPEN_CART_IDS.length) await repairOpenCart(row);
  }

  console.log(
    `Done. ${orderViolations.length} paid order violation(s) flagged for finance review, ` +
      `${cartViolations.length} open cart violation(s) found ` +
      `(${DRY_RUN ? "would repair" : OPEN_CART_IDS.length ? "repaired" : "report only"}).`
  );
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The validity rule is the part most worth testing, because it decides which orders get reported as a finance violation and which vouchers are treated as still legitimately valid. Because we kept is_voucher_expired_for_record pure, the test needs no network and no PrestaShop store. It just feeds in plain timestamps and a boolean and checks the answer, including the boundary right at date_to.

test_expired_voucher.py
from check_expired_voucher import is_voucher_expired_for_record

DATE_FROM = 1751328000.0  # 2025-07-01T00:00:00Z
DATE_TO = 1751932800.0    # 2025-07-08T00:00:00Z


def test_valid_within_window_is_not_flagged():
    record_date = DATE_FROM + 3600
    assert is_voucher_expired_for_record(record_date, DATE_FROM, DATE_TO, True) is False


def test_exactly_at_date_to_is_not_flagged():
    assert is_voucher_expired_for_record(DATE_TO, DATE_FROM, DATE_TO, True) is False


def test_one_second_past_date_to_is_flagged():
    assert is_voucher_expired_for_record(DATE_TO + 1, DATE_FROM, DATE_TO, True) is True


def test_before_date_from_is_flagged():
    assert is_voucher_expired_for_record(DATE_FROM - 1, DATE_FROM, DATE_TO, True) is True


def test_inactive_rule_still_referenced_is_flagged():
    record_date = DATE_FROM + 3600
    assert is_voucher_expired_for_record(record_date, DATE_FROM, DATE_TO, False) is True


def test_inactive_and_expired_is_still_just_flagged_true():
    assert is_voucher_expired_for_record(DATE_TO + 10, DATE_FROM, DATE_TO, False) is True


def test_exactly_at_date_from_is_not_flagged():
    assert is_voucher_expired_for_record(DATE_FROM, DATE_FROM, DATE_TO, True) is False
expired-voucher.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { isVoucherExpiredForRecord } from "./check-expired-voucher.js";

const DATE_FROM = 1751328000; // 2025-07-01T00:00:00Z
const DATE_TO = 1751932800;   // 2025-07-08T00:00:00Z

test("valid within window is not flagged", () => {
  assert.equal(isVoucherExpiredForRecord(DATE_FROM + 3600, DATE_FROM, DATE_TO, true), false);
});

test("exactly at date_to is not flagged", () => {
  assert.equal(isVoucherExpiredForRecord(DATE_TO, DATE_FROM, DATE_TO, true), false);
});

test("one second past date_to is flagged", () => {
  assert.equal(isVoucherExpiredForRecord(DATE_TO + 1, DATE_FROM, DATE_TO, true), true);
});

test("before date_from is flagged", () => {
  assert.equal(isVoucherExpiredForRecord(DATE_FROM - 1, DATE_FROM, DATE_TO, true), true);
});

test("inactive rule still referenced is flagged", () => {
  assert.equal(isVoucherExpiredForRecord(DATE_FROM + 3600, DATE_FROM, DATE_TO, false), true);
});

test("inactive and expired is still just flagged true", () => {
  assert.equal(isVoucherExpiredForRecord(DATE_TO + 10, DATE_FROM, DATE_TO, false), true);
});

test("exactly at date_from is not flagged", () => {
  assert.equal(isVoucherExpiredForRecord(DATE_FROM, DATE_FROM, DATE_TO, true), false);
});

Case studies

Seasonal promotion

The summer sale code that outlived the sale

A homeware store ran a five day summer sale voucher with a hard date_to. Shoppers who added the code on the last valid day, then took their time browsing more products before checking out, sometimes paid a day or two after the sale technically ended. Finance noticed the sale's total discount cost kept creeping past what the promotion budget allowed, but nothing in the back office flagged which orders were the problem.

Running the diagnostic across recent orders surfaced exactly those late payers: the order's date_add sat past the voucher's date_to, yet the discount had gone through in full. Finance used the report to true up the promotion's cost against budget and tightened the checkout flow to re-validate cart contents at payment time going forward.

Abandoned then resumed cart

The cart that sat for a week with a dead code inside it

A store selling subscription boxes saw customers frequently abandon a cart with a first-order voucher already applied, then come back a week later through an email reminder to finish the purchase. The voucher had long since expired, but because it was already alreadyInCart, checkout let it straight through.

The team ran the script in dry run against their pool of resumed carts first, saw the exact list of still-open carts carrying an expired code, and confirmed the discount amounts before authorizing the guarded repair. Only the open carts were touched via a full resource PUT, and every already-placed order stayed exactly as it was, left for finance to review by hand.

What good looks like

After this runs on a schedule, every expired voucher still riding on a paid order shows up as a clear, dated report row for finance instead of a silent mismatch someone stumbles on during reconciliation. Still-open carts get cleaned up safely through a full resource PUT, gated behind a dry run, and no paid order's order_cart_rules row is ever edited directly. The promotion budget and the actual discounts given finally agree.

FAQ

Why does PrestaShop let an expired voucher stay in the cart?

CartRule::checkValidity() runs a different check depending on an alreadyInCart flag. When a voucher is being newly entered, the date_to expiry is enforced. But once that same voucher is already sitting in the cart, the flag is true and the expiry check is effectively bypassed, so a code added before its expiry date stays valid through checkout even if the customer actually pays after date_to has passed.

Can an expired voucher really end up on a paid order?

Yes. The cart-to-order conversion copies the cart_rule association into order_cart_rule at payment time without re-validating the dates, and nothing re-scans placed orders afterward. So an expired discount can ride all the way into a paid order, leaving the discount shown on the order out of step with the amount actually charged.

Is it safe to auto-fix an expired voucher on a paid order?

No. This is a financial and discount-correctness issue, so the default action is to flag it for manual finance or merchant review rather than auto-correct it. For still-open, unpaid carts, a DRY_RUN-guarded repair can PUT the cart resource with the expired rule omitted from associations.cart_rules, but already-paid orders should never be edited directly since that would retroactively change invoiced totals.

Related field notes

Citations

On the problem:

  1. PrestaShop GitHub: Cart loads also expired vouchers. Issue #26235. github.com/PrestaShop/PrestaShop/issues/26235
  2. PrestaShop GitHub: Order creation from cart with expired voucher. Issue #32303. github.com/PrestaShop/PrestaShop/issues/32303
  3. PrestaShop GitHub: Inactive discount coupons (cart rule) still applied after deactivation. Issue #34067. github.com/PrestaShop/PrestaShop/issues/34067

On the solution:

  1. PrestaShop Developer Documentation: Cart rules webservice resource. devdocs.prestashop-project.org/9/webservice/resources/cart_rules/
  2. PrestaShop Developer Documentation: Order cart rules webservice resource. devdocs.prestashop-project.org/9/webservice/resources/order_cart_rules/
  3. PrestaShop Developer Documentation: Carts webservice resource. devdocs.prestashop-project.org/8/webservice/resources/carts/

Stuck on a tricky one?

If you have a problem in PrestaShop orders, payments, vouchers, or the webservice API 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 untangle your voucher records?

If this saved you a confusing finance review or a promotion budget that never balanced, 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 PrestaShop field notes