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:
6
.changeset/two-turtles-glow.md
Normal file
6
.changeset/two-turtles-glow.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
"@medusajs/core-flows": patch
|
||||||
|
"@medusajs/medusa": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix(core-flows,medusa): don't allow negative line item quantity
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user