diff --git a/packages/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts b/packages/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts index 4b97bdd521a1a..09c7cdcaaf68d 100644 --- a/packages/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts +++ b/packages/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts @@ -38,44 +38,40 @@ describe("Promotion Service: computeActions", () => { }) describe("when code is not present in database", () => { - it("should throw error when code in promotions array does not exist", async () => { - const error = await service - .computeActions(["DOES_NOT_EXIST"], { - customer: { - customer_group: { - id: "VIP", + it("should return empty array when promotion does not exist", async () => { + const response = await service.computeActions(["DOES_NOT_EXIST"], { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + unit_price: 100, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", }, }, - items: [ - { - id: "item_cotton_tshirt", - quantity: 1, - unit_price: 100, - product_category: { - id: "catg_cotton", - }, - product: { - id: "prod_tshirt", - }, + { + id: "item_cotton_sweater", + quantity: 5, + unit_price: 150, + product_category: { + id: "catg_cotton", }, - { - id: "item_cotton_sweater", - quantity: 5, - unit_price: 150, - product_category: { - id: "catg_cotton", - }, - product: { - id: "prod_sweater", - }, + product: { + id: "prod_sweater", }, - ], - }) - .catch((e) => e) + }, + ], + }) - expect(error.message).toContain( - "Promotion for code (DOES_NOT_EXIST) not found" - ) + expect(response).toEqual([]) }) it("should throw error when code in items adjustment does not exist", async () => { @@ -2315,4 +2311,366 @@ describe("Promotion Service: computeActions", () => { ]) }) }) + + describe("when promotion of type buyget", () => { + it("should compute adjustment when target and buy rules match", async () => { + const context = { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 2, + unit_price: 500, + product_category: { + id: "catg_tshirt", + }, + product: { + id: "prod_tshirt_1", + }, + }, + { + id: "item_cotton_tshirt2", + quantity: 2, + unit_price: 1000, + product_category: { + id: "catg_tshirt", + }, + product: { + id: "prod_tshirt_2", + }, + }, + { + id: "item_cotton_sweater", + quantity: 2, + unit_price: 1000, + product_category: { + id: "catg_sweater", + }, + product: { + id: "prod_sweater_1", + }, + }, + ], + } + + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.BUYGET, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + max_quantity: 1, + apply_to_quantity: 1, + buy_rules_min_quantity: 1, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_tshirt"], + }, + ], + buy_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_sweater"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions(["PROMOTION_TEST"], context) + + expect(result).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt2", + amount: 1000, + code: "PROMOTION_TEST", + }, + ]) + }) + + it("should return empty array when conditions for minimum qty aren't met", async () => { + const context = { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 2, + unit_price: 500, + product_category: { + id: "catg_tshirt", + }, + product: { + id: "prod_tshirt_1", + }, + }, + { + id: "item_cotton_tshirt2", + quantity: 2, + unit_price: 1000, + product_category: { + id: "catg_tshirt", + }, + product: { + id: "prod_tshirt_2", + }, + }, + { + id: "item_cotton_sweater", + quantity: 2, + unit_price: 1000, + product_category: { + id: "catg_sweater", + }, + product: { + id: "prod_sweater_1", + }, + }, + ], + } + + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.BUYGET, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + max_quantity: 1, + apply_to_quantity: 1, + buy_rules_min_quantity: 4, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_tshirt"], + }, + ], + buy_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_sweater"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions(["PROMOTION_TEST"], context) + + expect(result).toEqual([]) + }) + + it("should compute actions for multiple items when conditions for target qty exceed one item", async () => { + const context = { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 2, + unit_price: 500, + product_category: { + id: "catg_tshirt", + }, + product: { + id: "prod_tshirt_1", + }, + }, + { + id: "item_cotton_tshirt2", + quantity: 2, + unit_price: 1000, + product_category: { + id: "catg_tshirt", + }, + product: { + id: "prod_tshirt_2", + }, + }, + { + id: "item_cotton_sweater", + quantity: 2, + unit_price: 1000, + product_category: { + id: "catg_sweater", + }, + product: { + id: "prod_sweater_1", + }, + }, + ], + } + + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.BUYGET, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + max_quantity: 1, + apply_to_quantity: 4, + buy_rules_min_quantity: 1, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_tshirt"], + }, + ], + buy_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_sweater"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions(["PROMOTION_TEST"], context) + + expect(result).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt2", + amount: 2000, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 1000, + code: "PROMOTION_TEST", + }, + ]) + }) + + it("should return empty array when target rules arent met with context", async () => { + const context = { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 2, + unit_price: 500, + product_category: { + id: "catg_tshirt", + }, + product: { + id: "prod_tshirt_1", + }, + }, + { + id: "item_cotton_tshirt2", + quantity: 2, + unit_price: 1000, + product_category: { + id: "catg_tshirt", + }, + product: { + id: "prod_tshirt_2", + }, + }, + { + id: "item_cotton_sweater", + quantity: 2, + unit_price: 1000, + product_category: { + id: "catg_sweater", + }, + product: { + id: "prod_sweater_1", + }, + }, + ], + } + + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.BUYGET, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + max_quantity: 1, + apply_to_quantity: 4, + buy_rules_min_quantity: 1, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_not-found"], + }, + ], + buy_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_sweater"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions(["PROMOTION_TEST"], context) + + expect(result).toEqual([]) + }) + }) }) diff --git a/packages/promotion/src/services/promotion-module.ts b/packages/promotion/src/services/promotion-module.ts index 1756901800076..ecdc1b5113ef9 100644 --- a/packages/promotion/src/services/promotion-module.ts +++ b/packages/promotion/src/services/promotion-module.ts @@ -263,6 +263,8 @@ export default class PromotionModuleService< "application_method", "application_method.target_rules", "application_method.target_rules.values", + "application_method.buy_rules", + "application_method.buy_rules.values", "rules", "rules.values", "campaign", @@ -271,6 +273,10 @@ export default class PromotionModuleService< } ) + const sortedPermissionsToApply = promotions + .filter((p) => promotionCodesToApply.includes(p.code!)) + .sort(ComputeActionUtils.sortByBuyGetType) + const existingPromotionsMap = new Map( promotions.map((promotion) => [promotion.code!, promotion]) ) @@ -306,15 +312,8 @@ export default class PromotionModuleService< } } - for (const promotionCode of promotionCodesToApply) { - const promotion = existingPromotionsMap.get(promotionCode) - - if (!promotion) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Promotion for code (${promotionCode}) not found` - ) - } + for (const promotionToApply of sortedPermissionsToApply) { + const promotion = existingPromotionsMap.get(promotionToApply.code!)! const { application_method: applicationMethod, @@ -334,20 +333,9 @@ export default class PromotionModuleService< continue } - if (applicationMethod.target_type === ApplicationMethodTargetType.ORDER) { - const computedActionsForItems = - ComputeActionUtils.getComputedActionsForOrder( - promotion, - applicationContext, - methodIdPromoValueMap - ) - - computedActions.push(...computedActionsForItems) - } - - if (applicationMethod.target_type === ApplicationMethodTargetType.ITEMS) { + if (promotion.type === PromotionType.BUYGET) { const computedActionsForItems = - ComputeActionUtils.getComputedActionsForItems( + ComputeActionUtils.getComputedActionsForBuyGet( promotion, applicationContext[ApplicationMethodTargetType.ITEMS], methodIdPromoValueMap @@ -356,18 +344,46 @@ export default class PromotionModuleService< computedActions.push(...computedActionsForItems) } - if ( - applicationMethod.target_type === - ApplicationMethodTargetType.SHIPPING_METHODS - ) { - const computedActionsForShippingMethods = - ComputeActionUtils.getComputedActionsForShippingMethods( - promotion, - applicationContext[ApplicationMethodTargetType.SHIPPING_METHODS], - methodIdPromoValueMap - ) + if (promotion.type === PromotionType.STANDARD) { + if ( + applicationMethod.target_type === ApplicationMethodTargetType.ORDER + ) { + const computedActionsForItems = + ComputeActionUtils.getComputedActionsForOrder( + promotion, + applicationContext, + methodIdPromoValueMap + ) + + computedActions.push(...computedActionsForItems) + } + + if ( + applicationMethod.target_type === ApplicationMethodTargetType.ITEMS + ) { + const computedActionsForItems = + ComputeActionUtils.getComputedActionsForItems( + promotion, + applicationContext[ApplicationMethodTargetType.ITEMS], + methodIdPromoValueMap + ) + + computedActions.push(...computedActionsForItems) + } - computedActions.push(...computedActionsForShippingMethods) + if ( + applicationMethod.target_type === + ApplicationMethodTargetType.SHIPPING_METHODS + ) { + const computedActionsForShippingMethods = + ComputeActionUtils.getComputedActionsForShippingMethods( + promotion, + applicationContext[ApplicationMethodTargetType.SHIPPING_METHODS], + methodIdPromoValueMap + ) + + computedActions.push(...computedActionsForShippingMethods) + } } } diff --git a/packages/promotion/src/utils/compute-actions/buy-get.ts b/packages/promotion/src/utils/compute-actions/buy-get.ts new file mode 100644 index 0000000000000..cc6eaa44e114f --- /dev/null +++ b/packages/promotion/src/utils/compute-actions/buy-get.ts @@ -0,0 +1,101 @@ +import { PromotionTypes } from "@medusajs/types" +import { + ApplicationMethodTargetType, + ComputedActions, + MedusaError, + PromotionType, +} from "@medusajs/utils" +import { areRulesValidForContext } from "../validations/promotion-rule" +import { computeActionForBudgetExceeded } from "./usage" + +export function getComputedActionsForBuyGet( + promotion: PromotionTypes.PromotionDTO, + itemsContext: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.ITEMS], + methodIdPromoValueMap: Map +): PromotionTypes.ComputeActions[] { + const buyRulesMinQuantity = + promotion.application_method?.buy_rules_min_quantity + const applyToQuantity = promotion.application_method?.apply_to_quantity + const buyRules = promotion.application_method?.buy_rules + const targetRules = promotion.application_method?.target_rules + const computedActions: PromotionTypes.ComputeActions[] = [] + + if (!itemsContext) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `"items" should be present as an array in the context to compute actions` + ) + } + + if (!Array.isArray(buyRules) || !Array.isArray(targetRules)) { + return [] + } + + const validQuantity = itemsContext + .filter((item) => areRulesValidForContext(buyRules, item)) + .reduce((acc, next) => acc + next.quantity, 0) + + if ( + !buyRulesMinQuantity || + !applyToQuantity || + buyRulesMinQuantity > validQuantity + ) { + return [] + } + + const validItemsForTargetRules = itemsContext + .filter((item) => areRulesValidForContext(targetRules, item)) + .sort((a, b) => { + return b.unit_price - a.unit_price + }) + + let remainingQtyToApply = applyToQuantity + + for (const method of validItemsForTargetRules) { + const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0 + const multiplier = Math.min(method.quantity, remainingQtyToApply) + const amount = method.unit_price * multiplier + const newRemainingQtyToApply = remainingQtyToApply - multiplier + + if (newRemainingQtyToApply < 0 || amount <= 0) { + break + } else { + remainingQtyToApply = newRemainingQtyToApply + } + + const budgetExceededAction = computeActionForBudgetExceeded( + promotion, + amount + ) + + if (budgetExceededAction) { + computedActions.push(budgetExceededAction) + + continue + } + + methodIdPromoValueMap.set(method.id, appliedPromoValue + amount) + + computedActions.push({ + action: ComputedActions.ADD_ITEM_ADJUSTMENT, + item_id: method.id, + amount, + code: promotion.code!, + }) + } + + return computedActions +} + +export function sortByBuyGetType(a, b) { + if (a.type === PromotionType.BUYGET && b.type !== PromotionType.BUYGET) { + return -1 + } else if ( + a.type !== PromotionType.BUYGET && + b.type === PromotionType.BUYGET + ) { + return 1 + } else { + return 0 + } +} diff --git a/packages/promotion/src/utils/compute-actions/index.ts b/packages/promotion/src/utils/compute-actions/index.ts index 33d690935a0d4..de8317a075ca0 100644 --- a/packages/promotion/src/utils/compute-actions/index.ts +++ b/packages/promotion/src/utils/compute-actions/index.ts @@ -1,3 +1,4 @@ +export * from "./buy-get" export * from "./items" export * from "./order" export * from "./shipping-methods"