feat(core-flows,types,utils,medusa): Update existing line items when adding the same variant to cart (#7470)
* feat(core-flows,types,utils,medusa): Update existing line items when adding the same variant to cart * chore: split steps into 2 for add-to-cart * chore: split steps into 2 for add-to-cart * chore: iterate safely * chore: parallelize upsert
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
IRegionModuleService,
|
||||
ISalesChannelModuleService,
|
||||
ITaxModuleService,
|
||||
ProductStatus,
|
||||
} from "@medusajs/types"
|
||||
import {
|
||||
ContainerRegistrationKeys,
|
||||
@@ -1166,14 +1167,65 @@ medusaIntegrationTestRunner({
|
||||
})
|
||||
|
||||
describe("POST /store/carts/:id/line-items", () => {
|
||||
it.skip("should add item to cart", async () => {
|
||||
let region
|
||||
const productData = {
|
||||
title: "Medusa T-Shirt",
|
||||
handle: "t-shirt",
|
||||
status: ProductStatus.PUBLISHED,
|
||||
options: [
|
||||
{
|
||||
title: "Size",
|
||||
values: ["S"],
|
||||
},
|
||||
{
|
||||
title: "Color",
|
||||
values: ["Black", "White"],
|
||||
},
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
title: "S / Black",
|
||||
sku: "SHIRT-S-BLACK",
|
||||
options: {
|
||||
Size: "S",
|
||||
Color: "Black",
|
||||
},
|
||||
manage_inventory: false,
|
||||
prices: [
|
||||
{
|
||||
amount: 1500,
|
||||
currency_code: "usd",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "S / White",
|
||||
sku: "SHIRT-S-WHITE",
|
||||
options: {
|
||||
Size: "S",
|
||||
Color: "White",
|
||||
},
|
||||
manage_inventory: false,
|
||||
prices: [
|
||||
{
|
||||
amount: 1500,
|
||||
currency_code: "usd",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupTaxStructure(taxModule)
|
||||
|
||||
const region = await regionModule.create({
|
||||
region = await regionModule.create({
|
||||
name: "US",
|
||||
currency_code: "usd",
|
||||
})
|
||||
})
|
||||
|
||||
it("should add item to cart", async () => {
|
||||
const customer = await customerModule.create({
|
||||
email: "tony@stark-industries.com",
|
||||
})
|
||||
@@ -1357,6 +1409,82 @@ medusaIntegrationTestRunner({
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("adding an existing variant should update or create line item depending on metadata", async () => {
|
||||
const product = (
|
||||
await api.post(`/admin/products`, productData, adminHeaders)
|
||||
).data.product
|
||||
|
||||
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: 1,
|
||||
metadata: {
|
||||
Size: "S",
|
||||
Color: "Black",
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
).data.cart
|
||||
|
||||
let response = await api.post(`/store/carts/${cart.id}/line-items`, {
|
||||
variant_id: product.variants[0].id,
|
||||
quantity: 1,
|
||||
metadata: {
|
||||
Size: "S",
|
||||
Color: "Black",
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
expect(response.data.cart).toEqual(
|
||||
expect.objectContaining({
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
unit_price: 1500,
|
||||
quantity: 2,
|
||||
title: "S / Black",
|
||||
}),
|
||||
],
|
||||
subtotal: 3000,
|
||||
})
|
||||
)
|
||||
|
||||
response = await api.post(`/store/carts/${cart.id}/line-items`, {
|
||||
variant_id: product.variants[0].id,
|
||||
quantity: 1,
|
||||
metadata: {
|
||||
Size: "S",
|
||||
Color: "White",
|
||||
Special: "attribute",
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
expect(response.data.cart).toEqual(
|
||||
expect.objectContaining({
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
unit_price: 1500,
|
||||
quantity: 2,
|
||||
title: "S / Black",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
unit_price: 1500,
|
||||
quantity: 1,
|
||||
title: "S / Black",
|
||||
}),
|
||||
],
|
||||
subtotal: 4500,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("POST /store/payment-collections", () => {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
|
||||
import { CreateLineItemForCartDTO, ICartModuleService } from "@medusajs/types"
|
||||
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
|
||||
|
||||
interface StepInput {
|
||||
items: CreateLineItemForCartDTO[]
|
||||
}
|
||||
|
||||
export const addToCartStepId = "add-to-cart-step"
|
||||
export const addToCartStep = createStep(
|
||||
addToCartStepId,
|
||||
async (data: StepInput, { container }) => {
|
||||
const cartService = container.resolve<ICartModuleService>(
|
||||
ModuleRegistrationName.CART
|
||||
)
|
||||
|
||||
const items = await cartService.addLineItems(data.items)
|
||||
|
||||
return new StepResponse(items, items)
|
||||
},
|
||||
async (createdLineItems, { container }) => {
|
||||
const cartService: ICartModuleService = container.resolve(
|
||||
ModuleRegistrationName.CART
|
||||
)
|
||||
if (!createdLineItems?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
await cartService.deleteLineItems(createdLineItems.map((c) => c.id))
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
|
||||
import { CreateLineItemForCartDTO, ICartModuleService } from "@medusajs/types"
|
||||
import { createStep, StepResponse } from "@medusajs/workflows-sdk"
|
||||
|
||||
interface StepInput {
|
||||
id: string
|
||||
items: CreateLineItemForCartDTO[]
|
||||
}
|
||||
|
||||
export const createLineItemsStepId = "create-line-items-step"
|
||||
export const createLineItemsStep = createStep(
|
||||
createLineItemsStepId,
|
||||
async (data: StepInput, { container }) => {
|
||||
const cartModule = container.resolve<ICartModuleService>(
|
||||
ModuleRegistrationName.CART
|
||||
)
|
||||
|
||||
const createdItems = data.items.length
|
||||
? await cartModule.addLineItems(data.items)
|
||||
: []
|
||||
|
||||
return new StepResponse(createdItems, createdItems)
|
||||
},
|
||||
async (createdItems, { container }) => {
|
||||
if (!createdItems?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const cartModule: ICartModuleService = container.resolve(
|
||||
ModuleRegistrationName.CART
|
||||
)
|
||||
|
||||
await cartModule.deleteLineItems(createdItems.map((c) => c.id))
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
|
||||
import {
|
||||
CartLineItemDTO,
|
||||
CreateLineItemForCartDTO,
|
||||
ICartModuleService,
|
||||
UpdateLineItemWithSelectorDTO,
|
||||
} from "@medusajs/types"
|
||||
import { deepEqualObj, isPresent, MathBN } from "@medusajs/utils"
|
||||
import { createStep, StepResponse } from "@medusajs/workflows-sdk"
|
||||
|
||||
interface StepInput {
|
||||
id: string
|
||||
items: CreateLineItemForCartDTO[]
|
||||
}
|
||||
|
||||
export const getLineItemActionsStepId = "get-line-item-actions-step"
|
||||
export const getLineItemActionsStep = createStep(
|
||||
getLineItemActionsStepId,
|
||||
async (data: StepInput, { container }) => {
|
||||
const cartModule = container.resolve<ICartModuleService>(
|
||||
ModuleRegistrationName.CART
|
||||
)
|
||||
|
||||
const existingVariantItems = await cartModule.listLineItems({
|
||||
cart_id: data.id,
|
||||
variant_id: data.items.map((d) => d.variant_id!),
|
||||
})
|
||||
|
||||
const variantItemMap = new Map<string, CartLineItemDTO>(
|
||||
existingVariantItems.map((item) => [item.variant_id!, item])
|
||||
)
|
||||
|
||||
const itemsToCreate: CreateLineItemForCartDTO[] = []
|
||||
const itemsToUpdate: UpdateLineItemWithSelectorDTO[] = []
|
||||
|
||||
for (const item of data.items) {
|
||||
const existingItem = variantItemMap.get(item.variant_id!)
|
||||
const metadataMatches =
|
||||
(!isPresent(existingItem?.metadata) && !isPresent(item.metadata)) ||
|
||||
deepEqualObj(existingItem?.metadata, item.metadata)
|
||||
|
||||
if (existingItem && metadataMatches) {
|
||||
const quantity = MathBN.sum(
|
||||
existingItem.quantity as number,
|
||||
item.quantity || 1
|
||||
).toNumber()
|
||||
|
||||
itemsToUpdate.push({
|
||||
selector: { id: existingItem.id },
|
||||
data: { id: existingItem.id, quantity: quantity },
|
||||
})
|
||||
} else {
|
||||
itemsToCreate.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
return new StepResponse({ itemsToCreate, itemsToUpdate }, null)
|
||||
}
|
||||
)
|
||||
@@ -1,8 +1,8 @@
|
||||
export * from "./add-shipping-method-to-cart"
|
||||
export * from "./add-to-cart"
|
||||
export * from "./confirm-inventory"
|
||||
export * from "./create-carts"
|
||||
export * from "./create-line-item-adjustments"
|
||||
export * from "./create-line-items"
|
||||
export * from "./create-order-from-cart"
|
||||
export * from "./create-shipping-method-adjustments"
|
||||
export * from "./find-one-or-any-region"
|
||||
@@ -10,6 +10,7 @@ export * from "./find-or-create-customer"
|
||||
export * from "./find-sales-channel"
|
||||
export * from "./get-actions-to-compute-from-promotions"
|
||||
export * from "./get-item-tax-lines"
|
||||
export * from "./get-line-item-actions"
|
||||
export * from "./get-promotion-codes-to-apply"
|
||||
export * from "./get-variant-price-sets"
|
||||
export * from "./get-variants"
|
||||
@@ -22,6 +23,7 @@ export * from "./retrieve-cart-with-links"
|
||||
export * from "./set-tax-lines-for-items"
|
||||
export * from "./update-cart-promotions"
|
||||
export * from "./update-carts"
|
||||
export * from "./update-line-items"
|
||||
export * from "./validate-cart-payments"
|
||||
export * from "./validate-cart-shipping-options"
|
||||
export * from "./validate-variant-prices"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
|
||||
import {
|
||||
ICartModuleService,
|
||||
UpdateLineItemWithSelectorDTO,
|
||||
} from "@medusajs/types"
|
||||
import { getSelectsAndRelationsFromObjectArray } from "@medusajs/utils"
|
||||
import { createStep, StepResponse } from "@medusajs/workflows-sdk"
|
||||
|
||||
interface StepInput {
|
||||
id: string
|
||||
items: UpdateLineItemWithSelectorDTO[]
|
||||
}
|
||||
|
||||
export const updateLineItemsStepId = "update-line-items-step"
|
||||
export const updateLineItemsStep = createStep(
|
||||
updateLineItemsStepId,
|
||||
async (input: StepInput, { container }) => {
|
||||
const { id, items = [] } = input
|
||||
|
||||
if (!items?.length) {
|
||||
return new StepResponse([], [])
|
||||
}
|
||||
|
||||
const cartModule = container.resolve<ICartModuleService>(
|
||||
ModuleRegistrationName.CART
|
||||
)
|
||||
|
||||
const { selects, relations } = getSelectsAndRelationsFromObjectArray(
|
||||
items.map((item) => item.data)
|
||||
)
|
||||
|
||||
const itemsBeforeUpdate = await cartModule.listLineItems(
|
||||
{ id: items.map((d) => d.selector.id!) },
|
||||
{ select: selects, relations }
|
||||
)
|
||||
|
||||
const updatedItems = items.length
|
||||
? await cartModule.updateLineItems(items)
|
||||
: []
|
||||
|
||||
return new StepResponse(updatedItems, itemsBeforeUpdate)
|
||||
},
|
||||
async (itemsBeforeUpdate, { container }) => {
|
||||
if (!itemsBeforeUpdate?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const cartModule: ICartModuleService = container.resolve(
|
||||
ModuleRegistrationName.CART
|
||||
)
|
||||
|
||||
if (itemsBeforeUpdate.length) {
|
||||
const itemsToUpdate: UpdateLineItemWithSelectorDTO[] = []
|
||||
|
||||
for (const item of itemsBeforeUpdate) {
|
||||
const { id, ...data } = item
|
||||
|
||||
itemsToUpdate.push({ selector: { id }, data })
|
||||
}
|
||||
|
||||
await cartModule.updateLineItems(itemsToUpdate)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -5,10 +5,16 @@ import {
|
||||
import {
|
||||
WorkflowData,
|
||||
createWorkflow,
|
||||
parallelize,
|
||||
transform,
|
||||
} from "@medusajs/workflows-sdk"
|
||||
import { useRemoteQueryStep } from "../../../common/steps/use-remote-query"
|
||||
import { addToCartStep, refreshCartShippingMethodsStep } from "../steps"
|
||||
import {
|
||||
createLineItemsStep,
|
||||
getLineItemActionsStep,
|
||||
refreshCartShippingMethodsStep,
|
||||
updateLineItemsStep,
|
||||
} from "../steps"
|
||||
import { refreshCartPromotionsStep } from "../steps/refresh-cart-promotions"
|
||||
import { updateTaxLinesStep } from "../steps/update-tax-lines"
|
||||
import { validateVariantPricesStep } from "../steps/validate-variant-prices"
|
||||
@@ -78,7 +84,25 @@ export const addToCartWorkflow = createWorkflow(
|
||||
return items
|
||||
})
|
||||
|
||||
const items = addToCartStep({ items: lineItems })
|
||||
const { itemsToCreate = [], itemsToUpdate = [] } = getLineItemActionsStep({
|
||||
id: input.cart.id,
|
||||
items: lineItems,
|
||||
})
|
||||
|
||||
const [createdItems, updatedItems] = parallelize(
|
||||
createLineItemsStep({
|
||||
id: input.cart.id,
|
||||
items: itemsToCreate,
|
||||
}),
|
||||
updateLineItemsStep({
|
||||
id: input.cart.id,
|
||||
items: itemsToUpdate,
|
||||
})
|
||||
)
|
||||
|
||||
const items = transform({ createdItems, updatedItems }, (data) => {
|
||||
return [...(data.createdItems || []), ...(data.updatedItems || [])]
|
||||
})
|
||||
|
||||
const cart = useRemoteQueryStep({
|
||||
entry_point: "cart",
|
||||
@@ -88,9 +112,7 @@ export const addToCartWorkflow = createWorkflow(
|
||||
}).config({ name: "refetch–cart" })
|
||||
|
||||
refreshCartShippingMethodsStep({ cart })
|
||||
// TODO: since refreshCartShippingMethodsStep potentially removes cart shipping methods, we need the updated cart here
|
||||
// for the following 2 steps as they act upon final cart shape
|
||||
updateTaxLinesStep({ cart_or_cart_id: cart, items })
|
||||
updateTaxLinesStep({ cart_or_cart_id: input.cart.id, items })
|
||||
refreshCartPromotionsStep({ id: input.cart.id })
|
||||
refreshPaymentCollectionForCartStep({ cart_id: input.cart.id })
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
transform,
|
||||
} from "@medusajs/workflows-sdk"
|
||||
import { useRemoteQueryStep } from "../../../common/steps/use-remote-query"
|
||||
import { updateLineItemsStep } from "../../line-item/steps"
|
||||
import { updateLineItemsStepWithSelector } from "../../line-item/steps"
|
||||
import { refreshCartShippingMethodsStep } from "../steps"
|
||||
import { refreshCartPromotionsStep } from "../steps/refresh-cart-promotions"
|
||||
import { validateVariantPricesStep } from "../steps/validate-variant-prices"
|
||||
@@ -77,7 +77,7 @@ export const updateLineItemInCartWorkflow = createWorkflow(
|
||||
}
|
||||
})
|
||||
|
||||
const result = updateLineItemsStep(lineItemUpdate)
|
||||
const result = updateLineItemsStepWithSelector(lineItemUpdate)
|
||||
|
||||
const cart = useRemoteQueryStep({
|
||||
entry_point: "cart",
|
||||
|
||||
@@ -10,9 +10,10 @@ import {
|
||||
} from "@medusajs/utils"
|
||||
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
|
||||
|
||||
export const updateLineItemsStepId = "update-line-items"
|
||||
export const updateLineItemsStep = createStep(
|
||||
updateLineItemsStepId,
|
||||
export const updateLineItemsStepWithSelectorId =
|
||||
"update-line-items-with-selector"
|
||||
export const updateLineItemsStepWithSelector = createStep(
|
||||
updateLineItemsStepWithSelectorId,
|
||||
async (input: UpdateLineItemWithSelectorDTO, { container }) => {
|
||||
const service = container.resolve<ICartModuleService>(
|
||||
ModuleRegistrationName.CART
|
||||
|
||||
@@ -560,6 +560,11 @@ export interface CreateLineItemDTO {
|
||||
* The adjustments of the line item.
|
||||
*/
|
||||
adjustments?: CreateAdjustmentDTO[]
|
||||
|
||||
/**
|
||||
* Holds custom data in key-value pairs.
|
||||
*/
|
||||
metadata?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,7 +36,7 @@ export interface CreateCartCreateLineItemDTO {
|
||||
compare_at_unit_price?: BigNumberInput
|
||||
unit_price?: BigNumberInput
|
||||
|
||||
metadata?: Record<string, unknown>
|
||||
metadata?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface UpdateLineItemInCartWorkflowInputDTO {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export function deepEqualObj(obj1: object, obj2: object): boolean {
|
||||
export function deepEqualObj(obj1: unknown, obj2: unknown): boolean {
|
||||
if (typeof obj1 !== typeof obj2) {
|
||||
return false
|
||||
}
|
||||
@@ -7,6 +7,10 @@ export function deepEqualObj(obj1: object, obj2: object): boolean {
|
||||
return obj1 === obj2
|
||||
}
|
||||
|
||||
if (typeof obj2 !== "object" || obj2 === null) {
|
||||
return obj2 === obj1
|
||||
}
|
||||
|
||||
const obj1Keys = Object.keys(obj1)
|
||||
const obj2Keys = Object.keys(obj2)
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ export const defaultStoreCartFields = [
|
||||
"items.variant_sku",
|
||||
"items.variant_barcode",
|
||||
"items.variant_title",
|
||||
"items.metadata",
|
||||
"items.created_at",
|
||||
"items.updated_at",
|
||||
"items.title",
|
||||
|
||||
@@ -8,6 +8,7 @@ export const StoreGetCartsCart = createSelectParams()
|
||||
const ItemSchema = z.object({
|
||||
variant_id: z.string(),
|
||||
quantity: z.number(),
|
||||
metadata: z.record(z.unknown()).optional().nullable(),
|
||||
})
|
||||
|
||||
export type StoreCreateCartType = z.infer<typeof StoreCreateCart>
|
||||
@@ -62,7 +63,7 @@ export type StoreAddCartLineItemType = z.infer<typeof StoreAddCartLineItem>
|
||||
export const StoreAddCartLineItem = z.object({
|
||||
variant_id: z.string(),
|
||||
quantity: z.number(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
metadata: z.record(z.unknown()).optional().nullable(),
|
||||
})
|
||||
|
||||
export type StoreUpdateCartLineItemType = z.infer<
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
CreateShippingMethodDTO,
|
||||
CreateShippingMethodTaxLineDTO,
|
||||
UpdateLineItemDTO,
|
||||
UpdateLineItemTaxLineDTO,
|
||||
UpdateShippingMethodTaxLineDTO,
|
||||
} from "@types"
|
||||
import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config"
|
||||
@@ -1034,7 +1033,7 @@ export default class CartModuleService<
|
||||
}
|
||||
|
||||
const result = await this.lineItemTaxLineService_.upsert(
|
||||
taxLines as UpdateLineItemTaxLineDTO[],
|
||||
taxLines,
|
||||
sharedContext
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user