fix: Cart operation should calculate item prices accounting for quantity (#13251)

* fix(): Cart operation should calculate item prices accounting for quantity

* fix(): Cart operation should calculate item prices accounting for quantity

* fix(): Cart operation should calculate item prices accounting for quantity

* fix when call warning

* fix tests and remove unnecessary object copy

* Create warm-dancers-allow.md

* fix update line item in cart workflow

* fix changeset

* update order flows

* fix cart spec integration tests

* improve create order workflow

* fixes and tests adjustments/improvements

* configurable useQueryGraphStep return type

* revert nullable take

* cleanup useQueryGraphStep
This commit is contained in:
Adrien de Peretti
2025-08-25 09:38:58 +02:00
committed by GitHub
parent f0ef444992
commit 6264a6262b
27 changed files with 2173 additions and 335 deletions
@@ -1,8 +1,13 @@
import { Query } from "@medusajs/framework"
import {
CalculatedPriceSet,
IPricingModuleService,
} from "@medusajs/framework/types"
import { MedusaError, Modules } from "@medusajs/framework/utils"
import {
ContainerRegistrationKeys,
MedusaError,
Modules,
} from "@medusajs/framework/utils"
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
/**
@@ -21,99 +26,260 @@ export interface GetVariantPriceSetsStepInput {
context?: Record<string, unknown>
}
/**
* The calculated price sets of the variants. The object's keys are the variant IDs.
*/
export interface GetVariantPriceSetsStepBulkInput {
data: {
variantId: string
context?: Record<string, unknown>
}[]
}
interface VariantPriceSetData {
id: string
price_set?: { id: string }
}
interface PriceCalculationItem {
variantId: string
priceSetId: string
context?: Record<string, unknown>
}
export interface GetVariantPriceSetsStepOutput {
[k: string]: CalculatedPriceSet
}
export const getVariantPriceSetsStepId = "get-variant-price-sets"
async function fetchVariantPriceSets(
query: Query,
variantIds: string[]
): Promise<VariantPriceSetData[]> {
return (
await query.graph({
entity: "variant",
fields: ["id", "price_set.id"],
filters: { id: variantIds },
})
).data
}
/**
* Validates that all variants have price sets and throws error for missing ones
*/
function validateVariantPriceSets(
variantPriceSets: VariantPriceSetData[]
): void {
const notFound = variantPriceSets
.filter((v) => !v.price_set?.id)
.map((v) => v.id)
if (notFound.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Variants with IDs ${notFound.join(", ")} do not have a price`
)
}
}
/**
* Unified function to process variants with context grouping optimization
* TODO: to be discussed, support batch calculation from the pricing module. Currently
* trying to mitigate the impact by grouping items by exact same context.
*/
async function processVariantPriceSets(
pricingService: IPricingModuleService,
items: PriceCalculationItem[]
): Promise<GetVariantPriceSetsStepOutput> {
const result: GetVariantPriceSetsStepOutput = {}
// Group items by their context to minimize API calls
const contextGroups = groupItemsByContext(items)
for (const [, groupItems] of contextGroups) {
const priceSetIds = groupItems.map((item) => item.priceSetId)
const context = groupItems[0].context // All items in group have same context
const calculatedPriceSets = await pricingService.calculatePrices(
{ id: priceSetIds },
{ context: context as Record<string, string | number> }
)
// Map calculated prices back to variants
const priceSetMap = new Map(
calculatedPriceSets.map((priceSet) => [priceSet.id, priceSet])
)
for (const item of groupItems) {
const calculatedPriceSet = priceSetMap.get(item.priceSetId)
if (calculatedPriceSet) {
result[item.variantId] = calculatedPriceSet
}
}
}
return result
}
function createContextKey(context?: Record<string, unknown>): string {
if (!context || Object.keys(context).length === 0) {
return "no-context"
}
// Sort keys to ensure consistent grouping regardless of key order
const sortedEntries = Object.entries(context)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}:${JSON.stringify(value)}`)
return sortedEntries.join("|")
}
/**
* Groups calculation items by their context. It results in less API calls to the pricing module
* if we are able to group multiple item with the exact same context
*/
function groupItemsByContext(
items: PriceCalculationItem[]
): Map<string, PriceCalculationItem[]> {
const groups = new Map<string, PriceCalculationItem[]>()
for (const item of items) {
const contextKey = createContextKey(item.context)
const existingGroup = groups.get(contextKey)
if (existingGroup) {
existingGroup.push(item)
} else {
groups.set(contextKey, [item])
}
}
return groups
}
/**
* Converts shared context input to unified calculation items format
*/
function createCalculationItemsFromSharedContext(
variantPriceSets: VariantPriceSetData[],
sharedContext?: Record<string, unknown>
): PriceCalculationItem[] {
return variantPriceSets
.filter((v) => v.price_set?.id)
.map((v) => ({
variantId: v.id,
priceSetId: v.price_set!.id,
context: sharedContext,
}))
}
/**
* Converts individual context input to unified calculation items format
*/
function createCalculationItemsFromBulkData(
bulkData: GetVariantPriceSetsStepBulkInput["data"],
variantToPriceSetId: Map<string, string>
): PriceCalculationItem[] {
const calculationItems: PriceCalculationItem[] = []
for (const item of bulkData) {
const priceSetId = variantToPriceSetId.get(item.variantId)
if (priceSetId) {
calculationItems.push({
variantId: item.variantId,
priceSetId,
context: item.context,
})
}
}
return calculationItems
}
/**
* This step retrieves the calculated price sets of the specified variants.
*
* @example
* To retrieve a variant's price sets:
* To retrieve variant price sets with shared context:
*
* ```ts
* const data = getVariantPriceSetsStep({
* variantIds: ["variant_123"],
* context: { currency_code: "usd" }
* })
* ```
*
* To retrieve the calculated price sets of a variant:
* To retrieve variant price sets with individual contexts:
*
* ```ts
* const data = getVariantPriceSetsStep({
* variantIds: ["variant_123"],
* context: {
* currency_code: "usd"
* }
* data: [
* { variantId: "variant_123", context: { currency_code: "usd" } },
* { variantId: "variant_456", context: { currency_code: "usd" } }, // Same context - will be batched
* { variantId: "variant_789", context: { currency_code: "eur" } }
* ]
* })
* ```
*/
export const getVariantPriceSetsStep = createStep(
getVariantPriceSetsStepId,
async (data: GetVariantPriceSetsStepInput, { container }) => {
if (!data.variantIds.length) {
return new StepResponse({})
}
async (
data: GetVariantPriceSetsStepInput | GetVariantPriceSetsStepBulkInput,
{ container }
) => {
const pricingModuleService = container.resolve<IPricingModuleService>(
Modules.PRICING
)
const query = container.resolve<Query>(ContainerRegistrationKeys.QUERY)
const remoteQuery = container.resolve("remoteQuery")
let calculationItems: PriceCalculationItem[]
const variantPriceSets = await remoteQuery({
entryPoint: "variant",
fields: ["id", "price_set.id"],
variables: {
id: data.variantIds,
},
})
const notFound: string[] = []
const priceSetIds: string[] = []
variantPriceSets.forEach((v) => {
if (v.price_set?.id) {
priceSetIds.push(v.price_set.id)
} else {
notFound.push(v.id)
// Handle shared context variants (original input format)
if ("variantIds" in data) {
if (!data.variantIds.length) {
return new StepResponse({})
}
})
if (notFound.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Variants with IDs ${notFound.join(", ")} do not have a price`
const variantPriceSets = await fetchVariantPriceSets(
query,
data.variantIds
)
validateVariantPriceSets(variantPriceSets)
calculationItems = createCalculationItemsFromSharedContext(
variantPriceSets,
data.context
)
} else {
// Handle individual context variants (bulk input format)
const bulkData = data.data
if (!bulkData.length) {
return new StepResponse({})
}
const variantIds = bulkData.map((item) => item.variantId)
const variantPriceSets = await fetchVariantPriceSets(query, variantIds)
validateVariantPriceSets(variantPriceSets)
// Map variant IDs to price set IDs
const variantToPriceSetId = new Map<string, string>()
variantPriceSets.forEach((v) => {
if (v.price_set?.id) {
variantToPriceSetId.set(v.id, v.price_set.id)
}
})
calculationItems = createCalculationItemsFromBulkData(
bulkData,
variantToPriceSetId
)
}
const calculatedPriceSets = await pricingModuleService.calculatePrices(
{ id: priceSetIds },
{ context: data.context as Record<string, string | number> }
// Use unified processing logic for both input types
const result = await processVariantPriceSets(
pricingModuleService,
calculationItems
)
const idToPriceSet = new Map<string, Record<string, any>>(
calculatedPriceSets.map((p) => [p.id, p])
)
const variantToCalculatedPriceSets = variantPriceSets.reduce(
(acc, { id, price_set }) => {
const calculatedPriceSet = idToPriceSet.get(price_set?.id)
if (calculatedPriceSet) {
acc[id] = calculatedPriceSet
}
return acc
},
{}
)
return new StepResponse(
variantToCalculatedPriceSets as GetVariantPriceSetsStepOutput
)
return new StepResponse(result)
}
)
@@ -166,7 +166,6 @@ export const productVariantsFields = [
"product.discountable",
"product.is_giftcard",
"product.shipping_profile.id",
"calculated_price.*",
"inventory_items.inventory_item_id",
"inventory_items.required_quantity",
"inventory_items.inventory.requires_shipping",
@@ -2,10 +2,12 @@ import {
AdditionalData,
AddToCartWorkflowInputDTO,
ConfirmVariantInventoryWorkflowInputDTO,
WithCalculatedPrice,
} from "@medusajs/framework/types"
import {
CartWorkflowEvents,
deduplicate,
filterObjectByKeys,
isDefined,
} from "@medusajs/framework/utils"
import {
@@ -19,10 +21,10 @@ import {
} from "@medusajs/framework/workflows-sdk"
import { useQueryGraphStep } from "../../common"
import { emitEventStep } from "../../common/steps/emit-event"
import { useRemoteQueryStep } from "../../common/steps/use-remote-query"
import {
createLineItemsStep,
getLineItemActionsStep,
getVariantPriceSetsStep,
updateLineItemsStep,
} from "../steps"
import { validateCartStep } from "../steps/validate-cart"
@@ -36,6 +38,7 @@ import { requiredVariantFieldsForInventoryConfirmation } from "../utils/prepare-
import {
prepareLineItemData,
PrepareLineItemDataInput,
PrepareVariantLineItemInput,
} from "../utils/prepare-line-item-data"
import { pricingContextResult } from "../utils/schemas"
import { confirmVariantInventoryWorkflow } from "./confirm-variant-inventory"
@@ -148,40 +151,74 @@ export const addToCartWorkflow = createWorkflow(
)
const setPricingContextResult = setPricingContext.getResult()
const pricingContext = transform(
{ cart, setPricingContextResult },
(data) => {
return {
...data.cart,
...(data.setPricingContextResult ? data.setPricingContextResult : {}),
currency_code: data.cart.currency_code,
region_id: data.cart.region_id,
region: data.cart.region,
customer_id: data.cart.customer_id,
customer: data.cart.customer,
}
}
)
const variants = when({ variantIds }, ({ variantIds }) => {
return !!variantIds.length
}).then(() => {
return useRemoteQueryStep({
entry_point: "variants",
const variants = when(
"should-calculate-prices",
{ variantIds },
({ variantIds }) => {
return !!variantIds.length
}
).then(() => {
const pricingContext = transform(
{ cart, items: input.items, setPricingContextResult },
(data): { variantId: string; context: Record<string, unknown> }[] => {
const baseContext = {
...filterObjectByKeys(data.cart, cartFieldsForPricingContext),
...(data.setPricingContextResult
? data.setPricingContextResult
: {}),
currency_code: data.cart.currency_code,
region_id: data.cart.region_id,
region: data.cart.region,
customer_id: data.cart.customer_id,
customer: data.cart.customer,
}
return data.items
.filter((i) => i.variant_id)
.map((item) => {
return {
variantId: item.variant_id!,
context: {
...baseContext,
quantity: item.quantity,
},
}
})
}
)
const { data: variantsData } = useQueryGraphStep({
entity: "variants",
fields: deduplicate([
...productVariantsFields,
...requiredVariantFieldsForInventoryConfirmation,
]),
variables: {
filters: {
id: variantIds,
calculated_price: {
context: pricingContext,
},
},
})
})
validateVariantPricesStep({ variants })
const calculatedPriceSets = getVariantPriceSetsStep({
data: pricingContext,
})
const variants = transform(
{ variantsData, calculatedPriceSets },
({ variantsData, calculatedPriceSets }) => {
return variantsData.map((variant) => {
variant.calculated_price = calculatedPriceSets[variant.id]
return variant
})
}
)
validateVariantPricesStep({ variants })
return variants as (PrepareVariantLineItemInput &
ConfirmVariantInventoryWorkflowInputDTO["variants"][number] &
WithCalculatedPrice)[]
})
const lineItems = transform({ input, variants }, (data) => {
const items = (data.input.items ?? []).map((item) => {
@@ -17,13 +17,14 @@ import {
WorkflowData,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import { useQueryGraphStep } from "../../common"
import { emitEventStep } from "../../common/steps/emit-event"
import { useRemoteQueryStep } from "../../common/steps/use-remote-query"
import {
createCartsStep,
findOneOrAnyRegionStep,
findOrCreateCustomerStep,
findSalesChannelStep,
getVariantPriceSetsStep,
} from "../steps"
import { validateLineItemPricesStep } from "../steps/validate-line-item-prices"
import { validateSalesChannelStep } from "../steps/validate-sales-channel"
@@ -167,30 +168,62 @@ export const createCartWorkflow = createWorkflow(
}
)
const variants = when({ variantIds }, ({ variantIds }) => {
const variants = when("has-variants", { variantIds }, ({ variantIds }) => {
return !!variantIds.length
}).then(() => {
return useRemoteQueryStep({
entry_point: "variants",
const { data: variantsData } = useQueryGraphStep({
entity: "variants",
fields: deduplicate([
...productVariantsFields,
...requiredVariantFieldsForInventoryConfirmation,
]),
variables: {
filters: {
id: variantIds,
calculated_price: {
context: pricingContext,
},
},
})
})
validateVariantPricesStep({ variants })
const calculatedPriceContext = transform(
{ pricingContext, items: input.items },
(data): { variantId: string; context: Record<string, unknown> }[] => {
const baseContext = data.pricingContext
return (data.items ?? [])
.filter((i) => i.variant_id)
.map((item) => {
return {
variantId: item.variant_id!,
context: {
...baseContext,
quantity: item.quantity,
},
}
})
}
)
const calculatedPriceSets = getVariantPriceSetsStep({
data: calculatedPriceContext,
})
const variants = transform(
{ variantsData, calculatedPriceSets },
({ variantsData, calculatedPriceSets }) => {
return variantsData.map((variant) => {
variant.calculated_price = calculatedPriceSets[variant.id]
return variant
})
}
)
validateVariantPricesStep({ variants })
return variants
})
confirmVariantInventoryWorkflow.runAsStep({
input: {
sales_channel_id: salesChannel.id,
variants,
variants: variants!,
items: input.items!,
},
})
@@ -12,7 +12,7 @@ import {
AdditionalData,
ListShippingOptionsForCartWorkflowInput,
} from "@medusajs/types"
import { isDefined } from "@medusajs/framework/utils"
import { filterObjectByKeys, isDefined } from "@medusajs/framework/utils"
import { pricingContextResult } from "../utils/schemas"
export const listShippingOptionsForCartWorkflowId =
@@ -181,7 +181,7 @@ export const listShippingOptionsForCartWorkflow = createWorkflow(
calculated_price: {
context: {
...cart,
...filterObjectByKeys(cart, cartFieldsForPricingContext),
...(setPricingContextResult ? setPricingContextResult : {}),
currency_code: cart.currency_code,
region_id: cart.region_id,
@@ -11,8 +11,10 @@ import {
WorkflowData,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import { AdditionalData, CartDTO } from "@medusajs/types"
import { useQueryGraphStep } from "../../common"
import { useRemoteQueryStep } from "../../common/steps/use-remote-query"
import { updateLineItemsStep } from "../steps"
import { getVariantPriceSetsStep, updateLineItemsStep } from "../steps"
import { validateVariantPricesStep } from "../steps/validate-variant-prices"
import {
cartFieldsForPricingContext,
@@ -23,13 +25,12 @@ import {
prepareLineItemData,
PrepareLineItemDataInput,
} from "../utils/prepare-line-item-data"
import { pricingContextResult } from "../utils/schemas"
import { refreshCartShippingMethodsWorkflow } from "./refresh-cart-shipping-methods"
import { refreshPaymentCollectionForCartWorkflow } from "./refresh-payment-collection"
import { updateCartPromotionsWorkflow } from "./update-cart-promotions"
import { updateTaxLinesWorkflow } from "./update-tax-lines"
import { upsertTaxLinesWorkflow } from "./upsert-tax-lines"
import { AdditionalData } from "@medusajs/types"
import { pricingContextResult } from "../utils/schemas"
/**
* The details of the cart to refresh.
@@ -142,48 +143,77 @@ export const refreshCartItemsWorkflow = createWorkflow(
)
const setPricingContextResult = setPricingContext.getResult()
when({ input }, ({ input }) => {
when("force-refresh-calculate-prices", { input }, ({ input }) => {
return !!input.force_refresh
}).then(() => {
const cart = useRemoteQueryStep({
entry_point: "cart",
const { data: cart } = useQueryGraphStep({
entity: "cart",
fields: cartFieldsForRefreshSteps,
variables: { id: input.cart_id },
list: false,
filters: { id: input.cart_id },
pagination: {
take: 1,
},
options: {
isList: false,
},
})
const variantIds = transform({ cart }, (data) => {
const variantIds = transform({ cart }, (data: { cart: CartDTO }) => {
return (data.cart.items ?? []).map((i) => i.variant_id).filter(Boolean)
})
const cartPricingContext = transform(
{ cart, setPricingContextResult },
(data) => {
return {
...filterObjectByKeys(data.cart, cartFieldsForPricingContext),
(data): { variantId: string; context: Record<string, unknown> }[] => {
const cart = data.cart
const baseContext = {
...filterObjectByKeys(cart, cartFieldsForPricingContext),
...(data.setPricingContextResult
? data.setPricingContextResult
: {}),
currency_code: data.cart.currency_code,
region_id: data.cart.region_id,
region: data.cart.region,
customer_id: data.cart.customer_id,
customer: data.cart.customer,
currency_code: cart.currency_code,
region_id: cart.region_id,
region: cart.region,
customer_id: cart.customer_id,
customer: cart.customer,
}
return cart.items
.filter((i) => i.variant_id)
.map((item) => {
return {
variantId: item.variant_id,
context: {
...baseContext,
quantity: item.quantity,
},
}
})
}
)
const variants = useRemoteQueryStep({
entry_point: "variants",
const { data: variantsData } = useQueryGraphStep({
entity: "variants",
fields: productVariantsFields,
variables: {
filters: {
id: variantIds,
calculated_price: {
context: cartPricingContext,
},
},
}).config({ name: "fetch-variants" })
const calculatedPriceSets = getVariantPriceSetsStep({
data: cartPricingContext,
})
const variants = transform(
{ variantsData, calculatedPriceSets },
({ variantsData, calculatedPriceSets }) => {
return variantsData.map((variant) => {
variant.calculated_price = calculatedPriceSets[variant.id]
return variant
})
}
)
validateVariantPricesStep({ variants })
const lineItems = transform({ cart, variants }, ({ cart, variants }) => {
@@ -244,7 +274,7 @@ export const refreshCartItemsWorkflow = createWorkflow(
input: refreshCartInput,
})
when({ input }, ({ input }) => {
when("force-refresh-update-tax-lines", { input }, ({ input }) => {
return !!input.force_refresh
}).then(() => {
updateTaxLinesWorkflow.runAsStep({
@@ -252,7 +282,7 @@ export const refreshCartItemsWorkflow = createWorkflow(
})
})
when({ input }, ({ input }) => {
when("force-refresh-upsert-tax-lines", { input }, ({ input }) => {
return (
!input.force_refresh &&
(!!input.items?.length || !!input.shipping_methods?.length)
@@ -52,7 +52,7 @@ export const refreshCartShippingMethodsWorkflowId =
export const refreshCartShippingMethodsWorkflow = createWorkflow(
refreshCartShippingMethodsWorkflowId,
(input: WorkflowData<RefreshCartShippingMethodsWorkflowInput>) => {
const fetchCart = when({ input }, ({ input }) => {
const fetchCart = when("fetch-cart", { input }, ({ input }) => {
return !input.cart
}).then(() => {
return useRemoteQueryStep({
@@ -94,9 +94,13 @@ export const refreshCartShippingMethodsWorkflow = createWorkflow(
cart,
})
when({ listShippingOptionsInput }, ({ listShippingOptionsInput }) => {
return !!listShippingOptionsInput?.length
}).then(() => {
when(
"should-prepare-shipping-methods",
{ listShippingOptionsInput },
({ listShippingOptionsInput }) => {
return !!listShippingOptionsInput?.length
}
).then(() => {
const shippingOptions =
listShippingOptionsForCartWithPricingWorkflow.runAsStep({
input: {
@@ -56,7 +56,7 @@ export const refreshPaymentCollectionForCartWorkflowId =
export const refreshPaymentCollectionForCartWorkflow = createWorkflow(
refreshPaymentCollectionForCartWorkflowId,
(input: WorkflowData<RefreshPaymentCollectionForCartWorklowInput>) => {
const fetchCart = when({ input }, ({ input }) => {
const fetchCart = when("should-fetch-cart", { input }, ({ input }) => {
return !input.cart
}).then(() => {
return useRemoteQueryStep({
@@ -88,7 +88,7 @@ export const refreshPaymentCollectionForCartWorkflow = createWorkflow(
cart,
})
when({ cart }, ({ cart }) => {
when("should-update-payment-collection", { cart }, ({ cart }) => {
const valueIsEqual = MathBN.eq(
cart.payment_collection?.raw_amount ?? -1,
cart.raw_total
@@ -93,34 +93,33 @@ export const transferCartCustomerWorkflow = createWorkflow(
({ cart, customer }) => cart.customer?.id !== customer.id
)
when({ shouldTransfer }, ({ shouldTransfer }) => shouldTransfer).then(
() => {
const cartInput = transform(
{ cart, customer },
({ cart, customer }) => [
{
id: cart.id,
customer_id: customer.id,
email: customer.email,
},
]
)
when(
"should-transfer-cart",
{ shouldTransfer },
({ shouldTransfer }) => shouldTransfer
).then(() => {
const cartInput = transform({ cart, customer }, ({ cart, customer }) => [
{
id: cart.id,
customer_id: customer.id,
email: customer.email,
},
])
updateCartsStep(cartInput)
updateCartsStep(cartInput)
refreshCartItemsWorkflow.runAsStep({
input: { cart_id: input.id, force_refresh: true },
})
refreshCartItemsWorkflow.runAsStep({
input: { cart_id: input.id, force_refresh: true },
})
emitEventStep({
eventName: CartWorkflowEvents.CUSTOMER_TRANSFERRED,
data: {
id: input.id,
customer_id: customer.customer_id,
},
})
}
)
emitEventStep({
eventName: CartWorkflowEvents.CUSTOMER_TRANSFERRED,
data: {
id: input.id,
customer_id: customer.customer_id,
},
})
})
return new WorkflowResponse(void 0, {
hooks: [validate],
@@ -75,7 +75,7 @@ export const updateCartPromotionsWorkflowId = "update-cart-promotions"
export const updateCartPromotionsWorkflow = createWorkflow(
updateCartPromotionsWorkflowId,
(input: WorkflowData<UpdateCartPromotionsWorkflowInput>) => {
const fetchCart = when({ input }, ({ input }) => {
const fetchCart = when("should-fetch-cart", { input }, ({ input }) => {
return !input.cart
}).then(() => {
return useRemoteQueryStep({
@@ -1,5 +1,6 @@
import {
AdditionalData,
CartDTO,
UpdateCartWorkflowInputDTO,
} from "@medusajs/framework/types"
import {
@@ -16,11 +17,7 @@ import {
WorkflowData,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import {
emitEventStep,
useQueryGraphStep,
useRemoteQueryStep,
} from "../../common"
import { emitEventStep, useQueryGraphStep } from "../../common"
import { deleteLineItemsStep } from "../../line-item"
import {
findOrCreateCustomerStep,
@@ -83,9 +80,9 @@ export const updateCartWorkflowId = "update-cart"
export const updateCartWorkflow = createWorkflow(
updateCartWorkflowId,
(input: WorkflowData<UpdateCartWorkflowInput>) => {
const cartToUpdate = useRemoteQueryStep({
entry_point: "cart",
variables: { id: input.id },
const { data: cartToUpdate } = useQueryGraphStep({
entity: "cart",
filters: { id: input.id },
fields: [
"id",
"email",
@@ -95,18 +92,26 @@ export const updateCartWorkflow = createWorkflow(
"region.*",
"region.countries.*",
],
list: false,
throw_if_key_not_found: true,
pagination: {
take: 1,
},
options: {
throwIfKeyNotFound: true,
isList: false,
},
}).config({ name: "get-cart" })
const cartDataInput = transform({ input, cartToUpdate }, (data) => {
return {
sales_channel_id:
data.input.sales_channel_id ?? data.cartToUpdate.sales_channel_id,
customer_id: data.cartToUpdate.customer_id,
email: data.input.email ?? data.cartToUpdate.email,
const cartDataInput = transform(
{ input, cartToUpdate },
(data: { input: UpdateCartWorkflowInput; cartToUpdate: CartDTO }) => {
return {
sales_channel_id:
data.input.sales_channel_id ?? data.cartToUpdate.sales_channel_id,
customer_id: data.cartToUpdate.customer_id,
email: data.input.email ?? data.cartToUpdate.email,
}
}
})
)
const [salesChannel, customer] = parallelize(
findSalesChannelStep({
@@ -120,16 +125,23 @@ export const updateCartWorkflow = createWorkflow(
validateSalesChannelStep({ salesChannel })
const newRegion = when({ input }, (data) => {
const newRegion = when("should-fetch-region", { input }, (data) => {
return !!data.input.region_id
}).then(() => {
return useRemoteQueryStep({
entry_point: "region",
variables: { id: input.region_id },
const { data: newRegion } = useQueryGraphStep({
entity: "region",
filters: { id: input.region_id },
fields: ["id", "countries.*", "currency_code", "name"],
list: false,
throw_if_key_not_found: true,
pagination: {
take: 1,
},
options: {
throwIfKeyNotFound: true,
isList: false,
},
}).config({ name: "get-region" })
return newRegion
})
const region = transform({ cartToUpdate, newRegion }, (data) => {
@@ -239,9 +251,13 @@ export const updateCartWorkflow = createWorkflow(
}
)
when({ regionUpdated }, ({ regionUpdated }) => {
return !!regionUpdated
}).then(() => {
when(
"should-emit-region-updated",
{ regionUpdated },
({ regionUpdated }) => {
return !!regionUpdated
}
).then(() => {
emitEventStep({
eventName: CartWorkflowEvents.REGION_UPDATED,
data: { id: input.id },
@@ -258,7 +274,7 @@ export const updateCartWorkflow = createWorkflow(
// In case the region is updated, we might have a new currency OR tax inclusivity setting
// Therefore, we need to delete line items with a custom price for good measure
when({ regionUpdated }, ({ regionUpdated }) => {
when("should-delete-line-items", { regionUpdated }, ({ regionUpdated }) => {
return !!regionUpdated
}).then(() => {
const lineItems = useQueryGraphStep({
@@ -1,12 +1,17 @@
import {
AdditionalData,
CartDTO,
CustomerDTO,
RegionDTO,
UpdateLineItemInCartWorkflowInputDTO,
} from "@medusajs/framework/types"
import {
CartWorkflowEvents,
deduplicate,
filterObjectByKeys,
isDefined,
MedusaError,
QueryContext,
} from "@medusajs/framework/utils"
import {
createHook,
@@ -18,7 +23,6 @@ import {
} from "@medusajs/framework/workflows-sdk"
import { useQueryGraphStep } from "../../common"
import { emitEventStep } from "../../common/steps/emit-event"
import { useRemoteQueryStep } from "../../common/steps/use-remote-query"
import { updateLineItemsStepWithSelector } from "../../line-item/steps"
import { validateCartStep } from "../steps/validate-cart"
import { validateVariantPricesStep } from "../steps/validate-variant-prices"
@@ -32,6 +36,13 @@ import { confirmVariantInventoryWorkflow } from "./confirm-variant-inventory"
import { refreshCartItemsWorkflow } from "./refresh-cart-items"
const cartFields = cartFieldsForPricingContext.concat(["items.*"])
const variantFields = productVariantsFields.concat(["calculated_price.*"])
interface CartQueryDTO extends Omit<CartDTO, "items"> {
items: NonNullable<CartDTO["items"]>
customer: CustomerDTO
region: RegionDTO
}
export const updateLineItemInCartWorkflowId = "update-line-item-in-cart"
/**
@@ -97,17 +108,24 @@ export const updateLineItemInCartWorkflow = createWorkflow(
(
input: WorkflowData<UpdateLineItemInCartWorkflowInputDTO & AdditionalData>
) => {
const cartQuery = useQueryGraphStep({
const { data: cart } = useQueryGraphStep({
entity: "cart",
filters: { id: input.cart_id },
fields: cartFields,
options: { throwIfKeyNotFound: true },
options: { throwIfKeyNotFound: true, isList: false },
}).config({ name: "get-cart" })
const cart = transform({ cartQuery }, ({ cartQuery }) => cartQuery.data[0])
const item = transform({ cart, input }, ({ cart, input }) => {
return cart.items.find((i) => i.id === input.item_id)
})
const { item, variantIds } = transform(
{ cart, input },
(data: {
cart: CartQueryDTO
input: UpdateLineItemInCartWorkflowInputDTO & AdditionalData
}) => {
const item = data.cart.items.find((i) => i.id === data.input.item_id)!
const variantIds = [item?.variant_id].filter(Boolean)
return { item, variantIds }
}
)
validateCartStep({ cart })
@@ -116,10 +134,6 @@ export const updateLineItemInCartWorkflow = createWorkflow(
cart,
})
const variantIds = transform({ item }, ({ item }) => {
return [item.variant_id].filter(Boolean)
})
const setPricingContext = createHook(
"setPricingContext",
{
@@ -134,40 +148,55 @@ export const updateLineItemInCartWorkflow = createWorkflow(
)
const setPricingContextResult = setPricingContext.getResult()
const pricingContext = transform(
{ cart, setPricingContextResult },
(data) => {
{ cart, item, update: input.update, setPricingContextResult },
(data): Record<string, any> => {
return {
...data.cart,
...filterObjectByKeys(data.cart, cartFieldsForPricingContext),
...(data.setPricingContextResult ? data.setPricingContextResult : {}),
quantity: data.update.quantity ?? data.item.quantity,
currency_code: data.cart.currency_code,
region_id: data.cart.region_id,
region: data.cart.region,
customer_id: data.cart.customer_id,
customer: data.cart.customer,
region_id: data.cart.region_id!,
region: data.cart.region!,
customer_id: data.cart.customer_id!,
customer: data.cart.customer!,
}
}
)
const variants = when({ variantIds }, ({ variantIds }) => {
return !!variantIds.length
}).then(() => {
return useRemoteQueryStep({
entry_point: "variants",
const variants = when(
"should-fetch-variants",
{ variantIds },
({ variantIds }) => {
return !!variantIds.length
}
).then(() => {
const calculatedPriceQueryContext = transform(
{ pricingContext },
({ pricingContext }) => {
return QueryContext(pricingContext)
}
)
const { data: variants } = useQueryGraphStep({
entity: "variants",
fields: deduplicate([
...productVariantsFields,
...variantFields,
...requiredVariantFieldsForInventoryConfirmation,
]),
variables: {
filters: {
id: variantIds,
calculated_price: {
context: pricingContext,
},
},
context: {
calculated_price: calculatedPriceQueryContext,
},
}).config({ name: "fetch-variants" })
})
validateVariantPricesStep({ variants })
validateVariantPricesStep({ variants })
return variants
})
const items = transform({ input, item }, (data) => {
return [
@@ -123,7 +123,7 @@ export const updateTaxLinesWorkflowId = "update-tax-lines"
export const updateTaxLinesWorkflow = createWorkflow(
updateTaxLinesWorkflowId,
(input: WorkflowData<UpdateTaxLinesWorkflowInput>): WorkflowData<void> => {
const fetchCart = when({ input }, ({ input }) => {
const fetchCart = when("should-fetch-cart", { input }, ({ input }) => {
return !input.cart
}).then(() => {
return useRemoteQueryStep({
@@ -121,7 +121,7 @@ export const upsertTaxLinesWorkflowId = "upsert-tax-lines"
export const upsertTaxLinesWorkflow = createWorkflow(
upsertTaxLinesWorkflowId,
(input: WorkflowData<UpsertTaxLinesWorkflowInput>): WorkflowData<void> => {
const fetchCart = when({ input }, ({ input }) => {
const fetchCart = when("should-fetch-cart", { input }, ({ input }) => {
return !input.cart
}).then(() => {
return useRemoteQueryStep({
@@ -0,0 +1,13 @@
export interface SimpleProduct {
id: string
title: string
description: string
}
export interface FixtureEntryPoints {
simple_product: SimpleProduct
}
declare module "@medusajs/types/dist/modules-sdk/remote-query-entry-points" {
export interface RemoteQueryEntryPoints extends FixtureEntryPoints {}
}
@@ -0,0 +1,95 @@
import { createWorkflow, WorkflowResponse } from "@medusajs/workflows-sdk"
import { expectTypeOf } from "expect-type"
import { FixtureEntryPoints } from "../__fixtures__/remote-query"
import { useQueryGraphStep } from "../use-query-graph"
import { MedusaContainer } from "@medusajs/framework"
import { asFunction, createContainer } from "awilix"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
describe("useQueryGraphStep", () => {
let container!: MedusaContainer
beforeAll(() => {
container = createContainer() as unknown as MedusaContainer
container.register(
ContainerRegistrationKeys.QUERY,
asFunction(() => {
return {
graph: () => Promise.resolve({ data: [] }),
} as any
})
)
})
it("should return a single data item when is_list is false", async () => {
const workflow = createWorkflow("useQueryGraphStepTest1", (_: any) => {
const result = useQueryGraphStep({
entity: "simple_product",
fields: ["*"],
filters: {
id: "123",
},
options: {
isList: false,
},
})
return new WorkflowResponse(result)
})
const result = await workflow(container).run()
type Result = (typeof result)["result"]
expectTypeOf<Result["data"]>().toEqualTypeOf<
FixtureEntryPoints["simple_product"]
>()
})
it("should return a list of data items when is_list is true", async () => {
const workflow = createWorkflow("useQueryGraphStepTest1", (_: any) => {
const result = useQueryGraphStep({
entity: "simple_product",
fields: ["*"],
filters: {
id: "123",
},
options: {
isList: true,
},
})
return new WorkflowResponse(result)
})
const result = await workflow(container).run()
type Result = (typeof result)["result"]
expectTypeOf<Result["data"]>().toEqualTypeOf<
FixtureEntryPoints["simple_product"][]
>()
})
it("should return a list of data items when is_list is not specified", async () => {
const workflow = createWorkflow("useQueryGraphStepTest1", (_: any) => {
const result = useQueryGraphStep({
entity: "simple_product",
fields: ["*"],
filters: {
id: "123",
},
})
return new WorkflowResponse(result)
})
const result = await workflow(container).run()
type Result = (typeof result)["result"]
expectTypeOf<Result["data"]>().toEqualTypeOf<
FixtureEntryPoints["simple_product"][]
>()
})
})
@@ -7,10 +7,28 @@ import {
import { createStep, StepFunction, StepResponse } from "@medusajs/workflows-sdk"
import { ContainerRegistrationKeys } from "@medusajs/utils"
export type UseQueryGraphStepInput<TEntry extends string> =
RemoteQueryInput<TEntry> & {
options?: RemoteJoinerOptions
export type UseQueryGraphStepInput<
TEntry extends string,
TIsList extends boolean = boolean
> = RemoteQueryInput<TEntry> & {
options?: RemoteJoinerOptions & {
isList?: TIsList
}
}
export type UseQueryGraphStepOutput<
TEntry extends string,
TIsList extends boolean = boolean
> = ReturnType<
StepFunction<
any,
true extends TIsList
? GraphResultSet<TEntry>
: Omit<GraphResultSet<TEntry>, "data"> & {
data: GraphResultSet<TEntry>["data"][number]
}
>
>
const useQueryGraphStepId = "use-query-graph-step"
@@ -20,9 +38,20 @@ const step = createStep(
const query = container.resolve<RemoteQueryFunction>(
ContainerRegistrationKeys.QUERY
)
const isList = input.options?.isList ?? true
delete input.options?.isList
const { options, ...queryConfig } = input
const result = await query.graph(queryConfig as any, options)
if (!isList) {
const data = result.data?.[0]
result.data = data
return new StepResponse(result)
}
return new StepResponse(result)
}
)
@@ -100,9 +129,10 @@ const step = createStep(
* })
* ```
*/
export const useQueryGraphStep = <const TEntry extends string>(
input: UseQueryGraphStepInput<TEntry>
): ReturnType<StepFunction<any, GraphResultSet<TEntry>>> =>
step(input as any) as unknown as ReturnType<
StepFunction<any, GraphResultSet<TEntry>>
>
export const useQueryGraphStep = <
const TEntry extends string,
const TIsList extends boolean = boolean
>(
input: UseQueryGraphStepInput<TEntry, TIsList>
): UseQueryGraphStepOutput<TEntry, TIsList> =>
step(input as any) as unknown as UseQueryGraphStepOutput<TEntry, TIsList>
@@ -28,15 +28,15 @@ export interface RefreshDraftOrderAdjustmentsWorkflowInput {
* The draft order to refresh the adjustments for.
*/
order: OrderDTO
// TODO: I will reintroduce this type, once I have migrated all of the order flows to fit the expected type.
// TODO: I will reintroduce this type, once I have migrated all of the order flows to fit the expected type.
// Doing this in a single PR is too much work, so I'm going to do it in smaller PRs.
//
// order: Omit<OrderDTO, "items"> & {
// items?: ComputeActionItemLine[]
// promotions?: PromotionDTO[]
// }
/**
* The promo codes to add or remove from the draft order.
*/
@@ -14,7 +14,6 @@ export const productVariantsFields = [
"product.type.id",
"product.collection.title",
"product.handle",
"calculated_price.*",
"inventory_items.inventory_item_id",
"inventory_items.required_quantity",
"inventory_items.inventory.requires_shipping",
@@ -25,9 +25,10 @@ import {
} from "../../cart/utils/prepare-line-item-data"
import { pricingContextResult } from "../../cart/utils/schemas"
import { confirmVariantInventoryWorkflow } from "../../cart/workflows/confirm-variant-inventory"
import { useRemoteQueryStep } from "../../common"
import { useQueryGraphStep, useRemoteQueryStep } from "../../common"
import { createOrderLineItemsStep } from "../steps"
import { productVariantsFields } from "../utils/fields"
import { getVariantPriceSetsStep } from "../../cart"
function prepareLineItems(data) {
const items = (data.input.items ?? []).map((item) => {
@@ -193,30 +194,66 @@ export const addOrderLineItemsWorkflow = createWorkflow(
}
)
const variants = when({ variantIds }, ({ variantIds }) => {
return !!variantIds.length
}).then(() => {
return useRemoteQueryStep({
entry_point: "variants",
const variants = when(
"fetch-variants-with-calculated-price",
{ variantIds },
({ variantIds }) => {
return !!variantIds.length
}
).then(() => {
const { data: variantsData } = useQueryGraphStep({
entity: "variants",
fields: deduplicate([
...productVariantsFields,
...requiredVariantFieldsForInventoryConfirmation,
]),
variables: {
filters: {
id: variantIds,
calculated_price: {
context: pricingContext,
},
},
})
})
validateVariantPricesStep({ variants })
const calculatedPriceContext = transform(
{ pricingContext, items: input.items },
(data): { variantId: string; context: Record<string, unknown> }[] => {
const baseContext = data.pricingContext
return (data.items ?? [])
.filter((i) => i.variant_id)
.map((item) => {
return {
variantId: item.variant_id!,
context: {
...baseContext,
quantity: item.quantity,
},
}
})
}
)
const calculatedPriceSets = getVariantPriceSetsStep({
data: calculatedPriceContext,
})
const variants = transform(
{ variantsData, calculatedPriceSets },
({ variantsData, calculatedPriceSets }) => {
return variantsData.map((variant) => {
variant.calculated_price = calculatedPriceSets[variant.id]
return variant
})
}
)
validateVariantPricesStep({ variants })
return variants
})
confirmVariantInventoryWorkflow.runAsStep({
input: {
sales_channel_id: salesChannel.id,
variants,
variants: variants!,
items: input.items!,
},
})
@@ -1,6 +1,7 @@
import { AdditionalData, CreateOrderDTO } from "@medusajs/framework/types"
import {
MedusaError,
PromotionActions,
deduplicate,
isDefined,
isPresent,
@@ -14,6 +15,7 @@ import {
transform,
when,
} from "@medusajs/framework/workflows-sdk"
import { getVariantPriceSetsStep } from "../../cart"
import { findOneOrAnyRegionStep } from "../../cart/steps/find-one-or-any-region"
import { findOrCreateCustomerStep } from "../../cart/steps/find-or-create-customer"
import { findSalesChannelStep } from "../../cart/steps/find-sales-channel"
@@ -26,7 +28,8 @@ import {
} from "../../cart/utils/prepare-line-item-data"
import { pricingContextResult } from "../../cart/utils/schemas"
import { confirmVariantInventoryWorkflow } from "../../cart/workflows/confirm-variant-inventory"
import { useRemoteQueryStep } from "../../common"
import { useQueryGraphStep } from "../../common"
import { refreshDraftOrderAdjustmentsWorkflow } from "../../draft-order/workflows/refresh-draft-order-adjustments"
import { createOrdersStep } from "../steps"
import { productVariantsFields } from "../utils/fields"
import { updateOrderTaxLinesWorkflow } from "./update-tax-lines"
@@ -205,7 +208,6 @@ export const createOrderWorkflow = createWorkflow(
)
const setPricingContextResult = setPricingContext.getResult()
// TODO: This is on par with the context used in v1.*, but we can be more flexible.
const pricingContext = transform(
{ input, region, customerData, setPricingContextResult },
(data) => {
@@ -222,25 +224,133 @@ export const createOrderWorkflow = createWorkflow(
}
)
const variants = when({ variantIds }, ({ variantIds }) => {
return !!variantIds.length
}).then(() => {
return useRemoteQueryStep({
entry_point: "variants",
/**
* Only fetch variants with calculated prices if needed, otherwise only fetch variants without
* calculated prices.
*
* We need a variant calculated price when the item is either missing a unit price or is not
* tax inclusive.
*/
const { variantIdsForPriceCalculation, variantIdsWithoutCalculatedPrice } =
transform({ input }, (data) => {
const variantIdsForPriceCalculation: string[] = []
const variantIdsWithoutCalculatedPrice: string[] = []
data.input.items?.forEach((item) => {
if (
item.variant_id &&
(!isDefined(item.unit_price) || !isDefined(item.is_tax_inclusive))
) {
variantIdsForPriceCalculation.push(item.variant_id!)
} else {
variantIdsWithoutCalculatedPrice.push(item.variant_id!)
}
})
return {
variantIdsForPriceCalculation,
variantIdsWithoutCalculatedPrice,
}
})
/**
* Fetch all variant for which we don't need to calculate the price.
*/
const { data: variantsWithoutCalculatedPrice } = useQueryGraphStep({
entity: "variants",
fields: deduplicate([
...productVariantsFields,
...requiredVariantFieldsForInventoryConfirmation,
]),
filters: {
id: variantIdsWithoutCalculatedPrice,
},
}).config({ name: "query-variants-without-calculated-price" })
/**
* Fetch all variants for which we need to calculate the price.
*/
const variantsWithCalculatedPrice = when(
"fetch-variants-with-calculated-price",
{ variantIdsForPriceCalculation },
({ variantIdsForPriceCalculation }) => {
return !!variantIdsForPriceCalculation.length
}
).then(() => {
const calculatePricesContext = transform(
{ items: input.items, variantIdsForPriceCalculation, pricingContext },
(data) => {
const baseContext = data.pricingContext
return data.variantIdsForPriceCalculation
?.map((variant) => {
// Since we retrieve the variant ids from the item, it is not possible to not find the item back from the variant id.
const item = data.items?.find(
(item) => item.variant_id === variant
)!
return {
variantId: variant,
context: {
...baseContext,
quantity: item.quantity,
},
}
})
.filter(Boolean)
}
)
const { data: variants } = useQueryGraphStep({
entity: "variants",
fields: deduplicate([
...productVariantsFields,
...requiredVariantFieldsForInventoryConfirmation,
]),
variables: {
id: variantIds,
calculated_price: {
context: pricingContext,
},
filters: {
id: variantIdsForPriceCalculation,
},
}).config({ name: "query-variants-to-calculate-prices" })
const calculatedPriceSets = getVariantPriceSetsStep({
data: calculatePricesContext,
})
const reconstructedVariants = transform(
{
variants,
calculatedPriceSets,
},
(data) => {
return data.variants.map((variant) => {
variant.calculated_price = data.calculatedPriceSets[variant.id]
return variant
})
}
)
validateVariantPricesStep({ variants: reconstructedVariants }).config({
name: "validate-variants-with-calculated-price",
})
return reconstructedVariants
})
validateVariantPricesStep({ variants })
/**
* Aggregate all variants without calculated price and all variants with calculated price.
*/
const variants = transform(
{
variantsWithoutCalculatedPrice,
variantsWithCalculatedPrice,
},
(data) => {
return [
...data.variantsWithoutCalculatedPrice,
...(data.variantsWithCalculatedPrice ?? []),
]
}
)
confirmVariantInventoryWorkflow.runAsStep({
input: {
@@ -269,14 +379,63 @@ export const createOrderWorkflow = createWorkflow(
const orders = createOrdersStep([orderToCreate])
const order = transform({ orders }, (data) => data.orders?.[0])
updateOrderTaxLinesWorkflow.runAsStep({
input: {
order_id: order.id,
const appliedPromoCodes: string[] = transform(
input,
(order) => order.promo_codes ?? []
)
/**
* TODO: Currently need the refresh because when the order module creates the order, even though
* the totals are calculated, the order is being queried and without the totals. There is some
* point of discussion for improvements here down the line.
*/
const { data: freshOrder } = useQueryGraphStep({
entity: "orders",
fields: [
"shipping_address.*",
"billing_address.*",
"summary.*",
"items.*",
"credit_lines.*",
"items.tax_lines.*",
"items.adjustments.*",
"shipping_methods.*",
"shipping_methods.tax_lines.*",
"shipping_methods.adjustments.*",
"transactions.*",
"currency_code",
"items.tax_lines.*",
"items.adjustments.*",
"shipping_methods.tax_lines.*",
"shipping_methods.adjustments.*",
"total",
"id",
],
filters: {
id: order.id,
},
})
options: {
isList: false,
},
}).config({ name: "query-fresh-order" })
parallelize(
updateOrderTaxLinesWorkflow.runAsStep({
input: {
order_id: order.id,
},
}),
refreshDraftOrderAdjustmentsWorkflow.runAsStep({
input: {
order: freshOrder,
promo_codes: appliedPromoCodes,
action: PromotionActions.REPLACE,
},
})
)
const orderCreated = createHook("orderCreated", {
order,
order: freshOrder,
additional_data: input.additional_data,
})