fix(core-flows, medusa): don't allow negative line item quantity (#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
This commit is contained in:
Frane Polić
2025-09-16 11:54:20 +02:00
committed by GitHub
parent 25634b0382
commit 8565dcfc46
6 changed files with 238 additions and 65 deletions

View File

@@ -0,0 +1,6 @@
---
"@medusajs/core-flows": patch
"@medusajs/medusa": patch
---
fix(core-flows,medusa): don't allow negative line item quantity

View File

@@ -30,7 +30,13 @@ import {
ISalesChannelModuleService, ISalesChannelModuleService,
IStockLocationService, IStockLocationService,
} from "@medusajs/types" } from "@medusajs/types"
import { ContainerRegistrationKeys, Modules, PriceListStatus, PriceListType, RuleOperator, } from "@medusajs/utils" import {
ContainerRegistrationKeys,
Modules,
PriceListStatus,
PriceListType,
RuleOperator,
} from "@medusajs/utils"
import { import {
adminHeaders, adminHeaders,
createAdminUser, createAdminUser,

View File

@@ -716,6 +716,122 @@ medusaIntegrationTestRunner({
}) })
}) })
it("handle line item quantity edge cases", async () => {
const shippingProfile =
await fulfillmentModule.createShippingProfiles({
name: "Test",
type: "default",
})
const product = (
await api.post(
`/admin/products`,
{
...productData,
shipping_profile_id: shippingProfile.id,
},
adminHeaders
)
).data.product
// cannot create a cart with a negative item quantity
const errorRes = await api
.post(
`/store/carts`,
{
email: "tony@stark.com",
currency_code: region.currency_code,
region_id: region.id,
items: [
{
variant_id: product.variants[0].id,
quantity: -2,
},
],
},
storeHeaders
)
.catch((e) => e)
expect(errorRes.response.status).toEqual(400)
expect(errorRes.response.data).toEqual({
message:
"Invalid request: Value for field 'items, 0, quantity' too small, expected at least: '0'",
type: "invalid_data",
})
const cart = (
await api.post(
`/store/carts`,
{
email: "tony@stark.com",
currency_code: region.currency_code,
region_id: region.id,
items: [
{
variant_id: product.variants[0].id,
quantity: 5,
},
],
},
storeHeaders
)
).data.cart
// cannot add a negative quantity item to the cart
let response = await api
.post(
`/store/carts/${cart.id}/line-items`,
{
variant_id: product.variants[1].id,
quantity: -2,
},
storeHeaders
)
.catch((e) => e)
expect(response.response.status).toEqual(400)
expect(response.response.data).toEqual({
message:
"Invalid request: Value for field 'quantity' too small, expected at least: '0'",
type: "invalid_data",
})
// cannot update a negative quantity item on the cart
response = await api
.post(
`/store/carts/${cart.id}/line-items/${cart.items[0].id}`,
{
quantity: -1,
},
storeHeaders
)
.catch((e) => e)
expect(response.response.status).toEqual(400)
expect(response.response.data).toEqual({
message:
"Invalid request: Value for field 'quantity' too small, expected at least: '0'",
type: "invalid_data",
})
// should remove the item from the cart when quantity is 0
const cartResponse = await api.post(
`/store/carts/${cart.id}/line-items/${cart.items[0].id}`,
{
quantity: 0,
},
storeHeaders
)
expect(cartResponse.status).toEqual(200)
expect(cartResponse.data.cart).toEqual(
expect.objectContaining({
items: expect.arrayContaining([]),
})
)
})
it("adding an existing variant should update or create line item depending on metadata", async () => { it("adding an existing variant should update or create line item depending on metadata", async () => {
const shippingProfile = const shippingProfile =
await fulfillmentModule.createShippingProfiles({ await fulfillmentModule.createShippingProfiles({

View File

@@ -12,6 +12,7 @@ import {
isDefined, isDefined,
isPresent, isPresent,
MathBN, MathBN,
MedusaError,
PriceListType, PriceListType,
} from "@medusajs/framework/utils" } from "@medusajs/framework/utils"
@@ -91,7 +92,17 @@ export function prepareLineItemData(data: PrepareLineItemDataInput) {
} = data } = data
if (variant && !variant.product) { if (variant && !variant.product) {
throw new Error("Variant does not have a product") throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Variant does not have a product"
)
}
if (item && MathBN.lte(item.quantity, 0)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Item quantity must be greater than 0"
)
} }
let compareAtUnitPrice = item?.compare_at_unit_price let compareAtUnitPrice = item?.compare_at_unit_price
@@ -196,6 +207,6 @@ export function prepareAdjustmentsData(data: CreateOrderAdjustmentDTO[]) {
description: d.description, description: d.description,
promotion_id: d.promotion_id, promotion_id: d.promotion_id,
provider_id: d.provider_id, provider_id: d.provider_id,
is_tax_inclusive: d.is_tax_inclusive is_tax_inclusive: d.is_tax_inclusive,
})) }))
} }

