Diagnostic Vouchers & Cart Rules
System generated vouchers without a code cannot be deleted and pile up
A free-shipping-over-X promo. A loyalty discount that attaches itself the moment a cart qualifies. No code, no typing, nothing for the customer to enter. These codeless cart rules work fine right up until their uses run out or the promo window closes, and then they just sit there. No delete button ever shows up for them, so the cart_rule table quietly fills with dead rows that clutter every admin listing and report. Here is why PrestaShop never wired up a way to remove them, how to find the ones that are actually dead, and a script that reports them for review without ever guessing which ones are safe to remove on its own.
PrestaShop lets a merchant create a cart rule with an empty code field so it auto-applies to any qualifying cart through CartRule::autoAddToCart, rather than needing a coupon typed in. Because a codeless rule is matched by its conditions instead of a string a person entered, the back office has never had a reliable way to know it is safe to offer a delete affordance for one, a gap confirmed in PrestaShop core issues #12608 and #20246. Once such a rule's remaining quantity hits zero, its date_to passes, or it gets deactivated, it becomes permanently unusable but nothing ever purges it, so dead rows accumulate in cart_rule forever. Run a small Python or Node.js script that lists all cart rules, flags the ones with a blank code whose quantity, date_to, or active flag show it is dead, cross-checks order_cart_rules to make sure no historical order still references it, and writes a report for a human to review. It never deletes anything unless you explicitly confirm specific ids. Full code, tests, and the decision function are below.
The problem in plain words
Most vouchers in PrestaShop have a code. A customer types SUMMER10 at checkout, the discount applies, done. But plenty of cart rules are built to skip that step entirely. A free-shipping promo above a spending threshold, a referral bonus, a loyalty perk. These get created with the code field left blank, and PrestaShop attaches them automatically to any cart or order that meets the rule's conditions.
That auto-apply behavior is exactly what it is meant to do. The trouble starts once the rule stops being usable. Its quantity, the number of remaining global uses, hits zero. Or its date_to passes and the promo window closes. Or someone deactivates it. At that point the rule can never fire again, but it also never gets removed. The back office was never given a delete affordance for a codeless rule in the first place, because there is no reliable way for core to tell whether that particular row is safe to remove. So it just sits in the cart_rule table, forever, cluttering admin listings and any report that touches cart rules.
Why it happens
This is a documented gap in PrestaShop core, not something a merchant configured wrong. A few concrete ways a store ends up with a pile of these:
- A free-shipping-over-X promo or a seasonal auto-discount is created with no code so it applies silently, then the promotion period ends and the rule's
date_topasses with no cleanup step. - A loyalty or referral discount is generated per customer through
CartRule::autoAddToCart, gets used exactly once as designed, itsquantitydrops to zero, and it is never touched again. - The back office and front office have historically had no reliable way to tell whether a given
cart_rulerow has a code at all, so no delete or remove affordance was ever wired up for the codeless case, confirmed in PrestaShop core issues #12608 and #20246. autoAddToCartandautoRemovekeep re-attaching or silently ignoring an exhausted rule rather than deleting it, so the row just keeps existing in a state nothing acts on.
See the citations at the end for the exact issue threads this behavior is reported and reproduced in.
A rule with quantity zero, an expired date_to, or active set to false is dead going forward, but that alone does not make it safe to delete. It might still be the exact rule a real, already finalized historical order used for its discount. PrestaShop has no soft-delete or used-flag on order_cart_rules to tell you that a rule was already consumed by a real order versus simply never used, so the only safe check is to look. Cross-check order_cart_rules for the candidate's id_cart_rule before treating it as truly orphaned, and even then, only report it. Deleting is a decision for a human, not the script.
The fix, as a flow
We never delete anything by default. The script pulls every cart rule, keeps the ones with a blank code whose quantity, date_to, or active flag show it can never fire again, cross-checks each candidate against order_cart_rules to rule out a real historical order still referencing it, and writes a report of what is left. Only when a human reviews that report and explicitly confirms specific ids does the script issue a delete, and only for those ids.
Build it step by step
Enable the Webservice API and get a key
In the PrestaShop admin, go to Advanced Parameters, Webservice, and turn it on. Create a key with access to the cart_rules and order_cart_rules resources. Keep the shop URL and the key in environment variables, never in the file.
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 writes a report, never deletes
// 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 writes a report, never deletes
Talk to the Webservice API
Every call goes to {PRESTASHOP_URL}/api/<resource> with the key sent as the HTTP Basic username and a blank password, plus ?output_format=JSON since PrestaShop replies in XML by default. A small helper wraps GET and DELETE and raises on a bad status.
import os, requests
BASE_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
def api_get(path, params=None):
params = dict(params or {})
params["output_format"] = "JSON"
r = requests.get(f"{BASE_URL}/api/{path}", params=params, auth=(WS_KEY, ""), timeout=30)
r.raise_for_status()
return r.json()
def api_delete(path):
r = requests.delete(
f"{BASE_URL}/api/{path}",
params={"output_format": "JSON"},
auth=(WS_KEY, ""),
timeout=30,
)
r.raise_for_status()
return r.status_code
const BASE_URL = (process.env.PRESTASHOP_URL || "").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY;
function authHeader() {
return "Basic " + Buffer.from(`${WS_KEY}:`).toString("base64");
}
async function apiGet(path, params = {}) {
const qs = new URLSearchParams({ ...params, output_format: "JSON" });
const res = await fetch(`${BASE_URL}/api/${path}?${qs}`, {
headers: { Authorization: authHeader() },
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
async function apiDelete(path) {
const res = await fetch(`${BASE_URL}/api/${path}?output_format=JSON`, {
method: "DELETE",
headers: { Authorization: authHeader() },
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.status;
}
List every cart rule and cross-check historical orders
Pull all cart rules with display=full, paginating with limit as needed. For each candidate you flag as dead, confirm it separately with order_cart_rules filtered to that id_cart_rule, since a rule that hit quantity zero can still be the rule a real, finalized order used.
def list_cart_rules(limit=1000):
data = api_get("cart_rules", {"display": "full", "limit": limit})
rules = data.get("cart_rules") or []
out = []
for rule in rules:
out.append({
"id": int(rule["id"]),
"name": rule.get("name") or "",
"code": rule.get("code") or "",
"quantity": int(rule["quantity"]),
"quantity_per_user": int(rule["quantity_per_user"]),
"date_from": rule.get("date_from"),
"date_to": rule.get("date_to"),
"active": rule.get("active") in ("1", 1, True),
})
return out
def has_historical_order(cart_rule_id):
data = api_get("order_cart_rules", {"filter[id_cart_rule]": cart_rule_id, "display": "full"})
links = data.get("order_cart_rules") or []
return len(links) > 0
async function listCartRules(limit = 1000) {
const data = await apiGet("cart_rules", { display: "full", limit });
const rules = data.cart_rules || [];
return rules.map((rule) => ({
id: Number(rule.id),
name: rule.name || "",
code: rule.code || "",
quantity: Number(rule.quantity),
quantityPerUser: Number(rule.quantity_per_user),
dateFrom: rule.date_from,
dateTo: rule.date_to,
active: rule.active === "1" || rule.active === 1 || rule.active === true,
}));
}
async function hasHistoricalOrder(cartRuleId) {
const data = await apiGet("order_cart_rules", { "filter[id_cart_rule]": cartRuleId, display: "full" });
const links = data.order_cart_rules || [];
return links.length > 0;
}
Decide, with one pure function
Keep the decision in its own function that takes the fields that matter and today's date, and returns true or false with no I/O at all. A rule with a real code is never a candidate, no matter what its quantity or dates say. A codeless rule is a candidate only when it is exhausted, expired, or disabled.
from datetime import date, datetime
def is_orphaned_codeless_voucher(code, quantity, date_to, active, today):
if code.strip() != "":
return False
if quantity <= 0:
return True
if date_to:
parsed = date_to
if isinstance(parsed, str):
parsed = datetime.fromisoformat(parsed.split(" ")[0]).date()
if parsed < today:
return True
if active is False:
return True
return False
export function isOrphanedCodelessVoucher(code, quantity, dateTo, active, today) {
if (code.trim() !== "") return false;
if (quantity <= 0) return true;
if (dateTo) {
const parsed = new Date(String(dateTo).split(" ")[0] + "T00:00:00Z");
if (parsed < today) return true;
}
if (active === false) return true;
return false;
}
Report first, delete only on explicit confirmation
The default run only produces a CSV or JSON report of matching id_cart_rule, name, date_from, date_to, quantity, and quantity_per_user for the merchant to review. Nothing is written. Only when DRY_RUN is set to false and the merchant hands back a specific list of confirmed ids does the script send DELETE cart_rules/{id} for each, and only after re-confirming order_cart_rules is empty for that id.
Always start with DRY_RUN=true. This script never deletes a cart rule on its own judgment call. It reports candidates, and it only deletes an id that a human explicitly listed in CONFIRMED_DELETE_IDS after reviewing the report, and even then only after the order_cart_rules check comes back empty for that id.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and the only write it can ever make is deleting a cart rule id you explicitly confirmed and that has zero historical order references.
"""Find PrestaShop cart rules that were created with no code, auto-apply to qualifying
carts, and are now permanently unusable because their quantity ran out, their date_to
passed, or they were deactivated.
These codeless rules are matched by conditions rather than a customer-typed string, so
the back office has never had a reliable way to know it is safe to offer a delete
affordance for one (PrestaShop core issues #12608 and #20246). Once dead, they are never
purged, so they pile up in the cart_rule table and clutter admin listings and reports.
This script only reports by default. The optional, DRY_RUN-guarded delete step only
fires for ids a human lists in CONFIRMED_DELETE_IDS after reviewing the report, and even
then only after re-confirming order_cart_rules has zero rows for that id, since a rule
that is dead going forward can still be the rule a real, already finalized order used.
Safe to run again and again.
"""
import os
import csv
import sys
import logging
import requests
from datetime import date, datetime
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("report_orphaned_vouchers")
BASE_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
REPORT_PATH = os.environ.get("REPORT_PATH", "orphaned_vouchers_report.csv")
CONFIRMED_DELETE_IDS = {
int(x) for x in os.environ.get("CONFIRMED_DELETE_IDS", "").split(",") if x.strip()
}
def api_get(path, params=None):
params = dict(params or {})
params["output_format"] = "JSON"
r = requests.get(f"{BASE_URL}/api/{path}", params=params, auth=(WS_KEY, ""), timeout=30)
r.raise_for_status()
return r.json()
def api_delete(path):
r = requests.delete(
f"{BASE_URL}/api/{path}",
params={"output_format": "JSON"},
auth=(WS_KEY, ""),
timeout=30,
)
r.raise_for_status()
return r.status_code
def list_cart_rules(limit=1000):
data = api_get("cart_rules", {"display": "full", "limit": limit})
rules = data.get("cart_rules") or []
out = []
for rule in rules:
out.append({
"id": int(rule["id"]),
"name": rule.get("name") or "",
"code": rule.get("code") or "",
"quantity": int(rule["quantity"]),
"quantity_per_user": int(rule["quantity_per_user"]),
"date_from": rule.get("date_from"),
"date_to": rule.get("date_to"),
"active": rule.get("active") in ("1", 1, True),
})
return out
def has_historical_order(cart_rule_id):
data = api_get("order_cart_rules", {"filter[id_cart_rule]": cart_rule_id, "display": "full"})
links = data.get("order_cart_rules") or []
return len(links) > 0
def is_orphaned_codeless_voucher(code, quantity, date_to, active, today):
if code.strip() != "":
return False
if quantity <= 0:
return True
if date_to:
parsed = date_to
if isinstance(parsed, str):
parsed = datetime.fromisoformat(parsed.split(" ")[0]).date()
if parsed < today:
return True
if active is False:
return True
return False
def write_report(rows, path):
with open(path, "w", newline="") as f:
writer = csv.DictWriter(
f, fieldnames=["id_cart_rule", "name", "date_from", "date_to", "quantity", "quantity_per_user"]
)
writer.writeheader()
for row in rows:
writer.writerow(row)
def run():
today = date.today()
candidates = []
for rule in list_cart_rules():
if not is_orphaned_codeless_voucher(rule["code"], rule["quantity"], rule["date_to"], rule["active"], today):
continue
if has_historical_order(rule["id"]):
log.info("Cart rule %s (%s) is codeless and dead but still referenced by a historical order, skipping.",
rule["id"], rule["name"])
continue
candidates.append({
"id_cart_rule": rule["id"],
"name": rule["name"],
"date_from": rule["date_from"],
"date_to": rule["date_to"],
"quantity": rule["quantity"],
"quantity_per_user": rule["quantity_per_user"],
})
write_report(candidates, REPORT_PATH)
log.info("Report written to %s with %d orphaned codeless voucher(s).", REPORT_PATH, len(candidates))
if DRY_RUN or not CONFIRMED_DELETE_IDS:
log.info("Dry run or no confirmed ids. No cart rule was deleted.")
return
candidate_ids = {row["id_cart_rule"] for row in candidates}
for cart_rule_id in sorted(CONFIRMED_DELETE_IDS):
if cart_rule_id not in candidate_ids:
log.warning("Confirmed id %s is not in this run's report, skipping.", cart_rule_id)
continue
if has_historical_order(cart_rule_id):
log.warning("Confirmed id %s now shows a historical order reference, skipping delete.", cart_rule_id)
continue
api_delete(f"cart_rules/{cart_rule_id}")
log.info("Deleted cart rule %s.", cart_rule_id)
if __name__ == "__main__":
run()
/**
* Find PrestaShop cart rules that were created with no code, auto-apply to qualifying
* carts, and are now permanently unusable because their quantity ran out, their date_to
* passed, or they were deactivated.
*
* These codeless rules are matched by conditions rather than a customer-typed string, so
* the back office has never had a reliable way to know it is safe to offer a delete
* affordance for one (PrestaShop core issues #12608 and #20246). Once dead, they are
* never purged, so they pile up in the cart_rule table and clutter admin listings and
* reports.
*
* This script only reports by default. The optional, DRY_RUN-guarded delete step only
* fires for ids a human lists in CONFIRMED_DELETE_IDS after reviewing the report, and
* even then only after re-confirming order_cart_rules has zero rows for that id, since a
* rule that is dead going forward can still be the rule a real, already finalized order
* used. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/prestashop/orphaned-codeless-vouchers-accumulate/
*/
import { pathToFileURL } from "node:url";
import { writeFileSync } from "node:fs";
const BASE_URL = (process.env.PRESTASHOP_URL || "https://demo.example.com").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY || "WSKEYDUMMY";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const REPORT_PATH = process.env.REPORT_PATH || "orphaned_vouchers_report.csv";
const CONFIRMED_DELETE_IDS = new Set(
(process.env.CONFIRMED_DELETE_IDS || "")
.split(",")
.map((x) => x.trim())
.filter(Boolean)
.map(Number)
);
export function isOrphanedCodelessVoucher(code, quantity, dateTo, active, today) {
if (code.trim() !== "") return false;
if (quantity <= 0) return true;
if (dateTo) {
const parsed = new Date(String(dateTo).split(" ")[0] + "T00:00:00Z");
if (parsed < today) return true;
}
if (active === false) return true;
return false;
}
function authHeader() {
return "Basic " + Buffer.from(`${WS_KEY}:`).toString("base64");
}
async function apiGet(path, params = {}) {
const qs = new URLSearchParams({ ...params, output_format: "JSON" });
const res = await fetch(`${BASE_URL}/api/${path}?${qs}`, {
headers: { Authorization: authHeader() },
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
async function apiDelete(path) {
const res = await fetch(`${BASE_URL}/api/${path}?output_format=JSON`, {
method: "DELETE",
headers: { Authorization: authHeader() },
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.status;
}
async function listCartRules(limit = 1000) {
const data = await apiGet("cart_rules", { display: "full", limit });
const rules = data.cart_rules || [];
return rules.map((rule) => ({
id: Number(rule.id),
name: rule.name || "",
code: rule.code || "",
quantity: Number(rule.quantity),
quantityPerUser: Number(rule.quantity_per_user),
dateFrom: rule.date_from,
dateTo: rule.date_to,
active: rule.active === "1" || rule.active === 1 || rule.active === true,
}));
}
async function hasHistoricalOrder(cartRuleId) {
const data = await apiGet("order_cart_rules", { "filter[id_cart_rule]": cartRuleId, display: "full" });
const links = data.order_cart_rules || [];
return links.length > 0;
}
function writeReport(rows, path) {
const header = "id_cart_rule,name,date_from,date_to,quantity,quantity_per_user";
const lines = rows.map((r) =>
[r.idCartRule, r.name, r.dateFrom, r.dateTo, r.quantity, r.quantityPerUser]
.map((v) => `"${String(v ?? "").replace(/"/g, '""')}"`)
.join(",")
);
writeFileSync(path, [header, ...lines].join("\n") + "\n");
}
export async function run() {
const today = new Date();
const candidates = [];
for (const rule of await listCartRules()) {
if (!isOrphanedCodelessVoucher(rule.code, rule.quantity, rule.dateTo, rule.active, today)) continue;
if (await hasHistoricalOrder(rule.id)) {
console.log(`Cart rule ${rule.id} (${rule.name}) is codeless and dead but still referenced by a historical order, skipping.`);
continue;
}
candidates.push({
idCartRule: rule.id,
name: rule.name,
dateFrom: rule.dateFrom,
dateTo: rule.dateTo,
quantity: rule.quantity,
quantityPerUser: rule.quantityPerUser,
});
}
writeReport(candidates, REPORT_PATH);
console.log(`Report written to ${REPORT_PATH} with ${candidates.length} orphaned codeless voucher(s).`);
if (DRY_RUN || CONFIRMED_DELETE_IDS.size === 0) {
console.log("Dry run or no confirmed ids. No cart rule was deleted.");
return;
}
const candidateIds = new Set(candidates.map((row) => row.idCartRule));
for (const cartRuleId of [...CONFIRMED_DELETE_IDS].sort((a, b) => a - b)) {
if (!candidateIds.has(cartRuleId)) {
console.warn(`Confirmed id ${cartRuleId} is not in this run's report, skipping.`);
continue;
}
if (await hasHistoricalOrder(cartRuleId)) {
console.warn(`Confirmed id ${cartRuleId} now shows a historical order reference, skipping delete.`);
continue;
}
await apiDelete(`cart_rules/${cartRuleId}`);
console.log(`Deleted cart rule ${cartRuleId}.`);
}
}
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 which cart rules end up in the report at all. Because is_orphaned_codeless_voucher is pure, the test needs no network and no PrestaShop store. It just feeds in plain values and checks the answer.
from datetime import date
from report_orphaned_vouchers import is_orphaned_codeless_voucher
TODAY = date(2026, 7, 10)
def test_exhausted_codeless_rule_is_orphaned():
assert is_orphaned_codeless_voucher("", 0, "2026-12-31", True, TODAY) is True
def test_expired_codeless_rule_is_orphaned():
assert is_orphaned_codeless_voucher("", 5, "2026-01-01", True, TODAY) is True
def test_disabled_codeless_rule_is_orphaned():
assert is_orphaned_codeless_voucher("", 5, "2026-12-31", False, TODAY) is True
def test_still_valid_codeless_rule_is_not_orphaned():
assert is_orphaned_codeless_voucher("", 5, "2026-12-31", True, TODAY) is False
def test_rule_with_code_is_never_orphaned_even_if_exhausted():
assert is_orphaned_codeless_voucher("SUMMER10", 0, "2026-01-01", False, TODAY) is False
def test_blank_date_to_with_remaining_quantity_is_not_orphaned():
assert is_orphaned_codeless_voucher("", 3, None, True, TODAY) is False
def test_whitespace_only_code_counts_as_codeless():
assert is_orphaned_codeless_voucher(" ", 0, "2026-12-31", True, TODAY) is True
import { test } from "node:test";
import assert from "node:assert/strict";
import { isOrphanedCodelessVoucher } from "./report-orphaned-vouchers.js";
const TODAY = new Date("2026-07-10T00:00:00Z");
test("exhausted codeless rule is orphaned", () => {
assert.equal(isOrphanedCodelessVoucher("", 0, "2026-12-31", true, TODAY), true);
});
test("expired codeless rule is orphaned", () => {
assert.equal(isOrphanedCodelessVoucher("", 5, "2026-01-01", true, TODAY), true);
});
test("disabled codeless rule is orphaned", () => {
assert.equal(isOrphanedCodelessVoucher("", 5, "2026-12-31", false, TODAY), true);
});
test("still valid codeless rule is not orphaned", () => {
assert.equal(isOrphanedCodelessVoucher("", 5, "2026-12-31", true, TODAY), false);
});
test("rule with a code is never orphaned even if exhausted", () => {
assert.equal(isOrphanedCodelessVoucher("SUMMER10", 0, "2026-01-01", false, TODAY), false);
});
test("blank date_to with remaining quantity is not orphaned", () => {
assert.equal(isOrphanedCodelessVoucher("", 3, null, true, TODAY), false);
});
test("whitespace only code counts as codeless", () => {
assert.equal(isOrphanedCodelessVoucher(" ", 0, "2026-12-31", true, TODAY), true);
});
Case studies
Three years of expired seasonal rules
A homeware store ran a "free shipping over $75" cart rule every holiday season for three years running, always created fresh with no code so it auto-applied at checkout. Each year's rule was left in place after the season ended rather than removed, because there was never a delete button offered for it in the listing.
Running the reporter turned up six dead seasonal rules, all codeless, all with a date_to long past, and all clear of any order_cart_rules reference beyond what the finance team already expected from that season. They confirmed the ids and let the script delete exactly those six, and the cart rule listing finally matched what was actually still running.
One rule per customer, thousands left behind
A subscription box brand generated one codeless cart rule per customer through autoAddToCart for a one-time loyalty discount, each capped at quantity 1. Every rule did exactly what it was supposed to do once, then sat at quantity zero forever, and after two years there were thousands of them weighing down the admin's cart rule report.
The team ran the reporter as a scheduled job producing a CSV each week, reviewed a batch, and fed back confirmed ids in small groups rather than all at once. The order_cart_rules cross-check quietly kept a small number of rules out of every batch, exactly the ones a real customer had used, and those stayed untouched.
After this runs, the cart rule table stops being an ever-growing pile of promos nobody can use anymore. A report lands on a human's desk with exactly the codeless rules that are exhausted, expired, or disabled and clear of any real order history, and nothing gets deleted until that human says which ids to remove. The listing stays useful, reporting stops counting dead rows, and no order that actually used a rule is ever put at risk.
FAQ
Why does PrestaShop let a cart rule with no code exist at all?
A cart rule with an empty code field is meant to auto-apply to any cart or order that meets its conditions, such as free shipping over a spending threshold or a loyalty discount added through CartRule::autoAddToCart, so the customer never has to type anything in. That design is intentional. The problem is what happens after the rule stops being usable, since nothing in core ever offers to delete it.
Is it safe to delete a codeless cart rule once its quantity reaches zero?
Only after you confirm no historical order still references it. A cart rule can hit quantity zero or expire and still be the rule that a past, already finalized order used for its discount, and PrestaShop has no soft-delete or used-flag on order_cart_rules to tell you that safely. Cross-check order_cart_rules for the rule's id first, and only delete when that comes back empty.
Can I just deactivate old codeless vouchers instead of deleting them?
Deactivating stops a rule from being offered again, but it does not remove the row from admin listings and reporting, which is the actual clutter this issue is about. Deactivating is a reasonable first, reversible step, but the accumulation problem only goes away once the confirmed-dead rows are actually deleted.
Related field notes
Citations
On the problem:
- PrestaShop GitHub Issue #12608: Cannot delete cart rule with no code. github.com/PrestaShop/PrestaShop/issues/12608
- PrestaShop GitHub Issue #20246: Removing a cart rule associated to a product, or a generic shop cart rule is not possible. github.com/PrestaShop/PrestaShop/issues/20246
- PrestaShop 8 documentation: Cart Rules user guide. docs.prestashop-project.org user guide cart rules
On the solution:
- PrestaShop Developer Documentation: the cart_rules Webservice resource. devdocs.prestashop-project.org webservice resources cart_rules
- PrestaShop Developer Documentation: the order_cart_rules Webservice resource. devdocs.prestashop-project.org webservice resources order_cart_rules
- PrestaShop core source: CartRule.php on the develop branch. github.com/PrestaShop/PrestaShop CartRule.php
Stuck on a tricky one?
If you have a problem in PrestaShop vouchers, cart rules, orders, 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.
Did this clean up your cart rule listing?
If this saved you from a bloated voucher report or an accidental delete, 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