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 authored and GitHub committed 2025-08-25 09:38:58 +02:00
1 parent f0ef444992
commit 6264a6262b
26 files changed
+2132 -294

No files matched your search

+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, Modules,
PriceListStatus, PriceListStatus,
PriceListType, PriceListType,
ProductStatus,
PromotionRuleOperator, PromotionRuleOperator,
PromotionStatus, PromotionStatus,
PromotionType, 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", () => { describe("with sale price lists", () => {
let priceList 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 () => { it("should remove promotions when promotion is no longer in active state", async () => {
let responseBeforePromotionUpdate = await api.post( let responseBeforePromotionUpdate = await api.post(
`/store/carts/${cart.id}/line-items`, `/store/carts/${cart.id}/line-items`,
@@ -1,6 +1,10 @@
import { medusaIntegrationTestRunner } from "@medusajs/test-utils" import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { Modules, PromotionStatus, PromotionType } from "@medusajs/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 { setupTaxStructure } from "../../../../modules/__tests__/fixtures/tax"
import { medusaTshirtProduct } from "../../../__fixtures__/product" import { medusaTshirtProduct } from "../../../__fixtures__/product"
@@ -2469,7 +2473,8 @@ medusaIntegrationTestRunner({
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
id: "shipping_option_type", 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", label: "Shipping Option Type",
required: false, required: false,
field_type: "multiselect", field_type: "multiselect",
@@ -29,6 +29,7 @@ import {
IRegionModuleService, IRegionModuleService,
ISalesChannelModuleService, ISalesChannelModuleService,
IStockLocationService, IStockLocationService,
PricingContext,
} from "@medusajs/types" } from "@medusajs/types"
import { import {
ContainerRegistrationKeys, ContainerRegistrationKeys,
@@ -759,11 +760,23 @@ medusaIntegrationTestRunner({
* Tried jest, but for some reasons it is not able to provide * Tried jest, but for some reasons it is not able to provide
* correct arguments passed to the function * correct arguments passed to the function
*/ */
let pricingContext: any let calculatePricesHasBeenCalled = false
const originalFn = pricingModule.listPriceSets.bind(pricingModule)
pricingModule.listPriceSets = function () { const originalFn = pricingModule.calculatePrices.bind(pricingModule)
pricingContext = { ...arguments[0].context } pricingModule.calculatePrices = function (...args) {
return originalFn.bind(pricingModule)(...arguments) 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({ const { result } = await createCartWorkflow(appContainer).run({
@@ -782,15 +795,9 @@ medusaIntegrationTestRunner({
}) })
setPricingContextHook = undefined setPricingContextHook = undefined
pricingModule.listPriceSets = originalFn pricingModule.calculatePrices = originalFn
expect(pricingContext).toEqual( expect(calculatePricesHasBeenCalled).toBe(true)
expect.objectContaining({
unit_price: 100,
region_id: region.id,
currency_code: "usd",
})
)
const cart = await cartModuleService.retrieveCart(result.id, { const cart = await cartModuleService.retrieveCart(result.id, {
relations: ["items"], relations: ["items"],
@@ -924,11 +931,25 @@ medusaIntegrationTestRunner({
* Tried jest, but for some reasons it is not able to provide * Tried jest, but for some reasons it is not able to provide
* correct arguments passed to the function * correct arguments passed to the function
*/ */
let pricingContext: any let calculatePricesHasBeenCalled = false
const originalFn = pricingModule.listPriceSets.bind(pricingModule)
pricingModule.listPriceSets = function () { const originalFn = pricingModule.calculatePrices.bind(pricingModule)
pricingContext = { ...arguments[0].context } pricingModule.calculatePrices = function (...args) {
return originalFn.bind(pricingModule)(...arguments) 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({ const { result } = await createCartWorkflow(appContainer).run({
@@ -947,17 +968,9 @@ medusaIntegrationTestRunner({
}) })
setPricingContextHook = undefined setPricingContextHook = undefined
pricingModule.listPriceSets = originalFn pricingModule.calculatePrices = originalFn
expect(pricingContext).toEqual( expect(calculatePricesHasBeenCalled).toBe(true)
expect.objectContaining({
unit_price: 200,
region_id: region.id,
currency_code: "usd",
})
)
expect(pricingContext.customer_id).toBeDefined()
expect(pricingContext.customer_id).not.toEqual("1")
const cart = await cartModuleService.retrieveCart(result.id, { const cart = await cartModuleService.retrieveCart(result.id, {
relations: ["items"], relations: ["items"],
@@ -1851,7 +1864,7 @@ medusaIntegrationTestRunner({
expect(errors).toEqual([ expect(errors).toEqual([
{ {
action: "validate-variant-prices", action: "get-variant-price-sets",
handlerType: "invoke", handlerType: "invoke",
error: expect.objectContaining({ error: expect.objectContaining({
message: expect.stringContaining( message: expect.stringContaining(
@@ -1960,10 +1973,22 @@ medusaIntegrationTestRunner({
* correct arguments passed to the function * correct arguments passed to the function
*/ */
let pricingContext: any let pricingContext: any
const originalFn = pricingModule.listPriceSets.bind(pricingModule) let calculatePricessHaveBeenCalled = false
pricingModule.listPriceSets = function () { const originalFn = pricingModule.calculatePrices.bind(pricingModule)
pricingContext = { ...arguments[0].context } pricingModule.calculatePrices = function (...args) {
return originalFn.bind(pricingModule)(...arguments) 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({ await addToCartWorkflow(appContainer).run({
@@ -1979,14 +2004,9 @@ medusaIntegrationTestRunner({
}) })
setPricingContextHook = undefined setPricingContextHook = undefined
pricingModule.listPriceSets = originalFn pricingModule.calculatePrices = originalFn
expect(pricingContext).toEqual( expect(calculatePricessHaveBeenCalled).toBe(true)
expect.objectContaining({
unit_price: 100,
currency_code: "usd",
})
)
cart = await cartModuleService.retrieveCart(cart.id, { cart = await cartModuleService.retrieveCart(cart.id, {
relations: ["items"], relations: ["items"],
@@ -2114,9 +2134,24 @@ medusaIntegrationTestRunner({
* correct arguments passed to the function * correct arguments passed to the function
*/ */
let pricingContext: any let pricingContext: any
const originalFn = pricingModule.listPriceSets.bind(pricingModule) let calculatePricessHaveBeenCalled = false
pricingModule.listPriceSets = function () {
pricingContext = { ...arguments[0].context } 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) return originalFn.bind(pricingModule)(...arguments)
} }
@@ -2133,21 +2168,14 @@ medusaIntegrationTestRunner({
}) })
setPricingContextHook = undefined setPricingContextHook = undefined
pricingModule.listPriceSets = originalFn pricingModule.calculatePrices = originalFn
expect(pricingContext).toEqual(
expect.objectContaining({
unit_price: 200,
region_id: cart.region_id,
customer_id: cart.customer_id,
currency_code: "usd",
})
)
cart = await cartModuleService.retrieveCart(cart.id, { cart = await cartModuleService.retrieveCart(cart.id, {
relations: ["items"], relations: ["items"],
}) })
expect(calculatePricessHaveBeenCalled).toBe(true)
expect(cart).toEqual( expect(cart).toEqual(
expect.objectContaining({ expect.objectContaining({
id: cart.id, id: cart.id,
@@ -10,7 +10,12 @@ import {
IStockLocationServiceNext, IStockLocationServiceNext,
ITaxModuleService, ITaxModuleService,
} from "@medusajs/types" } from "@medusajs/types"
import { ContainerRegistrationKeys, Modules } from "@medusajs/utils" import {
ContainerRegistrationKeys,
Modules,
PromotionStatus,
PromotionType,
} from "@medusajs/utils"
import { import {
adminHeaders, adminHeaders,
createAdminUser, createAdminUser,
@@ -25,30 +30,25 @@ medusaIntegrationTestRunner({
env, env,
testSuite: ({ dbConnection, getContainer, api }) => { testSuite: ({ dbConnection, getContainer, api }) => {
let appContainer let appContainer
let cartModuleService: ICartModuleService
let regionModuleService: IRegionModuleService let regionModuleService: IRegionModuleService
let scModuleService: ISalesChannelModuleService let scModuleService: ISalesChannelModuleService
let productModule: IProductModuleService let productModule: IProductModuleService
let pricingModule: IPricingModuleService let pricingModule: IPricingModuleService
let inventoryModule: IInventoryServiceNext let inventoryModule: IInventoryServiceNext
let stockLocationModule: IStockLocationServiceNext let stockLocationModule: IStockLocationServiceNext
let fulfillmentModule: IFulfillmentModuleService
let taxModule: ITaxModuleService let taxModule: ITaxModuleService
let remoteLink, remoteQuery let remoteLink
beforeAll(async () => { beforeAll(async () => {
appContainer = getContainer() appContainer = getContainer()
cartModuleService = appContainer.resolve(Modules.CART)
regionModuleService = appContainer.resolve(Modules.REGION) regionModuleService = appContainer.resolve(Modules.REGION)
scModuleService = appContainer.resolve(Modules.SALES_CHANNEL) scModuleService = appContainer.resolve(Modules.SALES_CHANNEL)
productModule = appContainer.resolve(Modules.PRODUCT) productModule = appContainer.resolve(Modules.PRODUCT)
pricingModule = appContainer.resolve(Modules.PRICING) pricingModule = appContainer.resolve(Modules.PRICING)
inventoryModule = appContainer.resolve(Modules.INVENTORY) inventoryModule = appContainer.resolve(Modules.INVENTORY)
stockLocationModule = appContainer.resolve(Modules.STOCK_LOCATION) stockLocationModule = appContainer.resolve(Modules.STOCK_LOCATION)
fulfillmentModule = appContainer.resolve(Modules.FULFILLMENT)
taxModule = appContainer.resolve(Modules.TAX) taxModule = appContainer.resolve(Modules.TAX)
remoteLink = appContainer.resolve(ContainerRegistrationKeys.REMOTE_LINK) remoteLink = appContainer.resolve(ContainerRegistrationKeys.REMOTE_LINK)
remoteQuery = appContainer.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
}) })
beforeEach(async () => { beforeEach(async () => {
@@ -199,7 +199,6 @@ medusaIntegrationTestRunner({
country_code: "US", country_code: "US",
postal_code: "12345", postal_code: "12345",
}, },
promo_codes: ["testytest"],
items: [ items: [
{ {
variant_id: product.variants[0].id, variant_id: product.variants[0].id,
@@ -386,6 +385,388 @@ medusaIntegrationTestRunner({
expect(response.status).toEqual(200) 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 () => { it("should create a draft order and apply tax by product type", async () => {
const productType = await productModule.createProductTypes({ const productType = await productModule.createProductTypes({
value: "test_product_type", value: "test_product_type",
@@ -396,11 +777,17 @@ medusaIntegrationTestRunner({
currency_code: "usd", currency_code: "usd",
}) })
const [taxRegion] = await taxModule.createTaxRegions([{ const [taxRegion] = await taxModule.createTaxRegions([
{
country_code: "US", country_code: "US",
provider_id: "tp_system", provider_id: "tp_system",
default_tax_rate: { name: "US Default Rate", rate: 5, code: "US_DEF" }, default_tax_rate: {
}]) name: "US Default Rate",
rate: 5,
code: "US_DEF",
},
},
])
const [taxRate] = await taxModule.createTaxRates([ const [taxRate] = await taxModule.createTaxRates([
{ {
@@ -408,14 +795,15 @@ medusaIntegrationTestRunner({
name: "US Reduced", name: "US Reduced",
rate: 3, rate: 3,
code: "USREDUCE_PROD_TYPE", code: "USREDUCE_PROD_TYPE",
}]) },
])
await taxModule.createTaxRateRules([ await taxModule.createTaxRateRules([
{ {
reference: "product_type", reference: "product_type",
reference_id: productType.id, reference_id: productType.id,
tax_rate_id: taxRate.id, tax_rate_id: taxRate.id,
} },
]) ])
const salesChannel = await scModuleService.createSalesChannels({ const salesChannel = await scModuleService.createSalesChannels({
@@ -426,7 +814,6 @@ medusaIntegrationTestRunner({
name: "Warehouse", name: "Warehouse",
}) })
const [product] = await productModule.createProducts([ const [product] = await productModule.createProducts([
{ {
title: "Test product", title: "Test product",
@@ -473,6 +860,35 @@ medusaIntegrationTestRunner({
adminHeaders 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([ await remoteLink.create([
{ {
@@ -546,7 +962,9 @@ medusaIntegrationTestRunner({
adminHeaders 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) 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 { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { IOrderModuleService, OrderDTO } from "@medusajs/types" import {
import { Modules } from "@medusajs/utils" CreateOrderLineItemDTO,
IOrderModuleService,
OrderDTO,
} from "@medusajs/types"
import { Modules, ProductStatus } from "@medusajs/utils"
import { import {
adminHeaders, adminHeaders,
createAdminUser, createAdminUser,
@@ -26,6 +33,172 @@ medusaIntegrationTestRunner({
await createAdminUser(dbConnection, adminHeaders, appContainer) 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", () => { describe("Orders - Admin", () => {
it("should get an order", async () => { it("should get an order", async () => {
const created = await orderModule.createOrders({ const created = await orderModule.createOrders({
@@ -107,7 +280,7 @@ medusaIntegrationTestRunner({
id: expect.any(String), id: expect.any(String),
status: "pending", status: "pending",
version: 1, version: 1,
display_id: 1, display_id: 2,
payment_collections: [], payment_collections: [],
payment_status: "not_paid", payment_status: "not_paid",
region_id: "test_region_id", region_id: "test_region_id",
@@ -1,8 +1,13 @@
import { Query } from "@medusajs/framework"
import { import {
CalculatedPriceSet, CalculatedPriceSet,
IPricingModuleService, IPricingModuleService,
} from "@medusajs/framework/types" } 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" import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
/** /**
@@ -21,68 +26,52 @@ export interface GetVariantPriceSetsStepInput {
context?: Record<string, unknown> context?: Record<string, unknown>
} }
/** export interface GetVariantPriceSetsStepBulkInput {
* The calculated price sets of the variants. The object's keys are the variant IDs. 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 { export interface GetVariantPriceSetsStepOutput {
[k: string]: CalculatedPriceSet [k: string]: CalculatedPriceSet
} }
export const getVariantPriceSetsStepId = "get-variant-price-sets" export const getVariantPriceSetsStepId = "get-variant-price-sets"
/**
* This step retrieves the calculated price sets of the specified variants.
*
* @example
* To retrieve a variant's price sets:
*
* ```ts
* const data = getVariantPriceSetsStep({
* variantIds: ["variant_123"],
* })
* ```
*
* To retrieve the calculated price sets of a variant:
*
* ```ts
* const data = getVariantPriceSetsStep({
* variantIds: ["variant_123"],
* context: {
* currency_code: "usd"
* }
* })
* ```
*/
export const getVariantPriceSetsStep = createStep(
getVariantPriceSetsStepId,
async (data: GetVariantPriceSetsStepInput, { container }) => {
if (!data.variantIds.length) {
return new StepResponse({})
}
const pricingModuleService = container.resolve<IPricingModuleService>( async function fetchVariantPriceSets(
Modules.PRICING query: Query,
) variantIds: string[]
): Promise<VariantPriceSetData[]> {
const remoteQuery = container.resolve("remoteQuery") return (
await query.graph({
const variantPriceSets = await remoteQuery({ entity: "variant",
entryPoint: "variant",
fields: ["id", "price_set.id"], fields: ["id", "price_set.id"],
variables: { filters: { id: variantIds },
id: data.variantIds,
},
}) })
).data
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)
} }
})
/**
* 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) { if (notFound.length) {
throw new MedusaError( throw new MedusaError(
@@ -90,30 +79,207 @@ export const getVariantPriceSetsStep = createStep(
`Variants with IDs ${notFound.join(", ")} do not have a price` `Variants with IDs ${notFound.join(", ")} do not have a price`
) )
} }
}
const calculatedPriceSets = await pricingModuleService.calculatePrices( /**
* 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 }, { id: priceSetIds },
{ context: data.context as Record<string, string | number> } { context: context as Record<string, string | number> }
) )
const idToPriceSet = new Map<string, Record<string, any>>( // Map calculated prices back to variants
calculatedPriceSets.map((p) => [p.id, p]) const priceSetMap = new Map(
calculatedPriceSets.map((priceSet) => [priceSet.id, priceSet])
) )
const variantToCalculatedPriceSets = variantPriceSets.reduce( for (const item of groupItems) {
(acc, { id, price_set }) => { const calculatedPriceSet = priceSetMap.get(item.priceSetId)
const calculatedPriceSet = idToPriceSet.get(price_set?.id)
if (calculatedPriceSet) { if (calculatedPriceSet) {
acc[id] = calculatedPriceSet result[item.variantId] = calculatedPriceSet
}
}
} }
return acc 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 variant price sets with shared context:
*
* ```ts
* const data = getVariantPriceSetsStep({
* variantIds: ["variant_123"],
* context: { currency_code: "usd" }
* })
* ```
*
* To retrieve variant price sets with individual contexts:
*
* ```ts
* const data = getVariantPriceSetsStep({
* 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 | GetVariantPriceSetsStepBulkInput,
{ container }
) => {
const pricingModuleService = container.resolve<IPricingModuleService>(
Modules.PRICING
)
const query = container.resolve<Query>(ContainerRegistrationKeys.QUERY)
let calculationItems: PriceCalculationItem[]
// Handle shared context variants (original input format)
if ("variantIds" in data) {
if (!data.variantIds.length) {
return new StepResponse({})
}
const variantPriceSets = await fetchVariantPriceSets(
query,
data.variantIds
) )
return new StepResponse( validateVariantPriceSets(variantPriceSets)
variantToCalculatedPriceSets as GetVariantPriceSetsStepOutput
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
) )
} }
// Use unified processing logic for both input types
const result = await processVariantPriceSets(
pricingModuleService,
calculationItems
)
return new StepResponse(result)
}
) )
@@ -166,7 +166,6 @@ export const productVariantsFields = [
"product.discountable", "product.discountable",
"product.is_giftcard", "product.is_giftcard",
"product.shipping_profile.id", "product.shipping_profile.id",
"calculated_price.*",
"inventory_items.inventory_item_id", "inventory_items.inventory_item_id",
"inventory_items.required_quantity", "inventory_items.required_quantity",
"inventory_items.inventory.requires_shipping", "inventory_items.inventory.requires_shipping",
@@ -2,10 +2,12 @@ import {
AdditionalData, AdditionalData,
AddToCartWorkflowInputDTO, AddToCartWorkflowInputDTO,
ConfirmVariantInventoryWorkflowInputDTO, ConfirmVariantInventoryWorkflowInputDTO,
WithCalculatedPrice,
} from "@medusajs/framework/types" } from "@medusajs/framework/types"
import { import {
CartWorkflowEvents, CartWorkflowEvents,
deduplicate, deduplicate,
filterObjectByKeys,
isDefined, isDefined,
} from "@medusajs/framework/utils" } from "@medusajs/framework/utils"
import { import {
@@ -19,10 +21,10 @@ import {
} from "@medusajs/framework/workflows-sdk" } from "@medusajs/framework/workflows-sdk"
import { useQueryGraphStep } from "../../common" import { useQueryGraphStep } from "../../common"
import { emitEventStep } from "../../common/steps/emit-event" import { emitEventStep } from "../../common/steps/emit-event"
import { useRemoteQueryStep } from "../../common/steps/use-remote-query"
import { import {
createLineItemsStep, createLineItemsStep,
getLineItemActionsStep, getLineItemActionsStep,
getVariantPriceSetsStep,
updateLineItemsStep, updateLineItemsStep,
} from "../steps" } from "../steps"
import { validateCartStep } from "../steps/validate-cart" import { validateCartStep } from "../steps/validate-cart"
@@ -36,6 +38,7 @@ import { requiredVariantFieldsForInventoryConfirmation } from "../utils/prepare-
import { import {
prepareLineItemData, prepareLineItemData,
PrepareLineItemDataInput, PrepareLineItemDataInput,
PrepareVariantLineItemInput,
} from "../utils/prepare-line-item-data" } from "../utils/prepare-line-item-data"
import { pricingContextResult } from "../utils/schemas" import { pricingContextResult } from "../utils/schemas"
import { confirmVariantInventoryWorkflow } from "./confirm-variant-inventory" import { confirmVariantInventoryWorkflow } from "./confirm-variant-inventory"
@@ -148,41 +151,75 @@ export const addToCartWorkflow = createWorkflow(
) )
const setPricingContextResult = setPricingContext.getResult() const setPricingContextResult = setPricingContext.getResult()
const variants = when(
"should-calculate-prices",
{ variantIds },
({ variantIds }) => {
return !!variantIds.length
}
).then(() => {
const pricingContext = transform( const pricingContext = transform(
{ cart, setPricingContextResult }, { cart, items: input.items, setPricingContextResult },
(data) => { (data): { variantId: string; context: Record<string, unknown> }[] => {
return { const baseContext = {
...data.cart, ...filterObjectByKeys(data.cart, cartFieldsForPricingContext),
...(data.setPricingContextResult ? data.setPricingContextResult : {}), ...(data.setPricingContextResult
? data.setPricingContextResult
: {}),
currency_code: data.cart.currency_code, currency_code: data.cart.currency_code,
region_id: data.cart.region_id, region_id: data.cart.region_id,
region: data.cart.region, region: data.cart.region,
customer_id: data.cart.customer_id, customer_id: data.cart.customer_id,
customer: data.cart.customer, 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 variants = when({ variantIds }, ({ variantIds }) => { const { data: variantsData } = useQueryGraphStep({
return !!variantIds.length entity: "variants",
}).then(() => {
return useRemoteQueryStep({
entry_point: "variants",
fields: deduplicate([ fields: deduplicate([
...productVariantsFields, ...productVariantsFields,
...requiredVariantFieldsForInventoryConfirmation, ...requiredVariantFieldsForInventoryConfirmation,
]), ]),
variables: { filters: {
id: variantIds, id: variantIds,
calculated_price: {
context: pricingContext,
}, },
},
})
}) })
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 }) validateVariantPricesStep({ variants })
return variants as (PrepareVariantLineItemInput &
ConfirmVariantInventoryWorkflowInputDTO["variants"][number] &
WithCalculatedPrice)[]
})
const lineItems = transform({ input, variants }, (data) => { const lineItems = transform({ input, variants }, (data) => {
const items = (data.input.items ?? []).map((item) => { const items = (data.input.items ?? []).map((item) => {
const variant = (data.variants ?? []).find( const variant = (data.variants ?? []).find(
@@ -17,13 +17,14 @@ import {
WorkflowData, WorkflowData,
WorkflowResponse, WorkflowResponse,
} from "@medusajs/framework/workflows-sdk" } from "@medusajs/framework/workflows-sdk"
import { useQueryGraphStep } from "../../common"
import { emitEventStep } from "../../common/steps/emit-event" import { emitEventStep } from "../../common/steps/emit-event"
import { useRemoteQueryStep } from "../../common/steps/use-remote-query"
import { import {
createCartsStep, createCartsStep,
findOneOrAnyRegionStep, findOneOrAnyRegionStep,
findOrCreateCustomerStep, findOrCreateCustomerStep,
findSalesChannelStep, findSalesChannelStep,
getVariantPriceSetsStep,
} from "../steps" } from "../steps"
import { validateLineItemPricesStep } from "../steps/validate-line-item-prices" import { validateLineItemPricesStep } from "../steps/validate-line-item-prices"
import { validateSalesChannelStep } from "../steps/validate-sales-channel" 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 return !!variantIds.length
}).then(() => { }).then(() => {
return useRemoteQueryStep({ const { data: variantsData } = useQueryGraphStep({
entry_point: "variants", entity: "variants",
fields: deduplicate([ fields: deduplicate([
...productVariantsFields, ...productVariantsFields,
...requiredVariantFieldsForInventoryConfirmation, ...requiredVariantFieldsForInventoryConfirmation,
]), ]),
variables: { filters: {
id: variantIds, id: variantIds,
calculated_price: {
context: pricingContext,
}, },
},
})
}) })
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 }) validateVariantPricesStep({ variants })
return variants
})
confirmVariantInventoryWorkflow.runAsStep({ confirmVariantInventoryWorkflow.runAsStep({
input: { input: {
sales_channel_id: salesChannel.id, sales_channel_id: salesChannel.id,
variants, variants: variants!,
items: input.items!, items: input.items!,
}, },
}) })
@@ -12,7 +12,7 @@ import {
AdditionalData, AdditionalData,
ListShippingOptionsForCartWorkflowInput, ListShippingOptionsForCartWorkflowInput,
} from "@medusajs/types" } from "@medusajs/types"
import { isDefined } from "@medusajs/framework/utils" import { filterObjectByKeys, isDefined } from "@medusajs/framework/utils"
import { pricingContextResult } from "../utils/schemas" import { pricingContextResult } from "../utils/schemas"
export const listShippingOptionsForCartWorkflowId = export const listShippingOptionsForCartWorkflowId =
@@ -181,7 +181,7 @@ export const listShippingOptionsForCartWorkflow = createWorkflow(
calculated_price: { calculated_price: {
context: { context: {
...cart, ...filterObjectByKeys(cart, cartFieldsForPricingContext),
...(setPricingContextResult ? setPricingContextResult : {}), ...(setPricingContextResult ? setPricingContextResult : {}),
currency_code: cart.currency_code, currency_code: cart.currency_code,
region_id: cart.region_id, region_id: cart.region_id,
@@ -11,8 +11,10 @@ import {
WorkflowData, WorkflowData,
WorkflowResponse, WorkflowResponse,
} from "@medusajs/framework/workflows-sdk" } from "@medusajs/framework/workflows-sdk"
import { AdditionalData, CartDTO } from "@medusajs/types"
import { useQueryGraphStep } from "../../common"
import { useRemoteQueryStep } from "../../common/steps/use-remote-query" 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 { validateVariantPricesStep } from "../steps/validate-variant-prices"
import { import {
cartFieldsForPricingContext, cartFieldsForPricingContext,
@@ -23,13 +25,12 @@ import {
prepareLineItemData, prepareLineItemData,
PrepareLineItemDataInput, PrepareLineItemDataInput,
} from "../utils/prepare-line-item-data" } from "../utils/prepare-line-item-data"
import { pricingContextResult } from "../utils/schemas"
import { refreshCartShippingMethodsWorkflow } from "./refresh-cart-shipping-methods" import { refreshCartShippingMethodsWorkflow } from "./refresh-cart-shipping-methods"
import { refreshPaymentCollectionForCartWorkflow } from "./refresh-payment-collection" import { refreshPaymentCollectionForCartWorkflow } from "./refresh-payment-collection"
import { updateCartPromotionsWorkflow } from "./update-cart-promotions" import { updateCartPromotionsWorkflow } from "./update-cart-promotions"
import { updateTaxLinesWorkflow } from "./update-tax-lines" import { updateTaxLinesWorkflow } from "./update-tax-lines"
import { upsertTaxLinesWorkflow } from "./upsert-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. * The details of the cart to refresh.
@@ -142,48 +143,77 @@ export const refreshCartItemsWorkflow = createWorkflow(
) )
const setPricingContextResult = setPricingContext.getResult() const setPricingContextResult = setPricingContext.getResult()
when({ input }, ({ input }) => { when("force-refresh-calculate-prices", { input }, ({ input }) => {
return !!input.force_refresh return !!input.force_refresh
}).then(() => { }).then(() => {
const cart = useRemoteQueryStep({ const { data: cart } = useQueryGraphStep({
entry_point: "cart", entity: "cart",
fields: cartFieldsForRefreshSteps, fields: cartFieldsForRefreshSteps,
variables: { id: input.cart_id }, filters: { id: input.cart_id },
list: false, 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) return (data.cart.items ?? []).map((i) => i.variant_id).filter(Boolean)
}) })
const cartPricingContext = transform( const cartPricingContext = transform(
{ cart, setPricingContextResult }, { cart, setPricingContextResult },
(data) => { (data): { variantId: string; context: Record<string, unknown> }[] => {
return { const cart = data.cart
...filterObjectByKeys(data.cart, cartFieldsForPricingContext), const baseContext = {
...filterObjectByKeys(cart, cartFieldsForPricingContext),
...(data.setPricingContextResult ...(data.setPricingContextResult
? data.setPricingContextResult ? data.setPricingContextResult
: {}), : {}),
currency_code: data.cart.currency_code, currency_code: cart.currency_code,
region_id: data.cart.region_id, region_id: cart.region_id,
region: data.cart.region, region: cart.region,
customer_id: data.cart.customer_id, customer_id: cart.customer_id,
customer: data.cart.customer, 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({ const { data: variantsData } = useQueryGraphStep({
entry_point: "variants", entity: "variants",
fields: productVariantsFields, fields: productVariantsFields,
variables: { filters: {
id: variantIds, id: variantIds,
calculated_price: {
context: cartPricingContext,
},
}, },
}).config({ name: "fetch-variants" }) }).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 }) validateVariantPricesStep({ variants })
const lineItems = transform({ cart, variants }, ({ cart, variants }) => { const lineItems = transform({ cart, variants }, ({ cart, variants }) => {
@@ -244,7 +274,7 @@ export const refreshCartItemsWorkflow = createWorkflow(
input: refreshCartInput, input: refreshCartInput,
}) })
when({ input }, ({ input }) => { when("force-refresh-update-tax-lines", { input }, ({ input }) => {
return !!input.force_refresh return !!input.force_refresh
}).then(() => { }).then(() => {
updateTaxLinesWorkflow.runAsStep({ updateTaxLinesWorkflow.runAsStep({
@@ -252,7 +282,7 @@ export const refreshCartItemsWorkflow = createWorkflow(
}) })
}) })
when({ input }, ({ input }) => { when("force-refresh-upsert-tax-lines", { input }, ({ input }) => {
return ( return (
!input.force_refresh && !input.force_refresh &&
(!!input.items?.length || !!input.shipping_methods?.length) (!!input.items?.length || !!input.shipping_methods?.length)
@@ -52,7 +52,7 @@ export const refreshCartShippingMethodsWorkflowId =
export const refreshCartShippingMethodsWorkflow = createWorkflow( export const refreshCartShippingMethodsWorkflow = createWorkflow(
refreshCartShippingMethodsWorkflowId, refreshCartShippingMethodsWorkflowId,
(input: WorkflowData<RefreshCartShippingMethodsWorkflowInput>) => { (input: WorkflowData<RefreshCartShippingMethodsWorkflowInput>) => {
const fetchCart = when({ input }, ({ input }) => { const fetchCart = when("fetch-cart", { input }, ({ input }) => {
return !input.cart return !input.cart
}).then(() => { }).then(() => {
return useRemoteQueryStep({ return useRemoteQueryStep({
@@ -94,9 +94,13 @@ export const refreshCartShippingMethodsWorkflow = createWorkflow(
cart, cart,
}) })
when({ listShippingOptionsInput }, ({ listShippingOptionsInput }) => { when(
"should-prepare-shipping-methods",
{ listShippingOptionsInput },
({ listShippingOptionsInput }) => {
return !!listShippingOptionsInput?.length return !!listShippingOptionsInput?.length
}).then(() => { }
).then(() => {
const shippingOptions = const shippingOptions =
listShippingOptionsForCartWithPricingWorkflow.runAsStep({ listShippingOptionsForCartWithPricingWorkflow.runAsStep({
input: { input: {
@@ -56,7 +56,7 @@ export const refreshPaymentCollectionForCartWorkflowId =
export const refreshPaymentCollectionForCartWorkflow = createWorkflow( export const refreshPaymentCollectionForCartWorkflow = createWorkflow(
refreshPaymentCollectionForCartWorkflowId, refreshPaymentCollectionForCartWorkflowId,
(input: WorkflowData<RefreshPaymentCollectionForCartWorklowInput>) => { (input: WorkflowData<RefreshPaymentCollectionForCartWorklowInput>) => {
const fetchCart = when({ input }, ({ input }) => { const fetchCart = when("should-fetch-cart", { input }, ({ input }) => {
return !input.cart return !input.cart
}).then(() => { }).then(() => {
return useRemoteQueryStep({ return useRemoteQueryStep({
@@ -88,7 +88,7 @@ export const refreshPaymentCollectionForCartWorkflow = createWorkflow(
cart, cart,
}) })
when({ cart }, ({ cart }) => { when("should-update-payment-collection", { cart }, ({ cart }) => {
const valueIsEqual = MathBN.eq( const valueIsEqual = MathBN.eq(
cart.payment_collection?.raw_amount ?? -1, cart.payment_collection?.raw_amount ?? -1,
cart.raw_total cart.raw_total
@@ -93,18 +93,18 @@ export const transferCartCustomerWorkflow = createWorkflow(
({ cart, customer }) => cart.customer?.id !== customer.id ({ cart, customer }) => cart.customer?.id !== customer.id
) )
when({ shouldTransfer }, ({ shouldTransfer }) => shouldTransfer).then( when(
() => { "should-transfer-cart",
const cartInput = transform( { shouldTransfer },
{ cart, customer }, ({ shouldTransfer }) => shouldTransfer
({ cart, customer }) => [ ).then(() => {
const cartInput = transform({ cart, customer }, ({ cart, customer }) => [
{ {
id: cart.id, id: cart.id,
customer_id: customer.id, customer_id: customer.id,
email: customer.email, email: customer.email,
}, },
] ])
)
updateCartsStep(cartInput) updateCartsStep(cartInput)
@@ -119,8 +119,7 @@ export const transferCartCustomerWorkflow = createWorkflow(
customer_id: customer.customer_id, customer_id: customer.customer_id,
}, },
}) })
} })
)
return new WorkflowResponse(void 0, { return new WorkflowResponse(void 0, {
hooks: [validate], hooks: [validate],
@@ -75,7 +75,7 @@ export const updateCartPromotionsWorkflowId = "update-cart-promotions"
export const updateCartPromotionsWorkflow = createWorkflow( export const updateCartPromotionsWorkflow = createWorkflow(
updateCartPromotionsWorkflowId, updateCartPromotionsWorkflowId,
(input: WorkflowData<UpdateCartPromotionsWorkflowInput>) => { (input: WorkflowData<UpdateCartPromotionsWorkflowInput>) => {
const fetchCart = when({ input }, ({ input }) => { const fetchCart = when("should-fetch-cart", { input }, ({ input }) => {
return !input.cart return !input.cart
}).then(() => { }).then(() => {
return useRemoteQueryStep({ return useRemoteQueryStep({
@@ -1,5 +1,6 @@
import { import {
AdditionalData, AdditionalData,
CartDTO,
UpdateCartWorkflowInputDTO, UpdateCartWorkflowInputDTO,
} from "@medusajs/framework/types" } from "@medusajs/framework/types"
import { import {
@@ -16,11 +17,7 @@ import {
WorkflowData, WorkflowData,
WorkflowResponse, WorkflowResponse,
} from "@medusajs/framework/workflows-sdk" } from "@medusajs/framework/workflows-sdk"
import { import { emitEventStep, useQueryGraphStep } from "../../common"
emitEventStep,
useQueryGraphStep,
useRemoteQueryStep,
} from "../../common"
import { deleteLineItemsStep } from "../../line-item" import { deleteLineItemsStep } from "../../line-item"
import { import {
findOrCreateCustomerStep, findOrCreateCustomerStep,
@@ -83,9 +80,9 @@ export const updateCartWorkflowId = "update-cart"
export const updateCartWorkflow = createWorkflow( export const updateCartWorkflow = createWorkflow(
updateCartWorkflowId, updateCartWorkflowId,
(input: WorkflowData<UpdateCartWorkflowInput>) => { (input: WorkflowData<UpdateCartWorkflowInput>) => {
const cartToUpdate = useRemoteQueryStep({ const { data: cartToUpdate } = useQueryGraphStep({
entry_point: "cart", entity: "cart",
variables: { id: input.id }, filters: { id: input.id },
fields: [ fields: [
"id", "id",
"email", "email",
@@ -95,18 +92,26 @@ export const updateCartWorkflow = createWorkflow(
"region.*", "region.*",
"region.countries.*", "region.countries.*",
], ],
list: false, pagination: {
throw_if_key_not_found: true, take: 1,
},
options: {
throwIfKeyNotFound: true,
isList: false,
},
}).config({ name: "get-cart" }) }).config({ name: "get-cart" })
const cartDataInput = transform({ input, cartToUpdate }, (data) => { const cartDataInput = transform(
{ input, cartToUpdate },
(data: { input: UpdateCartWorkflowInput; cartToUpdate: CartDTO }) => {
return { return {
sales_channel_id: sales_channel_id:
data.input.sales_channel_id ?? data.cartToUpdate.sales_channel_id, data.input.sales_channel_id ?? data.cartToUpdate.sales_channel_id,
customer_id: data.cartToUpdate.customer_id, customer_id: data.cartToUpdate.customer_id,
email: data.input.email ?? data.cartToUpdate.email, email: data.input.email ?? data.cartToUpdate.email,
} }
}) }
)
const [salesChannel, customer] = parallelize( const [salesChannel, customer] = parallelize(
findSalesChannelStep({ findSalesChannelStep({
@@ -120,16 +125,23 @@ export const updateCartWorkflow = createWorkflow(
validateSalesChannelStep({ salesChannel }) validateSalesChannelStep({ salesChannel })
const newRegion = when({ input }, (data) => { const newRegion = when("should-fetch-region", { input }, (data) => {
return !!data.input.region_id return !!data.input.region_id
}).then(() => { }).then(() => {
return useRemoteQueryStep({ const { data: newRegion } = useQueryGraphStep({
entry_point: "region", entity: "region",
variables: { id: input.region_id }, filters: { id: input.region_id },
fields: ["id", "countries.*", "currency_code", "name"], fields: ["id", "countries.*", "currency_code", "name"],
list: false, pagination: {
throw_if_key_not_found: true, take: 1,
},
options: {
throwIfKeyNotFound: true,
isList: false,
},
}).config({ name: "get-region" }) }).config({ name: "get-region" })
return newRegion
}) })
const region = transform({ cartToUpdate, newRegion }, (data) => { const region = transform({ cartToUpdate, newRegion }, (data) => {
@@ -239,9 +251,13 @@ export const updateCartWorkflow = createWorkflow(
} }
) )
when({ regionUpdated }, ({ regionUpdated }) => { when(
"should-emit-region-updated",
{ regionUpdated },
({ regionUpdated }) => {
return !!regionUpdated return !!regionUpdated
}).then(() => { }
).then(() => {
emitEventStep({ emitEventStep({
eventName: CartWorkflowEvents.REGION_UPDATED, eventName: CartWorkflowEvents.REGION_UPDATED,
data: { id: input.id }, 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 // 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 // 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 return !!regionUpdated
}).then(() => { }).then(() => {
const lineItems = useQueryGraphStep({ const lineItems = useQueryGraphStep({
@@ -1,12 +1,17 @@
import { import {
AdditionalData, AdditionalData,
CartDTO,
CustomerDTO,
RegionDTO,
UpdateLineItemInCartWorkflowInputDTO, UpdateLineItemInCartWorkflowInputDTO,
} from "@medusajs/framework/types" } from "@medusajs/framework/types"
import { import {
CartWorkflowEvents, CartWorkflowEvents,
deduplicate, deduplicate,
filterObjectByKeys,
isDefined, isDefined,
MedusaError, MedusaError,
QueryContext,
} from "@medusajs/framework/utils" } from "@medusajs/framework/utils"
import { import {
createHook, createHook,
@@ -18,7 +23,6 @@ import {
} from "@medusajs/framework/workflows-sdk" } from "@medusajs/framework/workflows-sdk"
import { useQueryGraphStep } from "../../common" import { useQueryGraphStep } from "../../common"
import { emitEventStep } from "../../common/steps/emit-event" import { emitEventStep } from "../../common/steps/emit-event"
import { useRemoteQueryStep } from "../../common/steps/use-remote-query"
import { updateLineItemsStepWithSelector } from "../../line-item/steps" import { updateLineItemsStepWithSelector } from "../../line-item/steps"
import { validateCartStep } from "../steps/validate-cart" import { validateCartStep } from "../steps/validate-cart"
import { validateVariantPricesStep } from "../steps/validate-variant-prices" import { validateVariantPricesStep } from "../steps/validate-variant-prices"
@@ -32,6 +36,13 @@ import { confirmVariantInventoryWorkflow } from "./confirm-variant-inventory"
import { refreshCartItemsWorkflow } from "./refresh-cart-items" import { refreshCartItemsWorkflow } from "./refresh-cart-items"
const cartFields = cartFieldsForPricingContext.concat(["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" export const updateLineItemInCartWorkflowId = "update-line-item-in-cart"
/** /**
@@ -97,17 +108,24 @@ export const updateLineItemInCartWorkflow = createWorkflow(
( (
input: WorkflowData<UpdateLineItemInCartWorkflowInputDTO & AdditionalData> input: WorkflowData<UpdateLineItemInCartWorkflowInputDTO & AdditionalData>
) => { ) => {
const cartQuery = useQueryGraphStep({ const { data: cart } = useQueryGraphStep({
entity: "cart", entity: "cart",
filters: { id: input.cart_id }, filters: { id: input.cart_id },
fields: cartFields, fields: cartFields,
options: { throwIfKeyNotFound: true }, options: { throwIfKeyNotFound: true, isList: false },
}).config({ name: "get-cart" }) }).config({ name: "get-cart" })
const cart = transform({ cartQuery }, ({ cartQuery }) => cartQuery.data[0]) const { item, variantIds } = transform(
const item = transform({ cart, input }, ({ cart, input }) => { { cart, input },
return cart.items.find((i) => i.id === input.item_id) (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 }) validateCartStep({ cart })
@@ -116,10 +134,6 @@ export const updateLineItemInCartWorkflow = createWorkflow(
cart, cart,
}) })
const variantIds = transform({ item }, ({ item }) => {
return [item.variant_id].filter(Boolean)
})
const setPricingContext = createHook( const setPricingContext = createHook(
"setPricingContext", "setPricingContext",
{ {
@@ -134,41 +148,56 @@ export const updateLineItemInCartWorkflow = createWorkflow(
) )
const setPricingContextResult = setPricingContext.getResult() const setPricingContextResult = setPricingContext.getResult()
const pricingContext = transform( const pricingContext = transform(
{ cart, setPricingContextResult }, { cart, item, update: input.update, setPricingContextResult },
(data) => { (data): Record<string, any> => {
return { return {
...data.cart, ...filterObjectByKeys(data.cart, cartFieldsForPricingContext),
...(data.setPricingContextResult ? data.setPricingContextResult : {}), ...(data.setPricingContextResult ? data.setPricingContextResult : {}),
quantity: data.update.quantity ?? data.item.quantity,
currency_code: data.cart.currency_code, currency_code: data.cart.currency_code,
region_id: data.cart.region_id, region_id: data.cart.region_id!,
region: data.cart.region, region: data.cart.region!,
customer_id: data.cart.customer_id, customer_id: data.cart.customer_id!,
customer: data.cart.customer, customer: data.cart.customer!,
} }
} }
) )
const variants = when({ variantIds }, ({ variantIds }) => { const variants = when(
"should-fetch-variants",
{ variantIds },
({ variantIds }) => {
return !!variantIds.length return !!variantIds.length
}).then(() => { }
return useRemoteQueryStep({ ).then(() => {
entry_point: "variants", const calculatedPriceQueryContext = transform(
{ pricingContext },
({ pricingContext }) => {
return QueryContext(pricingContext)
}
)
const { data: variants } = useQueryGraphStep({
entity: "variants",
fields: deduplicate([ fields: deduplicate([
...productVariantsFields, ...variantFields,
...requiredVariantFieldsForInventoryConfirmation, ...requiredVariantFieldsForInventoryConfirmation,
]), ]),
variables: { filters: {
id: variantIds, id: variantIds,
calculated_price: {
context: pricingContext,
}, },
context: {
calculated_price: calculatedPriceQueryContext,
}, },
}).config({ name: "fetch-variants" }) }).config({ name: "fetch-variants" })
})
validateVariantPricesStep({ variants }) validateVariantPricesStep({ variants })
return variants
})
const items = transform({ input, item }, (data) => { const items = transform({ input, item }, (data) => {
return [ return [
Object.assign(data.item, { quantity: data.input.update.quantity }), Object.assign(data.item, { quantity: data.input.update.quantity }),
@@ -123,7 +123,7 @@ export const updateTaxLinesWorkflowId = "update-tax-lines"
export const updateTaxLinesWorkflow = createWorkflow( export const updateTaxLinesWorkflow = createWorkflow(
updateTaxLinesWorkflowId, updateTaxLinesWorkflowId,
(input: WorkflowData<UpdateTaxLinesWorkflowInput>): WorkflowData<void> => { (input: WorkflowData<UpdateTaxLinesWorkflowInput>): WorkflowData<void> => {
const fetchCart = when({ input }, ({ input }) => { const fetchCart = when("should-fetch-cart", { input }, ({ input }) => {
return !input.cart return !input.cart
}).then(() => { }).then(() => {
return useRemoteQueryStep({ return useRemoteQueryStep({
@@ -121,7 +121,7 @@ export const upsertTaxLinesWorkflowId = "upsert-tax-lines"
export const upsertTaxLinesWorkflow = createWorkflow( export const upsertTaxLinesWorkflow = createWorkflow(
upsertTaxLinesWorkflowId, upsertTaxLinesWorkflowId,
(input: WorkflowData<UpsertTaxLinesWorkflowInput>): WorkflowData<void> => { (input: WorkflowData<UpsertTaxLinesWorkflowInput>): WorkflowData<void> => {
const fetchCart = when({ input }, ({ input }) => { const fetchCart = when("should-fetch-cart", { input }, ({ input }) => {
return !input.cart return !input.cart
}).then(() => { }).then(() => {
return useRemoteQueryStep({ 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 { createStep, StepFunction, StepResponse } from "@medusajs/workflows-sdk"
import { ContainerRegistrationKeys } from "@medusajs/utils" import { ContainerRegistrationKeys } from "@medusajs/utils"
export type UseQueryGraphStepInput<TEntry extends string> = export type UseQueryGraphStepInput<
RemoteQueryInput<TEntry> & { TEntry extends string,
options?: RemoteJoinerOptions 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" const useQueryGraphStepId = "use-query-graph-step"
@@ -20,9 +38,20 @@ const step = createStep(
const query = container.resolve<RemoteQueryFunction>( const query = container.resolve<RemoteQueryFunction>(
ContainerRegistrationKeys.QUERY ContainerRegistrationKeys.QUERY
) )
const isList = input.options?.isList ?? true
delete input.options?.isList
const { options, ...queryConfig } = input const { options, ...queryConfig } = input
const result = await query.graph(queryConfig as any, options) 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) return new StepResponse(result)
} }
) )
@@ -100,9 +129,10 @@ const step = createStep(
* }) * })
* ``` * ```
*/ */
export const useQueryGraphStep = <const TEntry extends string>( export const useQueryGraphStep = <
input: UseQueryGraphStepInput<TEntry> const TEntry extends string,
): ReturnType<StepFunction<any, GraphResultSet<TEntry>>> => const TIsList extends boolean = boolean
step(input as any) as unknown as ReturnType< >(
StepFunction<any, GraphResultSet<TEntry>> input: UseQueryGraphStepInput<TEntry, TIsList>
> ): UseQueryGraphStepOutput<TEntry, TIsList> =>
step(input as any) as unknown as UseQueryGraphStepOutput<TEntry, TIsList>
@@ -14,7 +14,6 @@ export const productVariantsFields = [
"product.type.id", "product.type.id",
"product.collection.title", "product.collection.title",
"product.handle", "product.handle",
"calculated_price.*",
"inventory_items.inventory_item_id", "inventory_items.inventory_item_id",
"inventory_items.required_quantity", "inventory_items.required_quantity",
"inventory_items.inventory.requires_shipping", "inventory_items.inventory.requires_shipping",
@@ -25,9 +25,10 @@ import {
} from "../../cart/utils/prepare-line-item-data" } from "../../cart/utils/prepare-line-item-data"
import { pricingContextResult } from "../../cart/utils/schemas" import { pricingContextResult } from "../../cart/utils/schemas"
import { confirmVariantInventoryWorkflow } from "../../cart/workflows/confirm-variant-inventory" import { confirmVariantInventoryWorkflow } from "../../cart/workflows/confirm-variant-inventory"
import { useRemoteQueryStep } from "../../common" import { useQueryGraphStep, useRemoteQueryStep } from "../../common"
import { createOrderLineItemsStep } from "../steps" import { createOrderLineItemsStep } from "../steps"
import { productVariantsFields } from "../utils/fields" import { productVariantsFields } from "../utils/fields"
import { getVariantPriceSetsStep } from "../../cart"
function prepareLineItems(data) { function prepareLineItems(data) {
const items = (data.input.items ?? []).map((item) => { const items = (data.input.items ?? []).map((item) => {
@@ -193,30 +194,66 @@ export const addOrderLineItemsWorkflow = createWorkflow(
} }
) )
const variants = when({ variantIds }, ({ variantIds }) => { const variants = when(
"fetch-variants-with-calculated-price",
{ variantIds },
({ variantIds }) => {
return !!variantIds.length return !!variantIds.length
}).then(() => { }
return useRemoteQueryStep({ ).then(() => {
entry_point: "variants", const { data: variantsData } = useQueryGraphStep({
entity: "variants",
fields: deduplicate([ fields: deduplicate([
...productVariantsFields, ...productVariantsFields,
...requiredVariantFieldsForInventoryConfirmation, ...requiredVariantFieldsForInventoryConfirmation,
]), ]),
variables: { filters: {
id: variantIds, id: variantIds,
calculated_price: {
context: pricingContext,
}, },
},
})
}) })
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 }) validateVariantPricesStep({ variants })
return variants
})
confirmVariantInventoryWorkflow.runAsStep({ confirmVariantInventoryWorkflow.runAsStep({
input: { input: {
sales_channel_id: salesChannel.id, sales_channel_id: salesChannel.id,
variants, variants: variants!,
items: input.items!, items: input.items!,
}, },
}) })
@@ -1,6 +1,7 @@
import { AdditionalData, CreateOrderDTO } from "@medusajs/framework/types" import { AdditionalData, CreateOrderDTO } from "@medusajs/framework/types"
import { import {
MedusaError, MedusaError,
PromotionActions,
deduplicate, deduplicate,
isDefined, isDefined,
isPresent, isPresent,
@@ -14,6 +15,7 @@ import {
transform, transform,
when, when,
} from "@medusajs/framework/workflows-sdk" } from "@medusajs/framework/workflows-sdk"
import { getVariantPriceSetsStep } from "../../cart"
import { findOneOrAnyRegionStep } from "../../cart/steps/find-one-or-any-region" import { findOneOrAnyRegionStep } from "../../cart/steps/find-one-or-any-region"
import { findOrCreateCustomerStep } from "../../cart/steps/find-or-create-customer" import { findOrCreateCustomerStep } from "../../cart/steps/find-or-create-customer"
import { findSalesChannelStep } from "../../cart/steps/find-sales-channel" import { findSalesChannelStep } from "../../cart/steps/find-sales-channel"
@@ -26,7 +28,8 @@ import {
} from "../../cart/utils/prepare-line-item-data" } from "../../cart/utils/prepare-line-item-data"
import { pricingContextResult } from "../../cart/utils/schemas" import { pricingContextResult } from "../../cart/utils/schemas"
import { confirmVariantInventoryWorkflow } from "../../cart/workflows/confirm-variant-inventory" 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 { createOrdersStep } from "../steps"
import { productVariantsFields } from "../utils/fields" import { productVariantsFields } from "../utils/fields"
import { updateOrderTaxLinesWorkflow } from "./update-tax-lines" import { updateOrderTaxLinesWorkflow } from "./update-tax-lines"
@@ -205,7 +208,6 @@ export const createOrderWorkflow = createWorkflow(
) )
const setPricingContextResult = setPricingContext.getResult() const setPricingContextResult = setPricingContext.getResult()
// TODO: This is on par with the context used in v1.*, but we can be more flexible.
const pricingContext = transform( const pricingContext = transform(
{ input, region, customerData, setPricingContextResult }, { input, region, customerData, setPricingContextResult },
(data) => { (data) => {
@@ -222,25 +224,133 @@ export const createOrderWorkflow = createWorkflow(
} }
) )
const variants = when({ variantIds }, ({ variantIds }) => { /**
return !!variantIds.length * Only fetch variants with calculated prices if needed, otherwise only fetch variants without
}).then(() => { * calculated prices.
return useRemoteQueryStep({ *
entry_point: "variants", * 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([ fields: deduplicate([
...productVariantsFields, ...productVariantsFields,
...requiredVariantFieldsForInventoryConfirmation, ...requiredVariantFieldsForInventoryConfirmation,
]), ]),
variables: { filters: {
id: variantIds, id: variantIdsWithoutCalculatedPrice,
calculated_price: {
context: pricingContext,
}, },
}).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,
]),
filters: {
id: variantIdsForPriceCalculation,
},
}).config({ name: "query-variants-to-calculate-prices" })
const calculatedPriceSets = getVariantPriceSetsStep({
data: calculatePricesContext,
}) })
validateVariantPricesStep({ variants }) 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
})
/**
* 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({ confirmVariantInventoryWorkflow.runAsStep({
input: { input: {
@@ -269,14 +379,63 @@ export const createOrderWorkflow = createWorkflow(
const orders = createOrdersStep([orderToCreate]) const orders = createOrdersStep([orderToCreate])
const order = transform({ orders }, (data) => data.orders?.[0]) const order = transform({ orders }, (data) => data.orders?.[0])
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({ updateOrderTaxLinesWorkflow.runAsStep({
input: { input: {
order_id: order.id, order_id: order.id,
}, },
}),
refreshDraftOrderAdjustmentsWorkflow.runAsStep({
input: {
order: freshOrder,
promo_codes: appliedPromoCodes,
action: PromotionActions.REPLACE,
},
}) })
)
const orderCreated = createHook("orderCreated", { const orderCreated = createHook("orderCreated", {
order, order: freshOrder,
additional_data: input.additional_data, additional_data: input.additional_data,
}) })