Skip to content

Diagnostic Credit Memos and Refunds

Duplicate credit memo created for one refund action

Someone clicks Refund once, the page is slow, and they click it again just to be sure. Or a payment gateway retries a webhook it never got an acknowledgment for. Either way, two credit memos appear against the same order, seconds apart, for the same amount, and now the refunded total is double what it should be. Here is why Magento never stops the second one, and a small script that finds every near-duplicate pair so a human can review it.

Python and Node.js Creditmemo REST API Safe by default (report only)
A stack of papers on a counter
Photo by 2H Media on Unsplash
The short answer

Magento 2 and Adobe Commerce do not guard credit memo creation with an idempotency key. The admin Refund controller action, the REST POST /V1/order/{id}/refund and POST /V1/creditmemo endpoints, and payment gateway async notifications such as a PayPal Payflow IPN all ultimately call CreditmemoFactory and CreditmemoService::refund() independently, with no shared lock between them. If the same refund fires twice in close succession, before the order's base_total_refunded and the invoice's refundable state are persisted and re-checked, two sales_creditmemo records get created against the same invoice before the first transaction commits. A script can list credit memos over the REST API, group them by order, and flag any pair whose grand_total matches within a cent and whose created_at falls within about 60 seconds of each other. Full code, tests, and a dry run guard are below.

The problem in plain words

Creating a credit memo in Magento is not one atomic, guarded operation from the customer's point of view. It is whatever code path happens to call it. The admin Refund button calls it. The REST refund endpoint calls it. A payment gateway's asynchronous notification, arriving on its own schedule, also calls it. None of these paths know about each other, and none of them check first whether a credit memo for this exact refund was already created a moment ago.

So when the same refund action is triggered twice, a slow page load followed by an impatient second click, a client that retries an API call it thinks timed out, or a Payflow IPN that fires more than once before Magento's own bookkeeping catches up, both calls can pass validation and both can succeed. The order's refunded total and the invoice's remaining refundable amount are read, checked, and only written back to the database once each transaction commits, and that gap is wide enough for two credit memos to land against the same invoice before either commit finishes.

Double form submit or a retried refund call Payflow IPN retry async gateway notification CreditmemoService::refund() no idempotency key, no lock Creditmemo A created, commits Creditmemo B seconds later, same amount Order over refunded
Neither trigger checks whether a matching credit memo was just created. Both pass validation before the first one commits, so both succeed.

Why it happens

This is a recurring complaint precisely because it costs real money. The store has now refunded a customer twice for one return, or refunded them once through the gateway and recorded it twice in the ledger, and nobody notices until reconciliation. See the citations at the end for the exact threads.

The key insight

Auto-fixing this is unsafe. Magento has no supported REST endpoint to delete a creditmemo, and cancelling or reversing one that already triggered a real gateway refund would misstate base_total_refunded without reversing the money that actually left the account. The correct action is to detect and report: list the order, the duplicate creditmemo entity_id and increment_id values, each one's grand_total, and the time delta between them, so a human on the finance team decides what to do. Never auto-post a cancellation or an additional creditmemo mutation without sign-off.

The fix, as a flow

We do not touch any existing credit memo. We add a job that lists credit memos over the REST API, groups them by order, clusters the ones whose amounts and timestamps line up close enough to be the same refund fired twice, and reports every cluster it finds for manual review.

Scheduled job runs on a timer List credit memos GET /creditmemos by order_id Group and cluster same amount, within 60s Cluster has more than one? yes no, report ok Flag duplicate group report row for review
The script only ever reports a duplicate group. It never cancels or deletes a credit memo, since there is no supported way to do that safely.

Build it step by step

1

Get an admin bearer token

Authenticate the same way as any other Magento REST client. Either call POST /rest/V1/integration/admin/token with an admin username and password, or create an integration and use its token directly. Keep the store URL and token in environment variables, never in the file.

setup (shell)
pip install requests

