Files
medusa-store/packages/modules/promotion/src/utils/validations/application-method.ts
Oli Juhl b5ecdfcd12 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%)
```
2025-10-14 11:01:00 +00:00

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'`
)
}
}