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,
|
||||
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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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<CartDTO, "items"> {
|
||||
|
||||
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 },
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -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<typeof StoreAddCartLineItem>
|
||||
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(),
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user