Skip to content

Commit a4791af

Browse files
authored
fix(core-flows): re-price merged cart line items and hydrate compare_at on update (#16211)
1 parent 65040fc commit a4791af

8 files changed

Lines changed: 392 additions & 36 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@medusajs/core-flows": patch
3+
---
4+
5+
fix(core-flows): re-select the price of a merged cart line item for the resulting quantity, and hydrate compare_at_unit_price when a line item is updated

integration-tests/http/__tests__/cart/store/cart.spec.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1914,6 +1914,147 @@ medusaIntegrationTestRunner({
19141914
})
19151915
})
19161916

1917+
describe("with quantity based sale price lists", () => {
1918+
let tieredVariantId
1919+
let tieredCart
1920+
1921+
beforeEach(async () => {
1922+
const tieredProduct = (
1923+
await api.post(
1924+
`/admin/products`,
1925+
{
1926+
title: "Medusa T-Shirt with tiered sale prices",
1927+
handle: "t-shirt-with-tiered-sale-prices",
1928+
status: ProductStatus.PUBLISHED,
1929+
options: [{ title: "Size", values: ["S"] }],
1930+
variants: [
1931+
{
1932+
title: "S",
1933+
sku: "SHIRT-S-BLACK-w-tiered-sale-prices",
1934+
options: { Size: "S" },
1935+
manage_inventory: false,
1936+
prices: [{ amount: 4000, currency_code: "usd" }],
1937+
},
1938+
],
1939+
},
1940+
adminHeaders
1941+
)
1942+
).data.product
1943+
1944+
tieredVariantId = tieredProduct.variants[0].id
1945+
1946+
await api.post(
1947+
`/admin/price-lists`,
1948+
{
1949+
title: "tiered sale price list",
1950+
description: "test",
1951+
status: PriceListStatus.ACTIVE,
1952+
type: PriceListType.SALE,
1953+
prices: [
1954+
{
1955+
amount: 3799,
1956+
currency_code: "usd",
1957+
variant_id: tieredVariantId,
1958+
min_quantity: 3,
1959+
max_quantity: 4,
1960+
},
1961+
{
1962+
amount: 3599,
1963+
currency_code: "usd",
1964+
variant_id: tieredVariantId,
1965+
min_quantity: 5,
1966+
},
1967+
],
1968+
},
1969+
adminHeaders
1970+
)
1971+
1972+
tieredCart = (
1973+
await api.post(
1974+
`/store/carts`,
1975+
{
1976+
currency_code: "usd",
1977+
sales_channel_id: salesChannel.id,
1978+
region_id: region.id,
1979+
shipping_address: shippingAddressData,
1980+
},
1981+
storeHeaders
1982+
)
1983+
).data.cart
1984+
})
1985+
1986+
it("should re-calculate the price of a merged line item based on the resulting quantity", async () => {
1987+
let response
1988+
1989+
for (let i = 0; i < 3; i++) {
1990+
response = await api.post(
1991+
`/store/carts/${tieredCart.id}/line-items`,
1992+
{
1993+
variant_id: tieredVariantId,
1994+
quantity: 1,
1995+
},
1996+
storeHeaders
1997+
)
1998+
1999+
expect(response.status).toEqual(200)
2000+
}
2001+
2002+
expect(response.data.cart.items).toHaveLength(1)
2003+
expect(response.data.cart.items[0]).toEqual(
2004+
expect.objectContaining({
2005+
variant_id: tieredVariantId,
2006+
quantity: 3,
2007+
unit_price: 3799,
2008+
compare_at_unit_price: 4000,
2009+
})
2010+
)
2011+
})
2012+
2013+
it("should set compare_at_unit_price when a line item's quantity is updated", async () => {
2014+
const itemId = (
2015+
await api.post(
2016+
`/store/carts/${tieredCart.id}/line-items`,
2017+
{
2018+
variant_id: tieredVariantId,
2019+
quantity: 1,
2020+
},
2021+
storeHeaders
2022+
)
2023+
).data.cart.items[0].id
2024+
2025+
let response = await api.post(
2026+
`/store/carts/${tieredCart.id}/line-items/${itemId}`,
2027+
{ quantity: 3 },
2028+
storeHeaders
2029+
)
2030+
2031+
expect(response.status).toEqual(200)
2032+
expect(response.data.cart.items[0]).toEqual(
2033+
expect.objectContaining({
2034+
quantity: 3,
2035+
unit_price: 3799,
2036+
compare_at_unit_price: 4000,
2037+
})
2038+
)
2039+
2040+
// Dropping back below the first tier resets the compare-at price
2041+
response = await api.post(
2042+
`/store/carts/${tieredCart.id}/line-items/${itemId}`,
2043+
{ quantity: 1 },
2044+
storeHeaders
2045+
)
2046+
2047+
expect(response.status).toEqual(200)
2048+
expect(response.data.cart.items[0]).toEqual(
2049+
expect.objectContaining({
2050+
quantity: 1,
2051+
unit_price: 4000,
2052+
compare_at_unit_price: null,
2053+
})
2054+
)
2055+
})
2056+
})
2057+
19172058
describe("with manage_inventory true", () => {
19182059
let inventoryItem
19192060
beforeEach(async () => {

packages/core/core-flows/src/cart/steps/get-line-item-actions.ts

Lines changed: 7 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,12 @@ import {
55
UpdateLineItemWithoutSelectorDTO,
66
UpdateLineItemWithSelectorDTO,
77
} from "@medusajs/framework/types"
8-
import {
9-
MathBN,
10-
Modules,
11-
deepEqualObj,
12-
isPresent,
13-
} from "@medusajs/framework/utils"
8+
import { MathBN, Modules } from "@medusajs/framework/utils"
149
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
10+
import {
11+
findMatchingLineItem,
12+
lineItemFieldsForMerging,
13+
} from "../utils/find-matching-line-item"
1514

1615
/**
1716
* The details of the line items to create or update.
@@ -72,14 +71,7 @@ export const getLineItemActionsStep = createStep(
7271
variant_id: variantIds,
7372
},
7473
{
75-
select: [
76-
"id",
77-
"metadata",
78-
"variant_id",
79-
"quantity",
80-
"unit_price",
81-
"compare_at_unit_price",
82-
],
74+
select: lineItemFieldsForMerging,
8375
}
8476
)
8577

@@ -97,22 +89,10 @@ export const getLineItemActionsStep = createStep(
9789
const itemsToCreate: CreateLineItemForCartDTO[] = []
9890
const itemsToUpdate: UpdateLineItemWithSelectorDTO["data"][] = []
9991

100-
const metadataMatches = (
101-
existingItem: CartLineItemDTO,
102-
newItem: CreateLineItemForCartDTO
103-
) =>
104-
(!isPresent(existingItem?.metadata) && !isPresent(newItem.metadata)) ||
105-
deepEqualObj(existingItem?.metadata, newItem.metadata)
106-
10792
for (const item of data.items) {
10893
const variantItems = variantItemsMap.get(item.variant_id!)
10994

110-
const existingItem = variantItems?.find((existingItem) =>
111-
item.is_custom_price
112-
? metadataMatches(existingItem, item) &&
113-
item.unit_price === existingItem.unit_price
114-
: metadataMatches(existingItem, item) && !existingItem.is_custom_price
115-
)
95+
const existingItem = findMatchingLineItem(variantItems, item)
11696

11797
if (existingItem) {
11898
const quantity = MathBN.sum(
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import {
2+
BigNumberInput,
3+
CartLineItemDTO,
4+
ICartModuleService,
5+
} from "@medusajs/framework/types"
6+
import { MathBN, Modules, isDefined } from "@medusajs/framework/utils"
7+
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
8+
import {
9+
findMatchingLineItem,
10+
lineItemFieldsForMerging,
11+
} from "../utils/find-matching-line-item"
12+
13+
/**
14+
* The details of the items to resolve pricing quantities for.
15+
*/
16+
export interface GetLineItemPricingQuantitiesStepInput {
17+
/**
18+
* The ID of the cart the items are added to.
19+
*/
20+
id: string
21+
/**
22+
* The items being added to the cart.
23+
*/
24+
items?: {
25+
variant_id?: string
26+
quantity?: BigNumberInput
27+
unit_price?: BigNumberInput
28+
metadata?: Record<string, unknown> | null
29+
}[]
30+
}
31+
32+
export const getLineItemPricingQuantitiesStepId =
33+
"get-line-item-pricing-quantities"
34+
/**
35+
* This step resolves the quantity that each item added to a cart must be priced
36+
* for. When an item is merged into an existing line item of the cart, its price
37+
* has to be selected for the resulting total quantity, not only for the added
38+
* quantity. Otherwise, quantity-based prices (such as price list tiers) are
39+
* resolved against the wrong quantity.
40+
*
41+
* The returned quantities are aligned, by index, with the input items.
42+
*
43+
* @example
44+
* const quantities = getLineItemPricingQuantitiesStep({
45+
* "id": "cart_123",
46+
* "items": [{
47+
* "variant_id": "variant_123",
48+
* "quantity": 1,
49+
* }]
50+
* })
51+
*/
52+
export const getLineItemPricingQuantitiesStep = createStep(
53+
getLineItemPricingQuantitiesStepId,
54+
async (data: GetLineItemPricingQuantitiesStepInput, { container }) => {
55+
const items = data.items ?? []
56+
const quantities = items.map((item) =>
57+
MathBN.convert(item.quantity ?? 1).toNumber()
58+
)
59+
60+
const variantIds = items
61+
.map((item) => item.variant_id)
62+
.filter((id): id is string => !!id)
63+
64+
if (!variantIds.length) {
65+
return new StepResponse(quantities)
66+
}
67+
68+
const cartModule = container.resolve<ICartModuleService>(Modules.CART)
69+
70+
const existingVariantItems = await cartModule.listLineItems(
71+
{
72+
cart_id: data.id,
73+
variant_id: variantIds,
74+
},
75+
{
76+
select: lineItemFieldsForMerging,
77+
}
78+
)
79+
80+
const variantItemsMap = existingVariantItems.reduce(
81+
(result, variantItem) => {
82+
if (!result.has(variantItem.variant_id!)) {
83+
result.set(variantItem.variant_id!, [])
84+
}
85+
result.get(variantItem.variant_id!)!.push(variantItem)
86+
return result
87+
},
88+
new Map<string, CartLineItemDTO[]>()
89+
)
90+
91+
items.forEach((item, index) => {
92+
if (!item.variant_id) {
93+
return
94+
}
95+
96+
const existingItem = findMatchingLineItem(
97+
variantItemsMap.get(item.variant_id),
98+
{
99+
metadata: item.metadata,
100+
unit_price: item.unit_price,
101+
is_custom_price: isDefined(item.unit_price),
102+
}
103+
)
104+
105+
if (!existingItem) {
106+
return
107+
}
108+
109+
const quantity = MathBN.sum(
110+
existingItem.quantity as number,
111+
item.quantity ?? 1
112+
)
113+
114+
// In case of multiple items merging into the same line item, the
115+
// quantities accumulate, the same way they do when the items are updated.
116+
existingItem.quantity = quantity
117+
118+
quantities[index] = quantity.toNumber()
119+
})
120+
121+
return new StepResponse(quantities)
122+
}
123+
)

packages/core/core-flows/src/cart/steps/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export * from "./find-or-create-customer"
1111
export * from "./find-sales-channel"
1212
export * from "./get-actions-to-compute-from-promotions"
1313
export * from "./get-line-item-actions"
14+
export * from "./get-line-item-pricing-quantities"
1415
export * from "./update-cart-items-translations"
1516
export * from "./get-promotion-codes-to-apply"
1617
export * from "./get-variant-price-sets"

0 commit comments

Comments
 (0)