fix(pricing): Calculate prices with multiple rule values (#13079)

This commit is contained in:
Oli Juhl
2025-08-05 13:04:55 +02:00
committed by GitHub
parent 8616248adc
commit 02dd83f59a
4 changed files with 202 additions and 35 deletions

View File

@@ -0,0 +1,5 @@
---
"@medusajs/pricing": minor
---
feat(pricing): Calculate prices with multiple rule values

View File

@@ -473,7 +473,6 @@ moduleIntegrationTestRunner<IPricingModuleService>({
{ context } { context }
) )
expect(calculatedPrice).toEqual([ expect(calculatedPrice).toEqual([
{ {
id: "price-set-PLN", id: "price-set-PLN",
@@ -1233,11 +1232,27 @@ moduleIntegrationTestRunner<IPricingModuleService>({
]) ])
}) })
it("should return best price list price first when price list conditions match", async () => { it("should return cheapest price list price first when price list conditions match", async () => {
await createPriceLists(service)
await createPriceLists( await createPriceLists(
service, service,
{
title: "Test Price List One",
description: "test description",
type: PriceListType.OVERRIDE,
status: PriceListStatus.ACTIVE,
},
{}, {},
defaultPriceListPrices
)
await createPriceLists(
service,
{
title: "Test Price List Two",
description: "test description",
type: PriceListType.OVERRIDE,
status: PriceListStatus.ACTIVE,
},
{}, {},
defaultPriceListPrices.map((price) => { defaultPriceListPrices.map((price) => {
return { ...price, amount: price.amount / 2 } return { ...price, amount: price.amount / 2 }
@@ -1245,7 +1260,7 @@ moduleIntegrationTestRunner<IPricingModuleService>({
) )
const priceSetsResult = await service.calculatePrices( const priceSetsResult = await service.calculatePrices(
{ id: ["price-set-EUR", "price-set-PLN"] }, { id: ["price-set-PLN"] },
{ {
context: { context: {
currency_code: "PLN", currency_code: "PLN",
@@ -1261,32 +1276,32 @@ moduleIntegrationTestRunner<IPricingModuleService>({
id: "price-set-PLN", id: "price-set-PLN",
is_calculated_price_price_list: true, is_calculated_price_price_list: true,
is_calculated_price_tax_inclusive: false, is_calculated_price_tax_inclusive: false,
calculated_amount: 232, calculated_amount: 116,
raw_calculated_amount: { raw_calculated_amount: {
value: "232", value: "116",
precision: 20, precision: 20,
}, },
is_original_price_price_list: false, is_original_price_price_list: true,
is_original_price_tax_inclusive: false, is_original_price_tax_inclusive: false,
original_amount: 400, original_amount: 116,
raw_original_amount: { raw_original_amount: {
value: "400", value: "116",
precision: 20, precision: 20,
}, },
currency_code: "PLN", currency_code: "PLN",
calculated_price: { calculated_price: {
id: expect.any(String), id: expect.any(String),
price_list_id: expect.any(String), price_list_id: expect.any(String),
price_list_type: "sale", price_list_type: "override",
min_quantity: null, min_quantity: null,
max_quantity: null, max_quantity: null,
}, },
original_price: { original_price: {
id: expect.any(String), id: expect.any(String),
price_list_id: null, price_list_id: expect.any(String),
price_list_type: null, price_list_type: "override",
min_quantity: 1, min_quantity: null,
max_quantity: 5, max_quantity: null,
}, },
}, },
]) ])
@@ -1978,6 +1993,149 @@ moduleIntegrationTestRunner<IPricingModuleService>({
]) ])
}) })
it("should return price list prices for multiple price lists with customer groups", async () => {
const [{ id }] = await createPriceLists(
service,
{ type: "override" },
{
["customer.groups.id"]: ["vip-customer-group-id"],
},
[
{
amount: 600,
currency_code: "EUR",
price_set_id: "price-set-EUR",
},
]
)
const [{ id: idTwo }] = await createPriceLists(
service,
{ type: "override" },
{
["customer.groups.id"]: ["vip-customer-group-id-1"],
},
[
{
amount: 400,
currency_code: "EUR",
price_set_id: "price-set-EUR",
},
]
)
const priceSetsResult = await service.calculatePrices(
{ id: ["price-set-EUR"] },
{
context: {
currency_code: "EUR",
// @ts-ignore
customer: {
groups: {
id: ["vip-customer-group-id", "vip-customer-group-id-1"],
},
},
},
}
)
expect(priceSetsResult).toEqual([
{
id: "price-set-EUR",
is_calculated_price_price_list: true,
is_calculated_price_tax_inclusive: false,
calculated_amount: 400,
raw_calculated_amount: {
value: "400",
precision: 20,
},
is_original_price_price_list: true,
is_original_price_tax_inclusive: false,
original_amount: 400,
raw_original_amount: {
value: "400",
precision: 20,
},
currency_code: "EUR",
calculated_price: {
id: expect.any(String),
price_list_id: idTwo,
price_list_type: "override",
min_quantity: null,
max_quantity: null,
},
original_price: {
id: expect.any(String),
price_list_id: idTwo,
price_list_type: "override",
min_quantity: null,
max_quantity: null,
},
},
])
})
it("should return price list prices when price list conditions match within prices", async () => {
await createPriceLists(service, {}, { region_id: ["DE", "PL"] }, [
...defaultPriceListPrices,
{
amount: 111,
currency_code: "PLN",
price_set_id: "price-set-PLN",
rules: {
region_id: "DE",
},
},
])
const priceSetsResult = await service.calculatePrices(
{ id: ["price-set-EUR", "price-set-PLN"] },
{
context: {
currency_code: "PLN",
region_id: "DE",
customer_group_id: "vip-customer-group-id",
company_id: "medusa-company-id",
},
}
)
expect(priceSetsResult).toEqual([
{
id: "price-set-PLN",
is_calculated_price_price_list: true,
is_calculated_price_tax_inclusive: false,
calculated_amount: 111,
raw_calculated_amount: {
value: "111",
precision: 20,
},
is_original_price_price_list: false,
is_original_price_tax_inclusive: false,
original_amount: 400,
raw_original_amount: {
value: "400",
precision: 20,
},
currency_code: "PLN",
calculated_price: {
id: expect.any(String),
price_list_id: expect.any(String),
price_list_type: "sale",
min_quantity: null,
max_quantity: null,
},
original_price: {
id: expect.any(String),
price_list_id: null,
price_list_type: null,
min_quantity: 1,
max_quantity: 5,
},
},
])
})
it("should not return price list prices when price list conditions are met but price rules are not", async () => { it("should not return price list prices when price list conditions are met but price rules are not", async () => {
await createPriceLists(service, {}, { region_id: ["DE", "PL"] }, [ await createPriceLists(service, {}, { region_id: ["DE", "PL"] }, [
...defaultPriceListPrices, ...defaultPriceListPrices,

View File

@@ -180,10 +180,10 @@ export class PricingRepository
WHERE pr.price_id = price.id WHERE pr.price_id = price.id
AND pr.deleted_at IS NULL AND pr.deleted_at IS NULL
AND ( AND (
${flattenedContext ${flattenedContext
.map(([key, value]) => { .map(([key, value]) => {
if (typeof value === "number") { if (typeof value === "number") {
return ` return `
(pr.attribute = ? AND ( (pr.attribute = ? AND (
(pr.operator = 'eq' AND pr.value = ?) OR (pr.operator = 'eq' AND pr.value = ?) OR
(pr.operator = 'gt' AND ? > pr.value::numeric) OR (pr.operator = 'gt' AND ? > pr.value::numeric) OR
@@ -192,16 +192,13 @@ export class PricingRepository
(pr.operator = 'lte' AND ? <= pr.value::numeric) (pr.operator = 'lte' AND ? <= pr.value::numeric)
)) ))
` `
} else { } else {
const normalizeValue = Array.isArray(value) const normalizeValue = Array.isArray(value) ? value : [value]
? value const placeholders = normalizeValue.map(() => "?").join(",")
: [value] return `(pr.attribute = ? AND pr.value IN (${placeholders}))`
const placeholders = normalizeValue.map(() => "?").join(",") }
return `(pr.attribute = ? AND pr.value IN (${placeholders}))` })
} .join(" OR ")})
})
.join(" OR ")}
)
) = ( ) = (
/* Get total rule count */ /* Get total rule count */
SELECT COUNT(*) SELECT COUNT(*)
@@ -232,11 +229,16 @@ export class PricingRepository
WHERE plr.price_list_id = pl.id WHERE plr.price_list_id = pl.id
AND plr.deleted_at IS NULL AND plr.deleted_at IS NULL
AND ( AND (
${flattenedContext ${flattenedContext
.map(([key, value]) => { .map(([key, value]) => {
return `(plr.attribute = ? AND plr.value @> ?)` if (Array.isArray(value)) {
}) return value
.join(" OR ")} .map((v) => `(plr.attribute = ? AND plr.value @> ?)`)
.join(" OR ")
}
return `(plr.attribute = ? AND plr.value @> ?)`
})
.join(" OR ")}
) )
) = ( ) = (
/* Get total rule count */ /* Get total rule count */
@@ -248,7 +250,8 @@ export class PricingRepository
) )
`, `,
flattenedContext.flatMap(([key, value]) => { flattenedContext.flatMap(([key, value]) => {
return [key, JSON.stringify(Array.isArray(value) ? value : [value])] const valueAsArray = Array.isArray(value) ? value : [value]
return valueAsArray.flatMap((v) => [key, JSON.stringify(v)])
}) })
) )
@@ -275,7 +278,6 @@ export class PricingRepository
query query
.orderByRaw("price.price_list_id IS NOT NULL DESC") .orderByRaw("price.price_list_id IS NOT NULL DESC")
.orderByRaw("price.rules_count + COALESCE(pl.rules_count, 0) DESC") .orderByRaw("price.rules_count + COALESCE(pl.rules_count, 0) DESC")
.orderBy("pl.id", "asc")
.orderBy("price.amount", "asc") .orderBy("price.amount", "asc")
return await query return await query

View File

@@ -343,6 +343,7 @@ export default class PricingModuleService
/** /**
* When deciding which price to use we follow the following logic: * When deciding which price to use we follow the following logic:
* - If the price list is of type OVERRIDE, we always use the price list price. * - If the price list is of type OVERRIDE, we always use the price list price.
* - If there are multiple price list prices of type OVERRIDE, we use the one with the lowest amount.
* - If the price list is of type SALE, we use the lowest price between the price list price and the default price * - If the price list is of type SALE, we use the lowest price between the price list price and the default price
*/ */
if (priceListPrice) { if (priceListPrice) {
@@ -369,6 +370,7 @@ export default class PricingModuleService
} }
} }
pricesSetPricesMap.set(key, { calculatedPrice, originalPrice }) pricesSetPricesMap.set(key, { calculatedPrice, originalPrice })
priceIds.push( priceIds.push(
...(deduplicate( ...(deduplicate(