chore(promotion): Improve performances [1] (#12129)

**What**
Reduce database queries when possible and use proper data structure and aggregation when possible in order to reduce performance decrease overall
This commit is contained in:
Adrien de Peretti authored and GitHub committed 2025-04-10 15:53:39 +00:00
1 parent 31abba8cde
commit d87b25203c
5 files changed
+406 -307

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"@medusajs/promotion": patch
---
chore(promotion): Improve performances [1]
@@ -144,24 +144,25 @@ export default class PromotionModuleService
config?: FindConfig<PromotionDTO>, config?: FindConfig<PromotionDTO>,
sharedContext?: Context sharedContext?: Context
): Promise<PromotionDTO[]> { ): Promise<PromotionDTO[]> {
// Ensure we share the same now date across all filters
const now = new Date()
const activeFilters = { const activeFilters = {
status: PromotionStatus.ACTIVE,
$or: [ $or: [
{ {
status: PromotionStatus.ACTIVE,
campaign_id: null, campaign_id: null,
...filters, ...filters,
}, },
{ {
status: PromotionStatus.ACTIVE,
...filters, ...filters,
campaign: { campaign: {
...filters?.campaign, ...filters?.campaign,
$and: [ $and: [
{ {
$or: [{ starts_at: null }, { starts_at: { $lte: new Date() } }], $or: [{ starts_at: null }, { starts_at: { $lte: now } }],
}, },
{ {
$or: [{ ends_at: null }, { ends_at: { $gt: new Date() } }], $or: [{ ends_at: null }, { ends_at: { $gt: now } }],
}, },
], ],
}, },
@@ -172,12 +173,10 @@ export default class PromotionModuleService
return this.listPromotions(activeFilters, config, sharedContext) return this.listPromotions(activeFilters, config, sharedContext)
} }
@InjectManager() @InjectTransactionManager()
async registerUsage( async registerUsage(
computedActions: PromotionTypes.UsageComputedActions[], computedActions: PromotionTypes.UsageComputedActions[],
@MedusaContext() @MedusaContext() sharedContext: Context = {}
@MedusaContext()
sharedContext: Context = {}
): Promise<void> { ): Promise<void> {
const promotionCodes = computedActions const promotionCodes = computedActions
.map((computedAction) => computedAction.code) .map((computedAction) => computedAction.code)
@@ -225,16 +224,19 @@ export default class PromotionModuleService
continue continue
} }
campaignBudgetData.used = MathBN.add( // Calculate the new budget value
const newUsedValue = MathBN.add(
campaignBudgetData.used ?? 0, campaignBudgetData.used ?? 0,
computedAction.amount computedAction.amount
) )
if ( if (
campaignBudget.limit && campaignBudget.limit &&
MathBN.gt(campaignBudgetData.used, campaignBudget.limit) MathBN.gt(newUsedValue, campaignBudget.limit)
) { ) {
continue continue
} else {
campaignBudgetData.used = newUsedValue
} }
campaignBudgetMap.set(campaignBudget.id, campaignBudgetData) campaignBudgetMap.set(campaignBudget.id, campaignBudgetData)
@@ -248,25 +250,30 @@ export default class PromotionModuleService
continue continue
} }
const campaignBudgetData = { const newUsedValue = MathBN.add(campaignBudget.used ?? 0, 1)
id: campaignBudget.id,
used: MathBN.add(campaignBudget.used ?? 0, 1),
}
// Check if it exceeds the limit and cap it if necessary
if ( if (
campaignBudget.limit && campaignBudget.limit &&
MathBN.gt(campaignBudgetData.used, campaignBudget.limit) MathBN.gt(newUsedValue, campaignBudget.limit)
) { ) {
continue campaignBudgetMap.set(campaignBudget.id, {
id: campaignBudget.id,
used: campaignBudget.limit,
})
} else {
campaignBudgetMap.set(campaignBudget.id, {
id: campaignBudget.id,
used: newUsedValue,
})
} }
campaignBudgetMap.set(campaignBudget.id, campaignBudgetData)
promotionCodeUsageMap.set(promotion.code!, true) promotionCodeUsageMap.set(promotion.code!, true)
} }
}
if (campaignBudgetMap.size > 0) {
const campaignBudgetsData: UpdateCampaignBudgetDTO[] = [] const campaignBudgetsData: UpdateCampaignBudgetDTO[] = []
for (const [_, campaignBudgetData] of campaignBudgetMap) { for (const [_, campaignBudgetData] of campaignBudgetMap) {
campaignBudgetsData.push(campaignBudgetData) campaignBudgetsData.push(campaignBudgetData)
} }
@@ -278,7 +285,7 @@ export default class PromotionModuleService
} }
} }
@InjectManager() @InjectTransactionManager()
async revertUsage( async revertUsage(
computedActions: PromotionTypes.UsageComputedActions[], computedActions: PromotionTypes.UsageComputedActions[],
@MedusaContext() sharedContext: Context = {} @MedusaContext() sharedContext: Context = {}
@@ -329,11 +336,13 @@ export default class PromotionModuleService
continue continue
} }
campaignBudgetData.used = MathBN.sub( // Calculate new used value and ensure it doesn't go below 0
const newUsedValue = MathBN.sub(
campaignBudgetData.used ?? 0, campaignBudgetData.used ?? 0,
computedAction.amount computedAction.amount
) )
campaignBudgetData.used = MathBN.lt(newUsedValue, 0) ? 0 : newUsedValue
campaignBudgetMap.set(campaignBudget.id, campaignBudgetData) campaignBudgetMap.set(campaignBudget.id, campaignBudgetData)
} }
@@ -345,16 +354,21 @@ export default class PromotionModuleService
continue continue
} }
// Calculate new used value and ensure it doesn't go below 0
const newUsedValue = MathBN.sub(campaignBudget.used ?? 0, 1)
const usedValue = MathBN.lt(newUsedValue, 0) ? 0 : newUsedValue
campaignBudgetMap.set(campaignBudget.id, { campaignBudgetMap.set(campaignBudget.id, {
id: campaignBudget.id, id: campaignBudget.id,
used: MathBN.sub(campaignBudget.used ?? 0, 1), used: usedValue,
}) })
promotionCodeUsageMap.set(promotion.code!, true) promotionCodeUsageMap.set(promotion.code!, true)
} }
}
if (campaignBudgetMap.size > 0) {
const campaignBudgetsData: UpdateCampaignBudgetDTO[] = [] const campaignBudgetsData: UpdateCampaignBudgetDTO[] = []
for (const [_, campaignBudgetData] of campaignBudgetMap) { for (const [_, campaignBudgetData] of campaignBudgetMap) {
campaignBudgetsData.push(campaignBudgetData) campaignBudgetsData.push(campaignBudgetData)
} }
@@ -377,23 +391,48 @@ export default class PromotionModuleService
const computedActions: PromotionTypes.ComputeActions[] = [] const computedActions: PromotionTypes.ComputeActions[] = []
const { items = [], shipping_methods: shippingMethods = [] } = const { items = [], shipping_methods: shippingMethods = [] } =
applicationContext applicationContext
const appliedItemCodes: string[] = []
const appliedShippingCodes: string[] = []
const codeAdjustmentMap = new Map< const codeAdjustmentMap = new Map<
string, string,
PromotionTypes.ComputeActionAdjustmentLine[] {
items: PromotionTypes.ComputeActionAdjustmentLine[]
shipping: PromotionTypes.ComputeActionAdjustmentLine[]
}
>() >()
// Pre-process items and shipping methods to build adjustment map efficiently
for (const item of items) {
if (!item.adjustments?.length) continue
for (const adjustment of item.adjustments) {
if (!isString(adjustment.code)) continue
if (!codeAdjustmentMap.has(adjustment.code)) {
codeAdjustmentMap.set(adjustment.code, { items: [], shipping: [] })
}
codeAdjustmentMap.get(adjustment.code)!.items.push(adjustment)
}
}
for (const shippingMethod of shippingMethods) {
if (!shippingMethod.adjustments?.length) continue
for (const adjustment of shippingMethod.adjustments) {
if (!isString(adjustment.code)) continue
if (!codeAdjustmentMap.has(adjustment.code)) {
codeAdjustmentMap.set(adjustment.code, { items: [], shipping: [] })
}
codeAdjustmentMap.get(adjustment.code)!.shipping.push(adjustment)
}
}
const appliedCodes = Array.from(codeAdjustmentMap.keys())
const methodIdPromoValueMap = new Map<string, number>() const methodIdPromoValueMap = new Map<string, number>()
// Keeps a map of all elgible items in the buy section and its eligible quantity
const eligibleBuyItemMap = new Map<
string,
ComputeActionUtils.EligibleItem[]
>()
// Keeps a map of all elgible items in the target section and its eligible quantity
const eligibleTargetItemMap = new Map<
string,
ComputeActionUtils.EligibleItem[]
>()
const automaticPromotions = preventAutoPromotions const automaticPromotions = preventAutoPromotions
? [] ? []
: await this.listActivePromotions( : await this.listActivePromotions(
@@ -402,48 +441,17 @@ export default class PromotionModuleService
sharedContext 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 automaticPromotionCodes = automaticPromotions.map((p) => p.code!)
const promotionCodesToApply = [ const promotionCodesToApply = [
...promotionCodes, ...promotionCodes,
...automaticPromotionCodes, ...automaticPromotionCodes,
...appliedCodes,
] ]
items.forEach((item) => { const uniquePromotionCodes = Array.from(new Set(promotionCodesToApply))
item.adjustments?.forEach((adjustment) => {
if (isString(adjustment.code)) {
const adjustments = codeAdjustmentMap.get(adjustment.code) || []
adjustments.push(adjustment)
codeAdjustmentMap.set(adjustment.code, adjustments)
appliedItemCodes.push(adjustment.code)
}
})
})
shippingMethods.forEach((shippingMethod) => {
shippingMethod.adjustments?.forEach((adjustment) => {
if (isString(adjustment.code)) {
const adjustments = codeAdjustmentMap.get(adjustment.code) || []
adjustments.push(adjustment)
codeAdjustmentMap.set(adjustment.code, adjustments)
appliedShippingCodes.push(adjustment.code)
}
})
})
const promotions = await this.listActivePromotions( const promotions = await this.listActivePromotions(
{ { code: uniquePromotionCodes },
code: [
...promotionCodesToApply,
...appliedItemCodes,
...appliedShippingCodes,
],
},
{ {
take: null, take: null,
order: { application_method: { value: "DESC" } }, order: { application_method: { value: "DESC" } },
@@ -458,42 +466,51 @@ export default class PromotionModuleService
"campaign", "campaign",
"campaign.budget", "campaign.budget",
], ],
} },
sharedContext
) )
const existingPromotionsMap = new Map<string, PromotionTypes.PromotionDTO>( const existingPromotionsMap = new Map<string, PromotionTypes.PromotionDTO>(
promotions.map((promotion) => [promotion.code!, promotion]) promotions.map((promotion) => [promotion.code!, promotion])
) )
// We look at any existing promo codes applied in the context and recommend for (const [code, adjustments] of codeAdjustmentMap.entries()) {
// them to be removed to start calculations from the beginning and refresh for (const adjustment of adjustments.items) {
// the adjustments if they are requested to be applied again
const appliedCodes = [...appliedShippingCodes, ...appliedItemCodes]
for (const appliedCode of appliedCodes) {
const adjustments = codeAdjustmentMap.get(appliedCode) || []
const action = appliedShippingCodes.includes(appliedCode)
? ComputedActions.REMOVE_SHIPPING_METHOD_ADJUSTMENT
: ComputedActions.REMOVE_ITEM_ADJUSTMENT
adjustments.forEach((adjustment) =>
computedActions.push({ computedActions.push({
action, action: ComputedActions.REMOVE_ITEM_ADJUSTMENT,
adjustment_id: adjustment.id, adjustment_id: adjustment.id,
code: appliedCode, code,
}) })
) }
for (const adjustment of adjustments.shipping) {
computedActions.push({
action: ComputedActions.REMOVE_SHIPPING_METHOD_ADJUSTMENT,
adjustment_id: adjustment.id,
code,
})
}
} }
// We sort the promo codes to apply with buy get type first as they const sortedPromotionsToApply = promotions
// are likely to be most valuable. .filter(
const sortedPermissionsToApply = promotions (p) =>
.filter((p) => promotionCodesToApply.includes(p.code!)) promotionCodes.includes(p.code!) ||
automaticPromotionCodes.includes(p.code!)
)
.sort(ComputeActionUtils.sortByBuyGetType) .sort(ComputeActionUtils.sortByBuyGetType)
for (const promotionToApply of sortedPermissionsToApply) { const eligibleBuyItemMap = new Map<
const promotion = existingPromotionsMap.get(promotionToApply.code!)! string,
ComputeActionUtils.EligibleItem[]
>()
const eligibleTargetItemMap = new Map<
string,
ComputeActionUtils.EligibleItem[]
>()
for (const promotionToApply of sortedPromotionsToApply) {
const promotion = existingPromotionsMap.get(promotionToApply.code!)!
const { const {
application_method: applicationMethod, application_method: applicationMethod,
rules: promotionRules = [], rules: promotionRules = [],
@@ -524,9 +541,7 @@ export default class PromotionModuleService
) )
computedActions.push(...computedActionsForItems) computedActions.push(...computedActionsForItems)
} } else if (promotion.type === PromotionType.STANDARD) {
if (promotion.type === PromotionType.STANDARD) {
const isTargetOrder = const isTargetOrder =
applicationMethod.target_type === ApplicationMethodTargetType.ORDER applicationMethod.target_type === ApplicationMethodTargetType.ORDER
const isTargetItems = const isTargetItems =
@@ -620,11 +635,20 @@ export default class PromotionModuleService
const promotionsData: CreatePromotionDTO[] = [] const promotionsData: CreatePromotionDTO[] = []
const applicationMethodsData: CreateApplicationMethodDTO[] = [] const applicationMethodsData: CreateApplicationMethodDTO[] = []
const campaignsData: CreateCampaignDTO[] = [] const campaignsData: CreateCampaignDTO[] = []
const existingCampaigns = await this.campaignService_.list(
{ id: data.map((d) => d.campaign_id).filter((id) => isString(id)) }, const campaignIds = data
{ relations: ["budget"] }, .filter((d) => d.campaign_id)
sharedContext .map((d) => d.campaign_id)
) .filter((id): id is string => isString(id))
const existingCampaigns =
campaignIds.length > 0
? await this.campaignService_.list(
{ id: campaignIds },
{ relations: ["budget"] },
sharedContext
)
: []
const promotionCodeApplicationMethodDataMap = new Map< const promotionCodeApplicationMethodDataMap = new Map<
string, string,
@@ -672,7 +696,6 @@ export default class PromotionModuleService
if (!campaignData && !campaignId) { if (!campaignData && !campaignId) {
promotionsData.push({ ...promotionData }) promotionsData.push({ ...promotionData })
continue continue
} }
@@ -783,24 +806,28 @@ export default class PromotionModuleService
} }
} }
await this.createPromotionRulesAndValues_( if (promotionCodeRulesDataMap.has(promotion.code)) {
promotionCodeRulesDataMap.get(promotion.code) || [], await this.createPromotionRulesAndValues_(
"promotions", promotionCodeRulesDataMap.get(promotion.code) || [],
promotion, "promotions",
sharedContext promotion,
) sharedContext
)
}
} }
const createdApplicationMethods = const createdApplicationMethods =
await this.applicationMethodService_.create( applicationMethodsData.length > 0
applicationMethodsData, ? await this.applicationMethodService_.create(
sharedContext applicationMethodsData,
) sharedContext
)
: []
const createdCampaigns = await this.createCampaigns( const createdCampaigns =
campaignsData, campaignsData.length > 0
sharedContext ? await this.createCampaigns(campaignsData, sharedContext)
) : []
for (const campaignData of campaignsData) { for (const campaignData of campaignsData) {
const promotions = campaignData.promotions const promotions = campaignData.promotions
@@ -808,30 +835,36 @@ export default class PromotionModuleService
(c) => c.campaign_identifier === campaignData.campaign_identifier (c) => c.campaign_identifier === campaignData.campaign_identifier
) )
if (!campaign || !promotions || !promotions.length) { if (campaign && promotions && promotions.length) {
continue await this.addPromotionsToCampaign(
{ id: campaign.id, promotion_ids: promotions.map((p) => p.id) },
sharedContext
)
} }
await this.addPromotionsToCampaign(
{ id: campaign.id, promotion_ids: promotions.map((p) => p.id) },
sharedContext
)
} }
for (const applicationMethod of createdApplicationMethods) { for (const applicationMethod of createdApplicationMethods) {
await this.createPromotionRulesAndValues_( const targetRules = methodTargetRulesMap.get(
methodTargetRulesMap.get(applicationMethod.promotion.id) || [], applicationMethod.promotion.id
"method_target_rules",
applicationMethod,
sharedContext
) )
if (targetRules && targetRules.length > 0) {
await this.createPromotionRulesAndValues_(
targetRules,
"method_target_rules",
applicationMethod,
sharedContext
)
}
await this.createPromotionRulesAndValues_( const buyRules = methodBuyRulesMap.get(applicationMethod.promotion.id)
methodBuyRulesMap.get(applicationMethod.promotion.id) || [], if (buyRules && buyRules.length > 0) {
"method_buy_rules", await this.createPromotionRulesAndValues_(
applicationMethod, buyRules,
sharedContext "method_buy_rules",
) applicationMethod,
sharedContext
)
}
} }
return createdPromotions return createdPromotions
@@ -50,6 +50,17 @@ export function getComputedActionsForBuyGet(
): PromotionTypes.ComputeActions[] { ): PromotionTypes.ComputeActions[] {
const computedActions: 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 (!itemsContext?.length) {
return computedActions
}
const minimumBuyQuantity = MathBN.convert( const minimumBuyQuantity = MathBN.convert(
promotion.application_method?.buy_rules_min_quantity ?? 0 promotion.application_method?.buy_rules_min_quantity ?? 0
) )
@@ -58,11 +69,11 @@ export function getComputedActionsForBuyGet(
itemsContext.map((i) => [i.id, i]) itemsContext.map((i) => [i.id, i])
) )
if (!itemsContext) { if (
throw new MedusaError( MathBN.lte(minimumBuyQuantity, 0) ||
MedusaError.Types.INVALID_DATA, !promotion.application_method?.buy_rules?.length
`"items" should be present as an array in the context to compute actions` ) {
) return computedActions
} }
const eligibleBuyItems = filterItemsByPromotionRules( const eligibleBuyItems = filterItemsByPromotionRules(
@@ -70,18 +81,25 @@ export function getComputedActionsForBuyGet(
promotion.application_method?.buy_rules promotion.application_method?.buy_rules
) )
if (!eligibleBuyItems.length) {
return computedActions
}
const eligibleBuyItemQuantity = MathBN.sum( const eligibleBuyItemQuantity = MathBN.sum(
...eligibleBuyItems.map((item) => item.quantity) ...eligibleBuyItems.map((item) => item.quantity)
) )
/* /*
Get the total quantity of items where buy rules apply. If the total sum of eligible items 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. does not match up to the minimum buy quantity set on the promotion, return early.
*/ */
if (MathBN.gt(minimumBuyQuantity, eligibleBuyItemQuantity)) { if (MathBN.gt(minimumBuyQuantity, eligibleBuyItemQuantity)) {
return [] 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 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 as eligible, we can't use this item or its partial remaining quantity when we apply the promotion on
@@ -89,35 +107,19 @@ export function getComputedActionsForBuyGet(
We build the map here to use when we apply promotions on the target items. We build the map here to use when we apply promotions on the target items.
*/ */
for (const eligibleBuyItem of eligibleBuyItems) { for (const eligibleBuyItem of eligibleBuyItems) {
const eligibleItemsByPromotion =
eligibleBuyItemMap.get(promotion.code!) || []
const accumulatedQuantity = eligibleItemsByPromotion.reduce(
(acc, item) => MathBN.sum(acc, item.quantity),
MathBN.convert(0)
)
// If we have reached the minimum buy quantity from the eligible items for this promotion,
// we can break early and continue to applying the target items
if (MathBN.gte(accumulatedQuantity, minimumBuyQuantity)) { if (MathBN.gte(accumulatedQuantity, minimumBuyQuantity)) {
break break
} }
const eligibleQuantity = MathBN.sum(
...eligibleItemsByPromotion
.filter((buy) => buy.item_id === eligibleBuyItem.id)
.map((b) => b.quantity)
)
const reservableQuantity = MathBN.min( const reservableQuantity = MathBN.min(
eligibleBuyItem.quantity, eligibleBuyItem.quantity,
MathBN.sub(minimumBuyQuantity, eligibleQuantity) MathBN.sub(minimumBuyQuantity, accumulatedQuantity)
) )
// If we have reached the required minimum quantity, we break the loop early
if (MathBN.lte(reservableQuantity, 0)) { if (MathBN.lte(reservableQuantity, 0)) {
break continue
} }
eligibleItemsByPromotion.push({ eligibleItemsByPromotion.push({
@@ -128,93 +130,126 @@ export function getComputedActionsForBuyGet(
).toNumber(), ).toNumber(),
}) })
eligibleBuyItemMap.set(promotion.code!, eligibleItemsByPromotion) 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( const eligibleTargetItems = filterItemsByPromotionRules(
itemsContext, itemsContext,
promotion.application_method?.target_rules promotion.application_method?.target_rules
) )
const targetQuantity = MathBN.convert( // If no items match the target rules, return early
promotion.application_method?.apply_to_quantity ?? 0 if (!eligibleTargetItems.length) {
) return computedActions
}
/* // Track quantities of items that can't be used as targets because they were used in buy rules
In this loop, we build a map of eligible target items and quantity applicable to these items. const inapplicableQuantityMap = new Map<string, BigNumberInput>()
Here we remove the quantity we used previously to identify eligible buy items // Build map of quantities that are ineligible as targets because they were used to satisfy buy rules
from the eligible target items. for (const buyItem of eligibleItemsByPromotion) {
const currentValue =
This is done to prevent applying promotion to the same item we use to qualify the buy rules. inapplicableQuantityMap.get(buyItem.item_id) || MathBN.convert(0)
*/ inapplicableQuantityMap.set(
for (const eligibleTargetItem of eligibleTargetItems) { buyItem.item_id,
const inapplicableQuantity = MathBN.sum( MathBN.add(currentValue, buyItem.quantity)
...Array.from(eligibleBuyItemMap.values())
.flat(1)
.filter((buy) => buy.item_id === eligibleTargetItem.id)
.map((b) => b.quantity)
) )
}
// Track items eligible for receiving the discount and total quantity that can be discounted
const targetItemsByPromotion: EligibleItem[] = []
let targetableQuantity = 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( const applicableQuantity = MathBN.sub(
eligibleTargetItem.quantity, eligibleTargetItem.quantity,
inapplicableQuantity inapplicableQuantity
) )
const fulfillableQuantity = MathBN.min(targetQuantity, applicableQuantity) if (MathBN.lte(applicableQuantity, 0)) {
continue
}
// Calculate how many more items we need to fulfill target quantity
const remainingNeeded = MathBN.sub(targetQuantity, targetableQuantity)
const fulfillableQuantity = MathBN.min(remainingNeeded, applicableQuantity)
// If we have reached the required quantity to target from this item, we
// move on to the next item
if (MathBN.lte(fulfillableQuantity, 0)) { if (MathBN.lte(fulfillableQuantity, 0)) {
continue continue
} }
const targetItemsByPromotion = // Add this item to eligible targets
eligibleTargetItemMap.get(promotion.code!) || []
targetItemsByPromotion.push({ targetItemsByPromotion.push({
item_id: eligibleTargetItem.id, item_id: eligibleTargetItem.id,
quantity: MathBN.min(fulfillableQuantity, targetQuantity).toNumber(), quantity: fulfillableQuantity.toNumber(),
}) })
eligibleTargetItemMap.set(promotion.code!, targetItemsByPromotion) targetableQuantity = MathBN.add(targetableQuantity, fulfillableQuantity)
// If we've found enough items to fulfill target quantity, stop looking
if (MathBN.gte(targetableQuantity, targetQuantity)) {
break
}
} }
const targetItemsByPromotion = // Store eligible target items for this promotion
eligibleTargetItemMap.get(promotion.code!) || [] eligibleTargetItemMap.set(promotion.code!, targetItemsByPromotion)
const targettableQuantity = targetItemsByPromotion.reduce( // If we couldn't find enough eligible target items, return early
(sum, item) => MathBN.sum(sum, item.quantity), if (MathBN.lt(targetableQuantity, targetQuantity)) {
MathBN.convert(0) return computedActions
)
// If we were able to match the target requirements across all line items, we return early.
if (MathBN.lt(targettableQuantity, targetQuantity)) {
return []
} }
// Track remaining quantity to apply discount to and get discount percentage
let remainingQtyToApply = MathBN.convert(targetQuantity) let remainingQtyToApply = MathBN.convert(targetQuantity)
const applicablePercentage = promotion.application_method?.value ?? 100
// Apply discounts to eligible target items
for (const targetItem of targetItemsByPromotion) { for (const targetItem of targetItemsByPromotion) {
if (MathBN.lte(remainingQtyToApply, 0)) {
break
}
const item = itemsMap.get(targetItem.item_id)! const item = itemsMap.get(targetItem.item_id)!
const appliedPromoValue = const appliedPromoValue =
methodIdPromoValueMap.get(item.id) ?? MathBN.convert(0) methodIdPromoValueMap.get(item.id) ?? MathBN.convert(0)
const multiplier = MathBN.min(targetItem.quantity, remainingQtyToApply) const multiplier = MathBN.min(targetItem.quantity, remainingQtyToApply)
const applicableAmount = MathBN.mult(
MathBN.div(item.subtotal, item.quantity), // Calculate discount amount based on item price and applicable percentage
multiplier const pricePerUnit = MathBN.div(item.subtotal, item.quantity)
) const applicableAmount = MathBN.mult(pricePerUnit, multiplier)
const applicablePercentage = promotion.application_method?.value ?? 100
const amount = MathBN.mult(applicableAmount, applicablePercentage).div(100) const amount = MathBN.mult(applicableAmount, applicablePercentage).div(100)
const newRemainingQtyToApply = MathBN.sub(remainingQtyToApply, multiplier) if (MathBN.lte(amount, 0)) {
continue
if (MathBN.lt(newRemainingQtyToApply, 0) || MathBN.lte(amount, 0)) {
break
} else {
remainingQtyToApply = newRemainingQtyToApply
} }
remainingQtyToApply = MathBN.sub(remainingQtyToApply, multiplier)
// Check if applying this discount would exceed promotion budget
const budgetExceededAction = computeActionForBudgetExceeded( const budgetExceededAction = computeActionForBudgetExceeded(
promotion, promotion,
amount amount
@@ -222,15 +257,16 @@ export function getComputedActionsForBuyGet(
if (budgetExceededAction) { if (budgetExceededAction) {
computedActions.push(budgetExceededAction) computedActions.push(budgetExceededAction)
continue continue
} }
// Track total promotional value applied to this item
methodIdPromoValueMap.set( methodIdPromoValueMap.set(
item.id, item.id,
MathBN.add(appliedPromoValue, amount).toNumber() MathBN.add(appliedPromoValue, amount).toNumber()
) )
// Add computed discount action
computedActions.push({ computedActions.push({
action: ComputedActions.ADD_ITEM_ADJUSTMENT, action: ComputedActions.ADD_ITEM_ADJUSTMENT,
item_id: item.id, item_id: item.id,
@@ -75,17 +75,35 @@ function applyPromotionToItems(
allocationOverride?: ApplicationMethodAllocationValues allocationOverride?: ApplicationMethodAllocationValues
): PromotionTypes.ComputeActions[] { ): PromotionTypes.ComputeActions[] {
const { application_method: applicationMethod } = promotion const { application_method: applicationMethod } = promotion
if (!applicationMethod) {
return []
}
const allocation = applicationMethod?.allocation! || allocationOverride const allocation = applicationMethod?.allocation! || allocationOverride
const computedActions: PromotionTypes.ComputeActions[] = []
const applicableItems = getValidItemsForPromotion(items, promotion)
const target = applicationMethod?.target_type const target = applicationMethod?.target_type
if (!items?.length || !target) {
return []
}
const computedActions: PromotionTypes.ComputeActions[] = []
const applicableItems = getValidItemsForPromotion(items, promotion)
if (!applicableItems.length) {
return computedActions
}
const isTargetShippingMethod = target === TargetType.SHIPPING_METHODS const isTargetShippingMethod = target === TargetType.SHIPPING_METHODS
const isTargetLineItems = target === TargetType.ITEMS const isTargetLineItems = target === TargetType.ITEMS
const isTargetOrder = target === TargetType.ORDER const isTargetOrder = target === TargetType.ORDER
const promotionValue = applicationMethod?.value ?? 0
const maxQuantity = isTargetShippingMethod
? 1
: applicationMethod?.max_quantity!
let lineItemsTotal = MathBN.convert(0) let lineItemsTotal = MathBN.convert(0)
if (allocation === ApplicationMethodAllocation.ACROSS) { if (allocation === ApplicationMethodAllocation.ACROSS) {
lineItemsTotal = applicableItems.reduce( lineItemsTotal = applicableItems.reduce(
(acc, item) => (acc, item) =>
@@ -95,22 +113,27 @@ function applyPromotionToItems(
), ),
MathBN.convert(0) MathBN.convert(0)
) )
if (MathBN.lte(lineItemsTotal, 0)) {
return computedActions
}
} }
for (const item of applicableItems!) { for (const item of applicableItems) {
const appliedPromoValue = appliedPromotionsMap.get(item.id) ?? 0 if (MathBN.lte(item.subtotal, 0)) {
const maxQuantity = isTargetShippingMethod continue
? 1 }
: applicationMethod?.max_quantity!
if (isTargetShippingMethod) { if (isTargetShippingMethod) {
item.quantity = 1 item.quantity = 1
} }
const appliedPromoValue = appliedPromotionsMap.get(item.id) ?? 0
const amount = calculateAdjustmentAmountFromPromotion( const amount = calculateAdjustmentAmountFromPromotion(
item, item,
{ {
value: applicationMethod?.value ?? 0, value: promotionValue,
applied_value: appliedPromoValue, applied_value: appliedPromoValue,
max_quantity: maxQuantity, max_quantity: maxQuantity,
type: applicationMethod?.type!, type: applicationMethod?.type!,
@@ -130,7 +153,6 @@ function applyPromotionToItems(
if (budgetExceededAction) { if (budgetExceededAction) {
computedActions.push(budgetExceededAction) computedActions.push(budgetExceededAction)
continue continue
} }
@@ -143,9 +165,7 @@ function applyPromotionToItems(
amount, amount,
code: promotion.code!, code: promotion.code!,
}) })
} } else if (isTargetShippingMethod) {
if (isTargetShippingMethod) {
computedActions.push({ computedActions.push({
action: ComputedActions.ADD_SHIPPING_METHOD_ADJUSTMENT, action: ComputedActions.ADD_SHIPPING_METHOD_ADJUSTMENT,
shipping_method_id: item.id, shipping_method_id: item.id,
@@ -164,24 +184,39 @@ function getValidItemsForPromotion(
| PromotionTypes.ComputeActionContext[TargetType.SHIPPING_METHODS], | PromotionTypes.ComputeActionContext[TargetType.SHIPPING_METHODS],
promotion: PromotionTypes.PromotionDTO promotion: PromotionTypes.PromotionDTO
) { ) {
if (!items?.length || !promotion?.application_method) {
return []
}
const isTargetShippingMethod = const isTargetShippingMethod =
promotion.application_method?.target_type === TargetType.SHIPPING_METHODS promotion.application_method?.target_type === TargetType.SHIPPING_METHODS
return ( const targetRules = promotion.application_method?.target_rules ?? []
items?.filter((item) => { const hasTargetRules = targetRules.length > 0
const isSubtotalPresent = "subtotal" in item
const isQuantityPresent = "quantity" in item
const isPromotionApplicableToItem = areRulesValidForContext(
promotion?.application_method?.target_rules!,
item,
ApplicationMethodTargetType.ITEMS
)
return ( if (isTargetShippingMethod && !hasTargetRules) {
isPromotionApplicableToItem && return items.filter(
(isQuantityPresent || isTargetShippingMethod) && (item) => item && "subtotal" in item && MathBN.gt(item.subtotal, 0)
isSubtotalPresent )
) }
}) || []
) return items.filter((item) => {
if (!item || !("subtotal" in item) || MathBN.lte(item.subtotal, 0)) {
return false
}
if (!isTargetShippingMethod && !("quantity" in item)) {
return false
}
if (!hasTargetRules) {
return true
}
return areRulesValidForContext(
promotion?.application_method?.target_rules!,
item,
ApplicationMethodTargetType.ITEMS
)
})
} }
@@ -55,20 +55,44 @@ export function areRulesValidForContext(
context: Record<string, any>, context: Record<string, any>,
contextScope: ApplicationMethodTargetTypeValues contextScope: ApplicationMethodTargetTypeValues
): boolean { ): boolean {
return rules.every((rule) => { if (!rules?.length) {
const validRuleValues = rule.values?.map((ruleValue) => ruleValue.value) return true
}
if (!rule.attribute) { const isItemScope = contextScope === ApplicationMethodTargetType.ITEMS
const isShippingScope =
contextScope === ApplicationMethodTargetType.SHIPPING_METHODS
return rules.every((rule) => {
if (!rule.attribute || !rule.values?.length) {
return false return false
} }
const valuesToCheck = pickValueFromObject( const validRuleValues = rule.values
fetchRuleAttributeForContext(rule.attribute, contextScope), .filter((v) => isString(v.value))
context .map((v) => v.value as string)
)
if (!validRuleValues.length) {
return false
}
let ruleAttribute = rule.attribute
if (isItemScope) {
ruleAttribute = ruleAttribute.replace(
`${ApplicationMethodTargetType.ITEMS}.`,
""
)
} else if (isShippingScope) {
ruleAttribute = ruleAttribute.replace(
`${ApplicationMethodTargetType.SHIPPING_METHODS}.`,
""
)
}
const valuesToCheck = pickValueFromObject(ruleAttribute, context)
return evaluateRuleValueCondition( return evaluateRuleValueCondition(
validRuleValues.filter(isString), validRuleValues,
rule.operator!, rule.operator!,
valuesToCheck valuesToCheck
) )
@@ -76,83 +100,49 @@ export function areRulesValidForContext(
} }
/* /*
The context here can either be either: Optimized evaluateRuleValueCondition by using early returns and cleaner approach
- a cart context for evaluating rule conditions.
- an item context under a cart
- a shipping method context under a cart
The rule's attributes are set from the perspective of the cart context. For example: items.product.id
When the context here is item or shipping_method, we need to drop the "items."" or "shipping_method."
from the rule attribute string to accurate pick the values from the context.
*/ */
function fetchRuleAttributeForContext(
ruleAttribute: string,
contextScope: ApplicationMethodTargetTypeValues
): string {
if (contextScope === ApplicationMethodTargetType.ITEMS) {
ruleAttribute = ruleAttribute.replace(
`${ApplicationMethodTargetType.ITEMS}.`,
""
)
}
if (contextScope === ApplicationMethodTargetType.SHIPPING_METHODS) {
ruleAttribute = ruleAttribute.replace(
`${ApplicationMethodTargetType.SHIPPING_METHODS}.`,
""
)
}
return ruleAttribute
}
export function evaluateRuleValueCondition( export function evaluateRuleValueCondition(
ruleValues: string[], ruleValues: string[],
operator: string, operator: string,
ruleValuesToCheck: (string | number)[] | (string | number) ruleValuesToCheck: (string | number)[] | (string | number)
) { ): boolean {
if (!Array.isArray(ruleValuesToCheck)) { const valuesToCheck = Array.isArray(ruleValuesToCheck)
ruleValuesToCheck = [ruleValuesToCheck] ? ruleValuesToCheck
} : [ruleValuesToCheck]
if (!ruleValuesToCheck.length) { if (!valuesToCheck.length) {
return false return false
} }
return ruleValuesToCheck.every((ruleValueToCheck: string | number) => { switch (operator) {
if (operator === "in" || operator === "eq") { case "in":
return ruleValues.some((ruleValue) => ruleValue === `${ruleValueToCheck}`) case "eq": {
const ruleValueSet = new Set(ruleValues)
return valuesToCheck.every((val) => ruleValueSet.has(`${val}`))
} }
case "ne": {
if (operator === "ne") { const ruleValueSet = new Set(ruleValues)
return ruleValues.some((ruleValue) => ruleValue !== `${ruleValueToCheck}`) return valuesToCheck.every((val) => !ruleValueSet.has(`${val}`))
} }
case "gt":
if (operator === "gt") { return valuesToCheck.every((val) =>
return ruleValues.some((ruleValue) => ruleValues.some((ruleVal) => MathBN.gt(val, ruleVal))
MathBN.convert(ruleValueToCheck).gt(MathBN.convert(ruleValue))
) )
} case "gte":
return valuesToCheck.every((val) =>
if (operator === "gte") { ruleValues.some((ruleVal) => MathBN.gte(val, ruleVal))
return ruleValues.some((ruleValue) =>
MathBN.convert(ruleValueToCheck).gte(MathBN.convert(ruleValue))
) )
} case "lt":
return valuesToCheck.every((val) =>
if (operator === "lt") { ruleValues.some((ruleVal) => MathBN.lt(val, ruleVal))
return ruleValues.some((ruleValue) =>
MathBN.convert(ruleValueToCheck).lt(MathBN.convert(ruleValue))
) )
} case "lte":
return valuesToCheck.every((val) =>
if (operator === "lte") { ruleValues.some((ruleVal) => MathBN.lte(val, ruleVal))
return ruleValues.some((ruleValue) =>
MathBN.convert(ruleValueToCheck).lte(MathBN.convert(ruleValue))
) )
} default:
return false
return false }
})
} }