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 14:32:16 +01:00
committed by GitHub
parent ac86362e81
commit 7ebe885ec9
26 changed files with 1100 additions and 157 deletions

View File

@@ -0,0 +1,412 @@
import {
addToCartWorkflow,
createCartWorkflow,
findOrCreateCustomerStepId,
} from "@medusajs/core-flows"
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import {
ICartModuleService,
ICustomerModuleService,
IPricingModuleService,
IProductModuleService,
IRegionModuleService,
ISalesChannelModuleService,
} from "@medusajs/types"
import path from "path"
import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app"
import { useApi } from "../../../../environment-helpers/use-api"
import { getContainer } from "../../../../environment-helpers/use-container"
import { initDb, useDb } from "../../../../environment-helpers/use-db"
import adminSeeder from "../../../../helpers/admin-seeder"
jest.setTimeout(50000)
const env = { MEDUSA_FF_MEDUSA_V2: true }
describe("Carts workflows", () => {
let dbConnection
let appContainer
let shutdownServer
let cartModuleService: ICartModuleService
let regionModuleService: IRegionModuleService
let scModuleService: ISalesChannelModuleService
let customerModule: ICustomerModuleService
let productModule: IProductModuleService
let pricingModule: IPricingModuleService
let remoteLink
let defaultRegion
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd, env } as any)
shutdownServer = await startBootstrapApp({ cwd, env })
appContainer = getContainer()
cartModuleService = appContainer.resolve(ModuleRegistrationName.CART)
regionModuleService = appContainer.resolve(ModuleRegistrationName.REGION)
scModuleService = appContainer.resolve(ModuleRegistrationName.SALES_CHANNEL)
customerModule = appContainer.resolve(ModuleRegistrationName.CUSTOMER)
productModule = appContainer.resolve(ModuleRegistrationName.PRODUCT)
pricingModule = appContainer.resolve(ModuleRegistrationName.PRICING)
remoteLink = appContainer.resolve("remoteLink")
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
await shutdownServer()
})
beforeEach(async () => {
await adminSeeder(dbConnection)
await regionModuleService.createDefaultCountriesAndCurrencies()
// Here, so we don't have to create a region for each test
defaultRegion = await regionModuleService.create({
name: "Default Region",
currency_code: "dkk",
})
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
describe("CreateCartWorkflow", () => {
it("should create a cart", async () => {
const region = await regionModuleService.create({
name: "US",
currency_code: "usd",
})
const salesChannel = await scModuleService.create({
name: "Webshop",
})
const [product] = await productModule.create([
{
title: "Test product",
variants: [
{
title: "Test variant",
},
],
},
])
const priceSet = await pricingModule.create({
prices: [
{
amount: 3000,
currency_code: "usd",
},
],
})
await remoteLink.create([
{
productService: {
variant_id: product.variants[0].id,
},
pricingService: {
price_set_id: priceSet.id,
},
},
])
const { result } = await createCartWorkflow(appContainer).run({
input: {
email: "tony@stark.com",
currency_code: "usd",
region_id: region.id,
sales_channel_id: salesChannel.id,
items: [
{
variant_id: product.variants[0].id,
quantity: 1,
},
],
},
})
const cart = await cartModuleService.retrieve(result.id, {
relations: ["items"],
})
expect(cart).toEqual(
expect.objectContaining({
currency_code: "usd",
email: "tony@stark.com",
region_id: region.id,
sales_channel_id: salesChannel.id,
customer_id: expect.any(String),
items: expect.arrayContaining([
expect.objectContaining({
quantity: 1,
unit_price: 3000,
}),
]),
})
)
})
it("should throw when no regions exist", async () => {
await regionModuleService.delete(defaultRegion.id)
const { errors } = await createCartWorkflow(appContainer).run({
input: {
email: "tony@stark.com",
currency_code: "usd",
},
throwOnError: false,
})
expect(errors).toEqual([
{
action: "find-one-or-any-region",
handlerType: "invoke",
error: new Error("No regions found"),
},
])
})
it("should throw if sales channel is disabled", async () => {
const api = useApi() as any
const salesChannel = await scModuleService.create({
name: "Webshop",
is_disabled: true,
})
const { errors } = await createCartWorkflow(appContainer).run({
input: {
sales_channel_id: salesChannel.id,
},
throwOnError: false,
})
expect(errors).toEqual([
{
action: "find-sales-channel",
handlerType: "invoke",
error: new Error(
`Unable to assign cart to disabled Sales Channel: Webshop`
),
},
])
})
describe("compensation", () => {
it("should delete created customer if cart-creation fails", async () => {
expect.assertions(2)
const workflow = createCartWorkflow(appContainer)
workflow.appendAction("throw", findOrCreateCustomerStepId, {
invoke: async function failStep() {
throw new Error(`Failed to create cart`)
},
})
const { errors } = await workflow.run({
input: {
currency_code: "usd",
email: "tony@stark-industries.com",
},
throwOnError: false,
})
expect(errors).toEqual([
{
action: "throw",
handlerType: "invoke",
error: new Error(`Failed to create cart`),
},
])
const customers = await customerModule.list({
email: "tony@stark-industries.com",
})
expect(customers).toHaveLength(0)
})
it("should not delete existing customer if cart-creation fails", async () => {
expect.assertions(2)
const workflow = createCartWorkflow(appContainer)
workflow.appendAction("throw", findOrCreateCustomerStepId, {
invoke: async function failStep() {
throw new Error(`Failed to create cart`)
},
})
const customer = await customerModule.create({
email: "tony@stark-industries.com",
})
const { errors } = await workflow.run({
input: {
currency_code: "usd",
customer_id: customer.id,
},
throwOnError: false,
})
expect(errors).toEqual([
{
action: "throw",
handlerType: "invoke",
error: new Error(`Failed to create cart`),
},
])
const customers = await customerModule.list({
email: "tony@stark-industries.com",
})
expect(customers).toHaveLength(1)
})
})
})
describe("AddToCartWorkflow", () => {
it("should add item to cart", async () => {
let cart = await cartModuleService.create({
currency_code: "usd",
})
const [product] = await productModule.create([
{
title: "Test product",
variants: [
{
title: "Test variant",
},
],
},
])
const priceSet = await pricingModule.create({
prices: [
{
amount: 3000,
currency_code: "usd",
},
],
})
await remoteLink.create([
{
productService: {
variant_id: product.variants[0].id,
},
pricingService: {
price_set_id: priceSet.id,
},
},
])
cart = await cartModuleService.retrieve(cart.id, {
select: ["id", "region_id", "currency_code"],
})
await addToCartWorkflow(appContainer).run({
input: {
items: [
{
variant_id: product.variants[0].id,
quantity: 1,
},
],
cart,
},
})
cart = await cartModuleService.retrieve(cart.id, {
relations: ["items"],
})
expect(cart).toEqual(
expect.objectContaining({
id: cart.id,
currency_code: "usd",
items: expect.arrayContaining([
expect.objectContaining({
unit_price: 3000,
quantity: 1,
title: "Test variant",
}),
]),
})
)
})
it("should throw if no price sets for variant exist", async () => {
const cart = await cartModuleService.create({
currency_code: "usd",
})
const [product] = await productModule.create([
{
title: "Test product",
variants: [
{
title: "Test variant",
},
],
},
])
const { errors } = await addToCartWorkflow(appContainer).run({
input: {
items: [
{
variant_id: product.variants[0].id,
quantity: 1,
},
],
cart,
},
throwOnError: false,
})
expect(errors).toEqual([
{
action: "get-variant-price-sets",
handlerType: "invoke",
error: new Error(
`Variants with IDs ${product.variants[0].id} do not have a price`
),
},
])
})
it("should throw if variant does not exist", async () => {
const cart = await cartModuleService.create({
currency_code: "usd",
})
const { errors } = await addToCartWorkflow(appContainer).run({
input: {
items: [
{
variant_id: "prva_foo",
quantity: 1,
},
],
cart,
},
throwOnError: false,
})
expect(errors).toEqual([
{
action: "validate-variants-exist",
handlerType: "invoke",
error: new Error(`Variants with IDs prva_foo do not exist`),
},
])
})
})
})

View File

@@ -1,11 +1,9 @@
import {
createCartWorkflow,
findOrCreateCustomerStepId,
} from "@medusajs/core-flows"
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import {
ICartModuleService,
ICustomerModuleService,
IPricingModuleService,
IProductModuleService,
IRegionModuleService,
ISalesChannelModuleService,
} from "@medusajs/types"
@@ -29,6 +27,9 @@ describe("Store Carts API", () => {
let regionModuleService: IRegionModuleService
let scModuleService: ISalesChannelModuleService
let customerModule: ICustomerModuleService
let productModule: IProductModuleService
let pricingModule: IPricingModuleService
let remoteLink
let defaultRegion
@@ -41,6 +42,9 @@ describe("Store Carts API", () => {
regionModuleService = appContainer.resolve(ModuleRegistrationName.REGION)
scModuleService = appContainer.resolve(ModuleRegistrationName.SALES_CHANNEL)
customerModule = appContainer.resolve(ModuleRegistrationName.CUSTOMER)
productModule = appContainer.resolve(ModuleRegistrationName.PRODUCT)
pricingModule = appContainer.resolve(ModuleRegistrationName.PRICING)
remoteLink = appContainer.resolve("remoteLink")
})
afterAll(async () => {
@@ -51,7 +55,6 @@ describe("Store Carts API", () => {
beforeEach(async () => {
await adminSeeder(dbConnection)
// @ts-ignore
await regionModuleService.createDefaultCountriesAndCurrencies()
// Here, so we don't have to create a region for each test
@@ -77,6 +80,58 @@ describe("Store Carts API", () => {
name: "Webshop",
})
const [product] = await productModule.create([
{
title: "Test product",
variants: [
{
title: "Test variant",
},
{
title: "Test variant 2",
},
],
},
])
const [priceSet, priceSetTwo] = await pricingModule.create([
{
prices: [
{
amount: 3000,
currency_code: "usd",
},
],
},
{
prices: [
{
amount: 4000,
currency_code: "usd",
},
],
},
])
await remoteLink.create([
{
productService: {
variant_id: product.variants[0].id,
},
pricingService: {
price_set_id: priceSet.id,
},
},
{
productService: {
variant_id: product.variants[1].id,
},
pricingService: {
price_set_id: priceSetTwo.id,
},
},
])
const api = useApi() as any
const created = await api.post(`/store/carts`, {
@@ -84,6 +139,16 @@ describe("Store Carts API", () => {
currency_code: "usd",
region_id: region.id,
sales_channel_id: salesChannel.id,
items: [
{
variant_id: product.variants[0].id,
quantity: 1,
},
{
variant_id: product.variants[1].id,
quantity: 2,
},
],
})
expect(created.status).toEqual(200)
@@ -100,6 +165,16 @@ describe("Store Carts API", () => {
customer: expect.objectContaining({
email: "tony@stark.com",
}),
items: expect.arrayContaining([
expect.objectContaining({
quantity: 1,
unit_price: 3000,
}),
expect.objectContaining({
quantity: 2,
unit_price: 4000,
}),
]),
})
)
})
@@ -202,19 +277,6 @@ describe("Store Carts API", () => {
)
})
it("should throw when no regions exist", async () => {
const api = useApi() as any
await regionModuleService.delete(defaultRegion.id)
await expect(
api.post(`/store/carts`, {
email: "tony@stark.com",
currency_code: "usd",
})
).rejects.toThrow()
})
it("should respond 400 bad request on unknown props", async () => {
const api = useApi() as any
@@ -224,93 +286,6 @@ describe("Store Carts API", () => {
})
).rejects.toThrow()
})
it("should throw if sales channel is disabled", async () => {
const api = useApi() as any
const salesChannel = await scModuleService.create({
name: "Webshop",
is_disabled: true,
})
await expect(
api.post(`/store/carts`, {
sales_channel_id: salesChannel.id,
})
).rejects.toThrow()
})
describe("compensation", () => {
it("should delete created customer if cart-creation fails", async () => {
expect.assertions(2)
const workflow = createCartWorkflow(appContainer)
workflow.appendAction("throw", findOrCreateCustomerStepId, {
invoke: async function failStep() {
throw new Error(`Failed to create cart`)
},
})
const { errors } = await workflow.run({
input: {
currency_code: "usd",
email: "tony@stark-industries.com",
},
throwOnError: false,
})
expect(errors).toEqual([
{
action: "throw",
handlerType: "invoke",
error: new Error(`Failed to create cart`),
},
])
const customers = await customerModule.list({
email: "tony@stark-industries.com",
})
expect(customers).toHaveLength(0)
})
it("should not delete existing customer if cart-creation fails", async () => {
expect.assertions(2)
const workflow = createCartWorkflow(appContainer)
workflow.appendAction("throw", findOrCreateCustomerStepId, {
invoke: async function failStep() {
throw new Error(`Failed to create cart`)
},
})
const customer = await customerModule.create({
email: "tony@stark-industries.com",
})
const { errors } = await workflow.run({
input: {
currency_code: "usd",
customer_id: customer.id,
},
throwOnError: false,
})
expect(errors).toEqual([
{
action: "throw",
handlerType: "invoke",
error: new Error(`Failed to create cart`),
},
])
const customers = await customerModule.list({
email: "tony@stark-industries.com",
})
expect(customers).toHaveLength(1)
})
})
})
describe("GET /store/carts/:id", () => {
@@ -410,4 +385,64 @@ describe("Store Carts API", () => {
)
})
})
describe("POST /store/carts/:id/line-items", () => {
it("should add item to cart", async () => {
const cart = await cartModuleService.create({
currency_code: "usd",
})
const [product] = await productModule.create([
{
title: "Test product",
variants: [
{
title: "Test variant",
},
],
},
])
const priceSet = await pricingModule.create({
prices: [
{
amount: 3000,
currency_code: "usd",
},
],
})
await remoteLink.create([
{
productService: {
variant_id: product.variants[0].id,
},
pricingService: {
price_set_id: priceSet.id,
},
},
])
const api = useApi() as any
const response = await api.post(`/store/carts/${cart.id}/line-items`, {
variant_id: product.variants[0].id,
quantity: 1,
})
expect(response.status).toEqual(200)
expect(response.data.cart).toEqual(
expect.objectContaining({
id: cart.id,
currency_code: "usd",
items: expect.arrayContaining([
expect.objectContaining({
unit_price: 3000,
quantity: 1,
title: "Test variant",
}),
]),
})
)
})
})
})

