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
This commit is contained in:
Adrien de Peretti
2025-08-25 09:38:58 +02:00
committed by GitHub
parent f0ef444992
commit 6264a6262b
27 changed files with 2173 additions and 335 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@medusajs/core-flows": patch
"@medusajs/types": patch
---
fix(): Cart operation should calculate item prices accounting for quantity
@@ -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`,
@@ -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",
@@ -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,
@@ -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)
})
})
@@ -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",
@@ -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<string, unknown>
}
/**
* The calculated price sets of the variants. The object's keys are the variant IDs.
*/
export interface GetVariantPriceSetsStepBulkInput {
data: {
variantId: string
context?: Record<string, unknown>
}[]
}
interface VariantPriceSetData {
id: string
price_set?: { id: string }
}
interface PriceCalculationItem {
variantId: string
priceSetId: string
context?: Record<string, unknown>
}
export interface GetVariantPriceSetsStepOutput {
[k: string]: CalculatedPriceSet
}
export const getVariantPriceSetsStepId = "get-variant-price-sets"
async function fetchVariantPriceSets(
query: Query,
variantIds: string[]
): Promise<VariantPriceSetData[]> {
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<GetVariantPriceSetsStepOutput> {
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<string, string | number> }
)
// 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, unknown>): 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<string, PriceCalculationItem[]> {
const groups = new Map<string, PriceCalculationItem[]>()
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<string, unknown>
): 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<string, string>
): 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<IPricingModuleService>(
Modules.PRICING
)
const query = container.resolve<Query>(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<string, string>()
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<string, string | number> }
// Use unified processing logic for both input types
const result = await processVariantPriceSets(
pricingModuleService,
calculationItems
)
const idToPriceSet = new Map<string, Record<string, any>>(
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)
}
)
@@ -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",
@@ -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<string, unknown> }[] => {
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) => {
@@ -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<string, unknown> }[] => {
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!,
},
})
@@ -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,
@@ -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<string, unknown> }[] => {
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)
@@ -52,7 +52,7 @@ export const refreshCartShippingMethodsWorkflowId =
export const refreshCartShippingMethodsWorkflow = createWorkflow(
refreshCartShippingMethodsWorkflowId,
(input: WorkflowData<RefreshCartShippingMethodsWorkflowInput>) => {
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: {
@@ -56,7 +56,7 @@ export const refreshPaymentCollectionForCartWorkflowId =
export const refreshPaymentCollectionForCartWorkflow = createWorkflow(
refreshPaymentCollectionForCartWorkflowId,
(input: WorkflowData<RefreshPaymentCollectionForCartWorklowInput>) => {
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
@@ -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],
@@ -75,7 +75,7 @@ export const updateCartPromotionsWorkflowId = "update-cart-promotions"
export const updateCartPromotionsWorkflow = createWorkflow(
updateCartPromotionsWorkflowId,
(input: WorkflowData<UpdateCartPromotionsWorkflowInput>) => {
const fetchCart = when({ input }, ({ input }) => {
const fetchCart = when("should-fetch-cart", { input }, ({ input }) => {
return !input.cart
}).then(() => {
return useRemoteQueryStep({
@@ -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<UpdateCartWorkflowInput>) => {
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({
@@ -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<CartDTO, "items"> {
items: NonNullable<CartDTO["items"]>
customer: CustomerDTO
region: RegionDTO
}
export const updateLineItemInCartWorkflowId = "update-line-item-in-cart"
/**
@@ -97,17 +108,24 @@ export const updateLineItemInCartWorkflow = createWorkflow(
(
input: WorkflowData<UpdateLineItemInCartWorkflowInputDTO & AdditionalData>
) => {
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<string, any> => {
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 [
@@ -123,7 +123,7 @@ export const updateTaxLinesWorkflowId = "update-tax-lines"
export const updateTaxLinesWorkflow = createWorkflow(
updateTaxLinesWorkflowId,
(input: WorkflowData<UpdateTaxLinesWorkflowInput>): WorkflowData<void> => {
const fetchCart = when({ input }, ({ input }) => {
const fetchCart = when("should-fetch-cart", { input }, ({ input }) => {
return !input.cart
}).then(() => {
return useRemoteQueryStep({
@@ -121,7 +121,7 @@ export const upsertTaxLinesWorkflowId = "upsert-tax-lines"
export const upsertTaxLinesWorkflow = createWorkflow(
upsertTaxLinesWorkflowId,
(input: WorkflowData<UpsertTaxLinesWorkflowInput>): WorkflowData<void> => {
const fetchCart = when({ input }, ({ input }) => {
const fetchCart = when("should-fetch-cart", { input }, ({ input }) => {
return !input.cart
}).then(() => {
return useRemoteQueryStep({
@@ -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 {}
}
@@ -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<Result["data"]>().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<Result["data"]>().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<Result["data"]>().toEqualTypeOf<
FixtureEntryPoints["simple_product"][]
>()
})
})
@@ -7,10 +7,28 @@ import {
import { createStep, StepFunction, StepResponse } from "@medusajs/workflows-sdk"
import { ContainerRegistrationKeys } from "@medusajs/utils"
export type UseQueryGraphStepInput<TEntry extends string> =
RemoteQueryInput<TEntry> & {
options?: RemoteJoinerOptions
export type UseQueryGraphStepInput<
TEntry extends string,
TIsList extends boolean = boolean
> = RemoteQueryInput<TEntry> & {
options?: RemoteJoinerOptions & {
isList?: TIsList
}
}
export type UseQueryGraphStepOutput<
TEntry extends string,
TIsList extends boolean = boolean
> = ReturnType<
StepFunction<
any,
true extends TIsList
? GraphResultSet<TEntry>
: Omit<GraphResultSet<TEntry>, "data"> & {
data: GraphResultSet<TEntry>["data"][number]
}
>
>
const useQueryGraphStepId = "use-query-graph-step"
@@ -20,9 +38,20 @@ const step = createStep(
const query = container.resolve<RemoteQueryFunction>(
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 = <const TEntry extends string>(
input: UseQueryGraphStepInput<TEntry>
): ReturnType<StepFunction<any, GraphResultSet<TEntry>>> =>
step(input as any) as unknown as ReturnType<
StepFunction<any, GraphResultSet<TEntry>>
>
export const useQueryGraphStep = <
const TEntry extends string,
const TIsList extends boolean = boolean
>(
input: UseQueryGraphStepInput<TEntry, TIsList>
): UseQueryGraphStepOutput<TEntry, TIsList> =>
step(input as any) as unknown as UseQueryGraphStepOutput<TEntry, TIsList>
@@ -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<OrderDTO, "items"> & {
// items?: ComputeActionItemLine[]
// promotions?: PromotionDTO[]
// }
/**
* The promo codes to add or remove from the draft order.
*/
@@ -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",
@@ -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<string, unknown> }[] => {
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!,
},
})
@@ -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,
})