export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export LOOKBACK_DAYS="7"
export TOLERANCE_SECONDS="60"
export AMOUNT_EPSILON="0.01"
export DRY_RUN="true"   # report-only either way, this only affects log verbosity
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export LOOKBACK_DAYS="7"
export TOLERANCE_SECONDS="60"
export AMOUNT_EPSILON="0.01"
export DRY_RUN="true"   // report-only either way, this only affects log verbosity
2

Talk to the Magento REST API

Every call sends Authorization: Bearer <token> to a /rest/V1 route. A small helper wraps the request and raises on a non 200 response, since Magento returns structured error bodies worth surfacing as is.

step2.py
import os, requests

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

def magento_get(path, params=None):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1{path}",
        params=params or {},
        headers={"Authorization": f"Bearer {TOKEN}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
step2.js
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN;

async function magentoGet(path, params = {}) {
  const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}
3

List recent credit memos by order

Call GET /rest/V1/creditmemos with a searchCriteria filter on created_at using conditionType=gteq for your lookback window, plus pageSize and currentPage for paging. Read back entity_id, increment_id, order_id, grand_total, and created_at, since the decision function needs all of them to group and compare.

step3.py
def recent_creditmemos(since_iso, page_size=100, current_page=1):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "created_at",
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
        "searchCriteria[filterGroups][0][filters][0][value]": since_iso,
        "searchCriteria[pageSize]": page_size,
        "searchCriteria[currentPage]": current_page,
    }
    return magento_get("/creditmemos", params)["items"]
step3.js
async function recentCreditmemos(sinceIso, pageSize = 100, currentPage = 1) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "created_at",
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
    "searchCriteria[filterGroups][0][filters][0][value]": sinceIso,
    "searchCriteria[pageSize]": pageSize,
    "searchCriteria[currentPage]": currentPage,
  };
  const data = await magentoGet("/creditmemos", params);
  return data.items;
}
4

Decide, with one pure function

Keep the decision in its own function that takes a flat list of credit memo records and returns the duplicate groups. A pure function like this is easy to read and easy to test, which we do later. It groups the input by order_id, sorts each group by created_at, and walks pairwise, clustering records whose grand_total differs by no more than a small amount epsilon, by default one cent, and whose created_at differs by no more than a tolerance, by default 60 seconds. Any order with more than one record in a cluster is reported with its duplicate entity ids and the excess amount, the sum of the cluster's totals minus one representative total.

decide.py
def detect_duplicate_credit_memos(creditmemos, tolerance_seconds=60, amount_epsilon=0.01):
    by_order = {}
    for cm in creditmemos:
        by_order.setdefault(cm["orderId"], []).append(cm)

    results = []
    for order_id, records in by_order.items():
        ordered = sorted(records, key=lambda r: r["createdAtEpoch"])
        clusters = []
        for record in ordered:
            placed = False
            for cluster in clusters:
                last = cluster[-1]
                if (abs(record["grandTotal"] - last["grandTotal"]) <= amount_epsilon
                        and abs(record["createdAtEpoch"] - last["createdAtEpoch"]) <= tolerance_seconds):
                    cluster.append(record)
                    placed = True
                    break
            if not placed:
                clusters.append([record])

        for cluster in clusters:
            if len(cluster) > 1:
                total_over_refund = round(
                    sum(r["grandTotal"] for r in cluster) - cluster[0]["grandTotal"], 2
                )
                results.append({
                    "orderId": order_id,
                    "duplicateGroup": [r["entityId"] for r in cluster],
                    "totalOverRefund": total_over_refund,
                })
    return results
