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 (