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:
Oli Juhl
2025-10-14 13:01:00 +02:00
committed by GitHub
parent 57030fa43e
commit b5ecdfcd12
16 changed files with 859 additions and 43 deletions

View File

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

View File

@@ -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'"
)
})