From ca038ff58392ff1cd487e38dd4296ddfea747432 Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Sun, 31 Aug 2025 15:35:36 +0200 Subject: [PATCH] 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 --- .../promotions/admin/promotions.spec.ts | 6 +- .../create-promotion-form.tsx | 81 +- .../promotion-module/compute-actions.spec.ts | 787 +++++++++++++++++- .../promotion-module/promotion.spec.ts | 16 + packages/modules/promotion/package.json | 1 + .../src/migrations/Migration20250828075407.ts | 36 + .../src/utils/compute-actions/buy-get.ts | 700 +++++++++++----- .../utils/validations/application-method.ts | 21 + 8 files changed, 1406 insertions(+), 242 deletions(-) create mode 100644 packages/modules/promotion/src/migrations/Migration20250828075407.ts diff --git a/integration-tests/http/__tests__/promotions/admin/promotions.spec.ts b/integration-tests/http/__tests__/promotions/admin/promotions.spec.ts index 991292b863..60340517c6 100644 --- a/integration-tests/http/__tests__/promotions/admin/promotions.spec.ts +++ b/integration-tests/http/__tests__/promotions/admin/promotions.spec.ts @@ -2725,7 +2725,8 @@ medusaIntegrationTestRunner({ application_method: { type: "fixed", target_type: "items", - allocation: "across", + allocation: "each", + max_quantity: 1, value: 100, apply_to_quantity: 1, buy_rules_min_quantity: 1, @@ -2896,7 +2897,8 @@ medusaIntegrationTestRunner({ type: "fixed", currency_code: "usd", target_type: "items", - allocation: "across", + allocation: "each", + max_quantity: 1, value: 100, apply_to_quantity: 1, buy_rules_min_quantity: 1, diff --git a/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/create-promotion-form.tsx b/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/create-promotion-form.tsx index 684d4dd913..7a430990fd 100644 --- a/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/create-promotion-form.tsx +++ b/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/create-promotion-form.tsx @@ -288,6 +288,7 @@ export const CreatePromotionForm = () => { }) const isTypeStandard = watchType === "standard" + const isTypeBuyGet = watchType === "buyget" const targetType = useWatch({ control: form.control, @@ -811,44 +812,52 @@ export const CreatePromotionForm = () => { )} - {isTypeStandard && watchAllocation === "each" && ( - { - return ( - - - {t("promotions.form.max_quantity.title")} - + {((isTypeStandard && watchAllocation === "each") || + isTypeBuyGet) && ( + <> + {isTypeBuyGet && ( + <> + + + )} + { + return ( + + + {t("promotions.form.max_quantity.title")} + - - - + + + - - ]} - /> - - - ) - }} - /> + + ]} + /> + + + ) + }} + /> + )} {isTypeStandard && 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 8ff4979a95..a642ea41ee 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 @@ -1,5 +1,10 @@ import { IPromotionModuleService } from "@medusajs/framework/types" -import { ApplicationMethodType, Modules, PromotionStatus, PromotionType, } from "@medusajs/framework/utils" +import { + ApplicationMethodType, + Modules, + PromotionStatus, + PromotionType, +} from "@medusajs/framework/utils" import { moduleIntegrationTestRunner, SuiteOptions } from "@medusajs/test-utils" import { createCampaigns } from "../../../__fixtures__/campaigns" import { createDefaultPromotion } from "../../../__fixtures__/promotion" @@ -4762,7 +4767,7 @@ moduleIntegrationTestRunner({ target_type: "items", value: 100, allocation: "each", - max_quantity: 1, + max_quantity: 100, apply_to_quantity: 1, buy_rules_min_quantity: 1, target_rules: [ @@ -4791,7 +4796,7 @@ moduleIntegrationTestRunner({ { action: "addItemAdjustment", item_id: "item_cotton_tshirt2", - amount: 1000, + amount: 2000, code: "PROMOTION_TEST", }, ]) @@ -4943,7 +4948,7 @@ moduleIntegrationTestRunner({ type: "percentage", target_type: "items", allocation: "each", - max_quantity: 1, + max_quantity: 100, value: 100, apply_to_quantity: 4, buy_rules_min_quantity: 1, @@ -5045,7 +5050,7 @@ moduleIntegrationTestRunner({ target_type: "items", allocation: "each", value: 1000, - max_quantity: 1, + max_quantity: 4, apply_to_quantity: 4, buy_rules_min_quantity: 1, target_rules: [ @@ -5141,11 +5146,389 @@ moduleIntegrationTestRunner({ ]) }) + it("should handle 2+1 free promotion correctly for same product", async () => { + const twoGetOneFreePromotion = await createDefaultPromotion( + service, + { + code: "2PLUS1FREE", + type: PromotionType.BUYGET, + application_method: { + type: "percentage", + target_type: "items", + value: 100, + allocation: "each", + max_quantity: 10, // Allow multiple applications + apply_to_quantity: 1, + 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, + } + ) + + // Test with 2 items - should get no promotion (need at least 3 for 2+1) + let context = { + currency_code: "usd", + items: [ + { + id: "item_1", + quantity: 2, + subtotal: 1000, + product: { id: product1 }, + }, + ], + } + + let result = await service.computeActions( + [twoGetOneFreePromotion.code!], + context + ) + + expect(JSON.parse(JSON.stringify(result))).toEqual([]) + + // Test with 3 items - should get 1 free + context = { + currency_code: "usd", + items: [ + { + id: "item_1", + quantity: 3, + subtotal: 1500, + product: { id: product1 }, + }, + ], + } + + result = await service.computeActions( + [twoGetOneFreePromotion.code!], + context + ) + + expect(JSON.parse(JSON.stringify(result))).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_1", + amount: 500, // 1 item * (1500/3) = 500 + code: "2PLUS1FREE", + }, + ]) + + // Test with 5 items - should get 1 free (not 2, as you need 6 for 2 free) + context = { + currency_code: "usd", + items: [ + { + id: "item_1", + quantity: 5, + subtotal: 2500, + product: { id: product1 }, + }, + ], + } + + result = await service.computeActions( + [twoGetOneFreePromotion.code!], + context + ) + + expect(JSON.parse(JSON.stringify(result))).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_1", + amount: 500, // 1 item * (2500/5) = 500 + code: "2PLUS1FREE", + }, + ]) + + // Test with 6 items - should get 2 free + context = { + currency_code: "usd", + items: [ + { + id: "item_1", + quantity: 6, + subtotal: 3000, + product: { id: product1 }, + }, + ], + } + + result = await service.computeActions( + [twoGetOneFreePromotion.code!], + context + ) + + expect(JSON.parse(JSON.stringify(result))).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_1", + amount: 1000, // 2 items * (3000/6) = 1000 + code: "2PLUS1FREE", + }, + ]) + }) + + it("should handle multiple 2+1 free promotions correctly for same product", async () => { + // Apply 2+1 free promotion on the same product to a maximum of 2 items + const firstTwoGetOneFreePromotion = await createDefaultPromotion( + service, + { + code: "FIRST2PLUS1FREE", + type: PromotionType.BUYGET, + application_method: { + type: "percentage", + target_type: "items", + value: 100, + allocation: "each", + max_quantity: 2, + apply_to_quantity: 1, + 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, + } + ) + + // Apply 2+1 free promotion on the same product to a maximum of 1 item + const secondTwoGetOneFreePromotion = await createDefaultPromotion( + service, + { + code: "SECOND2PLUS1FREE", + type: PromotionType.BUYGET, + application_method: { + type: "percentage", + target_type: "items", + value: 100, + allocation: "each", + max_quantity: 1, + apply_to_quantity: 1, + 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, + } + ) + + // Test with 3 items - should get 1 free from first promotion (2 buy + 1 target) + let context = { + currency_code: "usd", + items: [ + { + id: "item_1", + quantity: 3, + subtotal: 1500, + product: { id: product1 }, + }, + ], + } + + let result = await service.computeActions( + [ + firstTwoGetOneFreePromotion.code!, + secondTwoGetOneFreePromotion.code!, + ], + context + ) + + // Only first promotion should apply (3 items: 2 buy + 1 target = 3, no items left for second) + expect(JSON.parse(JSON.stringify(result))).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_1", + amount: 500, // 1 item * (1500/3) = 500 + code: "FIRST2PLUS1FREE", + }, + ]) + + // Test with 6 items - should get 2 free total (2 from first promotion and 1 from second promotion) + context = { + currency_code: "usd", + items: [ + { + id: "item_1", + quantity: 6, + subtotal: 3000, + product: { id: product1 }, + }, + ], + } + + result = await service.computeActions( + [ + firstTwoGetOneFreePromotion.code!, + secondTwoGetOneFreePromotion.code!, + ], + context + ) + + // Both promotions should apply: 6 items allows for 2 applications from first promotion and 1 from second promotion + expect(JSON.parse(JSON.stringify(result))).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_1", + amount: 1000, // 2 item * (3000/6) = 1000 + code: "FIRST2PLUS1FREE", + }, + { + action: "addItemAdjustment", + item_id: "item_1", + amount: 500, // 1 item * (3000/6) = 500 + code: "SECOND2PLUS1FREE", + }, + ]) + + // Test with 7 items - should still get 2 free total (not 3) (2 from first promotion and 1 from second promotion) + context = { + currency_code: "usd", + items: [ + { + id: "item_1", + quantity: 7, + subtotal: 3500, + product: { id: product1 }, + }, + ], + } + + result = await service.computeActions( + [ + firstTwoGetOneFreePromotion.code!, + secondTwoGetOneFreePromotion.code!, + ], + context + ) + + // 7 items: first promotion uses 3 (2+1), second uses 3 (2+1), 1 item left over (2 from first promotion and 1 from second promotion) + expect(JSON.parse(JSON.stringify(result))).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_1", + amount: 1000, // 2 item * (3500/7) = 1000 + code: "FIRST2PLUS1FREE", + }, + { + action: "addItemAdjustment", + item_id: "item_1", + amount: 500, // 1 item * (3500/7) = 500 + code: "SECOND2PLUS1FREE", + }, + ]) + + // Test with 9 items - should get 3 free total (2 from first promotion and 1 from second promotion) + context = { + currency_code: "usd", + items: [ + { + id: "item_1", + quantity: 9, + subtotal: 4500, + product: { id: product1 }, + }, + ], + } + + result = await service.computeActions( + [ + firstTwoGetOneFreePromotion.code!, + secondTwoGetOneFreePromotion.code!, + ], + context + ) + + // 9 items: first promotion can apply twice (6 items), second once (3 items) (2 from first promotion and 1 from second promotion) + expect(JSON.parse(JSON.stringify(result))).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_1", + amount: 1000, // 2 items * (4500/9) = 1000 + code: "FIRST2PLUS1FREE", + }, + { + action: "addItemAdjustment", + item_id: "item_1", + amount: 500, // 1 item * (4500/9) = 500 + code: "SECOND2PLUS1FREE", + }, + ]) + + context = { + currency_code: "usd", + items: [ + { + id: "item_1", + quantity: 1000, + subtotal: 500000, + product: { id: product1 }, + }, + ], + } + + result = await service.computeActions( + [ + firstTwoGetOneFreePromotion.code!, + secondTwoGetOneFreePromotion.code!, + ], + context + ) + + // 1000 items: first promotion can apply twice (6 items), second once (3 items) (2 from first promotion and 1 from second promotion) + expect(JSON.parse(JSON.stringify(result))).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_1", + amount: 1000, // 2 items * (4500/9) = 1000 + code: "FIRST2PLUS1FREE", + }, + { + action: "addItemAdjustment", + item_id: "item_1", + amount: 500, // 1 item * (4500/9) = 500 + code: "SECOND2PLUS1FREE", + }, + ]) + }) + it("should compute adjustment accurately for a single item when multiple buyget promos are applied", async () => { const buyXGetXPromotionBulk1 = await createDefaultPromotion( service, { - code: "BUY50GET100", + code: "BUY50GET1000", type: PromotionType.BUYGET, campaign_id: null, application_method: { @@ -5177,7 +5560,7 @@ moduleIntegrationTestRunner({ const buyXGetXPromotionBulk2 = await createDefaultPromotion( service, { - code: "BUY10GET20", + code: "BUY10GET200", type: PromotionType.BUYGET, campaign_id: null, application_method: { @@ -5228,12 +5611,12 @@ moduleIntegrationTestRunner({ action: "addItemAdjustment", item_id: "item_cotton_tshirt", amount: 2500, - code: "BUY50GET100", + code: "BUY50GET1000", }, { action: "addItemAdjustment", amount: 10, - code: "BUY10GET20", + code: "BUY10GET200", item_id: "item_cotton_tshirt", }, ]) @@ -5243,7 +5626,7 @@ moduleIntegrationTestRunner({ const buyXGetXPromotionBulk1 = await createDefaultPromotion( service, { - code: "BUY50GET100", + code: "BUY50GET1000", type: PromotionType.BUYGET, campaign_id: null, application_method: { @@ -5275,7 +5658,7 @@ moduleIntegrationTestRunner({ const buyXGetXPromotionBulk2 = await createDefaultPromotion( service, { - code: "BUY10GET20", + code: "BUY10GET200", type: PromotionType.BUYGET, campaign_id: null, application_method: { @@ -5336,19 +5719,395 @@ moduleIntegrationTestRunner({ action: "addItemAdjustment", item_id: "item_cotton_tshirt2", amount: 1225, - code: "BUY50GET100", + code: "BUY50GET1000", }, { action: "addItemAdjustment", item_id: "item_cotton_tshirt", amount: 1275, - code: "BUY50GET100", + code: "BUY50GET1000", }, { action: "addItemAdjustment", item_id: "item_cotton_tshirt2", amount: 10, - code: "BUY10GET20", + code: "BUY10GET200", + }, + ]) + ) + }) + + it("should apply buyget promotion multiple times until eligible quantity is exhausted", async () => { + const buyProductId = "item_cotton_tshirt" + const getProductId = "item_cotton_tshirt2" + + const buyXGetXPromotion = await createDefaultPromotion(service, { + code: "TEST_BUYGET_PROMOTION", + type: PromotionType.BUYGET, + status: PromotionStatus.ACTIVE, + is_tax_inclusive: false, + is_automatic: false, + application_method: { + allocation: "each", + value: 100, + max_quantity: 100, + type: "percentage", + target_type: "items", + apply_to_quantity: 1, + buy_rules_min_quantity: 2, + target_rules: [ + { + operator: "eq", + attribute: "items.product.id", + values: [getProductId], + }, + ], + buy_rules: [ + { + operator: "eq", + attribute: "items.product.id", + values: [buyProductId], + }, + ], + }, + }) + + const buyXGetXPromotion2 = await createDefaultPromotion(service, { + code: "TEST_BUYGET_PROMOTION_2", + type: PromotionType.BUYGET, + status: PromotionStatus.ACTIVE, + is_tax_inclusive: false, + is_automatic: false, + application_method: { + allocation: "each", + value: 100, + type: "percentage", + target_type: "items", + apply_to_quantity: 1, + max_quantity: 100, + buy_rules_min_quantity: 1, + target_rules: [ + { + operator: "eq", + attribute: "items.product.id", + values: [getProductId], + }, + ], + buy_rules: [ + { + operator: "eq", + attribute: "items.product.id", + values: [buyProductId], + }, + ], + }, + }) + + const context = { + currency_code: "usd", + items: [ + { + id: getProductId, + quantity: 11, + subtotal: 2750, + original_total: 2750, + is_discountable: true, + product: { id: getProductId }, + }, + { + id: buyProductId, + quantity: 11, + subtotal: 2750, + original_total: 2750, + is_discountable: true, + product: { id: buyProductId }, + }, + ], + } + + const result = await service.computeActions( + [buyXGetXPromotion.code!, buyXGetXPromotion2.code!], + context + ) + + const serializedResult = JSON.parse(JSON.stringify(result)) + + // The first promotion should apply until eligible quantities are exhausted (buy 2 get 1) + // The second promotion should apply to the remaining quantity (buy 1 get 1) + expect(serializedResult).toHaveLength(2) + expect(serializedResult).toEqual( + expect.arrayContaining([ + { + action: "addItemAdjustment", + item_id: getProductId, + amount: 1250, + code: buyXGetXPromotion.code!, + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt2", + amount: 250, + code: "TEST_BUYGET_PROMOTION_2", + }, + ]) + ) + }) + + it("should apply buyget promotion multiple times until max quantity is reached", async () => { + const buyProductId = "item_cotton_tshirt" + const getProductId = "item_cotton_tshirt2" + + const buyXGetXPromotion = await createDefaultPromotion(service, { + code: "TEST_BUYGET_PROMOTION", + type: PromotionType.BUYGET, + status: PromotionStatus.ACTIVE, + is_tax_inclusive: false, + is_automatic: false, + application_method: { + allocation: "each", + value: 100, + type: "percentage", + target_type: "items", + apply_to_quantity: 1, + max_quantity: 2, + buy_rules_min_quantity: 2, + target_rules: [ + { + operator: "eq", + attribute: "items.product.id", + values: [getProductId], + }, + ], + buy_rules: [ + { + operator: "eq", + attribute: "items.product.id", + values: [buyProductId], + }, + ], + }, + }) + + const buyXGetXPromotion2 = await createDefaultPromotion(service, { + code: "TEST_BUYGET_PROMOTION_2", + type: PromotionType.BUYGET, + status: PromotionStatus.ACTIVE, + is_tax_inclusive: false, + is_automatic: false, + application_method: { + allocation: "each", + value: 100, + type: "percentage", + target_type: "items", + apply_to_quantity: 1, + max_quantity: 1, + buy_rules_min_quantity: 1, + target_rules: [ + { + operator: "eq", + attribute: "items.product.id", + values: [getProductId], + }, + ], + buy_rules: [ + { + operator: "eq", + attribute: "items.product.id", + values: [buyProductId], + }, + ], + }, + }) + + const context = { + currency_code: "usd", + items: [ + { + id: getProductId, + quantity: 11, + subtotal: 2750, + original_total: 2750, + is_discountable: true, + product: { id: getProductId }, + }, + { + id: buyProductId, + quantity: 11, + subtotal: 2750, + original_total: 2750, + is_discountable: true, + product: { id: buyProductId }, + }, + ], + } + + const result = await service.computeActions( + [buyXGetXPromotion.code!, buyXGetXPromotion2.code!], + context + ) + + const serializedResult = JSON.parse(JSON.stringify(result)) + + expect(serializedResult).toHaveLength(2) + expect(serializedResult).toEqual( + expect.arrayContaining([ + { + action: "addItemAdjustment", + item_id: getProductId, + amount: 500, + code: buyXGetXPromotion.code!, + }, + { + action: "addItemAdjustment", + item_id: getProductId, + amount: 250, + code: "TEST_BUYGET_PROMOTION_2", + }, + ]) + ) + }) + + it("should apply buyget promotion multiple times until eligible quantity is exhausted on a single item", async () => { + const buyAndGetProductId = "item_cotton_tshirt" + + const buyXGetXPromotion = await createDefaultPromotion(service, { + code: "TEST_BUYGET_PROMOTION", + type: PromotionType.BUYGET, + status: PromotionStatus.ACTIVE, + is_tax_inclusive: false, + is_automatic: false, + application_method: { + allocation: "each", + value: 100, + max_quantity: 100, + type: "percentage", + target_type: "items", + apply_to_quantity: 1, + buy_rules_min_quantity: 2, + target_rules: [ + { + operator: "eq", + attribute: "items.product.id", + values: [buyAndGetProductId], + }, + ], + buy_rules: [ + { + operator: "eq", + attribute: "items.product.id", + values: [buyAndGetProductId], + }, + ], + }, + }) + + const context = { + currency_code: "usd", + items: [ + { + id: buyAndGetProductId, + quantity: 10, + subtotal: 2500, + original_total: 2500, + is_discountable: true, + product: { id: buyAndGetProductId }, + }, + ], + } + + const result = await service.computeActions( + [buyXGetXPromotion.code!], + context + ) + + const serializedResult = JSON.parse(JSON.stringify(result)) + + expect(serializedResult).toHaveLength(1) + // Should apply buy get promotion 3 times to the same item + // Total eligible quantity is 10 + // After first application, (10 - 3 [2 buy + 1 get]) = 7 (eligible) - 250 + // After second application, (7 - 3 [2 buy + 1 get]) = 4 (eligible) - 250 + // After third application, (4 - 3 [2 buy + 1 get]) = 1 (eligible) - 250 + // Fourth application, not eligible as it requires atleast 2 eligible items to buy and 1 eligible item to get + expect(serializedResult).toEqual( + expect.arrayContaining([ + { + action: "addItemAdjustment", + item_id: buyAndGetProductId, + amount: 750, + code: buyXGetXPromotion.code!, + }, + ]) + ) + }) + + it("should apply buyget promotion multiple times until max quantity is reached on a single item", async () => { + const buyAndGetProductId = "item_cotton_tshirt" + + const buyXGetXPromotion = await createDefaultPromotion(service, { + code: "TEST_BUYGET_PROMOTION", + type: PromotionType.BUYGET, + status: PromotionStatus.ACTIVE, + is_tax_inclusive: false, + is_automatic: false, + application_method: { + allocation: "each", + value: 100, + max_quantity: 2, + type: "percentage", + target_type: "items", + apply_to_quantity: 1, + buy_rules_min_quantity: 2, + target_rules: [ + { + operator: "eq", + attribute: "items.product.id", + values: [buyAndGetProductId], + }, + ], + buy_rules: [ + { + operator: "eq", + attribute: "items.product.id", + values: [buyAndGetProductId], + }, + ], + }, + }) + + const context = { + currency_code: "usd", + items: [ + { + id: buyAndGetProductId, + quantity: 10, + subtotal: 2500, + original_total: 2500, + is_discountable: true, + product: { id: buyAndGetProductId }, + }, + ], + } + + const result = await service.computeActions( + [buyXGetXPromotion.code!], + context + ) + + const serializedResult = JSON.parse(JSON.stringify(result)) + expect(serializedResult).toHaveLength(1) + // Should apply buy get promotion 2 times (max quantity) to the same item + // Total eligible quantity is 10 + // After first application, (10 - 2 [2 buy + 1 get]) = 8 (eligible) - 250 + // After second application, (8 - 2 [2 buy + 1 get]) = 6 (eligible) - 250 + // Third application, not eligible it exceeds max quantity + expect(serializedResult).toEqual( + expect.arrayContaining([ + { + action: "addItemAdjustment", + item_id: buyAndGetProductId, + amount: 500, + code: buyXGetXPromotion.code!, }, ]) ) diff --git a/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/promotion.spec.ts b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/promotion.spec.ts index a95577e055..82d2f73403 100644 --- a/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/promotion.spec.ts +++ b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/promotion.spec.ts @@ -388,6 +388,8 @@ moduleIntegrationTestRunner({ type: PromotionType.BUYGET, application_method: { apply_to_quantity: 1, + max_quantity: 1, + allocation: "each", buy_rules_min_quantity: 1, buy_rules: [ { @@ -424,6 +426,8 @@ moduleIntegrationTestRunner({ type: PromotionType.BUYGET, application_method: { apply_to_quantity: 1, + max_quantity: 1, + allocation: "each", buy_rules_min_quantity: 1, buy_rules: [ { @@ -445,6 +449,8 @@ moduleIntegrationTestRunner({ type: PromotionType.BUYGET, application_method: { apply_to_quantity: 1, + max_quantity: 1, + allocation: "each", buy_rules_min_quantity: 1, } as any, }).catch((e) => e) @@ -458,6 +464,8 @@ moduleIntegrationTestRunner({ const error = await createDefaultPromotion(service, { type: PromotionType.BUYGET, application_method: { + max_quantity: 1, + allocation: "each", buy_rules_min_quantity: 1, buy_rules: [ { @@ -486,6 +494,8 @@ moduleIntegrationTestRunner({ type: PromotionType.BUYGET, application_method: { apply_to_quantity: 1, + max_quantity: 1, + allocation: "each", buy_rules: [ { attribute: "product_collection.id", @@ -513,6 +523,8 @@ moduleIntegrationTestRunner({ type: PromotionType.BUYGET, application_method: { apply_to_quantity: 1, + max_quantity: 1, + allocation: "each", buy_rules_min_quantity: 1, buy_rules: [ { @@ -1058,6 +1070,8 @@ moduleIntegrationTestRunner({ type: PromotionType.BUYGET, application_method: { apply_to_quantity: 1, + max_quantity: 1, + allocation: "each", buy_rules_min_quantity: 1, buy_rules: [ { @@ -1276,6 +1290,8 @@ moduleIntegrationTestRunner({ type: PromotionType.BUYGET, application_method: { apply_to_quantity: 1, + max_quantity: 1, + allocation: "each", buy_rules_min_quantity: 1, target_rules: [ { diff --git a/packages/modules/promotion/package.json b/packages/modules/promotion/package.json index abf11ac6f5..bc39a587ad 100644 --- a/packages/modules/promotion/package.json +++ b/packages/modules/promotion/package.json @@ -33,6 +33,7 @@ "migration:initial": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm migration:create --initial", "migration:create": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm migration:create", "migration:up": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm migration:up", + "migration:down": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm migration:down", "orm:cache:clear": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm cache:clear" }, "devDependencies": { diff --git a/packages/modules/promotion/src/migrations/Migration20250828075407.ts b/packages/modules/promotion/src/migrations/Migration20250828075407.ts new file mode 100644 index 0000000000..d94f5c4f42 --- /dev/null +++ b/packages/modules/promotion/src/migrations/Migration20250828075407.ts @@ -0,0 +1,36 @@ +import { Migration } from "@mikro-orm/migrations" + +export class Migration20250828075407 extends Migration { + override async up(): Promise { + const nullApplyToQuantityResult = await this.execute(` + SELECT COUNT(*) as count + FROM promotion_application_method pam + JOIN promotion p ON pam.promotion_id = p.id + WHERE p.type = 'buyget' + AND pam.apply_to_quantity IS NULL + `) + + const resultCount = parseInt(nullApplyToQuantityResult[0]?.count) + + if (resultCount > 0) { + console.log( + `Warning: Found ${resultCount} buy-get promotions with null apply_to_quantity. These should be fixed as apply_to_quantity is required for proper buy-get promotion functionality.` + ) + } + + this.addSql(` + UPDATE promotion_application_method + SET max_quantity = apply_to_quantity + WHERE promotion_id IN ( + SELECT id FROM promotion WHERE type = 'buyget' + ) + AND apply_to_quantity IS NOT NULL + `) + } + + override async down(): Promise { + // Note: This migration cannot be safely rolled back as we don't store + // the original max_quantity values. If rollback is needed, + // the original values would need to be restored manually. + } +} 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 f34153ede7..496d4a92c9 100644 --- a/packages/modules/promotion/src/utils/compute-actions/buy-get.ts +++ b/packages/modules/promotion/src/utils/compute-actions/buy-get.ts @@ -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, + currentPromotionCode: string +): Map { + const remainingQuantities = new Map() + + 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, + remainingTargetQuantities: Map, + 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() + + 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, + methodIdPromoValueMap: Map, + promotion: PromotionTypes.PromotionDTO, + itemsMap: Map, + 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, + remainingTargetQuantities: Map, + application: PromotionApplication +): void { + const totalConsumedQuantities = new Map() + + 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, + 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, + 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, eligibleTargetItemMap: Map ): 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( 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() + 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() + const totalEligibleTargetItemsMap = new Map() + const itemIdPromotionAmountMap = new Map() + 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 } diff --git a/packages/modules/promotion/src/utils/validations/application-method.ts b/packages/modules/promotion/src/utils/validations/application-method.ts index 570849756d..c8dfb04911 100644 --- a/packages/modules/promotion/src/utils/validations/application-method.ts +++ b/packages/modules/promotion/src/utils/validations/application-method.ts @@ -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 (