Skip to content

Reconciler Cart Price Rules and Coupons

Coupon marked used despite the order never completing

A shopper enters a limited use coupon, the cart looks fine, and then checkout fails, maybe the cart no longer meets a minimum order amount after a shipping method changed the total. No order gets created. But the coupon's usage count went up anyway, and now a one time code says it is spent, or a customer's per person limit is burned, for a purchase that does not exist. Here is why Magento does this and a small script that finds every coupon this happened to.

Python and Node.js Adobe Commerce REST API Safe by default (report only)
A sale sign
Photo by Justin Lim on Unsplash
The short answer

Magento increments coupon usage in Magento\SalesRule\Model\Plugin\CouponUsagesIncrement, a plugin that hooks beforeSubmit on Magento\Quote\Model\QuoteManagement and immediately writes the new counters to salesrule_coupon, salesrule_coupon_usage, and salesrule_customer. The actual cart validation, including minimum order amount checks and other cart price rule rules, only happens later, inside the nested submitQuote call. If that validation throws, the order is never created, but the usage increment already committed. There is no REST endpoint that decrements these counters, so the safe move is to run a small Python or Node.js script that reconciles each coupon's times_used against the orders that actually carry its code, and report the orphaned rows for a human to review. Full code, tests, and a dry run guard are below.

The problem in plain words

When a shopper places an order with a coupon, Magento's quote management does two things in the same request, in a specific order. First, a plugin bumps the coupon's usage counters. Second, the quote itself gets validated and turned into a real order. You would expect the counters to move only once the order actually exists. They do not.

CouponUsagesIncrement hooks into beforeSubmit, which runs before the nested submitQuote call that does the real work of validating the cart and creating the order. The plugin persists its counters to the database right away. If submitQuote then throws, for instance because a shipping method changed the total and the cart no longer clears the rule's minimum order amount, the order creation is aborted and no order ever exists. But the usage counters were already committed in a separate step before that failure, so the rollback that undoes the failed order does not touch them.

Checkout submits coupon on the quote beforeSubmit fires Usage counters committed to DB now salesrule_coupon_usage submitQuote runs after, and fails submitQuote minimum order amount check fails, throws No order but coupon still shows used The increment is not rolled back because it committed in a step before the one that failed.
The coupon usage counters commit on beforeSubmit, ahead of the cart validation inside submitQuote that can still fail and abort the order.

Why it happens

This is an ordering bug in how Magento wires quote submission together, not a one off glitch on a single store. A few concrete ways it shows up:

This is a known and reported problem, not a one off misconfiguration. See the citations at the end for the exact upstream issues and Adobe's own quality patch that addresses the related failed order and limited use coupon scenario.

The key insight

There is no REST resource for salesrule_coupon_usage, and decrementing these counters directly carries real risk: a customer might retry the same coupon and successfully complete an order afterward, and blindly rolling back the count could double correct a usage that a later, legitimate order consumed. So the safe pattern is not to auto fix. It is to detect precisely, by comparing each coupon's recorded usage against the orders that actually carry its code, and hand a clear report to a human before anything in the database changes.

The fix, as a flow

We do not touch salesrule_coupon, salesrule_coupon_usage, or salesrule_customer directly from the script. We read each coupon's times_used from the Coupon Management REST endpoint, read every order that actually carries that coupon code, count the real ones, and compare. Any coupon where the recorded usage is higher than the real order count is an orphan, and it goes into a dry run guarded report for an operator to confirm before a controlled database correction runs.

Fetch coupons code, times_used Fetch orders by coupon_code, per code Count real orders exclude cancelled times_used > order count? yes Report orphan DRY_RUN guarded no Counts agree nothing to fix
The script only ever reports an orphaned usage. Correcting salesrule_coupon and its related rows is left to a controlled operator script after a human confirms there is no in-flight retry.

Build it step by step

1

Get an admin token

Authenticate against the admin token endpoint with an admin username and password, or use an integration access token if you already have one. Keep the base URL and credentials in environment variables, never in the file.

setup (shell)
pip install requests

