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