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:
@@ -9,6 +9,7 @@ import {
|
|||||||
PromotionStatus,
|
PromotionStatus,
|
||||||
PromotionType,
|
PromotionType,
|
||||||
} from "@medusajs/utils"
|
} from "@medusajs/utils"
|
||||||
|
import { setTimeout } from "timers/promises"
|
||||||
import {
|
import {
|
||||||
createAdminUser,
|
createAdminUser,
|
||||||
generatePublishableKey,
|
generatePublishableKey,
|
||||||
@@ -17,7 +18,6 @@ import {
|
|||||||
import { setupTaxStructure } from "../../../../modules/__tests__/fixtures"
|
import { setupTaxStructure } from "../../../../modules/__tests__/fixtures"
|
||||||
import { createAuthenticatedCustomer } from "../../../../modules/helpers/create-authenticated-customer"
|
import { createAuthenticatedCustomer } from "../../../../modules/helpers/create-authenticated-customer"
|
||||||
import { medusaTshirtProduct } from "../../../__fixtures__/product"
|
import { medusaTshirtProduct } from "../../../__fixtures__/product"
|
||||||
import { setTimeout } from "timers/promises"
|
|
||||||
|
|
||||||
jest.setTimeout(100000)
|
jest.setTimeout(100000)
|
||||||
|
|
||||||
@@ -4987,6 +4987,315 @@ medusaIntegrationTestRunner({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("ONCE allocation promotions", () => {
|
||||||
|
it("should apply fixed promotion to lowest priced items first and respect max_quantity across cart", async () => {
|
||||||
|
// Create two products with different prices
|
||||||
|
const expensiveProduct = (
|
||||||
|
await api.post(
|
||||||
|
"/admin/products",
|
||||||
|
{
|
||||||
|
title: "Expensive Product",
|
||||||
|
status: ProductStatus.PUBLISHED,
|
||||||
|
options: [{ title: "Size", values: ["L"] }],
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
title: "Large",
|
||||||
|
sku: "expensive-l",
|
||||||
|
options: { Size: "L" },
|
||||||
|
manage_inventory: false,
|
||||||
|
prices: [{ amount: 10000, currency_code: "usd" }], // $100
|
||||||
|
},
|
||||||
|
],
|
||||||
|
shipping_profile_id: shippingProfile.id,
|
||||||
|
},
|
||||||
|
adminHeaders
|
||||||
|
)
|
||||||
|
).data.product
|
||||||
|
|
||||||
|
const cheapProduct = (
|
||||||
|
await api.post(
|
||||||
|
"/admin/products",
|
||||||
|
{
|
||||||
|
title: "Cheap Product",
|
||||||
|
status: ProductStatus.PUBLISHED,
|
||||||
|
options: [{ title: "Size", values: ["M"] }],
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
title: "Medium",
|
||||||
|
sku: "cheap-m",
|
||||||
|
options: { Size: "M" },
|
||||||
|
manage_inventory: false,
|
||||||
|
prices: [{ amount: 5000, currency_code: "usd" }], // $50
|
||||||
|
},
|
||||||
|
],
|
||||||
|
shipping_profile_id: shippingProfile.id,
|
||||||
|
},
|
||||||
|
adminHeaders
|
||||||
|
)
|
||||||
|
).data.product
|
||||||
|
|
||||||
|
const oncePromotion = (
|
||||||
|
await api.post(
|
||||||
|
`/admin/promotions`,
|
||||||
|
{
|
||||||
|
code: "ONCE_PROMO_FIXED",
|
||||||
|
type: PromotionType.STANDARD,
|
||||||
|
status: PromotionStatus.ACTIVE,
|
||||||
|
is_automatic: false,
|
||||||
|
application_method: {
|
||||||
|
type: "fixed",
|
||||||
|
target_type: "items",
|
||||||
|
allocation: "once",
|
||||||
|
value: 1000, // $10 off
|
||||||
|
max_quantity: 2,
|
||||||
|
currency_code: "usd",
|
||||||
|
target_rules: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
adminHeaders
|
||||||
|
)
|
||||||
|
).data.promotion
|
||||||
|
|
||||||
|
cart = (
|
||||||
|
await api.post(
|
||||||
|
`/store/carts`,
|
||||||
|
{
|
||||||
|
currency_code: "usd",
|
||||||
|
sales_channel_id: salesChannel.id,
|
||||||
|
region_id: region.id,
|
||||||
|
shipping_address: shippingAddressData,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
variant_id: expensiveProduct.variants[0].id,
|
||||||
|
quantity: 3,
|
||||||
|
},
|
||||||
|
{ variant_id: cheapProduct.variants[0].id, quantity: 5 },
|
||||||
|
],
|
||||||
|
promo_codes: [oncePromotion.code],
|
||||||
|
},
|
||||||
|
storeHeadersWithCustomer
|
||||||
|
)
|
||||||
|
).data.cart
|
||||||
|
|
||||||
|
// Should apply $10 discount twice to the cheap product only (lowest price)
|
||||||
|
const cheapItem = cart.items.find(
|
||||||
|
(i) => i.variant_id === cheapProduct.variants[0].id
|
||||||
|
)
|
||||||
|
const expensiveItem = cart.items.find(
|
||||||
|
(i) => i.variant_id === expensiveProduct.variants[0].id
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(cheapItem.adjustments).toHaveLength(1)
|
||||||
|
expect(cheapItem.adjustments[0].amount).toBe(2000) // 2 * $10
|
||||||
|
expect(cheapItem.adjustments[0].code).toBe(oncePromotion.code)
|
||||||
|
|
||||||
|
expect(expensiveItem.adjustments).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should distribute promotion across multiple items when max_quantity exceeds first item quantity", async () => {
|
||||||
|
const product1 = (
|
||||||
|
await api.post(
|
||||||
|
"/admin/products",
|
||||||
|
{
|
||||||
|
title: "Product 1",
|
||||||
|
status: ProductStatus.PUBLISHED,
|
||||||
|
options: [{ title: "Size", values: ["S"] }],
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
title: "Small",
|
||||||
|
sku: "prod1-s",
|
||||||
|
options: { Size: "S" },
|
||||||
|
manage_inventory: false,
|
||||||
|
prices: [{ amount: 5000, currency_code: "usd" }], // $50
|
||||||
|
},
|
||||||
|
],
|
||||||
|
shipping_profile_id: shippingProfile.id,
|
||||||
|
},
|
||||||
|
adminHeaders
|
||||||
|
)
|
||||||
|
).data.product
|
||||||
|
|
||||||
|
const product2 = (
|
||||||
|
await api.post(
|
||||||
|
"/admin/products",
|
||||||
|
{
|
||||||
|
title: "Product 2",
|
||||||
|
status: ProductStatus.PUBLISHED,
|
||||||
|
options: [{ title: "Size", values: ["M"] }],
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
title: "Medium",
|
||||||
|
sku: "prod2-m",
|
||||||
|
options: { Size: "M" },
|
||||||
|
manage_inventory: false,
|
||||||
|
prices: [{ amount: 6000, currency_code: "usd" }], // $60
|
||||||
|
},
|
||||||
|
],
|
||||||
|
shipping_profile_id: shippingProfile.id,
|
||||||
|
},
|
||||||
|
adminHeaders
|
||||||
|
)
|
||||||
|
).data.product
|
||||||
|
|
||||||
|
const oncePromotion = (
|
||||||
|
await api.post(
|
||||||
|
`/admin/promotions`,
|
||||||
|
{
|
||||||
|
code: "ONCE_PROMO_DISTRIBUTE",
|
||||||
|
type: PromotionType.STANDARD,
|
||||||
|
status: PromotionStatus.ACTIVE,
|
||||||
|
is_automatic: false,
|
||||||
|
application_method: {
|
||||||
|
type: "fixed",
|
||||||
|
target_type: "items",
|
||||||
|
allocation: "once",
|
||||||
|
value: 500, // $5 off
|
||||||
|
max_quantity: 4,
|
||||||
|
currency_code: "usd",
|
||||||
|
target_rules: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
adminHeaders
|
||||||
|
)
|
||||||
|
).data.promotion
|
||||||
|
|
||||||
|
cart = (
|
||||||
|
await api.post(
|
||||||
|
`/store/carts`,
|
||||||
|
{
|
||||||
|
currency_code: "usd",
|
||||||
|
sales_channel_id: salesChannel.id,
|
||||||
|
region_id: region.id,
|
||||||
|
shipping_address: shippingAddressData,
|
||||||
|
items: [
|
||||||
|
{ variant_id: product1.variants[0].id, quantity: 2 },
|
||||||
|
{ variant_id: product2.variants[0].id, quantity: 3 },
|
||||||
|
],
|
||||||
|
promo_codes: [oncePromotion.code],
|
||||||
|
},
|
||||||
|
storeHeadersWithCustomer
|
||||||
|
)
|
||||||
|
).data.cart
|
||||||
|
|
||||||
|
// Should apply: 2 units to product1 ($50), 2 units to product2 ($60)
|
||||||
|
const item1 = cart.items.find(
|
||||||
|
(i) => i.variant_id === product1.variants[0].id
|
||||||
|
)
|
||||||
|
const item2 = cart.items.find(
|
||||||
|
(i) => i.variant_id === product2.variants[0].id
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(item1.adjustments).toHaveLength(1)
|
||||||
|
expect(item1.adjustments[0].amount).toBe(1000) // 2 * $5
|
||||||
|
|
||||||
|
expect(item2.adjustments).toHaveLength(1)
|
||||||
|
expect(item2.adjustments[0].amount).toBe(1000) // 2 * $5
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should apply percentage promotion with once allocation to lowest priced items", async () => {
|
||||||
|
const product1 = (
|
||||||
|
await api.post(
|
||||||
|
"/admin/products",
|
||||||
|
{
|
||||||
|
title: "Expensive Product",
|
||||||
|
status: ProductStatus.PUBLISHED,
|
||||||
|
options: [{ title: "Size", values: ["L"] }],
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
title: "Large",
|
||||||
|
sku: "expensive-prod",
|
||||||
|
options: { Size: "L" },
|
||||||
|
manage_inventory: false,
|
||||||
|
prices: [{ amount: 10000, currency_code: "usd" }], // $100
|
||||||
|
},
|
||||||
|
],
|
||||||
|
shipping_profile_id: shippingProfile.id,
|
||||||
|
},
|
||||||
|
adminHeaders
|
||||||
|
)
|
||||||
|
).data.product
|
||||||
|
|
||||||
|
const product2 = (
|
||||||
|
await api.post(
|
||||||
|
"/admin/products",
|
||||||
|
{
|
||||||
|
title: "Cheap Product",
|
||||||
|
status: ProductStatus.PUBLISHED,
|
||||||
|
options: [{ title: "Size", values: ["S"] }],
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
title: "Small",
|
||||||
|
sku: "cheap-prod",
|
||||||
|
options: { Size: "S" },
|
||||||
|
manage_inventory: false,
|
||||||
|
prices: [{ amount: 5000, currency_code: "usd" }], // $50
|
||||||
|
},
|
||||||
|
],
|
||||||
|
shipping_profile_id: shippingProfile.id,
|
||||||
|
},
|
||||||
|
adminHeaders
|
||||||
|
)
|
||||||
|
).data.product
|
||||||
|
|
||||||
|
const oncePromotion = (
|
||||||
|
await api.post(
|
||||||
|
`/admin/promotions`,
|
||||||
|
{
|
||||||
|
code: "ONCE_PROMO_PERCENTAGE",
|
||||||
|
type: PromotionType.STANDARD,
|
||||||
|
status: PromotionStatus.ACTIVE,
|
||||||
|
is_automatic: false,
|
||||||
|
application_method: {
|
||||||
|
type: "percentage",
|
||||||
|
target_type: "items",
|
||||||
|
allocation: "once",
|
||||||
|
value: 20, // 20% off
|
||||||
|
max_quantity: 3,
|
||||||
|
currency_code: "usd",
|
||||||
|
target_rules: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
adminHeaders
|
||||||
|
)
|
||||||
|
).data.promotion
|
||||||
|
|
||||||
|
cart = (
|
||||||
|
await api.post(
|
||||||
|
`/store/carts`,
|
||||||
|
{
|
||||||
|
currency_code: "usd",
|
||||||
|
sales_channel_id: salesChannel.id,
|
||||||
|
region_id: region.id,
|
||||||
|
shipping_address: shippingAddressData,
|
||||||
|
items: [
|
||||||
|
{ variant_id: product1.variants[0].id, quantity: 5 },
|
||||||
|
{ variant_id: product2.variants[0].id, quantity: 4 },
|
||||||
|
],
|
||||||
|
promo_codes: [oncePromotion.code],
|
||||||
|
},
|
||||||
|
storeHeadersWithCustomer
|
||||||
|
)
|
||||||
|
).data.cart
|
||||||
|
|
||||||
|
// Should apply 20% to 3 units of the cheap product
|
||||||
|
// Tax-inclusive calculation: (($50 * 1.05) * 3 * 20%) / 1.05 ≈ $28.57 per unit * 3 = ~$2857
|
||||||
|
// The promotion inherits tax_inclusive from the cart's currency settings
|
||||||
|
const cheapItem = cart.items.find(
|
||||||
|
(i) => i.variant_id === product2.variants[0].id
|
||||||
|
)
|
||||||
|
const expensiveItem = cart.items.find(
|
||||||
|
(i) => i.variant_id === product1.variants[0].id
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(cheapItem.adjustments).toHaveLength(1)
|
||||||
|
// Tax-inclusive: 20% of (3 units * $50 tax-inclusive) accounting for 5% tax
|
||||||
|
expect(cheapItem.adjustments[0].amount).toBeCloseTo(2857.14, 0)
|
||||||
|
expect(cheapItem.adjustments[0].code).toBe(oncePromotion.code)
|
||||||
|
|
||||||
|
expect(expensiveItem.adjustments).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -7569,6 +7569,9 @@
|
|||||||
"allocation": {
|
"allocation": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"allocationTooltip": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"addCondition": {
|
"addCondition": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -8048,9 +8051,22 @@
|
|||||||
},
|
},
|
||||||
"required": ["title", "description"],
|
"required": ["title", "description"],
|
||||||
"additionalProperties": false
|
"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
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"code": {
|
"code": {
|
||||||
|
|||||||
@@ -2021,6 +2021,7 @@
|
|||||||
"campaign": "Campaign",
|
"campaign": "Campaign",
|
||||||
"method": "Method",
|
"method": "Method",
|
||||||
"allocation": "Allocation",
|
"allocation": "Allocation",
|
||||||
|
"allocationTooltip": "Each enforces the quantity limit per item, while Once enforces the quantity limit across the entire cart",
|
||||||
"addCondition": "Add condition",
|
"addCondition": "Add condition",
|
||||||
"clearAll": "Clear all",
|
"clearAll": "Clear all",
|
||||||
"taxInclusive": "Tax Inclusive",
|
"taxInclusive": "Tax Inclusive",
|
||||||
@@ -2162,6 +2163,10 @@
|
|||||||
"across": {
|
"across": {
|
||||||
"title": "Across",
|
"title": "Across",
|
||||||
"description": "Applies value across items"
|
"description": "Applies value across items"
|
||||||
|
},
|
||||||
|
"once": {
|
||||||
|
"title": "Once",
|
||||||
|
"description": "Applies value to a limited number of items"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"code": {
|
"code": {
|
||||||
|
|||||||
+50
-23
@@ -714,7 +714,7 @@ export const CreatePromotionForm = () => {
|
|||||||
<Form.Control>
|
<Form.Control>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
dir={direction}
|
dir={direction}
|
||||||
className="flex gap-y-3"
|
className="flex gap-y-3"
|
||||||
{...field}
|
{...field}
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
>
|
>
|
||||||
@@ -840,7 +840,9 @@ export const CreatePromotionForm = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{((isTypeStandard && watchAllocation === "each") ||
|
{((isTypeStandard &&
|
||||||
|
(watchAllocation === "each" ||
|
||||||
|
watchAllocation === "once")) ||
|
||||||
isTypeBuyGet) && (
|
isTypeBuyGet) && (
|
||||||
<>
|
<>
|
||||||
{isTypeBuyGet && (
|
{isTypeBuyGet && (
|
||||||
@@ -898,7 +900,9 @@ export const CreatePromotionForm = () => {
|
|||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Form.Label>
|
<Form.Label
|
||||||
|
tooltip={t("promotions.fields.allocationTooltip")}
|
||||||
|
>
|
||||||
{t("promotions.fields.allocation")}
|
{t("promotions.fields.allocation")}
|
||||||
</Form.Label>
|
</Form.Label>
|
||||||
|
|
||||||
@@ -909,27 +913,50 @@ export const CreatePromotionForm = () => {
|
|||||||
{...field}
|
{...field}
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
>
|
>
|
||||||
<RadioGroup.ChoiceBox
|
{!currentTemplate?.hiddenFields?.includes(
|
||||||
value={"each"}
|
"application_method.allocation.each"
|
||||||
label={t(
|
) && (
|
||||||
"promotions.form.allocation.each.title"
|
<RadioGroup.ChoiceBox
|
||||||
)}
|
value={"each"}
|
||||||
description={t(
|
label={t(
|
||||||
"promotions.form.allocation.each.description"
|
"promotions.form.allocation.each.title"
|
||||||
)}
|
)}
|
||||||
className={clx("basis-1/2")}
|
description={t(
|
||||||
/>
|
"promotions.form.allocation.each.description"
|
||||||
|
)}
|
||||||
|
className={clx("basis-1/3")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<RadioGroup.ChoiceBox
|
{!currentTemplate?.hiddenFields?.includes(
|
||||||
value={"across"}
|
"application_method.allocation.across"
|
||||||
label={t(
|
) && (
|
||||||
"promotions.form.allocation.across.title"
|
<RadioGroup.ChoiceBox
|
||||||
)}
|
value={"across"}
|
||||||
description={t(
|
label={t(
|
||||||
"promotions.form.allocation.across.description"
|
"promotions.form.allocation.across.title"
|
||||||
)}
|
)}
|
||||||
className={clx("basis-1/2")}
|
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>
|
</RadioGroup>
|
||||||
</Form.Control>
|
</Form.Control>
|
||||||
<Form.ErrorMessage />
|
<Form.ErrorMessage />
|
||||||
|
|||||||
+3
-2
@@ -29,7 +29,7 @@ export const CreatePromotionSchema = z
|
|||||||
rules: RuleSchema,
|
rules: RuleSchema,
|
||||||
is_tax_inclusive: z.boolean().optional(),
|
is_tax_inclusive: z.boolean().optional(),
|
||||||
application_method: z.object({
|
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)),
|
value: z.number().min(0).or(z.string().min(1)),
|
||||||
currency_code: z.string().optional(),
|
currency_code: z.string().optional(),
|
||||||
max_quantity: z.number().optional().nullable(),
|
max_quantity: z.number().optional().nullable(),
|
||||||
@@ -47,7 +47,8 @@ export const CreatePromotionSchema = z
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
data.application_method.allocation === "each" &&
|
(data.application_method.allocation === "each" ||
|
||||||
|
data.application_method.allocation === "once") &&
|
||||||
typeof data.application_method.max_quantity === "number"
|
typeof data.application_method.max_quantity === "number"
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
+12
-2
@@ -1,30 +1,40 @@
|
|||||||
const commonHiddenFields = [
|
const commonHiddenFields = [
|
||||||
"type",
|
"type",
|
||||||
"application_method.type",
|
"application_method.type",
|
||||||
|
]
|
||||||
|
|
||||||
|
const amountOfOrderHiddenFields = [
|
||||||
|
...commonHiddenFields,
|
||||||
"application_method.allocation",
|
"application_method.allocation",
|
||||||
]
|
]
|
||||||
|
|
||||||
const amountOfOrderHiddenFields = [...commonHiddenFields]
|
const amountOfProductHiddenFields = [
|
||||||
const amountOfProductHiddenFields = [...commonHiddenFields]
|
...commonHiddenFields,
|
||||||
|
"application_method.allocation.across",
|
||||||
|
]
|
||||||
|
|
||||||
const percentageOfOrderHiddenFields = [
|
const percentageOfOrderHiddenFields = [
|
||||||
...commonHiddenFields,
|
...commonHiddenFields,
|
||||||
|
"application_method.allocation",
|
||||||
"is_tax_inclusive",
|
"is_tax_inclusive",
|
||||||
]
|
]
|
||||||
const percentageOfProductHiddenFields = [
|
const percentageOfProductHiddenFields = [
|
||||||
...commonHiddenFields,
|
...commonHiddenFields,
|
||||||
|
"application_method.allocation.across",
|
||||||
"is_tax_inclusive",
|
"is_tax_inclusive",
|
||||||
]
|
]
|
||||||
|
|
||||||
const buyGetHiddenFields = [
|
const buyGetHiddenFields = [
|
||||||
...commonHiddenFields,
|
...commonHiddenFields,
|
||||||
"application_method.value",
|
"application_method.value",
|
||||||
|
"application_method.allocation",
|
||||||
"is_tax_inclusive",
|
"is_tax_inclusive",
|
||||||
]
|
]
|
||||||
|
|
||||||
const freeShippingHiddenFields = [
|
const freeShippingHiddenFields = [
|
||||||
...commonHiddenFields,
|
...commonHiddenFields,
|
||||||
"application_method.value",
|
"application_method.value",
|
||||||
|
"application_method.allocation",
|
||||||
"is_tax_inclusive",
|
"is_tax_inclusive",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+64
-2
@@ -29,8 +29,9 @@ const EditPromotionSchema = zod.object({
|
|||||||
status: zod.enum(["active", "inactive", "draft"]),
|
status: zod.enum(["active", "inactive", "draft"]),
|
||||||
value_type: zod.enum(["fixed", "percentage"]),
|
value_type: zod.enum(["fixed", "percentage"]),
|
||||||
value: zod.number().min(0).or(zod.string().min(1)),
|
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"]),
|
target_type: zod.enum(["order", "shipping_methods", "items"]),
|
||||||
|
max_quantity: zod.number().min(1).optional().nullable(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const EditPromotionDetailsForm = ({
|
export const EditPromotionDetailsForm = ({
|
||||||
@@ -49,6 +50,7 @@ export const EditPromotionDetailsForm = ({
|
|||||||
allocation: promotion.application_method!.allocation,
|
allocation: promotion.application_method!.allocation,
|
||||||
value_type: promotion.application_method!.type,
|
value_type: promotion.application_method!.type,
|
||||||
target_type: promotion.application_method!.target_type,
|
target_type: promotion.application_method!.target_type,
|
||||||
|
max_quantity: promotion.application_method!.max_quantity,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(EditPromotionSchema),
|
resolver: zodResolver(EditPromotionSchema),
|
||||||
})
|
})
|
||||||
@@ -58,7 +60,13 @@ export const EditPromotionDetailsForm = ({
|
|||||||
name: "value_type",
|
name: "value_type",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const watchAllocation = useWatch({
|
||||||
|
control: form.control,
|
||||||
|
name: "allocation",
|
||||||
|
})
|
||||||
|
|
||||||
const isFixedValueType = watchValueType === "fixed"
|
const isFixedValueType = watchValueType === "fixed"
|
||||||
|
const originalAllocation = promotion.application_method!.allocation
|
||||||
|
|
||||||
const { mutateAsync, isPending } = useUpdatePromotion(promotion.id)
|
const { mutateAsync, isPending } = useUpdatePromotion(promotion.id)
|
||||||
|
|
||||||
@@ -80,6 +88,7 @@ export const EditPromotionDetailsForm = ({
|
|||||||
value: parseFloat(data.value),
|
value: parseFloat(data.value),
|
||||||
type: data.value_type as any,
|
type: data.value_type as any,
|
||||||
allocation: data.allocation as any,
|
allocation: data.allocation as any,
|
||||||
|
max_quantity: data.max_quantity,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -335,7 +344,9 @@ export const EditPromotionDetailsForm = ({
|
|||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Form.Label>
|
<Form.Label
|
||||||
|
tooltip={t("promotions.fields.allocationTooltip")}
|
||||||
|
>
|
||||||
{t("promotions.fields.allocation")}
|
{t("promotions.fields.allocation")}
|
||||||
</Form.Label>
|
</Form.Label>
|
||||||
<Form.Control>
|
<Form.Control>
|
||||||
@@ -351,6 +362,7 @@ export const EditPromotionDetailsForm = ({
|
|||||||
description={t(
|
description={t(
|
||||||
"promotions.form.allocation.each.description"
|
"promotions.form.allocation.each.description"
|
||||||
)}
|
)}
|
||||||
|
disabled={originalAllocation === "across"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RadioGroup.ChoiceBox
|
<RadioGroup.ChoiceBox
|
||||||
@@ -361,6 +373,19 @@ export const EditPromotionDetailsForm = ({
|
|||||||
description={t(
|
description={t(
|
||||||
"promotions.form.allocation.across.description"
|
"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>
|
</RadioGroup>
|
||||||
</Form.Control>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export enum ApplicationMethodTargetType {
|
|||||||
export enum ApplicationMethodAllocation {
|
export enum ApplicationMethodAllocation {
|
||||||
EACH = "each",
|
EACH = "each",
|
||||||
ACROSS = "across",
|
ACROSS = "across",
|
||||||
|
ONCE = "once",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum PromotionRuleOperator {
|
export enum PromotionRuleOperator {
|
||||||
|
|||||||
+276
@@ -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,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
+2
-2
@@ -226,7 +226,7 @@ moduleIntegrationTestRunner({
|
|||||||
}).catch((e) => e)
|
}).catch((e) => e)
|
||||||
|
|
||||||
expect(error.message).toContain(
|
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)
|
}).catch((e) => e)
|
||||||
|
|
||||||
expect(error.message).toContain(
|
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 {
|
enum ApplicationMethodAllocationValues {
|
||||||
each
|
each
|
||||||
across
|
across
|
||||||
|
once
|
||||||
}
|
}
|
||||||
|
|
||||||
type Promotion {
|
type Promotion {
|
||||||
|
|||||||
@@ -7,15 +7,16 @@ import {
|
|||||||
import {
|
import {
|
||||||
ApplicationMethodAllocation,
|
ApplicationMethodAllocation,
|
||||||
ApplicationMethodTargetType,
|
ApplicationMethodTargetType,
|
||||||
ApplicationMethodTargetType as TargetType,
|
|
||||||
calculateAdjustmentAmountFromPromotion,
|
calculateAdjustmentAmountFromPromotion,
|
||||||
ComputedActions,
|
ComputedActions,
|
||||||
MathBN,
|
MathBN,
|
||||||
MedusaError,
|
MedusaError,
|
||||||
|
ApplicationMethodTargetType as TargetType,
|
||||||
} from "@medusajs/framework/utils"
|
} from "@medusajs/framework/utils"
|
||||||
import { areRulesValidForContext } from "../validations"
|
|
||||||
import { computeActionForBudgetExceeded } from "./usage"
|
|
||||||
import { Promotion } from "@models"
|
import { Promotion } from "@models"
|
||||||
|
import { areRulesValidForContext } from "../validations"
|
||||||
|
import { sortLineItemByPriceAscending } from "./sort-by-price"
|
||||||
|
import { computeActionForBudgetExceeded } from "./usage"
|
||||||
|
|
||||||
function validateContext(
|
function validateContext(
|
||||||
contextKey: string,
|
contextKey: string,
|
||||||
@@ -66,16 +67,24 @@ function applyPromotionToItems(
|
|||||||
|
|
||||||
const computedActions: PromotionTypes.ComputeActions[] = []
|
const computedActions: PromotionTypes.ComputeActions[] = []
|
||||||
|
|
||||||
const applicableItems = getValidItemsForPromotion(items, promotion)
|
let applicableItems = getValidItemsForPromotion(
|
||||||
|
items,
|
||||||
|
promotion
|
||||||
|
) as PromotionTypes.ComputeActionItemLine[]
|
||||||
|
|
||||||
if (!applicableItems.length) {
|
if (!applicableItems.length) {
|
||||||
return computedActions
|
return computedActions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (allocation === ApplicationMethodAllocation.ONCE) {
|
||||||
|
applicableItems = applicableItems.sort(sortLineItemByPriceAscending)
|
||||||
|
}
|
||||||
|
|
||||||
const isTargetLineItems = target === TargetType.ITEMS
|
const isTargetLineItems = target === TargetType.ITEMS
|
||||||
const isTargetOrder = target === TargetType.ORDER
|
const isTargetOrder = target === TargetType.ORDER
|
||||||
const promotionValue = applicationMethod?.value ?? 0
|
const promotionValue = applicationMethod?.value ?? 0
|
||||||
const maxQuantity = applicationMethod?.max_quantity!
|
const maxQuantity = applicationMethod?.max_quantity!
|
||||||
|
let remainingQuota = maxQuantity ?? 0
|
||||||
|
|
||||||
let lineItemsAmount = MathBN.convert(0)
|
let lineItemsAmount = MathBN.convert(0)
|
||||||
if (allocation === ApplicationMethodAllocation.ACROSS) {
|
if (allocation === ApplicationMethodAllocation.ACROSS) {
|
||||||
@@ -97,6 +106,12 @@ function applyPromotionToItems(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const item of applicableItems) {
|
for (const item of applicableItems) {
|
||||||
|
if (
|
||||||
|
allocation === ApplicationMethodAllocation.ONCE &&
|
||||||
|
remainingQuota <= 0
|
||||||
|
) {
|
||||||
|
break
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
MathBN.lte(
|
MathBN.lte(
|
||||||
promotion.is_tax_inclusive ? item.original_total : item.subtotal,
|
promotion.is_tax_inclusive ? item.original_total : item.subtotal,
|
||||||
@@ -108,15 +123,26 @@ function applyPromotionToItems(
|
|||||||
|
|
||||||
const appliedPromoValue = appliedPromotionsMap.get(item.id) ?? 0
|
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(
|
const amount = calculateAdjustmentAmountFromPromotion(
|
||||||
item,
|
item,
|
||||||
{
|
{
|
||||||
value: promotionValue,
|
value: promotionValue,
|
||||||
applied_value: appliedPromoValue,
|
applied_value: appliedPromoValue,
|
||||||
is_tax_inclusive: promotion.is_tax_inclusive,
|
is_tax_inclusive: promotion.is_tax_inclusive,
|
||||||
max_quantity: maxQuantity,
|
max_quantity: effectiveMaxQuantity,
|
||||||
type: applicationMethod?.type!,
|
type: applicationMethod?.type!,
|
||||||
allocation,
|
allocation: effectiveAllocation,
|
||||||
},
|
},
|
||||||
lineItemsAmount
|
lineItemsAmount
|
||||||
)
|
)
|
||||||
@@ -137,6 +163,15 @@ function applyPromotionToItems(
|
|||||||
|
|
||||||
appliedPromotionsMap.set(item.id, MathBN.add(appliedPromoValue, amount))
|
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) {
|
if (isTargetLineItems || isTargetOrder) {
|
||||||
computedActions.push({
|
computedActions.push({
|
||||||
action: ComputedActions.ADD_ITEM_ADJUSTMENT,
|
action: ComputedActions.ADD_ITEM_ADJUSTMENT,
|
||||||
|
|||||||
@@ -11,16 +11,17 @@ import {
|
|||||||
MathBN,
|
MathBN,
|
||||||
MedusaError,
|
MedusaError,
|
||||||
} from "@medusajs/framework/utils"
|
} from "@medusajs/framework/utils"
|
||||||
import { areRulesValidForContext } from "../validations"
|
|
||||||
import { computeActionForBudgetExceeded } from "./usage"
|
|
||||||
import { Promotion } from "@models"
|
import { Promotion } from "@models"
|
||||||
|
import { areRulesValidForContext } from "../validations"
|
||||||
|
import { sortShippingLineByPriceAscending } from "./sort-by-price"
|
||||||
|
import { computeActionForBudgetExceeded } from "./usage"
|
||||||
|
|
||||||
export function getComputedActionsForShippingMethods(
|
export function getComputedActionsForShippingMethods(
|
||||||
promotion: PromotionTypes.PromotionDTO | InferEntityType<typeof Promotion>,
|
promotion: PromotionTypes.PromotionDTO | InferEntityType<typeof Promotion>,
|
||||||
shippingMethodApplicationContext: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.SHIPPING_METHODS],
|
shippingMethodApplicationContext: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.SHIPPING_METHODS],
|
||||||
methodIdPromoValueMap: Map<string, number>
|
methodIdPromoValueMap: Map<string, number>
|
||||||
): PromotionTypes.ComputeActions[] {
|
): PromotionTypes.ComputeActions[] {
|
||||||
const applicableShippingItems: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.SHIPPING_METHODS] =
|
let applicableShippingItems: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.SHIPPING_METHODS] =
|
||||||
[]
|
[]
|
||||||
|
|
||||||
if (!shippingMethodApplicationContext) {
|
if (!shippingMethodApplicationContext) {
|
||||||
@@ -44,6 +45,13 @@ export function getComputedActionsForShippingMethods(
|
|||||||
applicableShippingItems.push(shippingMethodContext)
|
applicableShippingItems.push(shippingMethodContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allocation = promotion.application_method?.allocation!
|
||||||
|
if (allocation === ApplicationMethodAllocation.ONCE) {
|
||||||
|
applicableShippingItems = applicableShippingItems.sort(
|
||||||
|
sortShippingLineByPriceAscending
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return applyPromotionToShippingMethods(
|
return applyPromotionToShippingMethods(
|
||||||
promotion,
|
promotion,
|
||||||
applicableShippingItems,
|
applicableShippingItems,
|
||||||
@@ -59,9 +67,20 @@ export function applyPromotionToShippingMethods(
|
|||||||
const { application_method: applicationMethod } = promotion
|
const { application_method: applicationMethod } = promotion
|
||||||
const allocation = applicationMethod?.allocation!
|
const allocation = applicationMethod?.allocation!
|
||||||
const computedActions: PromotionTypes.ComputeActions[] = []
|
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!) {
|
for (const method of shippingMethods!) {
|
||||||
|
if (
|
||||||
|
allocation === ApplicationMethodAllocation.ONCE &&
|
||||||
|
remainingQuota <= 0
|
||||||
|
) {
|
||||||
|
break
|
||||||
|
}
|
||||||
if (!method.subtotal) {
|
if (!method.subtotal) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -99,6 +118,10 @@ export function applyPromotionToShippingMethods(
|
|||||||
MathBN.add(appliedPromoValue, amount)
|
MathBN.add(appliedPromoValue, amount)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (allocation === ApplicationMethodAllocation.ONCE) {
|
||||||
|
remainingQuota -= 1
|
||||||
|
}
|
||||||
|
|
||||||
computedActions.push({
|
computedActions.push({
|
||||||
action: ComputedActions.ADD_SHIPPING_METHOD_ADJUSTMENT,
|
action: ComputedActions.ADD_SHIPPING_METHOD_ADJUSTMENT,
|
||||||
shipping_method_id: method.id,
|
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[] = [
|
export const allowedAllocationTypes: string[] = [
|
||||||
ApplicationMethodAllocation.ACROSS,
|
ApplicationMethodAllocation.ACROSS,
|
||||||
ApplicationMethodAllocation.EACH,
|
ApplicationMethodAllocation.EACH,
|
||||||
|
ApplicationMethodAllocation.ONCE,
|
||||||
]
|
]
|
||||||
|
|
||||||
export const allowedAllocationForQuantity: string[] = [
|
export const allowedAllocationForQuantity: string[] = [
|
||||||
ApplicationMethodAllocation.EACH,
|
ApplicationMethodAllocation.EACH,
|
||||||
|
ApplicationMethodAllocation.ONCE,
|
||||||
]
|
]
|
||||||
|
|
||||||
export function validateApplicationMethodAttributes(
|
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