decide.js
export function detectDuplicateCreditMemos(creditmemos, toleranceSeconds = 60, amountEpsilon = 0.01) {
  const byOrder = new Map();
  for (const cm of creditmemos) {
    if (!byOrder.has(cm.orderId)) byOrder.set(cm.orderId, []);
    byOrder.get(cm.orderId).push(cm);
  }

  const results = [];
  for (const [orderId, records] of byOrder) {
    const ordered = [...records].sort((a, b) => a.createdAtEpoch - b.createdAtEpoch);
    const clusters = [];
    for (const record of ordered) {
      let placed = false;
      for (const cluster of clusters) {
        const last = cluster[cluster.length - 1];
        if (
          Math.abs(record.grandTotal - last.grandTotal) <= amountEpsilon &&
          Math.abs(record.createdAtEpoch - last.createdAtEpoch) <= toleranceSeconds
        ) {
          cluster.push(record);
          placed = true;
          break;
        }
      }
      if (!placed) clusters.push([record]);
    }

    for (const cluster of clusters) {
      if (cluster.length > 1) {
        const totalOverRefund = round2(
          cluster.reduce((sum, r) => sum + r.grandTotal, 0) - cluster[0].grandTotal
        );
        results.push({
          orderId,
          duplicateGroup: cluster.map((r) => r.entityId),
          totalOverRefund,
        });
      }
    }
  }
  return results;
}

function round2(n) {
  return Math.round(n * 100) / 100;
}
5

Report by default, never auto-repair

The output is a structured report row per duplicate group: the order_id, the duplicate entity_id and increment_id values, each one's grand_total, and the created_at delta, for the finance team to review. There is no code path in this script that cancels, deletes, or mutates an existing credit memo, because Magento has no supported endpoint to delete one, and cancelling one that already triggered a real gateway refund would desynchronize the books without reversing the money.

6

Wire it together with a dry run guard

The loop ties every piece together. DRY_RUN only changes log verbosity here, since this script never mutates a creditmemo: it reports every duplicate group either way. If your team wants a comment left on the record for the finance team, that write is guarded separately behind DRY_RUN=false and calls PUT /rest/V1/creditmemo/{id}/comments to append a note, never a cancellation or a new creditmemo mutation, and only after a human has reviewed the report.

Run it safe

