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:
Riqwan Thamir
2024-05-27 17:54:11 +02:00
committed by GitHub
parent 81c27e3524
commit 7baedf73d5
16 changed files with 339 additions and 49 deletions

View File

@@ -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", () => {

View File

@@ -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))
}
)

View File

@@ -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))
}
)

View File

@@ -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)
}
)

View File

@@ -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"

View File

@@ -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)
}
}
)

View File

@@ -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: "refetchcart" })
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 })

View File

@@ -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",

View File

@@ -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

View File

@@ -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
}
/**

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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",

View File

@@ -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<

View File

@@ -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
)