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.
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.
Why it happens
- The admin Refund controller action, the REST
POST /V1/order/{id}/refundandPOST /V1/creditmemoendpoints, and payment gateway async notifications all ultimately callCreditmemoFactoryandCreditmemoService::refund()independently. None of these entry points shares a lock or an idempotency key with the others. - A refund's own order state,
base_total_refunded, and the invoice'scan_refundflag are only re-checked and persisted once each request's transaction commits. Two nearly simultaneous refund triggers can both read the state as refundable before either one writes back. - This is documented for full refunds on PayPal Payflow orders, where the gateway's own IPN retry behavior triggers a second credit memo creation seconds after the first, tracked in
magento/magento2#24149and#35193. - It is also documented as a generic API-driven duplication, not tied to any one payment method, tracked in
magento/magento2#30987and#4329, and in a related async observer case in#40473where a second async process fires a duplicate refund path before the first has finished. - In every reported case the two credit memos appear seconds apart with matching amounts against the same order, which is exactly the signature a detection script can look for.
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.
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.
Build it step by step
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.
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
// 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
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.
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()
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();
}
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.
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"]
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;
}
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.
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
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;
}
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.
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.
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.
"""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 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.
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
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
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.
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.
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:
- GitHub Issue: Paypal Payflow Credit Memo is duplicated on full refund. github.com/magento/magento2/issues/24149
- GitHub Issue: Getting Extra Duplicate credit memo. github.com/magento/magento2/issues/30987
- GitHub Issue: Async sales email sending causes duplicate gift card refunds. github.com/magento/magento2/issues/40473
On the solution:
- Adobe Commerce Developer Documentation: search using REST endpoints, including searchCriteria. developer.adobe.com/commerce/webapi/rest/use-rest/performing-searches
- Adobe Commerce: Orders API reference. adobe-commerce.redoc.ly/2.4.6-admin/tag/orders
- 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.
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