Skip to content

[Bug]: Price-list updates carrying explicit price IDs are silently dropped when quantity fields are omitted #17289

Closed

Description

@neopack33

Package.json file

Relevant dependencies (sanitized excerpt; reported module versions are also 2.21.2):

{
  "dependencies": {
    "@medusajs/framework": "2.21.2",
    "@medusajs/medusa": "2.21.2"
  }
}

@medusajs/pricing and @medusajs/core-flows: 2.21.2.

Node.js version

v22.22.1

Database and its version

PostgreSQL 17.11

Operating system name and version

macOS 26.6.2

Browser name

N/A — server-side script.

What happended?

batchPriceListPricesWorkflow / updatePriceListPrices silently drops one of two updates even though each update carries a distinct, explicit price id.

Given two prices in the same price list and price set, both in EUR but with different quantity bands, updating them together with { id, variant_id, amount } changes only the second price. The first keeps its old amount, and the workflow reports success. The Admin update validator accepts this shape: currency and quantity fields are optional.

This differs from closed #11103, which concerned region rules and was resolved for that caller by supplying the distinguishing rules. This report isolates explicit-ID updates to quantity-band prices: callers already identify both target rows and should not have to resend unchanged identity fields. It still reproduces on 2.21.2, after the merged pricing-normalization changes in PR #13899.

Source analysis

All links below target the v2.21.2 TypeScript source:

  • hashPrice includes only the currency, price-set, price-list, quantity and rule fields present on the input; it does not include id.
  • normalizePrices keys pricesToUpsert by that hash, not by explicit ID. The two partial updates hash identically, so the second replaces the first in the map. A hash match with an existing price can also replace the supplied ID.
  • updatePriceListPrices_ normalizes the inputs before passing the collapsed array to upsertWithReplace.
  • The workflow step forwards each explicit ID and adds its variant's price-set ID; it does not fill omitted quantity fields before calling the module.

The entire pricing-module.ts file is byte-for-byte identical on develop at 0b97a96ea0d32ff3f4b18dbeeeeba9e763600a15 and v2.21.2. This is a source comparison, not a new runtime test of develop.

Workaround: resend currency_code, min_quantity, max_quantity and any applicable rules with each update so the price hashes remain distinct.

The separate rollback-compensation defect is already tracked by open PR #17062 and is outside this report.

Expected behavior

Both explicitly identified prices are updated: quantity band 1–9 becomes 78, and quantity band 10+ becomes 74. Omitting unchanged optional identity fields must not cause one distinct ID to overwrite another update before persistence.

Actual behavior

On 2.21.2, the script printed [[1, 80], [10, 74]] across 3 runs on a fresh database: the workflow succeeded, but band 1–9 remained 80 and only band 10+ changed to 74. For the untouched band 1–9 price, updated_at remained equal to created_at. Setting the price list to type "override" produced the same result. In the original reproduction, supplying currency and quantity boundaries with both updates correctly changed both prices.

Unrelated search-index subscriber errors were also logged under medusa exec; they are noted separately from the successful price-update workflow and its observed persisted result.

Link to reproduction repo

No standalone public reproduction repository. The complete script below runs in a standard Medusa 2.21.2 application with PostgreSQL, configured and migrated normally.

Save as src/scripts/repro-price-list-id-updates.ts, then run:

npx medusa exec ./src/scripts/repro-price-list-id-updates.ts

Use a disposable application/database. The script creates only synthetic data and leaves the product and price list for inspection. The observed output below was reproduced across 3 runs on a fresh database, including the same result with price-list type "override".

import type { ExecArgs, IPricingModuleService } from "@medusajs/framework/types"
import { Modules, PriceListStatus } from "@medusajs/framework/utils"
import {
  batchPriceListPricesWorkflow,
  createPriceListsWorkflow,
  createProductsWorkflow,
} from "@medusajs/medusa/core-flows"
import { randomUUID } from "node:crypto"