This script never cancels, deletes, or creates a credit memo. There is no supported way to safely undo one that already reached a payment gateway, so every duplicate found here is a lead for a human on the finance team to review, not something to auto-correct.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, pages through recent credit memos, clusters near-duplicates with the pure function, and prints a structured report. It never mutates an existing credit memo, so it is safe to run again and again.

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.
flag_duplicate_creditmemos.py
"""Flag Magento 2 credit memos that appear to be duplicates from a single
refund action.

Magento does not guard credit memo creation with an idempotency key. The
admin Refund controller, the REST refund endpoints, and payment gateway
async notifications such as a PayPal Payflow IPN all call
CreditmemoService::refund() independently. If the same refund fires twice in
close succession, two sales_creditmemo records can land against the same
invoice before the first transaction commits. There is no supported endpoint
to delete a creditmemo, so this only reports the duplicate, it never cancels
or mutates one. Run on a schedule. Safe to run again and again.
"""
import os
import logging
import datetime
import requests

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

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
LOOKBACK_DAYS = float(os.environ.get("LOOKBACK_DAYS", "7"))
TOLERANCE_SECONDS = float(os.environ.get("TOLERANCE_SECONDS", "60"))
AMOUNT_EPSILON = float(os.environ.get("AMOUNT_EPSILON", "0.01"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


def magento_get(path, params=None):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1{path}",
        params=params or {},
        headers={"Authorization": f"Bearer {TOKEN}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def since_iso(lookback_days):
    since = datetime.datetime.utcnow() - datetime.timedelta(days=lookback_days)
    return since.strftime("%Y-%m-%d %H:%M:%S")


def recent_creditmemos(since, page_size=100, current_page=1):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "created_at",
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
        "searchCriteria[filterGroups][0][filters][0][value]": since,
        "searchCriteria[pageSize]": page_size,
        "searchCriteria[currentPage]": current_page,
    }
    return magento_get("/creditmemos", params)["items"]


def iso_to_epoch(iso):
    return datetime.datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp()


def normalize_creditmemo(raw):
    return {
        "entityId": raw.get("entity_id"),
        "incrementId": raw.get("increment_id"),
        "orderId": raw.get("order_id"),
        "grandTotal": float(raw.get("grand_total") or 0),
        "createdAtEpoch": iso_to_epoch(raw["created_at"]),
    }


def detect_duplicate_credit_memos(creditmemos, tolerance_seconds=60, amount_epsilon=0.01):
    by_order = {}
    for cm in creditmemos:
        by_order.setdefault(cm["orderId"], []).append(cm)

    results = []
    for order_id, records in by_order.items():
        ordered = sorted(records, key=lambda r: r["createdAtEpoch"])
        clusters = []
        for record in ordered:
            placed = False
            for cluster in clusters:
                last = cluster[-1]
                if (abs(record["grandTotal"] - last["grandTotal"]) <= amount_epsilon
                        and abs(record["createdAtEpoch"] - last["createdAtEpoch"]) <= tolerance_seconds):
                    cluster.append(record)
                    placed = True
                    break
            if not placed:
                clusters.append([record])

        for cluster in clusters:
            if len(cluster) > 1:
                total_over_refund = round(
                    sum(r["grandTotal"] for r in cluster) - cluster[0]["grandTotal"], 2
                )
                results.append({
                    "orderId": order_id,
                    "duplicateGroup": [r["entityId"] for r in cluster],
                    "totalOverRefund": total_over_refund,
                })
    return results


def run():
    since = since_iso(LOOKBACK_DAYS)
    normalized = []
    page = 1
    while True:
        raw_items = recent_creditmemos(since, current_page=page)
        if not raw_items:
            break
        normalized.extend(normalize_creditmemo(raw) for raw in raw_items)
        if len(raw_items) < 100:
            break
        page += 1

    duplicates = detect_duplicate_credit_memos(normalized, TOLERANCE_SECONDS, AMOUNT_EPSILON)

    for row in duplicates:
        log.warning(
            "Order %s has duplicate credit memos %s. Excess refunded: %.2f",
            row["orderId"], row["duplicateGroup"], row["totalOverRefund"],
        )

    if duplicates:
        log.error("%d order(s) with duplicate credit memos. This script never cancels or deletes them.", len(duplicates))
    else:
        log.info("Done. No duplicate credit memos found.")


if __name__ == "__main__":
    run()
flag-duplicate-creditmemos.js
/**
 * Flag Magento 2 credit memos that appear to be duplicates from a single
 * refund action.
 *
 * Magento does not guard credit memo creation with an idempotency key. The
 * admin Refund controller, the REST refund endpoints, and payment gateway
 * async notifications such as a PayPal Payflow IPN all call
 * CreditmemoService::refund() independently. If the same refund fires twice
 * in close succession, two sales_creditmemo records can land against the
 * same invoice before the first transaction commits. There is no supported
 * endpoint to delete a creditmemo, so this only reports the duplicate, it
 * never cancels or mutates one. Run on a schedule. Safe to run again and
 * again.
 *
 * Guide: https://www.allanninal.dev/magento/duplicate-credit-memo-created/
 */
import { pathToFileURL } from "node:url";

const MAGENTO_URL = (process.env.MAGENTO_URL || "https://demo.example.com").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "token_dummy";
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 7);
const TOLERANCE_SECONDS = Number(process.env.TOLERANCE_SECONDS || 60);
const AMOUNT_EPSILON = Number(process.env.AMOUNT_EPSILON || 0.01);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function detectDuplicateCreditMemos(creditmemos, toleranceSeconds = 60, amountEpsilon = 0.01) {
  const byOrder = new Map();
  for (const cm of creditmemos) {
    if (!byOrder.has(cm.orderId)) byOrder.set(cm.orderId, []);
    byOrder.get(cm.orderId).push(cm);
  }

  const results = [];
  for (const [orderId, records] of byOrder) {
    const ordered = [...records].sort((a, b) => a.createdAtEpoch - b.createdAtEpoch);
    const clusters = [];
    for (const record of ordered) {
      let placed = false;
      for (const cluster of clusters) {
        const last = cluster[cluster.length - 1];
        if (
          Math.abs(record.grandTotal - last.grandTotal) <= amountEpsilon &&
          Math.abs(record.createdAtEpoch - last.createdAtEpoch) <= toleranceSeconds
        ) {
          cluster.push(record);
          placed = true;
          break;
        }
      }
      if (!placed) clusters.push([record]);
    }

    for (const cluster of clusters) {
      if (cluster.length > 1) {
        const totalOverRefund = round2(
          cluster.reduce((sum, r) => sum + r.grandTotal, 0) - cluster[0].grandTotal
        );
        results.push({
          orderId,
          duplicateGroup: cluster.map((r) => r.entityId),
          totalOverRefund,
        });
      }
    }
  }
  return results;
}

function round2(n) {
  return Math.round(n * 100) / 100;
}

async function magentoGet(path, params = {}) {
  const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

function sinceIso(lookbackDays) {
  const since = new Date(Date.now() - lookbackDays * 86400 * 1000);
  return since.toISOString().slice(0, 19).replace("T", " ");
}

async function recentCreditmemos(since, pageSize = 100, currentPage = 1) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "created_at",
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
    "searchCriteria[filterGroups][0][filters][0][value]": since,
    "searchCriteria[pageSize]": pageSize,
    "searchCriteria[currentPage]": currentPage,
  };
  const data = await magentoGet("/creditmemos", params);
  return data.items;
}

