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 (