From b5ecdfcd125e2c577cb4f7d8dde9afb25ea966dc Mon Sep 17 00:00:00 2001 From: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Tue, 14 Oct 2025 13:01:00 +0200 Subject: [PATCH] feat: Add allocation method type `ONCE` (#13700) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 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%) ``` --- .../http/__tests__/cart/store/cart.spec.ts | 311 +++++++++++++++++- .../src/i18n/translations/$schema.json | 18 +- .../dashboard/src/i18n/translations/en.json | 5 + .../create-promotion-form.tsx | 73 ++-- .../create-promotion-form/form-schema.ts | 5 +- .../create-promotion-form/templates.ts | 14 +- .../edit-promotion-details-form.tsx | 66 +++- packages/core/utils/src/promotion/index.ts | 1 + .../promotion-module/compute-actions.spec.ts | 276 ++++++++++++++++ .../promotion-module/promotion.spec.ts | 4 +- .../src/migrations/Migration20251006000000.ts | 15 + .../modules/promotion/src/schema/index.ts | 1 + .../src/utils/compute-actions/line-items.ts | 47 ++- .../utils/compute-actions/shipping-methods.ts | 31 +- .../utils/compute-actions/sort-by-price.ts | 23 ++ .../utils/validations/application-method.ts | 12 + 16 files changed, 859 insertions(+), 43 deletions(-) create mode 100644 packages/modules/promotion/src/migrations/Migration20251006000000.ts create mode 100644 packages/modules/promotion/src/utils/compute-actions/sort-by-price.ts 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'` + ) + } }