From 6264a6262b0220c70ad35c0d56a93a39218ccb80 Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Mon, 25 Aug 2025 09:38:58 +0200 Subject: [PATCH] fix: Cart operation should calculate item prices accounting for quantity (#13251) * fix(): Cart operation should calculate item prices accounting for quantity * fix(): Cart operation should calculate item prices accounting for quantity * fix(): Cart operation should calculate item prices accounting for quantity * fix when call warning * fix tests and remove unnecessary object copy * Create warm-dancers-allow.md * fix update line item in cart workflow * fix changeset * update order flows * fix cart spec integration tests * improve create order workflow * fixes and tests adjustments/improvements * configurable useQueryGraphStep return type * revert nullable take * cleanup useQueryGraphStep --- .changeset/warm-dancers-allow.md | 6 + .../http/__tests__/cart/store/cart.spec.ts | 562 ++++++++++++++++++ .../promotions/admin/promotions.spec.ts | 9 +- .../cart/store/cart.workflows.spec.ts | 134 +++-- .../__tests__/order/draft-order.spec.ts | 452 +++++++++++++- .../modules/__tests__/order/order.spec.ts | 181 +++++- .../src/cart/steps/get-variant-price-sets.ts | 284 +++++++-- .../core/core-flows/src/cart/utils/fields.ts | 1 - .../src/cart/workflows/add-to-cart.ts | 89 ++- .../src/cart/workflows/create-carts.ts | 55 +- .../list-shipping-options-for-cart.ts | 4 +- .../src/cart/workflows/refresh-cart-items.ts | 80 ++- .../refresh-cart-shipping-methods.ts | 12 +- .../workflows/refresh-payment-collection.ts | 4 +- .../cart/workflows/transfer-cart-customer.ts | 49 +- .../cart/workflows/update-cart-promotions.ts | 2 +- .../src/cart/workflows/update-cart.ts | 70 ++- .../workflows/update-line-item-in-cart.ts | 89 ++- .../src/cart/workflows/update-tax-lines.ts | 2 +- .../src/cart/workflows/upsert-tax-lines.ts | 2 +- .../common/steps/__fixtures__/remote-query.ts | 13 + .../__tests__/use-query-graph-step.spec.ts | 95 +++ .../src/common/steps/use-query-graph.ts | 48 +- .../refresh-draft-order-adjustments.ts | 6 +- .../core/core-flows/src/order/utils/fields.ts | 1 - .../src/order/workflows/add-line-items.ts | 63 +- .../src/order/workflows/create-order.ts | 195 +++++- 27 files changed, 2173 insertions(+), 335 deletions(-) create mode 100644 .changeset/warm-dancers-allow.md create mode 100644 packages/core/core-flows/src/common/steps/__fixtures__/remote-query.ts create mode 100644 packages/core/core-flows/src/common/steps/__tests__/use-query-graph-step.spec.ts diff --git a/.changeset/warm-dancers-allow.md b/.changeset/warm-dancers-allow.md new file mode 100644 index 0000000000..3f27344bd8 --- /dev/null +++ b/.changeset/warm-dancers-allow.md @@ -0,0 +1,6 @@ +--- +"@medusajs/core-flows": patch +"@medusajs/types": patch +--- + +fix(): Cart operation should calculate item prices accounting for quantity diff --git a/integration-tests/http/__tests__/cart/store/cart.spec.ts b/integration-tests/http/__tests__/cart/store/cart.spec.ts index 357bf261e8..aa87dd4dd5 100644 --- a/integration-tests/http/__tests__/cart/store/cart.spec.ts +++ b/integration-tests/http/__tests__/cart/store/cart.spec.ts @@ -4,6 +4,7 @@ import { Modules, PriceListStatus, PriceListType, + ProductStatus, PromotionRuleOperator, PromotionStatus, PromotionType, @@ -190,6 +191,100 @@ medusaIntegrationTestRunner({ ) }) + it("should successfully create a cart with a line item with quantity and calculate prices based on the correct quantity", async () => { + const productData = { + title: "Medusa T-Shirt based quantity", + handle: "t-shirt-with-quantity-prices", + status: ProductStatus.PUBLISHED, + options: [ + { + title: "Size", + values: ["S"], + }, + ], + variants: [ + { + title: "S", + sku: "SHIRT-S-BLACK-w-quantity-prices", + options: { + Size: "S", + }, + manage_inventory: false, + prices: [ + { + amount: 1500, + currency_code: "usd", + min_quantity: 1, + max_quantity: 4, + }, + { + amount: 1000, + currency_code: "usd", + min_quantity: 5, + max_quantity: 10, + }, + ], + }, + ], + } + + const newProduct = await api.post( + `/admin/products`, + productData, + adminHeaders + ) + + const variantId = newProduct.data.product.variants[0].id + + const newCart = ( + await api.post( + `/store/carts`, + { + currency_code: "usd", + sales_channel_id: salesChannel.id, + region_id: region.id, + shipping_address: shippingAddressData, + items: [{ variant_id: variantId, quantity: 6 }], + }, + storeHeaders + ) + ).data.cart + + expect(newCart).toEqual( + expect.objectContaining({ + item_subtotal: 5714.285714285715, + item_tax_total: 285.7142857142857, + item_total: 6000, + items: [ + expect.objectContaining({ + quantity: 6, + title: "Medusa T-Shirt based quantity", + unit_price: 1000, + updated_at: expect.any(String), + variant_barcode: null, + variant_id: expect.any(String), + variant_sku: "SHIRT-S-BLACK-w-quantity-prices", + variant_title: "S", + }), + ], + original_item_subtotal: 5714.285714285715, + original_item_tax_total: 285.7142857142857, + original_item_total: 6000, + original_shipping_subtotal: 0, + original_shipping_tax_total: 0, + original_shipping_total: 0, + original_tax_total: 285.7142857142857, + original_total: 6000, + shipping_subtotal: 0, + shipping_tax_total: 0, + shipping_total: 0, + subtotal: 5714.285714285715, + tax_total: 285.7142857142857, + total: 6000, + }) + ) + }) + describe("with sale price lists", () => { let priceList @@ -442,6 +537,473 @@ medusaIntegrationTestRunner({ ) }) + it("should add item to cart and calculate prices based on item quantity", async () => { + const productData = { + title: "Medusa T-Shirt based quantity", + handle: "t-shirt-with-quantity-prices", + status: ProductStatus.PUBLISHED, + options: [ + { + title: "Size", + values: ["S"], + }, + ], + variants: [ + { + title: "S", + sku: "SHIRT-S-BLACK-w-quantity-prices", + options: { + Size: "S", + }, + manage_inventory: false, + prices: [ + { + amount: 1500, + currency_code: "usd", + min_quantity: 1, + max_quantity: 4, + }, + { + amount: 1000, + currency_code: "usd", + min_quantity: 5, + max_quantity: 10, + }, + ], + }, + ], + } + + const newProduct = await api.post( + `/admin/products`, + productData, + adminHeaders + ) + + const variantId = newProduct.data.product.variants[0].id + + const newCart = ( + await api.post( + `/store/carts`, + { + currency_code: "usd", + sales_channel_id: salesChannel.id, + region_id: region.id, + shipping_address: shippingAddressData, + }, + storeHeaders + ) + ).data.cart + + /** + * Add item to cart with quantity 1 + * in order to have the price calculated based on the price rule + * with min_quantity 1 and max_quantity 4 + */ + + let response = await api.post( + `/store/carts/${newCart.id}/line-items`, + { + variant_id: variantId, + quantity: 1, + }, + storeHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.cart).toEqual( + expect.objectContaining({ + billing_address: null, + completed_at: null, + created_at: expect.any(String), + credit_line_subtotal: 0, + credit_line_tax_total: 0, + credit_line_total: 0, + credit_lines: [], + currency_code: "usd", + customer_id: null, + discount_subtotal: 0, + discount_tax_total: 0, + discount_total: 0, + email: null, + id: newCart.id, + item_subtotal: 1428.5714285714287, + item_tax_total: 71.42857142857143, + item_total: 1500, + items: [ + expect.objectContaining({ + compare_at_unit_price: null, + created_at: expect.any(String), + id: expect.any(String), + is_tax_inclusive: true, + metadata: {}, + product: expect.objectContaining({ + categories: [], + collection_id: null, + id: expect.any(String), + tags: [], + type_id: null, + }), + product_collection: null, + product_description: null, + product_handle: "t-shirt-with-quantity-prices", + product_id: expect.any(String), + product_subtitle: null, + product_title: "Medusa T-Shirt based quantity", + product_type: null, + product_type_id: null, + quantity: 1, + requires_shipping: false, + tax_lines: [ + { + code: "CADEFAULT", + description: "CA Default Rate", + id: expect.any(String), + provider_id: "system", + rate: 5, + }, + ], + thumbnail: null, + title: "Medusa T-Shirt based quantity", + unit_price: 1500, + updated_at: expect.any(String), + variant_barcode: null, + variant_id: expect.any(String), + variant_sku: "SHIRT-S-BLACK-w-quantity-prices", + variant_title: "S", + }), + ], + metadata: null, + original_item_subtotal: 1428.5714285714287, + original_item_tax_total: 71.42857142857143, + original_item_total: 1500, + original_shipping_subtotal: 0, + original_shipping_tax_total: 0, + original_shipping_total: 0, + original_tax_total: 71.42857142857143, + original_total: 1500, + region: expect.objectContaining({ + automatic_taxes: true, + countries: expect.any(Array), + currency_code: "usd", + id: expect.any(String), + name: "US", + }), + region_id: expect.any(String), + sales_channel_id: expect.any(String), + shipping_address: expect.objectContaining({ + address_1: "test address 1", + address_2: "test address 2", + city: "SF", + company: null, + country_code: "US", + first_name: null, + id: expect.any(String), + last_name: null, + phone: null, + postal_code: "94016", + province: "CA", + }), + shipping_address_id: expect.any(String), + shipping_methods: [], + shipping_subtotal: 0, + shipping_tax_total: 0, + shipping_total: 0, + subtotal: 1428.5714285714287, + tax_total: 71.42857142857143, + total: 1500, + updated_at: expect.any(String), + }) + ) + + /** + * Add item to cart with quantity 5 + * in order to have the price calculated based on the price rule + * with min_quantity 5 and max_quantity 10 + */ + + response = await api.post( + `/store/carts/${newCart.id}/line-items`, + { + variant_id: variantId, + quantity: 5, + }, + storeHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.cart).toEqual( + expect.objectContaining({ + billing_address: null, + completed_at: null, + created_at: expect.any(String), + credit_line_subtotal: 0, + credit_line_tax_total: 0, + credit_line_total: 0, + credit_lines: [], + currency_code: "usd", + customer_id: null, + discount_subtotal: 0, + discount_tax_total: 0, + discount_total: 0, + email: null, + id: newCart.id, + item_subtotal: 5714.285714285715, + item_tax_total: 285.7142857142857, + item_total: 6000, + items: [ + expect.objectContaining({ + adjustments: [], + compare_at_unit_price: null, + created_at: expect.any(String), + id: expect.any(String), + is_tax_inclusive: true, + metadata: {}, + product: { + categories: [], + collection_id: null, + id: expect.any(String), + tags: [], + type_id: null, + }, + product_collection: null, + product_description: null, + product_handle: "t-shirt-with-quantity-prices", + product_id: expect.any(String), + product_subtitle: null, + product_title: "Medusa T-Shirt based quantity", + product_type: null, + product_type_id: null, + quantity: 6, + requires_shipping: false, + tax_lines: [ + { + code: "CADEFAULT", + description: "CA Default Rate", + id: expect.any(String), + provider_id: "system", + rate: 5, + }, + ], + thumbnail: null, + title: "Medusa T-Shirt based quantity", + unit_price: 1000, + updated_at: expect.any(String), + variant_barcode: null, + variant_id: expect.any(String), + variant_sku: "SHIRT-S-BLACK-w-quantity-prices", + variant_title: "S", + }), + ], + metadata: null, + original_item_subtotal: 5714.285714285715, + original_item_tax_total: 285.7142857142857, + original_item_total: 6000, + original_shipping_subtotal: 0, + original_shipping_tax_total: 0, + original_shipping_total: 0, + original_tax_total: 285.7142857142857, + original_total: 6000, + promotions: [], + region: { + automatic_taxes: true, + countries: expect.any(Array), + currency_code: "usd", + id: expect.any(String), + name: "US", + }, + region_id: expect.any(String), + sales_channel_id: expect.any(String), + shipping_address: { + address_1: "test address 1", + address_2: "test address 2", + city: "SF", + company: null, + country_code: "US", + first_name: null, + id: expect.any(String), + last_name: null, + phone: null, + postal_code: "94016", + province: "CA", + }, + shipping_address_id: expect.any(String), + shipping_methods: [], + shipping_subtotal: 0, + shipping_tax_total: 0, + shipping_total: 0, + subtotal: 5714.285714285715, + tax_total: 285.7142857142857, + total: 6000, + updated_at: expect.any(String), + }) + ) + }) + + it("should update a cart line item quantity and calculate prices based the new item quantity", async () => { + const productData = { + title: "Medusa T-Shirt based quantity", + handle: "t-shirt-with-quantity-prices", + status: ProductStatus.PUBLISHED, + options: [ + { + title: "Size", + values: ["S"], + }, + ], + variants: [ + { + title: "S", + sku: "SHIRT-S-BLACK-w-quantity-prices", + options: { + Size: "S", + }, + manage_inventory: false, + prices: [ + { + amount: 1500, + currency_code: "usd", + min_quantity: 1, + max_quantity: 4, + }, + { + amount: 1000, + currency_code: "usd", + min_quantity: 5, + max_quantity: 10, + }, + ], + }, + ], + } + + const newProduct = await api.post( + `/admin/products`, + productData, + adminHeaders + ) + + const variantId = newProduct.data.product.variants[0].id + + const newCart = ( + await api.post( + `/store/carts`, + { + currency_code: "usd", + sales_channel_id: salesChannel.id, + region_id: region.id, + shipping_address: shippingAddressData, + }, + storeHeaders + ) + ).data.cart + + /** + * Add item to cart with quantity 1 + * in order to have the price calculated based on the price rule + * with min_quantity 1 and max_quantity 4 + */ + + let response = await api.post( + `/store/carts/${newCart.id}/line-items`, + { + variant_id: variantId, + quantity: 1, + }, + storeHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.cart).toEqual( + expect.objectContaining({ + item_subtotal: 1428.5714285714287, + item_tax_total: 71.42857142857143, + item_total: 1500, + items: [ + expect.objectContaining({ + quantity: 1, + title: "Medusa T-Shirt based quantity", + unit_price: 1500, + updated_at: expect.any(String), + variant_barcode: null, + variant_id: expect.any(String), + variant_sku: "SHIRT-S-BLACK-w-quantity-prices", + variant_title: "S", + }), + ], + original_item_subtotal: 1428.5714285714287, + original_item_tax_total: 71.42857142857143, + original_item_total: 1500, + original_shipping_subtotal: 0, + original_shipping_tax_total: 0, + original_shipping_total: 0, + original_tax_total: 71.42857142857143, + original_total: 1500, + shipping_subtotal: 0, + shipping_tax_total: 0, + shipping_total: 0, + subtotal: 1428.5714285714287, + tax_total: 71.42857142857143, + total: 1500, + }) + ) + + /** + * update item quantity to 5 + * in order to have the price calculated based on the price rule + * with min_quantity 5 and max_quantity 10 + */ + + const itemId = response.data.cart.items[0].id + response = await api + .post( + `/store/carts/${newCart.id}/line-items/${itemId}`, + { + quantity: 6, + }, + storeHeaders + ) + .catch((e) => { + console.log(e.response.data) + throw e + }) + + expect(response.status).toEqual(200) + expect(response.data.cart).toEqual( + expect.objectContaining({ + item_subtotal: 5714.285714285715, + item_tax_total: 285.7142857142857, + item_total: 6000, + items: [ + expect.objectContaining({ + quantity: 6, + title: "Medusa T-Shirt based quantity", + unit_price: 1000, + updated_at: expect.any(String), + variant_barcode: null, + variant_id: expect.any(String), + variant_sku: "SHIRT-S-BLACK-w-quantity-prices", + variant_title: "S", + }), + ], + original_item_subtotal: 5714.285714285715, + original_item_tax_total: 285.7142857142857, + original_item_total: 6000, + original_shipping_subtotal: 0, + original_shipping_tax_total: 0, + original_shipping_total: 0, + original_tax_total: 285.7142857142857, + original_total: 6000, + shipping_subtotal: 0, + shipping_tax_total: 0, + shipping_total: 0, + subtotal: 5714.285714285715, + tax_total: 285.7142857142857, + total: 6000, + }) + ) + }) + it("should remove promotions when promotion is no longer in active state", async () => { let responseBeforePromotionUpdate = await api.post( `/store/carts/${cart.id}/line-items`, diff --git a/integration-tests/http/__tests__/promotions/admin/promotions.spec.ts b/integration-tests/http/__tests__/promotions/admin/promotions.spec.ts index 49a2c2ba92..15e0f1c027 100644 --- a/integration-tests/http/__tests__/promotions/admin/promotions.spec.ts +++ b/integration-tests/http/__tests__/promotions/admin/promotions.spec.ts @@ -1,6 +1,10 @@ import { medusaIntegrationTestRunner } from "@medusajs/test-utils" import { Modules, PromotionStatus, PromotionType } from "@medusajs/utils" -import { createAdminUser, generatePublishableKey, generateStoreHeaders, } from "../../../../helpers/create-admin-user" +import { + createAdminUser, + generatePublishableKey, + generateStoreHeaders, +} from "../../../../helpers/create-admin-user" import { setupTaxStructure } from "../../../../modules/__tests__/fixtures/tax" import { medusaTshirtProduct } from "../../../__fixtures__/product" @@ -2469,7 +2473,8 @@ medusaIntegrationTestRunner({ expect.arrayContaining([ expect.objectContaining({ id: "shipping_option_type", - value: "shipping_methods.shipping_option.shipping_option_type_id", + value: + "shipping_methods.shipping_option.shipping_option_type_id", label: "Shipping Option Type", required: false, field_type: "multiselect", 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 6640beeba2..df95f9ca2a 100644 --- a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts @@ -29,6 +29,7 @@ import { IRegionModuleService, ISalesChannelModuleService, IStockLocationService, + PricingContext, } from "@medusajs/types" import { ContainerRegistrationKeys, @@ -759,11 +760,23 @@ medusaIntegrationTestRunner({ * Tried jest, but for some reasons it is not able to provide * correct arguments passed to the function */ - let pricingContext: any - const originalFn = pricingModule.listPriceSets.bind(pricingModule) - pricingModule.listPriceSets = function () { - pricingContext = { ...arguments[0].context } - return originalFn.bind(pricingModule)(...arguments) + let calculatePricesHasBeenCalled = false + + const originalFn = pricingModule.calculatePrices.bind(pricingModule) + pricingModule.calculatePrices = function (...args) { + calculatePricesHasBeenCalled = true + + const pricingContext = args[1]!.context + + expect(pricingContext).toEqual( + expect.objectContaining({ + unit_price: 100, + region_id: region.id, + currency_code: "usd", + }) + ) + + return originalFn.bind(pricingModule)(...args) } const { result } = await createCartWorkflow(appContainer).run({ @@ -782,15 +795,9 @@ medusaIntegrationTestRunner({ }) setPricingContextHook = undefined - pricingModule.listPriceSets = originalFn + pricingModule.calculatePrices = originalFn - expect(pricingContext).toEqual( - expect.objectContaining({ - unit_price: 100, - region_id: region.id, - currency_code: "usd", - }) - ) + expect(calculatePricesHasBeenCalled).toBe(true) const cart = await cartModuleService.retrieveCart(result.id, { relations: ["items"], @@ -924,11 +931,25 @@ medusaIntegrationTestRunner({ * Tried jest, but for some reasons it is not able to provide * correct arguments passed to the function */ - let pricingContext: any - const originalFn = pricingModule.listPriceSets.bind(pricingModule) - pricingModule.listPriceSets = function () { - pricingContext = { ...arguments[0].context } - return originalFn.bind(pricingModule)(...arguments) + let calculatePricesHasBeenCalled = false + + const originalFn = pricingModule.calculatePrices.bind(pricingModule) + pricingModule.calculatePrices = function (...args) { + calculatePricesHasBeenCalled = true + + const pricingContext = args[1]!.context + + expect(pricingContext).toEqual( + expect.objectContaining({ + unit_price: 200, + region_id: region.id, + currency_code: "usd", + }) + ) + expect(pricingContext?.customer_id).toBeDefined() + expect(pricingContext?.customer_id).not.toEqual("1") + + return originalFn.bind(pricingModule)(...args) } const { result } = await createCartWorkflow(appContainer).run({ @@ -947,17 +968,9 @@ medusaIntegrationTestRunner({ }) setPricingContextHook = undefined - pricingModule.listPriceSets = originalFn + pricingModule.calculatePrices = originalFn - expect(pricingContext).toEqual( - expect.objectContaining({ - unit_price: 200, - region_id: region.id, - currency_code: "usd", - }) - ) - expect(pricingContext.customer_id).toBeDefined() - expect(pricingContext.customer_id).not.toEqual("1") + expect(calculatePricesHasBeenCalled).toBe(true) const cart = await cartModuleService.retrieveCart(result.id, { relations: ["items"], @@ -1851,7 +1864,7 @@ medusaIntegrationTestRunner({ expect(errors).toEqual([ { - action: "validate-variant-prices", + action: "get-variant-price-sets", handlerType: "invoke", error: expect.objectContaining({ message: expect.stringContaining( @@ -1960,10 +1973,22 @@ medusaIntegrationTestRunner({ * correct arguments passed to the function */ let pricingContext: any - const originalFn = pricingModule.listPriceSets.bind(pricingModule) - pricingModule.listPriceSets = function () { - pricingContext = { ...arguments[0].context } - return originalFn.bind(pricingModule)(...arguments) + let calculatePricessHaveBeenCalled = false + const originalFn = pricingModule.calculatePrices.bind(pricingModule) + pricingModule.calculatePrices = function (...args) { + pricingContext = args[1]! + calculatePricessHaveBeenCalled = true + + expect(pricingContext).toEqual( + expect.objectContaining({ + context: expect.objectContaining({ + unit_price: 100, + currency_code: "usd", + }), + }) + ) + + return originalFn.bind(pricingModule)(...args) } await addToCartWorkflow(appContainer).run({ @@ -1979,14 +2004,9 @@ medusaIntegrationTestRunner({ }) setPricingContextHook = undefined - pricingModule.listPriceSets = originalFn + pricingModule.calculatePrices = originalFn - expect(pricingContext).toEqual( - expect.objectContaining({ - unit_price: 100, - currency_code: "usd", - }) - ) + expect(calculatePricessHaveBeenCalled).toBe(true) cart = await cartModuleService.retrieveCart(cart.id, { relations: ["items"], @@ -2114,9 +2134,24 @@ medusaIntegrationTestRunner({ * correct arguments passed to the function */ let pricingContext: any - const originalFn = pricingModule.listPriceSets.bind(pricingModule) - pricingModule.listPriceSets = function () { - pricingContext = { ...arguments[0].context } + let calculatePricessHaveBeenCalled = false + + const originalFn = pricingModule.calculatePrices.bind(pricingModule) + pricingModule.calculatePrices = function (...args) { + pricingContext = args[1]! + calculatePricessHaveBeenCalled = true + + expect(pricingContext).toEqual( + expect.objectContaining({ + context: expect.objectContaining({ + unit_price: 200, + region_id: cart.region_id, + customer_id: cart.customer_id, + currency_code: "usd", + }), + }) + ) + return originalFn.bind(pricingModule)(...arguments) } @@ -2133,21 +2168,14 @@ medusaIntegrationTestRunner({ }) setPricingContextHook = undefined - pricingModule.listPriceSets = originalFn - - expect(pricingContext).toEqual( - expect.objectContaining({ - unit_price: 200, - region_id: cart.region_id, - customer_id: cart.customer_id, - currency_code: "usd", - }) - ) + pricingModule.calculatePrices = originalFn cart = await cartModuleService.retrieveCart(cart.id, { relations: ["items"], }) + expect(calculatePricessHaveBeenCalled).toBe(true) + expect(cart).toEqual( expect.objectContaining({ id: cart.id, diff --git a/integration-tests/modules/__tests__/order/draft-order.spec.ts b/integration-tests/modules/__tests__/order/draft-order.spec.ts index 6aee2d4a2b..a238736e65 100644 --- a/integration-tests/modules/__tests__/order/draft-order.spec.ts +++ b/integration-tests/modules/__tests__/order/draft-order.spec.ts @@ -10,7 +10,12 @@ import { IStockLocationServiceNext, ITaxModuleService, } from "@medusajs/types" -import { ContainerRegistrationKeys, Modules } from "@medusajs/utils" +import { + ContainerRegistrationKeys, + Modules, + PromotionStatus, + PromotionType, +} from "@medusajs/utils" import { adminHeaders, createAdminUser, @@ -25,30 +30,25 @@ medusaIntegrationTestRunner({ env, testSuite: ({ dbConnection, getContainer, api }) => { let appContainer - let cartModuleService: ICartModuleService let regionModuleService: IRegionModuleService let scModuleService: ISalesChannelModuleService let productModule: IProductModuleService let pricingModule: IPricingModuleService let inventoryModule: IInventoryServiceNext let stockLocationModule: IStockLocationServiceNext - let fulfillmentModule: IFulfillmentModuleService let taxModule: ITaxModuleService - let remoteLink, remoteQuery + let remoteLink beforeAll(async () => { appContainer = getContainer() - cartModuleService = appContainer.resolve(Modules.CART) regionModuleService = appContainer.resolve(Modules.REGION) scModuleService = appContainer.resolve(Modules.SALES_CHANNEL) productModule = appContainer.resolve(Modules.PRODUCT) pricingModule = appContainer.resolve(Modules.PRICING) inventoryModule = appContainer.resolve(Modules.INVENTORY) stockLocationModule = appContainer.resolve(Modules.STOCK_LOCATION) - fulfillmentModule = appContainer.resolve(Modules.FULFILLMENT) taxModule = appContainer.resolve(Modules.TAX) remoteLink = appContainer.resolve(ContainerRegistrationKeys.REMOTE_LINK) - remoteQuery = appContainer.resolve(ContainerRegistrationKeys.REMOTE_QUERY) }) beforeEach(async () => { @@ -199,7 +199,6 @@ medusaIntegrationTestRunner({ country_code: "US", postal_code: "12345", }, - promo_codes: ["testytest"], items: [ { variant_id: product.variants[0].id, @@ -386,6 +385,388 @@ medusaIntegrationTestRunner({ expect(response.status).toEqual(200) }) + + it("should create a draft order applying the correct promotion on the items", async () => { + const region = await regionModuleService.createRegions({ + name: "US", + currency_code: "usd", + }) + + const salesChannel = await scModuleService.createSalesChannels({ + name: "Webshop", + }) + + const location = await stockLocationModule.createStockLocations({ + name: "Warehouse", + }) + + const [product, product_2] = await productModule.createProducts([ + { + title: "Test product", + variants: [ + { + title: "Test variant", + }, + ], + }, + { + title: "Another product", + variants: [ + { + title: "Variant variable", + manage_inventory: false, + }, + ], + }, + ]) + + 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, priceSet_2] = await pricingModule.createPriceSets([ + { + prices: [ + { + amount: 3000, + currency_code: "usd", + }, + ], + }, + { + prices: [ + { + amount: 1000, + currency_code: "usd", + }, + ], + }, + ]) + + /** + * Create a promotion to test with + */ + const promotion = ( + await api.post( + `/admin/promotions`, + { + code: "testytest", + type: PromotionType.STANDARD, + status: PromotionStatus.ACTIVE, + application_method: { + target_type: "items", + type: "fixed", + allocation: "each", + currency_code: "usd", + value: 100, + max_quantity: 100, + target_rules: [ + { + attribute: "variant_id", + operator: "in", + values: [product.variants[0].id, product_2.variants[0].id], + }, + ], + }, + }, + adminHeaders + ) + ).data.promotion + + await api.post( + "/admin/price-preferences", + { + attribute: "currency_code", + value: "usd", + is_tax_inclusive: true, + }, + adminHeaders + ) + + await remoteLink.create([ + { + [Modules.PRODUCT]: { + variant_id: product.variants[0].id, + }, + [Modules.PRICING]: { + price_set_id: priceSet.id, + }, + }, + { + [Modules.PRODUCT]: { + variant_id: product_2.variants[0].id, + }, + [Modules.PRICING]: { + price_set_id: priceSet_2.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, + }, + }, + { + [Modules.PRODUCT]: { + variant_id: product_2.variants[0].id, + }, + [Modules.INVENTORY]: { + inventory_item_id: inventoryItem.id, + }, + }, + ]) + + await setupTaxStructure(taxModule) + + const payload = { + email: "oli@test.dk", + region_id: region.id, + sales_channel_id: salesChannel.id, + currency_code: "usd", + shipping_address: { + first_name: "Test", + last_name: "Test", + address_1: "Test", + city: "Test", + country_code: "US", + postal_code: "12345", + phone: "12345", + }, + billing_address: { + first_name: "Test", + last_name: "Test", + address_1: "Test", + city: "Test", + country_code: "US", + postal_code: "12345", + }, + promo_codes: ["testytest"], + items: [ + { + variant_id: product.variants[0].id, + is_discountable: true, + quantity: 2, + }, + { + variant_id: product_2.variants[0].id, + is_discountable: true, + unit_price: 200, + quantity: 1, + metadata: { + note: "reduced price", + }, + }, + { + title: "Custom Item", + variant_sku: "sku123", + variant_barcode: "barcode123", + is_discountable: true, + unit_price: 2200, + quantity: 1, + }, + ], + shipping_methods: [ + { + name: "test-method", + shipping_option_id: "test-option", + amount: 100, + }, + ], + } + + const response = await api.post( + "/admin/draft-orders", + payload, + adminHeaders + ) + + expect(response.data).toEqual( + expect.objectContaining({ + draft_order: expect.objectContaining({ + status: "draft", + version: 1, + summary: expect.objectContaining({ + // TODO: add summary fields + }), + items: expect.arrayContaining([ + expect.objectContaining({ + title: "Test product", + subtitle: "Test variant", + product_title: "Test product", + product_description: null, + product_subtitle: null, + product_type: null, + product_type_id: null, + product_collection: null, + product_handle: "test-product", + variant_sku: null, + variant_barcode: null, + variant_title: "Test variant", + variant_option_values: null, + 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", + }), + metadata: {}, + tax_lines: [ + expect.objectContaining({ + code: "US_DEF", + provider_id: "system", + rate: 2, + }), + ], + adjustments: [ + expect.objectContaining({ + amount: 200, + code: "testytest", + is_tax_inclusive: false, + promotion_id: promotion.id, + provider_id: null, + }), + ], + unit_price: 3000, + quantity: 2, + raw_quantity: expect.objectContaining({ + value: "2", + }), + detail: expect.objectContaining({ + raw_quantity: expect.objectContaining({ + value: "2", + }), + raw_fulfilled_quantity: expect.objectContaining({ + value: "0", + }), + raw_shipped_quantity: expect.objectContaining({ + value: "0", + }), + raw_return_requested_quantity: expect.objectContaining({ + value: "0", + }), + raw_return_received_quantity: expect.objectContaining({ + value: "0", + }), + raw_return_dismissed_quantity: expect.objectContaining({ + value: "0", + }), + raw_written_off_quantity: expect.objectContaining({ + value: "0", + }), + quantity: 2, + fulfilled_quantity: 0, + shipped_quantity: 0, + return_requested_quantity: 0, + return_received_quantity: 0, + return_dismissed_quantity: 0, + written_off_quantity: 0, + }), + }), + expect.objectContaining({ + title: "Another product", + subtitle: "Variant variable", + raw_unit_price: expect.objectContaining({ + value: "200", + }), + metadata: { + note: "reduced price", + }, + unit_price: 200, + is_tax_inclusive: true, + quantity: 1, + raw_quantity: expect.objectContaining({ + value: "1", + }), + adjustments: [ + expect.objectContaining({ + amount: 100, + code: "testytest", + is_tax_inclusive: false, + promotion_id: promotion.id, + provider_id: null, + }), + ], + }), + expect.objectContaining({ + title: "Custom Item", + variant_sku: "sku123", + variant_barcode: "barcode123", + variant_title: null, + is_custom_price: true, + raw_unit_price: expect.objectContaining({ + value: "2200", + }), + unit_price: 2200, + quantity: 1, + raw_quantity: expect.objectContaining({ + value: "1", + }), + }), + ]), + shipping_address: expect.objectContaining({ + last_name: "Test", + address_1: "Test", + city: "Test", + country_code: "US", + postal_code: "12345", + phone: "12345", + }), + billing_address: expect.objectContaining({ + first_name: "Test", + last_name: "Test", + address_1: "Test", + city: "Test", + country_code: "US", + postal_code: "12345", + }), + shipping_methods: [ + expect.objectContaining({ + name: "test-method", + raw_amount: expect.objectContaining({ + value: "100", + }), + is_tax_inclusive: false, + shipping_option_id: "test-option", + data: null, + tax_lines: [ + expect.objectContaining({ + code: "US_DEF", + provider_id: "system", + rate: 2, + }), + ], + adjustments: [], + amount: 100, + }), + ], + }), + }) + ) + + expect(response.status).toEqual(200) + }) + it("should create a draft order and apply tax by product type", async () => { const productType = await productModule.createProductTypes({ value: "test_product_type", @@ -396,11 +777,17 @@ medusaIntegrationTestRunner({ currency_code: "usd", }) - const [taxRegion] = await taxModule.createTaxRegions([{ - country_code: "US", - provider_id: "tp_system", - default_tax_rate: { name: "US Default Rate", rate: 5, code: "US_DEF" }, - }]) + const [taxRegion] = await taxModule.createTaxRegions([ + { + country_code: "US", + provider_id: "tp_system", + default_tax_rate: { + name: "US Default Rate", + rate: 5, + code: "US_DEF", + }, + }, + ]) const [taxRate] = await taxModule.createTaxRates([ { @@ -408,14 +795,15 @@ medusaIntegrationTestRunner({ name: "US Reduced", rate: 3, code: "USREDUCE_PROD_TYPE", - }]) + }, + ]) await taxModule.createTaxRateRules([ { reference: "product_type", reference_id: productType.id, tax_rate_id: taxRate.id, - } + }, ]) const salesChannel = await scModuleService.createSalesChannels({ @@ -426,7 +814,6 @@ medusaIntegrationTestRunner({ name: "Warehouse", }) - const [product] = await productModule.createProducts([ { title: "Test product", @@ -473,6 +860,35 @@ medusaIntegrationTestRunner({ adminHeaders ) + /** + * Create a promotion to test with + */ + const promotion = ( + await api.post( + `/admin/promotions`, + { + code: "testytest", + type: PromotionType.STANDARD, + status: PromotionStatus.ACTIVE, + application_method: { + target_type: "items", + type: "fixed", + allocation: "each", + currency_code: "usd", + value: 100, + max_quantity: 100, + target_rules: [ + { + attribute: "variant_id", + operator: "in", + values: [product.variants[0].id], + }, + ], + }, + }, + adminHeaders + ) + ).data.promotion await remoteLink.create([ { @@ -546,7 +962,9 @@ medusaIntegrationTestRunner({ adminHeaders ) - expect(response.data.draft_order.items[0].tax_lines[0].code).toEqual("USREDUCE_PROD_TYPE") + expect(response.data.draft_order.items[0].tax_lines[0].code).toEqual( + "USREDUCE_PROD_TYPE" + ) expect(response.data.draft_order.items[0].tax_lines[0].rate).toEqual(3) }) }) diff --git a/integration-tests/modules/__tests__/order/order.spec.ts b/integration-tests/modules/__tests__/order/order.spec.ts index 45641e63f1..b848390b7d 100644 --- a/integration-tests/modules/__tests__/order/order.spec.ts +++ b/integration-tests/modules/__tests__/order/order.spec.ts @@ -1,7 +1,14 @@ -import { createOrderChangeWorkflow } from "@medusajs/core-flows" +import { + createOrderChangeWorkflow, + createOrderWorkflow, +} from "@medusajs/core-flows" import { medusaIntegrationTestRunner } from "@medusajs/test-utils" -import { IOrderModuleService, OrderDTO } from "@medusajs/types" -import { Modules } from "@medusajs/utils" +import { + CreateOrderLineItemDTO, + IOrderModuleService, + OrderDTO, +} from "@medusajs/types" +import { Modules, ProductStatus } from "@medusajs/utils" import { adminHeaders, createAdminUser, @@ -26,6 +33,172 @@ medusaIntegrationTestRunner({ await createAdminUser(dbConnection, adminHeaders, appContainer) }) + describe("CreateOrderWorkflow", () => { + it("should create an order with items quantity and no unit price and calculate prices based on the correct pricing context including quantity", async () => { + const salesChannel = await api.post( + "/admin/sales-channels", + { + name: "Test Sales Channel", + description: "Test Sales Channel Description", + }, + adminHeaders + ) + + const productData = { + title: "Medusa T-Shirt based quantity", + handle: "t-shirt-with-quantity-prices", + status: ProductStatus.PUBLISHED, + sales_channels: [ + { + id: salesChannel.data.sales_channel.id, + }, + ], + options: [ + { + title: "Size", + values: ["S"], + }, + ], + variants: [ + { + title: "S", + sku: "SHIRT-S-BLACK-w-quantity-prices", + options: { + Size: "S", + }, + manage_inventory: false, + prices: [ + { + amount: 1500, + currency_code: "usd", + min_quantity: 1, + max_quantity: 4, + }, + { + amount: 1000, + currency_code: "usd", + min_quantity: 5, + max_quantity: 10, + }, + ], + }, + ], + } + + const newProduct = await api.post( + `/admin/products`, + productData, + adminHeaders + ) + + const variantId = newProduct.data.product.variants[0].id + const salesChannelId = salesChannel.data.sales_channel.id + const customer = ( + await api.post( + "/admin/customers", + { + email: "test1@email.com", + }, + adminHeaders + ) + ).data.customer + const region = ( + await api.post( + "/admin/regions", + { name: "US", currency_code: "usd", countries: ["us"] }, + adminHeaders + ) + ).data.region + + const { result: created } = await createOrderWorkflow(appContainer).run( + { + input: { + email: customer.email, + metadata: { + foo: "bar", + }, + items: [ + { + title: "Medusa T-Shirt based quantity", + variant_id: variantId, + quantity: 6, + } as CreateOrderLineItemDTO, + ], + sales_channel_id: salesChannelId, + region_id: region.id, + shipping_address: { + first_name: "Test", + last_name: "Test", + address_1: "Test", + city: "Test", + country_code: "US", + postal_code: "12345", + phone: "12345", + }, + billing_address: { + first_name: "Test", + last_name: "Test", + address_1: "Test", + city: "Test", + country_code: "US", + postal_code: "12345", + }, + shipping_methods: [ + { + name: "Test shipping method", + amount: 10, + data: {}, + tax_lines: [ + { + description: "shipping Tax 1", + tax_rate_id: "tax_usa_shipping", + code: "code", + rate: 10, + }, + ], + adjustments: [ + { + code: "VIP_10", + amount: 1, + description: "VIP discount", + promotion_id: "prom_123", + }, + ], + }, + ], + currency_code: "usd", + customer_id: customer.id, + }, + } + ) + + const order = ( + await api.get( + "/admin/orders/" + + created.id + + "?fields=+raw_total,+raw_subtotal,+raw_discount_total", + adminHeaders + ) + ).data.order + + expect(order).toEqual( + expect.objectContaining({ + original_item_subtotal: 6000, + original_item_tax_total: 0, + original_item_total: 6000, + original_shipping_subtotal: 10, + original_shipping_tax_total: 1, + original_shipping_total: 11, + original_tax_total: 1, + original_total: 6011, + item_subtotal: 6000, + item_tax_total: 0, + item_total: 6000, + }) + ) + }) + }) + describe("Orders - Admin", () => { it("should get an order", async () => { const created = await orderModule.createOrders({ @@ -107,7 +280,7 @@ medusaIntegrationTestRunner({ id: expect.any(String), status: "pending", version: 1, - display_id: 1, + display_id: 2, payment_collections: [], payment_status: "not_paid", region_id: "test_region_id", diff --git a/packages/core/core-flows/src/cart/steps/get-variant-price-sets.ts b/packages/core/core-flows/src/cart/steps/get-variant-price-sets.ts index 0ab7680453..30610c8eab 100644 --- a/packages/core/core-flows/src/cart/steps/get-variant-price-sets.ts +++ b/packages/core/core-flows/src/cart/steps/get-variant-price-sets.ts @@ -1,8 +1,13 @@ +import { Query } from "@medusajs/framework" import { CalculatedPriceSet, IPricingModuleService, } from "@medusajs/framework/types" -import { MedusaError, Modules } from "@medusajs/framework/utils" +import { + ContainerRegistrationKeys, + MedusaError, + Modules, +} from "@medusajs/framework/utils" import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" /** @@ -21,99 +26,260 @@ export interface GetVariantPriceSetsStepInput { context?: Record } -/** - * The calculated price sets of the variants. The object's keys are the variant IDs. - */ +export interface GetVariantPriceSetsStepBulkInput { + data: { + variantId: string + context?: Record + }[] +} + +interface VariantPriceSetData { + id: string + price_set?: { id: string } +} + +interface PriceCalculationItem { + variantId: string + priceSetId: string + context?: Record +} + export interface GetVariantPriceSetsStepOutput { [k: string]: CalculatedPriceSet } export const getVariantPriceSetsStepId = "get-variant-price-sets" + +async function fetchVariantPriceSets( + query: Query, + variantIds: string[] +): Promise { + return ( + await query.graph({ + entity: "variant", + fields: ["id", "price_set.id"], + filters: { id: variantIds }, + }) + ).data +} + +/** + * Validates that all variants have price sets and throws error for missing ones + */ +function validateVariantPriceSets( + variantPriceSets: VariantPriceSetData[] +): void { + const notFound = variantPriceSets + .filter((v) => !v.price_set?.id) + .map((v) => v.id) + + if (notFound.length) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Variants with IDs ${notFound.join(", ")} do not have a price` + ) + } +} + +/** + * Unified function to process variants with context grouping optimization + * TODO: to be discussed, support batch calculation from the pricing module. Currently + * trying to mitigate the impact by grouping items by exact same context. + */ +async function processVariantPriceSets( + pricingService: IPricingModuleService, + items: PriceCalculationItem[] +): Promise { + const result: GetVariantPriceSetsStepOutput = {} + + // Group items by their context to minimize API calls + const contextGroups = groupItemsByContext(items) + + for (const [, groupItems] of contextGroups) { + const priceSetIds = groupItems.map((item) => item.priceSetId) + const context = groupItems[0].context // All items in group have same context + + const calculatedPriceSets = await pricingService.calculatePrices( + { id: priceSetIds }, + { context: context as Record } + ) + + // Map calculated prices back to variants + const priceSetMap = new Map( + calculatedPriceSets.map((priceSet) => [priceSet.id, priceSet]) + ) + + for (const item of groupItems) { + const calculatedPriceSet = priceSetMap.get(item.priceSetId) + if (calculatedPriceSet) { + result[item.variantId] = calculatedPriceSet + } + } + } + + return result +} + +function createContextKey(context?: Record): string { + if (!context || Object.keys(context).length === 0) { + return "no-context" + } + + // Sort keys to ensure consistent grouping regardless of key order + const sortedEntries = Object.entries(context) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => `${key}:${JSON.stringify(value)}`) + + return sortedEntries.join("|") +} + +/** + * Groups calculation items by their context. It results in less API calls to the pricing module + * if we are able to group multiple item with the exact same context + */ +function groupItemsByContext( + items: PriceCalculationItem[] +): Map { + const groups = new Map() + + for (const item of items) { + const contextKey = createContextKey(item.context) + const existingGroup = groups.get(contextKey) + + if (existingGroup) { + existingGroup.push(item) + } else { + groups.set(contextKey, [item]) + } + } + + return groups +} + +/** + * Converts shared context input to unified calculation items format + */ +function createCalculationItemsFromSharedContext( + variantPriceSets: VariantPriceSetData[], + sharedContext?: Record +): PriceCalculationItem[] { + return variantPriceSets + .filter((v) => v.price_set?.id) + .map((v) => ({ + variantId: v.id, + priceSetId: v.price_set!.id, + context: sharedContext, + })) +} + +/** + * Converts individual context input to unified calculation items format + */ +function createCalculationItemsFromBulkData( + bulkData: GetVariantPriceSetsStepBulkInput["data"], + variantToPriceSetId: Map +): PriceCalculationItem[] { + const calculationItems: PriceCalculationItem[] = [] + for (const item of bulkData) { + const priceSetId = variantToPriceSetId.get(item.variantId) + if (priceSetId) { + calculationItems.push({ + variantId: item.variantId, + priceSetId, + context: item.context, + }) + } + } + return calculationItems +} + /** * This step retrieves the calculated price sets of the specified variants. * * @example - * To retrieve a variant's price sets: + * To retrieve variant price sets with shared context: * * ```ts * const data = getVariantPriceSetsStep({ * variantIds: ["variant_123"], + * context: { currency_code: "usd" } * }) * ``` * - * To retrieve the calculated price sets of a variant: + * To retrieve variant price sets with individual contexts: * * ```ts * const data = getVariantPriceSetsStep({ - * variantIds: ["variant_123"], - * context: { - * currency_code: "usd" - * } + * data: [ + * { variantId: "variant_123", context: { currency_code: "usd" } }, + * { variantId: "variant_456", context: { currency_code: "usd" } }, // Same context - will be batched + * { variantId: "variant_789", context: { currency_code: "eur" } } + * ] * }) * ``` */ export const getVariantPriceSetsStep = createStep( getVariantPriceSetsStepId, - async (data: GetVariantPriceSetsStepInput, { container }) => { - if (!data.variantIds.length) { - return new StepResponse({}) - } - + async ( + data: GetVariantPriceSetsStepInput | GetVariantPriceSetsStepBulkInput, + { container } + ) => { const pricingModuleService = container.resolve( Modules.PRICING ) + const query = container.resolve(ContainerRegistrationKeys.QUERY) - const remoteQuery = container.resolve("remoteQuery") + let calculationItems: PriceCalculationItem[] - const variantPriceSets = await remoteQuery({ - entryPoint: "variant", - fields: ["id", "price_set.id"], - variables: { - id: data.variantIds, - }, - }) - - const notFound: string[] = [] - const priceSetIds: string[] = [] - - variantPriceSets.forEach((v) => { - if (v.price_set?.id) { - priceSetIds.push(v.price_set.id) - } else { - notFound.push(v.id) + // Handle shared context variants (original input format) + if ("variantIds" in data) { + if (!data.variantIds.length) { + return new StepResponse({}) } - }) - if (notFound.length) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Variants with IDs ${notFound.join(", ")} do not have a price` + const variantPriceSets = await fetchVariantPriceSets( + query, + data.variantIds + ) + + validateVariantPriceSets(variantPriceSets) + + calculationItems = createCalculationItemsFromSharedContext( + variantPriceSets, + data.context + ) + } else { + // Handle individual context variants (bulk input format) + const bulkData = data.data + if (!bulkData.length) { + return new StepResponse({}) + } + + const variantIds = bulkData.map((item) => item.variantId) + const variantPriceSets = await fetchVariantPriceSets(query, variantIds) + + validateVariantPriceSets(variantPriceSets) + + // Map variant IDs to price set IDs + const variantToPriceSetId = new Map() + variantPriceSets.forEach((v) => { + if (v.price_set?.id) { + variantToPriceSetId.set(v.id, v.price_set.id) + } + }) + + calculationItems = createCalculationItemsFromBulkData( + bulkData, + variantToPriceSetId ) } - const calculatedPriceSets = await pricingModuleService.calculatePrices( - { id: priceSetIds }, - { context: data.context as Record } + // Use unified processing logic for both input types + const result = await processVariantPriceSets( + pricingModuleService, + calculationItems ) - const idToPriceSet = new Map>( - calculatedPriceSets.map((p) => [p.id, p]) - ) - - const variantToCalculatedPriceSets = variantPriceSets.reduce( - (acc, { id, price_set }) => { - const calculatedPriceSet = idToPriceSet.get(price_set?.id) - if (calculatedPriceSet) { - acc[id] = calculatedPriceSet - } - - return acc - }, - {} - ) - - return new StepResponse( - variantToCalculatedPriceSets as GetVariantPriceSetsStepOutput - ) + return new StepResponse(result) } ) diff --git a/packages/core/core-flows/src/cart/utils/fields.ts b/packages/core/core-flows/src/cart/utils/fields.ts index 0a1ed9399e..14cc80c0cb 100644 --- a/packages/core/core-flows/src/cart/utils/fields.ts +++ b/packages/core/core-flows/src/cart/utils/fields.ts @@ -166,7 +166,6 @@ export const productVariantsFields = [ "product.discountable", "product.is_giftcard", "product.shipping_profile.id", - "calculated_price.*", "inventory_items.inventory_item_id", "inventory_items.required_quantity", "inventory_items.inventory.requires_shipping", 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 86c7cff0e9..84556b901b 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 @@ -2,10 +2,12 @@ import { AdditionalData, AddToCartWorkflowInputDTO, ConfirmVariantInventoryWorkflowInputDTO, + WithCalculatedPrice, } from "@medusajs/framework/types" import { CartWorkflowEvents, deduplicate, + filterObjectByKeys, isDefined, } from "@medusajs/framework/utils" import { @@ -19,10 +21,10 @@ import { } from "@medusajs/framework/workflows-sdk" import { useQueryGraphStep } from "../../common" import { emitEventStep } from "../../common/steps/emit-event" -import { useRemoteQueryStep } from "../../common/steps/use-remote-query" import { createLineItemsStep, getLineItemActionsStep, + getVariantPriceSetsStep, updateLineItemsStep, } from "../steps" import { validateCartStep } from "../steps/validate-cart" @@ -36,6 +38,7 @@ import { requiredVariantFieldsForInventoryConfirmation } from "../utils/prepare- import { prepareLineItemData, PrepareLineItemDataInput, + PrepareVariantLineItemInput, } from "../utils/prepare-line-item-data" import { pricingContextResult } from "../utils/schemas" import { confirmVariantInventoryWorkflow } from "./confirm-variant-inventory" @@ -148,40 +151,74 @@ export const addToCartWorkflow = createWorkflow( ) const setPricingContextResult = setPricingContext.getResult() - const pricingContext = transform( - { cart, setPricingContextResult }, - (data) => { - return { - ...data.cart, - ...(data.setPricingContextResult ? data.setPricingContextResult : {}), - currency_code: data.cart.currency_code, - region_id: data.cart.region_id, - region: data.cart.region, - customer_id: data.cart.customer_id, - customer: data.cart.customer, - } - } - ) - const variants = when({ variantIds }, ({ variantIds }) => { - return !!variantIds.length - }).then(() => { - return useRemoteQueryStep({ - entry_point: "variants", + const variants = when( + "should-calculate-prices", + { variantIds }, + ({ variantIds }) => { + return !!variantIds.length + } + ).then(() => { + const pricingContext = transform( + { cart, items: input.items, setPricingContextResult }, + (data): { variantId: string; context: Record }[] => { + const baseContext = { + ...filterObjectByKeys(data.cart, cartFieldsForPricingContext), + ...(data.setPricingContextResult + ? data.setPricingContextResult + : {}), + currency_code: data.cart.currency_code, + region_id: data.cart.region_id, + region: data.cart.region, + customer_id: data.cart.customer_id, + customer: data.cart.customer, + } + + return data.items + .filter((i) => i.variant_id) + .map((item) => { + return { + variantId: item.variant_id!, + context: { + ...baseContext, + quantity: item.quantity, + }, + } + }) + } + ) + + const { data: variantsData } = useQueryGraphStep({ + entity: "variants", fields: deduplicate([ ...productVariantsFields, ...requiredVariantFieldsForInventoryConfirmation, ]), - variables: { + filters: { id: variantIds, - calculated_price: { - context: pricingContext, - }, }, }) - }) - validateVariantPricesStep({ variants }) + const calculatedPriceSets = getVariantPriceSetsStep({ + data: pricingContext, + }) + + const variants = transform( + { variantsData, calculatedPriceSets }, + ({ variantsData, calculatedPriceSets }) => { + return variantsData.map((variant) => { + variant.calculated_price = calculatedPriceSets[variant.id] + return variant + }) + } + ) + + validateVariantPricesStep({ variants }) + + return variants as (PrepareVariantLineItemInput & + ConfirmVariantInventoryWorkflowInputDTO["variants"][number] & + WithCalculatedPrice)[] + }) const lineItems = transform({ input, variants }, (data) => { const items = (data.input.items ?? []).map((item) => { 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 3d65c512a8..9b51277f47 100644 --- a/packages/core/core-flows/src/cart/workflows/create-carts.ts +++ b/packages/core/core-flows/src/cart/workflows/create-carts.ts @@ -17,13 +17,14 @@ import { WorkflowData, WorkflowResponse, } from "@medusajs/framework/workflows-sdk" +import { useQueryGraphStep } from "../../common" import { emitEventStep } from "../../common/steps/emit-event" -import { useRemoteQueryStep } from "../../common/steps/use-remote-query" import { createCartsStep, findOneOrAnyRegionStep, findOrCreateCustomerStep, findSalesChannelStep, + getVariantPriceSetsStep, } from "../steps" import { validateLineItemPricesStep } from "../steps/validate-line-item-prices" import { validateSalesChannelStep } from "../steps/validate-sales-channel" @@ -167,30 +168,62 @@ export const createCartWorkflow = createWorkflow( } ) - const variants = when({ variantIds }, ({ variantIds }) => { + const variants = when("has-variants", { variantIds }, ({ variantIds }) => { return !!variantIds.length }).then(() => { - return useRemoteQueryStep({ - entry_point: "variants", + const { data: variantsData } = useQueryGraphStep({ + entity: "variants", fields: deduplicate([ ...productVariantsFields, ...requiredVariantFieldsForInventoryConfirmation, ]), - variables: { + filters: { id: variantIds, - calculated_price: { - context: pricingContext, - }, }, }) - }) - validateVariantPricesStep({ variants }) + const calculatedPriceContext = transform( + { pricingContext, items: input.items }, + (data): { variantId: string; context: Record }[] => { + const baseContext = data.pricingContext + + return (data.items ?? []) + .filter((i) => i.variant_id) + .map((item) => { + return { + variantId: item.variant_id!, + context: { + ...baseContext, + quantity: item.quantity, + }, + } + }) + } + ) + + const calculatedPriceSets = getVariantPriceSetsStep({ + data: calculatedPriceContext, + }) + + const variants = transform( + { variantsData, calculatedPriceSets }, + ({ variantsData, calculatedPriceSets }) => { + return variantsData.map((variant) => { + variant.calculated_price = calculatedPriceSets[variant.id] + return variant + }) + } + ) + + validateVariantPricesStep({ variants }) + + return variants + }) confirmVariantInventoryWorkflow.runAsStep({ input: { sales_channel_id: salesChannel.id, - variants, + variants: variants!, items: input.items!, }, }) diff --git a/packages/core/core-flows/src/cart/workflows/list-shipping-options-for-cart.ts b/packages/core/core-flows/src/cart/workflows/list-shipping-options-for-cart.ts index 2cae7f8898..e9aafe49b8 100644 --- a/packages/core/core-flows/src/cart/workflows/list-shipping-options-for-cart.ts +++ b/packages/core/core-flows/src/cart/workflows/list-shipping-options-for-cart.ts @@ -12,7 +12,7 @@ import { AdditionalData, ListShippingOptionsForCartWorkflowInput, } from "@medusajs/types" -import { isDefined } from "@medusajs/framework/utils" +import { filterObjectByKeys, isDefined } from "@medusajs/framework/utils" import { pricingContextResult } from "../utils/schemas" export const listShippingOptionsForCartWorkflowId = @@ -181,7 +181,7 @@ export const listShippingOptionsForCartWorkflow = createWorkflow( calculated_price: { context: { - ...cart, + ...filterObjectByKeys(cart, cartFieldsForPricingContext), ...(setPricingContextResult ? setPricingContextResult : {}), currency_code: cart.currency_code, region_id: cart.region_id, 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 c770b8761d..b7f75327dd 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 @@ -11,8 +11,10 @@ import { WorkflowData, WorkflowResponse, } from "@medusajs/framework/workflows-sdk" +import { AdditionalData, CartDTO } from "@medusajs/types" +import { useQueryGraphStep } from "../../common" import { useRemoteQueryStep } from "../../common/steps/use-remote-query" -import { updateLineItemsStep } from "../steps" +import { getVariantPriceSetsStep, updateLineItemsStep } from "../steps" import { validateVariantPricesStep } from "../steps/validate-variant-prices" import { cartFieldsForPricingContext, @@ -23,13 +25,12 @@ import { prepareLineItemData, PrepareLineItemDataInput, } from "../utils/prepare-line-item-data" +import { pricingContextResult } from "../utils/schemas" import { refreshCartShippingMethodsWorkflow } from "./refresh-cart-shipping-methods" import { refreshPaymentCollectionForCartWorkflow } from "./refresh-payment-collection" import { updateCartPromotionsWorkflow } from "./update-cart-promotions" import { updateTaxLinesWorkflow } from "./update-tax-lines" import { upsertTaxLinesWorkflow } from "./upsert-tax-lines" -import { AdditionalData } from "@medusajs/types" -import { pricingContextResult } from "../utils/schemas" /** * The details of the cart to refresh. @@ -142,48 +143,77 @@ export const refreshCartItemsWorkflow = createWorkflow( ) const setPricingContextResult = setPricingContext.getResult() - when({ input }, ({ input }) => { + when("force-refresh-calculate-prices", { input }, ({ input }) => { return !!input.force_refresh }).then(() => { - const cart = useRemoteQueryStep({ - entry_point: "cart", + const { data: cart } = useQueryGraphStep({ + entity: "cart", fields: cartFieldsForRefreshSteps, - variables: { id: input.cart_id }, - list: false, + filters: { id: input.cart_id }, + pagination: { + take: 1, + }, + options: { + isList: false, + }, }) - const variantIds = transform({ cart }, (data) => { + const variantIds = transform({ cart }, (data: { cart: CartDTO }) => { return (data.cart.items ?? []).map((i) => i.variant_id).filter(Boolean) }) const cartPricingContext = transform( { cart, setPricingContextResult }, - (data) => { - return { - ...filterObjectByKeys(data.cart, cartFieldsForPricingContext), + (data): { variantId: string; context: Record }[] => { + const cart = data.cart + const baseContext = { + ...filterObjectByKeys(cart, cartFieldsForPricingContext), ...(data.setPricingContextResult ? data.setPricingContextResult : {}), - currency_code: data.cart.currency_code, - region_id: data.cart.region_id, - region: data.cart.region, - customer_id: data.cart.customer_id, - customer: data.cart.customer, + currency_code: cart.currency_code, + region_id: cart.region_id, + region: cart.region, + customer_id: cart.customer_id, + customer: cart.customer, } + + return cart.items + .filter((i) => i.variant_id) + .map((item) => { + return { + variantId: item.variant_id, + context: { + ...baseContext, + quantity: item.quantity, + }, + } + }) } ) - const variants = useRemoteQueryStep({ - entry_point: "variants", + const { data: variantsData } = useQueryGraphStep({ + entity: "variants", fields: productVariantsFields, - variables: { + filters: { id: variantIds, - calculated_price: { - context: cartPricingContext, - }, }, }).config({ name: "fetch-variants" }) + const calculatedPriceSets = getVariantPriceSetsStep({ + data: cartPricingContext, + }) + + const variants = transform( + { variantsData, calculatedPriceSets }, + ({ variantsData, calculatedPriceSets }) => { + return variantsData.map((variant) => { + variant.calculated_price = calculatedPriceSets[variant.id] + return variant + }) + } + ) + validateVariantPricesStep({ variants }) const lineItems = transform({ cart, variants }, ({ cart, variants }) => { @@ -244,7 +274,7 @@ export const refreshCartItemsWorkflow = createWorkflow( input: refreshCartInput, }) - when({ input }, ({ input }) => { + when("force-refresh-update-tax-lines", { input }, ({ input }) => { return !!input.force_refresh }).then(() => { updateTaxLinesWorkflow.runAsStep({ @@ -252,7 +282,7 @@ export const refreshCartItemsWorkflow = createWorkflow( }) }) - when({ input }, ({ input }) => { + when("force-refresh-upsert-tax-lines", { input }, ({ input }) => { return ( !input.force_refresh && (!!input.items?.length || !!input.shipping_methods?.length) diff --git a/packages/core/core-flows/src/cart/workflows/refresh-cart-shipping-methods.ts b/packages/core/core-flows/src/cart/workflows/refresh-cart-shipping-methods.ts index 74c977b6b7..709e742111 100644 --- a/packages/core/core-flows/src/cart/workflows/refresh-cart-shipping-methods.ts +++ b/packages/core/core-flows/src/cart/workflows/refresh-cart-shipping-methods.ts @@ -52,7 +52,7 @@ export const refreshCartShippingMethodsWorkflowId = export const refreshCartShippingMethodsWorkflow = createWorkflow( refreshCartShippingMethodsWorkflowId, (input: WorkflowData) => { - const fetchCart = when({ input }, ({ input }) => { + const fetchCart = when("fetch-cart", { input }, ({ input }) => { return !input.cart }).then(() => { return useRemoteQueryStep({ @@ -94,9 +94,13 @@ export const refreshCartShippingMethodsWorkflow = createWorkflow( cart, }) - when({ listShippingOptionsInput }, ({ listShippingOptionsInput }) => { - return !!listShippingOptionsInput?.length - }).then(() => { + when( + "should-prepare-shipping-methods", + { listShippingOptionsInput }, + ({ listShippingOptionsInput }) => { + return !!listShippingOptionsInput?.length + } + ).then(() => { const shippingOptions = listShippingOptionsForCartWithPricingWorkflow.runAsStep({ input: { diff --git a/packages/core/core-flows/src/cart/workflows/refresh-payment-collection.ts b/packages/core/core-flows/src/cart/workflows/refresh-payment-collection.ts index e3c0d82c56..5c28d46d7c 100644 --- a/packages/core/core-flows/src/cart/workflows/refresh-payment-collection.ts +++ b/packages/core/core-flows/src/cart/workflows/refresh-payment-collection.ts @@ -56,7 +56,7 @@ export const refreshPaymentCollectionForCartWorkflowId = export const refreshPaymentCollectionForCartWorkflow = createWorkflow( refreshPaymentCollectionForCartWorkflowId, (input: WorkflowData) => { - const fetchCart = when({ input }, ({ input }) => { + const fetchCart = when("should-fetch-cart", { input }, ({ input }) => { return !input.cart }).then(() => { return useRemoteQueryStep({ @@ -88,7 +88,7 @@ export const refreshPaymentCollectionForCartWorkflow = createWorkflow( cart, }) - when({ cart }, ({ cart }) => { + when("should-update-payment-collection", { cart }, ({ cart }) => { const valueIsEqual = MathBN.eq( cart.payment_collection?.raw_amount ?? -1, cart.raw_total diff --git a/packages/core/core-flows/src/cart/workflows/transfer-cart-customer.ts b/packages/core/core-flows/src/cart/workflows/transfer-cart-customer.ts index 45fa328d59..310f99687c 100644 --- a/packages/core/core-flows/src/cart/workflows/transfer-cart-customer.ts +++ b/packages/core/core-flows/src/cart/workflows/transfer-cart-customer.ts @@ -93,34 +93,33 @@ export const transferCartCustomerWorkflow = createWorkflow( ({ cart, customer }) => cart.customer?.id !== customer.id ) - when({ shouldTransfer }, ({ shouldTransfer }) => shouldTransfer).then( - () => { - const cartInput = transform( - { cart, customer }, - ({ cart, customer }) => [ - { - id: cart.id, - customer_id: customer.id, - email: customer.email, - }, - ] - ) + when( + "should-transfer-cart", + { shouldTransfer }, + ({ shouldTransfer }) => shouldTransfer + ).then(() => { + const cartInput = transform({ cart, customer }, ({ cart, customer }) => [ + { + id: cart.id, + customer_id: customer.id, + email: customer.email, + }, + ]) - updateCartsStep(cartInput) + updateCartsStep(cartInput) - refreshCartItemsWorkflow.runAsStep({ - input: { cart_id: input.id, force_refresh: true }, - }) + refreshCartItemsWorkflow.runAsStep({ + input: { cart_id: input.id, force_refresh: true }, + }) - emitEventStep({ - eventName: CartWorkflowEvents.CUSTOMER_TRANSFERRED, - data: { - id: input.id, - customer_id: customer.customer_id, - }, - }) - } - ) + emitEventStep({ + eventName: CartWorkflowEvents.CUSTOMER_TRANSFERRED, + data: { + id: input.id, + customer_id: customer.customer_id, + }, + }) + }) return new WorkflowResponse(void 0, { hooks: [validate], diff --git a/packages/core/core-flows/src/cart/workflows/update-cart-promotions.ts b/packages/core/core-flows/src/cart/workflows/update-cart-promotions.ts index b5d4cf13f9..95599be690 100644 --- a/packages/core/core-flows/src/cart/workflows/update-cart-promotions.ts +++ b/packages/core/core-flows/src/cart/workflows/update-cart-promotions.ts @@ -75,7 +75,7 @@ export const updateCartPromotionsWorkflowId = "update-cart-promotions" export const updateCartPromotionsWorkflow = createWorkflow( updateCartPromotionsWorkflowId, (input: WorkflowData) => { - const fetchCart = when({ input }, ({ input }) => { + const fetchCart = when("should-fetch-cart", { input }, ({ input }) => { return !input.cart }).then(() => { return useRemoteQueryStep({ 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 487603d028..b2c013a67b 100644 --- a/packages/core/core-flows/src/cart/workflows/update-cart.ts +++ b/packages/core/core-flows/src/cart/workflows/update-cart.ts @@ -1,5 +1,6 @@ import { AdditionalData, + CartDTO, UpdateCartWorkflowInputDTO, } from "@medusajs/framework/types" import { @@ -16,11 +17,7 @@ import { WorkflowData, WorkflowResponse, } from "@medusajs/framework/workflows-sdk" -import { - emitEventStep, - useQueryGraphStep, - useRemoteQueryStep, -} from "../../common" +import { emitEventStep, useQueryGraphStep } from "../../common" import { deleteLineItemsStep } from "../../line-item" import { findOrCreateCustomerStep, @@ -83,9 +80,9 @@ export const updateCartWorkflowId = "update-cart" export const updateCartWorkflow = createWorkflow( updateCartWorkflowId, (input: WorkflowData) => { - const cartToUpdate = useRemoteQueryStep({ - entry_point: "cart", - variables: { id: input.id }, + const { data: cartToUpdate } = useQueryGraphStep({ + entity: "cart", + filters: { id: input.id }, fields: [ "id", "email", @@ -95,18 +92,26 @@ export const updateCartWorkflow = createWorkflow( "region.*", "region.countries.*", ], - list: false, - throw_if_key_not_found: true, + pagination: { + take: 1, + }, + options: { + throwIfKeyNotFound: true, + isList: false, + }, }).config({ name: "get-cart" }) - const cartDataInput = transform({ input, cartToUpdate }, (data) => { - return { - sales_channel_id: - data.input.sales_channel_id ?? data.cartToUpdate.sales_channel_id, - customer_id: data.cartToUpdate.customer_id, - email: data.input.email ?? data.cartToUpdate.email, + const cartDataInput = transform( + { input, cartToUpdate }, + (data: { input: UpdateCartWorkflowInput; cartToUpdate: CartDTO }) => { + return { + sales_channel_id: + data.input.sales_channel_id ?? data.cartToUpdate.sales_channel_id, + customer_id: data.cartToUpdate.customer_id, + email: data.input.email ?? data.cartToUpdate.email, + } } - }) + ) const [salesChannel, customer] = parallelize( findSalesChannelStep({ @@ -120,16 +125,23 @@ export const updateCartWorkflow = createWorkflow( validateSalesChannelStep({ salesChannel }) - const newRegion = when({ input }, (data) => { + const newRegion = when("should-fetch-region", { input }, (data) => { return !!data.input.region_id }).then(() => { - return useRemoteQueryStep({ - entry_point: "region", - variables: { id: input.region_id }, + const { data: newRegion } = useQueryGraphStep({ + entity: "region", + filters: { id: input.region_id }, fields: ["id", "countries.*", "currency_code", "name"], - list: false, - throw_if_key_not_found: true, + pagination: { + take: 1, + }, + options: { + throwIfKeyNotFound: true, + isList: false, + }, }).config({ name: "get-region" }) + + return newRegion }) const region = transform({ cartToUpdate, newRegion }, (data) => { @@ -239,9 +251,13 @@ export const updateCartWorkflow = createWorkflow( } ) - when({ regionUpdated }, ({ regionUpdated }) => { - return !!regionUpdated - }).then(() => { + when( + "should-emit-region-updated", + { regionUpdated }, + ({ regionUpdated }) => { + return !!regionUpdated + } + ).then(() => { emitEventStep({ eventName: CartWorkflowEvents.REGION_UPDATED, data: { id: input.id }, @@ -258,7 +274,7 @@ 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 }) => { + when("should-delete-line-items", { regionUpdated }, ({ regionUpdated }) => { return !!regionUpdated }).then(() => { const lineItems = useQueryGraphStep({ 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 74133eb149..9dc055434a 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,12 +1,17 @@ import { AdditionalData, + CartDTO, + CustomerDTO, + RegionDTO, UpdateLineItemInCartWorkflowInputDTO, } from "@medusajs/framework/types" import { CartWorkflowEvents, deduplicate, + filterObjectByKeys, isDefined, MedusaError, + QueryContext, } from "@medusajs/framework/utils" import { createHook, @@ -18,7 +23,6 @@ import { } from "@medusajs/framework/workflows-sdk" import { useQueryGraphStep } from "../../common" import { emitEventStep } from "../../common/steps/emit-event" -import { useRemoteQueryStep } from "../../common/steps/use-remote-query" import { updateLineItemsStepWithSelector } from "../../line-item/steps" import { validateCartStep } from "../steps/validate-cart" import { validateVariantPricesStep } from "../steps/validate-variant-prices" @@ -32,6 +36,13 @@ import { confirmVariantInventoryWorkflow } from "./confirm-variant-inventory" import { refreshCartItemsWorkflow } from "./refresh-cart-items" const cartFields = cartFieldsForPricingContext.concat(["items.*"]) +const variantFields = productVariantsFields.concat(["calculated_price.*"]) + +interface CartQueryDTO extends Omit { + items: NonNullable + customer: CustomerDTO + region: RegionDTO +} export const updateLineItemInCartWorkflowId = "update-line-item-in-cart" /** @@ -97,17 +108,24 @@ export const updateLineItemInCartWorkflow = createWorkflow( ( input: WorkflowData ) => { - const cartQuery = useQueryGraphStep({ + const { data: cart } = useQueryGraphStep({ entity: "cart", filters: { id: input.cart_id }, fields: cartFields, - options: { throwIfKeyNotFound: true }, + options: { throwIfKeyNotFound: true, isList: false }, }).config({ name: "get-cart" }) - const cart = transform({ cartQuery }, ({ cartQuery }) => cartQuery.data[0]) - const item = transform({ cart, input }, ({ cart, input }) => { - return cart.items.find((i) => i.id === input.item_id) - }) + const { item, variantIds } = transform( + { cart, input }, + (data: { + cart: CartQueryDTO + input: UpdateLineItemInCartWorkflowInputDTO & AdditionalData + }) => { + const item = data.cart.items.find((i) => i.id === data.input.item_id)! + const variantIds = [item?.variant_id].filter(Boolean) + return { item, variantIds } + } + ) validateCartStep({ cart }) @@ -116,10 +134,6 @@ export const updateLineItemInCartWorkflow = createWorkflow( cart, }) - const variantIds = transform({ item }, ({ item }) => { - return [item.variant_id].filter(Boolean) - }) - const setPricingContext = createHook( "setPricingContext", { @@ -134,40 +148,55 @@ export const updateLineItemInCartWorkflow = createWorkflow( ) const setPricingContextResult = setPricingContext.getResult() + const pricingContext = transform( - { cart, setPricingContextResult }, - (data) => { + { cart, item, update: input.update, setPricingContextResult }, + (data): Record => { return { - ...data.cart, + ...filterObjectByKeys(data.cart, cartFieldsForPricingContext), ...(data.setPricingContextResult ? data.setPricingContextResult : {}), + quantity: data.update.quantity ?? data.item.quantity, currency_code: data.cart.currency_code, - region_id: data.cart.region_id, - region: data.cart.region, - customer_id: data.cart.customer_id, - customer: data.cart.customer, + region_id: data.cart.region_id!, + region: data.cart.region!, + customer_id: data.cart.customer_id!, + customer: data.cart.customer!, } } ) - const variants = when({ variantIds }, ({ variantIds }) => { - return !!variantIds.length - }).then(() => { - return useRemoteQueryStep({ - entry_point: "variants", + const variants = when( + "should-fetch-variants", + { variantIds }, + ({ variantIds }) => { + return !!variantIds.length + } + ).then(() => { + const calculatedPriceQueryContext = transform( + { pricingContext }, + ({ pricingContext }) => { + return QueryContext(pricingContext) + } + ) + + const { data: variants } = useQueryGraphStep({ + entity: "variants", fields: deduplicate([ - ...productVariantsFields, + ...variantFields, ...requiredVariantFieldsForInventoryConfirmation, ]), - variables: { + filters: { id: variantIds, - calculated_price: { - context: pricingContext, - }, + }, + context: { + calculated_price: calculatedPriceQueryContext, }, }).config({ name: "fetch-variants" }) - }) - validateVariantPricesStep({ variants }) + validateVariantPricesStep({ variants }) + + return variants + }) const items = transform({ input, item }, (data) => { return [ diff --git a/packages/core/core-flows/src/cart/workflows/update-tax-lines.ts b/packages/core/core-flows/src/cart/workflows/update-tax-lines.ts index 5b4c3f6ebe..6854c3ce68 100644 --- a/packages/core/core-flows/src/cart/workflows/update-tax-lines.ts +++ b/packages/core/core-flows/src/cart/workflows/update-tax-lines.ts @@ -123,7 +123,7 @@ export const updateTaxLinesWorkflowId = "update-tax-lines" export const updateTaxLinesWorkflow = createWorkflow( updateTaxLinesWorkflowId, (input: WorkflowData): WorkflowData => { - const fetchCart = when({ input }, ({ input }) => { + const fetchCart = when("should-fetch-cart", { input }, ({ input }) => { return !input.cart }).then(() => { return useRemoteQueryStep({ diff --git a/packages/core/core-flows/src/cart/workflows/upsert-tax-lines.ts b/packages/core/core-flows/src/cart/workflows/upsert-tax-lines.ts index 8d26725c34..88e9749abe 100644 --- a/packages/core/core-flows/src/cart/workflows/upsert-tax-lines.ts +++ b/packages/core/core-flows/src/cart/workflows/upsert-tax-lines.ts @@ -121,7 +121,7 @@ export const upsertTaxLinesWorkflowId = "upsert-tax-lines" export const upsertTaxLinesWorkflow = createWorkflow( upsertTaxLinesWorkflowId, (input: WorkflowData): WorkflowData => { - const fetchCart = when({ input }, ({ input }) => { + const fetchCart = when("should-fetch-cart", { input }, ({ input }) => { return !input.cart }).then(() => { return useRemoteQueryStep({ diff --git a/packages/core/core-flows/src/common/steps/__fixtures__/remote-query.ts b/packages/core/core-flows/src/common/steps/__fixtures__/remote-query.ts new file mode 100644 index 0000000000..3556b1ed7d --- /dev/null +++ b/packages/core/core-flows/src/common/steps/__fixtures__/remote-query.ts @@ -0,0 +1,13 @@ +export interface SimpleProduct { + id: string + title: string + description: string +} + +export interface FixtureEntryPoints { + simple_product: SimpleProduct +} + +declare module "@medusajs/types/dist/modules-sdk/remote-query-entry-points" { + export interface RemoteQueryEntryPoints extends FixtureEntryPoints {} +} diff --git a/packages/core/core-flows/src/common/steps/__tests__/use-query-graph-step.spec.ts b/packages/core/core-flows/src/common/steps/__tests__/use-query-graph-step.spec.ts new file mode 100644 index 0000000000..92f5876f35 --- /dev/null +++ b/packages/core/core-flows/src/common/steps/__tests__/use-query-graph-step.spec.ts @@ -0,0 +1,95 @@ +import { createWorkflow, WorkflowResponse } from "@medusajs/workflows-sdk" +import { expectTypeOf } from "expect-type" +import { FixtureEntryPoints } from "../__fixtures__/remote-query" +import { useQueryGraphStep } from "../use-query-graph" +import { MedusaContainer } from "@medusajs/framework" +import { asFunction, createContainer } from "awilix" +import { ContainerRegistrationKeys } from "@medusajs/framework/utils" + +describe("useQueryGraphStep", () => { + let container!: MedusaContainer + + beforeAll(() => { + container = createContainer() as unknown as MedusaContainer + container.register( + ContainerRegistrationKeys.QUERY, + asFunction(() => { + return { + graph: () => Promise.resolve({ data: [] }), + } as any + }) + ) + }) + + it("should return a single data item when is_list is false", async () => { + const workflow = createWorkflow("useQueryGraphStepTest1", (_: any) => { + const result = useQueryGraphStep({ + entity: "simple_product", + fields: ["*"], + filters: { + id: "123", + }, + options: { + isList: false, + }, + }) + + return new WorkflowResponse(result) + }) + + const result = await workflow(container).run() + + type Result = (typeof result)["result"] + + expectTypeOf().toEqualTypeOf< + FixtureEntryPoints["simple_product"] + >() + }) + + it("should return a list of data items when is_list is true", async () => { + const workflow = createWorkflow("useQueryGraphStepTest1", (_: any) => { + const result = useQueryGraphStep({ + entity: "simple_product", + fields: ["*"], + filters: { + id: "123", + }, + options: { + isList: true, + }, + }) + + return new WorkflowResponse(result) + }) + + const result = await workflow(container).run() + + type Result = (typeof result)["result"] + + expectTypeOf().toEqualTypeOf< + FixtureEntryPoints["simple_product"][] + >() + }) + + it("should return a list of data items when is_list is not specified", async () => { + const workflow = createWorkflow("useQueryGraphStepTest1", (_: any) => { + const result = useQueryGraphStep({ + entity: "simple_product", + fields: ["*"], + filters: { + id: "123", + }, + }) + + return new WorkflowResponse(result) + }) + + const result = await workflow(container).run() + + type Result = (typeof result)["result"] + + expectTypeOf().toEqualTypeOf< + FixtureEntryPoints["simple_product"][] + >() + }) +}) diff --git a/packages/core/core-flows/src/common/steps/use-query-graph.ts b/packages/core/core-flows/src/common/steps/use-query-graph.ts index f9faf00bd4..0fb31cc958 100644 --- a/packages/core/core-flows/src/common/steps/use-query-graph.ts +++ b/packages/core/core-flows/src/common/steps/use-query-graph.ts @@ -7,10 +7,28 @@ import { import { createStep, StepFunction, StepResponse } from "@medusajs/workflows-sdk" import { ContainerRegistrationKeys } from "@medusajs/utils" -export type UseQueryGraphStepInput = - RemoteQueryInput & { - options?: RemoteJoinerOptions +export type UseQueryGraphStepInput< + TEntry extends string, + TIsList extends boolean = boolean +> = RemoteQueryInput & { + options?: RemoteJoinerOptions & { + isList?: TIsList } +} + +export type UseQueryGraphStepOutput< + TEntry extends string, + TIsList extends boolean = boolean +> = ReturnType< + StepFunction< + any, + true extends TIsList + ? GraphResultSet + : Omit, "data"> & { + data: GraphResultSet["data"][number] + } + > +> const useQueryGraphStepId = "use-query-graph-step" @@ -20,9 +38,20 @@ const step = createStep( const query = container.resolve( ContainerRegistrationKeys.QUERY ) + + const isList = input.options?.isList ?? true + delete input.options?.isList + const { options, ...queryConfig } = input const result = await query.graph(queryConfig as any, options) + + if (!isList) { + const data = result.data?.[0] + result.data = data + return new StepResponse(result) + } + return new StepResponse(result) } ) @@ -100,9 +129,10 @@ const step = createStep( * }) * ``` */ -export const useQueryGraphStep = ( - input: UseQueryGraphStepInput -): ReturnType>> => - step(input as any) as unknown as ReturnType< - StepFunction> - > +export const useQueryGraphStep = < + const TEntry extends string, + const TIsList extends boolean = boolean +>( + input: UseQueryGraphStepInput +): UseQueryGraphStepOutput => + step(input as any) as unknown as UseQueryGraphStepOutput diff --git a/packages/core/core-flows/src/draft-order/workflows/refresh-draft-order-adjustments.ts b/packages/core/core-flows/src/draft-order/workflows/refresh-draft-order-adjustments.ts index 59a184b915..f458f19dae 100644 --- a/packages/core/core-flows/src/draft-order/workflows/refresh-draft-order-adjustments.ts +++ b/packages/core/core-flows/src/draft-order/workflows/refresh-draft-order-adjustments.ts @@ -28,15 +28,15 @@ export interface RefreshDraftOrderAdjustmentsWorkflowInput { * The draft order to refresh the adjustments for. */ order: OrderDTO - - // TODO: I will reintroduce this type, once I have migrated all of the order flows to fit the expected type. + + // TODO: I will reintroduce this type, once I have migrated all of the order flows to fit the expected type. // Doing this in a single PR is too much work, so I'm going to do it in smaller PRs. // // order: Omit & { // items?: ComputeActionItemLine[] // promotions?: PromotionDTO[] // } - + /** * The promo codes to add or remove from the draft order. */ diff --git a/packages/core/core-flows/src/order/utils/fields.ts b/packages/core/core-flows/src/order/utils/fields.ts index a82aea39a2..e82712d9fa 100644 --- a/packages/core/core-flows/src/order/utils/fields.ts +++ b/packages/core/core-flows/src/order/utils/fields.ts @@ -14,7 +14,6 @@ export const productVariantsFields = [ "product.type.id", "product.collection.title", "product.handle", - "calculated_price.*", "inventory_items.inventory_item_id", "inventory_items.required_quantity", "inventory_items.inventory.requires_shipping", 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 862a601740..ad0acaf233 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 @@ -25,9 +25,10 @@ import { } from "../../cart/utils/prepare-line-item-data" import { pricingContextResult } from "../../cart/utils/schemas" import { confirmVariantInventoryWorkflow } from "../../cart/workflows/confirm-variant-inventory" -import { useRemoteQueryStep } from "../../common" +import { useQueryGraphStep, useRemoteQueryStep } from "../../common" import { createOrderLineItemsStep } from "../steps" import { productVariantsFields } from "../utils/fields" +import { getVariantPriceSetsStep } from "../../cart" function prepareLineItems(data) { const items = (data.input.items ?? []).map((item) => { @@ -193,30 +194,66 @@ export const addOrderLineItemsWorkflow = createWorkflow( } ) - const variants = when({ variantIds }, ({ variantIds }) => { - return !!variantIds.length - }).then(() => { - return useRemoteQueryStep({ - entry_point: "variants", + const variants = when( + "fetch-variants-with-calculated-price", + { variantIds }, + ({ variantIds }) => { + return !!variantIds.length + } + ).then(() => { + const { data: variantsData } = useQueryGraphStep({ + entity: "variants", fields: deduplicate([ ...productVariantsFields, ...requiredVariantFieldsForInventoryConfirmation, ]), - variables: { + filters: { id: variantIds, - calculated_price: { - context: pricingContext, - }, }, }) - }) - validateVariantPricesStep({ variants }) + const calculatedPriceContext = transform( + { pricingContext, items: input.items }, + (data): { variantId: string; context: Record }[] => { + const baseContext = data.pricingContext + + return (data.items ?? []) + .filter((i) => i.variant_id) + .map((item) => { + return { + variantId: item.variant_id!, + context: { + ...baseContext, + quantity: item.quantity, + }, + } + }) + } + ) + + const calculatedPriceSets = getVariantPriceSetsStep({ + data: calculatedPriceContext, + }) + + const variants = transform( + { variantsData, calculatedPriceSets }, + ({ variantsData, calculatedPriceSets }) => { + return variantsData.map((variant) => { + variant.calculated_price = calculatedPriceSets[variant.id] + return variant + }) + } + ) + + validateVariantPricesStep({ variants }) + + return variants + }) confirmVariantInventoryWorkflow.runAsStep({ input: { sales_channel_id: salesChannel.id, - variants, + variants: variants!, items: input.items!, }, }) 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 c57f776e15..141814a3d0 100644 --- a/packages/core/core-flows/src/order/workflows/create-order.ts +++ b/packages/core/core-flows/src/order/workflows/create-order.ts @@ -1,6 +1,7 @@ import { AdditionalData, CreateOrderDTO } from "@medusajs/framework/types" import { MedusaError, + PromotionActions, deduplicate, isDefined, isPresent, @@ -14,6 +15,7 @@ import { transform, when, } from "@medusajs/framework/workflows-sdk" +import { getVariantPriceSetsStep } from "../../cart" 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" @@ -26,7 +28,8 @@ import { } from "../../cart/utils/prepare-line-item-data" import { pricingContextResult } from "../../cart/utils/schemas" import { confirmVariantInventoryWorkflow } from "../../cart/workflows/confirm-variant-inventory" -import { useRemoteQueryStep } from "../../common" +import { useQueryGraphStep } from "../../common" +import { refreshDraftOrderAdjustmentsWorkflow } from "../../draft-order/workflows/refresh-draft-order-adjustments" import { createOrdersStep } from "../steps" import { productVariantsFields } from "../utils/fields" import { updateOrderTaxLinesWorkflow } from "./update-tax-lines" @@ -205,7 +208,6 @@ export const createOrderWorkflow = createWorkflow( ) const setPricingContextResult = setPricingContext.getResult() - // TODO: This is on par with the context used in v1.*, but we can be more flexible. const pricingContext = transform( { input, region, customerData, setPricingContextResult }, (data) => { @@ -222,25 +224,133 @@ export const createOrderWorkflow = createWorkflow( } ) - const variants = when({ variantIds }, ({ variantIds }) => { - return !!variantIds.length - }).then(() => { - return useRemoteQueryStep({ - entry_point: "variants", + /** + * Only fetch variants with calculated prices if needed, otherwise only fetch variants without + * calculated prices. + * + * We need a variant calculated price when the item is either missing a unit price or is not + * tax inclusive. + */ + const { variantIdsForPriceCalculation, variantIdsWithoutCalculatedPrice } = + transform({ input }, (data) => { + const variantIdsForPriceCalculation: string[] = [] + const variantIdsWithoutCalculatedPrice: string[] = [] + + data.input.items?.forEach((item) => { + if ( + item.variant_id && + (!isDefined(item.unit_price) || !isDefined(item.is_tax_inclusive)) + ) { + variantIdsForPriceCalculation.push(item.variant_id!) + } else { + variantIdsWithoutCalculatedPrice.push(item.variant_id!) + } + }) + + return { + variantIdsForPriceCalculation, + variantIdsWithoutCalculatedPrice, + } + }) + + /** + * Fetch all variant for which we don't need to calculate the price. + */ + const { data: variantsWithoutCalculatedPrice } = useQueryGraphStep({ + entity: "variants", + fields: deduplicate([ + ...productVariantsFields, + ...requiredVariantFieldsForInventoryConfirmation, + ]), + filters: { + id: variantIdsWithoutCalculatedPrice, + }, + }).config({ name: "query-variants-without-calculated-price" }) + + /** + * Fetch all variants for which we need to calculate the price. + */ + const variantsWithCalculatedPrice = when( + "fetch-variants-with-calculated-price", + { variantIdsForPriceCalculation }, + ({ variantIdsForPriceCalculation }) => { + return !!variantIdsForPriceCalculation.length + } + ).then(() => { + const calculatePricesContext = transform( + { items: input.items, variantIdsForPriceCalculation, pricingContext }, + (data) => { + const baseContext = data.pricingContext + + return data.variantIdsForPriceCalculation + ?.map((variant) => { + // Since we retrieve the variant ids from the item, it is not possible to not find the item back from the variant id. + const item = data.items?.find( + (item) => item.variant_id === variant + )! + + return { + variantId: variant, + context: { + ...baseContext, + quantity: item.quantity, + }, + } + }) + .filter(Boolean) + } + ) + + const { data: variants } = useQueryGraphStep({ + entity: "variants", fields: deduplicate([ ...productVariantsFields, ...requiredVariantFieldsForInventoryConfirmation, ]), - variables: { - id: variantIds, - calculated_price: { - context: pricingContext, - }, + filters: { + id: variantIdsForPriceCalculation, }, + }).config({ name: "query-variants-to-calculate-prices" }) + + const calculatedPriceSets = getVariantPriceSetsStep({ + data: calculatePricesContext, }) + + const reconstructedVariants = transform( + { + variants, + calculatedPriceSets, + }, + (data) => { + return data.variants.map((variant) => { + variant.calculated_price = data.calculatedPriceSets[variant.id] + return variant + }) + } + ) + + validateVariantPricesStep({ variants: reconstructedVariants }).config({ + name: "validate-variants-with-calculated-price", + }) + + return reconstructedVariants }) - validateVariantPricesStep({ variants }) + /** + * Aggregate all variants without calculated price and all variants with calculated price. + */ + const variants = transform( + { + variantsWithoutCalculatedPrice, + variantsWithCalculatedPrice, + }, + (data) => { + return [ + ...data.variantsWithoutCalculatedPrice, + ...(data.variantsWithCalculatedPrice ?? []), + ] + } + ) confirmVariantInventoryWorkflow.runAsStep({ input: { @@ -269,14 +379,63 @@ export const createOrderWorkflow = createWorkflow( const orders = createOrdersStep([orderToCreate]) const order = transform({ orders }, (data) => data.orders?.[0]) - updateOrderTaxLinesWorkflow.runAsStep({ - input: { - order_id: order.id, + const appliedPromoCodes: string[] = transform( + input, + (order) => order.promo_codes ?? [] + ) + + /** + * TODO: Currently need the refresh because when the order module creates the order, even though + * the totals are calculated, the order is being queried and without the totals. There is some + * point of discussion for improvements here down the line. + */ + const { data: freshOrder } = useQueryGraphStep({ + entity: "orders", + fields: [ + "shipping_address.*", + "billing_address.*", + "summary.*", + "items.*", + "credit_lines.*", + "items.tax_lines.*", + "items.adjustments.*", + "shipping_methods.*", + "shipping_methods.tax_lines.*", + "shipping_methods.adjustments.*", + "transactions.*", + "currency_code", + "items.tax_lines.*", + "items.adjustments.*", + "shipping_methods.tax_lines.*", + "shipping_methods.adjustments.*", + "total", + "id", + ], + filters: { + id: order.id, }, - }) + options: { + isList: false, + }, + }).config({ name: "query-fresh-order" }) + + parallelize( + updateOrderTaxLinesWorkflow.runAsStep({ + input: { + order_id: order.id, + }, + }), + refreshDraftOrderAdjustmentsWorkflow.runAsStep({ + input: { + order: freshOrder, + promo_codes: appliedPromoCodes, + action: PromotionActions.REPLACE, + }, + }) + ) const orderCreated = createHook("orderCreated", { - order, + order: freshOrder, additional_data: input.additional_data, })