Skip to content

Commit 1248119

Browse files
committed
fix(promotion): handle fixed discount type in buy-get promotions (#14934)
1 parent 7c5e5e3 commit 1248119

File tree

3 files changed

+287
-8
lines changed

3 files changed

+287
-8
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@medusajs/promotion": patch
3+
---
4+
5+
fix(promotion): support fixed amount discount type in buy-get promotions

packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6003,6 +6003,269 @@ moduleIntegrationTestRunner({
60036003
expect(JSON.parse(JSON.stringify(result))).toEqual([])
60046004
})
60056005

6006+
it("should compute fixed amount discount for buyget promotion", async () => {
6007+
const context = {
6008+
currency_code: "usd",
6009+
customer: {
6010+
customer_group: {
6011+
id: "VIP",
6012+
},
6013+
},
6014+
items: [
6015+
{
6016+
id: "item_cotton_tshirt",
6017+
quantity: 1,
6018+
subtotal: 500,
6019+
original_total: 500,
6020+
is_discountable: true,
6021+
product_category: {
6022+
id: "catg_tshirt",
6023+
},
6024+
product: {
6025+
id: "prod_tshirt_1",
6026+
},
6027+
},
6028+
{
6029+
id: "item_cotton_sweater",
6030+
quantity: 2,
6031+
subtotal: 1000,
6032+
original_total: 1000,
6033+
is_discountable: true,
6034+
product_category: {
6035+
id: "catg_sweater",
6036+
},
6037+
product: {
6038+
id: "prod_sweater_1",
6039+
},
6040+
},
6041+
],
6042+
}
6043+
6044+
await createDefaultPromotion(service, {
6045+
type: PromotionType.BUYGET,
6046+
rules: [
6047+
{
6048+
attribute: "customer.customer_group.id",
6049+
operator: "in",
6050+
values: ["VIP", "top100"],
6051+
},
6052+
],
6053+
application_method: {
6054+
type: "fixed",
6055+
target_type: "items",
6056+
value: 200,
6057+
allocation: "each",
6058+
max_quantity: 1,
6059+
apply_to_quantity: 1,
6060+
buy_rules_min_quantity: 1,
6061+
target_rules: [
6062+
{
6063+
attribute: "items.product_category.id",
6064+
operator: "eq",
6065+
values: ["catg_tshirt"],
6066+
},
6067+
],
6068+
buy_rules: [
6069+
{
6070+
attribute: "items.product_category.id",
6071+
operator: "eq",
6072+
values: ["catg_sweater"],
6073+
},
6074+
],
6075+
} as any,
6076+
})
6077+
6078+
const result = await service.computeActions(
6079+
["PROMOTION_TEST"],
6080+
context
6081+
)
6082+
6083+
expect(JSON.parse(JSON.stringify(result))).toEqual([
6084+
{
6085+
action: "addItemAdjustment",
6086+
item_id: "item_cotton_tshirt",
6087+
amount: 200,
6088+
code: "PROMOTION_TEST",
6089+
},
6090+
])
6091+
})
6092+
6093+
it("should cap fixed discount at item price when value exceeds item price", async () => {
6094+
const context = {
6095+
currency_code: "usd",
6096+
customer: {
6097+
customer_group: {
6098+
id: "VIP",
6099+
},
6100+
},
6101+
items: [
6102+
{
6103+
id: "item_cotton_tshirt",
6104+
quantity: 1,
6105+
subtotal: 500,
6106+
original_total: 500,
6107+
is_discountable: true,
6108+
product_category: {
6109+
id: "catg_tshirt",
6110+
},
6111+
product: {
6112+
id: "prod_tshirt_1",
6113+
},
6114+
},
6115+
{
6116+
id: "item_cotton_sweater",
6117+
quantity: 2,
6118+
subtotal: 1000,
6119+
original_total: 1000,
6120+
is_discountable: true,
6121+
product_category: {
6122+
id: "catg_sweater",
6123+
},
6124+
product: {
6125+
id: "prod_sweater_1",
6126+
},
6127+
},
6128+
],
6129+
}
6130+
6131+
await createDefaultPromotion(service, {
6132+
type: PromotionType.BUYGET,
6133+
rules: [
6134+
{
6135+
attribute: "customer.customer_group.id",
6136+
operator: "in",
6137+
values: ["VIP", "top100"],
6138+
},
6139+
],
6140+
application_method: {
6141+
type: "fixed",
6142+
target_type: "items",
6143+
value: 1000,
6144+
allocation: "each",
6145+
max_quantity: 1,
6146+
apply_to_quantity: 1,
6147+
buy_rules_min_quantity: 1,
6148+
target_rules: [
6149+
{
6150+
attribute: "items.product_category.id",
6151+
operator: "eq",
6152+
values: ["catg_tshirt"],
6153+
},
6154+
],
6155+
buy_rules: [
6156+
{
6157+
attribute: "items.product_category.id",
6158+
operator: "eq",
6159+
values: ["catg_sweater"],
6160+
},
6161+
],
6162+
} as any,
6163+
})
6164+
6165+
const result = await service.computeActions(
6166+
["PROMOTION_TEST"],
6167+
context
6168+
)
6169+
6170+
// Fixed value 1000 exceeds item price 500, should be capped at 500
6171+
expect(JSON.parse(JSON.stringify(result))).toEqual([
6172+
{
6173+
action: "addItemAdjustment",
6174+
item_id: "item_cotton_tshirt",
6175+
amount: 500,
6176+
code: "PROMOTION_TEST",
6177+
},
6178+
])
6179+
})
6180+
6181+
it("should apply fixed discount per unit when apply_to_quantity > 1", async () => {
6182+
const context = {
6183+
currency_code: "usd",
6184+
customer: {
6185+
customer_group: {
6186+
id: "VIP",
6187+
},
6188+
},
6189+
items: [
6190+
{
6191+
id: "item_cotton_tshirt",
6192+
quantity: 3,
6193+
subtotal: 3000,
6194+
original_total: 3000,
6195+
is_discountable: true,
6196+
product_category: {
6197+
id: "catg_tshirt",
6198+
},
6199+
product: {
6200+
id: "prod_tshirt_1",
6201+
},
6202+
},
6203+
{
6204+
id: "item_cotton_sweater",
6205+
quantity: 2,
6206+
subtotal: 1000,
6207+
original_total: 1000,
6208+
is_discountable: true,
6209+
product_category: {
6210+
id: "catg_sweater",
6211+
},
6212+
product: {
6213+
id: "prod_sweater_1",
6214+
},
6215+
},
6216+
],
6217+
}
6218+
6219+
await createDefaultPromotion(service, {
6220+
type: PromotionType.BUYGET,
6221+
rules: [
6222+
{
6223+
attribute: "customer.customer_group.id",
6224+
operator: "in",
6225+
values: ["VIP", "top100"],
6226+
},
6227+
],
6228+
application_method: {
6229+
type: "fixed",
6230+
target_type: "items",
6231+
value: 200,
6232+
allocation: "each",
6233+
max_quantity: 2,
6234+
apply_to_quantity: 2,
6235+
buy_rules_min_quantity: 1,
6236+
target_rules: [
6237+
{
6238+
attribute: "items.product_category.id",
6239+
operator: "eq",
6240+
values: ["catg_tshirt"],
6241+
},
6242+
],
6243+
buy_rules: [
6244+
{
6245+
attribute: "items.product_category.id",
6246+
operator: "eq",
6247+
values: ["catg_sweater"],
6248+
},
6249+
],
6250+
} as any,
6251+
})
6252+
6253+
const result = await service.computeActions(
6254+
["PROMOTION_TEST"],
6255+
context
6256+
)
6257+
6258+
// Fixed value 200 per unit * 2 units = 400 total
6259+
expect(JSON.parse(JSON.stringify(result))).toEqual([
6260+
{
6261+
action: "addItemAdjustment",
6262+
item_id: "item_cotton_tshirt",
6263+
amount: 400,
6264+
code: "PROMOTION_TEST",
6265+
},
6266+
])
6267+
})
6268+
60066269
it("should compute actions for multiple items when conditions for target qty exceed one item", async () => {
60076270
const context = {
60086271
currency_code: "usd",

packages/modules/promotion/src/utils/compute-actions/buy-get.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from "@medusajs/framework/types"
77
import {
88
ApplicationMethodTargetType,
9+
ApplicationMethodType,
910
ComputedActions,
1011
MathBN,
1112
MedusaError,
@@ -65,13 +66,13 @@ function normalizePromotionApplicationConfiguration(
6566
const maximumApplyQuantity = MathBN.convert(
6667
promotion.application_method?.max_quantity ?? 1
6768
)
68-
const applicablePercentage = promotion.application_method?.value ?? 100
69+
const promotionValue = promotion.application_method?.value ?? 0
6970

7071
return {
7172
minimumBuyQuantity,
7273
targetApplyQuantity,
7374
maximumApplyQuantity,
74-
applicablePercentage,
75+
promotionValue,
7576
}
7677
}
7778

@@ -111,7 +112,7 @@ type PromotionConfig = {
111112
minimumBuyQuantity: BigNumberInput
112113
targetApplyQuantity: BigNumberInput
113114
maximumApplyQuantity: BigNumberInput
114-
applicablePercentage: number
115+
promotionValue: number
115116
}
116117

117118
type PromotionApplication = {
@@ -267,7 +268,7 @@ function preparePromotionApplicationState(
267268
Applies promotion to the target items selected by preparePromotionApplicationState.
268269
269270
This function performs the application by:
270-
1. Calculating promotion amounts based on item prices and promotion percentage
271+
1. Calculating promotion amounts based on item prices and promotion value (percentage or fixed)
271272
2. Checking promotion budget limits to prevent overspending
272273
3. Updating promotional value tracking maps for cross-promotion coordination
273274
4. Accumulating total promotion amounts per item across all applications
@@ -301,10 +302,20 @@ function applyPromotionToTargetItems(
301302
const multiplier = MathBN.min(targetItem.quantity, remainingQtyToApply)
302303
const pricePerUnit = MathBN.div(item.subtotal, item.quantity)
303304
const applicableAmount = MathBN.mult(pricePerUnit, multiplier)
304-
const amount = MathBN.mult(
305-
applicableAmount,
306-
applicationConfig.applicablePercentage
307-
).div(100)
305+
306+
let amount
307+
if (promotion.application_method?.type === ApplicationMethodType.FIXED) {
308+
// Fixed: apply value per unit, capped at item price
309+
amount = MathBN.min(
310+
MathBN.mult(applicationConfig.promotionValue, multiplier),
311+
applicableAmount
312+
)
313+
} else {
314+
amount = MathBN.mult(
315+
applicableAmount,
316+
applicationConfig.promotionValue
317+
).div(100)
318+
}
308319

309320
if (MathBN.lte(amount, 0)) {
310321
continue

0 commit comments

Comments
 (0)