diff --git a/integration-tests/http/__tests__/cart/store/cart.spec.ts b/integration-tests/http/__tests__/cart/store/cart.spec.ts
index 6ed7f34f33..666933bdc9 100644
--- a/integration-tests/http/__tests__/cart/store/cart.spec.ts
+++ b/integration-tests/http/__tests__/cart/store/cart.spec.ts
@@ -9,6 +9,7 @@ import {
PromotionStatus,
PromotionType,
} from "@medusajs/utils"
+import { setTimeout } from "timers/promises"
import {
createAdminUser,
generatePublishableKey,
@@ -17,7 +18,6 @@ import {
import { setupTaxStructure } from "../../../../modules/__tests__/fixtures"
import { createAuthenticatedCustomer } from "../../../../modules/helpers/create-authenticated-customer"
import { medusaTshirtProduct } from "../../../__fixtures__/product"
-import { setTimeout } from "timers/promises"
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)
+ })
+ })
})
})
diff --git a/packages/admin/dashboard/src/i18n/translations/$schema.json b/packages/admin/dashboard/src/i18n/translations/$schema.json
index 726e246988..914b0a1a07 100644
--- a/packages/admin/dashboard/src/i18n/translations/$schema.json
+++ b/packages/admin/dashboard/src/i18n/translations/$schema.json
@@ -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": {
diff --git a/packages/admin/dashboard/src/i18n/translations/en.json b/packages/admin/dashboard/src/i18n/translations/en.json
index 6eef3ee66c..70db13f6b2 100644
--- a/packages/admin/dashboard/src/i18n/translations/en.json
+++ b/packages/admin/dashboard/src/i18n/translations/en.json
@@ -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": {
diff --git a/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/create-promotion-form.tsx b/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/create-promotion-form.tsx
index ea1dbdfd67..c31e45d739 100644
--- a/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/create-promotion-form.tsx
+++ b/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/create-promotion-form.tsx
@@ -714,7 +714,7 @@ export const CreatePromotionForm = () => {
@@ -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 (
-
+
{t("promotions.fields.allocation")}
@@ -909,27 +913,50 @@ export const CreatePromotionForm = () => {
{...field}
onValueChange={field.onChange}
>
-
+ {!currentTemplate?.hiddenFields?.includes(
+ "application_method.allocation.each"
+ ) && (
+
+ )}
-
+ {!currentTemplate?.hiddenFields?.includes(
+ "application_method.allocation.across"
+ ) && (
+
+ )}
+
+ {!currentTemplate?.hiddenFields?.includes(
+ "application_method.allocation.once"
+ ) && (
+
+ )}
diff --git a/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/form-schema.ts b/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/form-schema.ts
index 9dbb81bc11..2b4372be45 100644
--- a/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/form-schema.ts
+++ b/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/form-schema.ts
@@ -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"
)
},
diff --git a/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/templates.ts b/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/templates.ts
index c90a0aa329..c81b87734e 100644
--- a/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/templates.ts
+++ b/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/templates.ts
@@ -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",
]
diff --git a/packages/admin/dashboard/src/routes/promotions/promotion-edit-details/components/edit-promotion-form/edit-promotion-details-form.tsx b/packages/admin/dashboard/src/routes/promotions/promotion-edit-details/components/edit-promotion-form/edit-promotion-details-form.tsx
index 8e207c37ad..5d77c1f72a 100644
--- a/packages/admin/dashboard/src/routes/promotions/promotion-edit-details/components/edit-promotion-form/edit-promotion-details-form.tsx
+++ b/packages/admin/dashboard/src/routes/promotions/promotion-edit-details/components/edit-promotion-form/edit-promotion-details-form.tsx
@@ -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 (
-
+
{t("promotions.fields.allocation")}
@@ -351,6 +362,7 @@ export const EditPromotionDetailsForm = ({
description={t(
"promotions.form.allocation.each.description"
)}
+ disabled={originalAllocation === "across"}
/>
+
+
@@ -369,6 +394,43 @@ export const EditPromotionDetailsForm = ({
)
}}
/>
+ {(watchAllocation === "each" || watchAllocation === "once") && (
+ {
+ return (
+
+
+ {t("promotions.form.max_quantity.title")}
+
+
+
+
+
+ ]}
+ />
+
+
+
+ )
+ }}
+ />
+ )}
>
)}
diff --git a/packages/core/utils/src/promotion/index.ts b/packages/core/utils/src/promotion/index.ts
index 362feef3d9..14039a51c4 100644
--- a/packages/core/utils/src/promotion/index.ts
+++ b/packages/core/utils/src/promotion/index.ts
@@ -23,6 +23,7 @@ export enum ApplicationMethodTargetType {
export enum ApplicationMethodAllocation {
EACH = "each",
ACROSS = "across",
+ ONCE = "once",
}
export enum PromotionRuleOperator {
diff --git a/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts
index 570356ddc9..d82ce87551 100644
--- a/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts
+++ b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts
@@ -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,
+ },
+ ])
+ })
+ })
+ })
})
},
})
diff --git a/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/promotion.spec.ts b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/promotion.spec.ts
index c8ad3cc9e3..6c51d406dc 100644
--- a/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/promotion.spec.ts
+++ b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/promotion.spec.ts
@@ -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'"
)
})
diff --git a/packages/modules/promotion/src/migrations/Migration20251006000000.ts b/packages/modules/promotion/src/migrations/Migration20251006000000.ts
new file mode 100644
index 0000000000..2123f53adc
--- /dev/null
+++ b/packages/modules/promotion/src/migrations/Migration20251006000000.ts
@@ -0,0 +1,15 @@
+import { Migration } from '@mikro-orm/migrations';
+
+export class Migration20251006000000 extends Migration {
+
+ override async up(): Promise {
+ 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 {
+ 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'));`);
+ }
+
+}
diff --git a/packages/modules/promotion/src/schema/index.ts b/packages/modules/promotion/src/schema/index.ts
index ecaa60b4de..3d26104783 100644
--- a/packages/modules/promotion/src/schema/index.ts
+++ b/packages/modules/promotion/src/schema/index.ts
@@ -33,6 +33,7 @@ enum ApplicationMethodTargetTypeValues {
enum ApplicationMethodAllocationValues {
each
across
+ once
}
type Promotion {
diff --git a/packages/modules/promotion/src/utils/compute-actions/line-items.ts b/packages/modules/promotion/src/utils/compute-actions/line-items.ts
index ca43dccfbb..69736577c3 100644
--- a/packages/modules/promotion/src/utils/compute-actions/line-items.ts
+++ b/packages/modules/promotion/src/utils/compute-actions/line-items.ts
@@ -7,15 +7,16 @@ import {
import {
ApplicationMethodAllocation,
ApplicationMethodTargetType,
- ApplicationMethodTargetType as TargetType,
calculateAdjustmentAmountFromPromotion,
ComputedActions,
MathBN,
MedusaError,
+ ApplicationMethodTargetType as TargetType,
} from "@medusajs/framework/utils"
-import { areRulesValidForContext } from "../validations"
-import { computeActionForBudgetExceeded } from "./usage"
import { Promotion } from "@models"
+import { areRulesValidForContext } from "../validations"
+import { sortLineItemByPriceAscending } from "./sort-by-price"
+import { computeActionForBudgetExceeded } from "./usage"
function validateContext(
contextKey: string,
@@ -66,16 +67,24 @@ function applyPromotionToItems(
const computedActions: PromotionTypes.ComputeActions[] = []
- const applicableItems = getValidItemsForPromotion(items, promotion)
+ let applicableItems = getValidItemsForPromotion(
+ items,
+ promotion
+ ) as PromotionTypes.ComputeActionItemLine[]
if (!applicableItems.length) {
return computedActions
}
+ if (allocation === ApplicationMethodAllocation.ONCE) {
+ applicableItems = applicableItems.sort(sortLineItemByPriceAscending)
+ }
+
const isTargetLineItems = target === TargetType.ITEMS
const isTargetOrder = target === TargetType.ORDER
const promotionValue = applicationMethod?.value ?? 0
const maxQuantity = applicationMethod?.max_quantity!
+ let remainingQuota = maxQuantity ?? 0
let lineItemsAmount = MathBN.convert(0)
if (allocation === ApplicationMethodAllocation.ACROSS) {
@@ -97,6 +106,12 @@ function applyPromotionToItems(
}
for (const item of applicableItems) {
+ if (
+ allocation === ApplicationMethodAllocation.ONCE &&
+ remainingQuota <= 0
+ ) {
+ break
+ }
if (
MathBN.lte(
promotion.is_tax_inclusive ? item.original_total : item.subtotal,
@@ -108,15 +123,26 @@ function applyPromotionToItems(
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(
item,
{
value: promotionValue,
applied_value: appliedPromoValue,
is_tax_inclusive: promotion.is_tax_inclusive,
- max_quantity: maxQuantity,
+ max_quantity: effectiveMaxQuantity,
type: applicationMethod?.type!,
- allocation,
+ allocation: effectiveAllocation,
},
lineItemsAmount
)
@@ -137,6 +163,15 @@ function applyPromotionToItems(
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) {
computedActions.push({
action: ComputedActions.ADD_ITEM_ADJUSTMENT,
diff --git a/packages/modules/promotion/src/utils/compute-actions/shipping-methods.ts b/packages/modules/promotion/src/utils/compute-actions/shipping-methods.ts
index 999faa341b..cd5579bba6 100644
--- a/packages/modules/promotion/src/utils/compute-actions/shipping-methods.ts
+++ b/packages/modules/promotion/src/utils/compute-actions/shipping-methods.ts
@@ -11,16 +11,17 @@ import {
MathBN,
MedusaError,
} from "@medusajs/framework/utils"
-import { areRulesValidForContext } from "../validations"
-import { computeActionForBudgetExceeded } from "./usage"
import { Promotion } from "@models"
+import { areRulesValidForContext } from "../validations"
+import { sortShippingLineByPriceAscending } from "./sort-by-price"
+import { computeActionForBudgetExceeded } from "./usage"
export function getComputedActionsForShippingMethods(
promotion: PromotionTypes.PromotionDTO | InferEntityType,
shippingMethodApplicationContext: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.SHIPPING_METHODS],
methodIdPromoValueMap: Map
): PromotionTypes.ComputeActions[] {
- const applicableShippingItems: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.SHIPPING_METHODS] =
+ let applicableShippingItems: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.SHIPPING_METHODS] =
[]
if (!shippingMethodApplicationContext) {
@@ -44,6 +45,13 @@ export function getComputedActionsForShippingMethods(
applicableShippingItems.push(shippingMethodContext)
}
+ const allocation = promotion.application_method?.allocation!
+ if (allocation === ApplicationMethodAllocation.ONCE) {
+ applicableShippingItems = applicableShippingItems.sort(
+ sortShippingLineByPriceAscending
+ )
+ }
+
return applyPromotionToShippingMethods(
promotion,
applicableShippingItems,
@@ -59,9 +67,20 @@ export function applyPromotionToShippingMethods(
const { application_method: applicationMethod } = promotion
const allocation = applicationMethod?.allocation!
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!) {
+ if (
+ allocation === ApplicationMethodAllocation.ONCE &&
+ remainingQuota <= 0
+ ) {
+ break
+ }
if (!method.subtotal) {
continue
}
@@ -99,6 +118,10 @@ export function applyPromotionToShippingMethods(
MathBN.add(appliedPromoValue, amount)
)
+ if (allocation === ApplicationMethodAllocation.ONCE) {
+ remainingQuota -= 1
+ }
+
computedActions.push({
action: ComputedActions.ADD_SHIPPING_METHOD_ADJUSTMENT,
shipping_method_id: method.id,
diff --git a/packages/modules/promotion/src/utils/compute-actions/sort-by-price.ts b/packages/modules/promotion/src/utils/compute-actions/sort-by-price.ts
new file mode 100644
index 0000000000..e996141caa
--- /dev/null
+++ b/packages/modules/promotion/src/utils/compute-actions/sort-by-price.ts
@@ -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
+}
diff --git a/packages/modules/promotion/src/utils/validations/application-method.ts b/packages/modules/promotion/src/utils/validations/application-method.ts
index c8dfb04911..3f71fd7376 100644
--- a/packages/modules/promotion/src/utils/validations/application-method.ts
+++ b/packages/modules/promotion/src/utils/validations/application-method.ts
@@ -21,10 +21,12 @@ export const allowedAllocationTargetTypes: string[] = [
export const allowedAllocationTypes: string[] = [
ApplicationMethodAllocation.ACROSS,
ApplicationMethodAllocation.EACH,
+ ApplicationMethodAllocation.ONCE,
]
export const allowedAllocationForQuantity: string[] = [
ApplicationMethodAllocation.EACH,
+ ApplicationMethodAllocation.ONCE,
]
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'`
+ )
+ }
}