Repair Refunds, payouts, and reconciliation

Partial refund leaves the tax untouched

A customer returned one item from a bigger order. Someone typed the item's price into the refund box, hit refund, and moved on. The item's price came back, but the tax the customer paid on that item never did. Shopify does not refund tax on its own, it only refunds what you tell it to. Here is why that gap opens up and a small script that finds it and closes it.

Python and Node.js Admin GraphQL API Safe by default (dry run)
A close up of a typewriter with a tax return sign on it
Photo by Markus Winkler on Unsplash
The short answer

A partial refund only moves the money you explicitly include. If a refund is created with just a refund line item price and no tax amount, the customer gets the item's price back but keeps paying tax on money they no longer owe. Run a small Python or Node.js script that reads each order's original tax rate per line item, works out what tax a refund should have included, compares that to what the refund actually shows, and files a follow-up refund for the difference when it clears a small threshold. Full code, tests, and a dry run guard are below.

The problem in plain words

When Shopify processes a refund, it does not recompute tax for you. A refund is built from parts you choose: which line items, how much of each, and separately, how much tax. If the tax part is left out or zeroed, Shopify refunds exactly what was asked for, the item's price, and nothing else. Nothing in the Admin stops you from doing that, and nothing warns you afterward.

The order still shows as partially refunded, and the total refunded amount looks reasonable at a glance. But the tax line on that order did not move. The customer returned the goods and still paid sales tax on them, and if you eventually remit that tax to a tax authority, you are remitting tax on money you already gave back.

Partial refund item price only Item price refunded to buyer tax was never included Tax line unchanged still on the order Customer overpaid tax
The item's price goes back, but if the refund never touched the tax field, the tax line on the order does not move and the customer is left carrying it.

Why it happens

Refunds are built piece by piece, and tax is one of those pieces, not something Shopify tops up automatically. A few common ways stores end up with an untaxed partial refund:

This is a common source of confusion because everything looks fine on the surface. The order shows Partially refunded, the refunded total looks like a real number, and the customer got most of their money back. It takes comparing the refund against the order's original tax rate to see that a slice of tax never moved. See the citations at the end for the exact docs on refunds and tax lines.

The key insight

The tax that should come back on a refund is not a fixed number, it is a rate applied to whatever was actually refunded. So the safe pattern is not "refund a fixed tax amount." It is "work out the original tax rate per line item, apply it to the refunded subtotal, and compare that to what the refund already shows." When the gap is real and bigger than rounding noise, and only then, we file one more refund for the missing tax.

The fix, as a flow

We do not touch new refunds as they happen. We add a job that looks back over recent partially refunded orders, recomputes the tax each existing refund should have included, and compares it to the tax that refund actually recorded. When there is a real, positive gap, it files a small follow-up refund for the missing tax only. A refund that already ties out is left alone.

Scheduled job runs on a timer List refunded orders partially refunded, recent Recompute tax owed from the line item rate Real gap found? yes no, leave it refundCreate tax-only refund
The script only files a follow-up refund when a real, positive tax gap survives its own math. Refunds that already tie out are never touched twice.

Build it step by step

1

Get an Admin API access token

Create a custom app in your Shopify admin under Settings, Apps and sales channels, Develop apps. Give it the read_orders, write_orders, and read_all_orders scopes if you need history beyond sixty days, and install it to get an Admin API access token that starts with shpat_. Keep the token and the shop domain in environment variables, never in the file.

setup (shell)
pip install requests

export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export LOOKBACK_DAYS="14"
export MIN_GAP_CENTS="2"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export LOOKBACK_DAYS="14"
export MIN_GAP_CENTS="2"
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to the Admin GraphQL API

Every call goes to one GraphQL endpoint with your token in the X-Shopify-Access-Token header. A small helper sends a query and returns the data, and raises if Shopify reports an error. We use this same helper to read orders and refunds and to run the follow-up refund mutation.

step2.py
import os, requests

SHOP = os.environ["SHOPIFY_SHOP"]
TOKEN = os.environ["SHOPIFY_ACCESS_TOKEN"]
API_VERSION = os.environ.get("SHOPIFY_API_VERSION", "2025-01")
ENDPOINT = f"https://{SHOP}/admin/api/{API_VERSION}/graphql.json"

