Skip to content

Diagnostic Products, Variants & Channels

Variant cost price miscalculates with multiple stock rows

A variant sits in two or more warehouses, and someone asks a simple question: what does this thing actually cost us. In old Saleor, that question could crash outright the moment one warehouse never had a cost entered. In current Saleor, the crash is gone, but the same instinct, trying to work out cost from a pile of Stock rows, still produces nonsense: a null cost price sitting next to a real selling price, or a margin so far off it cannot be true. Here is why the original bug happened, why the fix was to move cost off Stock entirely, and a script that audits your variants for the same signature today.

Python and Node.js Saleor GraphQL API Report-only diagnostic
A labeled box on shelves
Photo by Egor Litvinov on Unsplash
The short answer

In legacy, pre-3.0 Saleor, ProductVariant.get_cost_price() picked the cheapest stock record by sorting every Stock row for a variant on its optional cost_price field. When a variant had two or more stock rows and at least one had cost_price set to None, Python's comparison of None against None raised TypeError: unorderable types: NoneType() < NoneType() (GitHub issue #1011). Saleor's architecture later removed per-stock cost altogether: cost price now lives solely on ProductVariantChannelListing.costPrice, one decimal value per channel, fully decoupled from Stock and warehouse quantity rows. Any script or integration that still tries to reconstruct a variant's cost by reading multiple Stock rows reproduces the same class of bug. Run a small Python or Node.js script that queries variants with their stocks and channel listings together, flags every row where costPrice is null while price is set, or where the margin is negative or absurd, and reports it for a human to confirm. Full code, tests, and citations are below.

The problem in plain words

Saleor's original data model kept cost on the warehouse stock record itself. Every Stock row, one per variant per warehouse, carried its own optional cost_price. To answer "what does this variant cost," the code took every stock row for that variant, sorted them by cost_price, and returned the cheapest one, on the theory that you would always want to know your best-case cost.

That sort is where it broke. cost_price was optional. A variant stocked in two warehouses where only one warehouse manager had bothered to fill in a cost meant the sort compared a real number against None in one pair, and None against None in another, since Python has no defined ordering between two None values. Python raised TypeError: unorderable types: NoneType() < NoneType(), and the entire cost lookup for that variant failed, not just the display of a missing number.

Warehouse A stock cost_price = 4.50 Warehouse B stock cost_price = None sorted(stocks, key=cost_price) None < None TypeError: unorderable types: NoneType() < NoneType() get_cost_price() crashes
One warehouse with an unset cost was enough to crash the whole lookup, because sorting stocks by cost_price compares None against None.

Why it happens

Saleor's own stock model documents that Stock exists to track quantity and allocation per warehouse, nothing about cost, which is exactly the mismatch behind this whole class of bug. See the citations at the end for the exact issue threads and docs.

The key insight

Cost price is not a per-warehouse fact in current Saleor, it is a per-channel fact. There is one costPrice per ProductVariantChannelListing, full stop, independent of how many Stock rows exist for that variant. So the fix is not a smarter sort, and it is not "average the stocks" or "ignore the nulls." It is to stop reading Stock for cost entirely and treat ProductVariantChannelListing.costPrice as the only source of truth. A script that still sorts or aggregates stocks to get a cost is the bug, whether or not it happens to crash.

The fix, as a flow

We do not try to compute a better cost from stock rows. We query each variant once, with its stocks and its channel listings side by side, cross-check the stock rows against warehouses that are actually linked to the channel, and classify each channel listing as fine, missing a cost while a price exists, or carrying a margin that cannot be right. Nothing gets written automatically. Every flagged row is a line in a report for a human to confirm.

Scheduled audit runs on a timer Query variants with stocks + channelListings Classify per channel missing cost, bad margin flagged? yes no, ok Report for human confirm, then guarded productVariantChannelListingUpdate
The audit only classifies and reports. It never picks a stock cost for you, because current Saleor has no per-stock cost to pick from.

