From 3cc512ef39b6d64b1fe04c1bcdb2e23fa03526f6 Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Sun, 17 Aug 2025 19:11:16 +0200 Subject: [PATCH] fix(utils): fix promotion case of each allocation not applying its total amount (#13199) * fix(utils): fix promotion case of each allocation not applying its amount * chore: fixed tests --------- Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> --- .changeset/fresh-dragons-build.md | 5 + .../http/__tests__/cart/store/cart.spec.ts | 111 +++++++++++++++++- .../core/utils/src/totals/promotion/index.ts | 44 ++++++- .../promotion-module/compute-actions.spec.ts | 2 +- 4 files changed, 150 insertions(+), 12 deletions(-) create mode 100644 .changeset/fresh-dragons-build.md diff --git a/.changeset/fresh-dragons-build.md b/.changeset/fresh-dragons-build.md new file mode 100644 index 0000000000..d5398ba1eb --- /dev/null +++ b/.changeset/fresh-dragons-build.md @@ -0,0 +1,5 @@ +--- +"@medusajs/utils": patch +--- + +fix(utils): fix promotion case of each allocation not applying its amount diff --git a/integration-tests/http/__tests__/cart/store/cart.spec.ts b/integration-tests/http/__tests__/cart/store/cart.spec.ts index 731e6d3376..357bf261e8 100644 --- a/integration-tests/http/__tests__/cart/store/cart.spec.ts +++ b/integration-tests/http/__tests__/cart/store/cart.spec.ts @@ -3321,18 +3321,18 @@ medusaIntegrationTestRunner({ expect(updated.status).toEqual(200) expect(updated.data.cart).toEqual( expect.objectContaining({ - discount_total: 105, - discount_subtotal: 100, - discount_tax_total: 5, + discount_total: 210, + discount_subtotal: 200, + discount_tax_total: 10, original_total: 210, - total: 105, // 210 - 100 tax excl promotion + 5 promotion tax + total: 0, // 210 - 200 tax excl promotion + 10 promotion tax items: expect.arrayContaining([ expect.objectContaining({ is_tax_inclusive: true, adjustments: expect.arrayContaining([ expect.objectContaining({ code: taxInclPromotion.code, - amount: 105, + amount: 210, is_tax_inclusive: true, }), ]), @@ -3739,6 +3739,107 @@ medusaIntegrationTestRunner({ ) }) + it("should apply promotions to multiple quantity of the same product", async () => { + const product = ( + await api.post( + `/admin/products`, + { + title: "Product for free", + description: "test", + options: [ + { + title: "Size", + values: ["S"], + }, + ], + variants: [ + { + title: "S / Black", + sku: "special-shirt", + options: { + Size: "S", + }, + manage_inventory: false, + prices: [ + { + amount: 100, + currency_code: "eur", + }, + ], + }, + ], + }, + adminHeaders + ) + ).data.product + + const sameProductPromotion = ( + await api.post( + `/admin/promotions`, + { + code: "SAME_PRODUCT_PROMOTION", + type: PromotionType.STANDARD, + status: PromotionStatus.ACTIVE, + is_tax_inclusive: false, + is_automatic: true, + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + value: 100, + max_quantity: 5, + currency_code: "eur", + target_rules: [ + { + attribute: "product_id", + operator: "in", + values: [product.id], + }, + ], + }, + }, + adminHeaders + ) + ).data.promotion + + cart = ( + await api.post( + `/store/carts`, + { + currency_code: "eur", + sales_channel_id: salesChannel.id, + region_id: noAutomaticRegion.id, + shipping_address: shippingAddressData, + items: [{ variant_id: product.variants[0].id, quantity: 2 }], + }, + storeHeadersWithCustomer + ) + ).data.cart + + expect(cart).toEqual( + expect.objectContaining({ + discount_total: 200, + original_total: 200, + total: 0, + items: expect.arrayContaining([ + expect.objectContaining({ + adjustments: expect.arrayContaining([ + expect.objectContaining({ + code: sameProductPromotion.code, + amount: 200, + }), + ]), + }), + ]), + promotions: expect.arrayContaining([ + expect.objectContaining({ + code: sameProductPromotion.code, + }), + ]), + }) + ) + }) + describe("Percentage promotions", () => { it("should apply a percentage promotion to a cart", async () => { const percentagePromotion = ( diff --git a/packages/core/utils/src/totals/promotion/index.ts b/packages/core/utils/src/totals/promotion/index.ts index 14f9ce6c1f..ab52ab12ba 100644 --- a/packages/core/utils/src/totals/promotion/index.ts +++ b/packages/core/utils/src/totals/promotion/index.ts @@ -9,7 +9,12 @@ function getPromotionValueForPercentage(promotion, lineItemAmount) { return MathBN.mult(MathBN.div(promotion.value, 100), lineItemAmount) } -function getPromotionValueForFixed(promotion, lineItemAmount, lineItemsAmount) { +function getPromotionValueForFixed( + promotion, + lineItemAmount, + lineItemsAmount, + lineItem +) { if (promotion.allocation === ApplicationMethodAllocation.ACROSS) { const promotionValueForItem = MathBN.mult( MathBN.div(lineItemAmount, lineItemsAmount), @@ -27,15 +32,37 @@ function getPromotionValueForFixed(promotion, lineItemAmount, lineItemsAmount) { return MathBN.mult(promotionValueForItem, MathBN.div(percentage, 100)) } - return promotion.value + + // For each allocation, promotion is applied in the scope of the line item. + // lineItemAmount will be the total applicable amount for the line item + // maximumPromotionAmount is the maximum amount that can be applied to the line item + // We need to return the minimum of the two + const maximumQuantity = MathBN.min( + lineItem.quantity, + promotion.max_quantity ?? MathBN.convert(1) + ) + + const maximumPromotionAmount = MathBN.mult(promotion.value, maximumQuantity) + + return MathBN.min(maximumPromotionAmount, lineItemAmount) } -export function getPromotionValue(promotion, lineItemAmount, lineItemsAmount) { +export function getPromotionValue( + promotion, + lineItemAmount, + lineItemsAmount, + lineItem +) { if (promotion.type === ApplicationMethodType.PERCENTAGE) { return getPromotionValueForPercentage(promotion, lineItemAmount) } - return getPromotionValueForFixed(promotion, lineItemAmount, lineItemsAmount) + return getPromotionValueForFixed( + promotion, + lineItemAmount, + lineItemsAmount, + lineItem + ) } export function getApplicableQuantity(lineItem, maxQuantity) { @@ -105,7 +132,8 @@ export function calculateAdjustmentAmountFromPromotion( const promotionValue = getPromotionValue( promotion, applicableAmount, - lineItemsAmount + lineItemsAmount, + lineItem ) const returnValue = MathBN.min(promotionValue, applicableAmount) @@ -139,14 +167,17 @@ export function calculateAdjustmentAmountFromPromotion( promotion.is_tax_inclusive ? lineItem.original_total : lineItem.subtotal, promotion.applied_value ) + const itemAmount = MathBN.div( promotion.is_tax_inclusive ? lineItem.original_total : lineItem.subtotal, lineItem.quantity ) + const maximumPromotionAmount = MathBN.mult( itemAmount, promotion.max_quantity ?? MathBN.convert(1) ) + const applicableAmount = MathBN.min( remainingItemAmount, maximumPromotionAmount @@ -159,7 +190,8 @@ export function calculateAdjustmentAmountFromPromotion( const promotionValue = getPromotionValue( promotion, applicableAmount, - lineItemsAmount + lineItemsAmount, + lineItem ) const returnValue = MathBN.min(promotionValue, applicableAmount) 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 8cb172034c..1b42e0e366 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 @@ -464,7 +464,7 @@ moduleIntegrationTestRunner({ type: "fixed", target_type: "items", allocation: "each", - value: 500, + value: 100, max_quantity: 5, target_rules: [ {