Reconciler Orders
Refunded line items are not returned to inventory automatically
A customer gets refunded, the order shows the money back, and the product page still says the same quantity is available as before the sale, or worse, still zero even though the item never left the shelf. BigCommerce refunds are a payment operation only. Nothing about processing a refund tells the catalog that a unit came back. Here is why that gap opens up and a small reconciler that restocks only the refunds that are genuinely safe to restock.
BigCommerce's refund flow, POST /v3/orders/{order_id}/payment_actions/refunds and the legacy /v2/orders/{id}/transactions path, is scoped purely to reversing the payment with the gateway. It records which line items and quantities were refunded, but it never touches the catalog or inventory subsystem, so inventory_level on /v3/catalog/products and its variants does not move. Refunds are commonly partial, issued out of band by a support agent or a returns app, and do not always mean the item is resellable, so BigCommerce deliberately leaves the restock decision to the merchant. Run a small Python or Node.js reconciler that reads each refunded order's refund items, resolves them to a product or variant, and issues a compensating PUT /v3/inventory/adjustments/relative for the ones that are not flagged as damaged, lost, or not returned. Full code, tests, and a dry run guard are below.
The problem in plain words
In BigCommerce, refunding an order is a payment operation. Calling POST /v3/orders/{order_id}/payment_actions/refunds, or working through the legacy transactions path, captures exactly which line items and quantities were refunded and reverses the charge with the payment gateway. That is the entire job of the endpoint. It has no reason to, and does not, call anything under /v3/catalog/products or the inventory adjustments API.
Stock levels live somewhere completely different: inventory_level and inventory_warning_level on /v3/catalog/products and /v3/catalog/products/{id}/variants. Those fields only change when an order is created or cancelled, when someone issues a direct catalog PUT, or when something calls the dedicated /v3/inventory/adjustments endpoints. A refund is none of those things. So the money goes back to the customer, the order shows Refunded or Partially Refunded, and the shelf count on the product page is exactly what it was the instant before the refund happened, whether that number was right or wrong.
Why it happens
This is not a bug so much as a deliberate boundary between two subsystems that BigCommerce keeps separate on purpose. A few reasons the gap shows up in practice:
- Refunds are commonly partial. A customer might get refunded for one damaged unit out of three, and there is no reliable way for BigCommerce to know from the refund alone whether the other two are still on their way, already delivered, or also coming back.
- Refunds are frequently issued out of band, by a support agent working directly in the control panel, or by a third-party returns app calling the refunds API on its own schedule, disconnected from any inventory logic the merchant might have.
- A refund does not automatically mean the item is restockable. Damaged goods, lost-in-transit claims, and goodwill refunds all reverse the payment while the merchant never gets a sellable unit back.
- Because assuming restockability is unsafe, BigCommerce leaves the "return to stock" decision to the merchant or app rather than auto-incrementing inventory_level on every refund, which means refunded quantity and on-hand stock silently drift apart unless something reconciles them.
Merchants notice this the same way every time: they refund an order, check the product page, and the count has not moved. See the citations at the end for the exact support threads and docs.
A refund is a statement about money, not about a box coming back into the warehouse. So the safe pattern is not "restock everything that gets refunded." It is "read each refund's line items and quantities, resolve them to a concrete product or variant, and restock only the ones not flagged as damaged, lost, or not-yet-returned." We keep a local ledger of refund items already reconciled so the job can re-run safely, and we always adjust inventory relatively, never by overwriting inventory_level outright, so a concurrent sale never gets clobbered.
The fix, as a flow
We do not touch the refund flow itself. We add a reconciler that looks at orders that have been refunded or partially refunded, reads what was actually refunded, and decides per line whether to add the quantity back to stock, skip it because it is flagged, or leave it alone because it was already reconciled.
Build it step by step
Get a store hash and an API access token
Create an API account in your BigCommerce control panel under Settings, API, or use the store's existing app credentials. Grant it Orders (read) and Inventory (modify) scope so it can read refunds and order products, and write inventory adjustments. You need the store hash from your control panel URL and the access token, sent on every call as the X-Auth-Token header. Keep both in environment variables, never in the file.
pip install requests
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export LOOKBACK_DAYS="30"
export LEDGER_PATH="reconciled_refunds.json"
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export LOOKBACK_DAYS="30"
export LEDGER_PATH="reconciled_refunds.json"
export DRY_RUN="true" // start safe, change to false to write
Talk to both the V2 Orders API and the V3 Management API
Refunds and order products live under https://api.bigcommerce.com/stores/{store_hash}/v2/ and the legacy /v2/orders/{id}/transactions path. Inventory adjustments and catalog reads live under /v3/, wrapped in {data, meta}. Both use the same X-Auth-Token header. A small helper handles GET and PUT for each base and raises on a non-2xx response.
import os, requests
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE_V2 = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v2"
API_BASE_V3 = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get(base, path, params=None):
r = requests.get(f"{base}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json() if r.text else []
def bc_put(base, path, body):
r = requests.put(f"{base}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const API_BASE_V2 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v2`;
const API_BASE_V3 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
async function bcGet(base, path, params = {}) {
const url = new URL(`${base}${path}`);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : [];
}
async function bcPut(base, path, body) {
const res = await fetch(`${base}${path}`, { method: "PUT", headers: HEADERS, body: JSON.stringify(body) });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
List refunded orders and read what was actually refunded
Call GET /v2/orders?status_id=4&status_id=14 (or page through with a broader lookback to catch orders that get refunded later) to find candidate orders. For each one, call GET /v3/orders/{order_id}/payment_actions/refunds (falling back to GET /v2/orders/{id}/transactions?type=refund for older stores) to get the refunded items array, then cross-reference against GET /v2/orders/{id}/products to resolve each refund item to a concrete product_id/variant_id and quantity.
REFUNDED = 4
PARTIALLY_REFUNDED = 14
def candidate_orders(lookback_days):
page = 1
while True:
orders = bc_get(API_BASE_V2, "/orders", {
"status_id": f"{REFUNDED},{PARTIALLY_REFUNDED}",
"min_date_created": f"-{lookback_days} days",
"page": page,
"limit": 50,
})
if not orders:
return
for order in orders:
yield order
page += 1
def order_refunds(order_id):
data = bc_get(API_BASE_V3, f"/orders/{order_id}/payment_actions/refunds")
return data.get("data", []) if isinstance(data, dict) else data
def order_products(order_id):
return bc_get(API_BASE_V2, f"/orders/{order_id}/products")
const REFUNDED = 4;
const PARTIALLY_REFUNDED = 14;
async function* candidateOrders(lookbackDays) {
let page = 1;
while (true) {
const orders = await bcGet(API_BASE_V2, "/orders", {
status_id: `${REFUNDED},${PARTIALLY_REFUNDED}`,
min_date_created: `-${lookbackDays} days`,
page,
limit: 50,
});
if (!orders.length) return;
for (const order of orders) yield order;
page += 1;
}
}
async function orderRefunds(orderId) {
const data = await bcGet(API_BASE_V3, `/orders/${orderId}/payment_actions/refunds`);
return Array.isArray(data) ? data : data.data || [];
}
async function orderProducts(orderId) {
return bcGet(API_BASE_V2, `/orders/${orderId}/products`);
}
Decide, with one pure function
Keep the decision in its own function that takes the resolved refund lines, a ledger of refund item ids already reconciled, and a map of which lines are flagged as non-restockable. It returns the exact list of compensating adjustments to make, one per unreconciled and unflagged line, each with a positive adjustment equal to the refunded quantity. No I/O, just set membership and arithmetic, so it is trivial to unit test.
def compute_restock_adjustments(refunded_lines, reconciled_ledger, skip_flags):
adjustments = []
for line in refunded_lines:
refund_item_id = line["refund_item_id"]
if refund_item_id in reconciled_ledger:
continue
if skip_flags.get(refund_item_id):
continue
if line["quantity"] <= 0:
continue
adjustments.append({
"product_id": line["product_id"],
"variant_id": line.get("variant_id"),
"adjustment": line["quantity"],
"refund_item_id": refund_item_id,
"order_id": line["order_id"],
})
return adjustments
export function computeRestockAdjustments(refundedLines, reconciledLedger, skipFlags) {
const adjustments = [];
for (const line of refundedLines) {
const refundItemId = line.refund_item_id;
if (reconciledLedger.has(refundItemId)) continue;
if (skipFlags[refundItemId]) continue;
if (line.quantity <= 0) continue;
adjustments.push({
product_id: line.product_id,
variant_id: line.variant_id ?? null,
adjustment: line.quantity,
refund_item_id: refundItemId,
order_id: line.order_id,
});
}
return adjustments;
}
Write compensating stock back with a relative adjustment
For each adjustment the pure function returns, call PUT /v3/inventory/adjustments/relative with {"reason": "refund-restock-reconciliation", "items": [{"product_id": ..., "variant_id": ..., "adjustment": ...}]}. This is a relative bump, not an absolute set, so a sale that happens between your read and your write is never overwritten. After a successful write, append the refund_item_id to the reconciled ledger so a re-run is a no-op for that line.
def apply_adjustment(adjustment):
body = {
"reason": "refund-restock-reconciliation",
"items": [{
"product_id": adjustment["product_id"],
"variant_id": adjustment["variant_id"],
"adjustment": adjustment["adjustment"],
}],
}
return bc_put(API_BASE_V3, "/inventory/adjustments/relative", body)
async function applyAdjustment(adjustment) {
const body = {
reason: "refund-restock-reconciliation",
items: [{
product_id: adjustment.product_id,
variant_id: adjustment.variant_id,
adjustment: adjustment.adjustment,
}],
};
return bcPut(API_BASE_V3, "/inventory/adjustments/relative", body);
}
Wire it together with a dry run guard and a non-restockable check
The loop resolves every refunded order into lines, checks each line's order notes or a custom field for a damaged, lost, or return-not-received marker before it ever reaches the pure function, then applies whatever the pure function decides. On the first few runs, leave DRY_RUN on so the script only logs the {product_id, variant_id, order_id, refund_item_id, adjustment} tuple for each line it would restock. Read the output, agree with it, then switch it off. Run it on a schedule, for example once a day.
Always start with DRY_RUN=true, and never auto-restock a refund line tied to an order with a damaged, lost, or return-not-received note. Check order notes or a custom field first, and flag those for human review instead of assuming the item is coming back to the shelf.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only restocks refund lines that are unreconciled in the ledger and not flagged as non-restockable.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Restock BigCommerce inventory for refunded line items that are safe to restock.
BigCommerce's refund flow, POST /v3/orders/{order_id}/payment_actions/refunds and
the legacy /v2/orders/{id}/transactions path, is scoped purely to reversing the
payment with the gateway. It records which line items and quantities were
refunded but never touches the catalog or inventory subsystem. Stock levels
(inventory_level, inventory_warning_level) live on /v3/catalog/products and its
variants and only change from order creation or cancellation triggers, direct
catalog PUTs, or the dedicated /v3/inventory/adjustments endpoints. Because
refunds are commonly partial, issued out of band, and do not always mean the
item is restockable (damaged, lost in transit, goodwill refund), BigCommerce
leaves the restock decision to the merchant, so refunded quantity and on-hand
stock silently drift apart unless something reconciles them.
This job lists orders at status_id 4 (Refunded) or 14 (Partially Refunded),
reads each order's refunds, resolves them to product_id/variant_id and
quantity, and restocks only the lines that are not already reconciled and not
flagged as damaged, lost, or return-not-received. Run on a schedule. Safe to
run again and again because reconciled refund_item_ids are recorded in a local
ledger.
Guide: https://www.allanninal.dev/bigcommerce/refund-does-not-restock-inventory/
"""
import json
import logging
import os
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("restock_refunded_inventory")
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE_V2 = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v2"
API_BASE_V3 = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "30"))
LEDGER_PATH = os.environ.get("LEDGER_PATH", "reconciled_refunds.json")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
REFUNDED = 4
PARTIALLY_REFUNDED = 14
NON_RESTOCKABLE_MARKERS = ("damaged", "lost", "return not received", "not returned")
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get(base, path, params=None):
r = requests.get(f"{base}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
if not r.text:
return []
return r.json()
def bc_put(base, path, body):
r = requests.put(f"{base}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
def compute_restock_adjustments(refunded_lines, reconciled_ledger, skip_flags):
"""Pure decision. No network, no side effects.
refunded_lines: [{refund_item_id, order_id, product_id, variant_id, quantity}]
reconciled_ledger: set of refund_item_id already compensated in a prior run.
skip_flags: refund_item_id -> True if the order/line is flagged non-restockable
(damaged, lost, or return not received).
Returns one adjustment per line that is unreconciled and not flagged, with
adjustment equal to quantity (always > 0). Lines with a non-positive
quantity are skipped defensively.
"""
adjustments = []
for line in refunded_lines:
refund_item_id = line["refund_item_id"]
if refund_item_id in reconciled_ledger:
continue
if skip_flags.get(refund_item_id):
continue
quantity = line["quantity"]
if quantity <= 0:
continue
adjustments.append({
"product_id": line["product_id"],
"variant_id": line.get("variant_id"),
"adjustment": quantity,
"refund_item_id": refund_item_id,
"order_id": line["order_id"],
})
return adjustments
def candidate_orders():
"""Page through orders currently Refunded or Partially Refunded."""
page = 1
while True:
orders = bc_get(
API_BASE_V2,
"/orders",
{
"status_id": f"{REFUNDED},{PARTIALLY_REFUNDED}",
"min_date_created": f"-{LOOKBACK_DAYS} days",
"page": page,
"limit": 50,
},
)
if not orders:
return
for order in orders:
yield order
page += 1
def order_refunds(order_id):
data = bc_get(API_BASE_V3, f"/orders/{order_id}/payment_actions/refunds")
if isinstance(data, dict):
return data.get("data", [])
return data or []
def order_products(order_id):
return bc_get(API_BASE_V2, f"/orders/{order_id}/products")
def order_is_flagged_non_restockable(order_id):
"""Check order notes for a damaged/lost/return-not-received marker.
Real stores may keep this on a custom field instead; adapt as needed.
"""
notes = bc_get(API_BASE_V2, f"/orders/{order_id}") or {}
staff_notes = (notes.get("staff_notes") or "").lower()
customer_message = (notes.get("customer_message") or "").lower()
combined = f"{staff_notes} {customer_message}"
return any(marker in combined for marker in NON_RESTOCKABLE_MARKERS)
def resolve_refunded_lines(order_id):
"""Resolve each refund item to a concrete product_id/variant_id and quantity."""
refunds = order_refunds(order_id)
products_by_item_id = {p["id"]: p for p in (order_products(order_id) or [])}
lines = []
for refund in refunds:
for item in refund.get("items", []):
if item.get("item_type") != "PRODUCT":
continue
order_product = products_by_item_id.get(item.get("item_id"))
if not order_product:
continue
lines.append({
"refund_item_id": f"{refund.get('id')}:{item.get('item_id')}",
"order_id": order_id,
"product_id": order_product.get("product_id"),
"variant_id": order_product.get("variant_id"),
"quantity": item.get("quantity", 0),
})
return lines
def apply_adjustment(adjustment):
body = {
"reason": "refund-restock-reconciliation",
"items": [{
"product_id": adjustment["product_id"],
"variant_id": adjustment["variant_id"],
"adjustment": adjustment["adjustment"],
}],
}
return bc_put(API_BASE_V3, "/inventory/adjustments/relative", body)
def load_ledger():
if not os.path.exists(LEDGER_PATH):
return set()
with open(LEDGER_PATH, "r") as f:
return set(json.load(f))
def save_ledger(ledger):
with open(LEDGER_PATH, "w") as f:
json.dump(sorted(ledger), f)
def run():
ledger = load_ledger()
restocked = 0
skipped_flagged = 0
for order in candidate_orders():
order_id = order["id"]
lines = resolve_refunded_lines(order_id)
if not lines:
continue
flagged = order_is_flagged_non_restockable(order_id)
skip_flags = {line["refund_item_id"]: flagged for line in lines}
if flagged:
skipped_flagged += len(lines)
adjustments = compute_restock_adjustments(lines, ledger, skip_flags)
for adjustment in adjustments:
log.info(
"product_id=%s variant_id=%s order_id=%s refund_item_id=%s adjustment=%s (%s)",
adjustment["product_id"], adjustment["variant_id"], adjustment["order_id"],
adjustment["refund_item_id"], adjustment["adjustment"],
"dry run" if DRY_RUN else "restocking",
)
if not DRY_RUN:
apply_adjustment(adjustment)
ledger.add(adjustment["refund_item_id"])
restocked += 1
if not DRY_RUN:
save_ledger(ledger)
log.info(
"Done. %d line(s) %s, %d line(s) skipped as flagged non-restockable.",
restocked, "to restock" if DRY_RUN else "restocked", skipped_flagged,
)
if __name__ == "__main__":
run()
/**
* Restock BigCommerce inventory for refunded line items that are safe to restock.
*
* BigCommerce's refund flow, POST /v3/orders/{order_id}/payment_actions/refunds
* and the legacy /v2/orders/{id}/transactions path, is scoped purely to
* reversing the payment with the gateway. It records which line items and
* quantities were refunded but never touches the catalog or inventory
* subsystem. Stock levels (inventory_level, inventory_warning_level) live on
* /v3/catalog/products and its variants and only change from order creation
* or cancellation triggers, direct catalog PUTs, or the dedicated
* /v3/inventory/adjustments endpoints. Because refunds are commonly partial,
* issued out of band, and do not always mean the item is restockable
* (damaged, lost in transit, goodwill refund), BigCommerce leaves the restock
* decision to the merchant, so refunded quantity and on-hand stock silently
* drift apart unless something reconciles them.
*
* This job lists orders at status_id 4 (Refunded) or 14 (Partially Refunded),
* reads each order's refunds, resolves them to product_id/variant_id and
* quantity, and restocks only the lines that are not already reconciled and
* not flagged as damaged, lost, or return-not-received. Run on a schedule.
*
* Guide: https://www.allanninal.dev/bigcommerce/refund-does-not-restock-inventory/
*/
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { pathToFileURL } from "node:url";
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example_hash";
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "bc_dummy";
const API_BASE_V2 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v2`;
const API_BASE_V3 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 30);
const LEDGER_PATH = process.env.LEDGER_PATH || "reconciled_refunds.json";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const REFUNDED = 4;
const PARTIALLY_REFUNDED = 14;
const NON_RESTOCKABLE_MARKERS = ["damaged", "lost", "return not received", "not returned"];
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
/**
* Pure decision. No network, no side effects.
*
* refundedLines: [{refund_item_id, order_id, product_id, variant_id, quantity}]
* reconciledLedger: Set of refund_item_id already compensated in a prior run.
* skipFlags: refund_item_id -> true if the order/line is flagged non-restockable
* (damaged, lost, or return not received).
*
* Returns one adjustment per line that is unreconciled and not flagged, with
* adjustment equal to quantity (always > 0). Lines with a non-positive
* quantity are skipped defensively.
*/
export function computeRestockAdjustments(refundedLines, reconciledLedger, skipFlags) {
const adjustments = [];
for (const line of refundedLines) {
const refundItemId = line.refund_item_id;
if (reconciledLedger.has(refundItemId)) continue;
if (skipFlags[refundItemId]) continue;
const quantity = line.quantity;
if (quantity <= 0) continue;
adjustments.push({
product_id: line.product_id,
variant_id: line.variant_id ?? null,
adjustment: quantity,
refund_item_id: refundItemId,
order_id: line.order_id,
});
}
return adjustments;
}
async function bcGet(base, path, params = {}) {
const url = new URL(`${base}${path}`);
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) url.searchParams.set(key, value);
}
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : [];
}
async function bcPut(base, path, body) {
const res = await fetch(`${base}${path}`, {
method: "PUT",
headers: HEADERS,
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function* candidateOrders() {
let page = 1;
while (true) {
const orders = await bcGet(API_BASE_V2, "/orders", {
status_id: `${REFUNDED},${PARTIALLY_REFUNDED}`,
min_date_created: `-${LOOKBACK_DAYS} days`,
page,
limit: 50,
});
if (!orders.length) return;
for (const order of orders) yield order;
page += 1;
}
}
async function orderRefunds(orderId) {
const data = await bcGet(API_BASE_V3, `/orders/${orderId}/payment_actions/refunds`);
if (Array.isArray(data)) return data;
return data.data || [];
}
async function orderProducts(orderId) {
return bcGet(API_BASE_V2, `/orders/${orderId}/products`);
}
async function orderIsFlaggedNonRestockable(orderId) {
const order = (await bcGet(API_BASE_V2, `/orders/${orderId}`)) || {};
const staffNotes = (order.staff_notes || "").toLowerCase();
const customerMessage = (order.customer_message || "").toLowerCase();
const combined = `${staffNotes} ${customerMessage}`;
return NON_RESTOCKABLE_MARKERS.some((marker) => combined.includes(marker));
}
async function resolveRefundedLines(orderId) {
const refunds = await orderRefunds(orderId);
const products = (await orderProducts(orderId)) || [];
const productsByItemId = new Map(products.map((p) => [p.id, p]));
const lines = [];
for (const refund of refunds) {
for (const item of refund.items || []) {
if (item.item_type !== "PRODUCT") continue;
const orderProduct = productsByItemId.get(item.item_id);
if (!orderProduct) continue;
lines.push({
refund_item_id: `${refund.id}:${item.item_id}`,
order_id: orderId,
product_id: orderProduct.product_id,
variant_id: orderProduct.variant_id,
quantity: item.quantity || 0,
});
}
}
return lines;
}
async function applyAdjustment(adjustment) {
const body = {
reason: "refund-restock-reconciliation",
items: [{
product_id: adjustment.product_id,
variant_id: adjustment.variant_id,
adjustment: adjustment.adjustment,
}],
};
return bcPut(API_BASE_V3, "/inventory/adjustments/relative", body);
}
function loadLedger() {
if (!existsSync(LEDGER_PATH)) return new Set();
return new Set(JSON.parse(readFileSync(LEDGER_PATH, "utf8")));
}
function saveLedger(ledger) {
writeFileSync(LEDGER_PATH, JSON.stringify([...ledger].sort()));
}
export async function run() {
const ledger = loadLedger();
let restocked = 0;
let skippedFlagged = 0;
for await (const order of candidateOrders()) {
const orderId = order.id;
const lines = await resolveRefundedLines(orderId);
if (!lines.length) continue;
const flagged = await orderIsFlaggedNonRestockable(orderId);
const skipFlags = {};
for (const line of lines) skipFlags[line.refund_item_id] = flagged;
if (flagged) skippedFlagged += lines.length;
const adjustments = computeRestockAdjustments(lines, ledger, skipFlags);
for (const adjustment of adjustments) {
console.log(
`product_id=${adjustment.product_id} variant_id=${adjustment.variant_id} ` +
`order_id=${adjustment.order_id} refund_item_id=${adjustment.refund_item_id} ` +
`adjustment=${adjustment.adjustment} (${DRY_RUN ? "dry run" : "restocking"})`
);
if (!DRY_RUN) {
await applyAdjustment(adjustment);
ledger.add(adjustment.refund_item_id);
}
restocked += 1;
}
}
if (!DRY_RUN) saveLedger(ledger);
console.log(
`Done. ${restocked} line(s) ${DRY_RUN ? "to restock" : "restocked"}, ` +
`${skippedFlagged} line(s) skipped as flagged non-restockable.`
);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which SKUs get sellable stock back. Because compute_restock_adjustments takes only plain values, a list, a set, and a dict, and returns a plain list, the test needs no network and no BigCommerce store. It just feeds in plain dicts and sets and checks the answer.
from restock_refunded_inventory import compute_restock_adjustments
def refund_line(refund_item_id="r1:100", order_id=1, product_id=100, variant_id=None, quantity=2):
return {
"refund_item_id": refund_item_id,
"order_id": order_id,
"product_id": product_id,
"variant_id": variant_id,
"quantity": quantity,
}
def test_restocks_an_unreconciled_unflagged_line():
result = compute_restock_adjustments([refund_line()], reconciled_ledger=set(), skip_flags={})
assert result == [{
"product_id": 100, "variant_id": None, "adjustment": 2,
"refund_item_id": "r1:100", "order_id": 1,
}]
def test_skips_a_line_already_in_the_ledger():
result = compute_restock_adjustments(
[refund_line()], reconciled_ledger={"r1:100"}, skip_flags={}
)
assert result == []
def test_skips_a_line_flagged_non_restockable():
result = compute_restock_adjustments(
[refund_line()], reconciled_ledger=set(), skip_flags={"r1:100": True}
)
assert result == []
def test_skips_a_line_with_zero_or_negative_quantity():
result = compute_restock_adjustments(
[refund_line(quantity=0)], reconciled_ledger=set(), skip_flags={}
)
assert result == []
def test_handles_multiple_lines_independently():
lines = [
refund_line(refund_item_id="r1:100", product_id=100, quantity=2),
refund_line(refund_item_id="r1:200", product_id=200, quantity=1),
]
result = compute_restock_adjustments(
lines, reconciled_ledger={"r1:200"}, skip_flags={}
)
assert len(result) == 1
assert result[0]["product_id"] == 100
assert result[0]["adjustment"] == 2
def test_preserves_variant_id_when_present():
result = compute_restock_adjustments(
[refund_line(variant_id=555)], reconciled_ledger=set(), skip_flags={}
)
assert result[0]["variant_id"] == 555
import { test } from "node:test";
import assert from "node:assert/strict";
import { computeRestockAdjustments } from "./restock-refunded-inventory.js";
const refundLine = ({
refund_item_id = "r1:100", order_id = 1, product_id = 100, variant_id = null, quantity = 2,
} = {}) => ({ refund_item_id, order_id, product_id, variant_id, quantity });
test("restocks an unreconciled unflagged line", () => {
const result = computeRestockAdjustments([refundLine()], new Set(), {});
assert.deepEqual(result, [{
product_id: 100, variant_id: null, adjustment: 2,
refund_item_id: "r1:100", order_id: 1,
}]);
});
test("skips a line already in the ledger", () => {
const result = computeRestockAdjustments([refundLine()], new Set(["r1:100"]), {});
assert.deepEqual(result, []);
});
test("skips a line flagged non-restockable", () => {
const result = computeRestockAdjustments([refundLine()], new Set(), { "r1:100": true });
assert.deepEqual(result, []);
});
test("skips a line with zero or negative quantity", () => {
const result = computeRestockAdjustments([refundLine({ quantity: 0 })], new Set(), {});
assert.deepEqual(result, []);
});
test("handles multiple lines independently", () => {
const lines = [
refundLine({ refund_item_id: "r1:100", product_id: 100, quantity: 2 }),
refundLine({ refund_item_id: "r1:200", product_id: 200, quantity: 1 }),
];
const result = computeRestockAdjustments(lines, new Set(["r1:200"]), {});
assert.equal(result.length, 1);
assert.equal(result[0].product_id, 100);
assert.equal(result[0].adjustment, 2);
});
test("preserves variant_id when present", () => {
const result = computeRestockAdjustments([refundLine({ variant_id: 555 })], new Set(), {});
assert.equal(result[0].variant_id, 555);
});
Case studies
The store that could not explain its stock counts
A mid-size apparel store noticed that its best sellers kept showing lower availability than the warehouse actually had. No one had touched the catalog. The gap traced back to months of partial refunds, mostly wrong-size exchanges, where support had processed the refund in the control panel and moved on, assuming inventory would sort itself out.
Now the reconciler runs nightly. It reads every refund from the last 30 days, resolves each line to a SKU, and adds the refunded quantity back for anything not flagged. Staff no longer manually adjust stock counts to compensate for a gap they could not previously trace.
The support team that almost oversold a damaged return
A customer received a cracked item and got refunded in full. The support agent left a note on the order: damaged, not being returned. Without a check for that note, an earlier version of the reconciler would have added the unit straight back into sellable stock, and the next buyer would have received nothing.
Because the job checks order notes for a damaged, lost, or return-not-received marker before it ever reaches the pure decision function, that line was flagged and skipped automatically. The refund happened, the stock count stayed honest, and no one had to catch the mistake after the fact.
After this runs on a schedule, refunded stock reappears on the shelf within one run of the refund actually happening, whether it was processed in the control panel, through a returns app, or via the raw refunds API. Anything flagged as damaged, lost, or not returned is skipped every time, and a local ledger keeps every run idempotent, so re-running the job never double-counts a single unit.
FAQ
Why does refunding an order in BigCommerce not put the item back in stock?
The refund endpoints only reverse the payment with the gateway. They record which line items and quantities were refunded, but they never call the catalog or inventory endpoints. Stock levels live on the product and variant records and only change from order creation or cancellation triggers, direct catalog edits, or the inventory adjustments endpoint, so a refund by itself leaves inventory_level exactly where it was.
Is it safe to auto-restock every refunded line item?
No. A refund does not always mean the item is coming back to be resold. Damaged goods, lost-in-transit claims, and goodwill refunds are all refunds where the merchant never gets sellable stock back. Only auto-restock lines that are not flagged with a damaged, lost, or return-not-received note, and route anything flagged to a human instead of assuming it is restockable.
Why use a relative inventory adjustment instead of just setting inventory_level directly?
Between the moment you read inventory_level and the moment you write it back, other orders can sell through the same stock. Writing an absolute inventory_level clobbers whatever sold in between. Adjusting relatively, such as adding back the refunded quantity through the dedicated adjustments endpoint, is safe under concurrent sales because it never overwrites a number you did not just read.
Related field notes
Citations
On the problem:
- BigCommerce Developer Center: Order Refunds overview and scope. developer.bigcommerce.com order refunds
- BigCommerce Community: does refunding an item put it back in inventory automatically? support.bigcommerce.com does refunding an item restock it
- BigCommerce Help Center: Processing Refunds. support.bigcommerce.com processing refunds
On the solution:
- BigCommerce API Reference: Create Order Refund. docs.bigcommerce.com create order refund
- BigCommerce Developer Center: Inventory Adjustments. developer.bigcommerce.com inventory adjustments
- BigCommerce API Reference: List Order Products. docs.bigcommerce.com list order products
Stuck on a tricky one?
If you have a problem in BigCommerce orders, payments, webhooks, inventory, or fulfillment 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 drift you did not know about?
If this saved you from overselling a damaged return, or explained a stock count you could not otherwise account for, 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