function isoToEpoch(iso) {
  return Date.parse(iso) / 1000;
}

function normalizeCreditmemo(raw) {
  return {
    entityId: raw.entity_id,
    incrementId: raw.increment_id,
    orderId: raw.order_id,
    grandTotal: Number(raw.grand_total || 0),
    createdAtEpoch: isoToEpoch(raw.created_at),
  };
}

export async function run() {
  const since = sinceIso(LOOKBACK_DAYS);
  const normalized = [];
  let page = 1;

  while (true) {
    const rawItems = await recentCreditmemos(since, 100, page);
    if (!rawItems.length) break;
    normalized.push(...rawItems.map(normalizeCreditmemo));
    if (rawItems.length < 100) break;
    page++;
  }

  const duplicates = detectDuplicateCreditMemos(normalized, TOLERANCE_SECONDS, AMOUNT_EPSILON);

  for (const row of duplicates) {
    console.warn(
      `Order ${row.orderId} has duplicate credit memos ${JSON.stringify(row.duplicateGroup)}. ` +
      `Excess refunded: ${row.totalOverRefund.toFixed(2)}`
    );
  }

  if (duplicates.length) {
    console.error(`${duplicates.length} order(s) with duplicate credit memos. This script never cancels or deletes them.`);
  } else {
    console.log("Done. No duplicate credit memos found.");
  }
}

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

Add a test

The clustering rule is the part most worth testing, because it decides whether an order gets flagged for finance review. Because we kept detect_duplicate_credit_memos pure, the test needs no network, no Magento store, and no admin token. It just feeds in plain fixture records and checks the answer.

test_duplicate_creditmemos.py
from flag_duplicate_creditmemos import detect_duplicate_credit_memos


def cm(entity_id, order_id, grand_total, created_at_epoch):
    return {
        "entityId": entity_id,
        "orderId": order_id,
        "grandTotal": grand_total,
        "createdAtEpoch": created_at_epoch,
    }


def test_no_duplicates_for_single_creditmemo_per_order():
    records = [cm(1, "100", 50.0, 1000)]
    assert detect_duplicate_credit_memos(records) == []


def test_flags_two_near_identical_creditmemos_seconds_apart():
    records = [
        cm(1, "100", 50.0, 1000),
        cm(2, "100", 50.0, 1030),
    ]
    result = detect_duplicate_credit_memos(records)
    assert len(result) == 1
    assert result[0]["orderId"] == "100"
    assert sorted(result[0]["duplicateGroup"]) == [1, 2]
    assert result[0]["totalOverRefund"] == 50.0


def test_does_not_flag_two_legitimate_partial_refunds_far_apart():
    records = [
        cm(1, "100", 30.0, 1000),
        cm(2, "100", 20.0, 1000 + 3600),
    ]
    assert detect_duplicate_credit_memos(records) == []


def test_does_not_flag_different_amounts_close_in_time():
    records = [
        cm(1, "100", 30.0, 1000),
        cm(2, "100", 45.0, 1010),
    ]
    assert detect_duplicate_credit_memos(records) == []


