feat(utils): consolidate promotion utils + refactor + fixes (#6531)

This commit is contained in:
Riqwan Thamir
2024-02-29 12:52:46 +00:00
committed by GitHub
parent 860d56041a
commit 0b9fcb6324
10 changed files with 288 additions and 249 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@medusajs/utils": patch
---
feat(utils): consolidate promotion utils + refactor
@@ -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",
},
])
@@ -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<string, PromotionTypes.PromotionDTO>(
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,
@@ -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"
@@ -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<string, number>,
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<string, number>,
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
}
@@ -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<string, number>,
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<string, number>
): PromotionTypes.ComputeActions[] {
validateContext("shipping_methods", shippingMethods)
return applyPromotionToItems(promotion, shippingMethods, appliedPromotionsMap)
}
export function getComputedActionsForOrder(
promotion: PromotionTypes.PromotionDTO,
itemApplicationContext: PromotionTypes.ComputeActionContext,
methodIdPromoValueMap: Map<string, number>
): 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<string, number>,
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
)
}) || []
)
}
@@ -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<string, number>
): PromotionTypes.ComputeActions[] {
return getComputedActionsForItems(
promotion,
itemApplicationContext[ApplicationMethodTargetType.ITEMS],
methodIdPromoValueMap,
ApplicationMethodAllocation.ACROSS
)
}
+2
View File
@@ -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
@@ -0,0 +1 @@
export * from "./totals"
@@ -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)
}