feat: Custom line items (#10408)

* feat: Custom line items

* fix tests

* fix migration

* Allow custom items in update line item workflow

* throw if line item doesn't have a price

* minor things

* wip

* fix flows

* fix test

* add default

* add to type
This commit is contained in:
Oli Juhl
2024-12-18 12:53:57 +01:00
committed by GitHub
parent bde4b82194
commit c9b8db04c1
26 changed files with 1380 additions and 421 deletions
+9
View File
@@ -0,0 +1,9 @@
---
"@medusajs/core-flows": patch
"@medusajs/cart": patch
"@medusajs/types": patch
"@medusajs/utils": patch
"@medusajs/medusa": patch
---
chore: Support custom line items
@@ -1,5 +1,5 @@
import { ModuleRegistrationName } from "@medusajs/utils"
import { medusaIntegrationTestRunner } from "@medusajs/test-utils" import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { ModuleRegistrationName } from "@medusajs/utils"
import { import {
adminHeaders, adminHeaders,
createAdminUser, createAdminUser,
File diff suppressed because it is too large Load Diff
@@ -207,8 +207,8 @@ medusaIntegrationTestRunner({
}, },
{ {
title: "Custom Item", title: "Custom Item",
sku: "sku123", variant_sku: "sku123",
barcode: "barcode123", variant_barcode: "barcode123",
unit_price: 2200, unit_price: 2200,
quantity: 1, quantity: 1,
}, },
@@ -254,6 +254,7 @@ medusaIntegrationTestRunner({
requires_shipping: true, requires_shipping: true,
is_discountable: true, is_discountable: true,
is_tax_inclusive: true, is_tax_inclusive: true,
is_custom_price: false,
raw_compare_at_unit_price: null, raw_compare_at_unit_price: null,
raw_unit_price: expect.objectContaining({ raw_unit_price: expect.objectContaining({
value: "3000", value: "3000",
@@ -323,7 +324,8 @@ medusaIntegrationTestRunner({
title: "Custom Item", title: "Custom Item",
variant_sku: "sku123", variant_sku: "sku123",
variant_barcode: "barcode123", variant_barcode: "barcode123",
variant_title: "Custom Item", variant_title: null,
is_custom_price: true,
raw_unit_price: expect.objectContaining({ raw_unit_price: expect.objectContaining({
value: "2200", value: "2200",
}), }),
@@ -0,0 +1,36 @@
import { MedusaError, isPresent } from "@medusajs/framework/utils"
import { createStep } from "@medusajs/framework/workflows-sdk"
export interface ValidateLineItemPricesStepInput {
items: {
unit_price?: number | null
title: string
}[]
}
export const validateLineItemPricesStepId = "validate-line-item-prices"
/**
* This step validates the specified line item objects to ensure they have prices.
*/
export const validateLineItemPricesStep = createStep(
validateLineItemPricesStepId,
async (data: ValidateLineItemPricesStepInput, { container }) => {
if (!data.items?.length) {
return
}
const priceNotFound: string[] = []
for (const item of data.items) {
if (!isPresent(item?.unit_price)) {
priceNotFound.push(item.title)
}
}
if (priceNotFound.length > 0) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Items ${priceNotFound.join(", ")} do not have a price`
)
}
}
)
@@ -18,6 +18,10 @@ export const validateVariantPricesStepId = "validate-variant-prices"
export const validateVariantPricesStep = createStep( export const validateVariantPricesStep = createStep(
validateVariantPricesStepId, validateVariantPricesStepId,
async (data: ValidateVariantPricesStepInput, { container }) => { async (data: ValidateVariantPricesStepInput, { container }) => {
if (!data.variants?.length) {
return
}
const priceNotFound: string[] = [] const priceNotFound: string[] = []
for (const variant of data.variants) { for (const variant of data.variants) {
if (!isPresent(variant?.calculated_price?.calculated_amount)) { if (!isPresent(variant?.calculated_price?.calculated_amount)) {
@@ -1,61 +1,106 @@
import { import {
BigNumberInput, BigNumberInput,
CartLineItemDTO,
CreateOrderAdjustmentDTO, CreateOrderAdjustmentDTO,
CreateOrderLineItemTaxLineDTO, CreateOrderLineItemTaxLineDTO,
InventoryItemDTO, InventoryItemDTO,
LineItemAdjustmentDTO,
LineItemTaxLineDTO,
ProductVariantDTO, ProductVariantDTO,
} from "@medusajs/framework/types" } from "@medusajs/framework/types"
import { isDefined, MathBN, PriceListType } from "@medusajs/framework/utils" import {
isDefined,
isPresent,
MathBN,
PriceListType,
} from "@medusajs/framework/utils"
interface Input { interface PrepareItemLineItemInput {
item?: CartLineItemDTO title?: string
subtitle?: string
thumbnail?: string
quantity: BigNumberInput quantity: BigNumberInput
metadata?: Record<string, any>
unitPrice: BigNumberInput product_id?: string
compareAtUnitPrice?: BigNumberInput | null product_title?: string
isTaxInclusive?: boolean product_description?: string
variant: ProductVariantDTO & { product_subtitle?: string
product_type?: string
product_type_id?: string
product_collection?: string
product_handle?: string
variant_id?: string
variant_sku?: string
variant_barcode?: string
variant_title?: string
variant_option_values?: Record<string, unknown>
requires_shipping?: boolean
is_discountable?: boolean
is_tax_inclusive?: boolean
raw_compare_at_unit_price?: BigNumberInput
compare_at_unit_price?: BigNumberInput
unit_price?: BigNumberInput
tax_lines?: LineItemTaxLineDTO[]
adjustments?: LineItemAdjustmentDTO[]
cart_id?: string
metadata?: Record<string, unknown> | null
}
export interface PrepareVariantLineItemInput extends ProductVariantDTO {
inventory_items: { inventory: InventoryItemDTO }[] inventory_items: { inventory: InventoryItemDTO }[]
calculated_price: { calculated_price: {
calculated_price: { calculated_price: {
price_list_type: string price_list_type: string
} }
is_calculated_price_tax_inclusive: boolean
original_amount: BigNumberInput original_amount: BigNumberInput
calculated_amount: BigNumberInput calculated_amount: BigNumberInput
} }
} }
export interface PrepareLineItemDataInput {
item?: PrepareItemLineItemInput
isCustomPrice?: boolean
variant?: PrepareVariantLineItemInput
taxLines?: CreateOrderLineItemTaxLineDTO[] taxLines?: CreateOrderLineItemTaxLineDTO[]
adjustments?: CreateOrderAdjustmentDTO[] adjustments?: CreateOrderAdjustmentDTO[]
cartId?: string cartId?: string
unitPrice?: BigNumberInput
isTaxInclusive: boolean
} }
export function prepareLineItemData(data: Input) { export function prepareLineItemData(data: PrepareLineItemDataInput) {
const { const {
item, item,
variant, variant,
unitPrice,
isTaxInclusive,
quantity,
metadata,
cartId, cartId,
taxLines, taxLines,
adjustments, adjustments,
isCustomPrice,
unitPrice,
isTaxInclusive,
} = data } = data
if (!variant.product) { if (variant && !variant.product) {
throw new Error("Variant does not have a product") throw new Error("Variant does not have a product")
} }
let compareAtUnitPrice = data.compareAtUnitPrice let compareAtUnitPrice = item?.compare_at_unit_price
const isSalePrice =
variant?.calculated_price?.calculated_price?.price_list_type ===
PriceListType.SALE
if ( if (
!isDefined(compareAtUnitPrice) && !isPresent(compareAtUnitPrice) &&
variant.calculated_price.calculated_price.price_list_type === isSalePrice &&
PriceListType.SALE &&
!MathBN.eq( !MathBN.eq(
variant.calculated_price.original_amount, variant.calculated_price?.original_amount,
variant.calculated_price.calculated_amount variant.calculated_price?.calculated_amount
) )
) { ) {
compareAtUnitPrice = variant.calculated_price.original_amount compareAtUnitPrice = variant.calculated_price.original_amount
@@ -63,9 +108,8 @@ export function prepareLineItemData(data: Input) {
// Note: If any of the items require shipping, we enable fulfillment // Note: If any of the items require shipping, we enable fulfillment
// unless explicitly set to not require shipping by the item in the request // unless explicitly set to not require shipping by the item in the request
const { inventory_items: inventoryItems } = variant const someInventoryRequiresShipping = variant?.inventory_items?.length
const someInventoryRequiresShipping = inventoryItems.length ? variant.inventory_items.some(
? inventoryItems.some(
(inventoryItem) => !!inventoryItem.inventory.requires_shipping (inventoryItem) => !!inventoryItem.inventory.requires_shipping
) )
: true : true
@@ -74,37 +118,42 @@ export function prepareLineItemData(data: Input) {
? item.requires_shipping ? item.requires_shipping
: someInventoryRequiresShipping : someInventoryRequiresShipping
const lineItem: any = { let lineItem: any = {
quantity, quantity: item?.quantity,
title: variant.title ?? item?.title, title: variant?.title ?? item?.title,
subtitle: variant.product.title ?? item?.subtitle, subtitle: variant?.product?.title ?? item?.subtitle,
thumbnail: variant.product.thumbnail ?? item?.thumbnail, thumbnail: variant?.product?.thumbnail ?? item?.thumbnail,
product_id: variant.product.id ?? item?.product_id, product_id: variant?.product?.id ?? item?.product_id,
product_title: variant.product.title ?? item?.product_title, product_title: variant?.product?.title ?? item?.product_title,
product_description: product_description:
variant.product.description ?? item?.product_description, variant?.product?.description ?? item?.product_description,
product_subtitle: variant.product.subtitle ?? item?.product_subtitle, product_subtitle: variant?.product?.subtitle ?? item?.product_subtitle,
product_type: variant.product.type?.value ?? item?.product_type ?? null, product_type: variant?.product?.type?.value ?? item?.product_type ?? null,
product_type_id: variant.product.type?.id ?? item?.product_type_id ?? null, product_type_id:
variant?.product?.type?.id ?? item?.product_type_id ?? null,
product_collection: product_collection:
variant.product.collection?.title ?? item?.product_collection ?? null, variant?.product?.collection?.title ?? item?.product_collection ?? null,
product_handle: variant.product.handle ?? item?.product_handle, product_handle: variant?.product?.handle ?? item?.product_handle,
variant_id: variant.id, variant_id: variant?.id,
variant_sku: variant.sku ?? item?.variant_sku, variant_sku: variant?.sku ?? item?.variant_sku,
variant_barcode: variant.barcode ?? item?.variant_barcode, variant_barcode: variant?.barcode ?? item?.variant_barcode,
variant_title: variant.title ?? item?.variant_title, variant_title: variant?.title ?? item?.variant_title,
variant_option_values: item?.variant_option_values, variant_option_values: item?.variant_option_values,
is_discountable: variant.product.discountable ?? item?.is_discountable, is_discountable: variant?.product?.discountable ?? item?.is_discountable,
requires_shipping: requiresShipping, requires_shipping: requiresShipping,
unit_price: unitPrice, unit_price: unitPrice,
compare_at_unit_price: compareAtUnitPrice, compare_at_unit_price: compareAtUnitPrice,
is_tax_inclusive: !!isTaxInclusive, is_tax_inclusive: !!isTaxInclusive,
metadata, metadata: item?.metadata ?? {},
}
if (isCustomPrice) {
lineItem.is_custom_price = !!isCustomPrice
} }
if (taxLines) { if (taxLines) {
@@ -1,13 +1,11 @@
import { AddToCartWorkflowInputDTO } from "@medusajs/framework/types"
import { CartWorkflowEvents, isDefined } from "@medusajs/framework/utils"
import { import {
AddToCartWorkflowInputDTO,
CreateLineItemForCartDTO,
} from "@medusajs/framework/types"
import { CartWorkflowEvents } from "@medusajs/framework/utils"
import {
WorkflowData,
createWorkflow, createWorkflow,
parallelize, parallelize,
transform, transform,
when,
WorkflowData,
} 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"
@@ -18,12 +16,16 @@ import {
updateLineItemsStep, updateLineItemsStep,
} from "../steps" } from "../steps"
import { validateCartStep } from "../steps/validate-cart" import { validateCartStep } from "../steps/validate-cart"
import { validateLineItemPricesStep } from "../steps/validate-line-item-prices"
import { validateVariantPricesStep } from "../steps/validate-variant-prices" import { validateVariantPricesStep } from "../steps/validate-variant-prices"
import { import {
cartFieldsForPricingContext, cartFieldsForPricingContext,
productVariantsFields, productVariantsFields,
} from "../utils/fields" } from "../utils/fields"
import { prepareLineItemData } from "../utils/prepare-line-item-data" import {
prepareLineItemData,
PrepareLineItemDataInput,
} from "../utils/prepare-line-item-data"
import { confirmVariantInventoryWorkflow } from "./confirm-variant-inventory" import { confirmVariantInventoryWorkflow } from "./confirm-variant-inventory"
import { refreshCartItemsWorkflow } from "./refresh-cart-items" import { refreshCartItemsWorkflow } from "./refresh-cart-items"
@@ -50,41 +52,55 @@ export const addToCartWorkflow = createWorkflow(
validateCartStep({ cart }) validateCartStep({ cart })
const variantIds = transform({ input }, (data) => { const variantIds = transform({ input }, (data) => {
return (data.input.items ?? []).map((i) => i.variant_id) return (data.input.items ?? []).map((i) => i.variant_id).filter(Boolean)
}) })
const variants = useRemoteQueryStep({ const variants = when({ variantIds }, ({ variantIds }) => {
return !!variantIds.length
}).then(() => {
return useRemoteQueryStep({
entry_point: "variants", entry_point: "variants",
fields: productVariantsFields, fields: productVariantsFields,
variables: { variables: {
id: variantIds, id: variantIds,
calculated_price: { context: cart }, calculated_price: {
context: cart,
}, },
throw_if_key_not_found: true, },
})
}) })
validateVariantPricesStep({ variants }) validateVariantPricesStep({ variants })
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((v) => v.id === item.variant_id)! const variant = (data.variants ?? []).find(
(v) => v.id === item.variant_id
)!
return prepareLineItemData({ const input: PrepareLineItemDataInput = {
item,
variant: variant, variant: variant,
unitPrice: cartId: data.input.cart_id,
item.unit_price || variant.calculated_price.calculated_amount, unitPrice: item.unit_price,
isTaxInclusive: isTaxInclusive:
item.is_tax_inclusive || item.is_tax_inclusive ??
variant.calculated_price.is_calculated_price_tax_inclusive, variant?.calculated_price?.is_calculated_price_tax_inclusive,
quantity: item.quantity, isCustomPrice: isDefined(item?.unit_price),
metadata: item?.metadata ?? {}, }
cartId: input.cart_id,
}) as CreateLineItemForCartDTO if (variant && !input.unitPrice) {
input.unitPrice = variant.calculated_price?.calculated_amount
}
return prepareLineItemData(input)
}) })
return items return items
}) })
validateLineItemPricesStep({ items: lineItems })
const { itemsToCreate = [], itemsToUpdate = [] } = getLineItemActionsStep({ const { itemsToCreate = [], itemsToUpdate = [] } = getLineItemActionsStep({
id: cart.id, id: cart.id,
items: lineItems, items: lineItems,
@@ -5,7 +5,7 @@ import {
import { import {
Modules, Modules,
OrderStatus, OrderStatus,
OrderWorkflowEvents, OrderWorkflowEvents
} from "@medusajs/framework/utils" } from "@medusajs/framework/utils"
import { import {
createWorkflow, createWorkflow,
@@ -31,6 +31,7 @@ import { prepareConfirmInventoryInput } from "../utils/prepare-confirm-inventory
import { import {
prepareAdjustmentsData, prepareAdjustmentsData,
prepareLineItemData, prepareLineItemData,
PrepareLineItemDataInput,
prepareTaxLinesData, prepareTaxLinesData,
} from "../utils/prepare-line-item-data" } from "../utils/prepare-line-item-data"
@@ -115,18 +116,17 @@ export const completeCartWorkflow = createWorkflow(
}) ?? [] }) ?? []
const allItems = (cart.items ?? []).map((item) => { const allItems = (cart.items ?? []).map((item) => {
return prepareLineItemData({ const input: PrepareLineItemDataInput = {
item, item,
variant: item.variant, variant: item.variant,
unitPrice: item.raw_unit_price ?? item.unit_price, cartId: cart.id,
compareAtUnitPrice: unitPrice: item.unit_price,
item.raw_compare_at_unit_price ?? item.compare_at_unit_price,
isTaxInclusive: item.is_tax_inclusive, isTaxInclusive: item.is_tax_inclusive,
quantity: item.raw_quantity ?? item.quantity,
metadata: item?.metadata,
taxLines: item.tax_lines ?? [], taxLines: item.tax_lines ?? [],
adjustments: item.adjustments ?? [], adjustments: item.adjustments ?? [],
}) }
return prepareLineItemData(input)
}) })
const shippingMethods = (cart.shipping_methods ?? []).map((sm) => { const shippingMethods = (cart.shipping_methods ?? []).map((sm) => {
@@ -2,14 +2,19 @@ import {
AdditionalData, AdditionalData,
CreateCartWorkflowInputDTO, CreateCartWorkflowInputDTO,
} from "@medusajs/framework/types" } from "@medusajs/framework/types"
import { CartWorkflowEvents, MedusaError } from "@medusajs/framework/utils"
import { import {
WorkflowData, CartWorkflowEvents,
WorkflowResponse, isDefined,
MedusaError,
} from "@medusajs/framework/utils"
import {
createHook, createHook,
createWorkflow, createWorkflow,
parallelize, parallelize,
transform, transform,
when,
WorkflowData,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk" } from "@medusajs/framework/workflows-sdk"
import { emitEventStep } from "../../common/steps/emit-event" import { emitEventStep } from "../../common/steps/emit-event"
import { useRemoteQueryStep } from "../../common/steps/use-remote-query" import { useRemoteQueryStep } from "../../common/steps/use-remote-query"
@@ -18,11 +23,14 @@ import {
findOneOrAnyRegionStep, findOneOrAnyRegionStep,
findOrCreateCustomerStep, findOrCreateCustomerStep,
findSalesChannelStep, findSalesChannelStep,
getVariantPriceSetsStep,
} from "../steps" } from "../steps"
import { validateLineItemPricesStep } from "../steps/validate-line-item-prices"
import { validateVariantPricesStep } from "../steps/validate-variant-prices" import { validateVariantPricesStep } from "../steps/validate-variant-prices"
import { productVariantsFields } from "../utils/fields" import { productVariantsFields } from "../utils/fields"
import { prepareLineItemData } from "../utils/prepare-line-item-data" import {
prepareLineItemData,
PrepareLineItemDataInput,
} from "../utils/prepare-line-item-data"
import { confirmVariantInventoryWorkflow } from "./confirm-variant-inventory" import { confirmVariantInventoryWorkflow } from "./confirm-variant-inventory"
import { refreshPaymentCollectionForCartWorkflow } from "./refresh-payment-collection" import { refreshPaymentCollectionForCartWorkflow } from "./refresh-payment-collection"
import { updateCartPromotionsWorkflow } from "./update-cart-promotions" import { updateCartPromotionsWorkflow } from "./update-cart-promotions"
@@ -36,7 +44,7 @@ export const createCartWorkflow = createWorkflow(
createCartWorkflowId, createCartWorkflowId,
(input: WorkflowData<CreateCartWorkflowInputDTO & AdditionalData>) => { (input: WorkflowData<CreateCartWorkflowInputDTO & AdditionalData>) => {
const variantIds = transform({ input }, (data) => { const variantIds = transform({ input }, (data) => {
return (data.input.items ?? []).map((i) => i.variant_id) return (data.input.items ?? []).map((i) => i.variant_id).filter(Boolean)
}) })
const [salesChannel, region, customerData] = parallelize( const [salesChannel, region, customerData] = parallelize(
@@ -68,7 +76,10 @@ export const createCartWorkflow = createWorkflow(
} }
) )
const variants = useRemoteQueryStep({ const variants = when({ variantIds }, ({ variantIds }) => {
return !!variantIds.length
}).then(() => {
return useRemoteQueryStep({
entry_point: "variants", entry_point: "variants",
fields: productVariantsFields, fields: productVariantsFields,
variables: { variables: {
@@ -77,7 +88,7 @@ export const createCartWorkflow = createWorkflow(
context: pricingContext, context: pricingContext,
}, },
}, },
throw_if_key_not_found: true, })
}) })
validateVariantPricesStep({ variants }) validateVariantPricesStep({ variants })
@@ -90,11 +101,6 @@ export const createCartWorkflow = createWorkflow(
}, },
}) })
const priceSets = getVariantPriceSetsStep({
variantIds,
context: pricingContext,
})
const cartInput = transform( const cartInput = transform(
{ input, region, customerData, salesChannel }, { input, region, customerData, salesChannel },
(data) => { (data) => {
@@ -131,26 +137,34 @@ export const createCartWorkflow = createWorkflow(
} }
) )
const lineItems = transform({ priceSets, 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((v) => v.id === item.variant_id)! const variant = (data.variants ?? []).find(
(v) => v.id === item.variant_id
)!
return prepareLineItemData({ const input: PrepareLineItemDataInput = {
item,
variant: variant, variant: variant,
unitPrice: unitPrice: item.unit_price,
item.unit_price ||
data.priceSets[item.variant_id].calculated_amount,
isTaxInclusive: isTaxInclusive:
item.is_tax_inclusive || item.is_tax_inclusive ??
data.priceSets[item.variant_id].is_calculated_price_tax_inclusive, variant?.calculated_price?.is_calculated_price_tax_inclusive,
quantity: item.quantity, isCustomPrice: isDefined(item?.unit_price),
metadata: item?.metadata ?? {}, }
})
if (variant && !input.unitPrice) {
input.unitPrice = variant.calculated_price?.calculated_amount
}
return prepareLineItemData(input)
}) })
return items return items
}) })
validateLineItemPricesStep({ items: lineItems })
const cartToCreate = transform({ lineItems, cartInput }, (data) => { const cartToCreate = transform({ lineItems, cartInput }, (data) => {
return { return {
...data.cartInput, ...data.cartInput,
@@ -6,6 +6,7 @@ import {
import { import {
createWorkflow, createWorkflow,
transform, transform,
when,
WorkflowData, WorkflowData,
WorkflowResponse, WorkflowResponse,
} from "@medusajs/framework/workflows-sdk" } from "@medusajs/framework/workflows-sdk"
@@ -17,7 +18,10 @@ import {
cartFieldsForRefreshSteps, cartFieldsForRefreshSteps,
productVariantsFields, productVariantsFields,
} from "../utils/fields" } from "../utils/fields"
import { prepareLineItemData } from "../utils/prepare-line-item-data" import {
prepareLineItemData,
PrepareLineItemDataInput,
} from "../utils/prepare-line-item-data"
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"
@@ -43,14 +47,17 @@ export const refreshCartItemsWorkflow = createWorkflow(
}) })
const variantIds = transform({ cart }, (data) => { const variantIds = transform({ cart }, (data) => {
return (data.cart.items ?? []).map((i) => i.variant_id) return (data.cart.items ?? []).map((i) => i.variant_id).filter(Boolean)
}) })
const cartPricingContext = transform({ cart }, ({ cart }) => { const cartPricingContext = transform({ cart }, ({ cart }) => {
return filterObjectByKeys(cart, cartFieldsForPricingContext) return filterObjectByKeys(cart, cartFieldsForPricingContext)
}) })
const variants = useRemoteQueryStep({ const variants = when({ variantIds }, ({ variantIds }) => {
return !!variantIds.length
}).then(() => {
return useRemoteQueryStep({
entry_point: "variants", entry_point: "variants",
fields: productVariantsFields, fields: productVariantsFields,
variables: { variables: {
@@ -59,24 +66,30 @@ export const refreshCartItemsWorkflow = createWorkflow(
context: cartPricingContext, context: cartPricingContext,
}, },
}, },
throw_if_key_not_found: true,
}).config({ name: "fetch-variants" }) }).config({ name: "fetch-variants" })
})
validateVariantPricesStep({ variants }) validateVariantPricesStep({ variants })
const lineItems = transform({ cart, variants }, ({ cart, variants }) => { const lineItems = transform({ cart, variants }, ({ cart, variants }) => {
const items = cart.items.map((item) => { const items = cart.items.map((item) => {
const variant = variants.find((v) => v.id === item.variant_id)! const variant = (variants ?? []).find((v) => v.id === item.variant_id)!
const preparedItem = prepareLineItemData({ const input: PrepareLineItemDataInput = {
item,
variant: variant, variant: variant,
unitPrice: variant.calculated_price.calculated_amount,
isTaxInclusive:
variant.calculated_price.is_calculated_price_tax_inclusive,
quantity: item.quantity,
metadata: item.metadata,
cartId: cart.id, cartId: cart.id,
}) unitPrice: item.unit_price,
isTaxInclusive: item.is_tax_inclusive,
}
if (variant && !item.is_custom_price) {
input.unitPrice = variant.calculated_price?.calculated_amount
input.isTaxInclusive =
variant.calculated_price?.is_calculated_price_tax_inclusive
}
const preparedItem = prepareLineItemData(input)
return { return {
selector: { id: item.id }, selector: { id: item.id },
@@ -16,7 +16,12 @@ import {
WorkflowData, WorkflowData,
WorkflowResponse, WorkflowResponse,
} from "@medusajs/framework/workflows-sdk" } from "@medusajs/framework/workflows-sdk"
import { emitEventStep, useRemoteQueryStep } from "../../common" import {
emitEventStep,
useQueryGraphStep,
useRemoteQueryStep,
} from "../../common"
import { deleteLineItemsStep } from "../../line-item"
import { import {
findOrCreateCustomerStep, findOrCreateCustomerStep,
findSalesChannelStep, findSalesChannelStep,
@@ -167,11 +172,18 @@ export const updateCartWorkflow = createWorkflow(
}) })
*/ */
when({ input, cartToUpdate }, ({ input, cartToUpdate }) => { const regionUpdated = transform(
{ input, cartToUpdate },
({ input, cartToUpdate }) => {
return ( return (
isDefined(input.region_id) && isDefined(input.region_id) &&
input.region_id !== cartToUpdate?.region?.id input.region_id !== cartToUpdate?.region?.id
) )
}
)
when({ regionUpdated }, ({ regionUpdated }) => {
return !!regionUpdated
}).then(() => { }).then(() => {
emitEventStep({ emitEventStep({
eventName: CartWorkflowEvents.REGION_UPDATED, eventName: CartWorkflowEvents.REGION_UPDATED,
@@ -187,6 +199,27 @@ 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 }) => {
return !!regionUpdated
}).then(() => {
const lineItems = useQueryGraphStep({
entity: "line_items",
filters: {
cart_id: input.id,
is_custom_price: true,
},
fields: ["id"],
})
const lineItemIds = transform({ lineItems }, ({ lineItems }) => {
return lineItems.data.map((i) => i.id)
})
deleteLineItemsStep(lineItemIds)
})
const cart = refreshCartItemsWorkflow.runAsStep({ const cart = refreshCartItemsWorkflow.runAsStep({
input: { cart_id: cartInput.id, promo_codes: input.promo_codes }, input: { cart_id: cartInput.id, promo_codes: input.promo_codes },
}) })
@@ -1,8 +1,10 @@
import { UpdateLineItemInCartWorkflowInputDTO } from "@medusajs/framework/types" import { UpdateLineItemInCartWorkflowInputDTO } from "@medusajs/framework/types"
import { isDefined, MedusaError } from "@medusajs/framework/utils"
import { import {
WorkflowData,
createWorkflow, createWorkflow,
transform, transform,
when,
WorkflowData,
} from "@medusajs/framework/workflows-sdk" } from "@medusajs/framework/workflows-sdk"
import { useQueryGraphStep } from "../../common" import { useQueryGraphStep } from "../../common"
import { useRemoteQueryStep } from "../../common/steps/use-remote-query" import { useRemoteQueryStep } from "../../common/steps/use-remote-query"
@@ -40,10 +42,13 @@ export const updateLineItemInCartWorkflow = createWorkflow(
validateCartStep({ cart }) validateCartStep({ cart })
const variantIds = transform({ item }, ({ item }) => { const variantIds = transform({ item }, ({ item }) => {
return [item.variant_id] return [item.variant_id].filter(Boolean)
}) })
const variants = useRemoteQueryStep({ const variants = when({ variantIds }, ({ variantIds }) => {
return !!variantIds.length
}).then(() => {
return useRemoteQueryStep({
entry_point: "variants", entry_point: "variants",
fields: productVariantsFields, fields: productVariantsFields,
variables: { variables: {
@@ -52,7 +57,7 @@ export const updateLineItemInCartWorkflow = createWorkflow(
context: cart, context: cart,
}, },
}, },
throw_if_key_not_found: true, }).config({ name: "fetch-variants" })
}) })
validateVariantPricesStep({ variants }) validateVariantPricesStep({ variants })
@@ -69,16 +74,36 @@ export const updateLineItemInCartWorkflow = createWorkflow(
}, },
}) })
const lineItemUpdate = transform({ input, variants }, (data) => { const lineItemUpdate = transform({ input, variants, item }, (data) => {
const variant = data.variants[0] const variant = data.variants?.[0] ?? undefined
const item = data.item
const updateData = {
...data.input.update,
unit_price: isDefined(data.input.update.unit_price)
? data.input.update.unit_price
: item.unit_price,
is_custom_price: isDefined(data.input.update.unit_price)
? true
: item.is_custom_price,
is_tax_inclusive:
item.is_tax_inclusive ||
variant?.calculated_price?.is_calculated_price_tax_inclusive,
}
if (variant && !updateData.is_custom_price) {
updateData.unit_price = variant.calculated_price.calculated_amount
}
if (!isDefined(updateData.unit_price)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Line item ${item.title} has no unit price`
)
}
return { return {
data: { data: updateData,
...data.input.update,
unit_price: variant.calculated_price.calculated_amount,
is_tax_inclusive:
!!variant.calculated_price.is_calculated_price_tax_inclusive,
},
selector: { selector: {
id: data.input.item_id, id: data.input.item_id,
}, },
@@ -1,68 +0,0 @@
import {
BigNumberInput,
CreateOrderAdjustmentDTO,
CreateOrderLineItemTaxLineDTO,
} from "@medusajs/framework/types"
import {
prepareAdjustmentsData,
prepareTaxLinesData,
} from "../../cart/utils/prepare-line-item-data"
interface Input {
quantity: BigNumberInput
metadata?: Record<string, any>
unitPrice: BigNumberInput
isTaxInclusive?: boolean
taxLines?: CreateOrderLineItemTaxLineDTO[]
adjustments?: CreateOrderAdjustmentDTO[]
variant: {
title: string
sku?: string
barcode?: string
}
}
interface Output {
quantity: BigNumberInput
title: string
variant_sku?: string
variant_barcode?: string
variant_title?: string
unit_price: BigNumberInput
is_tax_inclusive: boolean
metadata?: Record<string, any>
}
export function prepareCustomLineItemData(data: Input): Output {
const {
variant,
unitPrice,
isTaxInclusive,
quantity,
metadata,
taxLines,
adjustments,
} = data
const lineItem: any = {
quantity,
title: variant.title,
variant_sku: variant.sku,
variant_barcode: variant.barcode,
variant_title: variant.title,
unit_price: unitPrice,
is_tax_inclusive: !!isTaxInclusive,
metadata,
}
if (taxLines) {
lineItem.tax_lines = prepareTaxLinesData(taxLines)
}
if (adjustments) {
lineItem.adjustments = prepareAdjustmentsData(adjustments)
}
return lineItem
}
@@ -1,59 +1,48 @@
import { OrderLineItemDTO, OrderWorkflow } from "@medusajs/framework/types" import { OrderLineItemDTO, OrderWorkflow } from "@medusajs/framework/types"
import { MathBN, MedusaError } from "@medusajs/framework/utils" import { isDefined, MedusaError } from "@medusajs/framework/utils"
import { import {
WorkflowData,
WorkflowResponse,
createWorkflow, createWorkflow,
parallelize, parallelize,
transform, transform,
when,
WorkflowData,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk" } from "@medusajs/framework/workflows-sdk"
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"
import { getVariantPriceSetsStep } from "../../cart/steps/get-variant-price-sets" import { validateLineItemPricesStep } from "../../cart/steps/validate-line-item-prices"
import { validateVariantPricesStep } from "../../cart/steps/validate-variant-prices" import { validateVariantPricesStep } from "../../cart/steps/validate-variant-prices"
import { prepareLineItemData } from "../../cart/utils/prepare-line-item-data" import {
prepareLineItemData,
PrepareLineItemDataInput,
} from "../../cart/utils/prepare-line-item-data"
import { confirmVariantInventoryWorkflow } from "../../cart/workflows/confirm-variant-inventory" import { confirmVariantInventoryWorkflow } from "../../cart/workflows/confirm-variant-inventory"
import { useRemoteQueryStep } from "../../common" import { useRemoteQueryStep } from "../../common"
import { createOrderLineItemsStep } from "../steps" import { createOrderLineItemsStep } from "../steps"
import { productVariantsFields } from "../utils/fields" import { productVariantsFields } from "../utils/fields"
import { prepareCustomLineItemData } from "../utils/prepare-custom-line-item-data"
function prepareLineItems(data) { function prepareLineItems(data) {
const items = (data.input.items ?? []).map((item) => { const items = (data.input.items ?? []).map((item) => {
const variant = data.variants.find((v) => v.id === item.variant_id)! const variant = data.variants.find((v) => v.id === item.variant_id)!
if (!variant) { const input: PrepareLineItemDataInput = {
return prepareCustomLineItemData({ item,
variant: { variant: variant,
...item, unitPrice: item.unit_price,
},
unitPrice: MathBN.max(0, item.unit_price),
isTaxInclusive: isTaxInclusive:
item.is_tax_inclusive ?? item.is_tax_inclusive ??
data.priceSets[item.variant_id!]?.is_calculated_price_tax_inclusive, variant?.calculated_price?.is_calculated_price_tax_inclusive,
quantity: item.quantity as number, isCustomPrice: isDefined(item?.unit_price),
metadata: item?.metadata,
taxLines: item.tax_lines || [], taxLines: item.tax_lines || [],
adjustments: item.adjustments || [], adjustments: item.adjustments || [],
})
} }
return prepareLineItemData({ if (variant && !input.unitPrice) {
variant: variant, input.unitPrice = variant.calculated_price?.calculated_amount
unitPrice: MathBN.max( }
0,
item.unit_price ?? return prepareLineItemData(input)
data.priceSets[item.variant_id!]?.raw_calculated_amount
),
isTaxInclusive:
item.is_tax_inclusive ??
data.priceSets[item.variant_id!]?.is_calculated_price_tax_inclusive,
quantity: item.quantity as number,
metadata: item?.metadata,
taxLines: item.tax_lines || [],
adjustments: item.adjustments || [],
})
}) })
return items return items
@@ -117,7 +106,10 @@ export const addOrderLineItemsWorkflow = createWorkflow(
} }
) )
const variants = useRemoteQueryStep({ const variants = when({ variantIds }, ({ variantIds }) => {
return !!variantIds.length
}).then(() => {
return useRemoteQueryStep({
entry_point: "variants", entry_point: "variants",
fields: productVariantsFields, fields: productVariantsFields,
variables: { variables: {
@@ -126,8 +118,8 @@ export const addOrderLineItemsWorkflow = createWorkflow(
context: pricingContext, context: pricingContext,
}, },
}, },
throw_if_key_not_found: true, })
}).config({ name: "variants-query" }) })
validateVariantPricesStep({ variants }) validateVariantPricesStep({ variants })
@@ -139,15 +131,9 @@ export const addOrderLineItemsWorkflow = createWorkflow(
}, },
}) })
const priceSets = getVariantPriceSetsStep({ const lineItems = transform({ input, variants }, prepareLineItems)
variantIds,
context: pricingContext,
})
const lineItems = transform( validateLineItemPricesStep({ items: lineItems })
{ priceSets, input, variants },
prepareLineItems
)
return new WorkflowResponse( return new WorkflowResponse(
createOrderLineItemsStep({ createOrderLineItemsStep({
@@ -1,5 +1,5 @@
import { AdditionalData, CreateOrderDTO } from "@medusajs/framework/types" import { AdditionalData, CreateOrderDTO } from "@medusajs/framework/types"
import { MathBN, MedusaError, isPresent } from "@medusajs/framework/utils" import { MedusaError, isDefined, isPresent } from "@medusajs/framework/utils"
import { import {
WorkflowData, WorkflowData,
WorkflowResponse, WorkflowResponse,
@@ -7,51 +7,44 @@ import {
createWorkflow, createWorkflow,
parallelize, parallelize,
transform, transform,
when,
} from "@medusajs/framework/workflows-sdk" } from "@medusajs/framework/workflows-sdk"
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"
import { getVariantPriceSetsStep } from "../../cart/steps/get-variant-price-sets" import { validateLineItemPricesStep } from "../../cart/steps/validate-line-item-prices"
import { validateVariantPricesStep } from "../../cart/steps/validate-variant-prices" import { validateVariantPricesStep } from "../../cart/steps/validate-variant-prices"
import { prepareLineItemData } from "../../cart/utils/prepare-line-item-data" import {
PrepareLineItemDataInput,
prepareLineItemData,
} from "../../cart/utils/prepare-line-item-data"
import { confirmVariantInventoryWorkflow } from "../../cart/workflows/confirm-variant-inventory" import { confirmVariantInventoryWorkflow } from "../../cart/workflows/confirm-variant-inventory"
import { useRemoteQueryStep } from "../../common" import { useRemoteQueryStep } from "../../common"
import { createOrdersStep } from "../steps" import { createOrdersStep } from "../steps"
import { productVariantsFields } from "../utils/fields" import { productVariantsFields } from "../utils/fields"
import { prepareCustomLineItemData } from "../utils/prepare-custom-line-item-data"
import { updateOrderTaxLinesWorkflow } from "./update-tax-lines" import { updateOrderTaxLinesWorkflow } from "./update-tax-lines"
function prepareLineItems(data) { function prepareLineItems(data) {
const items = (data.input.items ?? []).map((item) => { const items = (data.input.items ?? []).map((item) => {
const variant = data.variants.find((v) => v.id === item.variant_id)! const variant = data.variants.find((v) => v.id === item.variant_id)!
if (!variant) { const input: PrepareLineItemDataInput = {
return prepareCustomLineItemData({ item,
variant: {
...item,
},
unitPrice: MathBN.max(0, item.unit_price),
isTaxInclusive: item.is_tax_inclusive,
quantity: item.quantity as number,
metadata: item?.metadata ?? {},
})
}
return prepareLineItemData({
variant: variant, variant: variant,
unitPrice: MathBN.max( unitPrice: item.unit_price ?? undefined,
0,
item.unit_price ??
data.priceSets[item.variant_id!]?.raw_calculated_amount
),
isTaxInclusive: isTaxInclusive:
item.is_tax_inclusive ?? item.is_tax_inclusive ??
data.priceSets[item.variant_id!]?.is_calculated_price_tax_inclusive, variant?.calculated_price?.is_calculated_price_tax_inclusive,
quantity: item.quantity as number, isCustomPrice: isDefined(item?.unit_price),
metadata: item?.metadata ?? {},
taxLines: item.tax_lines || [], taxLines: item.tax_lines || [],
adjustments: item.adjustments || [], adjustments: item.adjustments || [],
}) }
if (variant && !input.unitPrice) {
input.unitPrice = variant.calculated_price?.calculated_amount
}
return prepareLineItemData(input)
}) })
return items return items
@@ -126,7 +119,10 @@ export const createOrderWorkflow = createWorkflow(
} }
) )
const variants = useRemoteQueryStep({ const variants = when({ variantIds }, ({ variantIds }) => {
return !!variantIds.length
}).then(() => {
return useRemoteQueryStep({
entry_point: "variants", entry_point: "variants",
fields: productVariantsFields, fields: productVariantsFields,
variables: { variables: {
@@ -135,7 +131,7 @@ export const createOrderWorkflow = createWorkflow(
context: pricingContext, context: pricingContext,
}, },
}, },
throw_if_key_not_found: true, })
}) })
validateVariantPricesStep({ variants }) validateVariantPricesStep({ variants })
@@ -148,20 +144,14 @@ export const createOrderWorkflow = createWorkflow(
}, },
}) })
const priceSets = getVariantPriceSetsStep({
variantIds,
context: pricingContext,
})
const orderInput = transform( const orderInput = transform(
{ input, region, customerData, salesChannel }, { input, region, customerData, salesChannel },
getOrderInput getOrderInput
) )
const lineItems = transform( const lineItems = transform({ input, variants }, prepareLineItems)
{ priceSets, input, variants },
prepareLineItems validateLineItemPricesStep({ items: lineItems })
)
const orderToCreate = transform({ lineItems, orderInput }, (data) => { const orderToCreate = transform({ lineItems, orderInput }, (data) => {
return { return {
+5
View File
@@ -663,6 +663,11 @@ export interface CartLineItemDTO extends CartLineItemTotalsDTO {
*/ */
is_tax_inclusive: boolean is_tax_inclusive: boolean
/**
* Whether the line item price is a custom price.
*/
is_custom_price: boolean
/** /**
* The calculated price of the line item. * The calculated price of the line item.
*/ */
@@ -555,6 +555,11 @@ export interface CreateLineItemDTO {
*/ */
is_tax_inclusive?: boolean is_tax_inclusive?: boolean
/**
* Whether the line item's amount is a custom price.
*/
is_custom_price?: boolean
/** /**
* The calculated price of the line item after applying promotions. * The calculated price of the line item after applying promotions.
*/ */
+1 -1
View File
@@ -13,7 +13,7 @@ import {
export interface CreateCartCreateLineItemDTO { export interface CreateCartCreateLineItemDTO {
quantity: BigNumberInput quantity: BigNumberInput
variant_id: string variant_id?: string
title?: string title?: string
subtitle?: string subtitle?: string
@@ -68,7 +68,7 @@ export function deepFlatMap(
const currentKey = path[0] const currentKey = path[0]
const remainingPath = path.slice(1) const remainingPath = path.slice(1)
if (!isDefined(element[currentKey])) { if (!isDefined(element?.[currentKey])) {
callback({ ...context }) callback({ ...context })
continue continue
} }
@@ -37,23 +37,25 @@ const ShippingMethod = z.object({
amount: BigNumberInput, amount: BigNumberInput,
}) })
const Item = z const Item = z.object({
.object({
title: z.string().nullish(), title: z.string().nullish(),
variant_sku: z.string().nullish(),
variant_barcode: z.string().nullish(),
/**
* Use variant_sku instead
* @deprecated
*/
sku: z.string().nullish(), sku: z.string().nullish(),
/**
* Use variant_barcode instead
* @deprecated
*/
barcode: z.string().nullish(), barcode: z.string().nullish(),
variant_id: z.string().nullish(), variant_id: z.string().nullish(),
unit_price: BigNumberInput.nullish(), unit_price: BigNumberInput.nullish(),
quantity: z.number(), quantity: z.number(),
metadata: z.record(z.unknown()).nullish(), metadata: z.record(z.unknown()).nullish(),
}) })
.refine((data) => {
if (!data.variant_id) {
return data.title && (data.sku || data.barcode)
}
return true
})
export type AdminCreateDraftOrderType = z.infer<typeof CreateDraftOrder> export type AdminCreateDraftOrderType = z.infer<typeof CreateDraftOrder>
const CreateDraftOrder = z const CreateDraftOrder = z
@@ -2512,6 +2512,7 @@ moduleIntegrationTestRunner<ICartModuleService>({
requires_shipping: true, requires_shipping: true,
is_discountable: true, is_discountable: true,
is_tax_inclusive: false, is_tax_inclusive: false,
is_custom_price: false,
raw_compare_at_unit_price: null, raw_compare_at_unit_price: null,
raw_unit_price: { raw_unit_price: {
value: "100", value: "100",
@@ -2617,6 +2618,7 @@ moduleIntegrationTestRunner<ICartModuleService>({
requires_shipping: true, requires_shipping: true,
is_discountable: true, is_discountable: true,
is_tax_inclusive: false, is_tax_inclusive: false,
is_custom_price: false,
raw_compare_at_unit_price: null, raw_compare_at_unit_price: null,
raw_unit_price: { raw_unit_price: {
value: "200", value: "200",
@@ -617,6 +617,16 @@
"default": "false", "default": "false",
"mappedType": "boolean" "mappedType": "boolean"
}, },
"is_custom_price": {
"name": "is_custom_price",
"type": "boolean",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"default": "false",
"mappedType": "boolean"
},
"compare_at_unit_price": { "compare_at_unit_price": {
"name": "compare_at_unit_price", "name": "compare_at_unit_price",
"type": "numeric", "type": "numeric",
@@ -0,0 +1,13 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20241218091938 extends Migration {
async up(): Promise<void> {
this.addSql('alter table if exists "cart_line_item" add column if not exists "is_custom_price" boolean not null default false;');
}
async down(): Promise<void> {
this.addSql('alter table if exists "cart_line_item" drop column if exists "is_custom_price";');
}
}
@@ -1,7 +1,7 @@
import { model } from "@medusajs/framework/utils" import { model } from "@medusajs/framework/utils"
import Cart from "./cart" import Cart from "./cart"
import LineItemTaxLine from "./line-item-tax-line"
import LineItemAdjustment from "./line-item-adjustment" import LineItemAdjustment from "./line-item-adjustment"
import LineItemTaxLine from "./line-item-tax-line"
const LineItem = model const LineItem = model
.define( .define(
@@ -28,6 +28,7 @@ const LineItem = model
requires_shipping: model.boolean().default(true), requires_shipping: model.boolean().default(true),
is_discountable: model.boolean().default(true), is_discountable: model.boolean().default(true),
is_tax_inclusive: model.boolean().default(false), is_tax_inclusive: model.boolean().default(false),
is_custom_price: model.boolean().default(false),
compare_at_unit_price: model.bigNumber().nullable(), compare_at_unit_price: model.bigNumber().nullable(),
unit_price: model.bigNumber(), unit_price: model.bigNumber(),
metadata: model.json().nullable(), metadata: model.json().nullable(),