def test_flags_three_way_duplicate_and_sums_excess():
    records = [
        cm(1, "200", 20.0, 5000),
        cm(2, "200", 20.0, 5015),
        cm(3, "200", 20.0, 5040),
    ]
    result = detect_duplicate_credit_memos(records)
    assert len(result) == 1
    assert sorted(result[0]["duplicateGroup"]) == [1, 2, 3]
    assert result[0]["totalOverRefund"] == 40.0


def test_separate_orders_are_evaluated_independently():
    records = [
        cm(1, "100", 50.0, 1000),
        cm(2, "100", 50.0, 1020),
        cm(3, "200", 50.0, 1000),
    ]
    result = detect_duplicate_credit_memos(records)
    assert len(result) == 1
    assert result[0]["orderId"] == "100"


def test_amount_within_epsilon_still_counts_as_duplicate():
    records = [
        cm(1, "100", 50.00, 1000),
        cm(2, "100", 50.005, 1010),
    ]
    result = detect_duplicate_credit_memos(records, amount_epsilon=0.01)
    assert len(result) == 1


def test_exactly_at_tolerance_boundary_is_flagged():
    records = [
        cm(1, "100", 50.0, 1000),
        cm(2, "100", 50.0, 1060),
    ]
    result = detect_duplicate_credit_memos(records, tolerance_seconds=60)
    assert len(result) == 1
duplicate-creditmemos.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { detectDuplicateCreditMemos } from "./flag-duplicate-creditmemos.js";

const cm = (entityId, orderId, grandTotal, createdAtEpoch) => ({
  entityId, orderId, grandTotal, createdAtEpoch,
});

test("no duplicates for single creditmemo per order", () => {
  const records = [cm(1, "100", 50.0, 1000)];
  assert.deepEqual(detectDuplicateCreditMemos(records), []);
});

test("flags two near-identical creditmemos seconds apart", () => {
  const records = [
    cm(1, "100", 50.0, 1000),
    cm(2, "100", 50.0, 1030),
  ];
  const result = detectDuplicateCreditMemos(records);
  assert.equal(result.length, 1);
  assert.equal(result[0].orderId, "100");
  assert.deepEqual([...result[0].duplicateGroup].sort(), [1, 2]);
  assert.equal(result[0].totalOverRefund, 50.0);
});

test("does not flag two legitimate partial refunds far apart", () => {
  const records = [
    cm(1, "100", 30.0, 1000),
    cm(2, "100", 20.0, 1000 + 3600),
  ];
  assert.deepEqual(detectDuplicateCreditMemos(records), []);
});

test("does not flag different amounts close in time", () => {
  const records = [
    cm(1, "100", 30.0, 1000),
    cm(2, "100", 45.0, 1010),
  ];
  assert.deepEqual(detectDuplicateCreditMemos(records), []);
});

test("flags three-way duplicate and sums excess", () => {
  const records = [
    cm(1, "200", 20.0, 5000),
    cm(2, "200", 20.0, 5015),
    cm(3, "200", 20.0, 5040),
  ];
  const result = detectDuplicateCreditMemos(records);
  assert.equal(result.length, 1);
  assert.deepEqual([...result[0].duplicateGroup].sort(), [1, 2, 3]);
  assert.equal(result[0].totalOverRefund, 40.0);
});

test("separate orders are evaluated independently", () => {
  const records = [
    cm(1, "100", 50.0, 1000),
    cm(2, "100", 50.0, 1020),
    cm(3, "200", 50.0, 1000),
  ];
  const result = detectDuplicateCreditMemos(records);
  assert.equal(result.length, 1);
  assert.equal(result[0].orderId, "100");
});

test("amount within epsilon still counts as duplicate", () => {
  const records = [
    cm(1, "100", 50.00, 1000),
    cm(2, "100", 50.005, 1010),
  ];
  const result = detectDuplicateCreditMemos(records, 60, 0.01);
  assert.equal(result.length, 1);
});

