feat(promotion): Allow buyget promotion to apply multiple times on cart (#13305)

what:

Introduces 2 new features to promotion module:

1. Introduce max quantity limit to promotion application - This will limit the application of the promotion based on the quantity of the target products in the cart. 
2. When applying buy get promotions, we will now apply buyget promotion until eligible items are exhausted or max quantity is reached. 

```
- Buy 2 t-shirts, Get 1 sweater
- Max quantity -> 1

This means you can add two t-shirts, and get 1 sweaters for free. However, if you add four t-shirts, you only get one sweater for free.
```

```
- Buy 2 t-shirts, Get 1 sweater
- Max quantity -> 3

This means you can add six t-shirts, and get three sweaters for free. However, if you add eight t-shirts, you only get three sweaters for free
```

```
- Buy 4 t-shirts, Get 2 sweater
- Max quantity -> 1

This should throw on creation, as the max quantity should as a minimum be the same value as the target rule quantity
```

RESOLVES SUP-2357 / https://github.com/medusajs/medusa/issues/13265
This commit is contained in:
Riqwan Thamir
2025-08-31 15:35:36 +02:00
committed by GitHub
parent f53f027ce6
commit ca038ff583
8 changed files with 1406 additions and 242 deletions

View File

@@ -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,

View File

@@ -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" && (
<Form.Field
control={form.control}
name="application_method.max_quantity"
render={({ field }) => {
return (
<Form.Item className="basis-1/2">
<Form.Label>
{t("promotions.form.max_quantity.title")}
</Form.Label>
{((isTypeStandard && watchAllocation === "each") ||
isTypeBuyGet) && (
<>
{isTypeBuyGet && (
<>
<Divider />
</>
)}
<Form.Field
control={form.control}
name="application_method.max_quantity"
render={({ field }) => {
return (
<Form.Item className="basis-1/2">
<Form.Label>
{t("promotions.form.max_quantity.title")}
</Form.Label>
<Form.Control>
<Input
{...form.register(
"application_method.max_quantity",
{ valueAsNumber: true }
)}
type="number"
min={1}
placeholder="3"
/>
</Form.Control>
<Form.Control>
<Input
{...form.register(
"application_method.max_quantity",
{ valueAsNumber: true }
)}
type="number"
min={1}
placeholder="3"
/>
</Form.Control>
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
<Trans
t={t}
i18nKey="promotions.form.max_quantity.description"
components={[<br key="break" />]}
/>
</Text>
</Form.Item>
)
}}
/>
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
<Trans
t={t}
i18nKey="promotions.form.max_quantity.description"
components={[<br key="break" />]}
/>
</Text>
</Form.Item>
)
}}
/>
</>
)}
{isTypeStandard &&

View File

@@ -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!,
},
])
)

View File

@@ -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: [
{

View File

@@ -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": {

View File

@@ -0,0 +1,36 @@
import { Migration } from "@mikro-orm/migrations"
export class Migration20250828075407 extends Migration {
override async up(): Promise<void> {
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<void> {
// 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.
}
}

View File

@@ -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<string, EligibleItem[]>,
currentPromotionCode: string
): Map<string, BigNumberInput> {
const remainingQuantities = new Map<string, BigNumberInput>()
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<string, BigNumberInput>,
remainingTargetQuantities: Map<string, BigNumberInput>,
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<string, BigNumberInput>()
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<string, BigNumberInput>,
methodIdPromoValueMap: Map<string, BigNumberInput>,
promotion: PromotionTypes.PromotionDTO,
itemsMap: Map<string, ComputeActionItemLine>,
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<string, BigNumberInput>,
remainingTargetQuantities: Map<string, BigNumberInput>,
application: PromotionApplication
): void {
const totalConsumedQuantities = new Map<string, BigNumberInput>()
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<string, EligibleItem>,
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<string, BigNumberInput>,
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<string, EligibleItem[]>,
eligibleTargetItemMap: Map<string, EligibleItem[]>
): 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<string, ComputeActionItemLine>(
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<string, BigNumberInput>()
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<string, EligibleItem>()
const totalEligibleTargetItemsMap = new Map<string, EligibleItem>()
const itemIdPromotionAmountMap = new Map<string, BigNumberInput>()
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
}

View File

@@ -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 (