Build it step by step

1

Get an app token with read access to products, and write access only if you will apply fixes

Create an app in Saleor Dashboard under Configuration, Apps, and give it permission to read products, channels, and warehouses. The audit only needs read access. Add MANAGE_PRODUCTS only on a separate token you will use later to run the confirmed fix mutation. Keep the API URL and token in environment variables, never in the file.

setup (shell)
pip install requests

export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your-app-or-staff-token"
export SALEOR_CHANNEL="default-channel"
export DRY_RUN="true"   # start safe, this script never writes without it off
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your-app-or-staff-token"
export SALEOR_CHANNEL="default-channel"
export DRY_RUN="true"   // start safe, this script never writes without it off
2

Talk to the Saleor GraphQL API

Saleor is one GraphQL endpoint. Every call is a POST with a JSON body of {query, variables} and an Authorization: Bearer <token> header. A small helper sends a query and returns the data, raising if Saleor reports errors.

step2.py
import os, requests

API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]

def gql(query, variables=None):
    r = requests.post(
        API_URL,
        json={"query": query, "variables": variables or {}},
        headers={"Authorization": f"Bearer {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 API_URL = process.env.SALEOR_API_URL;
const TOKEN = process.env.SALEOR_AUTH_TOKEN;

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

Query variants with their stocks and channel listings together

Ask for productVariants on a channel, reading back stocks, which shows every warehouse quantity row, alongside channelListings, which is the only place price and costPrice actually live. Page with a cursor so the audit handles a full catalog.

step3.py
VARIANT_AUDIT_QUERY = """
query($channel: String, $cursor: String) {
  productVariants(first: 100, after: $cursor, channel: $channel) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        sku
        stocks {
          id
          quantity
          quantityAllocated
          warehouse { id name }
        }
        channelListings {
          id
          channel { id slug }
          price { amount currency }
          costPrice { amount currency }
        }
      }
    }
  }
}"""

def variant_snapshot(channel):
    cursor = None
    rows = []
    while True:
        data = gql(VARIANT_AUDIT_QUERY, {"channel": channel, "cursor": cursor})["productVariants"]
        for edge in data["edges"]:
            node = edge["node"]
            rows.append({
                "id": node["id"],
                "sku": node["sku"],
                "stockCount": len(node["stocks"]),
                "channelListings": [
                    {
                        "channelSlug": cl["channel"]["slug"],
                        "price": cl["price"]["amount"] if cl["price"] else None,
                        "costPrice": cl["costPrice"]["amount"] if cl["costPrice"] else None,
                    }
                    for cl in node["channelListings"]
                ],
            })
        if not data["pageInfo"]["hasNextPage"]:
            return rows
        cursor = data["pageInfo"]["endCursor"]
step3.js
const VARIANT_AUDIT_QUERY = `
query($channel: String, $cursor: String) {
  productVariants(first: 100, after: $cursor, channel: $channel) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        sku
        stocks {
          id
          quantity
          quantityAllocated
          warehouse { id name }
        }
        channelListings {
          id
          channel { id slug }
          price { amount currency }
          costPrice { amount currency }
        }
      }
    }
  }
}`;

async function variantSnapshot(channel) {
  let cursor = null;
  const rows = [];
  while (true) {
    const data = (await gql(VARIANT_AUDIT_QUERY, { channel, cursor })).productVariants;
    for (const edge of data.edges) {
      const node = edge.node;
      rows.push({
        id: node.id,
        sku: node.sku,
        stockCount: node.stocks.length,
        channelListings: node.channelListings.map((cl) => ({
          channelSlug: cl.channel.slug,
          price: cl.price ? cl.price.amount : null,
          costPrice: cl.costPrice ? cl.costPrice.amount : null,
        })),
      });
    }
    if (!data.pageInfo.hasNextPage) return rows;
    cursor = data.pageInfo.endCursor;
  }
}
4

Decide, with one pure function

Keep the decision in its own function that takes a variant's stock count and channel listings and returns a classification per channel. It never picks a cost. It only flags a channel as flag_missing_cost when there are multiple stock rows and costPrice is null while price is set, exactly the shape of the original None-vs-None failure, or flag_negative_margin when costPrice is set but is greater than price, which means the margin would be negative. Anything else is ok.

decide.py
def decide_cost_price_fix(variant):
    stock_count = variant["stockCount"]
    results = []
    for listing in variant["channelListings"]:
        slug = listing["channelSlug"]
        price = listing["price"]
        cost_price = listing["costPrice"]
        if stock_count > 1 and cost_price is None and price is not None:
            results.append({
                "channelSlug": slug,
                "action": "flag_missing_cost",
                "reason": "multiple stock rows and a null cost price on a priced channel, "
                          "the same shape as the pre-3.0 None-vs-None sort failure (issue #1011)",
            })
        elif cost_price is not None and price is not None and cost_price > price:
            results.append({
                "channelSlug": slug,
                "action": "flag_negative_margin",
                "reason": "cost price is greater than price, margin would be negative",
            })
        else:
            results.append({"channelSlug": slug, "action": "ok", "reason": "cost price looks consistent"})
    return results
decide.js
export function decideCostPriceFix(variant) {
  const stockCount = variant.stockCount;
  const results = [];
  for (const listing of variant.channelListings) {
    const slug = listing.channelSlug;
    const price = listing.price;
    const costPrice = listing.costPrice;
    if (stockCount > 1 && (costPrice === null || costPrice === undefined) && price !== null) {
      results.push({
        channelSlug: slug,
        action: "flag_missing_cost",
        reason: "multiple stock rows and a null cost price on a priced channel, " +
                "the same shape as the pre-3.0 None-vs-None sort failure (issue #1011)",
      });
    } else if (costPrice !== null && costPrice !== undefined && price !== null && costPrice > price) {
      results.push({
        channelSlug: slug,
        action: "flag_negative_margin",
        reason: "cost price is greater than price, margin would be negative",
      });
    } else {
      results.push({ channelSlug: slug, action: "ok", reason: "cost price looks consistent" });
    }
  }
  return results;
}
5

Report every flagged row, never guess a fix

Under DRY_RUN=true, the default and the only mode this script runs in against real data, it logs every flagged channel listing with the variant id, sku, channel slug, stock count, cost price, and price. It never writes. A confirmed cost is applied later, by a human, through a separate guarded mutation call.

report.py
def audit_report(variants):
    report = []
    for variant in variants:
        for row in decide_cost_price_fix(variant):
            if row["action"] == "ok":
                continue
            report.append({
                "variantId": variant["id"],
                "sku": variant["sku"],
                "channelSlug": row["channelSlug"],
                "stockCount": variant["stockCount"],
                "action": row["action"],
                "reason": row["reason"],
            })
    return report
report.js
export function auditReport(variants) {
  const report = [];
  for (const variant of variants) {
    for (const row of decideCostPriceFix(variant)) {
      if (row.action === "ok") continue;
      report.push({
        variantId: variant.id,
        sku: variant.sku,
        channelSlug: row.channelSlug,
        stockCount: variant.stockCount,
        action: row.action,
        reason: row.reason,
      });
    }
  }
  return report;
}
6

Apply a confirmed cost only through a guarded mutation

Once a human has confirmed the real cost for a flagged sku and channel, apply it with productVariantChannelListingUpdate, passing the existing price back unchanged alongside the confirmed cost. The mutation call itself stays behind the same DRY_RUN guard, so nothing writes until you explicitly set DRY_RUN=false and requires MANAGE_PRODUCTS on the token.

Run it safe

This script only reports. It never derives a cost from stock rows and it never writes without an explicit, human-confirmed value. Guessing which stock's cost is correct risks writing wrong financial data into ProductVariantChannelListing.costPrice, and that number feeds margin reports directly.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, pages through variants on a channel, classifies every channel listing as ok, missing cost, or negative margin, and only ever writes when DRY_RUN is off and a specific variant id and confirmed cost are passed in.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 51 Saleor fixes, free and open source.
audit_variant_cost_price.py
"""Audit Saleor variants for the cost price miscalculation pattern first
raised in saleor/saleor issue #1011. Legacy Saleor computed a variant's cost
by sorting its Stock rows on an optional cost_price field, and a variant with
two or more stock rows where at least one cost_price was None raised
TypeError: unorderable types: NoneType() < NoneType().

Saleor 3.x removed per-stock cost entirely. Cost price now lives only on
ProductVariantChannelListing.costPrice, one value per channel, independent of
how many Stock rows exist. This script never derives cost from stocks. It
only reports, under DRY_RUN=true (the default), every channel listing where
costPrice is null on a priced channel with multiple stock rows (the same
None-vs-None signature) or where costPrice exceeds price (a negative margin).
A confirmed fix is applied separately through a guarded mutation call, only
when DRY_RUN=false and a human has supplied the confirmed cost.
Run on a schedule. Safe to run again and again, it only ever reads.
"""
import os
import logging
import requests

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

API_URL = os.environ.get("SALEOR_API_URL", "https://store.saleor.cloud/graphql/")
TOKEN = os.environ.get("SALEOR_AUTH_TOKEN", "dummy-token")
CHANNEL = os.environ.get("SALEOR_CHANNEL", "default-channel")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

VARIANT_AUDIT_QUERY = """
query($channel: String, $cursor: String) {
  productVariants(first: 100, after: $cursor, channel: $channel) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        sku
        stocks {
          id
          quantity
          quantityAllocated
          warehouse { id name }
        }
        channelListings {
          id
          channel { id slug }
          price { amount currency }
          costPrice { amount currency }
        }
      }
    }
  }
}"""

FIX_COST_MUTATION = """
mutation FixVariantCost($id: ID!, $input: [ProductVariantChannelListingUpdateInput!]!) {
  productVariantChannelListingUpdate(id: $id, input: $input) {
    variant { id sku channelListings { channel { slug } costPrice { amount currency } } }
    errors { field message code }
  }
}"""


def gql(query, variables=None):
    r = requests.post(
        API_URL,
        json={"query": query, "variables": variables or {}},
        headers={"Authorization": f"Bearer {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 decide_cost_price_fix(variant):
    """Pure function. Never derives a cost from stocks, only classifies."""
    stock_count = variant["stockCount"]
    results = []
    for listing in variant["channelListings"]:
        slug = listing["channelSlug"]
        price = listing["price"]
        cost_price = listing["costPrice"]
        if stock_count > 1 and cost_price is None and price is not None:
            results.append({
                "channelSlug": slug,
                "action": "flag_missing_cost",
                "reason": "multiple stock rows and a null cost price on a priced channel, "
                          "the same shape as the pre-3.0 None-vs-None sort failure (issue #1011)",
            })
        elif cost_price is not None and price is not None and cost_price > price:
            results.append({
                "channelSlug": slug,
                "action": "flag_negative_margin",
                "reason": "cost price is greater than price, margin would be negative",
            })
        else:
            results.append({"channelSlug": slug, "action": "ok", "reason": "cost price looks consistent"})
    return results


def audit_report(variants):
    report = []
    for variant in variants:
        for row in decide_cost_price_fix(variant):
            if row["action"] == "ok":
                continue
            report.append({
                "variantId": variant["id"],
                "sku": variant["sku"],
                "channelSlug": row["channelSlug"],
                "stockCount": variant["stockCount"],
                "action": row["action"],
                "reason": row["reason"],
            })
    return report


def variant_snapshot(channel):
    cursor = None
    rows = []
    while True:
        data = gql(VARIANT_AUDIT_QUERY, {"channel": channel, "cursor": cursor})["productVariants"]
        for edge in data["edges"]:
            node = edge["node"]
            rows.append({
                "id": node["id"],
                "sku": node["sku"],
                "stockCount": len(node["stocks"]),
                "channelListings": [
                    {
                        "channelSlug": cl["channel"]["slug"],
                        "channelId": cl["channel"]["id"],
                        "price": cl["price"]["amount"] if cl["price"] else None,
                        "costPrice": cl["costPrice"]["amount"] if cl["costPrice"] else None,
                    }
                    for cl in node["channelListings"]
                ],
            })
        if not data["pageInfo"]["hasNextPage"]:
            return rows
        cursor = data["pageInfo"]["endCursor"]


def apply_confirmed_cost(variant_id, channel_id, price, confirmed_cost_price):
    """Guarded write. Only ever called by a human after reviewing the report."""
    if DRY_RUN:
        log.info("[dry-run] would update %s costPrice=%s", variant_id, confirmed_cost_price)
        return None
    input_row = {"channelId": channel_id, "price": price, "costPrice": confirmed_cost_price}
    result = gql(FIX_COST_MUTATION, {"id": variant_id, "input": [input_row]})["productVariantChannelListingUpdate"]
    if result["errors"]:
        raise RuntimeError(result["errors"])
    return result["variant"]


def run():
    variants = variant_snapshot(CHANNEL)
    report = audit_report(variants)
    for row in report:
        log.warning(
            "FLAG sku=%s variant=%s channel=%s stocks=%d action=%s",
            row["sku"], row["variantId"], row["channelSlug"], row["stockCount"], row["action"],
        )
    log.info("Done. %d channel listing(s) flagged out of %d variant(s) checked.", len(report), len(variants))
    return report


if __name__ == "__main__":
    run()
audit-variant-cost-price.js
/**
 * Audit Saleor variants for the cost price miscalculation pattern first
 * raised in saleor/saleor issue #1011. Legacy Saleor computed a variant's
 * cost by sorting its Stock rows on an optional cost_price field, and a
 * variant with two or more stock rows where at least one cost_price was
 * None raised TypeError: unorderable types: NoneType() < NoneType().
 *
 * Saleor 3.x removed per-stock cost entirely. Cost price now lives only on
 * ProductVariantChannelListing.costPrice, one value per channel, independent
 * of how many Stock rows exist. This script never derives cost from stocks.
 * It only reports, under DRY_RUN=true (the default), every channel listing
 * where costPrice is null on a priced channel with multiple stock rows (the
 * same None-vs-None signature) or where costPrice exceeds price (a negative
 * margin). A confirmed fix is applied separately through a guarded mutation
 * call, only when DRY_RUN=false and a human has supplied the confirmed cost.
 * Run on a schedule. Safe to run again and again, it only ever reads.
 *
 * Guide: https://www.allanninal.dev/saleor/variant-cost-price-miscalculation/
 */
import { pathToFileURL } from "node:url";

const API_URL = process.env.SALEOR_API_URL || "https://store.saleor.cloud/graphql/";
const TOKEN = process.env.SALEOR_AUTH_TOKEN || "dummy-token";
const CHANNEL = process.env.SALEOR_CHANNEL || "default-channel";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function decideCostPriceFix(variant) {
  const stockCount = variant.stockCount;
  const results = [];
  for (const listing of variant.channelListings) {
    const slug = listing.channelSlug;
    const price = listing.price;
    const costPrice = listing.costPrice;
    if (stockCount > 1 && (costPrice === null || costPrice === undefined) && price !== null) {
      results.push({
        channelSlug: slug,
        action: "flag_missing_cost",
        reason: "multiple stock rows and a null cost price on a priced channel, " +
                "the same shape as the pre-3.0 None-vs-None sort failure (issue #1011)",
      });
    } else if (costPrice !== null && costPrice !== undefined && price !== null && costPrice > price) {
      results.push({
        channelSlug: slug,
        action: "flag_negative_margin",
        reason: "cost price is greater than price, margin would be negative",
      });
    } else {
      results.push({ channelSlug: slug, action: "ok", reason: "cost price looks consistent" });
    }
  }
  return results;
}

export function auditReport(variants) {
  const report = [];
  for (const variant of variants) {
    for (const row of decideCostPriceFix(variant)) {
      if (row.action === "ok") continue;
      report.push({
        variantId: variant.id,
        sku: variant.sku,
        channelSlug: row.channelSlug,
        stockCount: variant.stockCount,
        action: row.action,
        reason: row.reason,
      });
    }
  }
  return report;
}

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

const VARIANT_AUDIT_QUERY = `
query($channel: String, $cursor: String) {
  productVariants(first: 100, after: $cursor, channel: $channel) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        sku
        stocks {
          id
          quantity
          quantityAllocated
          warehouse { id name }
        }
        channelListings {
          id
          channel { id slug }
          price { amount currency }
          costPrice { amount currency }
        }
      }
    }
  }
}`;

const FIX_COST_MUTATION = `
mutation FixVariantCost($id: ID!, $input: [ProductVariantChannelListingUpdateInput!]!) {
  productVariantChannelListingUpdate(id: $id, input: $input) {
    variant { id sku channelListings { channel { slug } costPrice { amount currency } } }
    errors { field message code }
  }
}`;

async function variantSnapshot(channel) {
  let cursor = null;
  const rows = [];
  while (true) {
    const data = (await gql(VARIANT_AUDIT_QUERY, { channel, cursor })).productVariants;
    for (const edge of data.edges) {
      const node = edge.node;
      rows.push({
        id: node.id,
        sku: node.sku,
        stockCount: node.stocks.length,
        channelListings: node.channelListings.map((cl) => ({
          channelSlug: cl.channel.slug,
          channelId: cl.channel.id,
          price: cl.price ? cl.price.amount : null,
          costPrice: cl.costPrice ? cl.costPrice.amount : null,
        })),
      });
    }
    if (!data.pageInfo.hasNextPage) return rows;
    cursor = data.pageInfo.endCursor;
  }
}

async function applyConfirmedCost(variantId, channelId, price, confirmedCostPrice) {
  // Guarded write. Only ever called by a human after reviewing the report.
  if (DRY_RUN !== false) {
    console.log("[dry-run] would update", variantId, confirmedCostPrice);
    return;
  }
  const inputRow = { channelId, price, costPrice: confirmedCostPrice };
  const result = (await gql(FIX_COST_MUTATION, { id: variantId, input: [inputRow] })).productVariantChannelListingUpdate;
  if (result.errors.length) throw new Error(JSON.stringify(result.errors));
  return result.variant;
}

export async function run() {
  const variants = await variantSnapshot(CHANNEL);
  const report = auditReport(variants);
  for (const row of report) {
    console.warn(`FLAG sku=${row.sku} variant=${row.variantId} channel=${row.channelSlug} stocks=${row.stockCount} action=${row.action}`);
  }
  console.log(`Done. ${report.length} channel listing(s) flagged out of ${variants.length} variant(s) checked.`);
  return report;
}

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 channel listings get flagged for a human to look at. Because decide_cost_price_fix is pure, the test needs no network and no Saleor account. It just feeds in plain variant rows and checks the classification.

test_variant_cost_price.py
from audit_variant_cost_price import decide_cost_price_fix


def variant(**over):
    base = {
        "id": "gid://saleor/ProductVariant/1",
        "sku": "SKU-1",
        "stockCount": 2,
        "channelListings": [{"channelSlug": "default-channel", "price": 10.0, "costPrice": 4.0}],
    }
    base.update(over)
    return base


def test_ok_when_cost_and_price_are_consistent():
    result = decide_cost_price_fix(variant())
    assert result == [{"channelSlug": "default-channel", "action": "ok", "reason": "cost price looks consistent"}]


def test_flags_missing_cost_with_multiple_stocks_and_a_price():
    v = variant(channelListings=[{"channelSlug": "default-channel", "price": 10.0, "costPrice": None}])
    result = decide_cost_price_fix(v)
    assert result[0]["action"] == "flag_missing_cost"


def test_no_flag_missing_cost_with_single_stock_row():
    v = variant(stockCount=1, channelListings=[{"channelSlug": "default-channel", "price": 10.0, "costPrice": None}])
    result = decide_cost_price_fix(v)
    assert result[0]["action"] == "ok"


def test_flags_negative_margin_when_cost_exceeds_price():
    v = variant(channelListings=[{"channelSlug": "default-channel", "price": 10.0, "costPrice": 15.0}])
    result = decide_cost_price_fix(v)
    assert result[0]["action"] == "flag_negative_margin"


def test_no_flag_when_price_is_also_missing():
    v = variant(channelListings=[{"channelSlug": "default-channel", "price": None, "costPrice": None}])
    result = decide_cost_price_fix(v)
    assert result[0]["action"] == "ok"


def test_multiple_channels_mixed_results():
    v = variant(channelListings=[
        {"channelSlug": "default-channel", "price": 10.0, "costPrice": 4.0},
        {"channelSlug": "us", "price": 12.0, "costPrice": None},
    ])
    result = decide_cost_price_fix(v)
    assert result[0]["action"] == "ok"
    assert result[1]["action"] == "flag_missing_cost"
variant-cost-price.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideCostPriceFix } from "./audit-variant-cost-price.js";

const variant = (over = {}) => ({
  id: "gid://saleor/ProductVariant/1",
  sku: "SKU-1",
  stockCount: 2,
  channelListings: [{ channelSlug: "default-channel", price: 10.0, costPrice: 4.0 }],
  ...over,
});

test("ok when cost and price are consistent", () => {
  const result = decideCostPriceFix(variant());
  assert.deepEqual(result, [{ channelSlug: "default-channel", action: "ok", reason: "cost price looks consistent" }]);
});

test("flags missing cost with multiple stocks and a price", () => {
  const v = variant({ channelListings: [{ channelSlug: "default-channel", price: 10.0, costPrice: null }] });
  const result = decideCostPriceFix(v);
  assert.equal(result[0].action, "flag_missing_cost");
});

test("no flag missing cost with single stock row", () => {
  const v = variant({ stockCount: 1, channelListings: [{ channelSlug: "default-channel", price: 10.0, costPrice: null }] });
  const result = decideCostPriceFix(v);
  assert.equal(result[0].action, "ok");
});

test("flags negative margin when cost exceeds price", () => {
  const v = variant({ channelListings: [{ channelSlug: "default-channel", price: 10.0, costPrice: 15.0 }] });
  const result = decideCostPriceFix(v);
  assert.equal(result[0].action, "flag_negative_margin");
});

test("no flag when price is also missing", () => {
  const v = variant({ channelListings: [{ channelSlug: "default-channel", price: null, costPrice: null }] });
  const result = decideCostPriceFix(v);
  assert.equal(result[0].action, "ok");
});

test("multiple channels mixed results", () => {
  const v = variant({
    channelListings: [
      { channelSlug: "default-channel", price: 10.0, costPrice: 4.0 },
      { channelSlug: "us", price: 12.0, costPrice: null },
    ],
  });
  const result = decideCostPriceFix(v);
  assert.equal(result[0].action, "ok");
  assert.equal(result[1].action, "flag_missing_cost");
});

Case studies

Multi-warehouse migration

A migration script rebuilt the old sort and hit the same wall

A furniture retailer moved off a legacy commerce platform where cost lived per warehouse, and the migration team wrote a script that read every Stock row per variant and tried to reconstruct a single cost the same way the old system had, sorting stocks and taking the cheapest. Some warehouses in the new Saleor store had never had a cost entered yet, and the script either crashed on comparing missing values or, once patched to "just skip nulls," silently produced a cost from whichever warehouse happened to have one, which was not the warehouse finance actually used for margin reporting.

Running the audit against the channel surfaced every variant where stock count was above one and cost price was null on a priced channel, in a single report instead of a crash log. The finance team confirmed the correct cost per sku from their own records and applied it through the guarded mutation, and the migration script was retired in favor of just reading ProductVariantChannelListing.costPrice directly going forward.

Vendor cost updates

A vendor feed update left cost above price on a discounted channel

A specialty foods store synced supplier cost updates into Saleor through a scheduled job, but a discount channel used for clearance stock had its price lowered without a matching cost adjustment, so the channel's costPrice ended up higher than its price, an invisible negative margin that nobody would notice until a margin report looked wrong at the end of the month.

The audit flagged every channel listing where cost exceeded price as flag_negative_margin, with the sku and channel slug attached, well before month end. The merchandising team either raised the clearance price or lowered the cost on the affected skus, and the fix went through the same confirmed, guarded mutation rather than an automatic guess at which number was wrong.

What good looks like

After this runs on a schedule, a variant with an inconsistent cost price gets caught as a report line with a sku, channel, and reason, not a crash and not a silently wrong margin. Nobody has to guess which warehouse's cost was the real one, because the audit never tries to guess either. A human confirms the number once, and the guarded mutation writes exactly that number to the one place cost actually lives.

FAQ

Why did Saleor crash when calculating a variant's cost price with multiple stock rows?

In legacy pre-3.0 Saleor, ProductVariant.get_cost_price() sorted every Stock row for a variant by its optional cost_price field to find the cheapest one. When a variant had two or more stock rows and at least one had cost_price set to None, Python tried to compare None against None with the less than operator, which raised TypeError: unorderable types: NoneType() < NoneType(). One warehouse with an unset cost was enough to break the whole calculation.

Where does cost price live in current Saleor, and why does that matter?

Saleor removed per-stock cost entirely. Cost price now lives only on ProductVariantChannelListing.costPrice, one decimal value per channel, fully decoupled from Stock and warehouse quantity rows. Any script that still reads multiple Stock rows to derive a variant's cost is solving a problem that no longer matches the data model, and it reproduces the same class of bug by mishandling nulls while aggregating across rows that were never meant to carry cost.

Is it safe to auto-fix a variant with a null or negative-margin cost price?

No. There is no safe way to derive the correct cost from multiple warehouse stocks, because Saleor 3.x stores exactly one cost value per channel and nothing about stock quantity implies what that value should be. The safe pattern is to detect and report the sku, channel, current costPrice, price, and stock count, then let a human confirm the real cost before a guarded productVariantChannelListingUpdate mutation writes it.

Related field notes

Citations

On the problem:

  1. Calculating variant cost price doesn't work with more than one stock record and empty cost price. github.com/saleor/saleor/issues/1011
  2. Saleor Commerce Documentation: Stock Overview. docs.saleor.io/developer/stock/overview
  3. What is the difference between a product's purchaseCost and its variants' cost price. github.com/saleor/saleor/issues/4724

On the solution:

  1. Saleor Commerce Documentation: ProductVariantChannelListing Object. docs.saleor.io/api-reference/products/objects/product-variant-channel-listing
  2. Saleor Commerce Documentation: productVariantChannelListingUpdate Mutation. docs.saleor.io/api-reference/products/mutations/product-variant-channel-listing-update
  3. Saleor Commerce Documentation: ProductVariant Object. docs.saleor.io/api-reference/products/objects/product-variant

Stuck on a tricky one?

If you have a problem in Saleor checkout, stock, channels, 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 cost price gap for you?

If this saved your margin report from a silent bad number, or saved you from an old crash, 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 Saleor field notes