Skip to content

Commit 8565dcf

Browse files
authored
fix(core-flows, medusa): don't allow negative line item quantity (medusajs#13508)
* fix(core-flows,medusa): don't allow negative line item quantity * fix: greater than 0 * feat: add test * wip: update update item flow to remove item when qty is 0 * fix: paralelize * fix: when argument * fix: emit event
1 parent 25634b0 commit 8565dcf

File tree

6 files changed

+234
-61
lines changed

6 files changed

+234
-61
lines changed

.changeset/two-turtles-glow.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@medusajs/core-flows": patch
3+
"@medusajs/medusa": patch
4+
---
5+
6+
fix(core-flows,medusa): don't allow negative line item quantity

integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,13 @@ import {
3030
ISalesChannelModuleService,
3131
IStockLocationService,
3232
} from "@medusajs/types"
33-
import { ContainerRegistrationKeys, Modules, PriceListStatus, PriceListType, RuleOperator, } from "@medusajs/utils"
33+
import {
34+
ContainerRegistrationKeys,
35+
Modules,
36+
PriceListStatus,
37+
PriceListType,
38+
RuleOperator,
39+
} from "@medusajs/utils"
3440
import {
3541
adminHeaders,
3642
createAdminUser,

integration-tests/modules/__tests__/cart/store/carts.spec.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,6 +716,122 @@ medusaIntegrationTestRunner({
716716
})
717717
})
718718

719+
it("handle line item quantity edge cases", async () => {
720+
const shippingProfile =
721+
await fulfillmentModule.createShippingProfiles({
722+
name: "Test",
723+
type: "default",
724+
})
725+
726+
const product = (
727+
await api.post(
728+
`/admin/products`,
729+
{
730+
...productData,
731+
shipping_profile_id: shippingProfile.id,
732+
},
733+
adminHeaders
734+
)
735+
).data.product
736+
737+
// cannot create a cart with a negative item quantity
738+
const errorRes = await api
739+
.post(
740+
`/store/carts`,
741+
{
742+
743+
currency_code: region.currency_code,
744+
region_id: region.id,
745+
items: [
746+
{
747+
variant_id: product.variants[0].id,
748+
quantity: -2,
749+
},
750+
],
751+
},
752+
storeHeaders
753+
)
754+
.catch((e) => e)
755+
756+
expect(errorRes.response.status).toEqual(400)
757+
expect(errorRes.response.data).toEqual({
758+
message:
759+
"Invalid request: Value for field 'items, 0, quantity' too small, expected at least: '0'",
760+
type: "invalid_data",
761+
})
762+
763+
const cart = (
764+
await api.post(
765+
`/store/carts`,
766+
{
767+
768+
currency_code: region.currency_code,
769+
region_id: region.id,
770+
items: [
771+
{
772+
variant_id: product.variants[0].id,
773+
quantity: 5,
774+
},
775+
],
776+
},
777+
storeHeaders
778+
)
779+
).data.cart
780+
781+
// cannot add a negative quantity item to the cart
782+
let response = await api
783+
.post(
784+
`/store/carts/${cart.id}/line-items`,
785+
{
786+
variant_id: product.variants[1].id,
787+
quantity: -2,
788+
},
789+
storeHeaders
790+
)
791+
.catch((e) => e)
792+
793+
expect(response.response.status).toEqual(400)
794+
expect(response.response.data).toEqual({
795+
message:
796+
"Invalid request: Value for field 'quantity' too small, expected at least: '0'",
797+
type: "invalid_data",
798+
})
799+
800+
// cannot update a negative quantity item on the cart
801+
response = await api
802+
.post(
803+
`/store/carts/${cart.id}/line-items/${cart.items[0].id}`,
804+
{
805+
quantity: -1,
806+
},
807+
storeHeaders
808+
)
809+
.catch((e) => e)
810+
811+
expect(response.response.status).toEqual(400)
812+
expect(response.response.data).toEqual({
813+
message:
814+
"Invalid request: Value for field 'quantity' too small, expected at least: '0'",
815+
type: "invalid_data",
816+
})
817+
818+
// should remove the item from the cart when quantity is 0
819+
const cartResponse = await api.post(
820+
`/store/carts/${cart.id}/line-items/${cart.items[0].id}`,
821+
{
822+
quantity: 0,
823+
},
824+
storeHeaders
825+
)
826+
827+
expect(cartResponse.status).toEqual(200)
828+
expect(cartResponse.data.cart).toEqual(
829+
expect.objectContaining({
830+
items: expect.arrayContaining([]),
831+
})
832+
)
833+
})
834+
719835
it("adding an existing variant should update or create line item depending on metadata", async () => {
720836
const shippingProfile =
721837
await fulfillmentModule.createShippingProfiles({

packages/core/core-flows/src/cart/utils/prepare-line-item-data.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
isDefined,
1313
isPresent,
1414
MathBN,
15+
MedusaError,
1516
PriceListType,
1617
} from "@medusajs/framework/utils"
1718

@@ -91,7 +92,17 @@ export function prepareLineItemData(data: PrepareLineItemDataInput) {
9192
} = data
9293

9394
if (variant && !variant.product) {
94-
throw new Error("Variant does not have a product")
95+
throw new MedusaError(
96+
MedusaError.Types.INVALID_DATA,
97+
"Variant does not have a product"
98+
)
99+
}
100+
101+
if (item && MathBN.lte(item.quantity, 0)) {
102+
throw new MedusaError(
103+
MedusaError.Types.INVALID_DATA,
104+
"Item quantity must be greater than 0"
105+
)
95106
}
96107

97108
let compareAtUnitPrice = item?.compare_at_unit_price
@@ -196,6 +207,6 @@ export function prepareAdjustmentsData(data: CreateOrderAdjustmentDTO[]) {
196207
description: d.description,
197208
promotion_id: d.promotion_id,
198209
provider_id: d.provider_id,
199-
is_tax_inclusive: d.is_tax_inclusive
210+
is_tax_inclusive: d.is_tax_inclusive,
200211
}))
201212
}