export MAGENTO_URL="https://yourstore.example.com"
export MAGENTO_ADMIN_USERNAME="admin"
export MAGENTO_ADMIN_PASSWORD="change-me"
export COUPON_CODES="SAVE10,WELCOME20"
export DRY_RUN="true"   # start safe, this script only reports either way
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export MAGENTO_URL="https://yourstore.example.com"
export MAGENTO_ADMIN_USERNAME="admin"
export MAGENTO_ADMIN_PASSWORD="change-me"
export COUPON_CODES="SAVE10,WELCOME20"
export DRY_RUN="true"   // start safe, this script only reports either way
2

Fetch each coupon's recorded usage

POST to /rest/V1/integration/admin/token to get a bearer token. Then call /rest/V1/salesRules/coupons/search filtered by code to read each coupon's coupon_id, rule_id, code, and times_used from the CouponInterface response.

step2.py
import os, requests

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")

def get_token(username, password):
    r = requests.post(
        f"{MAGENTO_URL}/rest/V1/integration/admin/token",
        json={"username": username, "password": password},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

def fetch_coupon(token, code):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "code",
        "searchCriteria[filterGroups][0][filters][0][value]": code,
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    }
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1/salesRules/coupons/search",
        params=params,
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    items = r.json().get("items", [])
    return items[0] if items else None
step2.js
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/$/, "");