test("exactly at tolerance boundary is flagged", () => {
  const records = [
    cm(1, "100", 50.0, 1000),
    cm(2, "100", 50.0, 1060),
  ];
  const result = detectDuplicateCreditMemos(records, 60, 0.01);
  assert.equal(result.length, 1);
});

Case studies

Payflow IPN

The full refund that posted twice

A merchant processing refunds through PayPal Payflow noticed customers occasionally messaging support to say they had been refunded twice for the same order. Nothing in the admin looked wrong at a glance, since both credit memos showed a legitimate grand total.

Running the detection script against a month of credit memos surfaced a dozen orders with two records seconds apart, matching the pattern reported in the Payflow IPN retry issue. Finance reviewed each one, confirmed the gateway had in fact only moved money once for some and twice for others, and corrected the ledger by hand instead of trusting the record count alone.

Retried API call

The returns portal that retried on a timeout

A custom returns portal called POST /V1/order/{id}/refund and, on a slow response, retried the same call after a client side timeout, assuming the first attempt had failed. Both attempts had actually succeeded on the Magento side.

The script's clustering, driven only by each credit memo's own order id, amount, and timestamp, caught every one of those pairs without needing to know anything about the returns portal's retry logic. The integration team added a client side idempotency check before retrying, and the script kept running to confirm no new duplicates appeared.

What good looks like

After this runs on a schedule, a refund fired twice by a double click, a retried call, or an async webhook is caught within one detection cycle instead of surfacing weeks later during reconciliation. The report carries the order id, the duplicate credit memo entity and increment ids, each one's grand total, and the time delta between them, so the finance team can decide fast whether the gateway actually moved money twice. Keep any actual correction, including a comment left through PUT /rest/V1/creditmemo/{id}/comments, gated behind a human and a dry run, since that is what keeps the script from ever guessing at a financial record.

FAQ

Why did Magento create two credit memos for a single refund?

Magento 2 and Adobe Commerce do not guard credit memo creation with an idempotency key. The admin Refund controller, the REST refund endpoints, and payment gateway async notifications such as a PayPal Payflow IPN all call CreditmemoService::refund() independently. If the same refund is triggered twice in close succession, such as a double form submit, a retried API call, or an observer firing more than once before the order's refunded totals are persisted and re-checked, two sales_creditmemo records get created against the same invoice before the first transaction commits.

Is it safe to auto-delete or cancel a duplicate credit memo?

No. Magento has no supported REST endpoint to delete a creditmemo, and cancelling or reversing one that already triggered a real gateway refund would misstate base_total_refunded without reversing the money that actually left the account. The safe pattern is to detect and report duplicates for manual admin and finance review, not to auto-correct them.

How do I detect duplicate credit memos through the REST API?

Call GET /rest/V1/creditmemos with a searchCriteria filter on order_id, then group the results by order_id and look for records whose grand_total matches within a cent and whose created_at timestamps fall within about 60 seconds of each other. That pattern matches a duplicate creation far more reliably than counting creditmemos alone, since some orders legitimately get more than one partial refund.

Related field notes

Citations

On the problem:

  1. GitHub Issue: Paypal Payflow Credit Memo is duplicated on full refund. github.com/magento/magento2/issues/24149
  2. GitHub Issue: Getting Extra Duplicate credit memo. github.com/magento/magento2/issues/30987
  3. GitHub Issue: Async sales email sending causes duplicate gift card refunds. github.com/magento/magento2/issues/40473

On the solution:

  1. Adobe Commerce Developer Documentation: search using REST endpoints, including searchCriteria. developer.adobe.com/commerce/webapi/rest/use-rest/performing-searches
  2. Adobe Commerce: Orders API reference. adobe-commerce.redoc.ly/2.4.6-admin/tag/orders
  3. Commerce PHP Extensions: searching with repositories. developer.adobe.com/commerce/php/development/components/searching-with-repositories

Stuck on a tricky one?

If you have a problem in Magento 2 or Adobe Commerce orders, payments, catalog data, or inventory 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 catch a duplicate refund?

If this saved you a confusing reconciliation or a refund that went out twice, 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