View File

@@ -12,6 +12,7 @@ import {
isDefined, isDefined,
MedusaError, MedusaError,
QueryContext, QueryContext,
MathBN,
} from "@medusajs/framework/utils" } from "@medusajs/framework/utils"
import { import {
createHook, createHook,
@@ -36,6 +37,7 @@ import { requiredVariantFieldsForInventoryConfirmation } from "../utils/prepare-
import { pricingContextResult } from "../utils/schemas" import { pricingContextResult } from "../utils/schemas"
import { confirmVariantInventoryWorkflow } from "./confirm-variant-inventory" import { confirmVariantInventoryWorkflow } from "./confirm-variant-inventory"
import { refreshCartItemsWorkflow } from "./refresh-cart-items" import { refreshCartItemsWorkflow } from "./refresh-cart-items"
import { deleteLineItemsWorkflow } from "../../line-item"
const cartFields = cartFieldsForPricingContext.concat(["items.*"]) const cartFields = cartFieldsForPricingContext.concat(["items.*"])
const variantFields = productVariantsFields.concat(["calculated_price.*"]) const variantFields = productVariantsFields.concat(["calculated_price.*"])
@@ -48,8 +50,9 @@ interface CartQueryDTO extends Omit<CartDTO, "items"> {
export const updateLineItemInCartWorkflowId = "update-line-item-in-cart" export const updateLineItemInCartWorkflowId = "update-line-item-in-cart"
/** /**
* 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 * This workflow updates a line item's details in a cart. You can update the line item's quantity, unit price, and more.
* by the [Update Line Item Store API Route](https://docs.medusajs.com/api/store#carts_postcartsidlineitemsline_id). * If the quantity is set to 0, the item will be removed from the cart.
* This workflow is executed by the [Update Line Item Store API Route](https://docs.medusajs.com/api/store#carts_postcartsidlineitemsline_id).
* *
* 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. * 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.
* *
@@ -184,11 +187,33 @@ export const updateLineItemInCartWorkflow = createWorkflow(
} }
) )
const shouldRemoveItem = transform(
{ input },
({ input }) =>
!!(
isDefined(input.update.quantity) &&
MathBN.eq(input.update.quantity, 0)
)
)
when(
"should-remove-item",
{ shouldRemoveItem },
({ shouldRemoveItem }) => shouldRemoveItem
).then(() => {
deleteLineItemsWorkflow.runAsStep({
input: {
cart_id: input.cart_id,
ids: [input.item_id],
},
})
})
const variants = when( const variants = when(
"should-fetch-variants", "should-fetch-variants",
{ variantIds }, { variantIds, shouldRemoveItem },
({ variantIds }) => { ({ variantIds, shouldRemoveItem }) => {
return !!variantIds.length return !!variantIds.length && !shouldRemoveItem
} }
).then(() => { ).then(() => {
const calculatedPriceQueryContext = transform( const calculatedPriceQueryContext = transform(
@@ -217,70 +242,79 @@ export const updateLineItemInCartWorkflow = createWorkflow(
return variants return variants
}) })
const items = transform({ input, item }, (data) => { when(
return [ "should-update-item",
Object.assign(data.item, { quantity: data.input.update.quantity }), { shouldRemoveItem },
] ({ shouldRemoveItem }) => !shouldRemoveItem
}) ).then(() => {
const items = transform({ input, item }, (data) => {
return [
Object.assign(data.item, { quantity: data.input.update.quantity }),
]
})
confirmVariantInventoryWorkflow.runAsStep({ confirmVariantInventoryWorkflow.runAsStep({
input: { input: {
sales_channel_id: pricingContext.sales_channel_id, sales_channel_id: pricingContext.sales_channel_id,
variants, variants,
items, items,
},
})
const lineItemUpdate = transform({ input, variants, item }, (data) => {
const variant = data.variants?.[0] ?? undefined
const item = data.item
const updateData = {
...data.input.update,
unit_price: isDefined(data.input.update.unit_price)
? data.input.update.unit_price
: item.unit_price,
is_custom_price: isDefined(data.input.update.unit_price)
? true
: item.is_custom_price,
is_tax_inclusive:
item.is_tax_inclusive ||
variant?.calculated_price?.is_calculated_price_tax_inclusive,
}
if (variant && !updateData.is_custom_price) {
updateData.unit_price = variant.calculated_price.calculated_amount
}
if (!isDefined(updateData.unit_price)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Line item ${item.title} has no unit price`
)
}
return {
data: updateData,
selector: {
id: data.input.item_id,
}, },
} })
})
updateLineItemsStepWithSelector(lineItemUpdate) const lineItemUpdate = transform(
{ input, variants, item, pricingContext },
(data) => {
const variant = data.variants?.[0] ?? undefined
const item = data.item
refreshCartItemsWorkflow.runAsStep({ const updateData = {
input: { cart_id: input.cart_id }, ...data.input.update,
unit_price: isDefined(data.input.update.unit_price)
? data.input.update.unit_price
: item.unit_price,
is_custom_price: isDefined(data.input.update.unit_price)
? true
: item.is_custom_price,
is_tax_inclusive:
item.is_tax_inclusive ||
variant?.calculated_price?.is_calculated_price_tax_inclusive,
}
if (variant && !updateData.is_custom_price) {
updateData.unit_price = variant.calculated_price.calculated_amount
}
if (!isDefined(updateData.unit_price)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Line item ${item.title} has no unit price`
)
}
return {
data: updateData,
selector: {
id: data.input.item_id,
},
}
}
)
updateLineItemsStepWithSelector(lineItemUpdate)
refreshCartItemsWorkflow.runAsStep({
input: { cart_id: input.cart_id },
})
}) })
parallelize( parallelize(
emitEventStep({
eventName: CartWorkflowEvents.UPDATED,
data: { id: input.cart_id },
}),
releaseLockStep({ releaseLockStep({
key: input.cart_id, key: input.cart_id,
skipOnSubWorkflow: true, skipOnSubWorkflow: true,
}),
emitEventStep({
eventName: CartWorkflowEvents.UPDATED,
data: { id: input.cart_id },
}) })
) )

View File

@@ -7,7 +7,7 @@ export const StoreGetCartsCart = createSelectParams()
const ItemSchema = z.object({ const ItemSchema = z.object({
variant_id: z.string(), variant_id: z.string(),
quantity: z.number(), quantity: z.number().gt(0),
metadata: z.record(z.unknown()).nullish(), metadata: z.record(z.unknown()).nullish(),
}) })
@@ -65,7 +65,7 @@ export const StoreCalculateCartTaxes = createSelectParams()
export type StoreAddCartLineItemType = z.infer<typeof StoreAddCartLineItem> export type StoreAddCartLineItemType = z.infer<typeof StoreAddCartLineItem>
export const StoreAddCartLineItem = z.object({ export const StoreAddCartLineItem = z.object({
variant_id: z.string(), variant_id: z.string(),
quantity: z.number(), quantity: z.number().gt(0),
metadata: z.record(z.unknown()).nullish(), metadata: z.record(z.unknown()).nullish(),
}) })
@@ -73,7 +73,7 @@ export type StoreUpdateCartLineItemType = z.infer<
typeof StoreUpdateCartLineItem typeof StoreUpdateCartLineItem
> >
export const StoreUpdateCartLineItem = z.object({ export const StoreUpdateCartLineItem = z.object({
quantity: z.number(), quantity: z.number().gte(0), // can be 0 to remove the item from the cart
metadata: z.record(z.unknown()).nullish(), metadata: z.record(z.unknown()).nullish(),
}) })