feat(promotion): Allow buyget promotion to apply multiple times on cart (#13305)
what: Introduces 2 new features to promotion module: 1. Introduce max quantity limit to promotion application - This will limit the application of the promotion based on the quantity of the target products in the cart. 2. When applying buy get promotions, we will now apply buyget promotion until eligible items are exhausted or max quantity is reached. ``` - Buy 2 t-shirts, Get 1 sweater - Max quantity -> 1 This means you can add two t-shirts, and get 1 sweaters for free. However, if you add four t-shirts, you only get one sweater for free. ``` ``` - Buy 2 t-shirts, Get 1 sweater - Max quantity -> 3 This means you can add six t-shirts, and get three sweaters for free. However, if you add eight t-shirts, you only get three sweaters for free ``` ``` - Buy 4 t-shirts, Get 2 sweater - Max quantity -> 1 This should throw on creation, as the max quantity should as a minimum be the same value as the target rule quantity ``` RESOLVES SUP-2357 / https://github.com/medusajs/medusa/issues/13265
This commit is contained in:
@@ -22,6 +22,420 @@ function sortByPrice(a: ComputeActionItemLine, b: ComputeActionItemLine) {
|
||||
return MathBN.lt(a.subtotal, b.subtotal) ? 1 : -1
|
||||
}
|
||||
|
||||
function isValidPromotionContext(
|
||||
promotion: PromotionTypes.PromotionDTO,
|
||||
itemsContext: ComputeActionItemLine[]
|
||||
): boolean {
|
||||
if (!itemsContext) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`"items" should be present as an array in the context to compute actions`
|
||||
)
|
||||
}
|
||||
|
||||
if (!itemsContext?.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
const minimumBuyQuantity = MathBN.convert(
|
||||
promotion.application_method?.buy_rules_min_quantity ?? 0
|
||||
)
|
||||
|
||||
if (
|
||||
MathBN.lte(minimumBuyQuantity, 0) ||
|
||||
!promotion.application_method?.buy_rules?.length
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function normalizePromotionApplicationConfiguration(
|
||||
promotion: PromotionTypes.PromotionDTO
|
||||
) {
|
||||
const minimumBuyQuantity = MathBN.convert(
|
||||
promotion.application_method?.buy_rules_min_quantity ?? 0
|
||||
)
|
||||
const targetApplyQuantity = MathBN.convert(
|
||||
promotion.application_method?.apply_to_quantity ?? 0
|
||||
)
|
||||
const maximumApplyQuantity = MathBN.convert(
|
||||
promotion.application_method?.max_quantity ?? 1
|
||||
)
|
||||
const applicablePercentage = promotion.application_method?.value ?? 100
|
||||
|
||||
return {
|
||||
minimumBuyQuantity,
|
||||
targetApplyQuantity,
|
||||
maximumApplyQuantity,
|
||||
applicablePercentage,
|
||||
}
|
||||
}
|
||||
|
||||
function calculateRemainingQuantities(
|
||||
eligibleItems: ComputeActionItemLine[],
|
||||
itemsMap: Map<string, EligibleItem[]>,
|
||||
currentPromotionCode: string
|
||||
): Map<string, BigNumberInput> {
|
||||
const remainingQuantities = new Map<string, BigNumberInput>()
|
||||
|
||||
for (const item of eligibleItems) {
|
||||
let consumedByOtherPromotions = MathBN.convert(0)
|
||||
|
||||
for (const [code, eligibleItems] of itemsMap) {
|
||||
if (code === currentPromotionCode) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const eligibleItem of eligibleItems) {
|
||||
if (eligibleItem.item_id === item.id) {
|
||||
consumedByOtherPromotions = MathBN.add(
|
||||
consumedByOtherPromotions,
|
||||
eligibleItem.quantity
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = MathBN.sub(item.quantity, consumedByOtherPromotions)
|
||||
remainingQuantities.set(item.id, MathBN.max(remaining, 0))
|
||||
}
|
||||
|
||||
return remainingQuantities
|
||||
}
|
||||
|
||||
type PromotionConfig = {
|
||||
minimumBuyQuantity: BigNumberInput
|
||||
targetApplyQuantity: BigNumberInput
|
||||
maximumApplyQuantity: BigNumberInput
|
||||
applicablePercentage: number
|
||||
}
|
||||
|
||||
type PromotionApplication = {
|
||||
buyItems: EligibleItem[]
|
||||
targetItems: EligibleItem[]
|
||||
isValid: boolean
|
||||
}
|
||||
|
||||
/*
|
||||
Determines which buy and target items should be used for a promotion application.
|
||||
|
||||
We run the following steps to prepare the promotion application state to be used within an application loop:
|
||||
1. Selecting enough buy items to satisfy the minimum buy quantity requirement from the remaining buy quantities
|
||||
2. Identifying target eligible items for application (excluding those used in buy rules) from the remaining target quantities
|
||||
3. Ensuring the application doesn't exceed max_quantity limits for the target items
|
||||
4. Returns a valid application state or marks it invalid if requirements can't be met
|
||||
*/
|
||||
function preparePromotionApplicationState(
|
||||
eligibleBuyItems: ComputeActionItemLine[],
|
||||
eligibleTargetItems: ComputeActionItemLine[],
|
||||
remainingBuyQuantities: Map<string, BigNumberInput>,
|
||||
remainingTargetQuantities: Map<string, BigNumberInput>,
|
||||
applicationConfig: PromotionConfig,
|
||||
appliedPromotionQuantity: BigNumberInput
|
||||
): PromotionApplication {
|
||||
const totalRemainingBuyQuantity = MathBN.sum(
|
||||
...Array.from(remainingBuyQuantities.values())
|
||||
)
|
||||
|
||||
if (
|
||||
MathBN.lt(totalRemainingBuyQuantity, applicationConfig.minimumBuyQuantity)
|
||||
) {
|
||||
return { buyItems: [], targetItems: [], isValid: false }
|
||||
}
|
||||
|
||||
const eligibleItemsByPromotion: EligibleItem[] = []
|
||||
let accumulatedQuantity = MathBN.convert(0)
|
||||
|
||||
for (const eligibleBuyItem of eligibleBuyItems) {
|
||||
if (MathBN.gte(accumulatedQuantity, applicationConfig.minimumBuyQuantity)) {
|
||||
break
|
||||
}
|
||||
|
||||
const availableQuantity =
|
||||
remainingBuyQuantities.get(eligibleBuyItem.id) || MathBN.convert(0)
|
||||
|
||||
if (MathBN.lte(availableQuantity, 0)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const reservableQuantity = MathBN.min(
|
||||
availableQuantity,
|
||||
MathBN.sub(applicationConfig.minimumBuyQuantity, accumulatedQuantity)
|
||||
)
|
||||
|
||||
if (MathBN.lte(reservableQuantity, 0)) {
|
||||
continue
|
||||
}
|
||||
|
||||
eligibleItemsByPromotion.push({
|
||||
item_id: eligibleBuyItem.id,
|
||||
quantity: reservableQuantity.toNumber(),
|
||||
})
|
||||
|
||||
accumulatedQuantity = MathBN.add(accumulatedQuantity, reservableQuantity)
|
||||
}
|
||||
|
||||
if (MathBN.lt(accumulatedQuantity, applicationConfig.minimumBuyQuantity)) {
|
||||
return { buyItems: [], targetItems: [], isValid: false }
|
||||
}
|
||||
|
||||
const quantitiesUsedInBuyRules = new Map<string, BigNumberInput>()
|
||||
|
||||
for (const buyItem of eligibleItemsByPromotion) {
|
||||
const currentValue =
|
||||
quantitiesUsedInBuyRules.get(buyItem.item_id) || MathBN.convert(0)
|
||||
|
||||
quantitiesUsedInBuyRules.set(
|
||||
buyItem.item_id,
|
||||
MathBN.add(currentValue, buyItem.quantity)
|
||||
)
|
||||
}
|
||||
|
||||
const targetItemsByPromotion: EligibleItem[] = []
|
||||
let availableTargetQuantity = MathBN.convert(0)
|
||||
|
||||
for (const eligibleTargetItem of eligibleTargetItems) {
|
||||
const availableTargetQuantityForItem =
|
||||
remainingTargetQuantities.get(eligibleTargetItem.id) || MathBN.convert(0)
|
||||
|
||||
const quantityUsedInBuyRules =
|
||||
quantitiesUsedInBuyRules.get(eligibleTargetItem.id) || MathBN.convert(0)
|
||||
|
||||
const applicableQuantity = MathBN.sub(
|
||||
availableTargetQuantityForItem,
|
||||
quantityUsedInBuyRules
|
||||
)
|
||||
|
||||
if (MathBN.lte(applicableQuantity, 0)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const remainingNeeded = MathBN.sub(
|
||||
applicationConfig.targetApplyQuantity,
|
||||
availableTargetQuantity
|
||||
)
|
||||
|
||||
const remainingMaxQuantityAllowance = MathBN.sub(
|
||||
applicationConfig.maximumApplyQuantity,
|
||||
appliedPromotionQuantity
|
||||
)
|
||||
|
||||
const fulfillableQuantity = MathBN.min(
|
||||
remainingNeeded,
|
||||
applicableQuantity,
|
||||
remainingMaxQuantityAllowance
|
||||
)
|
||||
|
||||
if (MathBN.lte(fulfillableQuantity, 0)) {
|
||||
continue
|
||||
}
|
||||
|
||||
targetItemsByPromotion.push({
|
||||
item_id: eligibleTargetItem.id,
|
||||
quantity: fulfillableQuantity.toNumber(),
|
||||
})
|
||||
|
||||
availableTargetQuantity = MathBN.add(
|
||||
availableTargetQuantity,
|
||||
fulfillableQuantity
|
||||
)
|
||||
|
||||
if (
|
||||
MathBN.gte(availableTargetQuantity, applicationConfig.targetApplyQuantity)
|
||||
) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const isValid = MathBN.gte(
|
||||
availableTargetQuantity,
|
||||
applicationConfig.targetApplyQuantity
|
||||
)
|
||||
|
||||
return {
|
||||
buyItems: eligibleItemsByPromotion,
|
||||
targetItems: targetItemsByPromotion,
|
||||
isValid,
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Applies promotion to the target items selected by preparePromotionApplicationState.
|
||||
|
||||
This function performs the application by:
|
||||
1. Calculating promotion amounts based on item prices and promotion percentage
|
||||
2. Checking promotion budget limits to prevent overspending
|
||||
3. Updating promotional value tracking maps for cross-promotion coordination
|
||||
4. Accumulating total promotion amounts per item across all applications
|
||||
5. Returns computed actions
|
||||
*/
|
||||
function applyPromotionToTargetItems(
|
||||
targetItems: EligibleItem[],
|
||||
itemIdPromotionAmountMap: Map<string, BigNumberInput>,
|
||||
methodIdPromoValueMap: Map<string, BigNumberInput>,
|
||||
promotion: PromotionTypes.PromotionDTO,
|
||||
itemsMap: Map<string, ComputeActionItemLine>,
|
||||
applicationConfig: PromotionConfig
|
||||
): {
|
||||
computedActions: PromotionTypes.ComputeActions[]
|
||||
appliedPromotionQuantity: BigNumberInput
|
||||
} {
|
||||
const computedActions: PromotionTypes.ComputeActions[] = []
|
||||
let appliedPromotionQuantity = MathBN.convert(0)
|
||||
let remainingQtyToApply = MathBN.convert(
|
||||
applicationConfig.targetApplyQuantity
|
||||
)
|
||||
|
||||
for (const targetItem of targetItems) {
|
||||
if (MathBN.lte(remainingQtyToApply, 0)) {
|
||||
break
|
||||
}
|
||||
|
||||
const item = itemsMap.get(targetItem.item_id)!
|
||||
const appliedPromoValue =
|
||||
methodIdPromoValueMap.get(item.id) ?? MathBN.convert(0)
|
||||
const multiplier = MathBN.min(targetItem.quantity, remainingQtyToApply)
|
||||
const pricePerUnit = MathBN.div(item.subtotal, item.quantity)
|
||||
const applicableAmount = MathBN.mult(pricePerUnit, multiplier)
|
||||
const amount = MathBN.mult(
|
||||
applicableAmount,
|
||||
applicationConfig.applicablePercentage
|
||||
).div(100)
|
||||
|
||||
if (MathBN.lte(amount, 0)) {
|
||||
continue
|
||||
}
|
||||
|
||||
remainingQtyToApply = MathBN.sub(remainingQtyToApply, multiplier)
|
||||
|
||||
const budgetExceededAction = computeActionForBudgetExceeded(
|
||||
promotion,
|
||||
amount
|
||||
)
|
||||
|
||||
if (budgetExceededAction) {
|
||||
computedActions.push(budgetExceededAction)
|
||||
continue
|
||||
}
|
||||
|
||||
methodIdPromoValueMap.set(
|
||||
item.id,
|
||||
MathBN.add(appliedPromoValue, amount).toNumber()
|
||||
)
|
||||
|
||||
const currentPromotionAmount =
|
||||
itemIdPromotionAmountMap.get(item.id) ?? MathBN.convert(0)
|
||||
|
||||
itemIdPromotionAmountMap.set(
|
||||
item.id,
|
||||
MathBN.add(currentPromotionAmount, amount)
|
||||
)
|
||||
|
||||
appliedPromotionQuantity = MathBN.add(appliedPromotionQuantity, multiplier)
|
||||
}
|
||||
|
||||
return { computedActions, appliedPromotionQuantity }
|
||||
}
|
||||
|
||||
/*
|
||||
Updates the remaining quantities of the eligible items (buy and target) based on the application.
|
||||
This is used to prevent double-usage of the same item in the next iteration of the
|
||||
application loop.
|
||||
|
||||
We track the total consumed quantities per item to handle buy+target scenarios of the same item.
|
||||
*/
|
||||
function updateEligibleItemQuantities(
|
||||
remainingBuyQuantities: Map<string, BigNumberInput>,
|
||||
remainingTargetQuantities: Map<string, BigNumberInput>,
|
||||
application: PromotionApplication
|
||||
): void {
|
||||
const totalConsumedQuantities = new Map<string, BigNumberInput>()
|
||||
|
||||
for (const buyItem of application.buyItems) {
|
||||
const currentConsumed =
|
||||
totalConsumedQuantities.get(buyItem.item_id) || MathBN.convert(0)
|
||||
|
||||
totalConsumedQuantities.set(
|
||||
buyItem.item_id,
|
||||
MathBN.add(currentConsumed, buyItem.quantity)
|
||||
)
|
||||
}
|
||||
|
||||
for (const targetItem of application.targetItems) {
|
||||
const currentConsumed =
|
||||
totalConsumedQuantities.get(targetItem.item_id) || MathBN.convert(0)
|
||||
|
||||
totalConsumedQuantities.set(
|
||||
targetItem.item_id,
|
||||
MathBN.add(currentConsumed, targetItem.quantity)
|
||||
)
|
||||
}
|
||||
|
||||
// Update remaining quantities of buy and target items based on totalConsumedQuantities tracked from previous iterations
|
||||
for (const [itemId, consumedQuantity] of totalConsumedQuantities) {
|
||||
if (remainingBuyQuantities.has(itemId)) {
|
||||
const currentBuyRemaining =
|
||||
remainingBuyQuantities.get(itemId) || MathBN.convert(0)
|
||||
|
||||
remainingBuyQuantities.set(
|
||||
itemId,
|
||||
MathBN.sub(currentBuyRemaining, consumedQuantity)
|
||||
)
|
||||
}
|
||||
|
||||
if (remainingTargetQuantities.has(itemId)) {
|
||||
const currentTargetRemaining =
|
||||
remainingTargetQuantities.get(itemId) || MathBN.convert(0)
|
||||
|
||||
remainingTargetQuantities.set(
|
||||
itemId,
|
||||
MathBN.sub(currentTargetRemaining, consumedQuantity)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateEligibleItems(
|
||||
totalEligibleItemsMap: Map<string, EligibleItem>,
|
||||
applicationItems: EligibleItem[]
|
||||
): void {
|
||||
for (const item of applicationItems) {
|
||||
const existingItem = totalEligibleItemsMap.get(item.item_id)
|
||||
|
||||
// If the item already exists, we add the quantity to the existing item
|
||||
if (existingItem) {
|
||||
existingItem.quantity = MathBN.add(
|
||||
existingItem.quantity,
|
||||
item.quantity
|
||||
).toNumber()
|
||||
} else {
|
||||
totalEligibleItemsMap.set(item.item_id, { ...item })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createComputedActionsFromPromotionApplication(
|
||||
itemIdPromotionAmountMap: Map<string, BigNumberInput>,
|
||||
promotionCode: string
|
||||
): PromotionTypes.ComputeActions[] {
|
||||
const computedActions: PromotionTypes.ComputeActions[] = []
|
||||
|
||||
for (const [itemId, totalAmount] of itemIdPromotionAmountMap) {
|
||||
if (MathBN.gt(totalAmount, 0)) {
|
||||
computedActions.push({
|
||||
action: ComputedActions.ADD_ITEM_ADJUSTMENT,
|
||||
item_id: itemId,
|
||||
amount: totalAmount,
|
||||
code: promotionCode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return computedActions
|
||||
}
|
||||
|
||||
/*
|
||||
Grabs all the items in the context where the rules apply
|
||||
We then sort by price to prioritize most valuable item
|
||||
@@ -48,233 +462,139 @@ export function getComputedActionsForBuyGet(
|
||||
eligibleBuyItemMap: Map<string, EligibleItem[]>,
|
||||
eligibleTargetItemMap: Map<string, EligibleItem[]>
|
||||
): PromotionTypes.ComputeActions[] {
|
||||
const computedActions: PromotionTypes.ComputeActions[] = []
|
||||
|
||||
if (!itemsContext) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`"items" should be present as an array in the context to compute actions`
|
||||
)
|
||||
if (!isValidPromotionContext(promotion, itemsContext)) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (!itemsContext?.length) {
|
||||
return computedActions
|
||||
}
|
||||
|
||||
const minimumBuyQuantity = MathBN.convert(
|
||||
promotion.application_method?.buy_rules_min_quantity ?? 0
|
||||
)
|
||||
const applicationConfig =
|
||||
normalizePromotionApplicationConfiguration(promotion)
|
||||
|
||||
const itemsMap = new Map<string, ComputeActionItemLine>(
|
||||
itemsContext.map((i) => [i.id, i])
|
||||
)
|
||||
|
||||
if (
|
||||
MathBN.lte(minimumBuyQuantity, 0) ||
|
||||
!promotion.application_method?.buy_rules?.length
|
||||
) {
|
||||
return computedActions
|
||||
}
|
||||
|
||||
const eligibleBuyItems = filterItemsByPromotionRules(
|
||||
itemsContext,
|
||||
promotion.application_method?.buy_rules
|
||||
)
|
||||
|
||||
if (!eligibleBuyItems.length) {
|
||||
return computedActions
|
||||
}
|
||||
|
||||
const eligibleBuyItemQuantity = MathBN.sum(
|
||||
...eligibleBuyItems.map((item) => item.quantity)
|
||||
)
|
||||
|
||||
/*
|
||||
Get the total quantity of items where buy rules apply. If the total sum of eligible items
|
||||
does not match up to the minimum buy quantity set on the promotion, return early.
|
||||
*/
|
||||
if (MathBN.gt(minimumBuyQuantity, eligibleBuyItemQuantity)) {
|
||||
return computedActions
|
||||
}
|
||||
|
||||
const eligibleItemsByPromotion: EligibleItem[] = []
|
||||
let accumulatedQuantity = MathBN.convert(0)
|
||||
|
||||
/*
|
||||
Eligibility of a BuyGet promotion can span across line items. Once an item has been chosen
|
||||
as eligible, we can't use this item or its partial remaining quantity when we apply the promotion on
|
||||
the target item.
|
||||
|
||||
We build the map here to use when we apply promotions on the target items.
|
||||
*/
|
||||
|
||||
for (const eligibleBuyItem of eligibleBuyItems) {
|
||||
if (MathBN.gte(accumulatedQuantity, minimumBuyQuantity)) {
|
||||
break
|
||||
}
|
||||
|
||||
const reservableQuantity = MathBN.min(
|
||||
eligibleBuyItem.quantity,
|
||||
MathBN.sub(minimumBuyQuantity, accumulatedQuantity)
|
||||
)
|
||||
|
||||
if (MathBN.lte(reservableQuantity, 0)) {
|
||||
continue
|
||||
}
|
||||
|
||||
eligibleItemsByPromotion.push({
|
||||
item_id: eligibleBuyItem.id,
|
||||
quantity: MathBN.min(
|
||||
eligibleBuyItem.quantity,
|
||||
reservableQuantity
|
||||
).toNumber(),
|
||||
})
|
||||
|
||||
accumulatedQuantity = MathBN.add(accumulatedQuantity, reservableQuantity)
|
||||
}
|
||||
|
||||
// Store the eligible buy items for this promotion code in the map
|
||||
eligibleBuyItemMap.set(promotion.code!, eligibleItemsByPromotion)
|
||||
|
||||
// If we couldn't accumulate enough items to meet the minimum buy quantity, return early
|
||||
if (MathBN.lt(accumulatedQuantity, minimumBuyQuantity)) {
|
||||
return computedActions
|
||||
}
|
||||
|
||||
// Get the number of target items that should receive the discount
|
||||
const targetQuantity = MathBN.convert(
|
||||
promotion.application_method?.apply_to_quantity ?? 0
|
||||
)
|
||||
|
||||
// If no target quantity is specified, return early
|
||||
if (MathBN.lte(targetQuantity, 0)) {
|
||||
return computedActions
|
||||
}
|
||||
|
||||
// Find all items that match the target rules criteria
|
||||
const eligibleTargetItems = filterItemsByPromotionRules(
|
||||
itemsContext,
|
||||
promotion.application_method?.target_rules
|
||||
)
|
||||
|
||||
// If no items match the target rules, return early
|
||||
if (!eligibleTargetItems.length) {
|
||||
return computedActions
|
||||
}
|
||||
const remainingBuyQuantities = calculateRemainingQuantities(
|
||||
eligibleBuyItems,
|
||||
eligibleBuyItemMap,
|
||||
promotion.code!
|
||||
)
|
||||
|
||||
// Track quantities of items that can't be used as targets because they were used in buy rules
|
||||
const inapplicableQuantityMap = new Map<string, BigNumberInput>()
|
||||
const remainingTargetQuantities = calculateRemainingQuantities(
|
||||
eligibleTargetItems,
|
||||
eligibleTargetItemMap,
|
||||
promotion.code!
|
||||
)
|
||||
|
||||
// Build map of quantities that are ineligible as targets because they were used to satisfy buy rules
|
||||
for (const buyItem of eligibleItemsByPromotion) {
|
||||
const currentValue =
|
||||
inapplicableQuantityMap.get(buyItem.item_id) || MathBN.convert(0)
|
||||
inapplicableQuantityMap.set(
|
||||
buyItem.item_id,
|
||||
MathBN.add(currentValue, buyItem.quantity)
|
||||
)
|
||||
}
|
||||
const totalEligibleBuyItemsMap = new Map<string, EligibleItem>()
|
||||
const totalEligibleTargetItemsMap = new Map<string, EligibleItem>()
|
||||
const itemIdPromotionAmountMap = new Map<string, BigNumberInput>()
|
||||
const computedActions: PromotionTypes.ComputeActions[] = []
|
||||
|
||||
// Track items eligible for receiving the discount and total quantity that can be discounted
|
||||
const targetItemsByPromotion: EligibleItem[] = []
|
||||
let targetableQuantity = MathBN.convert(0)
|
||||
const MAX_PROMOTION_ITERATIONS = 1000
|
||||
let iterationCount = 0
|
||||
let appliedPromotionQuantity = MathBN.convert(0)
|
||||
|
||||
// Find items eligible for discount, excluding quantities used in buy rules
|
||||
for (const eligibleTargetItem of eligibleTargetItems) {
|
||||
// Calculate how much of this item's quantity can receive the discount
|
||||
const inapplicableQuantity =
|
||||
inapplicableQuantityMap.get(eligibleTargetItem.id) || MathBN.convert(0)
|
||||
const applicableQuantity = MathBN.sub(
|
||||
eligibleTargetItem.quantity,
|
||||
inapplicableQuantity
|
||||
)
|
||||
/*
|
||||
This loop continues applying the promotion until one of the stopping conditions is met:
|
||||
- No more items satisfy the minimum buy quantity requirement
|
||||
- Maximum applicable promotion quantity is reached
|
||||
- No valid target items can be found for promotion application
|
||||
- Maximum iteration count is reached (safety check)
|
||||
|
||||
Each iteration:
|
||||
1. Prepares an application state (selects buy items + eligible target items)
|
||||
2. Applies promotion to the selected target items
|
||||
3. Updates remaining quantities to prevent double-usage in next iteration
|
||||
4. Updates the total eligible items for next iteration
|
||||
*/
|
||||
while (true) {
|
||||
iterationCount++
|
||||
|
||||
if (MathBN.lte(applicableQuantity, 0)) {
|
||||
continue
|
||||
}
|
||||
if (iterationCount > MAX_PROMOTION_ITERATIONS) {
|
||||
console.warn(
|
||||
`Buy-get promotion ${promotion.code} exceeded maximum iterations (${MAX_PROMOTION_ITERATIONS}). Breaking loop to prevent infinite execution.`
|
||||
)
|
||||
|
||||
// Calculate how many more items we need to fulfill target quantity
|
||||
const remainingNeeded = MathBN.sub(targetQuantity, targetableQuantity)
|
||||
const fulfillableQuantity = MathBN.min(remainingNeeded, applicableQuantity)
|
||||
|
||||
if (MathBN.lte(fulfillableQuantity, 0)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Add this item to eligible targets
|
||||
targetItemsByPromotion.push({
|
||||
item_id: eligibleTargetItem.id,
|
||||
quantity: fulfillableQuantity.toNumber(),
|
||||
})
|
||||
|
||||
targetableQuantity = MathBN.add(targetableQuantity, fulfillableQuantity)
|
||||
|
||||
// If we've found enough items to fulfill target quantity, stop looking
|
||||
if (MathBN.gte(targetableQuantity, targetQuantity)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
// We prepare an application state for the promotion to be applied on all eligible items
|
||||
// We use this as a source of truth to update the remaining quantities of the eligible items
|
||||
// and the total eligible items
|
||||
const applicationState = preparePromotionApplicationState(
|
||||
eligibleBuyItems,
|
||||
eligibleTargetItems,
|
||||
remainingBuyQuantities,
|
||||
remainingTargetQuantities,
|
||||
applicationConfig,
|
||||
appliedPromotionQuantity
|
||||
)
|
||||
|
||||
// Store eligible target items for this promotion
|
||||
eligibleTargetItemMap.set(promotion.code!, targetItemsByPromotion)
|
||||
|
||||
// If we couldn't find enough eligible target items, return early
|
||||
if (MathBN.lt(targetableQuantity, targetQuantity)) {
|
||||
return computedActions
|
||||
}
|
||||
|
||||
// Track remaining quantity to apply discount to and get discount percentage
|
||||
let remainingQtyToApply = MathBN.convert(targetQuantity)
|
||||
const applicablePercentage = promotion.application_method?.value ?? 100
|
||||
|
||||
// Apply discounts to eligible target items
|
||||
for (const targetItem of targetItemsByPromotion) {
|
||||
if (MathBN.lte(remainingQtyToApply, 0)) {
|
||||
// If the application state is not valid, we break the loop
|
||||
// If it is not valid, it means that there are no more eligible items to apply the promotion to
|
||||
// for the configuration of the promotion
|
||||
if (!applicationState.isValid) {
|
||||
break
|
||||
}
|
||||
|
||||
const item = itemsMap.get(targetItem.item_id)!
|
||||
const appliedPromoValue =
|
||||
methodIdPromoValueMap.get(item.id) ?? MathBN.convert(0)
|
||||
const multiplier = MathBN.min(targetItem.quantity, remainingQtyToApply)
|
||||
|
||||
// Calculate discount amount based on item price and applicable percentage
|
||||
const pricePerUnit = MathBN.div(item.subtotal, item.quantity)
|
||||
const applicableAmount = MathBN.mult(pricePerUnit, multiplier)
|
||||
const amount = MathBN.mult(applicableAmount, applicablePercentage).div(100)
|
||||
|
||||
if (MathBN.lte(amount, 0)) {
|
||||
continue
|
||||
}
|
||||
|
||||
remainingQtyToApply = MathBN.sub(remainingQtyToApply, multiplier)
|
||||
|
||||
// Check if applying this discount would exceed promotion budget
|
||||
const budgetExceededAction = computeActionForBudgetExceeded(
|
||||
// We apply the promotion to the target items based on the target items that are eligible
|
||||
// and the remaining quantities of the target items
|
||||
const application = applyPromotionToTargetItems(
|
||||
applicationState.targetItems,
|
||||
itemIdPromotionAmountMap,
|
||||
methodIdPromoValueMap,
|
||||
promotion,
|
||||
amount
|
||||
itemsMap,
|
||||
applicationConfig
|
||||
)
|
||||
|
||||
if (budgetExceededAction) {
|
||||
computedActions.push(budgetExceededAction)
|
||||
continue
|
||||
}
|
||||
computedActions.push(...application.computedActions)
|
||||
|
||||
// Track total promotional value applied to this item
|
||||
methodIdPromoValueMap.set(
|
||||
item.id,
|
||||
MathBN.add(appliedPromoValue, amount).toNumber()
|
||||
// Computed actions being generated means that the promotion is applied.
|
||||
// We now need to update the remaining quantities of the eligible items and the total eligible items
|
||||
// to be used in the next iteration of the loop
|
||||
appliedPromotionQuantity = MathBN.add(
|
||||
appliedPromotionQuantity,
|
||||
application.appliedPromotionQuantity
|
||||
)
|
||||
|
||||
// Add computed discount action
|
||||
computedActions.push({
|
||||
action: ComputedActions.ADD_ITEM_ADJUSTMENT,
|
||||
item_id: item.id,
|
||||
amount,
|
||||
code: promotion.code!,
|
||||
})
|
||||
updateEligibleItemQuantities(
|
||||
remainingBuyQuantities,
|
||||
remainingTargetQuantities,
|
||||
applicationState
|
||||
)
|
||||
|
||||
updateEligibleItems(totalEligibleBuyItemsMap, applicationState.buyItems)
|
||||
updateEligibleItems(
|
||||
totalEligibleTargetItemsMap,
|
||||
applicationState.targetItems
|
||||
)
|
||||
}
|
||||
|
||||
const finalActions = createComputedActionsFromPromotionApplication(
|
||||
itemIdPromotionAmountMap,
|
||||
promotion.code!
|
||||
)
|
||||
computedActions.push(...finalActions)
|
||||
|
||||
eligibleBuyItemMap.set(
|
||||
promotion.code!,
|
||||
Array.from(totalEligibleBuyItemsMap.values())
|
||||
)
|
||||
eligibleTargetItemMap.set(
|
||||
promotion.code!,
|
||||
Array.from(totalEligibleTargetItemsMap.values())
|
||||
)
|
||||
|
||||
return computedActions
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,27 @@ export function validateApplicationMethodAttributes(
|
||||
`buy_rules_min_quantity is a required field for Promotion type of ${PromotionType.BUYGET}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!isPresent(maxQuantity)) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`application_method.max_quantity is a required field for Promotion type of ${PromotionType.BUYGET}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!isPresent(applyToQuantity)) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`application_method.apply_to_quantity is a required field for Promotion type of ${PromotionType.BUYGET}`
|
||||
)
|
||||
}
|
||||
|
||||
if (MathBN.lt(maxQuantity!, applyToQuantity!)) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`max_quantity (${maxQuantity}) must be greater than or equal to apply_to_quantity (${applyToQuantity}) for BUYGET promotions.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user