packages/core/core-flows/src/cart/workflows/update-line-item-in-cart.ts

Lines changed: 89 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
isDefined,
1313
MedusaError,
1414
QueryContext,
15+
MathBN,
1516
} from "@medusajs/framework/utils"
1617
import {
1718
createHook,
@@ -36,6 +37,7 @@ import { requiredVariantFieldsForInventoryConfirmation } from "../utils/prepare-
3637
import { pricingContextResult } from "../utils/schemas"
3738
import { confirmVariantInventoryWorkflow } from "./confirm-variant-inventory"
3839
import { refreshCartItemsWorkflow } from "./refresh-cart-items"
40+
import { deleteLineItemsWorkflow } from "../../line-item"
3941

4042
const cartFields = cartFieldsForPricingContext.concat(["items.*"])
4143
const variantFields = productVariantsFields.concat(["calculated_price.*"])
@@ -48,8 +50,9 @@ interface CartQueryDTO extends Omit<CartDTO, "items"> {
4850

4951
export const updateLineItemInCartWorkflowId = "update-line-item-in-cart"
5052
/**
51-
* This workflow updates a line item's details in a cart. You can update the line item's quantity, unit price, and more. This workflow is executed
52-
* by the [Update Line Item Store API Route](https://docs.medusajs.com/api/store#carts_postcartsidlineitemsline_id).
53+
* This workflow updates a line item's details in a cart. You can update the line item's quantity, unit price, and more.
54+
* If the quantity is set to 0, the item will be removed from the cart.
55+
* This workflow is executed by the [Update Line Item Store API Route](https://docs.medusajs.com/api/store#carts_postcartsidlineitemsline_id).
5356
*
5457
* You can use this workflow within your own customizations or custom workflows, allowing you to update a line item's details in your custom flows.
5558
*
@@ -184,11 +187,33 @@ export const updateLineItemInCartWorkflow = createWorkflow(
184187
}
185188
)
186189

190+
const shouldRemoveItem = transform(
191+
{ input },
192+
({ input }) =>
193+
!!(
194+
isDefined(input.update.quantity) &&
195+
MathBN.eq(input.update.quantity, 0)
196+
)
197+
)
198+
199+
when(
200+
"should-remove-item",
201+
{ shouldRemoveItem },
202+
({ shouldRemoveItem }) => shouldRemoveItem
203+
).then(() => {
204+
deleteLineItemsWorkflow.runAsStep({
205+
input: {
206+
cart_id: input.cart_id,
207+
ids: [input.item_id],
208+
},
209+
})
210+
})
211+
187212
const variants = when(
188213
"should-fetch-variants",
189-
{ variantIds },
190-
({ variantIds }) => {
191-
return !!variantIds.length
214+
{ variantIds, shouldRemoveItem },
215+
({ variantIds, shouldRemoveItem }) => {
216+
return !!variantIds.length && !shouldRemoveItem
192217
}
193218
).then(() => {
194219
const calculatedPriceQueryContext = transform(
@@ -217,70 +242,79 @@ export const updateLineItemInCartWorkflow = createWorkflow(
217242
return variants
218243
})
219244

220-
const items = transform({ input, item }, (data) => {
221-
return [
222-
Object.assign(data.item, { quantity: data.input.update.quantity }),
223-
]
224-
})
245+
when(
246+
"should-update-item",
247+
{ shouldRemoveItem },
248+
({ shouldRemoveItem }) => !shouldRemoveItem
249+
).then(() => {
250+
const items = transform({ input, item }, (data) => {
251+
return [
252+
Object.assign(data.item, { quantity: data.input.update.quantity }),
253+
]
254+
})
225255

226-
confirmVariantInventoryWorkflow.runAsStep({
227-
input: {
228-
sales_channel_id: pricingContext.sales_channel_id,
229-
variants,
230-
items,
231-
},
232-
})
256+
confirmVariantInventoryWorkflow.runAsStep({
257+
input: {
258+
sales_channel_id: pricingContext.sales_channel_id,
259+
variants,
260+
items,
261+
},
262+
})
233263

234-
const lineItemUpdate = transform({ input, variants, item }, (data) => {
235-
const variant = data.variants?.[0] ?? undefined
236-
const item = data.item
264+
const lineItemUpdate = transform(
265+
{ input, variants, item, pricingContext },
266+
(data) => {
267+
const variant = data.variants?.[0] ?? undefined
268+
const item = data.item
237269

238-
const updateData = {
239-
...data.input.update,
240-
unit_price: isDefined(data.input.update.unit_price)
241-
? data.input.update.unit_price
242-
: item.unit_price,
243-
is_custom_price: isDefined(data.input.update.unit_price)
244-
? true
245-
: item.is_custom_price,
246-
is_tax_inclusive:
247-
item.is_tax_inclusive ||
248-
variant?.calculated_price?.is_calculated_price_tax_inclusive,
249-
}
270+
const updateData = {
271+
...data.input.update,
272+
unit_price: isDefined(data.input.update.unit_price)
273+
? data.input.update.unit_price
274+
: item.unit_price,
275+
is_custom_price: isDefined(data.input.update.unit_price)
276+
? true
277+
: item.is_custom_price,
278+
is_tax_inclusive:
279+
item.is_tax_inclusive ||
280+
variant?.calculated_price?.is_calculated_price_tax_inclusive,
281+
}
250282

251-
if (variant && !updateData.is_custom_price) {
252-
updateData.unit_price = variant.calculated_price.calculated_amount
253-
}
283+
if (variant && !updateData.is_custom_price) {
284+
updateData.unit_price = variant.calculated_price.calculated_amount
285+
}
254286

255-
if (!isDefined(updateData.unit_price)) {
256-
throw new MedusaError(
257-
MedusaError.Types.INVALID_DATA,
258-
`Line item ${item.title} has no unit price`
259-
)
260-
}
287+
if (!isDefined(updateData.unit_price)) {
288+
throw new MedusaError(
289+
MedusaError.Types.INVALID_DATA,
290+
`Line item ${item.title} has no unit price`
291+
)
292+
}
261293

262-
return {
263-
data: updateData,
264-
selector: {
265-
id: data.input.item_id,
266-
},
267-
}
268-
})
294+
return {
295+
data: updateData,
296+
selector: {
297+
id: data.input.item_id,
298+
},
299+
}
300+
}
301+
)
269302

270-
updateLineItemsStepWithSelector(lineItemUpdate)
303+
updateLineItemsStepWithSelector(lineItemUpdate)
271304

272-
refreshCartItemsWorkflow.runAsStep({
273-
input: { cart_id: input.cart_id },
305+
refreshCartItemsWorkflow.runAsStep({
306+
input: { cart_id: input.cart_id },
307+
})
274308
})
275309

276310
parallelize(
277-
emitEventStep({
278-
eventName: CartWorkflowEvents.UPDATED,
279-
data: { id: input.cart_id },
280-
}),
281311
releaseLockStep({
282312
key: input.cart_id,
283313
skipOnSubWorkflow: true,
314+
}),
315+
emitEventStep({
316+
eventName: CartWorkflowEvents.UPDATED,
317+
data: { id: input.cart_id },
284318
})
285319
)
286320

0 commit comments

Comments
 (0)