From c870a7a1baf7db748a831265f06dd4b7378b262e Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Wed, 26 Mar 2025 23:46:50 +0100 Subject: [PATCH] fix(utils,promotion): fix case where mutliple percentage promotions werent applied (#11992) what: - fix case where mutliple percentage promotions werent applied --- .changeset/witty-bees-pump.md | 6 + .../core/utils/src/totals/promotion/index.ts | 100 +++++++- .../promotion-module/compute-actions.spec.ts | 233 +++++++++++++++++- 3 files changed, 330 insertions(+), 9 deletions(-) create mode 100644 .changeset/witty-bees-pump.md diff --git a/.changeset/witty-bees-pump.md b/.changeset/witty-bees-pump.md new file mode 100644 index 0000000000..43ec918906 --- /dev/null +++ b/.changeset/witty-bees-pump.md @@ -0,0 +1,6 @@ +--- +"@medusajs/promotion": patch +"@medusajs/utils": patch +--- + +fix(promotion,utils): fix case where multiple percentage promotions werent applied diff --git a/packages/core/utils/src/totals/promotion/index.ts b/packages/core/utils/src/totals/promotion/index.ts index b869c32ca3..8a443cd3fd 100644 --- a/packages/core/utils/src/totals/promotion/index.ts +++ b/packages/core/utils/src/totals/promotion/index.ts @@ -9,12 +9,26 @@ function getPromotionValueForPercentage(promotion, lineItemTotal) { return MathBN.mult(MathBN.div(promotion.value, 100), lineItemTotal) } -function getPromotionValueForFixed(promotion, lineItemTotal, lineItemsTotal) { +function getPromotionValueForFixed(promotion, itemTotal, allItemsTotal) { if (promotion.allocation === ApplicationMethodAllocation.ACROSS) { - return MathBN.mult( - MathBN.div(lineItemTotal, lineItemsTotal), + const promotionValueForItem = MathBN.mult( + MathBN.div(itemTotal, allItemsTotal), promotion.value ) + + if (MathBN.lte(promotionValueForItem, itemTotal)) { + return promotionValueForItem + } + + const percentage = MathBN.div( + MathBN.mult(itemTotal, 100), + promotionValueForItem + ) + + return MathBN.mult( + promotionValueForItem, + MathBN.div(percentage, 100) + ).precision(4) } return promotion.value @@ -45,12 +59,84 @@ export function calculateAdjustmentAmountFromPromotion( promotion, lineItemsTotal: BigNumberInput = 0 ) { - const quantity = getApplicableQuantity(lineItem, promotion.max_quantity) - const lineItemTotal = MathBN.mult(getLineItemUnitPrice(lineItem), quantity) - const applicableTotal = MathBN.sub(lineItemTotal, promotion.applied_value) + /* + For a promotion with an across allocation, we consider not only the line item total, but also the total of all other line items in the order. + + We then distribute the promotion value proportionally across the line items based on the total of each line item. + + For example, if the promotion is 100$, and the order total is 400$, and the items are: + item1: 250$ + item2: 150$ + total: 400$ + + The promotion value for the line items would be: + item1: 62.5$ + item2: 37.5$ + total: 100$ + + For the next 100$ promotion, we remove the applied promotions value from the line item total and redistribute the promotion value across the line items based on the updated totals. + + Example: + item1: (250 - 62.5) = 187.5 + item2: (150 - 37.5) = 112.5 + total: 300 + + The promotion value for the line items would be: + item1: $62.5 + item2: $37.5 + total: 100$ + + */ + if (promotion.allocation === ApplicationMethodAllocation.ACROSS) { + const quantity = getApplicableQuantity(lineItem, promotion.max_quantity) + const lineItemTotal = MathBN.mult(getLineItemUnitPrice(lineItem), quantity) + const applicableTotal = MathBN.sub(lineItemTotal, promotion.applied_value) + + if (MathBN.lte(applicableTotal, 0)) { + return applicableTotal + } + + const promotionValue = getPromotionValue( + promotion, + applicableTotal, + lineItemsTotal + ) + + return MathBN.min(promotionValue, applicableTotal) + } + + /* + For a promotion with an EACH allocation, we calculate the promotion value on the line item as a whole. + + Example: + item1: { + subtotal: 200$, + unit_price: 50$, + quantity: 4, + } + + When applying promotions, we need to consider 2 values: + 1. What is the maximum promotion value? + 2. What is the maximum promotion we can apply on the line item? + + After applying each promotion, we reduce the maximum promotion that you can add to the line item by the value of the promotions applied. + + We then apply whichever is lower. + */ + + const remainingItemTotal = MathBN.sub( + lineItem.subtotal, + promotion.applied_value + ) + const unitPrice = MathBN.div(lineItem.subtotal, lineItem.quantity) + const maximumPromotionTotal = MathBN.mult( + unitPrice, + promotion.max_quantity ?? MathBN.convert(1) + ) + const applicableTotal = MathBN.min(remainingItemTotal, maximumPromotionTotal) if (MathBN.lte(applicableTotal, 0)) { - return applicableTotal + return MathBN.convert(0) } const promotionValue = getPromotionValue( 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 70cb09f147..e2eb239a08 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 @@ -736,7 +736,7 @@ moduleIntegrationTestRunner({ { action: "addItemAdjustment", item_id: "item_cotton_tshirt", - amount: 2, + amount: 5, code: "PROMOTION_TEST_2", }, { @@ -748,6 +748,81 @@ moduleIntegrationTestRunner({ ]) }) + it("should compute actions when 2 percentage promotions are applied to the same item", async () => { + await createDefaultPromotion(service, { + code: "PROMO_PERCENTAGE_1", + rules: [], + application_method: { + type: ApplicationMethodType.PERCENTAGE, + target_type: "items", + allocation: "each", + value: 100, + max_quantity: 1, + target_rules: [ + { + attribute: "product.id", + operator: "eq", + values: ["prod_tshirt"], + }, + ], + }, + }) + + await createDefaultPromotion(service, { + code: "PROMO_PERCENTAGE_2", + rules: [], + application_method: { + type: ApplicationMethodType.PERCENTAGE, + target_type: "items", + allocation: "each", + value: 100, + max_quantity: 1, + target_rules: [ + { + attribute: "product.id", + operator: "eq", + values: ["prod_tshirt"], + }, + ], + }, + }) + + const result = await service.computeActions( + ["PROMO_PERCENTAGE_1", "PROMO_PERCENTAGE_2"], + { + currency_code: "usd", + items: [ + { + id: "item_cotton_tshirt", + quantity: 4, + subtotal: 200, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", + }, + }, + ], + } + ) + + expect(JSON.parse(JSON.stringify(result))).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 50, + code: "PROMO_PERCENTAGE_1", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 50, + code: "PROMO_PERCENTAGE_2", + }, + ]) + }) + it("should not compute actions when applicable total is 0", async () => { await createDefaultPromotion(service, { rules: [ @@ -1695,6 +1770,160 @@ moduleIntegrationTestRunner({ ]) }) + it("should compute actions when 2 percentage promotions are applied to the same item", async () => { + await createDefaultPromotion(service, { + code: "PROMO_PERCENTAGE_1", + rules: [], + application_method: { + type: ApplicationMethodType.PERCENTAGE, + target_type: "items", + allocation: "across", + value: 50, + target_rules: [ + { + attribute: "product.id", + operator: "eq", + values: ["prod_tshirt"], + }, + ], + }, + }) + + await createDefaultPromotion(service, { + code: "PROMO_PERCENTAGE_2", + rules: [], + application_method: { + type: ApplicationMethodType.PERCENTAGE, + target_type: "items", + allocation: "across", + value: 50, + target_rules: [ + { + attribute: "product.id", + operator: "eq", + values: ["prod_tshirt"], + }, + ], + }, + }) + + const result = await service.computeActions( + ["PROMO_PERCENTAGE_1", "PROMO_PERCENTAGE_2"], + { + currency_code: "usd", + items: [ + { + id: "item_cotton_tshirt", + quantity: 4, + subtotal: 300, + product: { + id: "prod_tshirt", + }, + }, + { + id: "item_wool_tshirt", + quantity: 4, + subtotal: 100, + product: { + id: "prod_tshirt", + }, + }, + ], + } + ) + + expect(JSON.parse(JSON.stringify(result))).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 150, + code: "PROMO_PERCENTAGE_1", + }, + { + action: "addItemAdjustment", + item_id: "item_wool_tshirt", + amount: 50, + code: "PROMO_PERCENTAGE_1", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 75, + code: "PROMO_PERCENTAGE_2", + }, + { + action: "addItemAdjustment", + item_id: "item_wool_tshirt", + amount: 25, + code: "PROMO_PERCENTAGE_2", + }, + ]) + + await createDefaultPromotion(service, { + code: "PROMO_PERCENTAGE_3", + rules: [], + application_method: { + type: ApplicationMethodType.PERCENTAGE, + target_type: "items", + allocation: "across", + value: 100, + target_rules: [ + { + attribute: "product.id", + operator: "eq", + values: ["prod_tshirt"], + }, + ], + }, + }) + + const result2 = await service.computeActions( + [ + "PROMO_PERCENTAGE_1", + "PROMO_PERCENTAGE_2", + "PROMO_PERCENTAGE_3", + ], + { + currency_code: "usd", + items: [ + { + id: "item_cotton_tshirt", + quantity: 4, + subtotal: 300, + product: { + id: "prod_tshirt", + }, + }, + { + id: "item_wool_tshirt", + quantity: 4, + subtotal: 100, + product: { + id: "prod_tshirt", + }, + }, + ], + } + ) + + // Should only apply the most valuable promotion (100% off - PROMO_PERCENTAGE_3) that covers + // the entire total of the line item + expect(JSON.parse(JSON.stringify(result2))).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 300, + code: "PROMO_PERCENTAGE_3", + }, + { + action: "addItemAdjustment", + item_id: "item_wool_tshirt", + amount: 100, + code: "PROMO_PERCENTAGE_3", + }, + ]) + }) + it("should not compute actions when applicable total is 0", async () => { await createDefaultPromotion(service, { rules: [ @@ -3937,7 +4166,7 @@ moduleIntegrationTestRunner({ }, }, ], - }) + } as any) expect(JSON.parse(JSON.stringify(result))).toEqual([ {