async function getToken(username, password) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1/integration/admin/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ username, password }),
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function fetchCoupon(token, code) {
  const params = new URLSearchParams({
    "searchCriteria[filterGroups][0][filters][0][field]": "code",
    "searchCriteria[filterGroups][0][filters][0][value]": code,
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
  });
  const res = await fetch(`${MAGENTO_URL}/rest/V1/salesRules/coupons/search?${params}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  const body = await res.json();
  const items = body.items || [];
  return items[0] || null;
}
3

Fetch every order that actually carries the coupon code

Call /rest/V1/orders filtered on coupon_code, paging through searchCriteria. Read back entity_id, increment_id, coupon_code, and state for each order, since that state is what the decision function uses to exclude cancelled orders from the real count.

step3.py
def orders_for_coupon(token, code, page_size=100):
    page = 1
    while True:
        params = {
            "searchCriteria[filterGroups][0][filters][0][field]": "coupon_code",
            "searchCriteria[filterGroups][0][filters][0][value]": code,
            "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
            "searchCriteria[pageSize]": page_size,
            "searchCriteria[currentPage]": page,
        }
        r = requests.get(
            f"{MAGENTO_URL}/rest/V1/orders",
            params=params,
            headers={"Authorization": f"Bearer {token}"},
            timeout=30,
        )
        r.raise_for_status()
        items = r.json().get("items", [])
        for item in items:
            yield item
        if len(items) < page_size:
            return
        page += 1
step3.js
async function* ordersForCoupon(token, code, pageSize = 100) {
  let page = 1;
  while (true) {
    const params = new URLSearchParams({
      "searchCriteria[filterGroups][0][filters][0][field]": "coupon_code",
      "searchCriteria[filterGroups][0][filters][0][value]": code,
      "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
      "searchCriteria[pageSize]": String(pageSize),
      "searchCriteria[currentPage]": String(page),
    });
    const res = await fetch(`${MAGENTO_URL}/rest/V1/orders?${params}`, {
      headers: { Authorization: `Bearer ${token}` },
    });
    if (!res.ok) throw new Error(`Magento ${res.status}`);
    const body = await res.json();
    const items = body.items || [];
    for (const item of items) yield item;
    if (items.length < pageSize) return;
    page++;
  }
}
4

Decide, with one pure function

Keep the decision in its own function that takes only already fetched data, the list of coupons with their timesUsed, and a map from coupon code to the orders that carry it, and returns the orphaned rows. It never touches the network, so it is trivial to test with plain objects.

decide.py
def compute_orphaned_coupon_usages(coupons, orders_by_coupon_code, excluded_states=("canceled",)):
    results = []
    for c in coupons:
        orders = orders_by_coupon_code.get(c["code"], [])
        actual_order_count = sum(1 for o in orders if o.get("state") not in excluded_states)
        orphaned_count = max(0, c["timesUsed"] - actual_order_count)
        if orphaned_count > 0:
            results.append({
                "couponId": c["couponId"],
                "code": c["code"],
                "timesUsed": c["timesUsed"],
                "actualOrderCount": actual_order_count,
                "orphanedCount": orphaned_count,
            })
    return results
decide.js
export function computeOrphanedCouponUsages(coupons, ordersByCouponCode, excludedStates = ["canceled"]) {
  return coupons
    .map((c) => {
      const orders = ordersByCouponCode.get(c.code) || [];
      const actualOrderCount = orders.filter((o) => !excludedStates.includes(o.state)).length;
      const orphanedCount = Math.max(0, c.timesUsed - actualOrderCount);
      return { couponId: c.couponId, code: c.code, timesUsed: c.timesUsed, actualOrderCount, orphanedCount };
    })
    .filter((r) => r.orphanedCount > 0);
}
5

Write the report, never the database

There is no CouponManagementV1 write for decrementing usage, so the script's only output is a report, a JSON or CSV file listing every orphaned {couponId, code, ruleId, timesUsed, actualOrderCount, orphanedCount} row. Correcting salesrule_coupon and its related tables is a separate, controlled database script an operator runs by hand after confirming no retried order is still in flight.

report.py
import json

def write_report(rows, path):
    with open(path, "w") as fh:
        json.dump(rows, fh, indent=2)
report.js
import { writeFileSync } from "node:fs";

function writeReport(rows, path) {
  writeFileSync(path, JSON.stringify(rows, null, 2));
}
6

Wire it together with a dry run guard

The loop authenticates once, fetches every configured coupon and its orders, runs the pure decision function, and writes the JSON report. DRY_RUN stays on by default because this script never writes to Magento at all, it only ever reads and reports. Turning it off only changes whether the report file is written to disk versus just logged, so there is no unsafe mode here by design.

Run it safe

This script never calls a write endpoint and never touches salesrule_coupon, salesrule_coupon_usage, or salesrule_customer directly. Treat its report as the input to a human decision. Only after an operator confirms there is no in-flight retry should a separate, controlled database script correct the counters, and even then it should set times_used to the actual order count, not simply subtract the orphaned count.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, and only ever writes a JSON report, never a database write and never an unauthenticated REST write.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 59 Magento fixes, free and open source.
reconcile_coupon_usage.py
"""Reconcile Magento 2 or Adobe Commerce coupon usage against real orders.

CouponUsagesIncrement hooks beforeSubmit on QuoteManagement and commits
usage counters to salesrule_coupon, salesrule_coupon_usage, and
salesrule_customer before the nested submitQuote call actually validates
the cart and creates the order. If that validation throws, for example a
minimum order amount check fails, the order is never created but the
usage increment already committed. There is no REST endpoint that
decrements these counters, so this script only ever reads coupons and
orders and writes a JSON report of orphaned usage for a human to review.
Safe to run again and again.
"""
import os
import json
import logging
import requests

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

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
ADMIN_USERNAME = os.environ.get("MAGENTO_ADMIN_USERNAME")
ADMIN_PASSWORD = os.environ.get("MAGENTO_ADMIN_PASSWORD")
ADMIN_TOKEN = os.environ.get("MAGENTO_ADMIN_TOKEN")
COUPON_CODES = [c.strip() for c in os.environ.get("COUPON_CODES", "").split(",") if c.strip()]
EXCLUDED_STATES = [s.strip() for s in os.environ.get("EXCLUDED_STATES", "canceled").split(",") if s.strip()]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
OUTPUT_JSON = os.environ.get("OUTPUT_JSON", "orphaned_coupon_usage.json")
PAGE_SIZE = int(os.environ.get("PAGE_SIZE", "100"))


def get_token():
    if ADMIN_TOKEN:
        return ADMIN_TOKEN
    r = requests.post(
        f"{MAGENTO_URL}/rest/V1/integration/admin/token",
        json={"username": ADMIN_USERNAME, "password": ADMIN_PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def fetch_coupon(token, code):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "code",
        "searchCriteria[filterGroups][0][filters][0][value]": code,
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    }
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1/salesRules/coupons/search",
        params=params,
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    items = r.json().get("items", [])
    return items[0] if items else None


def orders_for_coupon(token, code, page_size=PAGE_SIZE):
    page = 1
    while True:
        params = {
            "searchCriteria[filterGroups][0][filters][0][field]": "coupon_code",
            "searchCriteria[filterGroups][0][filters][0][value]": code,
            "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
            "searchCriteria[pageSize]": page_size,
            "searchCriteria[currentPage]": page,
        }
        r = requests.get(
            f"{MAGENTO_URL}/rest/V1/orders",
            params=params,
            headers={"Authorization": f"Bearer {token}"},
            timeout=30,
        )
        r.raise_for_status()
        items = r.json().get("items", [])
        for item in items:
            yield item
        if len(items) < page_size:
            return
        page += 1


def compute_orphaned_coupon_usages(coupons, orders_by_coupon_code, excluded_states=("canceled",)):
    results = []
    for c in coupons:
        orders = orders_by_coupon_code.get(c["code"], [])
        actual_order_count = sum(1 for o in orders if o.get("state") not in excluded_states)
        orphaned_count = max(0, c["timesUsed"] - actual_order_count)
        if orphaned_count > 0:
            results.append({
                "couponId": c["couponId"],
                "code": c["code"],
                "timesUsed": c["timesUsed"],
                "actualOrderCount": actual_order_count,
                "orphanedCount": orphaned_count,
            })
    return results


def write_report(rows, path):
    with open(path, "w") as fh:
        json.dump(rows, fh, indent=2)


def run():
    token = get_token()
    coupons = []
    orders_by_code = {}
    for code in COUPON_CODES:
        coupon = fetch_coupon(token, code)
        if coupon is None:
            log.warning("Coupon code %s not found, skipping.", code)
            continue
        coupons.append({
            "couponId": coupon["coupon_id"],
            "ruleId": coupon["rule_id"],
            "code": coupon["code"],
            "timesUsed": coupon["times_used"],
        })
        orders_by_code[code] = [
            {"entityId": o.get("entity_id"), "incrementId": o.get("increment_id"), "state": o.get("state")}
            for o in orders_for_coupon(token, code)
        ]

    orphaned = compute_orphaned_coupon_usages(coupons, orders_by_code, EXCLUDED_STATES)

    for row in orphaned:
        log.info(
            "Coupon %s: times_used=%s actual_orders=%s orphaned=%s",
            row["code"], row["timesUsed"], row["actualOrderCount"], row["orphanedCount"],
        )

    if orphaned:
        write_report(orphaned, OUTPUT_JSON)

    log.info(
        "Done. %d coupon(s) with orphaned usage, %s.",
        len(orphaned), "report written, DRY_RUN has no write path either way" if not DRY_RUN else "report written (dry run)",
    )


if __name__ == "__main__":
    run()
reconcile-coupon-usage.js
/**
 * Reconcile Magento 2 or Adobe Commerce coupon usage against real orders.
 *
 * CouponUsagesIncrement hooks beforeSubmit on QuoteManagement and commits
 * usage counters to salesrule_coupon, salesrule_coupon_usage, and
 * salesrule_customer before the nested submitQuote call actually validates
 * the cart and creates the order. If that validation throws, for example a
 * minimum order amount check fails, the order is never created but the
 * usage increment already committed. There is no REST endpoint that
 * decrements these counters, so this script only ever reads coupons and
 * orders and writes a JSON report of orphaned usage for a human to review.
 * Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/magento/coupon-marked-used-without-order/
 */
import { pathToFileURL } from "node:url";
import { writeFileSync } from "node:fs";

const MAGENTO_URL = (process.env.MAGENTO_URL || "https://example.test").replace(/\/$/, "");
const ADMIN_USERNAME = process.env.MAGENTO_ADMIN_USERNAME || "admin";
const ADMIN_PASSWORD = process.env.MAGENTO_ADMIN_PASSWORD || "change-me";
const ADMIN_TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "";
const COUPON_CODES = (process.env.COUPON_CODES || "").split(",").map((c) => c.trim()).filter(Boolean);
const EXCLUDED_STATES = (process.env.EXCLUDED_STATES || "canceled").split(",").map((s) => s.trim()).filter(Boolean);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const OUTPUT_JSON = process.env.OUTPUT_JSON || "orphaned_coupon_usage.json";
const PAGE_SIZE = Number(process.env.PAGE_SIZE || 100);

export function computeOrphanedCouponUsages(coupons, ordersByCouponCode, excludedStates = ["canceled"]) {
  return coupons
    .map((c) => {
      const orders = ordersByCouponCode.get(c.code) || [];
      const actualOrderCount = orders.filter((o) => !excludedStates.includes(o.state)).length;
      const orphanedCount = Math.max(0, c.timesUsed - actualOrderCount);
      return { couponId: c.couponId, code: c.code, timesUsed: c.timesUsed, actualOrderCount, orphanedCount };
    })
    .filter((r) => r.orphanedCount > 0);
}

async function getToken() {
  if (ADMIN_TOKEN) return ADMIN_TOKEN;
  const res = await fetch(`${MAGENTO_URL}/rest/V1/integration/admin/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ username: ADMIN_USERNAME, password: ADMIN_PASSWORD }),
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function fetchCoupon(token, code) {
  const params = new URLSearchParams({
    "searchCriteria[filterGroups][0][filters][0][field]": "code",
    "searchCriteria[filterGroups][0][filters][0][value]": code,
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
  });
  const res = await fetch(`${MAGENTO_URL}/rest/V1/salesRules/coupons/search?${params}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  const body = await res.json();
  const items = body.items || [];
  return items[0] || null;
}

async function* ordersForCoupon(token, code, pageSize = PAGE_SIZE) {
  let page = 1;
  while (true) {
    const params = new URLSearchParams({
      "searchCriteria[filterGroups][0][filters][0][field]": "coupon_code",
      "searchCriteria[filterGroups][0][filters][0][value]": code,
      "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
      "searchCriteria[pageSize]": String(pageSize),
      "searchCriteria[currentPage]": String(page),
    });
    const res = await fetch(`${MAGENTO_URL}/rest/V1/orders?${params}`, {
      headers: { Authorization: `Bearer ${token}` },
    });
    if (!res.ok) throw new Error(`Magento ${res.status}`);
    const body = await res.json();
    const items = body.items || [];
    for (const item of items) yield item;
    if (items.length < pageSize) return;
    page++;
  }
}

function writeReport(rows, path) {
  writeFileSync(path, JSON.stringify(rows, null, 2));
}

export async function run() {
  const token = await getToken();
  const coupons = [];
  const ordersByCode = new Map();

  for (const code of COUPON_CODES) {
    const coupon = await fetchCoupon(token, code);
    if (!coupon) {
      console.warn(`Coupon code ${code} not found, skipping.`);
      continue;
    }
    coupons.push({
      couponId: coupon.coupon_id,
      ruleId: coupon.rule_id,
      code: coupon.code,
      timesUsed: coupon.times_used,
    });
    const orders = [];
    for await (const o of ordersForCoupon(token, code)) {
      orders.push({ entityId: o.entity_id, incrementId: o.increment_id, state: o.state });
    }
    ordersByCode.set(code, orders);
  }

  const orphaned = computeOrphanedCouponUsages(coupons, ordersByCode, EXCLUDED_STATES);

  for (const row of orphaned) {
    console.log(`Coupon ${row.code}: times_used=${row.timesUsed} actual_orders=${row.actualOrderCount} orphaned=${row.orphanedCount}`);
  }

  if (orphaned.length) {
    writeReport(orphaned, OUTPUT_JSON);
  }

  console.log(`Done. ${orphaned.length} coupon(s) with orphaned usage, ${DRY_RUN ? "report written (dry run)" : "report written, DRY_RUN has no write path either way"}.`);
  return orphaned;
}

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

Add a test

The reconciliation rule is the part most worth testing, because it decides which coupons get reported as orphaned. Since compute_orphaned_coupon_usages and computeOrphanedCouponUsages are pure, the tests need no network and no Magento instance. They just feed in plain coupons and orders and check the result.

test_coupon_reconciliation.py
from reconcile_coupon_usage import compute_orphaned_coupon_usages


def coupon(**over):
    base = {"couponId": 1, "ruleId": 10, "code": "SAVE10", "timesUsed": 1}
    base.update(over)
    return base


def test_no_orphan_when_counts_match():
    orders = {"SAVE10": [{"entityId": 1, "incrementId": "000000001", "state": "complete"}]}
    assert compute_orphaned_coupon_usages([coupon()], orders) == []


def test_orphan_when_times_used_exceeds_real_orders():
    orders = {"SAVE10": []}
    result = compute_orphaned_coupon_usages([coupon()], orders)
    assert result == [{"couponId": 1, "code": "SAVE10", "timesUsed": 1, "actualOrderCount": 0, "orphanedCount": 1}]


def test_cancelled_orders_are_excluded_from_actual_count():
    orders = {"SAVE10": [{"entityId": 1, "incrementId": "000000001", "state": "canceled"}]}
    result = compute_orphaned_coupon_usages([coupon()], orders)
    assert result[0]["actualOrderCount"] == 0
    assert result[0]["orphanedCount"] == 1


def test_no_orphan_when_multiple_orders_cover_usage():
    orders = {"SAVE10": [
        {"entityId": 1, "incrementId": "000000001", "state": "complete"},
        {"entityId": 2, "incrementId": "000000002", "state": "processing"},
    ]}
    result = compute_orphaned_coupon_usages([coupon(timesUsed=2)], orders)
    assert result == []


def test_missing_coupon_code_in_orders_map_counts_as_zero_orders():
    result = compute_orphaned_coupon_usages([coupon()], {})
    assert result[0]["actualOrderCount"] == 0


def test_multiple_coupons_only_flags_the_orphaned_one():
    coupons = [coupon(couponId=1, code="SAVE10", timesUsed=1), coupon(couponId=2, code="WELCOME20", timesUsed=1)]
    orders = {"SAVE10": [], "WELCOME20": [{"entityId": 5, "incrementId": "000000005", "state": "complete"}]}
    result = compute_orphaned_coupon_usages(coupons, orders)
    assert len(result) == 1
    assert result[0]["code"] == "SAVE10"
coupon-reconciliation.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { computeOrphanedCouponUsages } from "./reconcile-coupon-usage.js";

const coupon = (over = {}) => ({ couponId: 1, ruleId: 10, code: "SAVE10", timesUsed: 1, ...over });

test("no orphan when counts match", () => {
  const orders = new Map([["SAVE10", [{ entityId: 1, incrementId: "000000001", state: "complete" }]]]);
  assert.deepEqual(computeOrphanedCouponUsages([coupon()], orders), []);
});

test("orphan when times_used exceeds real orders", () => {
  const orders = new Map([["SAVE10", []]]);
  const result = computeOrphanedCouponUsages([coupon()], orders);
  assert.deepEqual(result, [{ couponId: 1, code: "SAVE10", timesUsed: 1, actualOrderCount: 0, orphanedCount: 1 }]);
});

test("cancelled orders are excluded from the actual count", () => {
  const orders = new Map([["SAVE10", [{ entityId: 1, incrementId: "000000001", state: "canceled" }]]]);
  const result = computeOrphanedCouponUsages([coupon()], orders);
  assert.equal(result[0].actualOrderCount, 0);
  assert.equal(result[0].orphanedCount, 1);
});

test("no orphan when multiple orders cover usage", () => {
  const orders = new Map([["SAVE10", [
    { entityId: 1, incrementId: "000000001", state: "complete" },
    { entityId: 2, incrementId: "000000002", state: "processing" },
  ]]]);
  const result = computeOrphanedCouponUsages([coupon({ timesUsed: 2 })], orders);
  assert.deepEqual(result, []);
});

test("missing coupon code in orders map counts as zero orders", () => {
  const result = computeOrphanedCouponUsages([coupon()], new Map());
  assert.equal(result[0].actualOrderCount, 0);
});

test("multiple coupons only flags the orphaned one", () => {
  const coupons = [coupon({ couponId: 1, code: "SAVE10", timesUsed: 1 }), coupon({ couponId: 2, code: "WELCOME20", timesUsed: 1 })];
  const orders = new Map([
    ["SAVE10", []],
    ["WELCOME20", [{ entityId: 5, incrementId: "000000005", state: "complete" }]],
  ]);
  const result = computeOrphanedCouponUsages(coupons, orders);
  assert.equal(result.length, 1);
  assert.equal(result[0].code, "SAVE10");
});

Case studies

Single use coupon

A launch code that only fired once, on paper

A small apparel store ran a single use welcome coupon for new signups. A handful of customers reported the code as already used the very first time they tried it. Support could see no order attached to their account under that email.

Running the reconciliation script against the coupon showed times_used at eleven while only eight real, non-cancelled orders actually carried the code. The three orphaned customers had all hit a shipping rate calculation that pushed their cart just under the rule's minimum order amount after the coupon had already incremented, so their checkout failed with no order created. Support manually reissued the code for those three after confirming no retry had gone through.

Per customer limit

A wholesale buyer locked out of a repeat use code

A B2B storefront let approved accounts use a recurring discount code up to three times a quarter. One account's salesrule_customer row showed the limit already exhausted, but the account only had one completed order that quarter.

The script's per coupon report, cross checked with orders filtered by that customer, showed two orphaned increments from failed checkout attempts during a period when a cart price rule change briefly raised the minimum order amount. The team applied Adobe's quality patch for the underlying ordering bug and had an operator correct the counter to match the real order count.

What good looks like

After running this reconciliation on a schedule, a coupon that shows as used always has a real order to point to, or it shows up in a dated report before a customer ever has to complain. Nothing is corrected automatically, so no legitimate usage is ever double corrected, but nothing orphaned goes unnoticed either. Longer term, applying Adobe's ACSD-54966 patch, or the fix that moves the usage increment to after a successful quote submission, closes the ordering bug at the source.

FAQ

Why does my Magento coupon show as used when the order never went through?

Magento increments the coupon's usage counters in the CouponUsagesIncrement plugin, which runs before the quote is actually validated and submitted as an order. If that later validation fails, for example the cart no longer meets a minimum order amount, the order is never created, but the usage counters were already committed to the database, leaving the coupon marked used for a purchase that does not exist.

Can I fix salesrule_coupon_usage through the REST API?

No. The coupon and sales rule REST endpoints expose reading and searching coupons, not a write operation that decrements times_used or removes a salesrule_coupon_usage or salesrule_customer row. Correcting those counters has to happen through a controlled database update or admin script, never an unauthenticated REST write, and only after confirming no retried order is still in flight.

How do I tell a real orphaned coupon usage from a customer who simply reused the code later?

Compare the coupon's recorded times_used against the count of real, non-cancelled orders that actually carry that coupon code. If times_used is higher than the matching order count, the extra usage has no order behind it and is very likely an orphan left by a failed submitQuote call. If the counts agree, or the shortfall is explained by cancelled orders you chose to exclude, there is nothing to fix.

Related field notes

Citations

On the problem:

  1. Unused coupon marked as used. github.com/magento/magento2/issues/32384
  2. Coupon Codes Usage Increases Although Placing Order Throws a Validation Error on Admin Dashboard. github.com/magento/magento2/issues/33100
  3. ACSD-54966: Fix for reusing coupon codes after failed orders, Adobe Commerce Quality Patches Tool. experienceleague.adobe.com quality-patches-tool acsd-54966

On the solution:

  1. Search using REST endpoints, searchCriteria filters. developer.adobe.com/commerce/webapi/rest/use-rest/performing-searches
  2. REST API reference. developer.adobe.com/commerce/webapi/rest/reference
  3. REST endpoints for Adobe Commerce. developer.adobe.com/commerce/webapi/reference/rest/paas

Stuck on a tricky one?

If you have a problem in Magento cart price rules, coupons, checkout, or order data 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 a coupon mystery?

If this saved you a confusing support ticket about a code that was never really used, 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 Magento field notes