def gql(query, variables=None):
    r = requests.post(
        ENDPOINT,
        json={"query": query, "variables": variables or {}},
        headers={"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    if body.get("errors"):
        raise RuntimeError(body["errors"])
    return body["data"]
step2.js
const SHOP = process.env.SHOPIFY_SHOP;
const TOKEN = process.env.SHOPIFY_ACCESS_TOKEN;
const API_VERSION = process.env.SHOPIFY_API_VERSION || "2025-01";
const ENDPOINT = `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`;

async function gql(query, variables = {}) {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: { "X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Shopify ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}
3

List recent orders with partial refunds, in shopMoney

Ask for orders that are partially refunded within a lookback window, and read back each order's line items with their original unit price and tax lines, plus every refund on the order with its refund line items and their subtotal and tax. We read every money value from shopMoney so the numbers are in the shop's own currency, and we page through with a cursor so the job handles a large backlog.

step3.py
ORDERS_QUERY = """
query($cursor: String, $q: String!) {
  orders(first: 25, after: $cursor, query: $q) {
    pageInfo { hasNextPage endCursor }
    nodes {
      id
      name
      lineItems(first: 100) {
        nodes {
          id
          quantity
          originalUnitPriceSet { shopMoney { amount } }
          taxLines { priceSet { shopMoney { amount } } }
        }
      }
      refunds(first: 20) {
        id
        refundLineItems(first: 50) {
          nodes {
            lineItem { id }
            subtotalSet { shopMoney { amount } }
            totalTaxSet { shopMoney { amount } }
          }
        }
      }
    }
  }
}"""

def recent_orders_with_refunds():
    q = f"created_at:>-{LOOKBACK_DAYS}d AND financial_status:partially_refunded"
    cursor = None
    while True:
        data = gql(ORDERS_QUERY, {"cursor": cursor, "q": q})["orders"]
        for node in data["nodes"]:
            yield node
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]
step3.js
const ORDERS_QUERY = `
query($cursor: String, $q: String!) {
  orders(first: 25, after: $cursor, query: $q) {
    pageInfo { hasNextPage endCursor }
    nodes {
      id
      name
      lineItems(first: 100) {
        nodes {
          id
          quantity
          originalUnitPriceSet { shopMoney { amount } }
          taxLines { priceSet { shopMoney { amount } } }
        }
      }
      refunds(first: 20) {
        id
        refundLineItems(first: 50) {
          nodes {
            lineItem { id }
            subtotalSet { shopMoney { amount } }
            totalTaxSet { shopMoney { amount } }
          }
        }
      }
    }
  }
}`;

async function* recentOrdersWithRefunds() {
  const q = `created_at:>-${LOOKBACK_DAYS}d AND financial_status:partially_refunded`;
  let cursor = null;
  while (true) {
    const data = (await gql(ORDERS_QUERY, { cursor, q })).orders;
    for (const node of data.nodes) yield node;
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}
4

Decide, with one pure function, in minor units

Keep the whole decision in functions that take plain data and return a number, no network calls anywhere near them. First we work out each line item's original tax rate, the tax lines divided by the unit price and quantity. Then, for a given refund, we apply that rate to whatever subtotal was actually refunded for that line item, and that is the tax the refund should show. We compare it to the tax the refund actually recorded, in cents throughout so floating point never nudges the answer, and only report a gap once it clears a small threshold and is positive. A refund that already gave back more tax than expected is not a bug this job should touch.

decide.py
def to_cents(amount):
    return round(float(amount) * 100)

def line_item_tax_rate(line_item):
    unit_price = to_cents(line_item["originalUnitPriceSet"]["shopMoney"]["amount"])
    if unit_price <= 0:
        return 0.0
    tax_cents = sum(
        to_cents(t["priceSet"]["shopMoney"]["amount"]) for t in (line_item.get("taxLines") or [])
    )
    quantity = line_item.get("quantity") or 1
    return tax_cents / (unit_price * quantity)

def untaxed_refund_gap_cents(refund, line_items_by_id, min_gap_cents=2):
    lines = refund.get("refundLineItems", {}).get("nodes", [])
    if not lines:
        return 0
    expected = 0.0
    actual = 0
    for rli in lines:
        line_item = line_items_by_id.get(rli["lineItem"]["id"])
        if line_item is None:
            continue
        refunded_subtotal = to_cents(rli["subtotalSet"]["shopMoney"]["amount"])
        expected += refunded_subtotal * line_item_tax_rate(line_item)
        actual += to_cents(rli["totalTaxSet"]["shopMoney"]["amount"])
    gap = round(expected - actual)
    return gap if gap >= min_gap_cents else 0
decide.js
export function toCents(amount) {
  return Math.round(parseFloat(amount) * 100);
}

export function lineItemTaxRate(lineItem) {
  const unitPrice = toCents(lineItem.originalUnitPriceSet.shopMoney.amount);
  if (unitPrice <= 0) return 0;
  const taxCents = (lineItem.taxLines || []).reduce(
    (sum, t) => sum + toCents(t.priceSet.shopMoney.amount), 0
  );
  const quantity = lineItem.quantity || 1;
  return taxCents / (unitPrice * quantity);
}

export function untaxedRefundGapCents(refund, lineItemsById, minGapCents = 2) {
  const lines = refund.refundLineItems?.nodes || [];
  if (!lines.length) return 0;
  let expected = 0;
  let actual = 0;
  for (const rli of lines) {
    const lineItem = lineItemsById[rli.lineItem.id];
    if (!lineItem) continue;
    const refundedSubtotal = toCents(rli.subtotalSet.shopMoney.amount);
    expected += refundedSubtotal * lineItemTaxRate(lineItem);
    actual += toCents(rli.totalTaxSet.shopMoney.amount);
  }
  const gap = Math.round(expected - actual);
  return gap >= minGapCents ? gap : 0;
}
5

File a follow-up refund for the missing tax

When the gap is real, call refundCreate with a manual transaction for exactly the missing tax amount and a note explaining why. This does not touch the original refund, it adds a small second refund that finishes the job the first one skipped. Always read back userErrors. If Shopify refuses, for example because the order was fully refunded already, the error tells you why, and the script should stop on it rather than pretend it worked.

apply.py
REFUND_CREATE = """
mutation($input: RefundInput!) {
  refundCreate(input: $input) {
    refund { id totalRefundedSet { shopMoney { amount } } }
    userErrors { field message }
  }
}"""

def cents_to_amount(cents):
    return f"{cents / 100:.2f}"

def refund_missing_tax(order_id, gap_cents):
    refund_input = {
        "orderId": order_id,
        "note": "Automatic correction: tax portion missed on an earlier partial refund",
        "transactions": [
            {"orderId": order_id, "kind": "REFUND", "gateway": "manual", "amount": cents_to_amount(gap_cents)}
        ],
    }
    result = gql(REFUND_CREATE, {"input": refund_input})["refundCreate"]
    if result["userErrors"]:
        raise RuntimeError(result["userErrors"])
    return result["refund"]
apply.js
const REFUND_CREATE = `
mutation($input: RefundInput!) {
  refundCreate(input: $input) {
    refund { id totalRefundedSet { shopMoney { amount } } }
    userErrors { field message }
  }
}`;

function centsToAmount(cents) {
  return (cents / 100).toFixed(2);
}

async function refundMissingTax(orderId, gapCents) {
  const input = {
    orderId,
    note: "Automatic correction: tax portion missed on an earlier partial refund",
    transactions: [
      { orderId, kind: "REFUND", gateway: "manual", amount: centsToAmount(gapCents) },
    ],
  };
  const result = (await gql(REFUND_CREATE, { input })).refundCreate;
  if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
  return result.refund;
}
6

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only logs which refunds are missing tax and by how much. Read the output, agree with it, then switch it off to let it write. Run it on a schedule that matches how often refunds happen, for example once a day.

Run it safe

Always start with DRY_RUN=true, and keep MIN_GAP_CENTS above zero so a stray cent of rounding never triggers a real refund. A follow-up refund moves real money out of your account, so the tag is your proof the math is real, not a guess.

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 a refund that already ties out never gets a second correction.

View this code on GitHub Full runnable folder with tests in the shopify-fixes repo.

find_untaxed_partial_refunds.py
"""Flag (and optionally fix) Shopify partial refunds that gave back the item but
kept the tax.

A partial refund that only sets a refund line item amount, with no matching
orderAdjustment or transaction for tax, leaves the tax portion sitting on the
order. The customer paid tax on money they no longer owe. This job walks
recent refunds, works out the tax that *should* have come back for each set of
refunded line items (proportional to what the order originally charged), compares
it to the tax Shopify actually refunded, and when the gap is real it issues a
follow-up refund for the missing tax only. Read-only unless DRY_RUN is false.
Run on a schedule. Safe to run again and again, because a refund that already
ties out is left alone.
"""
import os
import logging
import requests

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

SHOP = os.environ["SHOPIFY_SHOP"]
TOKEN = os.environ["SHOPIFY_ACCESS_TOKEN"]
API_VERSION = os.environ.get("SHOPIFY_API_VERSION", "2025-01")
ENDPOINT = f"https://{SHOP}/admin/api/{API_VERSION}/graphql.json"
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "14"))
MIN_GAP_CENTS = int(os.environ.get("MIN_GAP_CENTS", "2"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

ORDERS_QUERY = """
query($cursor: String, $q: String!) {
  orders(first: 25, after: $cursor, query: $q) {
    pageInfo { hasNextPage endCursor }
    nodes {
      id
      name
      currentTotalTaxSet { shopMoney { amount } }
      currentSubtotalLineItemsQuantity
      lineItems(first: 100) {
        nodes {
          id
          quantity
          originalUnitPriceSet { shopMoney { amount } }
          taxLines { priceSet { shopMoney { amount } } }
        }
      }
      refunds(first: 20) {
        id
        createdAt
        totalRefundedSet { shopMoney { amount } }
        refundLineItems(first: 50) {
          nodes {
            quantity
            lineItem { id }
            subtotalSet { shopMoney { amount } }
            totalTaxSet { shopMoney { amount } }
          }
        }
        transactions(first: 10) {
          nodes { kind status amountSet { shopMoney { amount } } }
        }
      }
    }
  }
}"""

REFUND_CREATE = """
mutation($input: RefundInput!) {
  refundCreate(input: $input) {
    refund { id totalRefundedSet { shopMoney { amount } } }
    userErrors { field message }
  }
}"""


def gql(query, variables=None):
    r = requests.post(
        ENDPOINT,
        json={"query": query, "variables": variables or {}},
        headers={"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    if body.get("errors"):
        raise RuntimeError(body["errors"])
    return body["data"]


def to_cents(amount):
    return round(float(amount) * 100)


def line_item_tax_rate(line_item):
    """The tax the order originally charged on one unit, as a fraction of the unit price.

    Returns 0.0 when the line item has no price (avoids a division by zero) or
    carries no tax lines (a tax-exempt product, for example).
    """
    unit_price = to_cents(line_item["originalUnitPriceSet"]["shopMoney"]["amount"])
    if unit_price <= 0:
        return 0.0
    tax_cents = sum(
        to_cents(t["priceSet"]["shopMoney"]["amount"]) for t in (line_item.get("taxLines") or [])
    )
    quantity = line_item.get("quantity") or 1
    return tax_cents / (unit_price * quantity)


def expected_tax_cents_for_refund(refund, line_items_by_id):
    """The tax that should be refunded for one refund, in cents.

    For every refunded line item we take its refunded subtotal (what the
    customer got back for the goods) and apply the original tax rate for that
    line item. This mirrors how Shopify computed the tax in the first place,
    so a refund that already includes tax will match and one that skipped it
    will not.
    """
    total = 0.0
    for rli in refund.get("refundLineItems", {}).get("nodes", []):
        line_item = line_items_by_id.get(rli["lineItem"]["id"])
        if line_item is None:
            continue
        refunded_subtotal_cents = to_cents(rli["subtotalSet"]["shopMoney"]["amount"])
        total += refunded_subtotal_cents * line_item_tax_rate(line_item)
    return total


def actual_tax_refunded_cents(refund):
    return sum(
        to_cents(rli["totalTaxSet"]["shopMoney"]["amount"])
        for rli in refund.get("refundLineItems", {}).get("nodes", [])
    )


def untaxed_refund_gap_cents(refund, line_items_by_id, min_gap_cents=MIN_GAP_CENTS):
    """Pure decision function. Returns the missing tax in cents, or 0 if none is owed.

    A gap only counts when it clears ``min_gap_cents``, so rounding noise of a
    cent or two never triggers a correction. Never returns a negative number:
    if the refund already gave back more tax than expected, that is not this
    job's problem to fix.
    """
    if not refund.get("refundLineItems", {}).get("nodes"):
        return 0
    expected = expected_tax_cents_for_refund(refund, line_items_by_id)
    actual = actual_tax_refunded_cents(refund)
    gap = round(expected - actual)
    if gap < min_gap_cents:
        return 0
    return gap


def order_line_items_by_id(order):
    return {li["id"]: li for li in order["lineItems"]["nodes"]}


def cents_to_amount(cents):
    return f"{cents / 100:.2f}"


def refund_missing_tax(order_id, gap_cents):
    refund_input = {
        "orderId": order_id,
        "note": "Automatic correction: tax portion missed on an earlier partial refund",
        "transactions": [
            {
                "orderId": order_id,
                "kind": "REFUND",
                "gateway": "manual",
                "amount": cents_to_amount(gap_cents),
            }
        ],
    }
    result = gql(REFUND_CREATE, {"input": refund_input})["refundCreate"]
    if result["userErrors"]:
        raise RuntimeError(result["userErrors"])
    return result["refund"]


def recent_orders_with_refunds():
    q = f"created_at:>-{LOOKBACK_DAYS}d AND financial_status:partially_refunded"
    cursor = None
    while True:
        data = gql(ORDERS_QUERY, {"cursor": cursor, "q": q})["orders"]
        for node in data["nodes"]:
            yield node
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]


def run():
    fixed = 0
    for order in recent_orders_with_refunds():
        line_items_by_id = order_line_items_by_id(order)
        for refund in order.get("refunds", []):
            gap_cents = untaxed_refund_gap_cents(refund, line_items_by_id)
            if gap_cents <= 0:
                continue
            log.warning(
                "Order %s refund %s is missing %s of tax. %s",
                order["name"], refund["id"], cents_to_amount(gap_cents),
                "would refund" if DRY_RUN else "refunding",
            )
            if not DRY_RUN:
                refund_missing_tax(order["id"], gap_cents)
            fixed += 1
    log.info("Done. %d refund(s) %s.", fixed, "to correct" if DRY_RUN else "corrected")


if __name__ == "__main__":
    run()
find-untaxed-partial-refunds.js
/**
 * Flag (and optionally fix) Shopify partial refunds that gave back the item but
 * kept the tax.
 *
 * A partial refund that only sets a refund line item amount, with no matching
 * orderAdjustment or transaction for tax, leaves the tax portion sitting on the
 * order. The customer paid tax on money they no longer owe. This job walks
 * recent refunds, works out the tax that should have come back for each set of
 * refunded line items (proportional to what the order originally charged), compares
 * it to the tax Shopify actually refunded, and when the gap is real it issues a
 * follow-up refund for the missing tax only. Read-only unless DRY_RUN is false.
 * Run on a schedule.
 */
import { pathToFileURL } from "node:url";

const SHOP = process.env.SHOPIFY_SHOP || "example.myshopify.com";
const TOKEN = process.env.SHOPIFY_ACCESS_TOKEN || "shpat_dummy";
const API_VERSION = process.env.SHOPIFY_API_VERSION || "2025-01";
const ENDPOINT = `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`;
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 14);
const MIN_GAP_CENTS = Number(process.env.MIN_GAP_CENTS || 2);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function toCents(amount) {
  return Math.round(parseFloat(amount) * 100);
}

export function lineItemTaxRate(lineItem) {
  const unitPrice = toCents(lineItem.originalUnitPriceSet.shopMoney.amount);
  if (unitPrice <= 0) return 0;
  const taxCents = (lineItem.taxLines || []).reduce(
    (sum, t) => sum + toCents(t.priceSet.shopMoney.amount),
    0
  );
  const quantity = lineItem.quantity || 1;
  return taxCents / (unitPrice * quantity);
}

export function expectedTaxCentsForRefund(refund, lineItemsById) {
  let total = 0;
  for (const rli of refund.refundLineItems?.nodes || []) {
    const lineItem = lineItemsById[rli.lineItem.id];
    if (!lineItem) continue;
    const refundedSubtotalCents = toCents(rli.subtotalSet.shopMoney.amount);
    total += refundedSubtotalCents * lineItemTaxRate(lineItem);
  }
  return total;
}

export function actualTaxRefundedCents(refund) {
  return (refund.refundLineItems?.nodes || []).reduce(
    (sum, rli) => sum + toCents(rli.totalTaxSet.shopMoney.amount),
    0
  );
}

export function untaxedRefundGapCents(refund, lineItemsById, minGapCents = MIN_GAP_CENTS) {
  if (!(refund.refundLineItems?.nodes || []).length) return 0;
  const expected = expectedTaxCentsForRefund(refund, lineItemsById);
  const actual = actualTaxRefundedCents(refund);
  const gap = Math.round(expected - actual);
  if (gap < minGapCents) return 0;
  return gap;
}

export function orderLineItemsById(order) {
  const map = {};
  for (const li of order.lineItems.nodes) map[li.id] = li;
  return map;
}

export function centsToAmount(cents) {
  return (cents / 100).toFixed(2);
}

async function gql(query, variables = {}) {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: { "X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Shopify ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}

const ORDERS_QUERY = `
query($cursor: String, $q: String!) {
  orders(first: 25, after: $cursor, query: $q) {
    pageInfo { hasNextPage endCursor }
    nodes {
      id
      name
      currentTotalTaxSet { shopMoney { amount } }
      lineItems(first: 100) {
        nodes {
          id
          quantity
          originalUnitPriceSet { shopMoney { amount } }
          taxLines { priceSet { shopMoney { amount } } }
        }
      }
      refunds(first: 20) {
        id
        createdAt
        totalRefundedSet { shopMoney { amount } }
        refundLineItems(first: 50) {
          nodes {
            quantity
            lineItem { id }
            subtotalSet { shopMoney { amount } }
            totalTaxSet { shopMoney { amount } }
          }
        }
        transactions(first: 10) {
          nodes { kind status amountSet { shopMoney { amount } } }
        }
      }
    }
  }
}`;

const REFUND_CREATE = `
mutation($input: RefundInput!) {
  refundCreate(input: $input) {
    refund { id totalRefundedSet { shopMoney { amount } } }
    userErrors { field message }
  }
}`;

async function* recentOrdersWithRefunds() {
  const q = `created_at:>-${LOOKBACK_DAYS}d AND financial_status:partially_refunded`;
  let cursor = null;
  while (true) {
    const data = (await gql(ORDERS_QUERY, { cursor, q })).orders;
    for (const node of data.nodes) yield node;
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}

async function refundMissingTax(orderId, gapCents) {
  const input = {
    orderId,
    note: "Automatic correction: tax portion missed on an earlier partial refund",
    transactions: [
      { orderId, kind: "REFUND", gateway: "manual", amount: centsToAmount(gapCents) },
    ],
  };
  const result = (await gql(REFUND_CREATE, { input })).refundCreate;
  if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
  return result.refund;
}

export async function run() {
  let fixed = 0;
  for await (const order of recentOrdersWithRefunds()) {
    const lineItemsById = orderLineItemsById(order);
    for (const refund of order.refunds || []) {
      const gapCents = untaxedRefundGapCents(refund, lineItemsById);
      if (gapCents <= 0) continue;
      console.warn(
        `Order ${order.name} refund ${refund.id} is missing ${centsToAmount(gapCents)} of tax. ${
          DRY_RUN ? "would refund" : "refunding"
        }`
      );
      if (!DRY_RUN) await refundMissingTax(order.id, gapCents);
      fixed++;
    }
  }
  console.log(`Done. ${fixed} refund(s) ${DRY_RUN ? "to correct" : "corrected"}.`);
}

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

Add a test

The decision math is the part most worth testing, because it decides whether real money moves out of the account a second time. Because we kept it in pure functions that take plain objects and return a number, the tests need no network and no Shopify account.

test_partial_refund_tax_gap.py
from find_untaxed_partial_refunds import (
    line_item_tax_rate,
    expected_tax_cents_for_refund,
    actual_tax_refunded_cents,
    untaxed_refund_gap_cents,
    to_cents,
)


def line_item(id="gid://shopify/LineItem/1", unit_price="50.00", quantity=1, tax="4.00"):
    return {
        "id": id,
        "quantity": quantity,
        "originalUnitPriceSet": {"shopMoney": {"amount": unit_price}},
        "taxLines": [{"priceSet": {"shopMoney": {"amount": tax}}}] if tax is not None else [],
    }


def refund_line(line_item_id="gid://shopify/LineItem/1", subtotal="50.00", tax="0.00"):
    return {
        "quantity": 1,
        "lineItem": {"id": line_item_id},
        "subtotalSet": {"shopMoney": {"amount": subtotal}},
        "totalTaxSet": {"shopMoney": {"amount": tax}},
    }


def refund(lines):
    return {"refundLineItems": {"nodes": lines}}


def test_line_item_tax_rate_computes_fraction_of_unit_price():
    assert round(line_item_tax_rate(line_item(unit_price="50.00", tax="4.00")), 4) == 0.08


def test_expected_tax_matches_original_rate():
    items = {"gid://shopify/LineItem/1": line_item(unit_price="50.00", tax="4.00")}
    r = refund([refund_line(subtotal="50.00", tax="0.00")])
    assert expected_tax_cents_for_refund(r, items) == 400


def test_gap_detects_tax_left_untouched():
    items = {"gid://shopify/LineItem/1": line_item(unit_price="50.00", tax="4.00")}
    r = refund([refund_line(subtotal="50.00", tax="0.00")])
    assert untaxed_refund_gap_cents(r, items) == 400


def test_gap_is_zero_when_refund_already_tied_out():
    items = {"gid://shopify/LineItem/1": line_item(unit_price="50.00", tax="4.00")}
    r = refund([refund_line(subtotal="50.00", tax="4.00")])
    assert untaxed_refund_gap_cents(r, items) == 0


def test_gap_never_goes_negative_when_tax_over_refunded():
    items = {"gid://shopify/LineItem/1": line_item(unit_price="50.00", tax="4.00")}
    r = refund([refund_line(subtotal="50.00", tax="9.00")])
    assert untaxed_refund_gap_cents(r, items) == 0
untaxed-refund.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import {
  lineItemTaxRate,
  expectedTaxCentsForRefund,
  untaxedRefundGapCents,
} from "./find-untaxed-partial-refunds.js";

const lineItem = ({ id = "gid://shopify/LineItem/1", unitPrice = "50.00", quantity = 1, tax = "4.00" } = {}) => ({
  id, quantity,
  originalUnitPriceSet: { shopMoney: { amount: unitPrice } },
  taxLines: tax === null ? [] : [{ priceSet: { shopMoney: { amount: tax } } }],
});

const refundLine = ({ lineItemId = "gid://shopify/LineItem/1", subtotal = "50.00", tax = "0.00" } = {}) => ({
  quantity: 1,
  lineItem: { id: lineItemId },
  subtotalSet: { shopMoney: { amount: subtotal } },
  totalTaxSet: { shopMoney: { amount: tax } },
});

const refund = (lines) => ({ refundLineItems: { nodes: lines } });

test("lineItemTaxRate computes fraction of unit price", () => {
  assert.equal(Math.round(lineItemTaxRate(lineItem({ unitPrice: "50.00", tax: "4.00" })) * 10000) / 10000, 0.08);
});

test("expectedTaxCentsForRefund matches the original rate", () => {
  const items = { "gid://shopify/LineItem/1": lineItem({ unitPrice: "50.00", tax: "4.00" }) };
  const r = refund([refundLine({ subtotal: "50.00", tax: "0.00" })]);
  assert.equal(expectedTaxCentsForRefund(r, items), 400);
});

test("gap detects tax left untouched", () => {
  const items = { "gid://shopify/LineItem/1": lineItem({ unitPrice: "50.00", tax: "4.00" }) };
  const r = refund([refundLine({ subtotal: "50.00", tax: "0.00" })]);
  assert.equal(untaxedRefundGapCents(r, items), 400);
});

test("gap is zero when the refund already ties out", () => {
  const items = { "gid://shopify/LineItem/1": lineItem({ unitPrice: "50.00", tax: "4.00" }) };
  const r = refund([refundLine({ subtotal: "50.00", tax: "4.00" })]);
  assert.equal(untaxedRefundGapCents(r, items), 0);
});

Case studies

Apparel return

The clothing store that typed in the wrong box

A clothing brand's support team refunded returns straight from the order screen. For months, whoever processed the refund typed the item's price into the refund total and left the tax field alone, since the box was easy to miss. Nothing errored, the order looked partially refunded, and no one noticed.

Running the script in dry run over the last quarter turned up hundreds of dollars of tax still sitting on closed orders. A batch of follow-up refunds cleared it in one pass, and the team added the tax field to their refund checklist so it stopped recurring.

Custom checkout app

The app that only refunded the subtotal

A merchant's custom return portal called refundCreate automatically, but the integration only ever set subtotalSet on the refund line item. It shipped that way for a year before a customer complained their credit card statement still showed the original tax.

Instead of rewriting the whole integration, the merchant ran this script nightly as a safety net. It catches whatever the app still gets wrong on tax, refunds it separately, and gives the team a paper trail while the app itself gets fixed properly.

What good looks like

After this runs on a schedule, a refund that skipped the tax gets a small, clearly noted follow-up refund instead of sitting wrong for months. Customers stop carrying tax on goods they returned, your tax remittance lines up with what you actually kept, and no one has to comb through refund history by hand. Keep MIN_GAP_CENTS above zero so it only ever acts on a gap that is really there.

FAQ

Why does a Shopify partial refund not refund the tax?

Shopify only refunds the tax amount you tell it to refund. If a partial refund is created by only setting a refund line item's price, without also including the tax portion for that line item, the item's price goes back to the customer but the tax stays charged on the order.

Is it safe to automatically refund the missing tax?

Yes, when the script only acts on a real, positive gap between the tax an order should have refunded and the tax it actually refunded, ignores rounding noise of a cent or two, and runs in dry run first so you can review the exact list before it writes anything.

How do you calculate the tax that should have been refunded?

Take the original tax rate for the line item, the tax lines divided by the unit price and quantity, and apply that same rate to the amount that was actually refunded for that line item. This mirrors how Shopify computed the tax in the first place, so a correctly taxed refund always matches and an untaxed one always shows a gap.

Related field notes

Citations

On the problem:

  1. Shopify Help Center: refund an order, including how item price and tax are entered separately. help.shopify.com/en/manual/fulfillment/managing-orders/refund-return-order
  2. Shopify Help Center: how taxes are calculated and displayed on orders. help.shopify.com/en/manual/taxes
  3. Shopify Community: partial refunds that leave the tax line unchanged on the order. community.shopify.com graphql admin api

On the solution:

  1. Shopify Admin GraphQL: the refundCreate mutation and the RefundInput type. shopify.dev/docs/api/admin-graphql/latest/mutations/refundCreate
  2. Shopify Admin GraphQL: the Order object, including lineItems, taxLines, and refunds. shopify.dev/docs/api/admin-graphql/latest/objects/Order
  3. Shopify Admin GraphQL: the RefundLineItem object, including subtotalSet and totalTaxSet. shopify.dev/docs/api/admin-graphql/latest/objects/RefundLineItem

Stuck on a tricky one?

If you have a problem in Shopify orders, payments, subscriptions, 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.

Contact me on LinkedIn

Did this catch a tax gap?

If this saved you from remitting tax on money you already gave back, 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 Shopify field notes