From b782d3bcb7e8088a962584b9a55200dd29c2161c Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Fri, 12 Jan 2024 14:00:06 +0100 Subject: [PATCH] feat(utils,types): added item/shipping adjustments for order/items/shipping_methods (#6050) what: - adds compute actions for the following cases: - items => each & across - shipping_method => each & across - order - adds a remove compute actions when code is no longer present in adjustments array RESOLVES CORE-1625 RESOLVES CORE-1626 RESOLVES CORE-1627 RESOLVES CORE-1628 RESOLVES CORE-1585 --- .changeset/lucky-ducks-chew.md | 6 + .../promotion-module/compute-actions.spec.ts | 1852 +++++++++++++++++ .../promotion-module/promotion.spec.ts | 104 +- .../src/services/promotion-module.ts | 173 +- .../src/types/promotion-rule-value.ts | 5 +- .../promotion/src/types/promotion-rule.ts | 2 +- .../src/utils/compute-actions/index.ts | 3 + .../src/utils/compute-actions/items.ts | 131 ++ .../src/utils/compute-actions/order.ts | 19 + .../utils/compute-actions/shipping-methods.ts | 114 + packages/promotion/src/utils/index.ts | 1 + .../utils/validations/application-method.ts | 4 +- .../src/utils/validations/promotion-rule.ts | 69 +- .../promotion/common/application-method.ts | 5 +- .../src/promotion/common/compute-actions.ts | 55 + packages/types/src/promotion/common/index.ts | 1 + .../promotion/common/promotion-rule-value.ts | 3 +- .../src/promotion/common/promotion-rule.ts | 8 +- .../types/src/promotion/common/promotion.ts | 3 +- packages/types/src/promotion/service.ts | 8 + .../__tests__/pick-value-from-object.spec.ts | 111 + packages/utils/src/common/index.ts | 1 + .../src/common/pick-value-from-object.ts | 37 + packages/utils/src/promotion/index.ts | 4 +- 24 files changed, 2638 insertions(+), 81 deletions(-) create mode 100644 .changeset/lucky-ducks-chew.md create mode 100644 packages/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts create mode 100644 packages/promotion/src/utils/compute-actions/index.ts create mode 100644 packages/promotion/src/utils/compute-actions/items.ts create mode 100644 packages/promotion/src/utils/compute-actions/order.ts create mode 100644 packages/promotion/src/utils/compute-actions/shipping-methods.ts create mode 100644 packages/types/src/promotion/common/compute-actions.ts create mode 100644 packages/utils/src/common/__tests__/pick-value-from-object.spec.ts create mode 100644 packages/utils/src/common/pick-value-from-object.ts diff --git a/.changeset/lucky-ducks-chew.md b/.changeset/lucky-ducks-chew.md new file mode 100644 index 0000000000..89f444bd11 --- /dev/null +++ b/.changeset/lucky-ducks-chew.md @@ -0,0 +1,6 @@ +--- +"@medusajs/types": patch +"@medusajs/utils": patch +--- + +feat(utils,types): added item/shipping adjustments for order/items/shipping_methods 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 new file mode 100644 index 0000000000..69fdb9ce07 --- /dev/null +++ b/packages/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts @@ -0,0 +1,1852 @@ +import { IPromotionModuleService } from "@medusajs/types" +import { PromotionType } from "@medusajs/utils" +import { SqlEntityManager } from "@mikro-orm/postgresql" +import { initialize } from "../../../../src" +import { DB_URL, MikroOrmWrapper } from "../../../utils" + +jest.setTimeout(30000) + +describe("Promotion Service: computeActions", () => { + let service: IPromotionModuleService + let repositoryManager: SqlEntityManager + + beforeEach(async () => { + await MikroOrmWrapper.setupDatabase() + repositoryManager = MikroOrmWrapper.forkManager() + + service = await initialize({ + database: { + clientUrl: DB_URL, + schema: process.env.MEDUSA_PROMOTION_DB_SCHEMA, + }, + }) + }) + + afterEach(async () => { + await MikroOrmWrapper.clearDatabase() + }) + + 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", + }, + }, + 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", + }, + product: { + id: "prod_sweater", + }, + }, + ], + }) + .catch((e) => e) + + expect(error.message).toContain( + "Promotion for code (DOES_NOT_EXIST) not found" + ) + }) + + it("should throw error when code in items adjustment does not exist", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + value: "200", + max_quantity: 1, + }, + }, + ]) + + const error = await service + .computeActions(["PROMOTION_TEST"], { + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + unit_price: 100, + adjustments: [ + { + id: "test-adjustment", + code: "DOES_NOT_EXIST", + }, + ], + }, + { + id: "item_cotton_sweater", + quantity: 5, + unit_price: 150, + }, + ], + }) + .catch((e) => e) + + expect(error.message).toContain( + "Applied Promotion for code (DOES_NOT_EXIST) not found" + ) + }) + + it("should throw error when code in shipping adjustment does not exist", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + value: "200", + max_quantity: 1, + }, + }, + ]) + + const error = await service + .computeActions(["PROMOTION_TEST"], { + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + unit_price: 100, + }, + { + id: "item_cotton_sweater", + quantity: 5, + unit_price: 150, + adjustments: [ + { + id: "test-adjustment", + code: "DOES_NOT_EXIST", + }, + ], + }, + ], + }) + .catch((e) => e) + + expect(error.message).toContain( + "Applied Promotion for code (DOES_NOT_EXIST) not found" + ) + }) + }) + + describe("when promotion is for items and allocation is each", () => { + it("should compute the correct item amendments", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + value: "200", + max_quantity: 1, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_cotton"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions(["PROMOTION_TEST"], { + customer: { + customer_group: { + id: "VIP", + }, + }, + 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", + }, + product: { + id: "prod_sweater", + }, + }, + ], + }) + + expect(result).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 100, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 150, + code: "PROMOTION_TEST", + }, + ]) + }) + + it("should compute the correct item amendments when there are multiple promotions to apply", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + value: "30", + max_quantity: 2, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_cotton"], + }, + ], + }, + }, + ]) + + const [createdPromotionTwo] = await service.create([ + { + code: "PROMOTION_TEST_2", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + value: "50", + max_quantity: 1, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_cotton"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions( + ["PROMOTION_TEST", "PROMOTION_TEST_2"], + { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + unit_price: 50, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", + }, + }, + { + id: "item_cotton_sweater", + quantity: 1, + unit_price: 150, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_sweater", + }, + }, + ], + } + ) + + expect(result).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 30, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 30, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 20, + code: "PROMOTION_TEST_2", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 50, + code: "PROMOTION_TEST_2", + }, + ]) + }) + + it("should not compute actions when applicable total is 0", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + value: "500", + max_quantity: 2, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_cotton"], + }, + ], + }, + }, + ]) + + const [createdPromotionTwo] = await service.create([ + { + code: "PROMOTION_TEST_2", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + value: "50", + max_quantity: 1, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_cotton"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions( + ["PROMOTION_TEST", "PROMOTION_TEST_2"], + { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + unit_price: 50, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", + }, + }, + { + id: "item_cotton_sweater", + quantity: 1, + unit_price: 150, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_sweater", + }, + }, + ], + } + ) + + expect(result).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 50, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 150, + code: "PROMOTION_TEST", + }, + ]) + }) + }) + + describe("when promotion is for items and allocation is across", () => { + it("should compute the correct item amendments", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "across", + value: "200", + max_quantity: 2, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_cotton"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions(["PROMOTION_TEST"], { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + unit_price: 100, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", + }, + }, + { + id: "item_cotton_sweater", + quantity: 2, + unit_price: 150, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_sweater", + }, + }, + ], + }) + + expect(result).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 50, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 150, + code: "PROMOTION_TEST", + }, + ]) + }) + + it("should compute the correct item amendments when there are multiple promotions to apply", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "across", + value: "30", + max_quantity: 2, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_cotton"], + }, + ], + }, + }, + ]) + + const [createdPromotionTwo] = await service.create([ + { + code: "PROMOTION_TEST_2", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "across", + value: "50", + max_quantity: 1, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_cotton"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions( + ["PROMOTION_TEST", "PROMOTION_TEST_2"], + { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + unit_price: 50, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", + }, + }, + { + id: "item_cotton_sweater", + quantity: 1, + unit_price: 150, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_sweater", + }, + }, + ], + } + ) + + expect(result).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 7.5, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 22.5, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 12.5, + code: "PROMOTION_TEST_2", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 37.5, + code: "PROMOTION_TEST_2", + }, + ]) + }) + + it("should not compute actions when applicable total is 0", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "across", + value: "500", + max_quantity: 2, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_cotton"], + }, + ], + }, + }, + ]) + + const [createdPromotionTwo] = await service.create([ + { + code: "PROMOTION_TEST_2", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "across", + value: "50", + max_quantity: 1, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_cotton"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions( + ["PROMOTION_TEST", "PROMOTION_TEST_2"], + { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + unit_price: 50, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", + }, + }, + { + id: "item_cotton_sweater", + quantity: 1, + unit_price: 150, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_sweater", + }, + }, + ], + } + ) + + expect(result).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 50, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 150, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 12.5, + code: "PROMOTION_TEST_2", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 37.5, + code: "PROMOTION_TEST_2", + }, + ]) + }) + }) + + describe("when promotion is for shipping_method and allocation is each", () => { + it("should compute the correct shipping_method amendments", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "shipping_methods", + allocation: "each", + value: "200", + max_quantity: 2, + target_rules: [ + { + attribute: "shipping_option.id", + operator: "in", + values: ["express", "standard"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions(["PROMOTION_TEST"], { + customer: { + customer_group: { + id: "VIP", + }, + }, + shipping_methods: [ + { + id: "shipping_method_express", + unit_price: 250, + shipping_option: { + id: "express", + }, + }, + { + id: "shipping_method_standard", + unit_price: 150, + shipping_option: { + id: "standard", + }, + }, + { + id: "shipping_method_snail", + unit_price: 200, + shipping_option: { + id: "snail", + }, + }, + ], + }) + + expect(result).toEqual([ + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_express", + amount: 200, + code: "PROMOTION_TEST", + }, + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_standard", + amount: 150, + code: "PROMOTION_TEST", + }, + ]) + }) + + it("should compute the correct item amendments when there are multiple promotions to apply", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "shipping_methods", + allocation: "each", + value: "200", + max_quantity: 2, + target_rules: [ + { + attribute: "shipping_option.id", + operator: "in", + values: ["express", "standard"], + }, + ], + }, + }, + ]) + + const [createdPromotionTwo] = await service.create([ + { + code: "PROMOTION_TEST_2", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "shipping_methods", + allocation: "each", + value: "200", + max_quantity: 2, + target_rules: [ + { + attribute: "shipping_option.id", + operator: "in", + values: ["express", "standard"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions( + ["PROMOTION_TEST", "PROMOTION_TEST_2"], + { + customer: { + customer_group: { + id: "VIP", + }, + }, + shipping_methods: [ + { + id: "shipping_method_express", + unit_price: 250, + shipping_option: { + id: "express", + }, + }, + { + id: "shipping_method_standard", + unit_price: 150, + shipping_option: { + id: "standard", + }, + }, + { + id: "shipping_method_snail", + unit_price: 200, + shipping_option: { + id: "snail", + }, + }, + ], + } + ) + + expect(result).toEqual([ + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_express", + amount: 200, + code: "PROMOTION_TEST", + }, + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_standard", + amount: 150, + code: "PROMOTION_TEST", + }, + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_express", + amount: 50, + code: "PROMOTION_TEST_2", + }, + ]) + }) + + it("should not compute actions when applicable total is 0", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "shipping_methods", + allocation: "each", + value: "500", + max_quantity: 2, + target_rules: [ + { + attribute: "shipping_option.id", + operator: "in", + values: ["express", "standard"], + }, + ], + }, + }, + ]) + + const [createdPromotionTwo] = await service.create([ + { + code: "PROMOTION_TEST_2", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "shipping_methods", + allocation: "each", + value: "200", + max_quantity: 2, + target_rules: [ + { + attribute: "shipping_option.id", + operator: "in", + values: ["express", "standard"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions( + ["PROMOTION_TEST", "PROMOTION_TEST_2"], + { + customer: { + customer_group: { + id: "VIP", + }, + }, + shipping_methods: [ + { + id: "shipping_method_express", + unit_price: 250, + shipping_option: { + id: "express", + }, + }, + { + id: "shipping_method_standard", + unit_price: 150, + shipping_option: { + id: "standard", + }, + }, + { + id: "shipping_method_snail", + unit_price: 200, + shipping_option: { + id: "snail", + }, + }, + ], + } + ) + + expect(result).toEqual([ + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_express", + amount: 250, + code: "PROMOTION_TEST", + }, + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_standard", + amount: 150, + code: "PROMOTION_TEST", + }, + ]) + }) + }) + + describe("when promotion is for shipping_method and allocation is across", () => { + it("should compute the correct shipping_method amendments", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "shipping_methods", + allocation: "across", + value: "200", + max_quantity: 2, + target_rules: [ + { + attribute: "shipping_option.id", + operator: "in", + values: ["express", "standard"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions(["PROMOTION_TEST"], { + customer: { + customer_group: { + id: "VIP", + }, + }, + shipping_methods: [ + { + id: "shipping_method_express", + unit_price: 500, + shipping_option: { + id: "express", + }, + }, + { + id: "shipping_method_standard", + unit_price: 100, + shipping_option: { + id: "standard", + }, + }, + { + id: "shipping_method_snail", + unit_price: 200, + shipping_option: { + id: "snail", + }, + }, + ], + }) + + expect(result).toEqual([ + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_express", + amount: 166.66666666666669, + code: "PROMOTION_TEST", + }, + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_standard", + amount: 33.33333333333333, + code: "PROMOTION_TEST", + }, + ]) + }) + + it("should compute the correct item amendments when there are multiple promotions to apply", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "shipping_methods", + allocation: "across", + value: "200", + max_quantity: 2, + target_rules: [ + { + attribute: "shipping_option.id", + operator: "in", + values: ["express", "standard"], + }, + ], + }, + }, + ]) + + const [createdPromotion2] = await service.create([ + { + code: "PROMOTION_TEST_2", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "shipping_methods", + allocation: "across", + value: "200", + max_quantity: 2, + target_rules: [ + { + attribute: "shipping_option.id", + operator: "in", + values: ["express", "standard"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions( + ["PROMOTION_TEST", "PROMOTION_TEST_2"], + { + customer: { + customer_group: { + id: "VIP", + }, + }, + shipping_methods: [ + { + id: "shipping_method_express", + unit_price: 500, + shipping_option: { + id: "express", + }, + }, + { + id: "shipping_method_standard", + unit_price: 100, + shipping_option: { + id: "standard", + }, + }, + { + id: "shipping_method_snail", + unit_price: 200, + shipping_option: { + id: "snail", + }, + }, + ], + } + ) + + expect(result).toEqual([ + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_express", + amount: 166.66666666666669, + code: "PROMOTION_TEST", + }, + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_standard", + amount: 33.33333333333333, + code: "PROMOTION_TEST", + }, + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_express", + amount: 83.33333333333331, + code: "PROMOTION_TEST_2", + }, + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_standard", + amount: 16.66666666666667, + code: "PROMOTION_TEST_2", + }, + ]) + }) + + it("should not compute actions when applicable total is 0", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "shipping_methods", + allocation: "across", + value: "1000", + max_quantity: 2, + target_rules: [ + { + attribute: "shipping_option.id", + operator: "in", + values: ["express", "standard"], + }, + ], + }, + }, + ]) + + const [createdPromotion2] = await service.create([ + { + code: "PROMOTION_TEST_2", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "shipping_methods", + allocation: "across", + value: "200", + max_quantity: 2, + target_rules: [ + { + attribute: "shipping_option.id", + operator: "in", + values: ["express", "standard"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions( + ["PROMOTION_TEST", "PROMOTION_TEST_2"], + { + customer: { + customer_group: { + id: "VIP", + }, + }, + shipping_methods: [ + { + id: "shipping_method_express", + unit_price: 500, + shipping_option: { + id: "express", + }, + }, + { + id: "shipping_method_standard", + unit_price: 100, + shipping_option: { + id: "standard", + }, + }, + { + id: "shipping_method_snail", + unit_price: 200, + shipping_option: { + id: "snail", + }, + }, + ], + } + ) + + expect(result).toEqual([ + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_express", + amount: 500, + code: "PROMOTION_TEST", + }, + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_standard", + amount: 100, + code: "PROMOTION_TEST", + }, + ]) + }) + }) + + describe("when promotion is for the entire order", () => { + it("should compute the correct item amendments", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "order", + value: "200", + max_quantity: 2, + }, + }, + ]) + + const result = await service.computeActions(["PROMOTION_TEST"], { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + unit_price: 100, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", + }, + }, + { + id: "item_cotton_sweater", + quantity: 2, + unit_price: 150, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_sweater", + }, + }, + ], + }) + + expect(result).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 50, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 150, + code: "PROMOTION_TEST", + }, + ]) + }) + + it("should compute the correct item amendments when there are multiple promotions to apply", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "order", + value: "30", + max_quantity: 2, + }, + }, + ]) + + const [createdPromotionTwo] = await service.create([ + { + code: "PROMOTION_TEST_2", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "order", + value: "50", + max_quantity: 1, + }, + }, + ]) + + const result = await service.computeActions( + ["PROMOTION_TEST", "PROMOTION_TEST_2"], + { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + unit_price: 50, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", + }, + }, + { + id: "item_cotton_sweater", + quantity: 1, + unit_price: 150, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_sweater", + }, + }, + ], + } + ) + + expect(result).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 7.5, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 22.5, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 12.5, + code: "PROMOTION_TEST_2", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 37.5, + code: "PROMOTION_TEST_2", + }, + ]) + }) + + it("should not compute actions when applicable total is 0", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "order", + value: "500", + max_quantity: 2, + }, + }, + ]) + + const [createdPromotionTwo] = await service.create([ + { + code: "PROMOTION_TEST_2", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "order", + value: "50", + max_quantity: 1, + }, + }, + ]) + + const result = await service.computeActions( + ["PROMOTION_TEST", "PROMOTION_TEST_2"], + { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + unit_price: 50, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", + }, + }, + { + id: "item_cotton_sweater", + quantity: 1, + unit_price: 150, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_sweater", + }, + }, + ], + } + ) + + expect(result).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 50, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 150, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 12.5, + code: "PROMOTION_TEST_2", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 37.5, + code: "PROMOTION_TEST_2", + }, + ]) + }) + }) + + describe("when adjustments are present in the context", () => { + it("should compute the correct item amendments along with removal of applied item adjustment", async () => { + const [adjustmentPromotion] = await service.create([ + { + code: "ADJUSTMENT_CODE", + type: PromotionType.STANDARD, + }, + ]) + + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + value: "200", + max_quantity: 1, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_cotton"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions(["PROMOTION_TEST"], { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + unit_price: 100, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", + }, + adjustments: [ + { + id: "test-adjustment", + code: "ADJUSTMENT_CODE", + }, + ], + }, + { + id: "item_cotton_sweater", + quantity: 5, + unit_price: 150, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_sweater", + }, + }, + ], + }) + + expect(result).toEqual([ + { + action: "removeItemAdjustment", + adjustment_id: "test-adjustment", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 100, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 150, + code: "PROMOTION_TEST", + }, + ]) + }) + + it("should compute the correct item amendments along with removal of applied shipping adjustment", async () => { + const [adjustmentPromotion] = await service.create([ + { + code: "ADJUSTMENT_CODE", + type: PromotionType.STANDARD, + }, + ]) + + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "shipping_methods", + allocation: "across", + value: "200", + max_quantity: 2, + target_rules: [ + { + attribute: "shipping_option.id", + operator: "in", + values: ["express", "standard"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions(["PROMOTION_TEST"], { + customer: { + customer_group: { + id: "VIP", + }, + }, + shipping_methods: [ + { + id: "shipping_method_express", + unit_price: 500, + shipping_option: { + id: "express", + }, + adjustments: [ + { + id: "test-adjustment", + code: "ADJUSTMENT_CODE", + }, + ], + }, + { + id: "shipping_method_standard", + unit_price: 100, + shipping_option: { + id: "standard", + }, + }, + { + id: "shipping_method_snail", + unit_price: 200, + shipping_option: { + id: "snail", + }, + }, + ], + }) + + expect(result).toEqual([ + { + action: "removeShippingMethodAdjustment", + adjustment_id: "test-adjustment", + }, + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_express", + amount: 166.66666666666669, + code: "PROMOTION_TEST", + }, + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_standard", + amount: 33.33333333333333, + code: "PROMOTION_TEST", + }, + ]) + }) + }) +}) diff --git a/packages/promotion/integration-tests/__tests__/services/promotion-module/promotion.spec.ts b/packages/promotion/integration-tests/__tests__/services/promotion-module/promotion.spec.ts index dc259dccc0..c98c18f949 100644 --- a/packages/promotion/integration-tests/__tests__/services/promotion-module/promotion.spec.ts +++ b/packages/promotion/integration-tests/__tests__/services/promotion-module/promotion.spec.ts @@ -99,63 +99,6 @@ describe("Promotion Service", () => { ) }) - it("should create a promotion with order application method with rules successfully", async () => { - const [createdPromotion] = await service.create([ - { - code: "PROMOTION_TEST", - type: PromotionType.STANDARD, - application_method: { - type: "fixed", - target_type: "order", - value: "100", - target_rules: [ - { - attribute: "product_id", - operator: "eq", - values: ["prod_tshirt"], - }, - ], - }, - }, - ]) - - const [promotion] = await service.list( - { - id: [createdPromotion.id], - }, - { - relations: [ - "application_method", - "application_method.target_rules.values", - ], - } - ) - - expect(promotion).toEqual( - expect.objectContaining({ - code: "PROMOTION_TEST", - is_automatic: false, - type: "standard", - application_method: expect.objectContaining({ - type: "fixed", - target_type: "order", - value: 100, - target_rules: [ - expect.objectContaining({ - attribute: "product_id", - operator: "eq", - values: expect.arrayContaining([ - expect.objectContaining({ - value: "prod_tshirt", - }), - ]), - }), - ], - }), - }) - ) - }) - it("should throw error when creating an item application method without allocation", async () => { const error = await service .create([ @@ -164,7 +107,7 @@ describe("Promotion Service", () => { type: PromotionType.STANDARD, application_method: { type: "fixed", - target_type: "item", + target_type: "items", value: "100", }, }, @@ -172,7 +115,7 @@ describe("Promotion Service", () => { .catch((e) => e) expect(error.message).toContain( - "application_method.allocation should be either 'across OR each' when application_method.target_type is either 'shipping OR item'" + "application_method.allocation should be either 'across OR each' when application_method.target_type is either 'shipping_methods OR items'" ) }) @@ -185,7 +128,7 @@ describe("Promotion Service", () => { application_method: { type: "fixed", allocation: "each", - target_type: "shipping", + target_type: "shipping_methods", value: "100", }, }, @@ -197,6 +140,33 @@ describe("Promotion Service", () => { ) }) + it("should throw error when creating an order application method with rules", async () => { + const error = await service + .create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + application_method: { + type: "fixed", + target_type: "order", + value: "100", + target_rules: [ + { + attribute: "product_id", + operator: "eq", + values: ["prod_tshirt"], + }, + ], + }, + }, + ]) + .catch((e) => e) + + expect(error.message).toContain( + "Target rules for application method with target type (order) is not allowed" + ) + }) + it("should create a promotion with rules successfully", async () => { const [createdPromotion] = await service.create([ { @@ -390,7 +360,7 @@ describe("Promotion Service", () => { type: PromotionType.STANDARD, application_method: { type: "fixed", - target_type: "item", + target_type: "items", allocation: "across", value: "100", }, @@ -424,7 +394,7 @@ describe("Promotion Service", () => { type: PromotionType.STANDARD, application_method: { type: "fixed", - target_type: "item", + target_type: "items", allocation: "each", value: "100", max_quantity: 500, @@ -483,7 +453,7 @@ describe("Promotion Service", () => { .catch((e) => e) expect(error.message).toContain( - `application_method.target_type should be one of order, shipping, item` + `application_method.target_type should be one of order, shipping_methods, items` ) error = await service @@ -604,7 +574,7 @@ describe("Promotion Service", () => { type: PromotionType.STANDARD, application_method: { type: "fixed", - target_type: "item", + target_type: "items", allocation: "each", value: "100", max_quantity: 500, @@ -676,7 +646,7 @@ describe("Promotion Service", () => { type: PromotionType.STANDARD, application_method: { type: "fixed", - target_type: "item", + target_type: "items", allocation: "each", value: "100", max_quantity: 500, @@ -760,7 +730,7 @@ describe("Promotion Service", () => { ], application_method: { type: "fixed", - target_type: "item", + target_type: "items", allocation: "each", value: "100", max_quantity: 500, @@ -821,7 +791,7 @@ describe("Promotion Service", () => { type: PromotionType.STANDARD, application_method: { type: "fixed", - target_type: "item", + target_type: "items", allocation: "each", value: "100", max_quantity: 500, diff --git a/packages/promotion/src/services/promotion-module.ts b/packages/promotion/src/services/promotion-module.ts index fbe52aa512..07d4d6eb68 100644 --- a/packages/promotion/src/services/promotion-module.ts +++ b/packages/promotion/src/services/promotion-module.ts @@ -7,10 +7,12 @@ import { PromotionTypes, } from "@medusajs/types" import { + ApplicationMethodTargetType, InjectManager, InjectTransactionManager, MedusaContext, MedusaError, + isString, } from "@medusajs/utils" import { ApplicationMethod, Promotion } from "@models" import { @@ -23,11 +25,14 @@ import { joinerConfig } from "../joiner-config" import { CreateApplicationMethodDTO, CreatePromotionDTO, + CreatePromotionRuleDTO, UpdateApplicationMethodDTO, UpdatePromotionDTO, } from "../types" import { + ComputeActionUtils, allowedAllocationForQuantity, + areRulesValidForContext, validateApplicationMethodAttributes, validatePromotionRuleAttributes, } from "../utils" @@ -71,6 +76,161 @@ export default class PromotionModuleService< return joinerConfig } + async computeActions( + promotionCodesToApply: string[], + applicationContext: PromotionTypes.ComputeActionContext, + // TODO: specify correct type with options + options: Record = {} + ): Promise { + const computedActions: PromotionTypes.ComputeActions[] = [] + const { items = [], shipping_methods: shippingMethods = [] } = + applicationContext + const appliedItemCodes: string[] = [] + const appliedShippingCodes: string[] = [] + const codeAdjustmentMap = new Map< + string, + PromotionTypes.ComputeActionAdjustmentLine + >() + const methodIdPromoValueMap = new Map() + + items.forEach((item) => { + item.adjustments?.forEach((adjustment) => { + if (isString(adjustment.code)) { + codeAdjustmentMap.set(adjustment.code, adjustment) + appliedItemCodes.push(adjustment.code) + } + }) + }) + + shippingMethods.forEach((shippingMethod) => { + shippingMethod.adjustments?.forEach((adjustment) => { + if (isString(adjustment.code)) { + codeAdjustmentMap.set(adjustment.code, adjustment) + appliedShippingCodes.push(adjustment.code) + } + }) + }) + + const promotions = await this.list( + { + code: [ + ...promotionCodesToApply, + ...appliedItemCodes, + ...appliedShippingCodes, + ], + }, + { + relations: [ + "application_method", + "application_method.target_rules", + "application_method.target_rules.values", + "rules", + "rules.values", + ], + } + ) + + const existingPromotionsMap = new Map( + promotions.map((promotion) => [promotion.code!, promotion]) + ) + + for (const appliedCode of [...appliedShippingCodes, ...appliedItemCodes]) { + const promotion = existingPromotionsMap.get(appliedCode) + + if (!promotion) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Applied Promotion for code (${appliedCode}) not found` + ) + } + + if (promotionCodesToApply.includes(appliedCode)) { + continue + } + + if (appliedItemCodes.includes(appliedCode)) { + computedActions.push({ + action: "removeItemAdjustment", + adjustment_id: codeAdjustmentMap.get(appliedCode)!.id, + }) + } + + if (appliedShippingCodes.includes(appliedCode)) { + computedActions.push({ + action: "removeShippingMethodAdjustment", + adjustment_id: codeAdjustmentMap.get(appliedCode)!.id, + }) + } + } + + 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` + ) + } + + const { + application_method: applicationMethod, + rules: promotionRules = [], + } = promotion + + if (!applicationMethod) { + continue + } + + const isPromotionApplicable = areRulesValidForContext( + promotionRules, + applicationContext + ) + + if (!isPromotionApplicable) { + continue + } + + 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) + } + + if ( + applicationMethod.target_type === + ApplicationMethodTargetType.SHIPPING_METHODS + ) { + const computedActionsForShippingMethods = + ComputeActionUtils.getComputedActionsForShippingMethods( + promotion, + applicationContext[ApplicationMethodTargetType.SHIPPING_METHODS], + methodIdPromoValueMap + ) + + computedActions.push(...computedActionsForShippingMethods) + } + } + + return computedActions + } + @InjectManager("baseRepository_") async retrieve( id: string, @@ -194,6 +354,17 @@ export default class PromotionModuleService< promotion, } + if ( + applicationMethodData.target_type === + ApplicationMethodTargetType.ORDER && + targetRulesData.length + ) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Target rules for application method with target type (${ApplicationMethodTargetType.ORDER}) is not allowed` + ) + } + validateApplicationMethodAttributes(applicationMethodData) applicationMethodsData.push(applicationMethodData) @@ -394,7 +565,7 @@ export default class PromotionModuleService< for (const ruleData of rulesData) { const { values, ...rest } = ruleData - const promotionRuleData = { + const promotionRuleData: CreatePromotionRuleDTO = { ...rest, [relationName]: [relation], } diff --git a/packages/promotion/src/types/promotion-rule-value.ts b/packages/promotion/src/types/promotion-rule-value.ts index 0b644e88af..38b796be7a 100644 --- a/packages/promotion/src/types/promotion-rule-value.ts +++ b/packages/promotion/src/types/promotion-rule-value.ts @@ -1,12 +1,13 @@ import { PromotionRuleDTO } from "@medusajs/types" +import { PromotionRule } from "@models" export interface CreatePromotionRuleValueDTO { value: any - promotion_rule: string | PromotionRuleDTO + promotion_rule: string | PromotionRuleDTO | PromotionRule } export interface UpdatePromotionRuleValueDTO { id: string value: any - promotion_rule: string | PromotionRuleDTO + promotion_rule: string | PromotionRuleDTO | PromotionRule } diff --git a/packages/promotion/src/types/promotion-rule.ts b/packages/promotion/src/types/promotion-rule.ts index 0a43960c84..750fe96a41 100644 --- a/packages/promotion/src/types/promotion-rule.ts +++ b/packages/promotion/src/types/promotion-rule.ts @@ -1,7 +1,7 @@ import { PromotionRuleOperatorValues } from "@medusajs/types" export interface CreatePromotionRuleDTO { - description?: string + description?: string | null attribute: string operator: PromotionRuleOperatorValues } diff --git a/packages/promotion/src/utils/compute-actions/index.ts b/packages/promotion/src/utils/compute-actions/index.ts new file mode 100644 index 0000000000..8b2cbf288c --- /dev/null +++ b/packages/promotion/src/utils/compute-actions/index.ts @@ -0,0 +1,3 @@ +export * from "./items" +export * from "./order" +export * from "./shipping-methods" diff --git a/packages/promotion/src/utils/compute-actions/items.ts b/packages/promotion/src/utils/compute-actions/items.ts new file mode 100644 index 0000000000..0e2a308e88 --- /dev/null +++ b/packages/promotion/src/utils/compute-actions/items.ts @@ -0,0 +1,131 @@ +import { + ApplicationMethodAllocationValues, + PromotionTypes, +} from "@medusajs/types" +import { + ApplicationMethodAllocation, + ApplicationMethodTargetType, + MedusaError, +} from "@medusajs/utils" +import { areRulesValidForContext } from "../validations" + +export function getComputedActionsForItems( + promotion: PromotionTypes.PromotionDTO, + itemApplicationContext: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.ITEMS], + methodIdPromoValueMap: Map, + allocationOverride?: ApplicationMethodAllocationValues +): PromotionTypes.ComputeActions[] { + const applicableItems: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.ITEMS] = + [] + + if (!itemApplicationContext) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `"items" should be present as an array in the context for computeActions` + ) + } + + for (const itemContext of itemApplicationContext) { + const isPromotionApplicableToItem = areRulesValidForContext( + promotion?.application_method?.target_rules!, + itemContext + ) + + if (!isPromotionApplicableToItem) { + continue + } + + applicableItems.push(itemContext) + } + + return applyPromotionToItems( + promotion, + applicableItems, + methodIdPromoValueMap, + allocationOverride + ) +} + +export function applyPromotionToItems( + promotion: PromotionTypes.PromotionDTO, + items: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.ITEMS], + methodIdPromoValueMap: Map, + allocationOverride?: ApplicationMethodAllocationValues +): PromotionTypes.ComputeActions[] { + const { application_method: applicationMethod } = promotion + const allocation = applicationMethod?.allocation! + const computedActions: PromotionTypes.ComputeActions[] = [] + + if ( + [allocation, allocationOverride].includes(ApplicationMethodAllocation.EACH) + ) { + for (const method of items!) { + const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0 + const promotionValue = parseFloat(applicationMethod!.value!) + const applicableTotal = + method.unit_price * + Math.min(method.quantity, applicationMethod?.max_quantity!) - + appliedPromoValue + + const amount = Math.min(promotionValue, applicableTotal) + + if (amount <= 0) { + continue + } + + methodIdPromoValueMap.set(method.id, appliedPromoValue + amount) + + computedActions.push({ + action: "addItemAdjustment", + item_id: method.id, + amount, + code: promotion.code!, + }) + } + } + + if ( + [allocation, allocationOverride].includes( + ApplicationMethodAllocation.ACROSS + ) + ) { + const totalApplicableValue = items!.reduce((acc, method) => { + const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0 + return ( + acc + + method.unit_price * + Math.min(method.quantity, applicationMethod?.max_quantity!) - + appliedPromoValue + ) + }, 0) + + for (const method of items!) { + const promotionValue = parseFloat(applicationMethod!.value!) + const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0 + + const applicableTotal = + method.unit_price * + Math.min(method.quantity, applicationMethod?.max_quantity!) - + appliedPromoValue + + // TODO: should we worry about precision here? + const applicablePromotionValue = + (applicableTotal / totalApplicableValue) * promotionValue + + const amount = Math.min(applicablePromotionValue, applicableTotal) + + if (amount <= 0) { + continue + } + + computedActions.push({ + action: "addItemAdjustment", + item_id: method.id, + amount, + code: promotion.code!, + }) + } + } + + return computedActions +} diff --git a/packages/promotion/src/utils/compute-actions/order.ts b/packages/promotion/src/utils/compute-actions/order.ts new file mode 100644 index 0000000000..c48b6b318a --- /dev/null +++ b/packages/promotion/src/utils/compute-actions/order.ts @@ -0,0 +1,19 @@ +import { PromotionTypes } from "@medusajs/types" +import { + ApplicationMethodAllocation, + ApplicationMethodTargetType, +} from "@medusajs/utils" +import { getComputedActionsForItems } from "./items" + +export function getComputedActionsForOrder( + promotion: PromotionTypes.PromotionDTO, + itemApplicationContext: PromotionTypes.ComputeActionContext, + methodIdPromoValueMap: Map +): PromotionTypes.ComputeActions[] { + return getComputedActionsForItems( + promotion, + itemApplicationContext[ApplicationMethodTargetType.ITEMS], + methodIdPromoValueMap, + ApplicationMethodAllocation.ACROSS + ) +} diff --git a/packages/promotion/src/utils/compute-actions/shipping-methods.ts b/packages/promotion/src/utils/compute-actions/shipping-methods.ts new file mode 100644 index 0000000000..2028c1b907 --- /dev/null +++ b/packages/promotion/src/utils/compute-actions/shipping-methods.ts @@ -0,0 +1,114 @@ +import { PromotionTypes } from "@medusajs/types" +import { + ApplicationMethodAllocation, + ApplicationMethodTargetType, + MedusaError, +} from "@medusajs/utils" +import { areRulesValidForContext } from "../validations" + +export function getComputedActionsForShippingMethods( + promotion: PromotionTypes.PromotionDTO, + shippingMethodApplicationContext: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.SHIPPING_METHODS], + methodIdPromoValueMap: Map +): PromotionTypes.ComputeActions[] { + const applicableShippingItems: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.SHIPPING_METHODS] = + [] + + if (!shippingMethodApplicationContext) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `"shipping_methods" should be present as an array in the context for computeActions` + ) + } + + for (const shippingMethodContext of shippingMethodApplicationContext) { + const isPromotionApplicableToItem = areRulesValidForContext( + promotion.application_method?.target_rules!, + shippingMethodContext + ) + + if (!isPromotionApplicableToItem) { + continue + } + + applicableShippingItems.push(shippingMethodContext) + } + + return applyPromotionToShippingMethods( + promotion, + applicableShippingItems, + methodIdPromoValueMap + ) +} + +export function applyPromotionToShippingMethods( + promotion: PromotionTypes.PromotionDTO, + shippingMethods: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.SHIPPING_METHODS], + methodIdPromoValueMap: Map +): PromotionTypes.ComputeActions[] { + const { application_method: applicationMethod } = promotion + const allocation = applicationMethod?.allocation! + const computedActions: PromotionTypes.ComputeActions[] = [] + + if (allocation === ApplicationMethodAllocation.EACH) { + for (const method of shippingMethods!) { + const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0 + const promotionValue = parseFloat(applicationMethod!.value!) + const applicableTotal = method.unit_price - appliedPromoValue + const amount = Math.min(promotionValue, applicableTotal) + + if (amount <= 0) { + continue + } + + methodIdPromoValueMap.set(method.id, appliedPromoValue + amount) + + computedActions.push({ + action: "addShippingMethodAdjustment", + shipping_method_id: method.id, + amount, + code: promotion.code!, + }) + } + } + + if (allocation === ApplicationMethodAllocation.ACROSS) { + const totalApplicableValue = shippingMethods!.reduce((acc, method) => { + const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0 + + return acc + method.unit_price - appliedPromoValue + }, 0) + + if (totalApplicableValue <= 0) { + return computedActions + } + + for (const method of shippingMethods!) { + const promotionValue = parseFloat(applicationMethod!.value!) + const applicableTotal = method.unit_price + const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0 + + // TODO: should we worry about precision here? + const applicablePromotionValue = + (applicableTotal / totalApplicableValue) * promotionValue - + appliedPromoValue + + const amount = Math.min(applicablePromotionValue, applicableTotal) + + if (amount <= 0) { + continue + } + + methodIdPromoValueMap.set(method.id, appliedPromoValue + amount) + + computedActions.push({ + action: "addShippingMethodAdjustment", + shipping_method_id: method.id, + amount, + code: promotion.code!, + }) + } + } + + return computedActions +} diff --git a/packages/promotion/src/utils/index.ts b/packages/promotion/src/utils/index.ts index 81a5199502..912982c245 100644 --- a/packages/promotion/src/utils/index.ts +++ b/packages/promotion/src/utils/index.ts @@ -1 +1,2 @@ +export * as ComputeActionUtils from "./compute-actions" export * from "./validations" diff --git a/packages/promotion/src/utils/validations/application-method.ts b/packages/promotion/src/utils/validations/application-method.ts index 75dc0275a0..1f7d299fa6 100644 --- a/packages/promotion/src/utils/validations/application-method.ts +++ b/packages/promotion/src/utils/validations/application-method.ts @@ -12,8 +12,8 @@ import { } from "@medusajs/utils" export const allowedAllocationTargetTypes: string[] = [ - ApplicationMethodTargetType.SHIPPING, - ApplicationMethodTargetType.ITEM, + ApplicationMethodTargetType.SHIPPING_METHODS, + ApplicationMethodTargetType.ITEMS, ] export const allowedAllocationTypes: string[] = [ diff --git a/packages/promotion/src/utils/validations/promotion-rule.ts b/packages/promotion/src/utils/validations/promotion-rule.ts index 9c6a5eb843..fa932ee0e5 100644 --- a/packages/promotion/src/utils/validations/promotion-rule.ts +++ b/packages/promotion/src/utils/validations/promotion-rule.ts @@ -1,5 +1,11 @@ -import { PromotionRuleOperatorValues } from "@medusajs/types" -import { MedusaError, PromotionRuleOperator, isPresent } from "@medusajs/utils" +import { PromotionRuleDTO, PromotionRuleOperatorValues } from "@medusajs/types" +import { + MedusaError, + PromotionRuleOperator, + isPresent, + isString, + pickValueFromObject, +} from "@medusajs/utils" import { CreatePromotionRuleDTO } from "../../types" export function validatePromotionRuleAttributes( @@ -37,3 +43,62 @@ export function validatePromotionRuleAttributes( throw new MedusaError(MedusaError.Types.INVALID_DATA, errors.join(", ")) } + +export function areRulesValidForContext( + rules: PromotionRuleDTO[], + context: Record +): boolean { + return rules.every((rule) => { + const validRuleValues = rule.values?.map((ruleValue) => ruleValue.value) + + if (!rule.attribute) { + return false + } + + const valuesToCheck = pickValueFromObject(rule.attribute, context) + + return evaluateRuleValueCondition( + validRuleValues.filter(isString), + rule.operator!, + valuesToCheck + ) + }) +} + +export function evaluateRuleValueCondition( + ruleValues: string[], + operator: string, + ruleValuesToCheck: string[] | string +) { + if (!Array.isArray(ruleValuesToCheck)) { + ruleValuesToCheck = [ruleValuesToCheck] + } + + return ruleValuesToCheck.every((ruleValueToCheck: string) => { + if (operator === "in" || operator === "eq") { + return ruleValues.some((ruleValue) => ruleValue === ruleValueToCheck) + } + + if (operator === "ne") { + return ruleValues.some((ruleValue) => ruleValue !== ruleValueToCheck) + } + + if (operator === "gt") { + return ruleValues.some((ruleValue) => ruleValue > ruleValueToCheck) + } + + if (operator === "gte") { + return ruleValues.some((ruleValue) => ruleValue >= ruleValueToCheck) + } + + if (operator === "lt") { + return ruleValues.some((ruleValue) => ruleValue < ruleValueToCheck) + } + + if (operator === "lte") { + return ruleValues.some((ruleValue) => ruleValue <= ruleValueToCheck) + } + + return false + }) +} diff --git a/packages/types/src/promotion/common/application-method.ts b/packages/types/src/promotion/common/application-method.ts index c5ce5a2d2a..77478e14b2 100644 --- a/packages/types/src/promotion/common/application-method.ts +++ b/packages/types/src/promotion/common/application-method.ts @@ -3,7 +3,10 @@ import { PromotionDTO } from "./promotion" import { CreatePromotionRuleDTO, PromotionRuleDTO } from "./promotion-rule" export type ApplicationMethodTypeValues = "fixed" | "percentage" -export type ApplicationMethodTargetTypeValues = "order" | "shipping" | "item" +export type ApplicationMethodTargetTypeValues = + | "order" + | "shipping_methods" + | "items" export type ApplicationMethodAllocationValues = "each" | "across" export interface ApplicationMethodDTO { diff --git a/packages/types/src/promotion/common/compute-actions.ts b/packages/types/src/promotion/common/compute-actions.ts new file mode 100644 index 0000000000..3770c6ce7b --- /dev/null +++ b/packages/types/src/promotion/common/compute-actions.ts @@ -0,0 +1,55 @@ +export type ComputeActions = + | AddItemAdjustmentAction + | RemoveItemAdjustmentAction + | AddShippingMethodAdjustment + | RemoveShippingMethodAdjustment + +export interface AddItemAdjustmentAction { + action: "addItemAdjustment" + item_id: string + amount: number + code: string + description?: string +} + +export interface RemoveItemAdjustmentAction { + action: "removeItemAdjustment" + adjustment_id: string + description?: string +} + +export interface AddShippingMethodAdjustment { + action: "addShippingMethodAdjustment" + shipping_method_id: string + amount: number + code: string + description?: string +} + +export interface RemoveShippingMethodAdjustment { + action: "removeShippingMethodAdjustment" + adjustment_id: string +} + +export interface ComputeActionAdjustmentLine { + id: string + code: string +} + +export interface ComputeActionItemLine { + id: string + quantity: number + unit_price: number + adjustments?: ComputeActionAdjustmentLine[] +} + +export interface ComputeActionShippingLine { + id: string + unit_price: number + adjustments?: ComputeActionAdjustmentLine[] +} + +export interface ComputeActionContext { + items?: ComputeActionItemLine[] + shipping_methods?: ComputeActionShippingLine[] +} diff --git a/packages/types/src/promotion/common/index.ts b/packages/types/src/promotion/common/index.ts index 7bb35ddb07..11bae51b17 100644 --- a/packages/types/src/promotion/common/index.ts +++ b/packages/types/src/promotion/common/index.ts @@ -1,4 +1,5 @@ export * from "./application-method" +export * from "./compute-actions" export * from "./promotion" export * from "./promotion-rule" export * from "./promotion-rule-value" diff --git a/packages/types/src/promotion/common/promotion-rule-value.ts b/packages/types/src/promotion/common/promotion-rule-value.ts index ffca6450fc..7610202a77 100644 --- a/packages/types/src/promotion/common/promotion-rule-value.ts +++ b/packages/types/src/promotion/common/promotion-rule-value.ts @@ -3,10 +3,11 @@ import { PromotionRuleDTO } from "./promotion-rule" export interface PromotionRuleValueDTO { id: string + value?: string } export interface CreatePromotionRuleValueDTO { - value: any + value: string promotion_rule: PromotionRuleDTO } diff --git a/packages/types/src/promotion/common/promotion-rule.ts b/packages/types/src/promotion/common/promotion-rule.ts index 98adedc37f..eeac63a53d 100644 --- a/packages/types/src/promotion/common/promotion-rule.ts +++ b/packages/types/src/promotion/common/promotion-rule.ts @@ -1,4 +1,5 @@ import { BaseFilterable } from "../../dal" +import { PromotionRuleValueDTO } from "./promotion-rule-value" export type PromotionRuleOperatorValues = | "gt" @@ -11,10 +12,14 @@ export type PromotionRuleOperatorValues = export interface PromotionRuleDTO { id: string + description?: string | null + attribute?: string + operator?: PromotionRuleOperatorValues + values: PromotionRuleValueDTO[] } export interface CreatePromotionRuleDTO { - description?: string + description?: string | null attribute: string operator: PromotionRuleOperatorValues values: string[] | string @@ -31,4 +36,5 @@ export interface RemovePromotionRuleDTO { export interface FilterablePromotionRuleProps extends BaseFilterable { id?: string[] + code?: string[] } diff --git a/packages/types/src/promotion/common/promotion.ts b/packages/types/src/promotion/common/promotion.ts index 7fbbef439e..9c3a47528e 100644 --- a/packages/types/src/promotion/common/promotion.ts +++ b/packages/types/src/promotion/common/promotion.ts @@ -4,7 +4,7 @@ import { CreateApplicationMethodDTO, UpdateApplicationMethodDTO, } from "./application-method" -import { CreatePromotionRuleDTO } from "./promotion-rule" +import { CreatePromotionRuleDTO, PromotionRuleDTO } from "./promotion-rule" export type PromotionType = "standard" | "buyget" @@ -14,6 +14,7 @@ export interface PromotionDTO { type?: PromotionType is_automatic?: boolean application_method?: ApplicationMethodDTO + rules?: PromotionRuleDTO[] } export interface CreatePromotionDTO { diff --git a/packages/types/src/promotion/service.ts b/packages/types/src/promotion/service.ts index cacda1faa5..a22a54d9f9 100644 --- a/packages/types/src/promotion/service.ts +++ b/packages/types/src/promotion/service.ts @@ -2,6 +2,8 @@ import { FindConfig } from "../common" import { IModuleService } from "../modules-sdk" import { Context } from "../shared-context" import { + ComputeActionContext, + ComputeActions, CreatePromotionDTO, CreatePromotionRuleDTO, FilterablePromotionProps, @@ -11,6 +13,12 @@ import { } from "./common" export interface IPromotionModuleService extends IModuleService { + computeActions( + promotionCodesToApply: string[], + applicationContext: ComputeActionContext, + options?: Record + ): Promise + create( data: CreatePromotionDTO[], sharedContext?: Context diff --git a/packages/utils/src/common/__tests__/pick-value-from-object.spec.ts b/packages/utils/src/common/__tests__/pick-value-from-object.spec.ts new file mode 100644 index 0000000000..187b8163b9 --- /dev/null +++ b/packages/utils/src/common/__tests__/pick-value-from-object.spec.ts @@ -0,0 +1,111 @@ +import { pickValueFromObject } from "../pick-value-from-object" + +describe("pickValueFromObject", function () { + it("should return true or false for different types of data", function () { + const expectations = [ + { + input: { + 1: "attribute.another_attribute", + 2: { + attribute: { + another_attribute: "test", + }, + }, + }, + output: "test", + }, + { + input: { + 1: "attribute.another_attribute.array_attribute", + 2: { + attribute: { + another_attribute: [ + { + array_attribute: "test 1", + }, + { + array_attribute: "test 2", + }, + ], + }, + }, + }, + output: ["test 1", "test 2"], + }, + { + input: { + 1: "attribute.another_attribute.array_attribute.deep_array_attribute", + 2: { + attribute: { + another_attribute: [ + { + array_attribute: [ + { + deep_array_attribute: "test 1", + }, + { + deep_array_attribute: "test 2", + }, + ], + }, + { + array_attribute: [], + }, + ], + }, + }, + }, + output: ["test 1", "test 2"], + }, + { + input: { + 1: "attribute.another_attribute.array_attribute", + 2: { + attribute: { + another_attribute: [ + { + array_attribute: [ + { + deep_array_attribute: "test 1", + }, + { + deep_array_attribute: "test 2", + }, + ], + }, + { + array_attribute: [], + }, + ], + }, + }, + }, + output: [ + { + deep_array_attribute: "test 1", + }, + { + deep_array_attribute: "test 2", + }, + ], + }, + { + input: { + 1: "attribute.missing_attribute", + 2: { + attribute: { + another_attribute: "test", + }, + }, + }, + output: undefined, + }, + ] + + expectations.forEach((expectation) => { + expect( + pickValueFromObject(expectation.input["1"], expectation.input["2"]) + ).toEqual(expectation.output) + }) + }) +}) diff --git a/packages/utils/src/common/index.ts b/packages/utils/src/common/index.ts index 7c5def8d4f..45aa19fa51 100644 --- a/packages/utils/src/common/index.ts +++ b/packages/utils/src/common/index.ts @@ -23,6 +23,7 @@ export * from "./medusa-container" export * from "./object-from-string-path" export * from "./object-to-string-path" export * from "./optional-numeric-serializer" +export * from "./pick-value-from-object" export * from "./promise-all" export * from "./remote-query-object-from-string" export * from "./remote-query-object-to-string" diff --git a/packages/utils/src/common/pick-value-from-object.ts b/packages/utils/src/common/pick-value-from-object.ts new file mode 100644 index 0000000000..7153e23b0d --- /dev/null +++ b/packages/utils/src/common/pick-value-from-object.ts @@ -0,0 +1,37 @@ +import { isObject } from "./is-object" + +export function pickValueFromObject( + path: string, + object: Record +): any { + const segments = path.split(".") + let result: any = undefined + + for (const segment of segments) { + const segmentsLeft = [...segments].splice(1, segments.length - 1) + const segmentOutput = object[segment] + + if (segmentsLeft.length === 0) { + result = segmentOutput + break + } + + if (isObject(segmentOutput)) { + result = pickValueFromObject(segmentsLeft.join("."), segmentOutput) + break + } + + if (Array.isArray(segmentOutput)) { + result = segmentOutput + .map((segmentOutput_) => + pickValueFromObject(segmentsLeft.join("."), segmentOutput_) + ) + .flat() + break + } + + result = segmentOutput + } + + return result +} diff --git a/packages/utils/src/promotion/index.ts b/packages/utils/src/promotion/index.ts index 3e298f2568..0094b41a84 100644 --- a/packages/utils/src/promotion/index.ts +++ b/packages/utils/src/promotion/index.ts @@ -10,8 +10,8 @@ export enum ApplicationMethodType { export enum ApplicationMethodTargetType { ORDER = "order", - SHIPPING = "shipping", - ITEM = "item", + SHIPPING_METHODS = "shipping_methods", + ITEMS = "items", } export enum ApplicationMethodAllocation {