From 8cefd90a14b665d3ec2d61a0d406173f5c23b09d Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Wed, 4 Sep 2024 19:06:09 +0200 Subject: [PATCH 1/2] fix(promotion): handle promotion buy X get X scenario --- .../promotion-module/compute-actions.spec.ts | 175 ++++++++++++++++ .../src/services/promotion-module.ts | 2 +- .../src/utils/compute-actions/buy-get.ts | 193 +++++++++++++----- 3 files changed, 321 insertions(+), 49 deletions(-) diff --git a/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts index 95ef4af993cea..a0d57d4f7ef89 100644 --- a/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts +++ b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts @@ -4689,6 +4689,181 @@ moduleIntegrationTestRunner({ expect(JSON.parse(JSON.stringify(result))).toEqual([]) }) + + describe("when scenario is buy x get x", () => { + let buyXGetXPromotion + let product1 = "prod_tshirt_1" + let product2 = "prod_tshirt_2" + + beforeEach(async () => { + buyXGetXPromotion = await createDefaultPromotion(service, { + type: PromotionType.BUYGET, + application_method: { + type: "fixed", + target_type: "items", + value: 2000, + allocation: "each", + max_quantity: 2, + apply_to_quantity: 2, + buy_rules_min_quantity: 2, + target_rules: [ + { + attribute: "product.id", + operator: "eq", + values: [product1], + }, + ], + buy_rules: [ + { + attribute: "product.id", + operator: "eq", + values: [product1], + }, + ], + } as any, + }) + }) + + it("should compute adjustment accurately for a single item", async () => { + const context = { + currency_code: "usd", + items: [ + { + id: "item_cotton_tshirt", + quantity: 4, + subtotal: 1000, + product: { id: product1 }, + }, + { + id: "item_cotton_tshirt2", + quantity: 2, + subtotal: 2000, + product: { id: product2 }, + }, + ], + } + + const result = await service.computeActions( + [buyXGetXPromotion.code!], + context + ) + + expect(JSON.parse(JSON.stringify(result))).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 500, + code: "PROMOTION_TEST", + }, + ]) + }) + + it("should compute adjustment accurately across items", async () => { + const context = { + currency_code: "usd", + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + subtotal: 500, + product: { id: product1 }, + }, + { + id: "item_cotton_tshirt1", + quantity: 1, + subtotal: 500, + product: { id: product1 }, + }, + { + id: "item_cotton_tshirt2", + quantity: 1, + subtotal: 1000, + product: { id: product1 }, + }, + { + id: "item_cotton_tshirt3", + quantity: 1, + subtotal: 1000, + product: { id: product1 }, + }, + ], + } + + const result = await service.computeActions( + [buyXGetXPromotion.code!], + context + ) + + expect(JSON.parse(JSON.stringify(result))).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 500, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt1", + amount: 500, + code: "PROMOTION_TEST", + }, + ]) + }) + + it("should not compute adjustment when required quantity for target isn't met", async () => { + const context = { + currency_code: "usd", + items: [ + { + id: "item_cotton_tshirt", + quantity: 3, + subtotal: 1000, + product: { id: product1 }, + }, + ], + } + + const result = await service.computeActions( + [buyXGetXPromotion.code!], + context + ) + + expect(JSON.parse(JSON.stringify(result))).toEqual([]) + }) + + it("should not compute adjustment when required quantity for target isn't met across items", async () => { + const context = { + currency_code: "usd", + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + subtotal: 1000, + product: { id: product1 }, + }, + { + id: "item_cotton_tshirt1", + quantity: 1, + subtotal: 1000, + product: { id: product1 }, + }, + { + id: "item_cotton_tshirt2", + quantity: 1, + subtotal: 1000, + product: { id: product1 }, + }, + ], + } + + const result = await service.computeActions( + [buyXGetXPromotion.code!], + context + ) + + expect(JSON.parse(JSON.stringify(result))).toEqual([]) + }) + }) }) }) }, diff --git a/packages/modules/promotion/src/services/promotion-module.ts b/packages/modules/promotion/src/services/promotion-module.ts index 6406473491289..98ea5995beb20 100644 --- a/packages/modules/promotion/src/services/promotion-module.ts +++ b/packages/modules/promotion/src/services/promotion-module.ts @@ -374,7 +374,7 @@ export default class PromotionModuleService const computedActionsForItems = ComputeActionUtils.getComputedActionsForBuyGet( promotion, - applicationContext[ApplicationMethodTargetType.ITEMS], + applicationContext[ApplicationMethodTargetType.ITEMS]!, methodIdPromoValueMap ) diff --git a/packages/modules/promotion/src/utils/compute-actions/buy-get.ts b/packages/modules/promotion/src/utils/compute-actions/buy-get.ts index ecad8b3998c1d..9b90853366973 100644 --- a/packages/modules/promotion/src/utils/compute-actions/buy-get.ts +++ b/packages/modules/promotion/src/utils/compute-actions/buy-get.ts @@ -1,27 +1,65 @@ -import { BigNumberInput, PromotionTypes } from "@medusajs/types" +import { + BigNumberInput, + ComputeActionItemLine, + PromotionTypes, +} from "@medusajs/types" import { ApplicationMethodTargetType, ComputedActions, MathBN, MedusaError, PromotionType, - isPresent, } from "@medusajs/utils" import { areRulesValidForContext } from "../validations" import { computeActionForBudgetExceeded } from "./usage" +type EligibleItem = { item_id: string; quantity: BigNumberInput } + +function sortByPrice(a: ComputeActionItemLine, b: ComputeActionItemLine) { + const aPrice = MathBN.div(a.subtotal, a.quantity) + const bPrice = MathBN.div(b.subtotal, b.quantity) + + return MathBN.lt(bPrice, aPrice) ? -1 : 1 +} + +/* + Grabs all the items in the context where the rules apply + We then sort by price to prioritize most valuable item +*/ +function fetchEligibleItemsByRules( + itemsContext: ComputeActionItemLine[], + rules?: PromotionTypes.PromotionRuleDTO[] +) { + return itemsContext + .filter((item) => + areRulesValidForContext( + rules || [], + item, + ApplicationMethodTargetType.ITEMS + ) + ) + .sort(sortByPrice) +} + // TODO: calculations should eventually move to a totals util outside of the module export function getComputedActionsForBuyGet( promotion: PromotionTypes.PromotionDTO, - itemsContext: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.ITEMS], + itemsContext: ComputeActionItemLine[], methodIdPromoValueMap: Map ): PromotionTypes.ComputeActions[] { - const buyRulesMinQuantity = - promotion.application_method?.buy_rules_min_quantity - const applyToQuantity = promotion.application_method?.apply_to_quantity - const buyRules = promotion.application_method?.buy_rules - const targetRules = promotion.application_method?.target_rules const computedActions: PromotionTypes.ComputeActions[] = [] + // Keeps a map of all elgible items in the buy section and its eligible quantity + const eligibleBuyItemMap = new Map() + // Keeps a map of all elgible items in the target section and its eligible quantity + const eligibleTargetItemMap = new Map() + + const minimumBuyQuantity = MathBN.convert( + promotion.application_method?.buy_rules_min_quantity ?? 0 + ) + + const itemsMap = new Map( + itemsContext.map((i) => [i.id, i]) + ) if (!itemsContext) { throw new MedusaError( @@ -30,56 +68,115 @@ export function getComputedActionsForBuyGet( ) } - if (!Array.isArray(buyRules) || !Array.isArray(targetRules)) { - return [] - } + const eligibleBuyItems = fetchEligibleItemsByRules( + itemsContext, + promotion.application_method?.buy_rules + ) - const validQuantity = MathBN.sum( - ...itemsContext - .filter((item) => - areRulesValidForContext( - buyRules, - item, - ApplicationMethodTargetType.ITEMS - ) - ) - .map((item) => item.quantity) + const eligibleBuyItemQuantity = MathBN.sum( + ...eligibleBuyItems.map((item) => item.quantity) ) - if ( - !buyRulesMinQuantity || - !applyToQuantity || - MathBN.gt(buyRulesMinQuantity, validQuantity) - ) { + /* + 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 [] } - const validItemsForTargetRules = itemsContext - .filter((item) => - areRulesValidForContext( - targetRules, - item, - ApplicationMethodTargetType.ITEMS - ) + /* + 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 a map here to use when we apply promotions on the target items. + */ + for (const eligibleBuyItem of eligibleBuyItems) { + const buyItems = eligibleBuyItemMap.get(promotion.code!) || [] + const eligibleQuantity = MathBN.sum(...buyItems.map((b) => b.quantity)) + const reservableQuantity = MathBN.sub(minimumBuyQuantity, eligibleQuantity) + + // If we have reached the required minimum quantity, we break the loop early + if (MathBN.lte(reservableQuantity, 0)) { + break + } + + buyItems.push({ + item_id: eligibleBuyItem.id, + quantity: MathBN.min(eligibleBuyItem.quantity, reservableQuantity), + }) + + eligibleBuyItemMap.set(promotion.code!, buyItems) + } + + const eligibleTargetItems = fetchEligibleItemsByRules( + itemsContext, + promotion.application_method?.target_rules + ) + + const targetQuantity = MathBN.convert( + promotion.application_method?.apply_to_quantity ?? 0 + ) + + /* + In this loop, we build a map of eligible target items and quantity applicable to these items. + + Here we remove the quantity we used previously to identify eligible buy items + from the eligible target items. + + This is done to prevent applying promotion to the same item we use to qualify the buy rules. + */ + for (const eligibleTargetItem of eligibleTargetItems) { + const items = eligibleTargetItemMap.get(promotion.code!) || [] + const eligibleBuyItems = ( + eligibleBuyItemMap.get(promotion.code!) || [] + ).filter((buy) => buy.item_id === eligibleTargetItem.id) + + const inapplicableQuantity = MathBN.sum( + ...eligibleBuyItems.map((b) => b.quantity) + ) + const applicableQuantity = MathBN.sub( + eligibleTargetItem.quantity, + inapplicableQuantity ) - .filter((item) => isPresent(item.subtotal) && isPresent(item.quantity)) - .sort((a, b) => { - const divA = MathBN.eq(a.quantity, 0) ? 1 : a.quantity - const divB = MathBN.eq(b.quantity, 0) ? 1 : b.quantity + const fulfillableQuantity = MathBN.min(targetQuantity, applicableQuantity) - const aPrice = MathBN.div(a.subtotal, divA) - const bPrice = MathBN.div(b.subtotal, divB) + // If we have reached the required quantity to target from this item, we + // move on to the next item + if (MathBN.lte(fulfillableQuantity, 0)) { + continue + } - return MathBN.lt(bPrice, aPrice) ? -1 : 1 + items.push({ + item_id: eligibleTargetItem.id, + quantity: MathBN.min(fulfillableQuantity, targetQuantity), }) - let remainingQtyToApply = MathBN.convert(applyToQuantity) + eligibleTargetItemMap.set(promotion.code!, items) + } + + const targetItems = Array.from(eligibleTargetItemMap.values()).flat(1) + const availableTargetQuantity = targetItems.reduce( + (sum, item) => MathBN.sum(sum, item.quantity), + MathBN.convert(0) + ) + + // If we were able to match the target requirements across all line items, we return early. + if (MathBN.lt(availableTargetQuantity, targetQuantity)) { + return [] + } + + let remainingQtyToApply = MathBN.convert(targetQuantity) - for (const method of validItemsForTargetRules) { - const appliedPromoValue = methodIdPromoValueMap.get(method.id) ?? 0 - const multiplier = MathBN.min(method.quantity, remainingQtyToApply) - const div = MathBN.eq(method.quantity, 0) ? 1 : method.quantity - const amount = MathBN.mult(MathBN.div(method.subtotal, div), multiplier) + for (const targetItem of targetItems) { + const item = itemsMap.get(targetItem.item_id)! + const appliedPromoValue = methodIdPromoValueMap.get(item.id) ?? 0 + const multiplier = MathBN.min(targetItem.quantity, remainingQtyToApply) + const amount = MathBN.mult( + MathBN.div(item.subtotal, item.quantity), + multiplier + ) const newRemainingQtyToApply = MathBN.sub(remainingQtyToApply, multiplier) if (MathBN.lt(newRemainingQtyToApply, 0) || MathBN.lte(amount, 0)) { @@ -99,11 +196,11 @@ export function getComputedActionsForBuyGet( continue } - methodIdPromoValueMap.set(method.id, MathBN.add(appliedPromoValue, amount)) + methodIdPromoValueMap.set(item.id, MathBN.add(appliedPromoValue, amount)) computedActions.push({ action: ComputedActions.ADD_ITEM_ADJUSTMENT, - item_id: method.id, + item_id: item.id, amount, code: promotion.code!, }) From 15c6b0fbeabba622ebb022c2f43573cf368463ed Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Tue, 10 Sep 2024 13:50:32 +0200 Subject: [PATCH 2/2] chore: fix qualifiication rules --- .../promotion-module/compute-actions.spec.ts | 212 +++++++++++++++++- .../src/services/promotion-module.ts | 9 +- .../src/utils/compute-actions/buy-get.ts | 103 ++++++--- 3 files changed, 285 insertions(+), 39 deletions(-) diff --git a/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts index a0d57d4f7ef89..528b041c4a79a 100644 --- a/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts +++ b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts @@ -4758,6 +4758,214 @@ moduleIntegrationTestRunner({ ]) }) + it("should compute adjustment accurately for a single item when multiple buyget promos are applied", async () => { + const buyXGetXPromotionBulk1 = await createDefaultPromotion( + service, + { + code: "BUY50GET1000", + type: PromotionType.BUYGET, + campaign_id: null, + application_method: { + type: "fixed", + target_type: "items", + value: 20000, + allocation: "each", + max_quantity: 1000, + apply_to_quantity: 1000, + buy_rules_min_quantity: 50, + target_rules: [ + { + attribute: "product.id", + operator: "eq", + values: [product1], + }, + ], + buy_rules: [ + { + attribute: "product.id", + operator: "eq", + values: [product1], + }, + ], + } as any, + } + ) + + const buyXGetXPromotionBulk2 = await createDefaultPromotion( + service, + { + code: "BUY10GET20", + type: PromotionType.BUYGET, + campaign_id: null, + application_method: { + type: "fixed", + target_type: "items", + value: 20000, + allocation: "each", + max_quantity: 20, + apply_to_quantity: 20, + buy_rules_min_quantity: 10, + target_rules: [ + { + attribute: "product.id", + operator: "eq", + values: [product1], + }, + ], + buy_rules: [ + { + attribute: "product.id", + operator: "eq", + values: [product1], + }, + ], + } as any, + } + ) + + const context = { + currency_code: "usd", + items: [ + { + id: "item_cotton_tshirt", + quantity: 1080, + subtotal: 2700, + product: { id: product1 }, + }, + ], + } + + const result = await service.computeActions( + [buyXGetXPromotionBulk1.code!, buyXGetXPromotionBulk2.code!], + context + ) + + expect(JSON.parse(JSON.stringify(result))).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 2500, + code: "BUY50GET1000", + }, + { + action: "addItemAdjustment", + amount: 50, + code: "BUY10GET20", + item_id: "item_cotton_tshirt", + }, + ]) + }) + + it("should compute adjustment accurately for multiple items when multiple buyget promos are applied", async () => { + const buyXGetXPromotionBulk1 = await createDefaultPromotion( + service, + { + code: "BUY50GET1000", + type: PromotionType.BUYGET, + campaign_id: null, + application_method: { + type: "fixed", + target_type: "items", + value: 20000, + allocation: "each", + max_quantity: 1000, + apply_to_quantity: 1000, + buy_rules_min_quantity: 50, + target_rules: [ + { + attribute: "product.id", + operator: "eq", + values: [product1], + }, + ], + buy_rules: [ + { + attribute: "product.id", + operator: "eq", + values: [product1], + }, + ], + } as any, + } + ) + + const buyXGetXPromotionBulk2 = await createDefaultPromotion( + service, + { + code: "BUY10GET20", + type: PromotionType.BUYGET, + campaign_id: null, + application_method: { + type: "fixed", + target_type: "items", + value: 20000, + allocation: "each", + max_quantity: 20, + apply_to_quantity: 20, + buy_rules_min_quantity: 10, + target_rules: [ + { + attribute: "product.id", + operator: "eq", + values: [product1], + }, + ], + buy_rules: [ + { + attribute: "product.id", + operator: "eq", + values: [product1], + }, + ], + } as any, + } + ) + + const context = { + currency_code: "usd", + items: [ + { + id: "item_cotton_tshirt", + quantity: 540, + subtotal: 1350, + product: { id: product1 }, + }, + { + id: "item_cotton_tshirt2", + quantity: 540, + subtotal: 1350, + product: { id: product1 }, + }, + ], + } + + const result = await service.computeActions( + [buyXGetXPromotionBulk1.code!, buyXGetXPromotionBulk2.code!], + context + ) + + expect(JSON.parse(JSON.stringify(result))).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt2", + amount: 1225, + code: "BUY50GET1000", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 1275, + code: "BUY50GET1000", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt2", + amount: 50, + code: "BUY10GET20", + }, + ]) + }) + it("should compute adjustment accurately across items", async () => { const context = { currency_code: "usd", @@ -4797,13 +5005,13 @@ moduleIntegrationTestRunner({ expect(JSON.parse(JSON.stringify(result))).toEqual([ { action: "addItemAdjustment", - item_id: "item_cotton_tshirt", + item_id: "item_cotton_tshirt1", amount: 500, code: "PROMOTION_TEST", }, { action: "addItemAdjustment", - item_id: "item_cotton_tshirt1", + item_id: "item_cotton_tshirt", amount: 500, code: "PROMOTION_TEST", }, diff --git a/packages/modules/promotion/src/services/promotion-module.ts b/packages/modules/promotion/src/services/promotion-module.ts index ef97b4fae223e..190aff8672174 100644 --- a/packages/modules/promotion/src/services/promotion-module.ts +++ b/packages/modules/promotion/src/services/promotion-module.ts @@ -53,6 +53,7 @@ import { validateApplicationMethodAttributes, validatePromotionRuleAttributes, } from "@utils" +import { EligibleItem } from "src/utils/compute-actions" import { joinerConfig } from "../joiner-config" import { CreatePromotionRuleValueDTO } from "../types/promotion-rule-value" @@ -337,6 +338,10 @@ export default class PromotionModuleService PromotionTypes.ComputeActionAdjustmentLine[] >() const methodIdPromoValueMap = new Map() + // Keeps a map of all elgible items in the buy section and its eligible quantity + const eligibleBuyItemMap = new Map() + // Keeps a map of all elgible items in the target section and its eligible quantity + const eligibleTargetItemMap = new Map() const automaticPromotions = preventAutoPromotions ? [] : await this.listPromotions( @@ -468,7 +473,9 @@ export default class PromotionModuleService ComputeActionUtils.getComputedActionsForBuyGet( promotion, applicationContext[ApplicationMethodTargetType.ITEMS]!, - methodIdPromoValueMap + methodIdPromoValueMap, + eligibleBuyItemMap, + eligibleTargetItemMap ) computedActions.push(...computedActionsForItems) diff --git a/packages/modules/promotion/src/utils/compute-actions/buy-get.ts b/packages/modules/promotion/src/utils/compute-actions/buy-get.ts index 9b90853366973..c3a3d33e0677d 100644 --- a/packages/modules/promotion/src/utils/compute-actions/buy-get.ts +++ b/packages/modules/promotion/src/utils/compute-actions/buy-get.ts @@ -13,20 +13,20 @@ import { import { areRulesValidForContext } from "../validations" import { computeActionForBudgetExceeded } from "./usage" -type EligibleItem = { item_id: string; quantity: BigNumberInput } +export type EligibleItem = { + item_id: string + quantity: BigNumberInput +} function sortByPrice(a: ComputeActionItemLine, b: ComputeActionItemLine) { - const aPrice = MathBN.div(a.subtotal, a.quantity) - const bPrice = MathBN.div(b.subtotal, b.quantity) - - return MathBN.lt(bPrice, aPrice) ? -1 : 1 + return MathBN.lt(a.subtotal, b.subtotal) ? 1 : -1 } /* Grabs all the items in the context where the rules apply We then sort by price to prioritize most valuable item */ -function fetchEligibleItemsByRules( +function filterItemsByPromotionRules( itemsContext: ComputeActionItemLine[], rules?: PromotionTypes.PromotionRuleDTO[] ) { @@ -41,17 +41,14 @@ function fetchEligibleItemsByRules( .sort(sortByPrice) } -// TODO: calculations should eventually move to a totals util outside of the module export function getComputedActionsForBuyGet( promotion: PromotionTypes.PromotionDTO, itemsContext: ComputeActionItemLine[], - methodIdPromoValueMap: Map + methodIdPromoValueMap: Map, + eligibleBuyItemMap: Map, + eligibleTargetItemMap: Map ): PromotionTypes.ComputeActions[] { const computedActions: PromotionTypes.ComputeActions[] = [] - // Keeps a map of all elgible items in the buy section and its eligible quantity - const eligibleBuyItemMap = new Map() - // Keeps a map of all elgible items in the target section and its eligible quantity - const eligibleTargetItemMap = new Map() const minimumBuyQuantity = MathBN.convert( promotion.application_method?.buy_rules_min_quantity ?? 0 @@ -68,7 +65,7 @@ export function getComputedActionsForBuyGet( ) } - const eligibleBuyItems = fetchEligibleItemsByRules( + const eligibleBuyItems = filterItemsByPromotionRules( itemsContext, promotion.application_method?.buy_rules ) @@ -90,27 +87,51 @@ export function getComputedActionsForBuyGet( as eligible, we can't use this item or its partial remaining quantity when we apply the promotion on the target item. - We build a 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) { - const buyItems = eligibleBuyItemMap.get(promotion.code!) || [] - const eligibleQuantity = MathBN.sum(...buyItems.map((b) => b.quantity)) - const reservableQuantity = MathBN.sub(minimumBuyQuantity, eligibleQuantity) + 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)) { + break + } + + const eligibleQuantity = MathBN.sum( + ...eligibleItemsByPromotion + .filter((buy) => buy.item_id === eligibleBuyItem.id) + .map((b) => b.quantity) + ) + + const reservableQuantity = MathBN.min( + eligibleBuyItem.quantity, + MathBN.sub(minimumBuyQuantity, eligibleQuantity) + ) // If we have reached the required minimum quantity, we break the loop early if (MathBN.lte(reservableQuantity, 0)) { break } - buyItems.push({ + eligibleItemsByPromotion.push({ item_id: eligibleBuyItem.id, - quantity: MathBN.min(eligibleBuyItem.quantity, reservableQuantity), + quantity: MathBN.min( + eligibleBuyItem.quantity, + reservableQuantity + ).toNumber(), }) - eligibleBuyItemMap.set(promotion.code!, buyItems) + eligibleBuyItemMap.set(promotion.code!, eligibleItemsByPromotion) } - const eligibleTargetItems = fetchEligibleItemsByRules( + const eligibleTargetItems = filterItemsByPromotionRules( itemsContext, promotion.application_method?.target_rules ) @@ -128,18 +149,18 @@ export function getComputedActionsForBuyGet( This is done to prevent applying promotion to the same item we use to qualify the buy rules. */ for (const eligibleTargetItem of eligibleTargetItems) { - const items = eligibleTargetItemMap.get(promotion.code!) || [] - const eligibleBuyItems = ( - eligibleBuyItemMap.get(promotion.code!) || [] - ).filter((buy) => buy.item_id === eligibleTargetItem.id) - const inapplicableQuantity = MathBN.sum( - ...eligibleBuyItems.map((b) => b.quantity) + ...Array.from(eligibleBuyItemMap.values()) + .flat(1) + .filter((buy) => buy.item_id === eligibleTargetItem.id) + .map((b) => b.quantity) ) + const applicableQuantity = MathBN.sub( eligibleTargetItem.quantity, inapplicableQuantity ) + const fulfillableQuantity = MathBN.min(targetQuantity, applicableQuantity) // If we have reached the required quantity to target from this item, we @@ -148,35 +169,42 @@ export function getComputedActionsForBuyGet( continue } - items.push({ + const targetItemsByPromotion = + eligibleTargetItemMap.get(promotion.code!) || [] + + targetItemsByPromotion.push({ item_id: eligibleTargetItem.id, - quantity: MathBN.min(fulfillableQuantity, targetQuantity), + quantity: MathBN.min(fulfillableQuantity, targetQuantity).toNumber(), }) - eligibleTargetItemMap.set(promotion.code!, items) + eligibleTargetItemMap.set(promotion.code!, targetItemsByPromotion) } - const targetItems = Array.from(eligibleTargetItemMap.values()).flat(1) - const availableTargetQuantity = targetItems.reduce( + const targetItemsByPromotion = + eligibleTargetItemMap.get(promotion.code!) || [] + + const targettableQuantity = targetItemsByPromotion.reduce( (sum, item) => MathBN.sum(sum, item.quantity), MathBN.convert(0) ) // If we were able to match the target requirements across all line items, we return early. - if (MathBN.lt(availableTargetQuantity, targetQuantity)) { + if (MathBN.lt(targettableQuantity, targetQuantity)) { return [] } let remainingQtyToApply = MathBN.convert(targetQuantity) - for (const targetItem of targetItems) { + for (const targetItem of targetItemsByPromotion) { const item = itemsMap.get(targetItem.item_id)! - const appliedPromoValue = methodIdPromoValueMap.get(item.id) ?? 0 + const appliedPromoValue = + methodIdPromoValueMap.get(item.id) ?? MathBN.convert(0) const multiplier = MathBN.min(targetItem.quantity, remainingQtyToApply) const amount = MathBN.mult( MathBN.div(item.subtotal, item.quantity), multiplier ) + const newRemainingQtyToApply = MathBN.sub(remainingQtyToApply, multiplier) if (MathBN.lt(newRemainingQtyToApply, 0) || MathBN.lte(amount, 0)) { @@ -196,7 +224,10 @@ export function getComputedActionsForBuyGet( continue } - methodIdPromoValueMap.set(item.id, MathBN.add(appliedPromoValue, amount)) + methodIdPromoValueMap.set( + item.id, + MathBN.add(appliedPromoValue, amount).toNumber() + ) computedActions.push({ action: ComputedActions.ADD_ITEM_ADJUSTMENT,