feat: Create cart with line items (#6449)

**What**
- Add support for creating a cart with items
- Add endpoint `POST /store/carts/:id/line-items`
- Add `CreateCartWorkflow`
- Add `AddToCartWorkflow`
- Add steps for both workflows

**Testing**
- Endpoints
- Workflows

I would still call this a first iteration, as we are missing a few pieces of the full flow, such as payment sessions, discounts, and taxes.

Co-authored-by: Adrien de Peretti <25098370+adrien2p@users.noreply.github.com>
This commit is contained in:
Oli Juhl
2024-02-26 13:32:16 +00:00
committed by GitHub
co-authored by Adrien de Peretti
parent ac86362e81
commit 7ebe885ec9
26 changed files with 1100 additions and 157 deletions
@@ -0,0 +1,31 @@
import { CreateLineItemForCartDTO, ICartModuleService } from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
import { ModuleRegistrationName } from "../../../../../modules-sdk/dist"
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)
},
async (createdLineItems, { container }) => {
const cartService: ICartModuleService = container.resolve(
ModuleRegistrationName.CART
)
if (!createdLineItems?.length) {
return
}
await cartService.removeLineItems(createdLineItems.map((c) => c.id))
}
)
@@ -1,5 +1,6 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IRegionModuleService } from "@medusajs/types"
import { MedusaError } from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
export const findOneOrAnyRegionStepId = "find-one-or-any-region"
@@ -14,7 +15,10 @@ export const findOneOrAnyRegionStep = createStep(
const regions = await service.list({})
if (!regions?.length) {
throw Error("No regions found")
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"No regions found"
)
}
return new StepResponse(regions[0])
@@ -0,0 +1,82 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IPricingModuleService } from "@medusajs/types"
import { MedusaError } from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
interface StepInput {
variantIds: string[]
context?: Record<string, unknown>
}
export const getVariantPriceSetsStepId = "get-variant-price-sets"
export const getVariantPriceSetsStep = createStep(
getVariantPriceSetsStepId,
async (data: StepInput, { container }) => {
if (!data.variantIds.length) {
return new StepResponse({})
}
const pricingModuleService = container.resolve<IPricingModuleService>(
ModuleRegistrationName.PRICING
)
const remoteQuery = container.resolve("remoteQuery")
const variantPriceSets = await remoteQuery(
{
variant: {
fields: ["id"],
price: {
fields: ["price_set_id"],
},
},
},
{
variant: {
id: data.variantIds,
},
}
)
const notFound: string[] = []
const priceSetIds: string[] = []
variantPriceSets.forEach((v) => {
if (v.price?.price_set_id) {
priceSetIds.push(v.price.price_set_id)
} else {
notFound.push(v.id)
}
})
if (notFound.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Variants with IDs ${notFound.join(", ")} do not have a price`
)
}
const calculatedPriceSets = await pricingModuleService.calculatePrices(
{ id: priceSetIds },
{ context: data.context as Record<string, string | number> }
)
const idToPriceSet = new Map<string, Record<string, any>>(
calculatedPriceSets.map((p) => [p.id, p])
)
const variantToCalculatedPriceSets = variantPriceSets.reduce(
(acc, { id, price }) => {
const calculatedPriceSet = idToPriceSet.get(price?.price_set_id)
if (calculatedPriceSet) {
acc[id] = calculatedPriceSet
}
return acc
},
{}
)
return new StepResponse(variantToCalculatedPriceSets)
}
)
@@ -0,0 +1,30 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import {
FilterableProductVariantProps,
FindConfig,
IProductModuleService,
ProductVariantDTO,
} from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
interface StepInput {
filter?: FilterableProductVariantProps
config?: FindConfig<ProductVariantDTO>
}
export const getVariantsStepId = "get-variants"
export const getVariantsStep = createStep(
getVariantsStepId,
async (data: StepInput, { container }) => {
const productModuleService = container.resolve<IProductModuleService>(
ModuleRegistrationName.PRODUCT
)
const variants = await productModuleService.listVariants(
data.filter,
data.config
)
return new StepResponse(variants)
}
)
@@ -1,3 +1,4 @@
export * from "./add-to-cart"
export * from "./create-carts"
export * from "./create-line-item-adjustments"
export * from "./create-shipping-method-adjustments"
@@ -5,8 +6,12 @@ export * from "./find-one-or-any-region"
export * from "./find-or-create-customer"
export * from "./find-sales-channel"
export * from "./get-actions-to-compute-from-promotions"
export * from "./get-variant-price-sets"
export * from "./get-variants"
export * from "./prepare-adjustments-from-promotion-actions"
export * from "./remove-line-item-adjustments"
export * from "./remove-shipping-method-adjustments"
export * from "./retrieve-cart"
export * from "./update-carts"
export * from "./validate-variants-existence"
@@ -0,0 +1,42 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IProductModuleService } from "@medusajs/types"
import { MedusaError } from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
interface StepInput {
variantIds: string[]
}
export const validateVariantsExistStepId = "validate-variants-exist"
export const validateVariantsExistStep = createStep(
validateVariantsExistStepId,
async (data: StepInput, { container }) => {
const productModuleService = container.resolve<IProductModuleService>(
ModuleRegistrationName.PRODUCT
)
const variants = await productModuleService.listVariants(
{
id: data.variantIds,
},
{
select: ["id"],
}
)
const variantIdToData = new Set(variants.map((v) => v.id))
const notFoundVariants = new Set(
[...data.variantIds].filter((x) => !variantIdToData.has(x))
)
if (notFoundVariants.size) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Variants with IDs ${[...notFoundVariants].join(", ")} do not exist`
)
}
return new StepResponse(Array.from(variants.map((v) => v.id)))
}
)
@@ -0,0 +1,42 @@
import { ProductVariantDTO } from "@medusajs/types"
interface Input {
quantity: number
metadata?: Record<string, any>
unitPrice: number
variant: ProductVariantDTO
cartId?: string
}
export function prepareLineItemData(data: Input) {
const { variant, unitPrice, quantity, metadata, cartId } = data
const lineItem: any = {
quantity,
title: variant.title,
subtitle: variant.product.title,
thumbnail: variant.product.thumbnail,
product_id: variant.product.id,
product_title: variant.product.title,
product_description: variant.product.description,
product_subtitle: variant.product.subtitle,
product_type: variant.product.type?.[0].value ?? null,
product_collection: variant.product.collection?.[0].value ?? null,
product_handle: variant.product.handle,
variant_id: variant.id,
variant_sku: variant.sku,
variant_barcode: variant.barcode,
variant_title: variant.title,
unit_price: unitPrice,
metadata,
}
if (cartId) {
lineItem.cart_id = cartId
}
return lineItem
}
@@ -0,0 +1,75 @@
import {
AddToCartWorkflowInputDTO,
CreateLineItemForCartDTO,
} from "@medusajs/types"
import {
WorkflowData,
createWorkflow,
transform,
} from "@medusajs/workflows-sdk"
import {
addToCartStep,
getVariantPriceSetsStep,
getVariantsStep,
validateVariantsExistStep,
} from "../steps"
import { prepareLineItemData } from "../utils/prepare-line-item-data"
// TODO: The AddToCartWorkflow are missing the following steps:
// - Confirm inventory exists (inventory module)
// - Refresh/delete shipping methods (fulfillment module)
// - Create line item adjustments (promotion module)
// - Update payment sessions (payment module)
export const addToCartWorkflowId = "add-to-cart"
export const addToCartWorkflow = createWorkflow(
addToCartWorkflowId,
(input: WorkflowData<AddToCartWorkflowInputDTO>) => {
const variantIds = transform({ input }, (data) => {
return (data.input.items ?? []).map((i) => i.variant_id)
})
validateVariantsExistStep({ variantIds })
// TODO: This is on par with the context used in v1.*, but we can be more flexible.
const pricingContext = transform({ cart: input.cart }, (data) => {
return {
currency_code: data.cart.currency_code,
region_id: data.cart.region_id,
customer_id: data.cart.customer_id,
}
})
const priceSets = getVariantPriceSetsStep({
variantIds,
context: pricingContext,
})
const variants = getVariantsStep({
filter: { id: variantIds },
})
const lineItems = transform(
{ priceSets, input, variants, cart: input.cart },
(data) => {
const items = (data.input.items ?? []).map((item) => {
const variant = data.variants.find((v) => v.id === item.variant_id)!
return prepareLineItemData({
variant: variant,
unitPrice: data.priceSets[item.variant_id].calculated_amount,
quantity: item.quantity,
metadata: item?.metadata ?? {},
cartId: data.cart.id,
}) as CreateLineItemForCartDTO
})
return items
}
)
const items = addToCartStep({ items: lineItems })
return items
}
)
@@ -1,21 +1,35 @@
import { CartDTO, CreateCartWorkflowInputDTO } from "@medusajs/types"
import {
WorkflowData,
createWorkflow,
parallelize,
transform,
WorkflowData,
} from "@medusajs/workflows-sdk"
import {
createCartsStep,
findOneOrAnyRegionStep,
findOrCreateCustomerStep,
findSalesChannelStep,
getVariantPriceSetsStep,
getVariantsStep,
validateVariantsExistStep,
} from "../steps"
import { prepareLineItemData } from "../utils/prepare-line-item-data"
// TODO: The UpdateLineItemsWorkflow are missing the following steps:
// - Confirm inventory exists (inventory module)
// - Refresh/delete shipping methods (fulfillment module)
// - Refresh/create line item adjustments (promotion module)
// - Update payment sessions (payment module)
export const createCartWorkflowId = "create-cart"
export const createCartWorkflow = createWorkflow(
createCartWorkflowId,
(input: WorkflowData<CreateCartWorkflowInputDTO>): WorkflowData<CartDTO> => {
const variantIds = transform({ input }, (data) => {
return (data.input.items ?? []).map((i) => i.variant_id)
})
const [salesChannel, region, customerData] = parallelize(
findSalesChannelStep({
salesChannelId: input.sales_channel_id,
@@ -26,9 +40,27 @@ export const createCartWorkflow = createWorkflow(
findOrCreateCustomerStep({
customerId: input.customer_id,
email: input.email,
})
}),
validateVariantsExistStep({ variantIds })
)
// TODO: This is on par with the context used in v1.*, but we can be more flexible.
const pricingContext = transform(
{ input, region, customerData },
(data) => {
return {
currency_code: data.input.currency_code ?? data.region.currency_code,
region_id: data.region.id,
customer_id: data.customerData.customer?.id,
}
}
)
const priceSets = getVariantPriceSetsStep({
variantIds,
context: pricingContext,
})
const cartInput = transform(
{ input, region, customerData, salesChannel },
(data) => {
@@ -51,11 +83,53 @@ export const createCartWorkflow = createWorkflow(
}
)
// TODO: Add line items
const variants = getVariantsStep({
filter: { id: variantIds },
config: {
select: [
"id",
"title",
"sku",
"barcode",
"product.id",
"product.title",
"product.description",
"product.subtitle",
"product.thumbnail",
"product.type",
"product.collection",
"product.handle",
],
relations: ["product"],
},
})
// @ts-ignore
const cart = createCartsStep([cartInput])
const lineItems = transform({ priceSets, input, variants }, (data) => {
const items = (data.input.items ?? []).map((item) => {
const variant = data.variants.find((v) => v.id === item.variant_id)!
return cart[0]
return prepareLineItemData({
variant: variant,
unitPrice: data.priceSets[item.variant_id].calculated_amount,
quantity: item.quantity,
metadata: item?.metadata ?? {},
})
})
return items
})
const cartToCreate = transform({ lineItems, cartInput }, (data) => {
return {
...data.cartInput,
items: data.lineItems,
}
})
const carts = createCartsStep([cartToCreate])
const cart = transform({ carts }, (data) => data.carts?.[0])
return cart
}
)
@@ -1,3 +1,4 @@
export * from "./add-to-cart"
export * from "./create-carts"
export * from "./update-cart-promotions"
export * from "./update-carts"