### 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%)
```
174 lines
5.3 KiB
TypeScript
174 lines
5.3 KiB
TypeScript
import {
|
|
ApplicationMethodAllocation,
|
|
ApplicationMethodTargetType,
|
|
ApplicationMethodType,
|
|
BigNumber,
|
|
isDefined,
|
|
isPresent,
|
|
MathBN,
|
|
MedusaError,
|
|
PromotionType,
|
|
} from "@medusajs/framework/utils"
|
|
import { InferEntityType } from "@medusajs/types"
|
|
import { Promotion } from "@models"
|
|
import { CreateApplicationMethodDTO, UpdateApplicationMethodDTO } from "@types"
|
|
|
|
export const allowedAllocationTargetTypes: string[] = [
|
|
ApplicationMethodTargetType.SHIPPING_METHODS,
|
|
ApplicationMethodTargetType.ITEMS,
|
|
]
|
|
|
|
export const allowedAllocationTypes: string[] = [
|
|
ApplicationMethodAllocation.ACROSS,
|
|
ApplicationMethodAllocation.EACH,
|
|
ApplicationMethodAllocation.ONCE,
|
|
]
|
|
|
|
export const allowedAllocationForQuantity: string[] = [
|
|
ApplicationMethodAllocation.EACH,
|
|
ApplicationMethodAllocation.ONCE,
|
|
]
|
|
|
|
export function validateApplicationMethodAttributes(
|
|
data: UpdateApplicationMethodDTO | CreateApplicationMethodDTO,
|
|
promotion: InferEntityType<typeof Promotion>
|
|
) {
|
|
const applicationMethod = promotion?.application_method || {}
|
|
const buyRulesMinQuantity =
|
|
data.buy_rules_min_quantity || applicationMethod?.buy_rules_min_quantity
|
|
const applyToQuantity =
|
|
data.apply_to_quantity || applicationMethod?.apply_to_quantity
|
|
const targetType = data.target_type || applicationMethod?.target_type
|
|
const type = data.type || applicationMethod?.type
|
|
const applicationMethodType = data.type || applicationMethod?.type
|
|
const value = new BigNumber(data.value ?? applicationMethod.value ?? 0)
|
|
const maxQuantity = data.max_quantity || applicationMethod.max_quantity
|
|
const allocation = data.allocation || applicationMethod.allocation
|
|
const allTargetTypes: string[] = Object.values(ApplicationMethodTargetType)
|
|
|
|
if (
|
|
type === ApplicationMethodType.PERCENTAGE &&
|
|
(MathBN.lte(value, 0) || MathBN.gt(value, 100))
|
|
) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.INVALID_DATA,
|
|
`Application Method value should be a percentage number between 0 and 100`
|
|
)
|
|
}
|
|
|
|
if (promotion?.type === PromotionType.BUYGET) {
|
|
if (!isPresent(applyToQuantity)) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.INVALID_DATA,
|
|
`apply_to_quantity is a required field for Promotion type of ${PromotionType.BUYGET}`
|
|
)
|
|
}
|
|
|
|
if (!isPresent(buyRulesMinQuantity)) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.INVALID_DATA,
|
|
`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 (
|
|
allocation === ApplicationMethodAllocation.ACROSS &&
|
|
isPresent(maxQuantity)
|
|
) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.INVALID_DATA,
|
|
`application_method.max_quantity is not allowed to be set for allocation (${ApplicationMethodAllocation.ACROSS})`
|
|
)
|
|
}
|
|
|
|
if (!allTargetTypes.includes(targetType)) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.INVALID_DATA,
|
|
`application_method.target_type should be one of ${allTargetTypes.join(
|
|
", "
|
|
)}`
|
|
)
|
|
}
|
|
|
|
const allTypes: string[] = Object.values(ApplicationMethodType)
|
|
|
|
if (!allTypes.includes(applicationMethodType)) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.INVALID_DATA,
|
|
`application_method.type should be one of ${allTypes.join(", ")}`
|
|
)
|
|
}
|
|
|
|
if (
|
|
allowedAllocationTargetTypes.includes(targetType) &&
|
|
!allowedAllocationTypes.includes(allocation || "")
|
|
) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.INVALID_DATA,
|
|
`application_method.allocation should be either '${allowedAllocationTypes.join(
|
|
" OR "
|
|
)}' when application_method.target_type is either '${allowedAllocationTargetTypes.join(
|
|
" OR "
|
|
)}'`
|
|
)
|
|
}
|
|
|
|
const allAllocationTypes: string[] = Object.values(
|
|
ApplicationMethodAllocation
|
|
)
|
|
|
|
if (allocation && !allAllocationTypes.includes(allocation)) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.INVALID_DATA,
|
|
`application_method.allocation should be one of ${allAllocationTypes.join(
|
|
", "
|
|
)}`
|
|
)
|
|
}
|
|
|
|
if (
|
|
allocation &&
|
|
allowedAllocationForQuantity.includes(allocation) &&
|
|
!isDefined(maxQuantity)
|
|
) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.INVALID_DATA,
|
|
`application_method.max_quantity is required when application_method.allocation is '${allowedAllocationForQuantity.join(
|
|
" OR "
|
|
)}'`
|
|
)
|
|
}
|
|
|
|
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'`
|
|
)
|
|
}
|
|
}
|