export default async function ({ container }: ExecArgs) {
  const pricing = container.resolve<IPricingModuleService>(Modules.PRICING)
  const suffix = randomUUID()

  const { result: [product] } = await createProductsWorkflow(container).run({
    input: {
      products: [{
        title: `Price update repro ${suffix}`,
        options: [{ title: "Default", values: ["Default"] }],
        variants: [{
          title: "Default",
          sku: `price-update-repro-${suffix}`,
          manage_inventory: false,
          options: { Default: "Default" },
          prices: [{ currency_code: "eur", amount: 100 }],
        }],
      }],
    },
  })
  const variantId = product.variants?.[0]?.id
  if (!variantId) throw new Error("Repro variant was not created")

  const { result: [list] } = await createPriceListsWorkflow(container).run({
    input: {
      price_lists_data: [{
        title: `Price update repro ${suffix}`,
        description: "Synthetic explicit-ID update reproduction",
        status: PriceListStatus.ACTIVE,
        prices: [
          { variant_id: variantId, currency_code: "eur", amount: 80,
            min_quantity: 1, max_quantity: 9 },
          { variant_id: variantId, currency_code: "eur", amount: 76,
            min_quantity: 10 },
        ],
      }],
    },
  })

  const readPrices = async () =>
    (await pricing.listPrices({ price_list_id: [list.id] }))
      .sort((a, b) => Number(a.min_quantity) - Number(b.min_quantity))

  const [band1, band2] = await readPrices()
  if (!band1 || !band2) throw new Error("Expected two quantity-band prices")

  await batchPriceListPricesWorkflow(container).run({
    input: {
      data: {
        id: list.id,
        create: [],
        delete: [],
        update: [
          { id: band1.id, variant_id: variantId, amount: 78 },
          { id: band2.id, variant_id: variantId, amount: 74 },
        ],
      },
    },
  })

  console.log((await readPrices()).map((p) => [Number(p.min_quantity), Number(p.amount)]))
  // Expected: [[1, 78], [10, 74]]
  // Observed on 2.21.2: [[1, 80], [10, 74]] — no workflow error.
}

Suggested fix: when an input carries an explicit id, preserve and key the update by that ID instead of deduplicating it by optional identity fields. Reject conflicting updates to the same target rather than silently retaining only one.

Activity

  1. medusa-os-bot commented on Oct 15, 2026

    @medusa-os-bot
    Contributor

    Thanks for reporting this! We've labeled it as a bug and will investigate.

    Confirmed bug in the pricing module's normalization logic. When batchPriceListPricesWorkflow / updatePriceListPrices receives multiple price updates that each carry an explicit id but omit optional identity fields (currency_code, min_quantity, max_quantity), the internal hashPrice function produces identical hashes for all such entries (since id is not part of the hash). The normalizePrices function then stores entries in a map keyed by this hash, so the second entry silently overwrites the first. Only the last update survives; the rest are dropped without error. Root cause: hashPrice at pricing-module.ts around line 1838 and pricesToUpsert.set(entryHash, entry) at around line 990. Workaround: include currency_code, min_quantity, and max_quantity in each update so the hashes remain distinct. Fix direction: when an input carries an explicit id, key the update by id instead of the computed hash. The fix is contained but touches a sensitive core pricing…

  2. medusa-os-bot commented on Oct 22, 2026

    @medusa-os-bot
    Contributor

    Thanks for the report. This looks like a small, well-scoped fix, so we're attempting one automatically.

    In normalizePrices (packages/modules/pricing/src/services/pricing-module.ts:990), price updates are stored in a map keyed by the output of hashPrice, which does not include the price id field (hashPrice at line 1838 covers only currency, price-set, price-list, min/max quantity, and rules). When two updates share the same values for those fields — or those fields are omitted — both produce the same hash and the second entry silently overwrites the first. Only one of the two updates reaches the database. Fix: when an input entry carries an explicit id, use that id as the map key in pricesToUpsert instead of the computed hash, so two distinct identified prices cannot collide.

    A pull request that references this issue will follow. A maintainer reviews every change before it's merged. If we can't produce a safe fix, the issue is handed to the team instead.

  3. medusa-os-bot commented on Oct 22, 2026

    @medusa-os-bot
    Contributor

    We tried to put together an automated fix for this issue, but couldn't produce one that is small and safe enough to open as a pull request.

    The automated fix run did not complete.

    The issue has been escalated to the team, and a maintainer will follow up here. There's nothing you need to do in the meantime.

  4. added a commit that references this issue on Oct 23, 2026
    f7d822b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions