diff --git a/integration-tests/plugins/__tests__/cart/store/cart.workflows.spec.ts b/integration-tests/plugins/__tests__/cart/store/cart.workflows.spec.ts new file mode 100644 index 0000000000..408d076c09 --- /dev/null +++ b/integration-tests/plugins/__tests__/cart/store/cart.workflows.spec.ts @@ -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`), + }, + ]) + }) + }) +}) diff --git a/integration-tests/plugins/__tests__/cart/store/carts.spec.ts b/integration-tests/plugins/__tests__/cart/store/carts.spec.ts index fba57c7767..eb56343cdd 100644 --- a/integration-tests/plugins/__tests__/cart/store/carts.spec.ts +++ b/integration-tests/plugins/__tests__/cart/store/carts.spec.ts @@ -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", + }), + ]), + }) + ) + }) + }) }) diff --git a/integration-tests/plugins/__tests__/workflow-engine/tests.ts b/integration-tests/plugins/__tests__/workflow-engine/tests.ts index 01a9dbbd80..78206bd883 100644 --- a/integration-tests/plugins/__tests__/workflow-engine/tests.ts +++ b/integration-tests/plugins/__tests__/workflow-engine/tests.ts @@ -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", }, diff --git a/packages/core-flows/src/definition/cart/steps/add-to-cart.ts b/packages/core-flows/src/definition/cart/steps/add-to-cart.ts new file mode 100644 index 0000000000..59b26b34e3 --- /dev/null +++ b/packages/core-flows/src/definition/cart/steps/add-to-cart.ts @@ -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( + 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)) + } +) diff --git a/packages/core-flows/src/definition/cart/steps/find-one-or-any-region.ts b/packages/core-flows/src/definition/cart/steps/find-one-or-any-region.ts index 0398236b0c..248ef6c090 100644 --- a/packages/core-flows/src/definition/cart/steps/find-one-or-any-region.ts +++ b/packages/core-flows/src/definition/cart/steps/find-one-or-any-region.ts @@ -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]) diff --git a/packages/core-flows/src/definition/cart/steps/get-variant-price-sets.ts b/packages/core-flows/src/definition/cart/steps/get-variant-price-sets.ts new file mode 100644 index 0000000000..58e4a3c9f6 --- /dev/null +++ b/packages/core-flows/src/definition/cart/steps/get-variant-price-sets.ts @@ -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 +} + +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( + 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 } + ) + + const idToPriceSet = new Map>( + 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) + } +) diff --git a/packages/core-flows/src/definition/cart/steps/get-variants.ts b/packages/core-flows/src/definition/cart/steps/get-variants.ts new file mode 100644 index 0000000000..7da18741c4 --- /dev/null +++ b/packages/core-flows/src/definition/cart/steps/get-variants.ts @@ -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 +} + +export const getVariantsStepId = "get-variants" +export const getVariantsStep = createStep( + getVariantsStepId, + async (data: StepInput, { container }) => { + const productModuleService = container.resolve( + ModuleRegistrationName.PRODUCT + ) + + const variants = await productModuleService.listVariants( + data.filter, + data.config + ) + + return new StepResponse(variants) + } +) diff --git a/packages/core-flows/src/definition/cart/steps/index.ts b/packages/core-flows/src/definition/cart/steps/index.ts index 2809b76efb..d91c40f916 100644 --- a/packages/core-flows/src/definition/cart/steps/index.ts +++ b/packages/core-flows/src/definition/cart/steps/index.ts @@ -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" + diff --git a/packages/core-flows/src/definition/cart/steps/validate-variants-existence.ts b/packages/core-flows/src/definition/cart/steps/validate-variants-existence.ts new file mode 100644 index 0000000000..df07d230d8 --- /dev/null +++ b/packages/core-flows/src/definition/cart/steps/validate-variants-existence.ts @@ -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( + 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))) + } +) diff --git a/packages/core-flows/src/definition/cart/utils/prepare-line-item-data.ts b/packages/core-flows/src/definition/cart/utils/prepare-line-item-data.ts new file mode 100644 index 0000000000..61c61bd80d --- /dev/null +++ b/packages/core-flows/src/definition/cart/utils/prepare-line-item-data.ts @@ -0,0 +1,42 @@ +import { ProductVariantDTO } from "@medusajs/types" + +interface Input { + quantity: number + metadata?: Record + 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 +} diff --git a/packages/core-flows/src/definition/cart/workflows/add-to-cart.ts b/packages/core-flows/src/definition/cart/workflows/add-to-cart.ts new file mode 100644 index 0000000000..e997cb8bf5 --- /dev/null +++ b/packages/core-flows/src/definition/cart/workflows/add-to-cart.ts @@ -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) => { + 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 + } +) diff --git a/packages/core-flows/src/definition/cart/workflows/create-carts.ts b/packages/core-flows/src/definition/cart/workflows/create-carts.ts index d3b3ac5354..af47c474f8 100644 --- a/packages/core-flows/src/definition/cart/workflows/create-carts.ts +++ b/packages/core-flows/src/definition/cart/workflows/create-carts.ts @@ -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): WorkflowData => { + 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 } ) diff --git a/packages/core-flows/src/definition/cart/workflows/index.ts b/packages/core-flows/src/definition/cart/workflows/index.ts index 2255e2a619..d5a918fb5d 100644 --- a/packages/core-flows/src/definition/cart/workflows/index.ts +++ b/packages/core-flows/src/definition/cart/workflows/index.ts @@ -1,3 +1,4 @@ +export * from "./add-to-cart" export * from "./create-carts" export * from "./update-cart-promotions" export * from "./update-carts" diff --git a/packages/medusa/src/api-v2/store/carts/[id]/line-items/route.ts b/packages/medusa/src/api-v2/store/carts/[id]/line-items/route.ts new file mode 100644 index 0000000000..4be30ea6f4 --- /dev/null +++ b/packages/medusa/src/api-v2/store/carts/[id]/line-items/route.ts @@ -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( + 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 }) +} diff --git a/packages/medusa/src/api-v2/store/carts/[id]/line-items/validators.ts b/packages/medusa/src/api-v2/store/carts/[id]/line-items/validators.ts new file mode 100644 index 0000000000..d382565776 --- /dev/null +++ b/packages/medusa/src/api-v2/store/carts/[id]/line-items/validators.ts @@ -0,0 +1,12 @@ +import { IsInt, IsOptional, IsString } from "class-validator" + +export class StorePostCartsCartLineItemsReq { + @IsString() + variant_id: string + + @IsInt() + quantity: number + + @IsOptional() + metadata?: Record | undefined +} diff --git a/packages/medusa/src/api-v2/store/carts/middlewares.ts b/packages/medusa/src/api-v2/store/carts/middlewares.ts index e38e8d6855..6a45a541d4 100644 --- a/packages/medusa/src/api-v2/store/carts/middlewares.ts +++ b/packages/medusa/src/api-v2/store/carts/middlewares.ts @@ -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", diff --git a/packages/medusa/src/api-v2/store/carts/route.ts b/packages/medusa/src/api-v2/store/carts/route.ts index 971e559e7e..f7dbf2b741 100644 --- a/packages/medusa/src/api-v2/store/carts/route.ts +++ b/packages/medusa/src/api-v2/store/carts/route.ts @@ -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) { diff --git a/packages/medusa/src/api/middlewares/error-handler.ts b/packages/medusa/src/api/middlewares/error-handler.ts index 29a5bffd33..eddef50f19 100644 --- a/packages/medusa/src/api/middlewares/error-handler.ts +++ b/packages/medusa/src/api/middlewares/error-handler.ts @@ -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" diff --git a/packages/medusa/src/loaders/database.ts b/packages/medusa/src/loaders/database.ts index 992a91d551..447d0da66e 100644 --- a/packages/medusa/src/loaders/database.ts +++ b/packages/medusa/src/loaders/database.ts @@ -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 diff --git a/packages/medusa/src/services/pricing.ts b/packages/medusa/src/services/pricing.ts index 841e998639..2ace2e98aa 100644 --- a/packages/medusa/src/services/pricing.ts +++ b/packages/medusa/src/services/pricing.ts @@ -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 = { diff --git a/packages/types/src/cart/workflows.ts b/packages/types/src/cart/workflows.ts index 6011913a93..d7a808e216 100644 --- a/packages/types/src/cart/workflows.ts +++ b/packages/types/src/cart/workflows.ts @@ -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 + + requires_shipping?: boolean + is_discountable?: boolean + is_tax_inclusive?: boolean + is_giftcard?: boolean + + compare_at_unit_price?: number + unit_price?: number | string + + metadata?: Record } export interface CreateCartAddressDTO { @@ -29,5 +58,10 @@ export interface CreateCartWorkflowInputDTO { billing_address?: CreateCartAddressDTO | string metadata?: Record - items?: CreateCartLineItemDTO[] + items?: CreateCartCreateLineItemDTO[] +} + +export interface AddToCartWorkflowInputDTO { + items: CreateCartCreateLineItemDTO[] + cart: CartDTO } diff --git a/packages/types/src/product/common.ts b/packages/types/src/product/common.ts index 7f44de05f9..bc1b99646b 100644 --- a/packages/types/src/product/common.ts +++ b/packages/types/src/product/common.ts @@ -98,7 +98,7 @@ export interface ProductDTO { * * @expandable */ - type: ProductTypeDTO[] + type: ProductTypeDTO /** * The associated product tags. * diff --git a/packages/utils/src/orchestration/symbol.ts b/packages/utils/src/orchestration/symbol.ts index 8ec8177d7f..1d9bcf7f93 100644 --- a/packages/utils/src/orchestration/symbol.ts +++ b/packages/utils/src/orchestration/symbol.ts @@ -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() diff --git a/packages/workflow-engine-inmemory/src/utils/workflow-orchestrator-storage.ts b/packages/workflow-engine-inmemory/src/utils/workflow-orchestrator-storage.ts index e138768aef..f5cc70070d 100644 --- a/packages/workflow-engine-inmemory/src/utils/workflow-orchestrator-storage.ts +++ b/packages/workflow-engine-inmemory/src/utils/workflow-orchestrator-storage.ts @@ -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 { 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) { diff --git a/packages/workflow-engine-redis/src/utils/workflow-orchestrator-storage.ts b/packages/workflow-engine-redis/src/utils/workflow-orchestrator-storage.ts index 4b33f8e37e..38dd4eb295 100644 --- a/packages/workflow-engine-redis/src/utils/workflow-orchestrator-storage.ts +++ b/packages/workflow-engine-redis/src/utils/workflow-orchestrator-storage.ts @@ -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 { const data = await this.redisClient.get(key) - return data ? JSON.parse(data, this.jsonWithSymbol) : undefined + return data ? JSON.parse(data) : undefined } async list(): Promise { @@ -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) { diff --git a/packages/workflows-sdk/src/utils/composer/type.ts b/packages/workflows-sdk/src/utils/composer/type.ts index 0b7ac8acda..d61c004fdf 100644 --- a/packages/workflows-sdk/src/utils/composer/type.ts +++ b/packages/workflows-sdk/src/utils/composer/type.ts @@ -10,6 +10,15 @@ import { Context, MedusaContainer } from "@medusajs/types" export type StepFunctionResult = (this: CreateWorkflowComposerContext) => WorkflowData +type StepFunctionReturnConfig = { + config( + config: { name?: string } & Omit< + TransactionStepsDefinition, + "next" | "uuid" | "action" + > + ): WorkflowData +} + /** * A step function to be used in a workflow. * @@ -19,16 +28,17 @@ export type StepFunctionResult = export type StepFunction = (keyof TInput extends [] ? // Function that doesn't expect any input { - (): WorkflowData + (): WorkflowData & StepFunctionReturnConfig } : // function that expects an input object { - (input: WorkflowData | TInput): WorkflowData + (input: WorkflowData | TInput): WorkflowData & + StepFunctionReturnConfig }) & WorkflowDataProperties export type WorkflowDataProperties = { - __type: Symbol + __type: string __step__: string } @@ -37,9 +47,11 @@ export type WorkflowDataProperties = { * * @typeParam T - The type of a step's input or result. */ -export type WorkflowData = (T extends object +export type WorkflowData = (T extends Array + ? Array> + : T extends object ? { - [Key in keyof T]: WorkflowData + [Key in keyof T]: T[Key] | WorkflowData } : T & WorkflowDataProperties) & T &