From c9b8db04c1b35f1cf129bb9ad74789fbc2881815 Mon Sep 17 00:00:00 2001 From: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Wed, 18 Dec 2024 12:53:57 +0100 Subject: [PATCH] feat: Custom line items (#10408) * feat: Custom line items * fix tests * fix migration * Allow custom items in update line item workflow * throw if line item doesn't have a price * minor things * wip * fix flows * fix test * add default * add to type --- .changeset/gorgeous-tools-enjoy.md | 9 + .../http/__tests__/order/admin/order.spec.ts | 2 +- .../cart/store/cart.workflows.spec.ts | 960 ++++++++++++++++-- .../__tests__/order/draft-order.spec.ts | 8 +- .../cart/steps/validate-line-item-prices.ts | 36 + .../src/cart/steps/validate-variant-prices.ts | 4 + .../src/cart/utils/prepare-line-item-data.ts | 147 ++- .../src/cart/workflows/add-to-cart.ts | 68 +- .../src/cart/workflows/complete-cart.ts | 16 +- .../src/cart/workflows/create-carts.ts | 76 +- .../src/cart/workflows/refresh-cart-items.ts | 53 +- .../src/cart/workflows/update-cart.ts | 45 +- .../workflows/update-line-item-in-cart.ts | 63 +- .../src/common/steps/use-remote-query.ts | 4 +- .../utils/prepare-custom-line-item-data.ts | 68 -- .../src/order/workflows/add-line-items.ts | 86 +- .../src/order/workflows/create-order.ts | 78 +- packages/core/types/src/cart/common.ts | 5 + packages/core/types/src/cart/mutations.ts | 5 + packages/core/types/src/cart/workflows.ts | 2 +- .../core/utils/src/common/deep-flat-map.ts | 2 +- .../src/api/admin/draft-orders/validators.ts | 36 +- .../services/cart-module/index.spec.ts | 2 + .../src/migrations/.snapshot-medusa-cart.json | 10 + .../src/migrations/Migration20241218091938.ts | 13 + packages/modules/cart/src/models/line-item.ts | 3 +- 26 files changed, 1380 insertions(+), 421 deletions(-) create mode 100644 .changeset/gorgeous-tools-enjoy.md create mode 100644 packages/core/core-flows/src/cart/steps/validate-line-item-prices.ts delete mode 100644 packages/core/core-flows/src/order/utils/prepare-custom-line-item-data.ts create mode 100644 packages/modules/cart/src/migrations/Migration20241218091938.ts diff --git a/.changeset/gorgeous-tools-enjoy.md b/.changeset/gorgeous-tools-enjoy.md new file mode 100644 index 0000000000..f3c99bd03f --- /dev/null +++ b/.changeset/gorgeous-tools-enjoy.md @@ -0,0 +1,9 @@ +--- +"@medusajs/core-flows": patch +"@medusajs/cart": patch +"@medusajs/types": patch +"@medusajs/utils": patch +"@medusajs/medusa": patch +--- + +chore: Support custom line items diff --git a/integration-tests/http/__tests__/order/admin/order.spec.ts b/integration-tests/http/__tests__/order/admin/order.spec.ts index 1bb66dd8f5..092b033f22 100644 --- a/integration-tests/http/__tests__/order/admin/order.spec.ts +++ b/integration-tests/http/__tests__/order/admin/order.spec.ts @@ -1,5 +1,5 @@ -import { ModuleRegistrationName } from "@medusajs/utils" import { medusaIntegrationTestRunner } from "@medusajs/test-utils" +import { ModuleRegistrationName } from "@medusajs/utils" import { adminHeaders, createAdminUser, diff --git a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts index c60fef4d6d..9500c804f7 100644 --- a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts @@ -1,13 +1,16 @@ import { addShippingMethodToCartWorkflow, addToCartWorkflow, + completeCartWorkflow, createCartWorkflow, createPaymentCollectionForCartWorkflow, + createPaymentSessionsWorkflow, deleteLineItemsStepId, deleteLineItemsWorkflow, findOrCreateCustomerStepId, listShippingOptionsForCartWorkflow, refreshPaymentCollectionForCartWorkflow, + updateCartWorkflow, updateLineItemInCartWorkflow, updateLineItemsStepId, updatePaymentCollectionStepId, @@ -61,7 +64,8 @@ medusaIntegrationTestRunner({ let stockLocationModule: IStockLocationService let inventoryModule: IInventoryService let fulfillmentModule: IFulfillmentModuleService - let remoteLink, remoteQuery, storeHeaders + let remoteLink, remoteQuery, query + let storeHeaders let salesChannel let defaultRegion let customer, storeHeadersWithCustomer @@ -82,6 +86,7 @@ medusaIntegrationTestRunner({ remoteQuery = appContainer.resolve( ContainerRegistrationKeys.REMOTE_QUERY ) + query = appContainer.resolve(ContainerRegistrationKeys.QUERY) }) beforeEach(async () => { @@ -695,6 +700,336 @@ medusaIntegrationTestRunner({ }) }) + describe("CompleteCartWorkflow", () => { + it("should complete cart with custom item", async () => { + const salesChannel = await scModuleService.createSalesChannels({ + name: "Webshop", + }) + + const location = await stockLocationModule.createStockLocations({ + name: "Warehouse", + }) + + const region = await regionModuleService.createRegions({ + name: "US", + currency_code: "usd", + }) + + let cart = await cartModuleService.createCarts({ + currency_code: "usd", + sales_channel_id: salesChannel.id, + region_id: region.id, + }) + + await remoteLink.create([ + { + [Modules.SALES_CHANNEL]: { + sales_channel_id: salesChannel.id, + }, + [Modules.STOCK_LOCATION]: { + stock_location_id: location.id, + }, + }, + ]) + + cart = await cartModuleService.retrieveCart(cart.id, { + select: ["id", "region_id", "currency_code", "sales_channel_id"], + }) + + await addToCartWorkflow(appContainer).run({ + input: { + items: [ + { + title: "Test item", + subtitle: "Test subtitle", + thumbnail: "some-url", + requires_shipping: true, + is_discountable: false, + is_tax_inclusive: false, + unit_price: 3000, + metadata: { + foo: "bar", + }, + quantity: 1, + }, + ], + cart_id: cart.id, + }, + }) + + cart = await cartModuleService.retrieveCart(cart.id, { + relations: ["items"], + }) + + await createPaymentCollectionForCartWorkflow(appContainer).run({ + input: { + cart_id: cart.id, + }, + }) + + const [paymentCollection] = + await paymentModule.listPaymentCollections({}) + + await createPaymentSessionsWorkflow(appContainer).run({ + input: { + payment_collection_id: paymentCollection.id, + provider_id: "pp_system_default", + context: {}, + data: {}, + }, + }) + + await completeCartWorkflow(appContainer).run({ + input: { + id: cart.id, + }, + }) + + const { data } = await query.graph({ + entity: "cart", + filters: { + id: cart.id, + }, + fields: ["id", "currency_code", "completed_at", "items.*"], + }) + + expect(data[0]).toEqual( + expect.objectContaining({ + id: cart.id, + currency_code: "usd", + completed_at: expect.any(Date), + items: [ + { + cart_id: cart.id, + compare_at_unit_price: null, + created_at: expect.any(Date), + deleted_at: null, + id: expect.any(String), + is_discountable: false, + is_tax_inclusive: false, + is_custom_price: true, + metadata: { + foo: "bar", + }, + product_collection: null, + product_description: null, + product_handle: null, + product_id: null, + product_subtitle: null, + product_title: null, + product_type: null, + product_type_id: null, + quantity: 1, + raw_compare_at_unit_price: null, + raw_unit_price: { + precision: 20, + value: "3000", + }, + requires_shipping: true, + subtitle: "Test subtitle", + thumbnail: "some-url", + title: "Test item", + unit_price: 3000, + updated_at: expect.any(Date), + variant_barcode: null, + variant_id: null, + variant_option_values: null, + variant_sku: null, + variant_title: null, + }, + ], + }) + ) + }) + }) + + describe("UpdateCartWorkflow", () => { + it("should remove item with custom price when region is updated", async () => { + const salesChannel = await scModuleService.createSalesChannels({ + name: "Webshop", + }) + + const location = await stockLocationModule.createStockLocations({ + name: "Warehouse", + }) + + const regions = await regionModuleService.createRegions([ + { + name: "US", + currency_code: "usd", + }, + { + name: "EU", + currency_code: "eur", + }, + ]) + + let cart = await cartModuleService.createCarts({ + currency_code: "usd", + sales_channel_id: salesChannel.id, + region_id: regions.find((r) => r.currency_code === "usd")!.id, + }) + + const [product] = await productModule.createProducts([ + { + title: "Test product", + variants: [ + { + title: "Test variant", + manage_inventory: false, + }, + ], + }, + ]) + + const priceSet = await pricingModule.createPriceSets({ + prices: [ + { + amount: 3000, + currency_code: "usd", + }, + { + amount: 2000, + currency_code: "eur", + }, + ], + }) + + await pricingModule.createPricePreferences([ + { + attribute: "currency_code", + value: "usd", + is_tax_inclusive: true, + }, + ]) + + await remoteLink.create([ + { + [Modules.PRODUCT]: { + variant_id: product.variants[0].id, + }, + [Modules.PRICING]: { + price_set_id: priceSet.id, + }, + }, + { + [Modules.SALES_CHANNEL]: { + sales_channel_id: salesChannel.id, + }, + [Modules.STOCK_LOCATION]: { + stock_location_id: location.id, + }, + }, + ]) + + cart = await cartModuleService.retrieveCart(cart.id, { + select: ["id", "region_id", "currency_code", "sales_channel_id"], + }) + + await addToCartWorkflow(appContainer).run({ + input: { + items: [ + { + variant_id: product.variants[0].id, + quantity: 1, + }, + { + title: "Test item", + subtitle: "Test subtitle", + thumbnail: "some-url", + requires_shipping: true, + is_discountable: false, + is_tax_inclusive: false, + unit_price: 1500, + metadata: { + foo: "bar", + }, + quantity: 1, + }, + ], + cart_id: cart.id, + }, + }) + + cart = await cartModuleService.retrieveCart(cart.id, { + relations: ["items"], + }) + + expect(cart).toEqual( + expect.objectContaining({ + id: cart.id, + currency_code: "usd", + items: expect.arrayContaining([ + expect.objectContaining({ + // Regular line item + id: expect.any(String), + is_discountable: true, + is_tax_inclusive: true, + is_custom_price: false, + quantity: 1, + requires_shipping: true, + subtitle: "Test product", + title: "Test variant", + unit_price: 3000, + updated_at: expect.any(Date), + }), + expect.objectContaining({ + // Custom line item + id: expect.any(String), + is_discountable: false, + is_tax_inclusive: false, + is_custom_price: true, + quantity: 1, + metadata: { + foo: "bar", + }, + requires_shipping: true, + subtitle: "Test subtitle", + thumbnail: "some-url", + title: "Test item", + unit_price: 1500, + updated_at: expect.any(Date), + }), + ]), + }) + ) + + await updateCartWorkflow(appContainer).run({ + input: { + id: cart.id, + region_id: regions.find((r) => r.currency_code === "eur")!.id, + }, + }) + + cart = await cartModuleService.retrieveCart(cart.id, { + relations: ["items"], + }) + + expect(cart).toEqual( + expect.objectContaining({ + id: cart.id, + currency_code: "eur", + items: expect.arrayContaining([ + expect.objectContaining({ + // Regular line item + id: expect.any(String), + is_discountable: true, + is_tax_inclusive: false, + is_custom_price: false, + quantity: 1, + requires_shipping: true, + subtitle: "Test product", + title: "Test variant", + unit_price: 2000, + updated_at: expect.any(Date), + }), + ]), + }) + ) + expect(cart.items?.length).toEqual(1) + }) + }) + describe("AddToCartWorkflow", () => { it("should add item to cart", async () => { const salesChannel = await scModuleService.createSalesChannels({ @@ -812,7 +1147,7 @@ medusaIntegrationTestRunner({ ) }) - it("should throw if no price sets for variant exist", async () => { + it("should add custom item to cart", async () => { const salesChannel = await scModuleService.createSalesChannels({ name: "Webshop", }) @@ -826,30 +1161,6 @@ medusaIntegrationTestRunner({ sales_channel_id: salesChannel.id, }) - const [product] = await productModule.createProducts([ - { - title: "Test product", - variants: [ - { - title: "Test variant", - }, - ], - }, - ]) - - const inventoryItem = await inventoryModule.createInventoryItems({ - sku: "inv-1234", - }) - - await inventoryModule.createInventoryLevels([ - { - inventory_item_id: inventoryItem.id, - location_id: location.id, - stocked_quantity: 2, - reserved_quantity: 0, - }, - ]) - await remoteLink.create([ { [Modules.SALES_CHANNEL]: { @@ -859,69 +1170,83 @@ medusaIntegrationTestRunner({ stock_location_id: location.id, }, }, - { - [Modules.PRODUCT]: { - variant_id: product.variants[0].id, - }, - [Modules.INVENTORY]: { - inventory_item_id: inventoryItem.id, - }, - }, ]) - const { errors } = await addToCartWorkflow(appContainer).run({ + cart = await cartModuleService.retrieveCart(cart.id, { + select: ["id", "region_id", "currency_code", "sales_channel_id"], + }) + + await addToCartWorkflow(appContainer).run({ input: { items: [ { - variant_id: product.variants[0].id, + title: "Test item", + subtitle: "Test subtitle", + thumbnail: "some-url", + requires_shipping: true, + is_discountable: false, + is_tax_inclusive: false, + unit_price: 3000, + metadata: { + foo: "bar", + }, quantity: 1, }, ], cart_id: cart.id, }, - throwOnError: false, }) - expect(errors).toEqual([ - { - action: "validate-variant-prices", - handlerType: "invoke", - error: expect.objectContaining({ - message: expect.stringContaining( - `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.createCarts({ - currency_code: "usd", + cart = await cartModuleService.retrieveCart(cart.id, { + relations: ["items"], }) - const { errors } = await addToCartWorkflow(appContainer).run({ - input: { + expect(cart).toEqual( + expect.objectContaining({ + id: cart.id, + currency_code: "usd", items: [ { - variant_id: "prva_foo", + cart_id: expect.any(String), + compare_at_unit_price: null, + created_at: expect.any(Date), + deleted_at: null, + id: expect.any(String), + is_discountable: false, + is_tax_inclusive: false, + is_custom_price: true, + metadata: { + foo: "bar", + }, + product_collection: null, + product_description: null, + product_handle: null, + product_id: null, + product_subtitle: null, + product_title: null, + product_type: null, + product_type_id: null, quantity: 1, + raw_compare_at_unit_price: null, + raw_unit_price: { + precision: 20, + value: "3000", + }, + requires_shipping: true, + subtitle: "Test subtitle", + thumbnail: "some-url", + title: "Test item", + unit_price: 3000, + updated_at: expect.any(Date), + variant_barcode: null, + variant_id: null, + variant_option_values: null, + variant_sku: null, + variant_title: null, }, ], - cart_id: cart.id, - }, - throwOnError: false, - }) - - expect(errors).toEqual([ - { - action: "use-remote-query", - handlerType: "invoke", - error: expect.objectContaining({ - message: `ProductVariant id not found: prva_foo`, - }), - }, - ]) + }) + ) }) it("should add item to cart with price list", async () => { @@ -1072,6 +1397,495 @@ medusaIntegrationTestRunner({ }) ) }) + + it("should throw if no price sets for variant exist", async () => { + const salesChannel = await scModuleService.createSalesChannels({ + name: "Webshop", + }) + + const location = await stockLocationModule.createStockLocations({ + name: "Warehouse", + }) + + let cart = await cartModuleService.createCarts({ + currency_code: "usd", + sales_channel_id: salesChannel.id, + }) + + const [product] = await productModule.createProducts([ + { + title: "Test product", + variants: [ + { + title: "Test variant", + }, + ], + }, + ]) + + const inventoryItem = await inventoryModule.createInventoryItems({ + sku: "inv-1234", + }) + + await inventoryModule.createInventoryLevels([ + { + inventory_item_id: inventoryItem.id, + location_id: location.id, + stocked_quantity: 2, + reserved_quantity: 0, + }, + ]) + + await remoteLink.create([ + { + [Modules.SALES_CHANNEL]: { + sales_channel_id: salesChannel.id, + }, + [Modules.STOCK_LOCATION]: { + stock_location_id: location.id, + }, + }, + { + [Modules.PRODUCT]: { + variant_id: product.variants[0].id, + }, + [Modules.INVENTORY]: { + inventory_item_id: inventoryItem.id, + }, + }, + ]) + + const { errors } = await addToCartWorkflow(appContainer).run({ + input: { + items: [ + { + variant_id: product.variants[0].id, + quantity: 1, + }, + ], + cart_id: cart.id, + }, + throwOnError: false, + }) + + expect(errors).toEqual([ + { + action: "validate-variant-prices", + handlerType: "invoke", + error: expect.objectContaining({ + message: expect.stringContaining( + `Variants with IDs ${product.variants[0].id} do not have a price` + ), + }), + }, + ]) + }) + }) + + describe("updateLineItemInCartWorkflow", () => { + it("should update item in cart", async () => { + const salesChannel = await scModuleService.createSalesChannels({ + name: "Webshop", + }) + + const location = await stockLocationModule.createStockLocations({ + name: "Warehouse", + }) + + const [product] = await productModule.createProducts([ + { + title: "Test product", + variants: [ + { + title: "Test variant", + }, + ], + }, + ]) + + const inventoryItem = await inventoryModule.createInventoryItems({ + sku: "inv-1234", + }) + + await inventoryModule.createInventoryLevels([ + { + inventory_item_id: inventoryItem.id, + location_id: location.id, + stocked_quantity: 2, + reserved_quantity: 0, + }, + ]) + + const priceSet = await pricingModule.createPriceSets({ + prices: [ + { + amount: 3000, + currency_code: "usd", + }, + ], + }) + + await remoteLink.create([ + { + [Modules.PRODUCT]: { + variant_id: product.variants[0].id, + }, + [Modules.PRICING]: { + price_set_id: priceSet.id, + }, + }, + { + [Modules.SALES_CHANNEL]: { + sales_channel_id: salesChannel.id, + }, + [Modules.STOCK_LOCATION]: { + stock_location_id: location.id, + }, + }, + { + [Modules.PRODUCT]: { + variant_id: product.variants[0].id, + }, + [Modules.INVENTORY]: { + inventory_item_id: inventoryItem.id, + }, + }, + ]) + + let cart = await cartModuleService.createCarts({ + currency_code: "usd", + sales_channel_id: salesChannel.id, + items: [ + { + variant_id: product.variants[0].id, + quantity: 1, + unit_price: 5000, + is_custom_price: true, + title: "Test variant", + }, + ], + }) + + cart = await cartModuleService.retrieveCart(cart.id, { + select: ["id", "region_id", "currency_code"], + relations: ["items", "items.variant_id", "items.metadata"], + }) + + const item = cart.items?.[0]! + + await updateLineItemInCartWorkflow(appContainer).run({ + input: { + item_id: item.id, + update: { + metadata: { + foo: "bar", + }, + quantity: 2, + }, + cart_id: cart.id, + }, + throwOnError: false, + }) + + const updatedItem = await cartModuleService.retrieveLineItem(item.id) + + expect(updatedItem).toEqual( + expect.objectContaining({ + id: item.id, + unit_price: 5000, + is_custom_price: true, + quantity: 2, + title: "Test variant", + }) + ) + }) + + it("should update custom item in cart", async () => { + const salesChannel = await scModuleService.createSalesChannels({ + name: "Webshop", + }) + + const location = await stockLocationModule.createStockLocations({ + name: "Warehouse", + }) + + let cart = await cartModuleService.createCarts({ + currency_code: "usd", + sales_channel_id: salesChannel.id, + items: [ + { + title: "Test item", + subtitle: "Test subtitle", + thumbnail: "some-url", + requires_shipping: true, + is_discountable: false, + is_tax_inclusive: false, + is_custom_price: true, + variant_id: "some_random_id", + unit_price: 3000, + metadata: { + foo: "bar", + }, + quantity: 1, + }, + ], + }) + + await remoteLink.create([ + { + [Modules.SALES_CHANNEL]: { + sales_channel_id: salesChannel.id, + }, + [Modules.STOCK_LOCATION]: { + stock_location_id: location.id, + }, + }, + ]) + + cart = await cartModuleService.retrieveCart(cart.id, { + select: ["id", "region_id", "currency_code", "sales_channel_id"], + relations: ["items"], + }) + + await updateLineItemInCartWorkflow(appContainer).run({ + input: { + cart_id: cart.id, + item_id: cart.items?.[0]!.id!, + update: { + quantity: 2, + title: "Some other title", + }, + }, + }) + + cart = await cartModuleService.retrieveCart(cart.id, { + relations: ["items"], + }) + + expect(cart).toEqual( + expect.objectContaining({ + id: cart.id, + currency_code: "usd", + items: [ + { + cart_id: expect.any(String), + compare_at_unit_price: null, + created_at: expect.any(Date), + deleted_at: null, + id: expect.any(String), + is_discountable: false, + is_tax_inclusive: false, + is_custom_price: true, + metadata: { + foo: "bar", + }, + product_collection: null, + product_description: null, + product_handle: null, + product_id: null, + product_subtitle: null, + product_title: null, + product_type: null, + product_type_id: null, + quantity: 2, + raw_compare_at_unit_price: null, + raw_unit_price: { + precision: 20, + value: "3000", + }, + requires_shipping: true, + subtitle: "Test subtitle", + thumbnail: "some-url", + title: "Some other title", + unit_price: 3000, + updated_at: expect.any(Date), + variant_barcode: null, + variant_id: "some_random_id", + variant_option_values: null, + variant_sku: null, + variant_title: null, + }, + ], + }) + ) + + await updateLineItemInCartWorkflow(appContainer).run({ + input: { + cart_id: cart.id, + item_id: cart.items?.[0]!.id!, + update: { + quantity: 4, + }, + }, + }) + + cart = await cartModuleService.retrieveCart(cart.id, { + relations: ["items"], + }) + + expect(cart).toEqual( + expect.objectContaining({ + id: cart.id, + currency_code: "usd", + items: [ + { + cart_id: expect.any(String), + compare_at_unit_price: null, + created_at: expect.any(Date), + deleted_at: null, + id: expect.any(String), + is_discountable: false, + is_tax_inclusive: false, + is_custom_price: true, + metadata: { + foo: "bar", + }, + product_collection: null, + product_description: null, + product_handle: null, + product_id: null, + product_subtitle: null, + product_title: null, + product_type: null, + product_type_id: null, + quantity: 4, + raw_compare_at_unit_price: null, + raw_unit_price: { + precision: 20, + value: "3000", + }, + requires_shipping: true, + subtitle: "Test subtitle", + thumbnail: "some-url", + title: "Some other title", + unit_price: 3000, + updated_at: expect.any(Date), + variant_barcode: null, + variant_id: "some_random_id", + variant_option_values: null, + variant_sku: null, + variant_title: null, + }, + ], + }) + ) + }) + + it("should update unit price of regular item in cart", async () => { + const salesChannel = await scModuleService.createSalesChannels({ + name: "Webshop", + }) + + const location = await stockLocationModule.createStockLocations({ + name: "Warehouse", + }) + + const [product] = await productModule.createProducts([ + { + title: "Test product", + variants: [ + { + title: "Test variant", + }, + ], + }, + ]) + + const inventoryItem = await inventoryModule.createInventoryItems({ + sku: "inv-1234", + }) + + await inventoryModule.createInventoryLevels([ + { + inventory_item_id: inventoryItem.id, + location_id: location.id, + stocked_quantity: 2, + reserved_quantity: 0, + }, + ]) + + const priceSet = await pricingModule.createPriceSets({ + prices: [ + { + amount: 3000, + currency_code: "usd", + }, + ], + }) + + await remoteLink.create([ + { + [Modules.PRODUCT]: { + variant_id: product.variants[0].id, + }, + [Modules.PRICING]: { + price_set_id: priceSet.id, + }, + }, + { + [Modules.SALES_CHANNEL]: { + sales_channel_id: salesChannel.id, + }, + [Modules.STOCK_LOCATION]: { + stock_location_id: location.id, + }, + }, + { + [Modules.PRODUCT]: { + variant_id: product.variants[0].id, + }, + [Modules.INVENTORY]: { + inventory_item_id: inventoryItem.id, + }, + }, + ]) + + let cart = await cartModuleService.createCarts({ + currency_code: "usd", + sales_channel_id: salesChannel.id, + items: [ + { + variant_id: product.variants[0].id, + quantity: 1, + unit_price: 5000, + title: "Test item", + }, + ], + }) + + cart = await cartModuleService.retrieveCart(cart.id, { + select: ["id", "region_id", "currency_code"], + relations: ["items", "items.variant_id", "items.metadata"], + }) + + const item = cart.items?.[0]! + + await updateLineItemInCartWorkflow(appContainer).run({ + input: { + cart_id: cart.id, + item_id: item.id, + update: { + metadata: { + foo: "bar", + }, + unit_price: 4000, + quantity: 2, + }, + }, + }) + + const updatedItem = await cartModuleService.retrieveLineItem(item.id) + + expect(updatedItem).toEqual( + expect.objectContaining({ + id: item.id, + unit_price: 4000, + is_custom_price: true, + quantity: 2, + title: "Test variant", + }) + ) + }) }) describe("updateLineItemInCartWorkflow", () => { @@ -1184,8 +1998,8 @@ medusaIntegrationTestRunner({ { variant_id: product.variants[0].id, quantity: 1, - unit_price: 5000, title: "Test item", + unit_price: 5000, }, ], }) @@ -1197,9 +2011,7 @@ medusaIntegrationTestRunner({ const item = cart.items?.[0]! - const { errors } = await updateLineItemInCartWorkflow( - appContainer - ).run({ + await updateLineItemInCartWorkflow(appContainer).run({ input: { cart_id: cart.id, item_id: item.id, diff --git a/integration-tests/modules/__tests__/order/draft-order.spec.ts b/integration-tests/modules/__tests__/order/draft-order.spec.ts index b097ed54c0..e11bf06c43 100644 --- a/integration-tests/modules/__tests__/order/draft-order.spec.ts +++ b/integration-tests/modules/__tests__/order/draft-order.spec.ts @@ -207,8 +207,8 @@ medusaIntegrationTestRunner({ }, { title: "Custom Item", - sku: "sku123", - barcode: "barcode123", + variant_sku: "sku123", + variant_barcode: "barcode123", unit_price: 2200, quantity: 1, }, @@ -254,6 +254,7 @@ medusaIntegrationTestRunner({ requires_shipping: true, is_discountable: true, is_tax_inclusive: true, + is_custom_price: false, raw_compare_at_unit_price: null, raw_unit_price: expect.objectContaining({ value: "3000", @@ -323,7 +324,8 @@ medusaIntegrationTestRunner({ title: "Custom Item", variant_sku: "sku123", variant_barcode: "barcode123", - variant_title: "Custom Item", + variant_title: null, + is_custom_price: true, raw_unit_price: expect.objectContaining({ value: "2200", }), diff --git a/packages/core/core-flows/src/cart/steps/validate-line-item-prices.ts b/packages/core/core-flows/src/cart/steps/validate-line-item-prices.ts new file mode 100644 index 0000000000..9241e1d1cf --- /dev/null +++ b/packages/core/core-flows/src/cart/steps/validate-line-item-prices.ts @@ -0,0 +1,36 @@ +import { MedusaError, isPresent } from "@medusajs/framework/utils" +import { createStep } from "@medusajs/framework/workflows-sdk" + +export interface ValidateLineItemPricesStepInput { + items: { + unit_price?: number | null + title: string + }[] +} + +export const validateLineItemPricesStepId = "validate-line-item-prices" +/** + * This step validates the specified line item objects to ensure they have prices. + */ +export const validateLineItemPricesStep = createStep( + validateLineItemPricesStepId, + async (data: ValidateLineItemPricesStepInput, { container }) => { + if (!data.items?.length) { + return + } + + const priceNotFound: string[] = [] + for (const item of data.items) { + if (!isPresent(item?.unit_price)) { + priceNotFound.push(item.title) + } + } + + if (priceNotFound.length > 0) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Items ${priceNotFound.join(", ")} do not have a price` + ) + } + } +) diff --git a/packages/core/core-flows/src/cart/steps/validate-variant-prices.ts b/packages/core/core-flows/src/cart/steps/validate-variant-prices.ts index b5731a765e..fe34300bf5 100644 --- a/packages/core/core-flows/src/cart/steps/validate-variant-prices.ts +++ b/packages/core/core-flows/src/cart/steps/validate-variant-prices.ts @@ -18,6 +18,10 @@ export const validateVariantPricesStepId = "validate-variant-prices" export const validateVariantPricesStep = createStep( validateVariantPricesStepId, async (data: ValidateVariantPricesStepInput, { container }) => { + if (!data.variants?.length) { + return + } + const priceNotFound: string[] = [] for (const variant of data.variants) { if (!isPresent(variant?.calculated_price?.calculated_amount)) { diff --git a/packages/core/core-flows/src/cart/utils/prepare-line-item-data.ts b/packages/core/core-flows/src/cart/utils/prepare-line-item-data.ts index 433e36a8db..74d216f576 100644 --- a/packages/core/core-flows/src/cart/utils/prepare-line-item-data.ts +++ b/packages/core/core-flows/src/cart/utils/prepare-line-item-data.ts @@ -1,61 +1,106 @@ import { BigNumberInput, - CartLineItemDTO, CreateOrderAdjustmentDTO, CreateOrderLineItemTaxLineDTO, InventoryItemDTO, + LineItemAdjustmentDTO, + LineItemTaxLineDTO, ProductVariantDTO, } from "@medusajs/framework/types" -import { isDefined, MathBN, PriceListType } from "@medusajs/framework/utils" +import { + isDefined, + isPresent, + MathBN, + PriceListType, +} from "@medusajs/framework/utils" -interface Input { - item?: CartLineItemDTO +interface PrepareItemLineItemInput { + title?: string + subtitle?: string + thumbnail?: string quantity: BigNumberInput - metadata?: Record - unitPrice: BigNumberInput - compareAtUnitPrice?: BigNumberInput | null - isTaxInclusive?: boolean - variant: ProductVariantDTO & { - inventory_items: { inventory: InventoryItemDTO }[] + + product_id?: string + product_title?: string + product_description?: string + product_subtitle?: string + product_type?: string + product_type_id?: string + product_collection?: string + product_handle?: string + + variant_id?: string + variant_sku?: string + variant_barcode?: string + variant_title?: string + variant_option_values?: Record + + requires_shipping?: boolean + + is_discountable?: boolean + is_tax_inclusive?: boolean + + raw_compare_at_unit_price?: BigNumberInput + compare_at_unit_price?: BigNumberInput + unit_price?: BigNumberInput + + tax_lines?: LineItemTaxLineDTO[] + adjustments?: LineItemAdjustmentDTO[] + cart_id?: string + metadata?: Record | null +} + +export interface PrepareVariantLineItemInput extends ProductVariantDTO { + inventory_items: { inventory: InventoryItemDTO }[] + calculated_price: { calculated_price: { - calculated_price: { - price_list_type: string - } - original_amount: BigNumberInput - calculated_amount: BigNumberInput + price_list_type: string } + is_calculated_price_tax_inclusive: boolean + original_amount: BigNumberInput + calculated_amount: BigNumberInput } +} + +export interface PrepareLineItemDataInput { + item?: PrepareItemLineItemInput + isCustomPrice?: boolean + variant?: PrepareVariantLineItemInput taxLines?: CreateOrderLineItemTaxLineDTO[] adjustments?: CreateOrderAdjustmentDTO[] cartId?: string + unitPrice?: BigNumberInput + isTaxInclusive: boolean } -export function prepareLineItemData(data: Input) { +export function prepareLineItemData(data: PrepareLineItemDataInput) { const { item, variant, - unitPrice, - isTaxInclusive, - quantity, - metadata, cartId, taxLines, adjustments, + isCustomPrice, + unitPrice, + isTaxInclusive, } = data - if (!variant.product) { + if (variant && !variant.product) { throw new Error("Variant does not have a product") } - let compareAtUnitPrice = data.compareAtUnitPrice + let compareAtUnitPrice = item?.compare_at_unit_price + + const isSalePrice = + variant?.calculated_price?.calculated_price?.price_list_type === + PriceListType.SALE if ( - !isDefined(compareAtUnitPrice) && - variant.calculated_price.calculated_price.price_list_type === - PriceListType.SALE && + !isPresent(compareAtUnitPrice) && + isSalePrice && !MathBN.eq( - variant.calculated_price.original_amount, - variant.calculated_price.calculated_amount + variant.calculated_price?.original_amount, + variant.calculated_price?.calculated_amount ) ) { compareAtUnitPrice = variant.calculated_price.original_amount @@ -63,9 +108,8 @@ export function prepareLineItemData(data: Input) { // Note: If any of the items require shipping, we enable fulfillment // unless explicitly set to not require shipping by the item in the request - const { inventory_items: inventoryItems } = variant - const someInventoryRequiresShipping = inventoryItems.length - ? inventoryItems.some( + const someInventoryRequiresShipping = variant?.inventory_items?.length + ? variant.inventory_items.some( (inventoryItem) => !!inventoryItem.inventory.requires_shipping ) : true @@ -74,37 +118,42 @@ export function prepareLineItemData(data: Input) { ? item.requires_shipping : someInventoryRequiresShipping - const lineItem: any = { - quantity, - title: variant.title ?? item?.title, - subtitle: variant.product.title ?? item?.subtitle, - thumbnail: variant.product.thumbnail ?? item?.thumbnail, + let lineItem: any = { + quantity: item?.quantity, + title: variant?.title ?? item?.title, + subtitle: variant?.product?.title ?? item?.subtitle, + thumbnail: variant?.product?.thumbnail ?? item?.thumbnail, - product_id: variant.product.id ?? item?.product_id, - product_title: variant.product.title ?? item?.product_title, + product_id: variant?.product?.id ?? item?.product_id, + product_title: variant?.product?.title ?? item?.product_title, product_description: - variant.product.description ?? item?.product_description, - product_subtitle: variant.product.subtitle ?? item?.product_subtitle, - product_type: variant.product.type?.value ?? item?.product_type ?? null, - product_type_id: variant.product.type?.id ?? item?.product_type_id ?? null, + variant?.product?.description ?? item?.product_description, + product_subtitle: variant?.product?.subtitle ?? item?.product_subtitle, + product_type: variant?.product?.type?.value ?? item?.product_type ?? null, + product_type_id: + variant?.product?.type?.id ?? item?.product_type_id ?? null, product_collection: - variant.product.collection?.title ?? item?.product_collection ?? null, - product_handle: variant.product.handle ?? item?.product_handle, + variant?.product?.collection?.title ?? item?.product_collection ?? null, + product_handle: variant?.product?.handle ?? item?.product_handle, - variant_id: variant.id, - variant_sku: variant.sku ?? item?.variant_sku, - variant_barcode: variant.barcode ?? item?.variant_barcode, - variant_title: variant.title ?? item?.variant_title, + variant_id: variant?.id, + variant_sku: variant?.sku ?? item?.variant_sku, + variant_barcode: variant?.barcode ?? item?.variant_barcode, + variant_title: variant?.title ?? item?.variant_title, variant_option_values: item?.variant_option_values, - is_discountable: variant.product.discountable ?? item?.is_discountable, + is_discountable: variant?.product?.discountable ?? item?.is_discountable, requires_shipping: requiresShipping, unit_price: unitPrice, compare_at_unit_price: compareAtUnitPrice, is_tax_inclusive: !!isTaxInclusive, - metadata, + metadata: item?.metadata ?? {}, + } + + if (isCustomPrice) { + lineItem.is_custom_price = !!isCustomPrice } if (taxLines) { diff --git a/packages/core/core-flows/src/cart/workflows/add-to-cart.ts b/packages/core/core-flows/src/cart/workflows/add-to-cart.ts index 22beb5f262..737305b5a9 100644 --- a/packages/core/core-flows/src/cart/workflows/add-to-cart.ts +++ b/packages/core/core-flows/src/cart/workflows/add-to-cart.ts @@ -1,13 +1,11 @@ +import { AddToCartWorkflowInputDTO } from "@medusajs/framework/types" +import { CartWorkflowEvents, isDefined } from "@medusajs/framework/utils" import { - AddToCartWorkflowInputDTO, - CreateLineItemForCartDTO, -} from "@medusajs/framework/types" -import { CartWorkflowEvents } from "@medusajs/framework/utils" -import { - WorkflowData, createWorkflow, parallelize, transform, + when, + WorkflowData, } from "@medusajs/framework/workflows-sdk" import { useQueryGraphStep } from "../../common" import { emitEventStep } from "../../common/steps/emit-event" @@ -18,12 +16,16 @@ import { updateLineItemsStep, } from "../steps" import { validateCartStep } from "../steps/validate-cart" +import { validateLineItemPricesStep } from "../steps/validate-line-item-prices" import { validateVariantPricesStep } from "../steps/validate-variant-prices" import { cartFieldsForPricingContext, productVariantsFields, } from "../utils/fields" -import { prepareLineItemData } from "../utils/prepare-line-item-data" +import { + prepareLineItemData, + PrepareLineItemDataInput, +} from "../utils/prepare-line-item-data" import { confirmVariantInventoryWorkflow } from "./confirm-variant-inventory" import { refreshCartItemsWorkflow } from "./refresh-cart-items" @@ -50,41 +52,55 @@ export const addToCartWorkflow = createWorkflow( validateCartStep({ cart }) const variantIds = transform({ input }, (data) => { - return (data.input.items ?? []).map((i) => i.variant_id) + return (data.input.items ?? []).map((i) => i.variant_id).filter(Boolean) }) - const variants = useRemoteQueryStep({ - entry_point: "variants", - fields: productVariantsFields, - variables: { - id: variantIds, - calculated_price: { context: cart }, - }, - throw_if_key_not_found: true, + const variants = when({ variantIds }, ({ variantIds }) => { + return !!variantIds.length + }).then(() => { + return useRemoteQueryStep({ + entry_point: "variants", + fields: productVariantsFields, + variables: { + id: variantIds, + calculated_price: { + context: cart, + }, + }, + }) }) validateVariantPricesStep({ variants }) const lineItems = transform({ input, variants }, (data) => { const items = (data.input.items ?? []).map((item) => { - const variant = data.variants.find((v) => v.id === item.variant_id)! + const variant = (data.variants ?? []).find( + (v) => v.id === item.variant_id + )! - return prepareLineItemData({ + const input: PrepareLineItemDataInput = { + item, variant: variant, - unitPrice: - item.unit_price || variant.calculated_price.calculated_amount, + cartId: data.input.cart_id, + unitPrice: item.unit_price, isTaxInclusive: - item.is_tax_inclusive || - variant.calculated_price.is_calculated_price_tax_inclusive, - quantity: item.quantity, - metadata: item?.metadata ?? {}, - cartId: input.cart_id, - }) as CreateLineItemForCartDTO + item.is_tax_inclusive ?? + variant?.calculated_price?.is_calculated_price_tax_inclusive, + isCustomPrice: isDefined(item?.unit_price), + } + + if (variant && !input.unitPrice) { + input.unitPrice = variant.calculated_price?.calculated_amount + } + + return prepareLineItemData(input) }) return items }) + validateLineItemPricesStep({ items: lineItems }) + const { itemsToCreate = [], itemsToUpdate = [] } = getLineItemActionsStep({ id: cart.id, items: lineItems, diff --git a/packages/core/core-flows/src/cart/workflows/complete-cart.ts b/packages/core/core-flows/src/cart/workflows/complete-cart.ts index 42c28e1b5b..b0d199c860 100644 --- a/packages/core/core-flows/src/cart/workflows/complete-cart.ts +++ b/packages/core/core-flows/src/cart/workflows/complete-cart.ts @@ -5,7 +5,7 @@ import { import { Modules, OrderStatus, - OrderWorkflowEvents, + OrderWorkflowEvents } from "@medusajs/framework/utils" import { createWorkflow, @@ -31,6 +31,7 @@ import { prepareConfirmInventoryInput } from "../utils/prepare-confirm-inventory import { prepareAdjustmentsData, prepareLineItemData, + PrepareLineItemDataInput, prepareTaxLinesData, } from "../utils/prepare-line-item-data" @@ -115,18 +116,17 @@ export const completeCartWorkflow = createWorkflow( }) ?? [] const allItems = (cart.items ?? []).map((item) => { - return prepareLineItemData({ + const input: PrepareLineItemDataInput = { item, variant: item.variant, - unitPrice: item.raw_unit_price ?? item.unit_price, - compareAtUnitPrice: - item.raw_compare_at_unit_price ?? item.compare_at_unit_price, + cartId: cart.id, + unitPrice: item.unit_price, isTaxInclusive: item.is_tax_inclusive, - quantity: item.raw_quantity ?? item.quantity, - metadata: item?.metadata, taxLines: item.tax_lines ?? [], adjustments: item.adjustments ?? [], - }) + } + + return prepareLineItemData(input) }) const shippingMethods = (cart.shipping_methods ?? []).map((sm) => { diff --git a/packages/core/core-flows/src/cart/workflows/create-carts.ts b/packages/core/core-flows/src/cart/workflows/create-carts.ts index ddec635c2b..b1e1075499 100644 --- a/packages/core/core-flows/src/cart/workflows/create-carts.ts +++ b/packages/core/core-flows/src/cart/workflows/create-carts.ts @@ -2,14 +2,19 @@ import { AdditionalData, CreateCartWorkflowInputDTO, } from "@medusajs/framework/types" -import { CartWorkflowEvents, MedusaError } from "@medusajs/framework/utils" import { - WorkflowData, - WorkflowResponse, + CartWorkflowEvents, + isDefined, + MedusaError, +} from "@medusajs/framework/utils" +import { createHook, createWorkflow, parallelize, transform, + when, + WorkflowData, + WorkflowResponse, } from "@medusajs/framework/workflows-sdk" import { emitEventStep } from "../../common/steps/emit-event" import { useRemoteQueryStep } from "../../common/steps/use-remote-query" @@ -18,11 +23,14 @@ import { findOneOrAnyRegionStep, findOrCreateCustomerStep, findSalesChannelStep, - getVariantPriceSetsStep, } from "../steps" +import { validateLineItemPricesStep } from "../steps/validate-line-item-prices" import { validateVariantPricesStep } from "../steps/validate-variant-prices" import { productVariantsFields } from "../utils/fields" -import { prepareLineItemData } from "../utils/prepare-line-item-data" +import { + prepareLineItemData, + PrepareLineItemDataInput, +} from "../utils/prepare-line-item-data" import { confirmVariantInventoryWorkflow } from "./confirm-variant-inventory" import { refreshPaymentCollectionForCartWorkflow } from "./refresh-payment-collection" import { updateCartPromotionsWorkflow } from "./update-cart-promotions" @@ -36,7 +44,7 @@ export const createCartWorkflow = createWorkflow( createCartWorkflowId, (input: WorkflowData) => { const variantIds = transform({ input }, (data) => { - return (data.input.items ?? []).map((i) => i.variant_id) + return (data.input.items ?? []).map((i) => i.variant_id).filter(Boolean) }) const [salesChannel, region, customerData] = parallelize( @@ -68,16 +76,19 @@ export const createCartWorkflow = createWorkflow( } ) - const variants = useRemoteQueryStep({ - entry_point: "variants", - fields: productVariantsFields, - variables: { - id: variantIds, - calculated_price: { - context: pricingContext, + const variants = when({ variantIds }, ({ variantIds }) => { + return !!variantIds.length + }).then(() => { + return useRemoteQueryStep({ + entry_point: "variants", + fields: productVariantsFields, + variables: { + id: variantIds, + calculated_price: { + context: pricingContext, + }, }, - }, - throw_if_key_not_found: true, + }) }) validateVariantPricesStep({ variants }) @@ -90,11 +101,6 @@ export const createCartWorkflow = createWorkflow( }, }) - const priceSets = getVariantPriceSetsStep({ - variantIds, - context: pricingContext, - }) - const cartInput = transform( { input, region, customerData, salesChannel }, (data) => { @@ -131,26 +137,34 @@ export const createCartWorkflow = createWorkflow( } ) - const lineItems = transform({ priceSets, input, variants }, (data) => { + const lineItems = transform({ input, variants }, (data) => { const items = (data.input.items ?? []).map((item) => { - const variant = data.variants.find((v) => v.id === item.variant_id)! + const variant = (data.variants ?? []).find( + (v) => v.id === item.variant_id + )! - return prepareLineItemData({ + const input: PrepareLineItemDataInput = { + item, variant: variant, - unitPrice: - item.unit_price || - data.priceSets[item.variant_id].calculated_amount, + unitPrice: item.unit_price, isTaxInclusive: - item.is_tax_inclusive || - data.priceSets[item.variant_id].is_calculated_price_tax_inclusive, - quantity: item.quantity, - metadata: item?.metadata ?? {}, - }) + item.is_tax_inclusive ?? + variant?.calculated_price?.is_calculated_price_tax_inclusive, + isCustomPrice: isDefined(item?.unit_price), + } + + if (variant && !input.unitPrice) { + input.unitPrice = variant.calculated_price?.calculated_amount + } + + return prepareLineItemData(input) }) return items }) + validateLineItemPricesStep({ items: lineItems }) + const cartToCreate = transform({ lineItems, cartInput }, (data) => { return { ...data.cartInput, diff --git a/packages/core/core-flows/src/cart/workflows/refresh-cart-items.ts b/packages/core/core-flows/src/cart/workflows/refresh-cart-items.ts index ac5724c6c2..722cd2a625 100644 --- a/packages/core/core-flows/src/cart/workflows/refresh-cart-items.ts +++ b/packages/core/core-flows/src/cart/workflows/refresh-cart-items.ts @@ -6,6 +6,7 @@ import { import { createWorkflow, transform, + when, WorkflowData, WorkflowResponse, } from "@medusajs/framework/workflows-sdk" @@ -17,7 +18,10 @@ import { cartFieldsForRefreshSteps, productVariantsFields, } from "../utils/fields" -import { prepareLineItemData } from "../utils/prepare-line-item-data" +import { + prepareLineItemData, + PrepareLineItemDataInput, +} from "../utils/prepare-line-item-data" import { refreshCartShippingMethodsWorkflow } from "./refresh-cart-shipping-methods" import { refreshPaymentCollectionForCartWorkflow } from "./refresh-payment-collection" import { updateCartPromotionsWorkflow } from "./update-cart-promotions" @@ -43,40 +47,49 @@ export const refreshCartItemsWorkflow = createWorkflow( }) const variantIds = transform({ cart }, (data) => { - return (data.cart.items ?? []).map((i) => i.variant_id) + return (data.cart.items ?? []).map((i) => i.variant_id).filter(Boolean) }) const cartPricingContext = transform({ cart }, ({ cart }) => { return filterObjectByKeys(cart, cartFieldsForPricingContext) }) - const variants = useRemoteQueryStep({ - entry_point: "variants", - fields: productVariantsFields, - variables: { - id: variantIds, - calculated_price: { - context: cartPricingContext, + const variants = when({ variantIds }, ({ variantIds }) => { + return !!variantIds.length + }).then(() => { + return useRemoteQueryStep({ + entry_point: "variants", + fields: productVariantsFields, + variables: { + id: variantIds, + calculated_price: { + context: cartPricingContext, + }, }, - }, - throw_if_key_not_found: true, - }).config({ name: "fetch-variants" }) + }).config({ name: "fetch-variants" }) + }) validateVariantPricesStep({ variants }) const lineItems = transform({ cart, variants }, ({ cart, variants }) => { const items = cart.items.map((item) => { - const variant = variants.find((v) => v.id === item.variant_id)! + const variant = (variants ?? []).find((v) => v.id === item.variant_id)! - const preparedItem = prepareLineItemData({ + const input: PrepareLineItemDataInput = { + item, variant: variant, - unitPrice: variant.calculated_price.calculated_amount, - isTaxInclusive: - variant.calculated_price.is_calculated_price_tax_inclusive, - quantity: item.quantity, - metadata: item.metadata, cartId: cart.id, - }) + unitPrice: item.unit_price, + isTaxInclusive: item.is_tax_inclusive, + } + + if (variant && !item.is_custom_price) { + input.unitPrice = variant.calculated_price?.calculated_amount + input.isTaxInclusive = + variant.calculated_price?.is_calculated_price_tax_inclusive + } + + const preparedItem = prepareLineItemData(input) return { selector: { id: item.id }, diff --git a/packages/core/core-flows/src/cart/workflows/update-cart.ts b/packages/core/core-flows/src/cart/workflows/update-cart.ts index 9114ed6f57..2733ea1595 100644 --- a/packages/core/core-flows/src/cart/workflows/update-cart.ts +++ b/packages/core/core-flows/src/cart/workflows/update-cart.ts @@ -16,7 +16,12 @@ import { WorkflowData, WorkflowResponse, } from "@medusajs/framework/workflows-sdk" -import { emitEventStep, useRemoteQueryStep } from "../../common" +import { + emitEventStep, + useQueryGraphStep, + useRemoteQueryStep, +} from "../../common" +import { deleteLineItemsStep } from "../../line-item" import { findOrCreateCustomerStep, findSalesChannelStep, @@ -167,11 +172,18 @@ export const updateCartWorkflow = createWorkflow( }) */ - when({ input, cartToUpdate }, ({ input, cartToUpdate }) => { - return ( - isDefined(input.region_id) && - input.region_id !== cartToUpdate?.region?.id - ) + const regionUpdated = transform( + { input, cartToUpdate }, + ({ input, cartToUpdate }) => { + return ( + isDefined(input.region_id) && + input.region_id !== cartToUpdate?.region?.id + ) + } + ) + + when({ regionUpdated }, ({ regionUpdated }) => { + return !!regionUpdated }).then(() => { emitEventStep({ eventName: CartWorkflowEvents.REGION_UPDATED, @@ -187,6 +199,27 @@ export const updateCartWorkflow = createWorkflow( }) ) + // In case the region is updated, we might have a new currency OR tax inclusivity setting + // Therefore, we need to delete line items with a custom price for good measure + when({ regionUpdated }, ({ regionUpdated }) => { + return !!regionUpdated + }).then(() => { + const lineItems = useQueryGraphStep({ + entity: "line_items", + filters: { + cart_id: input.id, + is_custom_price: true, + }, + fields: ["id"], + }) + + const lineItemIds = transform({ lineItems }, ({ lineItems }) => { + return lineItems.data.map((i) => i.id) + }) + + deleteLineItemsStep(lineItemIds) + }) + const cart = refreshCartItemsWorkflow.runAsStep({ input: { cart_id: cartInput.id, promo_codes: input.promo_codes }, }) diff --git a/packages/core/core-flows/src/cart/workflows/update-line-item-in-cart.ts b/packages/core/core-flows/src/cart/workflows/update-line-item-in-cart.ts index 95f6e359af..630f085d5d 100644 --- a/packages/core/core-flows/src/cart/workflows/update-line-item-in-cart.ts +++ b/packages/core/core-flows/src/cart/workflows/update-line-item-in-cart.ts @@ -1,8 +1,10 @@ import { UpdateLineItemInCartWorkflowInputDTO } from "@medusajs/framework/types" +import { isDefined, MedusaError } from "@medusajs/framework/utils" import { - WorkflowData, createWorkflow, transform, + when, + WorkflowData, } from "@medusajs/framework/workflows-sdk" import { useQueryGraphStep } from "../../common" import { useRemoteQueryStep } from "../../common/steps/use-remote-query" @@ -40,19 +42,22 @@ export const updateLineItemInCartWorkflow = createWorkflow( validateCartStep({ cart }) const variantIds = transform({ item }, ({ item }) => { - return [item.variant_id] + return [item.variant_id].filter(Boolean) }) - const variants = useRemoteQueryStep({ - entry_point: "variants", - fields: productVariantsFields, - variables: { - id: variantIds, - calculated_price: { - context: cart, + const variants = when({ variantIds }, ({ variantIds }) => { + return !!variantIds.length + }).then(() => { + return useRemoteQueryStep({ + entry_point: "variants", + fields: productVariantsFields, + variables: { + id: variantIds, + calculated_price: { + context: cart, + }, }, - }, - throw_if_key_not_found: true, + }).config({ name: "fetch-variants" }) }) validateVariantPricesStep({ variants }) @@ -69,16 +74,36 @@ export const updateLineItemInCartWorkflow = createWorkflow( }, }) - const lineItemUpdate = transform({ input, variants }, (data) => { - const variant = data.variants[0] + const lineItemUpdate = transform({ input, variants, item }, (data) => { + const variant = data.variants?.[0] ?? undefined + const item = data.item + + const updateData = { + ...data.input.update, + unit_price: isDefined(data.input.update.unit_price) + ? data.input.update.unit_price + : item.unit_price, + is_custom_price: isDefined(data.input.update.unit_price) + ? true + : item.is_custom_price, + is_tax_inclusive: + item.is_tax_inclusive || + variant?.calculated_price?.is_calculated_price_tax_inclusive, + } + + if (variant && !updateData.is_custom_price) { + updateData.unit_price = variant.calculated_price.calculated_amount + } + + if (!isDefined(updateData.unit_price)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Line item ${item.title} has no unit price` + ) + } return { - data: { - ...data.input.update, - unit_price: variant.calculated_price.calculated_amount, - is_tax_inclusive: - !!variant.calculated_price.is_calculated_price_tax_inclusive, - }, + data: updateData, selector: { id: data.input.item_id, }, diff --git a/packages/core/core-flows/src/common/steps/use-remote-query.ts b/packages/core/core-flows/src/common/steps/use-remote-query.ts index 27045d394c..6392745c31 100644 --- a/packages/core/core-flows/src/common/steps/use-remote-query.ts +++ b/packages/core/core-flows/src/common/steps/use-remote-query.ts @@ -53,9 +53,9 @@ export const useRemoteQueryStepId = "use-remote-query" * Learn more in the [Remote Query documentation](https://docs.medusajs.com/learn/fundamentals/module-links/query). * * :::note - * + * * This step is deprecated. Use {@link useQueryGraphStep} instead. - * + * * ::: * * @example diff --git a/packages/core/core-flows/src/order/utils/prepare-custom-line-item-data.ts b/packages/core/core-flows/src/order/utils/prepare-custom-line-item-data.ts deleted file mode 100644 index 16410cd31b..0000000000 --- a/packages/core/core-flows/src/order/utils/prepare-custom-line-item-data.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { - BigNumberInput, - CreateOrderAdjustmentDTO, - CreateOrderLineItemTaxLineDTO, -} from "@medusajs/framework/types" -import { - prepareAdjustmentsData, - prepareTaxLinesData, -} from "../../cart/utils/prepare-line-item-data" - -interface Input { - quantity: BigNumberInput - metadata?: Record - unitPrice: BigNumberInput - isTaxInclusive?: boolean - taxLines?: CreateOrderLineItemTaxLineDTO[] - adjustments?: CreateOrderAdjustmentDTO[] - variant: { - title: string - sku?: string - barcode?: string - } -} - -interface Output { - quantity: BigNumberInput - title: string - variant_sku?: string - variant_barcode?: string - variant_title?: string - unit_price: BigNumberInput - is_tax_inclusive: boolean - metadata?: Record -} - -export function prepareCustomLineItemData(data: Input): Output { - const { - variant, - unitPrice, - isTaxInclusive, - quantity, - metadata, - taxLines, - adjustments, - } = data - - const lineItem: any = { - quantity, - title: variant.title, - variant_sku: variant.sku, - variant_barcode: variant.barcode, - variant_title: variant.title, - - unit_price: unitPrice, - is_tax_inclusive: !!isTaxInclusive, - metadata, - } - - if (taxLines) { - lineItem.tax_lines = prepareTaxLinesData(taxLines) - } - - if (adjustments) { - lineItem.adjustments = prepareAdjustmentsData(adjustments) - } - - return lineItem -} diff --git a/packages/core/core-flows/src/order/workflows/add-line-items.ts b/packages/core/core-flows/src/order/workflows/add-line-items.ts index ce7f2dfb2d..3ac6d2f0df 100644 --- a/packages/core/core-flows/src/order/workflows/add-line-items.ts +++ b/packages/core/core-flows/src/order/workflows/add-line-items.ts @@ -1,59 +1,48 @@ import { OrderLineItemDTO, OrderWorkflow } from "@medusajs/framework/types" -import { MathBN, MedusaError } from "@medusajs/framework/utils" +import { isDefined, MedusaError } from "@medusajs/framework/utils" import { - WorkflowData, - WorkflowResponse, createWorkflow, parallelize, transform, + when, + WorkflowData, + WorkflowResponse, } from "@medusajs/framework/workflows-sdk" import { findOneOrAnyRegionStep } from "../../cart/steps/find-one-or-any-region" import { findOrCreateCustomerStep } from "../../cart/steps/find-or-create-customer" import { findSalesChannelStep } from "../../cart/steps/find-sales-channel" -import { getVariantPriceSetsStep } from "../../cart/steps/get-variant-price-sets" +import { validateLineItemPricesStep } from "../../cart/steps/validate-line-item-prices" import { validateVariantPricesStep } from "../../cart/steps/validate-variant-prices" -import { prepareLineItemData } from "../../cart/utils/prepare-line-item-data" +import { + prepareLineItemData, + PrepareLineItemDataInput, +} from "../../cart/utils/prepare-line-item-data" import { confirmVariantInventoryWorkflow } from "../../cart/workflows/confirm-variant-inventory" import { useRemoteQueryStep } from "../../common" import { createOrderLineItemsStep } from "../steps" import { productVariantsFields } from "../utils/fields" -import { prepareCustomLineItemData } from "../utils/prepare-custom-line-item-data" function prepareLineItems(data) { const items = (data.input.items ?? []).map((item) => { const variant = data.variants.find((v) => v.id === item.variant_id)! - if (!variant) { - return prepareCustomLineItemData({ - variant: { - ...item, - }, - unitPrice: MathBN.max(0, item.unit_price), - isTaxInclusive: - item.is_tax_inclusive ?? - data.priceSets[item.variant_id!]?.is_calculated_price_tax_inclusive, - quantity: item.quantity as number, - metadata: item?.metadata, - taxLines: item.tax_lines || [], - adjustments: item.adjustments || [], - }) - } - - return prepareLineItemData({ + const input: PrepareLineItemDataInput = { + item, variant: variant, - unitPrice: MathBN.max( - 0, - item.unit_price ?? - data.priceSets[item.variant_id!]?.raw_calculated_amount - ), + unitPrice: item.unit_price, isTaxInclusive: item.is_tax_inclusive ?? - data.priceSets[item.variant_id!]?.is_calculated_price_tax_inclusive, - quantity: item.quantity as number, - metadata: item?.metadata, + variant?.calculated_price?.is_calculated_price_tax_inclusive, + isCustomPrice: isDefined(item?.unit_price), taxLines: item.tax_lines || [], adjustments: item.adjustments || [], - }) + } + + if (variant && !input.unitPrice) { + input.unitPrice = variant.calculated_price?.calculated_amount + } + + return prepareLineItemData(input) }) return items @@ -117,17 +106,20 @@ export const addOrderLineItemsWorkflow = createWorkflow( } ) - const variants = useRemoteQueryStep({ - entry_point: "variants", - fields: productVariantsFields, - variables: { - id: variantIds, - calculated_price: { - context: pricingContext, + const variants = when({ variantIds }, ({ variantIds }) => { + return !!variantIds.length + }).then(() => { + return useRemoteQueryStep({ + entry_point: "variants", + fields: productVariantsFields, + variables: { + id: variantIds, + calculated_price: { + context: pricingContext, + }, }, - }, - throw_if_key_not_found: true, - }).config({ name: "variants-query" }) + }) + }) validateVariantPricesStep({ variants }) @@ -139,15 +131,9 @@ export const addOrderLineItemsWorkflow = createWorkflow( }, }) - const priceSets = getVariantPriceSetsStep({ - variantIds, - context: pricingContext, - }) + const lineItems = transform({ input, variants }, prepareLineItems) - const lineItems = transform( - { priceSets, input, variants }, - prepareLineItems - ) + validateLineItemPricesStep({ items: lineItems }) return new WorkflowResponse( createOrderLineItemsStep({ diff --git a/packages/core/core-flows/src/order/workflows/create-order.ts b/packages/core/core-flows/src/order/workflows/create-order.ts index 80a0150511..4ccd369b27 100644 --- a/packages/core/core-flows/src/order/workflows/create-order.ts +++ b/packages/core/core-flows/src/order/workflows/create-order.ts @@ -1,5 +1,5 @@ import { AdditionalData, CreateOrderDTO } from "@medusajs/framework/types" -import { MathBN, MedusaError, isPresent } from "@medusajs/framework/utils" +import { MedusaError, isDefined, isPresent } from "@medusajs/framework/utils" import { WorkflowData, WorkflowResponse, @@ -7,51 +7,44 @@ import { createWorkflow, parallelize, transform, + when, } from "@medusajs/framework/workflows-sdk" import { findOneOrAnyRegionStep } from "../../cart/steps/find-one-or-any-region" import { findOrCreateCustomerStep } from "../../cart/steps/find-or-create-customer" import { findSalesChannelStep } from "../../cart/steps/find-sales-channel" -import { getVariantPriceSetsStep } from "../../cart/steps/get-variant-price-sets" +import { validateLineItemPricesStep } from "../../cart/steps/validate-line-item-prices" import { validateVariantPricesStep } from "../../cart/steps/validate-variant-prices" -import { prepareLineItemData } from "../../cart/utils/prepare-line-item-data" +import { + PrepareLineItemDataInput, + prepareLineItemData, +} from "../../cart/utils/prepare-line-item-data" import { confirmVariantInventoryWorkflow } from "../../cart/workflows/confirm-variant-inventory" import { useRemoteQueryStep } from "../../common" import { createOrdersStep } from "../steps" import { productVariantsFields } from "../utils/fields" -import { prepareCustomLineItemData } from "../utils/prepare-custom-line-item-data" import { updateOrderTaxLinesWorkflow } from "./update-tax-lines" function prepareLineItems(data) { const items = (data.input.items ?? []).map((item) => { const variant = data.variants.find((v) => v.id === item.variant_id)! - if (!variant) { - return prepareCustomLineItemData({ - variant: { - ...item, - }, - unitPrice: MathBN.max(0, item.unit_price), - isTaxInclusive: item.is_tax_inclusive, - quantity: item.quantity as number, - metadata: item?.metadata ?? {}, - }) - } - - return prepareLineItemData({ + const input: PrepareLineItemDataInput = { + item, variant: variant, - unitPrice: MathBN.max( - 0, - item.unit_price ?? - data.priceSets[item.variant_id!]?.raw_calculated_amount - ), + unitPrice: item.unit_price ?? undefined, isTaxInclusive: item.is_tax_inclusive ?? - data.priceSets[item.variant_id!]?.is_calculated_price_tax_inclusive, - quantity: item.quantity as number, - metadata: item?.metadata ?? {}, + variant?.calculated_price?.is_calculated_price_tax_inclusive, + isCustomPrice: isDefined(item?.unit_price), taxLines: item.tax_lines || [], adjustments: item.adjustments || [], - }) + } + + if (variant && !input.unitPrice) { + input.unitPrice = variant.calculated_price?.calculated_amount + } + + return prepareLineItemData(input) }) return items @@ -126,16 +119,19 @@ export const createOrderWorkflow = createWorkflow( } ) - const variants = useRemoteQueryStep({ - entry_point: "variants", - fields: productVariantsFields, - variables: { - id: variantIds, - calculated_price: { - context: pricingContext, + const variants = when({ variantIds }, ({ variantIds }) => { + return !!variantIds.length + }).then(() => { + return useRemoteQueryStep({ + entry_point: "variants", + fields: productVariantsFields, + variables: { + id: variantIds, + calculated_price: { + context: pricingContext, + }, }, - }, - throw_if_key_not_found: true, + }) }) validateVariantPricesStep({ variants }) @@ -148,20 +144,14 @@ export const createOrderWorkflow = createWorkflow( }, }) - const priceSets = getVariantPriceSetsStep({ - variantIds, - context: pricingContext, - }) - const orderInput = transform( { input, region, customerData, salesChannel }, getOrderInput ) - const lineItems = transform( - { priceSets, input, variants }, - prepareLineItems - ) + const lineItems = transform({ input, variants }, prepareLineItems) + + validateLineItemPricesStep({ items: lineItems }) const orderToCreate = transform({ lineItems, orderInput }, (data) => { return { diff --git a/packages/core/types/src/cart/common.ts b/packages/core/types/src/cart/common.ts index d066c49146..87f063beec 100644 --- a/packages/core/types/src/cart/common.ts +++ b/packages/core/types/src/cart/common.ts @@ -663,6 +663,11 @@ export interface CartLineItemDTO extends CartLineItemTotalsDTO { */ is_tax_inclusive: boolean + /** + * Whether the line item price is a custom price. + */ + is_custom_price: boolean + /** * The calculated price of the line item. */ diff --git a/packages/core/types/src/cart/mutations.ts b/packages/core/types/src/cart/mutations.ts index 3d51cd8cb6..d961b57b53 100644 --- a/packages/core/types/src/cart/mutations.ts +++ b/packages/core/types/src/cart/mutations.ts @@ -555,6 +555,11 @@ export interface CreateLineItemDTO { */ is_tax_inclusive?: boolean + /** + * Whether the line item's amount is a custom price. + */ + is_custom_price?: boolean + /** * The calculated price of the line item after applying promotions. */ diff --git a/packages/core/types/src/cart/workflows.ts b/packages/core/types/src/cart/workflows.ts index c5f94c36fa..194e575fef 100644 --- a/packages/core/types/src/cart/workflows.ts +++ b/packages/core/types/src/cart/workflows.ts @@ -13,7 +13,7 @@ import { export interface CreateCartCreateLineItemDTO { quantity: BigNumberInput - variant_id: string + variant_id?: string title?: string subtitle?: string diff --git a/packages/core/utils/src/common/deep-flat-map.ts b/packages/core/utils/src/common/deep-flat-map.ts index 3be83e3a17..e1336c810e 100644 --- a/packages/core/utils/src/common/deep-flat-map.ts +++ b/packages/core/utils/src/common/deep-flat-map.ts @@ -68,7 +68,7 @@ export function deepFlatMap( const currentKey = path[0] const remainingPath = path.slice(1) - if (!isDefined(element[currentKey])) { + if (!isDefined(element?.[currentKey])) { callback({ ...context }) continue } diff --git a/packages/medusa/src/api/admin/draft-orders/validators.ts b/packages/medusa/src/api/admin/draft-orders/validators.ts index 4581538f97..800f157912 100644 --- a/packages/medusa/src/api/admin/draft-orders/validators.ts +++ b/packages/medusa/src/api/admin/draft-orders/validators.ts @@ -37,23 +37,25 @@ const ShippingMethod = z.object({ amount: BigNumberInput, }) -const Item = z - .object({ - title: z.string().nullish(), - sku: z.string().nullish(), - barcode: z.string().nullish(), - variant_id: z.string().nullish(), - unit_price: BigNumberInput.nullish(), - quantity: z.number(), - metadata: z.record(z.unknown()).nullish(), - }) - .refine((data) => { - if (!data.variant_id) { - return data.title && (data.sku || data.barcode) - } - - return true - }) +const Item = z.object({ + title: z.string().nullish(), + variant_sku: z.string().nullish(), + variant_barcode: z.string().nullish(), + /** + * Use variant_sku instead + * @deprecated + */ + sku: z.string().nullish(), + /** + * Use variant_barcode instead + * @deprecated + */ + barcode: z.string().nullish(), + variant_id: z.string().nullish(), + unit_price: BigNumberInput.nullish(), + quantity: z.number(), + metadata: z.record(z.unknown()).nullish(), +}) export type AdminCreateDraftOrderType = z.infer const CreateDraftOrder = z diff --git a/packages/modules/cart/integration-tests/__tests__/services/cart-module/index.spec.ts b/packages/modules/cart/integration-tests/__tests__/services/cart-module/index.spec.ts index 5d276268af..4977a3e0dd 100644 --- a/packages/modules/cart/integration-tests/__tests__/services/cart-module/index.spec.ts +++ b/packages/modules/cart/integration-tests/__tests__/services/cart-module/index.spec.ts @@ -2512,6 +2512,7 @@ moduleIntegrationTestRunner({ requires_shipping: true, is_discountable: true, is_tax_inclusive: false, + is_custom_price: false, raw_compare_at_unit_price: null, raw_unit_price: { value: "100", @@ -2617,6 +2618,7 @@ moduleIntegrationTestRunner({ requires_shipping: true, is_discountable: true, is_tax_inclusive: false, + is_custom_price: false, raw_compare_at_unit_price: null, raw_unit_price: { value: "200", diff --git a/packages/modules/cart/src/migrations/.snapshot-medusa-cart.json b/packages/modules/cart/src/migrations/.snapshot-medusa-cart.json index 1b02c64962..bcdc0b173d 100644 --- a/packages/modules/cart/src/migrations/.snapshot-medusa-cart.json +++ b/packages/modules/cart/src/migrations/.snapshot-medusa-cart.json @@ -617,6 +617,16 @@ "default": "false", "mappedType": "boolean" }, + "is_custom_price": { + "name": "is_custom_price", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "false", + "mappedType": "boolean" + }, "compare_at_unit_price": { "name": "compare_at_unit_price", "type": "numeric", diff --git a/packages/modules/cart/src/migrations/Migration20241218091938.ts b/packages/modules/cart/src/migrations/Migration20241218091938.ts new file mode 100644 index 0000000000..d4314e8c5c --- /dev/null +++ b/packages/modules/cart/src/migrations/Migration20241218091938.ts @@ -0,0 +1,13 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20241218091938 extends Migration { + + async up(): Promise { + this.addSql('alter table if exists "cart_line_item" add column if not exists "is_custom_price" boolean not null default false;'); + } + + async down(): Promise { + this.addSql('alter table if exists "cart_line_item" drop column if exists "is_custom_price";'); + } + +} diff --git a/packages/modules/cart/src/models/line-item.ts b/packages/modules/cart/src/models/line-item.ts index b37a13211d..65d842b540 100644 --- a/packages/modules/cart/src/models/line-item.ts +++ b/packages/modules/cart/src/models/line-item.ts @@ -1,7 +1,7 @@ import { model } from "@medusajs/framework/utils" import Cart from "./cart" -import LineItemTaxLine from "./line-item-tax-line" import LineItemAdjustment from "./line-item-adjustment" +import LineItemTaxLine from "./line-item-tax-line" const LineItem = model .define( @@ -28,6 +28,7 @@ const LineItem = model requires_shipping: model.boolean().default(true), is_discountable: model.boolean().default(true), is_tax_inclusive: model.boolean().default(false), + is_custom_price: model.boolean().default(false), compare_at_unit_price: model.bigNumber().nullable(), unit_price: model.bigNumber(), metadata: model.json().nullable(),