View File

@@ -2,10 +2,10 @@ import { useApi } from "../../../environment-helpers/use-api"
import { initDb, useDb } from "../../../environment-helpers/use-db"
import {
StepResponse,
WorkflowData,
createStep,
createWorkflow,
StepResponse,
WorkflowData,
} from "@medusajs/workflows-sdk"
import { AxiosInstance } from "axios"
import path from "path"
@@ -200,9 +200,9 @@ export const workflowEngineTestSuite = (env, extraParams = {}) => {
data: expect.objectContaining({
invoke: {
"my-step": {
__type: "WorkflowWorkflowData",
__type: "Symbol(WorkflowWorkflowData)",
output: {
__type: "WorkflowStepResponse",
__type: "Symbol(WorkflowStepResponse)",
output: {
result: "abc",
},
@@ -248,7 +248,7 @@ export const workflowEngineTestSuite = (env, extraParams = {}) => {
data: expect.objectContaining({
invoke: expect.objectContaining({
"my-step-async": {
__type: "WorkflowStepResponse",
__type: "Symbol(WorkflowStepResponse)",
output: {
all: "good",
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
export * from "./add-to-cart"
export * from "./create-carts"
export * from "./update-cart-promotions"
export * from "./update-carts"

View File

@@ -0,0 +1,44 @@
import { addToCartWorkflow } from "@medusajs/core-flows"
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { ICartModuleService } from "@medusajs/types"
import { remoteQueryObjectFromString } from "@medusajs/utils"
import { MedusaRequest, MedusaResponse } from "../../../../../types/routing"
import { defaultStoreCartFields } from "../../query-config"
import { StorePostCartsCartLineItemsReq } from "./validators"
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const cartModuleService = req.scope.resolve<ICartModuleService>(
ModuleRegistrationName.CART
)
const cart = await cartModuleService.retrieve(req.params.id, {
select: ["id", "region_id", "currency_code"],
})
const workflowInput = {
items: [req.validatedBody as StorePostCartsCartLineItemsReq],
cart,
}
const { errors } = await addToCartWorkflow(req.scope).run({
input: workflowInput,
throwOnError: false,
})
if (Array.isArray(errors) && errors[0]) {
throw errors[0].error
}
const remoteQuery = req.scope.resolve("remoteQuery")
const query = remoteQueryObjectFromString({
entryPoint: "cart",
fields: defaultStoreCartFields,
})
const [updatedCart] = await remoteQuery(query, {
cart: { id: req.params.id },
})
res.status(200).json({ cart: updatedCart })
}

View File

@@ -0,0 +1,12 @@
import { IsInt, IsOptional, IsString } from "class-validator"
export class StorePostCartsCartLineItemsReq {
@IsString()
variant_id: string
@IsInt()
quantity: number
@IsOptional()
metadata?: Record<string, unknown> | undefined
}

View File

@@ -1,6 +1,7 @@
import { transformBody, transformQuery } from "../../../api/middlewares"
import { MiddlewareRoute } from "../../../loaders/helpers/routing/types"
import { authenticate } from "../../../utils/authenticate-middleware"
import { StorePostCartsCartLineItemsReq } from "./[id]/line-items/validators"
import * as QueryConfig from "./query-config"
import {
StoreDeleteCartsCartPromotionsReq,
@@ -40,6 +41,11 @@ export const storeCartRoutesMiddlewares: MiddlewareRoute[] = [
matcher: "/store/carts/:id",
middlewares: [transformBody(StorePostCartsCartReq)],
},
{
method: ["POST"],
matcher: "/store/carts/:id/line-items",
middlewares: [transformBody(StorePostCartsCartLineItemsReq)],
},
{
method: ["POST"],
matcher: "/store/carts/:id/promotions",

View File

@@ -3,13 +3,9 @@ import { CreateCartWorkflowInputDTO } from "@medusajs/types"
import { remoteQueryObjectFromString } from "@medusajs/utils"
import { MedusaRequest, MedusaResponse } from "../../../types/routing"
import { defaultStoreCartFields } from "../carts/query-config"
import { StorePostCartReq } from "./validators"
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const input = req.validatedBody as StorePostCartReq
const workflowInput: CreateCartWorkflowInputDTO = {
...input,
}
const workflowInput = req.validatedBody as CreateCartWorkflowInputDTO
// If the customer is logged in, we auto-assign them to the cart
if (req.auth_user?.app_metadata?.customer_id) {

View File

@@ -1,7 +1,7 @@
import { NextFunction, Request, Response } from "express"
import { Logger } from "../../types/global"
import { MedusaError } from "medusa-core-utils"
import { Logger } from "../../types/global"
import { formatException } from "../../utils"
const QUERY_RUNNER_RELEASED = "QueryRunnerAlreadyReleasedError"

View File

@@ -1,3 +1,4 @@
import { handlePostgresDatabaseError } from "@medusajs/utils"
import { AwilixContainer } from "awilix"
import {
DataSource,
@@ -7,7 +8,6 @@ import {
} from "typeorm"
import { ConfigModule } from "../types/global"
import "../utils/naming-strategy"
import { handlePostgresDatabaseError } from "@medusajs/utils"
type Options = {
configModule: ConfigModule

View File

@@ -4,18 +4,18 @@ import {
PriceSetMoneyAmountDTO,
RemoteQueryFunction,
} from "@medusajs/types"
import {
CustomerService,
ProductVariantService,
RegionService,
TaxProviderService,
} from "."
import {
FlagRouter,
MedusaV2Flag,
promiseAll,
removeNullish,
} from "@medusajs/utils"
import {
CustomerService,
ProductVariantService,
RegionService,
TaxProviderService,
} from "."
import {
IPriceSelectionStrategy,
PriceSelectionContext,
@@ -36,11 +36,11 @@ import {
TaxedPricing,
} from "../types/pricing"
import { EntityManager } from "typeorm"
import { MedusaError } from "medusa-core-utils"
import { EntityManager } from "typeorm"
import { TransactionBaseService } from "../interfaces"
import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
import { TaxServiceRate } from "../types/tax-service"
import { TransactionBaseService } from "../interfaces"
import { calculatePriceTaxAmount } from "../utils"
type InjectedDependencies = {

View File

@@ -1,6 +1,35 @@
export interface CreateCartLineItemDTO {
variant_id: string
import { CartDTO } from "./common"
export interface CreateCartCreateLineItemDTO {
quantity: number
variant_id: string
title?: string
subtitle?: string
thumbnail?: string
product_id?: string
product_title?: string
product_description?: string
product_subtitle?: string
product_type?: string
product_collection?: string
product_handle?: string
variant_sku?: string
variant_barcode?: string
variant_title?: string
variant_option_values?: Record<string, unknown>
requires_shipping?: boolean
is_discountable?: boolean
is_tax_inclusive?: boolean
is_giftcard?: boolean
compare_at_unit_price?: number
unit_price?: number | string
metadata?: Record<string, unknown>
}
export interface CreateCartAddressDTO {
@@ -29,5 +58,10 @@ export interface CreateCartWorkflowInputDTO {
billing_address?: CreateCartAddressDTO | string
metadata?: Record<string, unknown>
items?: CreateCartLineItemDTO[]
items?: CreateCartCreateLineItemDTO[]
}
export interface AddToCartWorkflowInputDTO {
items: CreateCartCreateLineItemDTO[]
cart: CartDTO
}

View File

@@ -98,7 +98,7 @@ export interface ProductDTO {
*
* @expandable
*/
type: ProductTypeDTO[]
type: ProductTypeDTO
/**
* The associated product tags.
*

View File

@@ -1,12 +1,18 @@
export const SymbolMedusaWorkflowComposerContext = Symbol.for(
"MedusaWorkflowComposerContext"
)
export const SymbolInputReference = Symbol.for("WorkflowInputReference")
export const SymbolWorkflowStep = Symbol.for("WorkflowStep")
export const SymbolWorkflowHook = Symbol.for("WorkflowHook")
export const SymbolWorkflowWorkflowData = Symbol.for("WorkflowWorkflowData")
export const SymbolWorkflowStepResponse = Symbol.for("WorkflowStepResponse")
export const SymbolWorkflowStepBind = Symbol.for("WorkflowStepBind")
).toString()
export const SymbolInputReference = Symbol.for(
"WorkflowInputReference"
).toString()
export const SymbolWorkflowStep = Symbol.for("WorkflowStep").toString()
export const SymbolWorkflowHook = Symbol.for("WorkflowHook").toString()
export const SymbolWorkflowWorkflowData = Symbol.for(
"WorkflowWorkflowData"
).toString()
export const SymbolWorkflowStepResponse = Symbol.for(
"WorkflowStepResponse"
).toString()
export const SymbolWorkflowStepBind = Symbol.for("WorkflowStepBind").toString()
export const SymbolWorkflowStepTransformer = Symbol.for(
"WorkflowStepTransformer"
)
).toString()

View File

@@ -55,7 +55,7 @@ export class InMemoryDistributedTransactionStorage extends DistributedTransactio
])
}
private stringifyWithSymbol(key, value) {
/*private stringifyWithSymbol(key, value) {
if (key === "__type" && typeof value === "symbol") {
return Symbol.keyFor(value)
}
@@ -69,7 +69,7 @@ export class InMemoryDistributedTransactionStorage extends DistributedTransactio
}
return value
}
}*/
async get(key: string): Promise<TransactionCheckpoint | undefined> {
return this.storage.get(key)
@@ -105,7 +105,7 @@ export class InMemoryDistributedTransactionStorage extends DistributedTransactio
})
}
const stringifiedData = JSON.stringify(data, this.stringifyWithSymbol)
const stringifiedData = JSON.stringify(data)
const parsedData = JSON.parse(stringifiedData)
if (hasFinished && !retentionTime) {

View File

@@ -99,7 +99,7 @@ export class RedisDistributedTransactionStorage extends DistributedTransactionSt
})
}
private stringifyWithSymbol(key, value) {
/*private stringifyWithSymbol(key, value) {
if (key === "__type" && typeof value === "symbol") {
return Symbol.keyFor(value)
}
@@ -113,12 +113,12 @@ export class RedisDistributedTransactionStorage extends DistributedTransactionSt
}
return value
}
}*/
async get(key: string): Promise<TransactionCheckpoint | undefined> {
const data = await this.redisClient.get(key)
return data ? JSON.parse(data, this.jsonWithSymbol) : undefined
return data ? JSON.parse(data) : undefined
}
async list(): Promise<TransactionCheckpoint[]> {
@@ -129,7 +129,7 @@ export class RedisDistributedTransactionStorage extends DistributedTransactionSt
for (const key of keys) {
const data = await this.redisClient.get(key)
if (data) {
transactions.push(JSON.parse(data, this.jsonWithSymbol))
transactions.push(JSON.parse(data))
}
}
return transactions
@@ -159,7 +159,7 @@ export class RedisDistributedTransactionStorage extends DistributedTransactionSt
})
}
const stringifiedData = JSON.stringify(data, this.stringifyWithSymbol)
const stringifiedData = JSON.stringify(data)
const parsedData = JSON.parse(stringifiedData)
if (!hasFinished) {

View File

@@ -10,6 +10,15 @@ import { Context, MedusaContainer } from "@medusajs/types"
export type StepFunctionResult<TOutput extends unknown | unknown[] = unknown> =
(this: CreateWorkflowComposerContext) => WorkflowData<TOutput>
type StepFunctionReturnConfig<TOutput> = {
config(
config: { name?: string } & Omit<
TransactionStepsDefinition,
"next" | "uuid" | "action"
>
): WorkflowData<TOutput>
}
/**
* A step function to be used in a workflow.
*
@@ -19,16 +28,17 @@ export type StepFunctionResult<TOutput extends unknown | unknown[] = unknown> =
export type StepFunction<TInput, TOutput = unknown> = (keyof TInput extends []
? // Function that doesn't expect any input
{
(): WorkflowData<TOutput>
(): WorkflowData<TOutput> & StepFunctionReturnConfig<TOutput>
}
: // function that expects an input object
{
(input: WorkflowData<TInput> | TInput): WorkflowData<TOutput>
(input: WorkflowData<TInput> | TInput): WorkflowData<TOutput> &
StepFunctionReturnConfig<TOutput>
}) &
WorkflowDataProperties<TOutput>
export type WorkflowDataProperties<T = unknown> = {
__type: Symbol
__type: string
__step__: string
}
@@ -37,9 +47,11 @@ export type WorkflowDataProperties<T = unknown> = {
*
* @typeParam T - The type of a step's input or result.
*/
export type WorkflowData<T = unknown> = (T extends object
export type WorkflowData<T = unknown> = (T extends Array<infer Item>
? Array<Item | WorkflowData<Item>>
: T extends object
? {
[Key in keyof T]: WorkflowData<T[Key]>
[Key in keyof T]: T[Key] | WorkflowData<T[Key]>
}
: T & WorkflowDataProperties<T>) &
T &