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

@@ -7569,6 +7569,9 @@
"allocation": {
"type": "string"
},
"allocationTooltip": {
"type": "string"
},
"addCondition": {
"type": "string"
},
@@ -8048,9 +8051,22 @@
},
"required": ["title", "description"],
"additionalProperties": false
},
"once": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"description": {
"type": "string"
}
},
"required": ["title", "description"],
"additionalProperties": false
}
},
"required": ["each", "across"],
"required": ["each", "across", "once"],
"additionalProperties": false
},
"code": {

View File

@@ -2021,6 +2021,7 @@
"campaign": "Campaign",
"method": "Method",
"allocation": "Allocation",
"allocationTooltip": "Each enforces the quantity limit per item, while Once enforces the quantity limit across the entire cart",
"addCondition": "Add condition",
"clearAll": "Clear all",
"taxInclusive": "Tax Inclusive",
@@ -2162,6 +2163,10 @@
"across": {
"title": "Across",
"description": "Applies value across items"
},
"once": {
"title": "Once",
"description": "Applies value to a limited number of items"
}
},
"code": {

View File

@@ -714,7 +714,7 @@ export const CreatePromotionForm = () => {
<Form.Control>
<RadioGroup
dir={direction}
className="flex gap-y-3"
className="flex gap-y-3"
{...field}
onValueChange={field.onChange}
>
@@ -840,7 +840,9 @@ export const CreatePromotionForm = () => {
</>
)}
{((isTypeStandard && watchAllocation === "each") ||
{((isTypeStandard &&
(watchAllocation === "each" ||
watchAllocation === "once")) ||
isTypeBuyGet) && (
<>
{isTypeBuyGet && (
@@ -898,7 +900,9 @@ export const CreatePromotionForm = () => {
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
<Form.Label
tooltip={t("promotions.fields.allocationTooltip")}
>
{t("promotions.fields.allocation")}
</Form.Label>
@@ -909,27 +913,50 @@ export const CreatePromotionForm = () => {
{...field}
onValueChange={field.onChange}
>
<RadioGroup.ChoiceBox
value={"each"}
label={t(
"promotions.form.allocation.each.title"
)}
description={t(
"promotions.form.allocation.each.description"
)}
className={clx("basis-1/2")}
/>
{!currentTemplate?.hiddenFields?.includes(
"application_method.allocation.each"
) && (
<RadioGroup.ChoiceBox
value={"each"}
label={t(
"promotions.form.allocation.each.title"
)}
description={t(
"promotions.form.allocation.each.description"
)}
className={clx("basis-1/3")}
/>
)}
<RadioGroup.ChoiceBox
value={"across"}
label={t(
"promotions.form.allocation.across.title"
)}
description={t(
"promotions.form.allocation.across.description"
)}
className={clx("basis-1/2")}
/>
{!currentTemplate?.hiddenFields?.includes(
"application_method.allocation.across"
) && (
<RadioGroup.ChoiceBox
value={"across"}
label={t(
"promotions.form.allocation.across.title"
)}
description={t(
"promotions.form.allocation.across.description"
)}
className={clx("basis-1/3")}
/>
)}
{!currentTemplate?.hiddenFields?.includes(
"application_method.allocation.once"
) && (
<RadioGroup.ChoiceBox
value={"once"}
label={t(
"promotions.form.allocation.once.title"
)}
description={t(
"promotions.form.allocation.once.description"
)}
className={clx("basis-1/3")}
/>
)}
</RadioGroup>
</Form.Control>
<Form.ErrorMessage />

View File

@@ -29,7 +29,7 @@ export const CreatePromotionSchema = z
rules: RuleSchema,
is_tax_inclusive: z.boolean().optional(),
application_method: z.object({
allocation: z.enum(["each", "across"]),
allocation: z.enum(["each", "across", "once"]),
value: z.number().min(0).or(z.string().min(1)),
currency_code: z.string().optional(),
max_quantity: z.number().optional().nullable(),
@@ -47,7 +47,8 @@ export const CreatePromotionSchema = z
}
return (
data.application_method.allocation === "each" &&
(data.application_method.allocation === "each" ||
data.application_method.allocation === "once") &&
typeof data.application_method.max_quantity === "number"
)
},

View File

@@ -1,30 +1,40 @@
const commonHiddenFields = [
"type",
"application_method.type",
]
const amountOfOrderHiddenFields = [
...commonHiddenFields,
"application_method.allocation",
]
const amountOfOrderHiddenFields = [...commonHiddenFields]
const amountOfProductHiddenFields = [...commonHiddenFields]
const amountOfProductHiddenFields = [
...commonHiddenFields,
"application_method.allocation.across",
]
const percentageOfOrderHiddenFields = [
...commonHiddenFields,
"application_method.allocation",
"is_tax_inclusive",
]
const percentageOfProductHiddenFields = [
...commonHiddenFields,
"application_method.allocation.across",
"is_tax_inclusive",
]
const buyGetHiddenFields = [
...commonHiddenFields,
"application_method.value",
"application_method.allocation",
"is_tax_inclusive",
]
const freeShippingHiddenFields = [
...commonHiddenFields,
"application_method.value",
"application_method.allocation",
"is_tax_inclusive",
]

View File

@@ -29,8 +29,9 @@ const EditPromotionSchema = zod.object({
status: zod.enum(["active", "inactive", "draft"]),
value_type: zod.enum(["fixed", "percentage"]),
value: zod.number().min(0).or(zod.string().min(1)),
allocation: zod.enum(["each", "across"]),
allocation: zod.enum(["each", "across", "once"]),
target_type: zod.enum(["order", "shipping_methods", "items"]),
max_quantity: zod.number().min(1).optional().nullable(),
})
export const EditPromotionDetailsForm = ({
@@ -49,6 +50,7 @@ export const EditPromotionDetailsForm = ({
allocation: promotion.application_method!.allocation,
value_type: promotion.application_method!.type,
target_type: promotion.application_method!.target_type,
max_quantity: promotion.application_method!.max_quantity,
},
resolver: zodResolver(EditPromotionSchema),
})
@@ -58,7 +60,13 @@ export const EditPromotionDetailsForm = ({
name: "value_type",
})
const watchAllocation = useWatch({
control: form.control,
name: "allocation",
})
const isFixedValueType = watchValueType === "fixed"
const originalAllocation = promotion.application_method!.allocation
const { mutateAsync, isPending } = useUpdatePromotion(promotion.id)
@@ -80,6 +88,7 @@ export const EditPromotionDetailsForm = ({
value: parseFloat(data.value),
type: data.value_type as any,
allocation: data.allocation as any,
max_quantity: data.max_quantity,
},
},
{
@@ -335,7 +344,9 @@ export const EditPromotionDetailsForm = ({
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
<Form.Label
tooltip={t("promotions.fields.allocationTooltip")}
>
{t("promotions.fields.allocation")}
</Form.Label>
<Form.Control>
@@ -351,6 +362,7 @@ export const EditPromotionDetailsForm = ({
description={t(
"promotions.form.allocation.each.description"
)}
disabled={originalAllocation === "across"}
/>
<RadioGroup.ChoiceBox
@@ -361,6 +373,19 @@ export const EditPromotionDetailsForm = ({
description={t(
"promotions.form.allocation.across.description"
)}
disabled={
originalAllocation === "each" ||
originalAllocation === "once"
}
/>
<RadioGroup.ChoiceBox
value={"once"}
label={t("promotions.form.allocation.once.title")}
description={t(
"promotions.form.allocation.once.description"
)}
disabled={originalAllocation === "across"}
/>
</RadioGroup>
</Form.Control>
@@ -369,6 +394,43 @@ export const EditPromotionDetailsForm = ({
)
}}
/>
{(watchAllocation === "each" || watchAllocation === "once") && (
<Form.Field
control={form.control}
name="max_quantity"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t("promotions.form.max_quantity.title")}
</Form.Label>
<Form.Control>
<Input
{...form.register("max_quantity", {
valueAsNumber: true,
})}
type="number"
min={1}
placeholder="3"
/>
</Form.Control>
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
<Trans
t={t}
i18nKey="promotions.form.max_quantity.description"
components={[<br key="break" />]}
/>
</Text>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
)}
</>
)}
</div>