diff --git a/.changeset/happy-starfishes-grab.md b/.changeset/happy-starfishes-grab.md new file mode 100644 index 0000000000000..5fc878f2967e5 --- /dev/null +++ b/.changeset/happy-starfishes-grab.md @@ -0,0 +1,5 @@ +--- +"@medusajs/utils": patch +--- + +feat(utils): consolidate promotion utils + refactor diff --git a/packages/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts b/packages/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts index 2c1af38820968..30cea95c962d5 100644 --- a/packages/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts +++ b/packages/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts @@ -3424,13 +3424,13 @@ describe("Promotion Service: computeActions", () => { { action: "addShippingMethodAdjustment", shipping_method_id: "shipping_method_express", - amount: 83.33333333333331, + amount: 166.66666666666666, code: "PROMOTION_TEST_2", }, { action: "addShippingMethodAdjustment", shipping_method_id: "shipping_method_standard", - amount: 16.66666666666667, + amount: 33.333333333333336, code: "PROMOTION_TEST_2", }, ]) diff --git a/packages/promotion/src/services/promotion-module.ts b/packages/promotion/src/services/promotion-module.ts index 6df34ea43521b..489a1ccc31709 100644 --- a/packages/promotion/src/services/promotion-module.ts +++ b/packages/promotion/src/services/promotion-module.ts @@ -7,8 +7,10 @@ import { PromotionTypes, } from "@medusajs/types" import { + ApplicationMethodAllocation, ApplicationMethodTargetType, CampaignBudgetType, + ComputedActions, InjectManager, InjectTransactionManager, MedusaContext, @@ -251,6 +253,8 @@ export default class PromotionModuleService< sharedContext ) + // Promotions we need to apply includes all the codes that are passed as an argument + // to this method, along with any automatic promotions that can be applied to the context const automaticPromotionCodes = automaticPromotions.map((p) => p.code!) const promotionCodesToApply = [ ...promotionCodes, @@ -307,17 +311,21 @@ export default class PromotionModuleService< } ) - const appliedCodes = [...appliedShippingCodes, ...appliedItemCodes] - const sortedPermissionsToApply = promotions - .filter((p) => promotionCodesToApply.includes(p.code!)) - .sort(ComputeActionUtils.sortByBuyGetType) - const existingPromotionsMap = new Map( promotions.map((promotion) => [promotion.code!, promotion]) ) + // We look at any existing promo codes applied in the context and recommend + // them to be removed to start calculations from the beginning and refresh + // the adjustments if they are requested to be applied again + const appliedCodes = [...appliedShippingCodes, ...appliedItemCodes] + for (const appliedCode of appliedCodes) { const promotion = existingPromotionsMap.get(appliedCode) + const adjustments = codeAdjustmentMap.get(appliedCode) || [] + const action = appliedShippingCodes.includes(appliedCode) + ? ComputedActions.REMOVE_SHIPPING_METHOD_ADJUSTMENT + : ComputedActions.REMOVE_ITEM_ADJUSTMENT if (!promotion) { throw new MedusaError( @@ -326,31 +334,21 @@ export default class PromotionModuleService< ) } - if (appliedItemCodes.includes(appliedCode)) { - const adjustments = codeAdjustmentMap.get(appliedCode) || [] - - adjustments.forEach((adjustment) => - computedActions.push({ - action: "removeItemAdjustment", - adjustment_id: adjustment.id, - code: appliedCode, - }) - ) - } - - if (appliedShippingCodes.includes(appliedCode)) { - const adjustments = codeAdjustmentMap.get(appliedCode) || [] - - adjustments.forEach((adjustment) => - computedActions.push({ - action: "removeShippingMethodAdjustment", - adjustment_id: adjustment.id, - code: appliedCode, - }) - ) - } + adjustments.forEach((adjustment) => + computedActions.push({ + action, + adjustment_id: adjustment.id, + code: appliedCode, + }) + ) } + // We sort the promo codes to apply with buy get type first as they + // are likely to be most valuable. + const sortedPermissionsToApply = promotions + .filter((p) => promotionCodesToApply.includes(p.code!)) + .sort(ComputeActionUtils.sortByBuyGetType) + for (const promotionToApply of sortedPermissionsToApply) { const promotion = existingPromotionsMap.get(promotionToApply.code!)! @@ -384,36 +382,30 @@ export default class PromotionModuleService< } if (promotion.type === PromotionType.STANDARD) { - if ( + const isTargetOrder = applicationMethod.target_type === ApplicationMethodTargetType.ORDER - ) { - const computedActionsForItems = - ComputeActionUtils.getComputedActionsForOrder( - promotion, - applicationContext, - methodIdPromoValueMap - ) - - computedActions.push(...computedActionsForItems) - } - - if ( + const isTargetItems = applicationMethod.target_type === ApplicationMethodTargetType.ITEMS - ) { + const isTargetShipping = + applicationMethod.target_type === + ApplicationMethodTargetType.SHIPPING_METHODS + const allocationOverride = isTargetOrder + ? ApplicationMethodAllocation.ACROSS + : undefined + + if (isTargetOrder || isTargetItems) { const computedActionsForItems = ComputeActionUtils.getComputedActionsForItems( promotion, applicationContext[ApplicationMethodTargetType.ITEMS], - methodIdPromoValueMap + methodIdPromoValueMap, + allocationOverride ) computedActions.push(...computedActionsForItems) } - if ( - applicationMethod.target_type === - ApplicationMethodTargetType.SHIPPING_METHODS - ) { + if (isTargetShipping) { const computedActionsForShippingMethods = ComputeActionUtils.getComputedActionsForShippingMethods( promotion, diff --git a/packages/promotion/src/utils/compute-actions/index.ts b/packages/promotion/src/utils/compute-actions/index.ts index de8317a075ca0..ccf7494ca65f6 100644 --- a/packages/promotion/src/utils/compute-actions/index.ts +++ b/packages/promotion/src/utils/compute-actions/index.ts @@ -1,5 +1,3 @@ export * from "./buy-get" -export * from "./items" -export * from "./order" -export * from "./shipping-methods" +export * from "./line-items" export * from "./usage" diff --git a/packages/promotion/src/utils/compute-actions/items.ts b/packages/promotion/src/utils/compute-actions/items.ts deleted file mode 100644 index 77b55c8634797..0000000000000 --- a/packages/promotion/src/utils/compute-actions/items.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { - ApplicationMethodAllocationValues, - PromotionTypes, -} from "@medusajs/types" -import { - ApplicationMethodAllocation, - ApplicationMethodTargetType, - ApplicationMethodType, - ComputedActions, - MedusaError, -} from "@medusajs/utils" -import { areRulesValidForContext } from "../validations" -import { computeActionForBudgetExceeded } from "./usage" - -export function getComputedActionsForItems( - promotion: PromotionTypes.PromotionDTO, - itemApplicationContext: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.ITEMS], - methodIdPromoValueMap: Map, - allocationOverride?: ApplicationMethodAllocationValues -): PromotionTypes.ComputeActions[] { - const applicableItems: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.ITEMS] = - [] - - if (!itemApplicationContext) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `"items" should be present as an array in the context for computeActions` - ) - } - - for (const itemContext of itemApplicationContext) { - const isPromotionApplicableToItem = areRulesValidForContext( - promotion?.application_method?.target_rules!, - itemContext - ) - - if (!isPromotionApplicableToItem) { - continue - } - - applicableItems.push(itemContext) - } - - return applyPromotionToItems( - promotion, - applicableItems, - methodIdPromoValueMap, - allocationOverride - ) -} - -// TODO: calculations should eventually move to a totals util outside of the module -export function applyPromotionToItems( - promotion: PromotionTypes.PromotionDTO, - items: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.ITEMS], - methodIdPromoValueMap: Map, - allocationOverride?: ApplicationMethodAllocationValues -): PromotionTypes.ComputeActions[] { - const { application_method: applicationMethod } = promotion - const allocation = applicationMethod?.allocation! - const computedActions: PromotionTypes.ComputeActions[] = [] - - if ( - [allocation, allocationOverride].includes(ApplicationMethodAllocation.EACH) - ) { - for (const method of items!) { - if (!method.subtotal || !method.quantity) { - continue - } - - const appliedPromoValue = methodIdPromoValueMap.get(method.id) ?? 0 - const quantityMultiplier = Math.min( - method.quantity, - applicationMethod?.max_quantity! - ) - const totalItemValue = - (method.subtotal / method.quantity) * quantityMultiplier - let promotionValue = applicationMethod?.value ?? 0 - const applicableTotal = totalItemValue - appliedPromoValue - if (applicationMethod?.type === ApplicationMethodType.PERCENTAGE) { - promotionValue = (promotionValue / 100) * applicableTotal - } - - const amount = Math.min(promotionValue, applicableTotal) - - if (amount <= 0) { - continue - } - - const budgetExceededAction = computeActionForBudgetExceeded( - promotion, - amount - ) - - if (budgetExceededAction) { - computedActions.push(budgetExceededAction) - - continue - } - - methodIdPromoValueMap.set(method.id, appliedPromoValue + amount) - - computedActions.push({ - action: ComputedActions.ADD_ITEM_ADJUSTMENT, - item_id: method.id, - amount, - code: promotion.code!, - }) - } - } - - if ( - [allocation, allocationOverride].includes( - ApplicationMethodAllocation.ACROSS - ) - ) { - const totalApplicableValue = items!.reduce((acc, method) => { - const appliedPromoValue = methodIdPromoValueMap.get(method.id) ?? 0 - const perItemCost = method.subtotal - ? method.subtotal / method.quantity - : 0 - - return acc + perItemCost * method.quantity - appliedPromoValue - }, 0) - - for (const method of items!) { - if (!method.subtotal || !method.quantity) { - continue - } - - const appliedPromoValue = methodIdPromoValueMap.get(method.id) ?? 0 - const promotionValue = applicationMethod?.value ?? 0 - const applicableTotal = - (method.subtotal / method.quantity) * method.quantity - - appliedPromoValue - - if (applicableTotal <= 0) { - continue - } - - // TODO: should we worry about precision here? - let applicablePromotionValue = - (applicableTotal / totalApplicableValue) * promotionValue - - if (applicationMethod?.type === ApplicationMethodType.PERCENTAGE) { - applicablePromotionValue = (promotionValue / 100) * applicableTotal - } - - const amount = Math.min(applicablePromotionValue, applicableTotal) - - if (amount <= 0) { - continue - } - - const budgetExceededAction = computeActionForBudgetExceeded( - promotion, - amount - ) - - if (budgetExceededAction) { - computedActions.push(budgetExceededAction) - - continue - } - - methodIdPromoValueMap.set(method.id, appliedPromoValue + amount) - - computedActions.push({ - action: ComputedActions.ADD_ITEM_ADJUSTMENT, - item_id: method.id, - amount, - code: promotion.code!, - }) - } - } - - return computedActions -} diff --git a/packages/promotion/src/utils/compute-actions/line-items.ts b/packages/promotion/src/utils/compute-actions/line-items.ts new file mode 100644 index 0000000000000..1ed3be14297bc --- /dev/null +++ b/packages/promotion/src/utils/compute-actions/line-items.ts @@ -0,0 +1,180 @@ +import { + ApplicationMethodAllocationValues, + PromotionTypes, +} from "@medusajs/types" +import { + ApplicationMethodAllocation, + ComputedActions, + MedusaError, + ApplicationMethodTargetType as TargetType, + calculateAdjustmentAmountFromPromotion, +} from "@medusajs/utils" +import { areRulesValidForContext } from "../validations" +import { computeActionForBudgetExceeded } from "./usage" + +function validateContext( + contextKey: string, + context: PromotionTypes.ComputeActionContext[TargetType] +) { + if (!context) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `"${contextKey}" should be present as an array in the context for computeActions` + ) + } +} + +export function getComputedActionsForItems( + promotion: PromotionTypes.PromotionDTO, + items: PromotionTypes.ComputeActionContext[TargetType.ITEMS], + appliedPromotionsMap: Map, + allocationOverride?: ApplicationMethodAllocationValues +): PromotionTypes.ComputeActions[] { + validateContext("items", items) + + return applyPromotionToItems( + promotion, + items, + appliedPromotionsMap, + allocationOverride + ) +} + +export function getComputedActionsForShippingMethods( + promotion: PromotionTypes.PromotionDTO, + shippingMethods: PromotionTypes.ComputeActionContext[TargetType.SHIPPING_METHODS], + appliedPromotionsMap: Map +): PromotionTypes.ComputeActions[] { + validateContext("shipping_methods", shippingMethods) + + return applyPromotionToItems(promotion, shippingMethods, appliedPromotionsMap) +} + +export function getComputedActionsForOrder( + promotion: PromotionTypes.PromotionDTO, + itemApplicationContext: PromotionTypes.ComputeActionContext, + methodIdPromoValueMap: Map +): PromotionTypes.ComputeActions[] { + return getComputedActionsForItems( + promotion, + itemApplicationContext[TargetType.ITEMS], + methodIdPromoValueMap, + ApplicationMethodAllocation.ACROSS + ) +} + +function applyPromotionToItems( + promotion: PromotionTypes.PromotionDTO, + items: + | PromotionTypes.ComputeActionContext[TargetType.ITEMS] + | PromotionTypes.ComputeActionContext[TargetType.SHIPPING_METHODS], + appliedPromotionsMap: Map, + allocationOverride?: ApplicationMethodAllocationValues +): PromotionTypes.ComputeActions[] { + const { application_method: applicationMethod } = promotion + const allocation = applicationMethod?.allocation! || allocationOverride + const computedActions: PromotionTypes.ComputeActions[] = [] + const applicableItems = getValidItemsForPromotion(items, promotion) + const target = applicationMethod?.target_type + + const isTargetShippingMethod = target === TargetType.SHIPPING_METHODS + const isTargetLineItems = target === TargetType.ITEMS + const isTargetOrder = target === TargetType.ORDER + + let lineItemsTotal = 0 + + if (allocation === ApplicationMethodAllocation.ACROSS) { + lineItemsTotal = applicableItems.reduce( + (acc, item) => + acc + item.subtotal - (appliedPromotionsMap.get(item.id) ?? 0), + 0 + ) + } + + for (const item of applicableItems!) { + const appliedPromoValue = appliedPromotionsMap.get(item.id) ?? 0 + const maxQuantity = isTargetShippingMethod + ? 1 + : applicationMethod?.max_quantity! + + if (isTargetShippingMethod) { + item.quantity = 1 + } + + const amount = calculateAdjustmentAmountFromPromotion( + item, + { + value: applicationMethod?.value ?? 0, + applied_value: appliedPromoValue, + max_quantity: maxQuantity, + type: applicationMethod?.type!, + allocation, + }, + lineItemsTotal + ) + + if (amount <= 0) { + continue + } + + const budgetExceededAction = computeActionForBudgetExceeded( + promotion, + amount + ) + + if (budgetExceededAction) { + computedActions.push(budgetExceededAction) + + continue + } + + appliedPromotionsMap.set(item.id, appliedPromoValue + amount) + + if (isTargetLineItems || isTargetOrder) { + computedActions.push({ + action: ComputedActions.ADD_ITEM_ADJUSTMENT, + item_id: item.id, + amount, + code: promotion.code!, + }) + } + + if (isTargetShippingMethod) { + computedActions.push({ + action: ComputedActions.ADD_SHIPPING_METHOD_ADJUSTMENT, + shipping_method_id: item.id, + amount, + code: promotion.code!, + }) + } + } + + return computedActions +} + +function getValidItemsForPromotion( + items: + | PromotionTypes.ComputeActionContext[TargetType.ITEMS] + | PromotionTypes.ComputeActionContext[TargetType.SHIPPING_METHODS], + promotion: PromotionTypes.PromotionDTO +) { + const isTargetShippingMethod = + promotion.application_method?.target_type === TargetType.SHIPPING_METHODS + + return ( + items?.filter((item) => { + const isSubtotalPresent = "subtotal" in item + const isQuantityPresent = "quantity" in item + const isPromotionApplicableToItem = areRulesValidForContext( + promotion?.application_method?.target_rules!, + item + ) + + return ( + isPromotionApplicableToItem && + (isQuantityPresent || isTargetShippingMethod) && + isSubtotalPresent + ) + }) || [] + ) +} diff --git a/packages/promotion/src/utils/compute-actions/order.ts b/packages/promotion/src/utils/compute-actions/order.ts deleted file mode 100644 index c48b6b318a4be..0000000000000 --- a/packages/promotion/src/utils/compute-actions/order.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { PromotionTypes } from "@medusajs/types" -import { - ApplicationMethodAllocation, - ApplicationMethodTargetType, -} from "@medusajs/utils" -import { getComputedActionsForItems } from "./items" - -export function getComputedActionsForOrder( - promotion: PromotionTypes.PromotionDTO, - itemApplicationContext: PromotionTypes.ComputeActionContext, - methodIdPromoValueMap: Map -): PromotionTypes.ComputeActions[] { - return getComputedActionsForItems( - promotion, - itemApplicationContext[ApplicationMethodTargetType.ITEMS], - methodIdPromoValueMap, - ApplicationMethodAllocation.ACROSS - ) -} diff --git a/packages/utils/src/totals/index.ts b/packages/utils/src/totals/index.ts index 260851b0b2aa5..32969d2385c5f 100644 --- a/packages/utils/src/totals/index.ts +++ b/packages/utils/src/totals/index.ts @@ -7,6 +7,8 @@ import { BigNumber as BigNumberJs } from "bignumber.js" import { BigNumber } from "./big-number" import { toBigNumberJs } from "./to-big-number-js" +export * from "./promotion" + type GetLineItemTotalsContext = { includeTax?: boolean taxRate?: number | null diff --git a/packages/utils/src/totals/promotion/index.ts b/packages/utils/src/totals/promotion/index.ts new file mode 100644 index 0000000000000..38116e93781fe --- /dev/null +++ b/packages/utils/src/totals/promotion/index.ts @@ -0,0 +1 @@ +export * from "./totals" diff --git a/packages/utils/src/totals/promotion/totals.ts b/packages/utils/src/totals/promotion/totals.ts new file mode 100644 index 0000000000000..df24544819182 --- /dev/null +++ b/packages/utils/src/totals/promotion/totals.ts @@ -0,0 +1,58 @@ +import { + ApplicationMethodAllocation, + ApplicationMethodType, +} from "../../promotion" + +function getPromotionValueForPercentage(promotion, lineItemTotal) { + return (promotion.value / 100) * lineItemTotal +} + +function getPromotionValueForFixed(promotion, lineItemTotal, lineItemsTotal) { + if (promotion.allocation === ApplicationMethodAllocation.ACROSS) { + return (lineItemTotal / lineItemsTotal) * promotion.value + } + + return promotion.value +} + +export function getPromotionValue(promotion, lineItemTotal, lineItemsTotal) { + if (promotion.type === ApplicationMethodType.PERCENTAGE) { + return getPromotionValueForPercentage(promotion, lineItemTotal) + } + + return getPromotionValueForFixed(promotion, lineItemTotal, lineItemsTotal) +} + +export function getApplicableQuantity(lineItem, maxQuantity) { + if (maxQuantity && lineItem.quantity) { + return Math.min(lineItem.quantity, maxQuantity) + } + + return lineItem.quantity +} + +function getLineItemUnitPrice(lineItem) { + return lineItem.subtotal / lineItem.quantity +} + +export function calculateAdjustmentAmountFromPromotion( + lineItem, + promotion, + lineItemsTotal = 0 +) { + const quantity = getApplicableQuantity(lineItem, promotion.max_quantity) + const lineItemTotal = getLineItemUnitPrice(lineItem) * quantity + const applicableTotal = lineItemTotal - promotion.applied_value + + if (applicableTotal <= 0) { + return applicableTotal + } + + const promotionValue = getPromotionValue( + promotion, + applicableTotal, + lineItemsTotal + ) + + return Math.min(promotionValue, applicableTotal) +}