feat: Add allocation method type ONCE (#13700)
### What
Add a new `once` allocation strategy to promotions that limits application to a maximum number of items across the entire cart, rather than per line item.
### Why
Merchants want to create promotions that apply to a limited number of items across the entire cart. For example:
- "Get $10 off, applied to one item only"
- "20% off up to 2 items in your cart"
Current allocation strategies:
- `each`: Applies to each line item independently (respects `max_quantity` per item)
- `across`: Distributes proportionally across all items
Neither supports limiting total applications across the entire cart.
### How
Add `once` to the `ApplicationMethodAllocation` enum.
Behavior:
- Applies promotion to maximum `max_quantity` items across entire cart
- Always prioritizes lowest-priced eligible items first
- Distributes sequentially across items until quota exhausted
- Requires `max_quantity` field to be set
### Example Usage
**Scenario 1: Fixed discount**
```javascript
{
type: "fixed",
allocation: "once",
value: 10, // $10 off
max_quantity: 2 // Apply to 2 items max across cart
}
Cart:
- Item A: 3 units @ $100/unit
- Item B: 5 units @ $50/unit (lowest price)
Result: $20 discount on Item B (2 units × $10)
```
**Scenario 2: Distribution across items**
```javascript
{
type: "fixed",
allocation: "once",
value: 5,
max_quantity: 4
}
Cart:
- Item A: 2 units @ $50/unit
- Item B: 3 units @ $60/unit
Result:
- Item A: $10 discount (2 units × $5)
- Item B: $10 discount (2 units × $5, remaining quota)
```
**Scenario 3: Percentage discount - single item**
```javascript
{
type: "percentage",
allocation: "once",
value: 20, // 20% off
max_quantity: 3 // Apply to 3 items max
}
Cart:
- Item A: 5 units @ $100/unit
- Item B: 4 units @ $50/unit (lowest price)
Result: $30 discount on Item B (3 units × $50 × 20% = $30)
```
**Scenario 4: Percentage discount - distributed across items**
```javascript
{
type: "percentage",
allocation: "once",
value: 15, // 15% off
max_quantity: 5
}
Cart:
- Item A: 2 units @ $40/unit (lowest price)
- Item B: 4 units @ $80/unit
Result:
- Item A: $12 discount (2 units × $40 × 15% = $12)
- Item B: $36 discount (3 units × $80 × 15% = $36, remaining quota)
Total: $48 discount
```
**Scenario 5: Percentage with max_quantity = 1**
```javascript
{
type: "percentage",
allocation: "once",
value: 25, // 25% off
max_quantity: 1 // Only one item
}
Cart:
- Item A: 3 units @ $60/unit
- Item B: 2 units @ $30/unit (lowest price)
Result: $7.50 discount on Item B (1 unit × $30 × 25%)
```
This commit is contained in:
@@ -7390,6 +7390,282 @@ moduleIntegrationTestRunner({
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("when promotion allocation is once", () => {
|
||||
describe("when application type is fixed", () => {
|
||||
it("should apply promotion to lowest priced items first and respect max_quantity limit across all items", async () => {
|
||||
await createDefaultPromotion(service, {
|
||||
application_method: {
|
||||
type: "fixed",
|
||||
target_type: "items",
|
||||
allocation: "once",
|
||||
value: 10,
|
||||
max_quantity: 2,
|
||||
} as any,
|
||||
})
|
||||
|
||||
const result = await service.computeActions(["PROMOTION_TEST"], {
|
||||
currency_code: "usd",
|
||||
items: [
|
||||
{
|
||||
id: "item_expensive",
|
||||
quantity: 3,
|
||||
subtotal: 300, // $100/unit
|
||||
},
|
||||
{
|
||||
id: "item_cheap",
|
||||
quantity: 5,
|
||||
subtotal: 250, // $50/unit - lowest price, should get discount first
|
||||
},
|
||||
{
|
||||
id: "item_medium",
|
||||
quantity: 2,
|
||||
subtotal: 150, // $75/unit
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(JSON.parse(JSON.stringify(result))).toEqual([
|
||||
{
|
||||
action: "addItemAdjustment",
|
||||
item_id: "item_cheap",
|
||||
amount: 20, // 2 units * $10
|
||||
code: "PROMOTION_TEST",
|
||||
is_tax_inclusive: false,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should distribute across items when max_quantity exceeds first item quantity", async () => {
|
||||
await createDefaultPromotion(service, {
|
||||
application_method: {
|
||||
type: "fixed",
|
||||
target_type: "items",
|
||||
allocation: "once",
|
||||
value: 5,
|
||||
max_quantity: 4,
|
||||
} as any,
|
||||
})
|
||||
|
||||
const result = await service.computeActions(["PROMOTION_TEST"], {
|
||||
currency_code: "usd",
|
||||
items: [
|
||||
{
|
||||
id: "item_a",
|
||||
quantity: 2,
|
||||
subtotal: 100, // $50/unit
|
||||
},
|
||||
{
|
||||
id: "item_b",
|
||||
quantity: 3,
|
||||
subtotal: 180, // $60/unit
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(JSON.parse(JSON.stringify(result))).toEqual([
|
||||
{
|
||||
action: "addItemAdjustment",
|
||||
item_id: "item_a",
|
||||
amount: 10, // 2 units * $5
|
||||
code: "PROMOTION_TEST",
|
||||
is_tax_inclusive: false,
|
||||
},
|
||||
{
|
||||
action: "addItemAdjustment",
|
||||
item_id: "item_b",
|
||||
amount: 10, // 2 units * $5 (remaining quota)
|
||||
code: "PROMOTION_TEST",
|
||||
is_tax_inclusive: false,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should apply to only one item when max_quantity is 1", async () => {
|
||||
await createDefaultPromotion(service, {
|
||||
application_method: {
|
||||
type: "fixed",
|
||||
target_type: "items",
|
||||
allocation: "once",
|
||||
value: 10,
|
||||
max_quantity: 1,
|
||||
} as any,
|
||||
})
|
||||
|
||||
const result = await service.computeActions(["PROMOTION_TEST"], {
|
||||
currency_code: "usd",
|
||||
items: [
|
||||
{
|
||||
id: "item_1",
|
||||
quantity: 3,
|
||||
subtotal: 90, // $30/unit - lowest
|
||||
},
|
||||
{
|
||||
id: "item_2",
|
||||
quantity: 2,
|
||||
subtotal: 100, // $50/unit
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(JSON.parse(JSON.stringify(result))).toEqual([
|
||||
{
|
||||
action: "addItemAdjustment",
|
||||
item_id: "item_1",
|
||||
amount: 10, // 1 unit * $10
|
||||
code: "PROMOTION_TEST",
|
||||
is_tax_inclusive: false,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("when application type is percentage", () => {
|
||||
it("should apply percentage discount to lowest priced items first", async () => {
|
||||
await createDefaultPromotion(service, {
|
||||
application_method: {
|
||||
type: "percentage",
|
||||
target_type: "items",
|
||||
allocation: "once",
|
||||
value: 20,
|
||||
max_quantity: 3,
|
||||
} as any,
|
||||
})
|
||||
|
||||
const result = await service.computeActions(["PROMOTION_TEST"], {
|
||||
currency_code: "usd",
|
||||
items: [
|
||||
{
|
||||
id: "item_expensive",
|
||||
quantity: 5,
|
||||
subtotal: 500, // $100/unit
|
||||
},
|
||||
{
|
||||
id: "item_cheap",
|
||||
quantity: 4,
|
||||
subtotal: 200, // $50/unit - lowest price
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(JSON.parse(JSON.stringify(result))).toEqual([
|
||||
{
|
||||
action: "addItemAdjustment",
|
||||
item_id: "item_cheap",
|
||||
amount: 30, // 3 units * $50 * 20% = $30
|
||||
code: "PROMOTION_TEST",
|
||||
is_tax_inclusive: false,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should distribute percentage discount across multiple items when max_quantity exceeds first item quantity", async () => {
|
||||
await createDefaultPromotion(service, {
|
||||
application_method: {
|
||||
type: "percentage",
|
||||
target_type: "items",
|
||||
allocation: "once",
|
||||
value: 25,
|
||||
max_quantity: 5,
|
||||
} as any,
|
||||
})
|
||||
|
||||
const result = await service.computeActions(["PROMOTION_TEST"], {
|
||||
currency_code: "usd",
|
||||
items: [
|
||||
{
|
||||
id: "item_a",
|
||||
quantity: 2,
|
||||
subtotal: 100, // $50/unit - cheapest
|
||||
},
|
||||
{
|
||||
id: "item_b",
|
||||
quantity: 3,
|
||||
subtotal: 180, // $60/unit - second cheapest
|
||||
},
|
||||
{
|
||||
id: "item_c",
|
||||
quantity: 4,
|
||||
subtotal: 400, // $100/unit - most expensive
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(JSON.parse(JSON.stringify(result))).toEqual([
|
||||
{
|
||||
action: "addItemAdjustment",
|
||||
item_id: "item_a",
|
||||
amount: 25, // 2 units * $50 * 25% = $25
|
||||
code: "PROMOTION_TEST",
|
||||
is_tax_inclusive: false,
|
||||
},
|
||||
{
|
||||
action: "addItemAdjustment",
|
||||
item_id: "item_b",
|
||||
amount: 45, // 3 units * $60 * 25% = $45 (remaining quota)
|
||||
code: "PROMOTION_TEST",
|
||||
is_tax_inclusive: false,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("with target rules", () => {
|
||||
it("should only apply to items matching target rules and respect once allocation", async () => {
|
||||
await createDefaultPromotion(service, {
|
||||
application_method: {
|
||||
type: "fixed",
|
||||
target_type: "items",
|
||||
allocation: "once",
|
||||
value: 15,
|
||||
max_quantity: 2,
|
||||
target_rules: [
|
||||
{
|
||||
attribute: "items.product_category.id",
|
||||
operator: "eq",
|
||||
values: ["catg_electronics"],
|
||||
},
|
||||
],
|
||||
} as any,
|
||||
})
|
||||
|
||||
const result = await service.computeActions(["PROMOTION_TEST"], {
|
||||
currency_code: "usd",
|
||||
items: [
|
||||
{
|
||||
id: "item_phone",
|
||||
quantity: 3,
|
||||
subtotal: 3000,
|
||||
product_category: { id: "catg_electronics" },
|
||||
},
|
||||
{
|
||||
id: "item_book",
|
||||
quantity: 5,
|
||||
subtotal: 50, // Cheaper but doesn't match rules
|
||||
product_category: { id: "catg_books" },
|
||||
},
|
||||
{
|
||||
id: "item_tablet",
|
||||
quantity: 2,
|
||||
subtotal: 1000,
|
||||
product_category: { id: "catg_electronics" },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Should only consider electronics items, and apply to cheapest one (tablet at $500/unit vs phone at $1000/unit)
|
||||
expect(JSON.parse(JSON.stringify(result))).toEqual([
|
||||
{
|
||||
action: "addItemAdjustment",
|
||||
item_id: "item_tablet",
|
||||
amount: 30, // 2 units * $15
|
||||
code: "PROMOTION_TEST",
|
||||
is_tax_inclusive: false,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -226,7 +226,7 @@ moduleIntegrationTestRunner({
|
||||
}).catch((e) => e)
|
||||
|
||||
expect(error.message).toContain(
|
||||
"application_method.allocation should be either 'across OR each' when application_method.target_type is either 'shipping_methods OR items'"
|
||||
"application_method.allocation should be either 'across OR each OR once' when application_method.target_type is either 'shipping_methods OR items'"
|
||||
)
|
||||
})
|
||||
|
||||
@@ -239,7 +239,7 @@ moduleIntegrationTestRunner({
|
||||
}).catch((e) => e)
|
||||
|
||||
expect(error.message).toContain(
|
||||
"application_method.max_quantity is required when application_method.allocation is 'each'"
|
||||
"application_method.max_quantity is required when application_method.allocation is 'each OR once'"
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
export class Migration20251006000000 extends Migration {
|
||||
|
||||
override async up(): Promise<void> {
|
||||
this.addSql(`ALTER TABLE "promotion_application_method" DROP CONSTRAINT IF EXISTS "promotion_application_method_allocation_check";`);
|
||||
this.addSql(`ALTER TABLE "promotion_application_method" ADD CONSTRAINT "promotion_application_method_allocation_check" CHECK ("allocation" IN ('each', 'across', 'once'));`);
|
||||
}
|
||||
|
||||
override async down(): Promise<void> {
|
||||
this.addSql(`ALTER TABLE "promotion_application_method" DROP CONSTRAINT IF EXISTS "promotion_application_method_allocation_check";`);
|
||||
this.addSql(`ALTER TABLE "promotion_application_method" ADD CONSTRAINT "promotion_application_method_allocation_check" CHECK ("allocation" IN ('each', 'across'));`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -33,6 +33,7 @@ enum ApplicationMethodTargetTypeValues {
|
||||
enum ApplicationMethodAllocationValues {
|
||||
each
|
||||
across
|
||||
once
|
||||
}
|
||||
|
||||
type Promotion {
|
||||
|
||||
@@ -7,15 +7,16 @@ import {
|
||||
import {
|
||||
ApplicationMethodAllocation,
|
||||
ApplicationMethodTargetType,
|
||||
ApplicationMethodTargetType as TargetType,
|
||||
calculateAdjustmentAmountFromPromotion,
|
||||
ComputedActions,
|
||||
MathBN,
|
||||
MedusaError,
|
||||
ApplicationMethodTargetType as TargetType,
|
||||
} from "@medusajs/framework/utils"
|
||||
import { areRulesValidForContext } from "../validations"
|
||||
import { computeActionForBudgetExceeded } from "./usage"
|
||||
import { Promotion } from "@models"
|
||||
import { areRulesValidForContext } from "../validations"
|
||||
import { sortLineItemByPriceAscending } from "./sort-by-price"
|
||||
import { computeActionForBudgetExceeded } from "./usage"
|
||||
|
||||
function validateContext(
|
||||
contextKey: string,
|
||||
@@ -66,16 +67,24 @@ function applyPromotionToItems(
|
||||
|
||||
const computedActions: PromotionTypes.ComputeActions[] = []
|
||||
|
||||
const applicableItems = getValidItemsForPromotion(items, promotion)
|
||||
let applicableItems = getValidItemsForPromotion(
|
||||
items,
|
||||
promotion
|
||||
) as PromotionTypes.ComputeActionItemLine[]
|
||||
|
||||
if (!applicableItems.length) {
|
||||
return computedActions
|
||||
}
|
||||
|
||||
if (allocation === ApplicationMethodAllocation.ONCE) {
|
||||
applicableItems = applicableItems.sort(sortLineItemByPriceAscending)
|
||||
}
|
||||
|
||||
const isTargetLineItems = target === TargetType.ITEMS
|
||||
const isTargetOrder = target === TargetType.ORDER
|
||||
const promotionValue = applicationMethod?.value ?? 0
|
||||
const maxQuantity = applicationMethod?.max_quantity!
|
||||
let remainingQuota = maxQuantity ?? 0
|
||||
|
||||
let lineItemsAmount = MathBN.convert(0)
|
||||
if (allocation === ApplicationMethodAllocation.ACROSS) {
|
||||
@@ -97,6 +106,12 @@ function applyPromotionToItems(
|
||||
}
|
||||
|
||||
for (const item of applicableItems) {
|
||||
if (
|
||||
allocation === ApplicationMethodAllocation.ONCE &&
|
||||
remainingQuota <= 0
|
||||
) {
|
||||
break
|
||||
}
|
||||
if (
|
||||
MathBN.lte(
|
||||
promotion.is_tax_inclusive ? item.original_total : item.subtotal,
|
||||
@@ -108,15 +123,26 @@ function applyPromotionToItems(
|
||||
|
||||
const appliedPromoValue = appliedPromotionsMap.get(item.id) ?? 0
|
||||
|
||||
const effectiveMaxQuantity =
|
||||
allocation === ApplicationMethodAllocation.ONCE
|
||||
? Math.min(remainingQuota ?? 0, Number(item.quantity))
|
||||
: maxQuantity
|
||||
|
||||
// If the allocation is once, we rely on the existing logic for each allocation, as the calculate is the same: apply the promotion value to the line item
|
||||
const effectiveAllocation =
|
||||
allocation === ApplicationMethodAllocation.ONCE
|
||||
? ApplicationMethodAllocation.EACH
|
||||
: allocation
|
||||
|
||||
const amount = calculateAdjustmentAmountFromPromotion(
|
||||
item,
|
||||
{
|
||||
value: promotionValue,
|
||||
applied_value: appliedPromoValue,
|
||||
is_tax_inclusive: promotion.is_tax_inclusive,
|
||||
max_quantity: maxQuantity,
|
||||
max_quantity: effectiveMaxQuantity,
|
||||
type: applicationMethod?.type!,
|
||||
allocation,
|
||||
allocation: effectiveAllocation,
|
||||
},
|
||||
lineItemsAmount
|
||||
)
|
||||
@@ -137,6 +163,15 @@ function applyPromotionToItems(
|
||||
|
||||
appliedPromotionsMap.set(item.id, MathBN.add(appliedPromoValue, amount))
|
||||
|
||||
if (allocation === ApplicationMethodAllocation.ONCE) {
|
||||
// We already know exactly how many units we applied via effectiveMaxQuantity
|
||||
const quantityApplied = Math.min(
|
||||
effectiveMaxQuantity,
|
||||
Number(item.quantity)
|
||||
)
|
||||
remainingQuota -= quantityApplied
|
||||
}
|
||||
|
||||
if (isTargetLineItems || isTargetOrder) {
|
||||
computedActions.push({
|
||||
action: ComputedActions.ADD_ITEM_ADJUSTMENT,
|
||||
|
||||
@@ -11,16 +11,17 @@ import {
|
||||
MathBN,
|
||||
MedusaError,
|
||||
} from "@medusajs/framework/utils"
|
||||
import { areRulesValidForContext } from "../validations"
|
||||
import { computeActionForBudgetExceeded } from "./usage"
|
||||
import { Promotion } from "@models"
|
||||
import { areRulesValidForContext } from "../validations"
|
||||
import { sortShippingLineByPriceAscending } from "./sort-by-price"
|
||||
import { computeActionForBudgetExceeded } from "./usage"
|
||||
|
||||
export function getComputedActionsForShippingMethods(
|
||||
promotion: PromotionTypes.PromotionDTO | InferEntityType<typeof Promotion>,
|
||||
shippingMethodApplicationContext: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.SHIPPING_METHODS],
|
||||
methodIdPromoValueMap: Map<string, number>
|
||||
): PromotionTypes.ComputeActions[] {
|
||||
const applicableShippingItems: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.SHIPPING_METHODS] =
|
||||
let applicableShippingItems: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.SHIPPING_METHODS] =
|
||||
[]
|
||||
|
||||
if (!shippingMethodApplicationContext) {
|
||||
@@ -44,6 +45,13 @@ export function getComputedActionsForShippingMethods(
|
||||
applicableShippingItems.push(shippingMethodContext)
|
||||
}
|
||||
|
||||
const allocation = promotion.application_method?.allocation!
|
||||
if (allocation === ApplicationMethodAllocation.ONCE) {
|
||||
applicableShippingItems = applicableShippingItems.sort(
|
||||
sortShippingLineByPriceAscending
|
||||
)
|
||||
}
|
||||
|
||||
return applyPromotionToShippingMethods(
|
||||
promotion,
|
||||
applicableShippingItems,
|
||||
@@ -59,9 +67,20 @@ export function applyPromotionToShippingMethods(
|
||||
const { application_method: applicationMethod } = promotion
|
||||
const allocation = applicationMethod?.allocation!
|
||||
const computedActions: PromotionTypes.ComputeActions[] = []
|
||||
const maxQuantity = applicationMethod?.max_quantity ?? 0
|
||||
let remainingQuota = maxQuantity
|
||||
|
||||
if (allocation === ApplicationMethodAllocation.EACH) {
|
||||
if (
|
||||
allocation === ApplicationMethodAllocation.EACH ||
|
||||
allocation === ApplicationMethodAllocation.ONCE
|
||||
) {
|
||||
for (const method of shippingMethods!) {
|
||||
if (
|
||||
allocation === ApplicationMethodAllocation.ONCE &&
|
||||
remainingQuota <= 0
|
||||
) {
|
||||
break
|
||||
}
|
||||
if (!method.subtotal) {
|
||||
continue
|
||||
}
|
||||
@@ -99,6 +118,10 @@ export function applyPromotionToShippingMethods(
|
||||
MathBN.add(appliedPromoValue, amount)
|
||||
)
|
||||
|
||||
if (allocation === ApplicationMethodAllocation.ONCE) {
|
||||
remainingQuota -= 1
|
||||
}
|
||||
|
||||
computedActions.push({
|
||||
action: ComputedActions.ADD_SHIPPING_METHOD_ADJUSTMENT,
|
||||
shipping_method_id: method.id,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MathBN } from "@medusajs/framework/utils"
|
||||
import {
|
||||
ComputeActionItemLine,
|
||||
ComputeActionShippingLine,
|
||||
} from "@medusajs/types"
|
||||
|
||||
export function sortLineItemByPriceAscending(
|
||||
a: ComputeActionItemLine,
|
||||
b: ComputeActionItemLine
|
||||
) {
|
||||
const priceA = MathBN.div(a.subtotal, a.quantity)
|
||||
const priceB = MathBN.div(b.subtotal, b.quantity)
|
||||
return MathBN.lt(priceA, priceB) ? -1 : 1
|
||||
}
|
||||
|
||||
export function sortShippingLineByPriceAscending(
|
||||
a: ComputeActionShippingLine,
|
||||
b: ComputeActionShippingLine
|
||||
) {
|
||||
const priceA = a.subtotal ?? 0
|
||||
const priceB = b.subtotal ?? 0
|
||||
return MathBN.lt(priceA, priceB) ? -1 : 1
|
||||
}
|
||||
@@ -21,10 +21,12 @@ export const allowedAllocationTargetTypes: string[] = [
|
||||
export const allowedAllocationTypes: string[] = [
|
||||
ApplicationMethodAllocation.ACROSS,
|
||||
ApplicationMethodAllocation.EACH,
|
||||
ApplicationMethodAllocation.ONCE,
|
||||
]
|
||||
|
||||
export const allowedAllocationForQuantity: string[] = [
|
||||
ApplicationMethodAllocation.EACH,
|
||||
ApplicationMethodAllocation.ONCE,
|
||||
]
|
||||
|
||||
export function validateApplicationMethodAttributes(
|
||||
@@ -158,4 +160,14 @@ export function validateApplicationMethodAttributes(
|
||||
)}'`
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
allocation === ApplicationMethodAllocation.ONCE &&
|
||||
targetType === ApplicationMethodTargetType.ORDER
|
||||
) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`application_method.allocation 'once' is not compatible with target_type 'order'`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user