From 8565dcfc46c9ccc923a44582bb215c615ac2792f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frane=20Poli=C4=87?= <16856471+fPolic@users.noreply.github.com> Date: Tue, 16 Sep 2025 11:54:20 +0200 Subject: [PATCH] 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 --- .changeset/two-turtles-glow.md | 6 + .../cart/store/cart.workflows.spec.ts | 8 +- .../__tests__/cart/store/carts.spec.ts | 116 +++++++++++++ .../src/cart/utils/prepare-line-item-data.ts | 15 +- .../workflows/update-line-item-in-cart.ts | 152 +++++++++++------- .../medusa/src/api/store/carts/validators.ts | 6 +- 6 files changed, 238 insertions(+), 65 deletions(-) create mode 100644 .changeset/two-turtles-glow.md diff --git a/.changeset/two-turtles-glow.md b/.changeset/two-turtles-glow.md new file mode 100644 index 0000000000..8d34c4a5bb --- /dev/null +++ b/.changeset/two-turtles-glow.md @@ -0,0 +1,6 @@ +--- +"@medusajs/core-flows": patch +"@medusajs/medusa": patch +--- + +fix(core-flows,medusa): don't allow negative line item quantity diff --git a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts index 5fcd35cc0a..256af175ac 100644 --- a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts @@ -30,7 +30,13 @@ import { ISalesChannelModuleService, IStockLocationService, } from "@medusajs/types" -import { ContainerRegistrationKeys, Modules, PriceListStatus, PriceListType, RuleOperator, } from "@medusajs/utils" +import { + ContainerRegistrationKeys, + Modules, + PriceListStatus, + PriceListType, + RuleOperator, +} from "@medusajs/utils" import { adminHeaders, createAdminUser, diff --git a/integration-tests/modules/__tests__/cart/store/carts.spec.ts b/integration-tests/modules/__tests__/cart/store/carts.spec.ts index 2a3d21ad28..ef0900de0b 100644 --- a/integration-tests/modules/__tests__/cart/store/carts.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/carts.spec.ts @@ -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 () => { const shippingProfile = await fulfillmentModule.createShippingProfiles({ diff --git a/packages/core/core-flows/src/cart/utils/prepare-line-item-data.ts b/packages/core/core-flows/src/cart/utils/prepare-line-item-data.ts index a553ebbfd1..1ba0d7fe34 100644 --- a/packages/core/core-flows/src/cart/utils/prepare-line-item-data.ts +++ b/packages/core/core-flows/src/cart/utils/prepare-line-item-data.ts @@ -12,6 +12,7 @@ import { isDefined, isPresent, MathBN, + MedusaError, PriceListType, } from "@medusajs/framework/utils" @@ -91,7 +92,17 @@ export function prepareLineItemData(data: PrepareLineItemDataInput) { } = data 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 @@ -196,6 +207,6 @@ export function prepareAdjustmentsData(data: CreateOrderAdjustmentDTO[]) { description: d.description, promotion_id: d.promotion_id, provider_id: d.provider_id, - is_tax_inclusive: d.is_tax_inclusive + is_tax_inclusive: d.is_tax_inclusive, })) } diff --git a/packages/core/core-flows/src/cart/workflows/update-line-item-in-cart.ts b/packages/core/core-flows/src/cart/workflows/update-line-item-in-cart.ts index 86e51a1775..ddd8c7085a 100644 --- a/packages/core/core-flows/src/cart/workflows/update-line-item-in-cart.ts +++ b/packages/core/core-flows/src/cart/workflows/update-line-item-in-cart.ts @@ -12,6 +12,7 @@ import { isDefined, MedusaError, QueryContext, + MathBN, } from "@medusajs/framework/utils" import { createHook, @@ -36,6 +37,7 @@ import { requiredVariantFieldsForInventoryConfirmation } from "../utils/prepare- import { pricingContextResult } from "../utils/schemas" import { confirmVariantInventoryWorkflow } from "./confirm-variant-inventory" import { refreshCartItemsWorkflow } from "./refresh-cart-items" +import { deleteLineItemsWorkflow } from "../../line-item" const cartFields = cartFieldsForPricingContext.concat(["items.*"]) const variantFields = productVariantsFields.concat(["calculated_price.*"]) @@ -48,8 +50,9 @@ interface CartQueryDTO extends Omit { 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 - * by the [Update Line Item Store API Route](https://docs.medusajs.com/api/store#carts_postcartsidlineitemsline_id). + * This workflow updates a line item's details in a cart. You can update the line item's quantity, unit price, and more. + * 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. * @@ -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( "should-fetch-variants", - { variantIds }, - ({ variantIds }) => { - return !!variantIds.length + { variantIds, shouldRemoveItem }, + ({ variantIds, shouldRemoveItem }) => { + return !!variantIds.length && !shouldRemoveItem } ).then(() => { const calculatedPriceQueryContext = transform( @@ -217,70 +242,79 @@ export const updateLineItemInCartWorkflow = createWorkflow( return variants }) - const items = transform({ input, item }, (data) => { - return [ - Object.assign(data.item, { quantity: data.input.update.quantity }), - ] - }) + when( + "should-update-item", + { shouldRemoveItem }, + ({ shouldRemoveItem }) => !shouldRemoveItem + ).then(() => { + const items = transform({ input, item }, (data) => { + return [ + Object.assign(data.item, { quantity: data.input.update.quantity }), + ] + }) - confirmVariantInventoryWorkflow.runAsStep({ - input: { - sales_channel_id: pricingContext.sales_channel_id, - variants, - 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, + confirmVariantInventoryWorkflow.runAsStep({ + input: { + sales_channel_id: pricingContext.sales_channel_id, + variants, + items, }, - } - }) + }) - updateLineItemsStepWithSelector(lineItemUpdate) + const lineItemUpdate = transform( + { input, variants, item, pricingContext }, + (data) => { + const variant = data.variants?.[0] ?? undefined + const item = data.item - refreshCartItemsWorkflow.runAsStep({ - input: { cart_id: input.cart_id }, + 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) + + refreshCartItemsWorkflow.runAsStep({ + input: { cart_id: input.cart_id }, + }) }) parallelize( - emitEventStep({ - eventName: CartWorkflowEvents.UPDATED, - data: { id: input.cart_id }, - }), releaseLockStep({ key: input.cart_id, skipOnSubWorkflow: true, + }), + emitEventStep({ + eventName: CartWorkflowEvents.UPDATED, + data: { id: input.cart_id }, }) ) diff --git a/packages/medusa/src/api/store/carts/validators.ts b/packages/medusa/src/api/store/carts/validators.ts index f7ee20fc73..e9b1100fc6 100644 --- a/packages/medusa/src/api/store/carts/validators.ts +++ b/packages/medusa/src/api/store/carts/validators.ts @@ -7,7 +7,7 @@ export const StoreGetCartsCart = createSelectParams() const ItemSchema = z.object({ variant_id: z.string(), - quantity: z.number(), + quantity: z.number().gt(0), metadata: z.record(z.unknown()).nullish(), }) @@ -65,7 +65,7 @@ export const StoreCalculateCartTaxes = createSelectParams() export type StoreAddCartLineItemType = z.infer export const StoreAddCartLineItem = z.object({ variant_id: z.string(), - quantity: z.number(), + quantity: z.number().gt(0), metadata: z.record(z.unknown()).nullish(), }) @@ -73,7 +73,7 @@ export type StoreUpdateCartLineItemType = z.infer< typeof StoreUpdateCartLineItem > 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(), })