From 68ddd866a5ff9414e2db5b80d75acc5e81948540 Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Tue, 16 Jan 2024 21:06:06 +0100 Subject: [PATCH] feat(utils,types): add registerUsages for promotions + computeActions consider usage (#6094) RESOLVES CORE-1639 RESOLVES CORE-1640 RESOLVES CORE-1634 --- .changeset/cool-rockets-wash.md | 6 + .../promotion-module/compute-actions.spec.ts | 496 +++++++++++++++++- .../promotion-module/register-usage.spec.ts | 199 +++++++ .../promotion/src/repositories/campaign.ts | 18 +- .../src/services/promotion-module.ts | 118 ++++- .../src/utils/compute-actions/index.ts | 1 + .../src/utils/compute-actions/items.ts | 53 +- .../utils/compute-actions/shipping-methods.ts | 28 +- .../src/utils/compute-actions/usage.ts | 39 ++ .../utils/validations/application-method.ts | 11 + .../src/promotion/common/campaign-budget.ts | 4 +- .../src/promotion/common/compute-actions.ts | 12 + .../types/src/promotion/common/promotion.ts | 2 + packages/types/src/promotion/service.ts | 2 + packages/utils/src/promotion/index.ts | 8 + 15 files changed, 943 insertions(+), 54 deletions(-) create mode 100644 .changeset/cool-rockets-wash.md create mode 100644 packages/promotion/integration-tests/__tests__/services/promotion-module/register-usage.spec.ts create mode 100644 packages/promotion/src/utils/compute-actions/usage.ts diff --git a/.changeset/cool-rockets-wash.md b/.changeset/cool-rockets-wash.md new file mode 100644 index 0000000000..e67340d512 --- /dev/null +++ b/.changeset/cool-rockets-wash.md @@ -0,0 +1,6 @@ +--- +"@medusajs/types": patch +"@medusajs/utils": patch +--- + +feat(utils,types): add registerUsages for promotion's computed actions 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 69fdb9ce07..398f7e00e3 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 @@ -2,6 +2,7 @@ import { IPromotionModuleService } from "@medusajs/types" import { PromotionType } from "@medusajs/utils" import { SqlEntityManager } from "@mikro-orm/postgresql" import { initialize } from "../../../../src" +import { createCampaigns } from "../../../__fixtures__/campaigns" import { DB_URL, MikroOrmWrapper } from "../../../utils" jest.setTimeout(30000) @@ -459,10 +460,10 @@ describe("Promotion Service: computeActions", () => { }, ]) }) - }) - describe("when promotion is for items and allocation is across", () => { - it("should compute the correct item amendments", async () => { + it("should compute budget exceeded action when applicable total exceeds campaign budget for type spend", async () => { + await createCampaigns(repositoryManager) + const [createdPromotion] = await service.create([ { code: "PROMOTION_TEST", @@ -474,12 +475,13 @@ describe("Promotion Service: computeActions", () => { values: ["VIP", "top100"], }, ], + campaign_id: "campaign-id-1", application_method: { type: "fixed", target_type: "items", - allocation: "across", - value: "200", - max_quantity: 2, + allocation: "each", + value: "500", + max_quantity: 5, target_rules: [ { attribute: "product_category.id", @@ -500,7 +502,126 @@ describe("Promotion Service: computeActions", () => { items: [ { id: "item_cotton_tshirt", - quantity: 1, + quantity: 5, + unit_price: 1000, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", + }, + }, + ], + }) + + expect(result).toEqual([ + { action: "campaignBudgetExceeded", code: "PROMOTION_TEST" }, + ]) + }) + + it("should compute budget exceeded action when applicable total exceeds campaign budget for type usage", async () => { + await createCampaigns(repositoryManager) + + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + campaign_id: "campaign-id-2", + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + value: "500", + max_quantity: 5, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_cotton"], + }, + ], + }, + }, + ]) + + await service.updateCampaigns({ + id: "campaign-id-2", + budget: { used: 1000 }, + }) + + const result = await service.computeActions(["PROMOTION_TEST"], { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 5, + unit_price: 1000, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", + }, + }, + ], + }) + + expect(result).toEqual([ + { action: "campaignBudgetExceeded", 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: "400", + 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: 2, unit_price: 100, product_category: { id: "catg_cotton", @@ -512,7 +633,7 @@ describe("Promotion Service: computeActions", () => { { id: "item_cotton_sweater", quantity: 2, - unit_price: 150, + unit_price: 300, product_category: { id: "catg_cotton", }, @@ -527,13 +648,13 @@ describe("Promotion Service: computeActions", () => { { action: "addItemAdjustment", item_id: "item_cotton_tshirt", - amount: 50, + amount: 100, code: "PROMOTION_TEST", }, { action: "addItemAdjustment", item_id: "item_cotton_sweater", - amount: 150, + amount: 300, code: "PROMOTION_TEST", }, ]) @@ -556,7 +677,6 @@ describe("Promotion Service: computeActions", () => { target_type: "items", allocation: "across", value: "30", - max_quantity: 2, target_rules: [ { attribute: "product_category.id", @@ -584,7 +704,6 @@ describe("Promotion Service: computeActions", () => { target_type: "items", allocation: "across", value: "50", - max_quantity: 1, target_rules: [ { attribute: "product_category.id", @@ -676,7 +795,6 @@ describe("Promotion Service: computeActions", () => { target_type: "items", allocation: "across", value: "500", - max_quantity: 2, target_rules: [ { attribute: "product_category.id", @@ -704,7 +822,6 @@ describe("Promotion Service: computeActions", () => { target_type: "items", allocation: "across", value: "50", - max_quantity: 1, target_rules: [ { attribute: "product_category.id", @@ -778,6 +895,125 @@ describe("Promotion Service: computeActions", () => { }, ]) }) + + it("should compute budget exceeded action when applicable total exceeds campaign budget for type spend", async () => { + await createCampaigns(repositoryManager) + + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + campaign_id: "campaign-id-1", + application_method: { + type: "fixed", + target_type: "items", + allocation: "across", + value: "1500", + 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: 5, + unit_price: 1000, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", + }, + }, + ], + }) + + expect(result).toEqual([ + { action: "campaignBudgetExceeded", code: "PROMOTION_TEST" }, + ]) + }) + + it("should compute budget exceeded action when applicable total exceeds campaign budget for type usage", async () => { + await createCampaigns(repositoryManager) + + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + campaign_id: "campaign-id-2", + application_method: { + type: "fixed", + target_type: "items", + allocation: "across", + value: "500", + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_cotton"], + }, + ], + }, + }, + ]) + + await service.updateCampaigns({ + id: "campaign-id-2", + budget: { used: 1000 }, + }) + + const result = await service.computeActions(["PROMOTION_TEST"], { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 5, + unit_price: 1000, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", + }, + }, + ], + }) + + expect(result).toEqual([ + { action: "campaignBudgetExceeded", code: "PROMOTION_TEST" }, + ]) + }) }) describe("when promotion is for shipping_method and allocation is each", () => { @@ -1076,6 +1312,119 @@ describe("Promotion Service: computeActions", () => { }, ]) }) + + it("should compute budget exceeded action when applicable total exceeds campaign budget for type spend", async () => { + await createCampaigns(repositoryManager) + + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + campaign_id: "campaign-id-1", + application_method: { + type: "fixed", + target_type: "shipping_methods", + allocation: "each", + value: "1200", + 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: 1200, + shipping_option: { + id: "express", + }, + }, + ], + }) + + expect(result).toEqual([ + { action: "campaignBudgetExceeded", code: "PROMOTION_TEST" }, + ]) + }) + + it("should compute budget exceeded action when applicable total exceeds campaign budget for type usage", async () => { + await createCampaigns(repositoryManager) + + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + campaign_id: "campaign-id-2", + application_method: { + type: "fixed", + target_type: "shipping_methods", + allocation: "each", + value: "1200", + max_quantity: 2, + target_rules: [ + { + attribute: "shipping_option.id", + operator: "in", + values: ["express", "standard"], + }, + ], + }, + }, + ]) + + await service.updateCampaigns({ + id: "campaign-id-2", + budget: { used: 1000 }, + }) + + const result = await service.computeActions(["PROMOTION_TEST"], { + customer: { + customer_group: { + id: "VIP", + }, + }, + shipping_methods: [ + { + id: "shipping_method_express", + unit_price: 1200, + shipping_option: { + id: "express", + }, + }, + ], + }) + + expect(result).toEqual([ + { action: "campaignBudgetExceeded", code: "PROMOTION_TEST" }, + ]) + }) }) describe("when promotion is for shipping_method and allocation is across", () => { @@ -1096,7 +1445,6 @@ describe("Promotion Service: computeActions", () => { target_type: "shipping_methods", allocation: "across", value: "200", - max_quantity: 2, target_rules: [ { attribute: "shipping_option.id", @@ -1172,7 +1520,6 @@ describe("Promotion Service: computeActions", () => { target_type: "shipping_methods", allocation: "across", value: "200", - max_quantity: 2, target_rules: [ { attribute: "shipping_option.id", @@ -1200,7 +1547,6 @@ describe("Promotion Service: computeActions", () => { target_type: "shipping_methods", allocation: "across", value: "200", - max_quantity: 2, target_rules: [ { attribute: "shipping_option.id", @@ -1291,7 +1637,6 @@ describe("Promotion Service: computeActions", () => { target_type: "shipping_methods", allocation: "across", value: "1000", - max_quantity: 2, target_rules: [ { attribute: "shipping_option.id", @@ -1319,7 +1664,6 @@ describe("Promotion Service: computeActions", () => { target_type: "shipping_methods", allocation: "across", value: "200", - max_quantity: 2, target_rules: [ { attribute: "shipping_option.id", @@ -1380,6 +1724,117 @@ describe("Promotion Service: computeActions", () => { }, ]) }) + + it("should compute budget exceeded action when applicable total exceeds campaign budget for type spend", async () => { + await createCampaigns(repositoryManager) + + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + campaign_id: "campaign-id-1", + application_method: { + type: "fixed", + target_type: "shipping_methods", + allocation: "across", + value: "1200", + 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: 1200, + shipping_option: { + id: "express", + }, + }, + ], + }) + + expect(result).toEqual([ + { action: "campaignBudgetExceeded", code: "PROMOTION_TEST" }, + ]) + }) + + it("should compute budget exceeded action when applicable total exceeds campaign budget for type usage", async () => { + await createCampaigns(repositoryManager) + + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + campaign_id: "campaign-id-2", + application_method: { + type: "fixed", + target_type: "shipping_methods", + allocation: "across", + value: "1200", + target_rules: [ + { + attribute: "shipping_option.id", + operator: "in", + values: ["express", "standard"], + }, + ], + }, + }, + ]) + + await service.updateCampaigns({ + id: "campaign-id-2", + budget: { used: 1000 }, + }) + + const result = await service.computeActions(["PROMOTION_TEST"], { + customer: { + customer_group: { + id: "VIP", + }, + }, + shipping_methods: [ + { + id: "shipping_method_express", + unit_price: 1200, + shipping_option: { + id: "express", + }, + }, + ], + }) + + expect(result).toEqual([ + { action: "campaignBudgetExceeded", code: "PROMOTION_TEST" }, + ]) + }) }) describe("when promotion is for the entire order", () => { @@ -1740,6 +2195,7 @@ describe("Promotion Service: computeActions", () => { { action: "removeItemAdjustment", adjustment_id: "test-adjustment", + code: "ADJUSTMENT_CODE", }, { action: "addItemAdjustment", @@ -1780,7 +2236,6 @@ describe("Promotion Service: computeActions", () => { target_type: "shipping_methods", allocation: "across", value: "200", - max_quantity: 2, target_rules: [ { attribute: "shipping_option.id", @@ -1833,6 +2288,7 @@ describe("Promotion Service: computeActions", () => { { action: "removeShippingMethodAdjustment", adjustment_id: "test-adjustment", + code: "ADJUSTMENT_CODE", }, { action: "addShippingMethodAdjustment", diff --git a/packages/promotion/integration-tests/__tests__/services/promotion-module/register-usage.spec.ts b/packages/promotion/integration-tests/__tests__/services/promotion-module/register-usage.spec.ts new file mode 100644 index 0000000000..354b3c5582 --- /dev/null +++ b/packages/promotion/integration-tests/__tests__/services/promotion-module/register-usage.spec.ts @@ -0,0 +1,199 @@ +import { IPromotionModuleService } from "@medusajs/types" +import { SqlEntityManager } from "@mikro-orm/postgresql" +import { initialize } from "../../../../src" +import { createCampaigns } from "../../../__fixtures__/campaigns" +import { DB_URL, MikroOrmWrapper } from "../../../utils" + +jest.setTimeout(30000) + +describe("Promotion Service: campaign usage", () => { + let service: IPromotionModuleService + let repositoryManager: SqlEntityManager + + beforeEach(async () => { + await MikroOrmWrapper.setupDatabase() + repositoryManager = MikroOrmWrapper.forkManager() + + await createCampaigns(repositoryManager) + + service = await initialize({ + database: { + clientUrl: DB_URL, + schema: process.env.MEDUSA_PROMOTION_DB_SCHEMA, + }, + }) + }) + + afterEach(async () => { + await MikroOrmWrapper.clearDatabase() + }) + + describe("registerUsage", () => { + it("should register usage for type spend", async () => { + const createdPromotion = await service.create({ + code: "TEST_PROMO_SPEND", + type: "standard", + campaign_id: "campaign-id-1", + }) + + await service.registerUsage([ + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_express", + amount: 200, + code: createdPromotion.code!, + }, + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_standard", + amount: 500, + code: createdPromotion.code!, + }, + ]) + + const campaign = await service.retrieveCampaign("campaign-id-1", { + relations: ["budget"], + }) + + expect(campaign.budget).toEqual( + expect.objectContaining({ + type: "spend", + limit: 1000, + used: 700, + }) + ) + }) + + it("should register usage for type usage", async () => { + const createdPromotion = await service.create({ + code: "TEST_PROMO_USAGE", + type: "standard", + campaign_id: "campaign-id-2", + }) + + await service.registerUsage([ + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_express", + amount: 200, + code: createdPromotion.code!, + }, + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_standard", + amount: 500, + code: createdPromotion.code!, + }, + ]) + + const campaign = await service.retrieveCampaign("campaign-id-2", { + relations: ["budget"], + }) + + expect(campaign.budget).toEqual( + expect.objectContaining({ + type: "usage", + limit: 1000, + used: 1, + }) + ) + }) + + it("should not throw an error when compute action with code does not exist", async () => { + const response = await service + .registerUsage([ + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_express", + amount: 200, + code: "DOESNOTEXIST", + }, + ]) + .catch((e) => e) + + expect(response).toEqual(undefined) + }) + + it("should not register usage when limit is exceed for type usage", async () => { + const createdPromotion = await service.create({ + code: "TEST_PROMO_USAGE", + type: "standard", + campaign_id: "campaign-id-2", + }) + + await service.updateCampaigns({ + id: "campaign-id-2", + budget: { used: 1000, limit: 1000 }, + }) + + await service.registerUsage([ + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_express", + amount: 200, + code: createdPromotion.code!, + }, + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_standard", + amount: 500, + code: createdPromotion.code!, + }, + ]) + + const campaign = await service.retrieveCampaign("campaign-id-2", { + relations: ["budget"], + }) + + expect(campaign).toEqual( + expect.objectContaining({ + budget: expect.objectContaining({ + limit: 1000, + used: 1000, + }), + }) + ) + }) + + it("should not register usage above limit when exceeded for type spend", async () => { + const createdPromotion = await service.create({ + code: "TEST_PROMO_SPEND", + type: "standard", + campaign_id: "campaign-id-1", + }) + + await service.updateCampaigns({ + id: "campaign-id-1", + budget: { used: 900, limit: 1000 }, + }) + + await service.registerUsage([ + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_express", + amount: 100, + code: createdPromotion.code!, + }, + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_standard", + amount: 100, + code: createdPromotion.code!, + }, + ]) + + const campaign = await service.retrieveCampaign("campaign-id-1", { + relations: ["budget"], + }) + + expect(campaign).toEqual( + expect.objectContaining({ + budget: expect.objectContaining({ + limit: 1000, + used: 1000, + }), + }) + ) + }) + }) +}) diff --git a/packages/promotion/src/repositories/campaign.ts b/packages/promotion/src/repositories/campaign.ts index 1f68a6f7fd..231dcb8563 100644 --- a/packages/promotion/src/repositories/campaign.ts +++ b/packages/promotion/src/repositories/campaign.ts @@ -73,12 +73,14 @@ export class CampaignRepository extends DALUtils.mikroOrmBaseRepositoryFactory< const campaignPromotionIdsMap = new Map() data.forEach((campaignData) => { - const campaignPromotionIds = - campaignData.promotions?.map((p) => p.id) || [] + const campaignPromotionIds = campaignData.promotions?.map((p) => p.id) campaignIds.push(campaignData.id) - promotionIdsToUpsert.push(...campaignPromotionIds) - campaignPromotionIdsMap.set(campaignData.id, campaignPromotionIds) + + if (campaignPromotionIds) { + promotionIdsToUpsert.push(...campaignPromotionIds) + campaignPromotionIdsMap.set(campaignData.id, campaignPromotionIds) + } delete campaignData.promotions }) @@ -109,8 +111,12 @@ export class CampaignRepository extends DALUtils.mikroOrmBaseRepositoryFactory< const updatedCampaigns = await super.update(data, context) for (const updatedCampaign of updatedCampaigns) { - const upsertPromotionIds = - campaignPromotionIdsMap.get(updatedCampaign.id) || [] + const upsertPromotionIds = campaignPromotionIdsMap.get(updatedCampaign.id) + + if (!upsertPromotionIds) { + continue + } + const existingPromotionIds = ( existingCampaignsMap.get(updatedCampaign.id)?.promotions || [] ).map((p) => p.id) diff --git a/packages/promotion/src/services/promotion-module.ts b/packages/promotion/src/services/promotion-module.ts index bc56733d56..90d61307e9 100644 --- a/packages/promotion/src/services/promotion-module.ts +++ b/packages/promotion/src/services/promotion-module.ts @@ -8,6 +8,7 @@ import { } from "@medusajs/types" import { ApplicationMethodTargetType, + CampaignBudgetType, InjectManager, InjectTransactionManager, MedusaContext, @@ -90,6 +91,110 @@ export default class PromotionModuleService< return joinerConfig } + @InjectManager("baseRepository_") + async registerUsage( + computedActions: PromotionTypes.UsageComputedActions[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + const promotionCodes = computedActions + .map((computedAction) => computedAction.code) + .filter(Boolean) + + const promotionCodeCampaignBudgetMap = new Map< + string, + UpdateCampaignBudgetDTO + >() + const promotionCodeUsageMap = new Map() + + const existingPromotions = await this.list( + { code: promotionCodes }, + { relations: ["application_method", "campaign", "campaign.budget"] }, + sharedContext + ) + + const existingPromotionsMap = new Map( + existingPromotions.map((promotion) => [promotion.code!, promotion]) + ) + + for (let computedAction of computedActions) { + if (!ComputeActionUtils.canRegisterUsage(computedAction)) { + continue + } + + const promotion = existingPromotionsMap.get(computedAction.code) + + if (!promotion) { + continue + } + + const campaignBudget = promotion.campaign?.budget + + if (!campaignBudget) { + continue + } + + if (campaignBudget.type === CampaignBudgetType.SPEND) { + const campaignBudgetData = promotionCodeCampaignBudgetMap.get( + campaignBudget.id + ) || { id: campaignBudget.id, used: campaignBudget.used || 0 } + + campaignBudgetData.used = + (campaignBudgetData.used || 0) + computedAction.amount + + if ( + campaignBudget.limit && + campaignBudgetData.used > campaignBudget.limit + ) { + continue + } + + promotionCodeCampaignBudgetMap.set( + campaignBudget.id, + campaignBudgetData + ) + } + + if (campaignBudget.type === CampaignBudgetType.USAGE) { + const promotionAlreadyUsed = + promotionCodeUsageMap.get(promotion.code!) || false + + if (promotionAlreadyUsed) { + continue + } + + const campaignBudgetData = { + id: campaignBudget.id, + used: (campaignBudget.used || 0) + 1, + } + + if ( + campaignBudget.limit && + campaignBudgetData.used > campaignBudget.limit + ) { + continue + } + + promotionCodeCampaignBudgetMap.set( + campaignBudget.id, + campaignBudgetData + ) + + promotionCodeUsageMap.set(promotion.code!, true) + } + + const campaignBudgetsData: UpdateCampaignBudgetDTO[] = [] + + for (const [_, campaignBudgetData] of promotionCodeCampaignBudgetMap) { + campaignBudgetsData.push(campaignBudgetData) + } + + await this.campaignBudgetService_.update( + campaignBudgetsData, + sharedContext + ) + } + } + async computeActions( promotionCodesToApply: string[], applicationContext: PromotionTypes.ComputeActionContext, @@ -140,6 +245,8 @@ export default class PromotionModuleService< "application_method.target_rules.values", "rules", "rules.values", + "campaign", + "campaign.budget", ], } ) @@ -166,6 +273,7 @@ export default class PromotionModuleService< computedActions.push({ action: "removeItemAdjustment", adjustment_id: codeAdjustmentMap.get(appliedCode)!.id, + code: appliedCode, }) } @@ -173,6 +281,7 @@ export default class PromotionModuleService< computedActions.push({ action: "removeShippingMethodAdjustment", adjustment_id: codeAdjustmentMap.get(appliedCode)!.id, + code: appliedCode, }) } } @@ -544,6 +653,7 @@ export default class PromotionModuleService< !allowedAllocationForQuantity.includes(applicationMethodData.allocation) ) { applicationMethodData.max_quantity = null + existingApplicationMethod.max_quantity = null } validateApplicationMethodAttributes({ @@ -952,12 +1062,8 @@ export default class PromotionModuleService< const campaignBudgetsData: UpdateCampaignBudgetDTO[] = [] const existingCampaigns = await this.listCampaigns( - { - id: campaignIds, - }, - { - relations: ["budget"], - }, + { id: campaignIds }, + { relations: ["budget"] }, sharedContext ) diff --git a/packages/promotion/src/utils/compute-actions/index.ts b/packages/promotion/src/utils/compute-actions/index.ts index 8b2cbf288c..33d690935a 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 "./items" export * from "./order" export * from "./shipping-methods" +export * from "./usage" diff --git a/packages/promotion/src/utils/compute-actions/items.ts b/packages/promotion/src/utils/compute-actions/items.ts index 0e2a308e88..afbf400b56 100644 --- a/packages/promotion/src/utils/compute-actions/items.ts +++ b/packages/promotion/src/utils/compute-actions/items.ts @@ -5,9 +5,11 @@ import { import { ApplicationMethodAllocation, ApplicationMethodTargetType, + ComputedActions, MedusaError, } from "@medusajs/utils" import { areRulesValidForContext } from "../validations" +import { computeActionForBudgetExceeded } from "./usage" export function getComputedActionsForItems( promotion: PromotionTypes.PromotionDTO, @@ -61,22 +63,35 @@ export function applyPromotionToItems( ) { for (const method of items!) { const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0 - const promotionValue = parseFloat(applicationMethod!.value!) + const quantityMultiplier = Math.min( + method.quantity, + applicationMethod?.max_quantity! + ) + const promotionValue = + parseFloat(applicationMethod!.value!) * quantityMultiplier const applicableTotal = - method.unit_price * - Math.min(method.quantity, applicationMethod?.max_quantity!) - - appliedPromoValue - + method.unit_price * quantityMultiplier - appliedPromoValue const amount = Math.min(promotionValue, applicableTotal) if (amount <= 0) { continue } + const budgetExceededAction = computeActionForBudgetExceeded( + promotion, + amount + ) + + if (budgetExceededAction) { + computedActions.push(budgetExceededAction) + + continue + } + methodIdPromoValueMap.set(method.id, appliedPromoValue + amount) computedActions.push({ - action: "addItemAdjustment", + action: ComputedActions.ADD_ITEM_ADJUSTMENT, item_id: method.id, amount, code: promotion.code!, @@ -91,35 +106,37 @@ export function applyPromotionToItems( ) { 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 - ) + return acc + method.unit_price * method.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 + method.unit_price * method.quantity - appliedPromoValue // TODO: should we worry about precision here? const applicablePromotionValue = (applicableTotal / totalApplicableValue) * promotionValue - const amount = Math.min(applicablePromotionValue, applicableTotal) if (amount <= 0) { continue } + const budgetExceededAction = computeActionForBudgetExceeded( + promotion, + amount + ) + + if (budgetExceededAction) { + computedActions.push(budgetExceededAction) + + continue + } + computedActions.push({ - action: "addItemAdjustment", + action: ComputedActions.ADD_ITEM_ADJUSTMENT, item_id: method.id, amount, code: promotion.code!, diff --git a/packages/promotion/src/utils/compute-actions/shipping-methods.ts b/packages/promotion/src/utils/compute-actions/shipping-methods.ts index 2028c1b907..440021f482 100644 --- a/packages/promotion/src/utils/compute-actions/shipping-methods.ts +++ b/packages/promotion/src/utils/compute-actions/shipping-methods.ts @@ -2,9 +2,11 @@ import { PromotionTypes } from "@medusajs/types" import { ApplicationMethodAllocation, ApplicationMethodTargetType, + ComputedActions, MedusaError, } from "@medusajs/utils" import { areRulesValidForContext } from "../validations" +import { computeActionForBudgetExceeded } from "./usage" export function getComputedActionsForShippingMethods( promotion: PromotionTypes.PromotionDTO, @@ -61,10 +63,21 @@ export function applyPromotionToShippingMethods( continue } + const budgetExceededAction = computeActionForBudgetExceeded( + promotion, + amount + ) + + if (budgetExceededAction) { + computedActions.push(budgetExceededAction) + + continue + } + methodIdPromoValueMap.set(method.id, appliedPromoValue + amount) computedActions.push({ - action: "addShippingMethodAdjustment", + action: ComputedActions.ADD_SHIPPING_METHOD_ADJUSTMENT, shipping_method_id: method.id, amount, code: promotion.code!, @@ -99,10 +112,21 @@ export function applyPromotionToShippingMethods( continue } + const budgetExceededAction = computeActionForBudgetExceeded( + promotion, + amount + ) + + if (budgetExceededAction) { + computedActions.push(budgetExceededAction) + + continue + } + methodIdPromoValueMap.set(method.id, appliedPromoValue + amount) computedActions.push({ - action: "addShippingMethodAdjustment", + action: ComputedActions.ADD_SHIPPING_METHOD_ADJUSTMENT, shipping_method_id: method.id, amount, code: promotion.code!, diff --git a/packages/promotion/src/utils/compute-actions/usage.ts b/packages/promotion/src/utils/compute-actions/usage.ts new file mode 100644 index 0000000000..f2f5e10e51 --- /dev/null +++ b/packages/promotion/src/utils/compute-actions/usage.ts @@ -0,0 +1,39 @@ +import { + CampaignBudgetExceededAction, + ComputeActions, + PromotionDTO, +} from "@medusajs/types" +import { CampaignBudgetType, ComputedActions } from "@medusajs/utils" + +export function canRegisterUsage(computedAction: ComputeActions): boolean { + return ( + [ + ComputedActions.ADD_ITEM_ADJUSTMENT, + ComputedActions.ADD_SHIPPING_METHOD_ADJUSTMENT, + ] as string[] + ).includes(computedAction.action) +} + +export function computeActionForBudgetExceeded( + promotion: PromotionDTO, + amount: number +): CampaignBudgetExceededAction | void { + const campaignBudget = promotion.campaign?.budget + + if (!campaignBudget) { + return + } + + const campaignBudgetUsed = campaignBudget.used || 0 + const totalUsed = + campaignBudget.type === CampaignBudgetType.SPEND + ? campaignBudgetUsed + amount + : campaignBudgetUsed + 1 + + if (campaignBudget.limit && totalUsed > campaignBudget.limit) { + return { + action: ComputedActions.CAMPAIGN_BUDGET_EXCEEDED, + code: promotion.code!, + } + } +} diff --git a/packages/promotion/src/utils/validations/application-method.ts b/packages/promotion/src/utils/validations/application-method.ts index 1f7d299fa6..95d384e19f 100644 --- a/packages/promotion/src/utils/validations/application-method.ts +++ b/packages/promotion/src/utils/validations/application-method.ts @@ -9,6 +9,7 @@ import { ApplicationMethodType, MedusaError, isDefined, + isPresent, } from "@medusajs/utils" export const allowedAllocationTargetTypes: string[] = [ @@ -33,6 +34,16 @@ export function validateApplicationMethodAttributes(data: { }) { const allTargetTypes: string[] = Object.values(ApplicationMethodTargetType) + if ( + data.allocation === ApplicationMethodAllocation.ACROSS && + isPresent(data.max_quantity) + ) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `application_method.max_quantity is not allowed to be set for allocation (${ApplicationMethodAllocation.ACROSS})` + ) + } + if (!allTargetTypes.includes(data.target_type)) { throw new MedusaError( MedusaError.Types.INVALID_DATA, diff --git a/packages/types/src/promotion/common/campaign-budget.ts b/packages/types/src/promotion/common/campaign-budget.ts index 0b2b103d58..4f341a43cb 100644 --- a/packages/types/src/promotion/common/campaign-budget.ts +++ b/packages/types/src/promotion/common/campaign-budget.ts @@ -5,8 +5,8 @@ export type CampaignBudgetTypeValues = "spend" | "usage" export interface CampaignBudgetDTO { id: string type?: CampaignBudgetTypeValues - limit?: string | null - used?: string + limit?: number | null + used?: number } export interface FilterableCampaignBudgetProps diff --git a/packages/types/src/promotion/common/compute-actions.ts b/packages/types/src/promotion/common/compute-actions.ts index 8e57402efe..b1f97a3c0c 100644 --- a/packages/types/src/promotion/common/compute-actions.ts +++ b/packages/types/src/promotion/common/compute-actions.ts @@ -3,6 +3,16 @@ export type ComputeActions = | RemoveItemAdjustmentAction | AddShippingMethodAdjustment | RemoveShippingMethodAdjustment + | CampaignBudgetExceededAction + +export type UsageComputedActions = + | AddShippingMethodAdjustment + | AddItemAdjustmentAction + +export interface CampaignBudgetExceededAction { + action: "campaignBudgetExceeded" + code: string +} export interface AddItemAdjustmentAction { action: "addItemAdjustment" @@ -16,6 +26,7 @@ export interface RemoveItemAdjustmentAction { action: "removeItemAdjustment" adjustment_id: string description?: string + code: string } export interface AddShippingMethodAdjustment { @@ -29,6 +40,7 @@ export interface AddShippingMethodAdjustment { export interface RemoveShippingMethodAdjustment { action: "removeShippingMethodAdjustment" adjustment_id: string + code: string } export interface ComputeActionAdjustmentLine extends Record { diff --git a/packages/types/src/promotion/common/promotion.ts b/packages/types/src/promotion/common/promotion.ts index 211223f889..d0cea3caad 100644 --- a/packages/types/src/promotion/common/promotion.ts +++ b/packages/types/src/promotion/common/promotion.ts @@ -5,6 +5,7 @@ import { CreateApplicationMethodDTO, UpdateApplicationMethodDTO, } from "./application-method" +import { CampaignDTO } from "./campaign" import { CreatePromotionRuleDTO, PromotionRuleDTO } from "./promotion-rule" export type PromotionType = "standard" | "buyget" @@ -16,6 +17,7 @@ export interface PromotionDTO { is_automatic?: boolean application_method?: ApplicationMethodDTO rules?: PromotionRuleDTO[] + campaign?: CampaignDTO } export interface CreatePromotionDTO { diff --git a/packages/types/src/promotion/service.ts b/packages/types/src/promotion/service.ts index 9f35256ac0..0644ae71e5 100644 --- a/packages/types/src/promotion/service.ts +++ b/packages/types/src/promotion/service.ts @@ -17,6 +17,8 @@ import { import { CreateCampaignDTO, UpdateCampaignDTO } from "./mutations" export interface IPromotionModuleService extends IModuleService { + registerUsage(computedActions: ComputeActions[]): Promise + computeActions( promotionCodesToApply: string[], applicationContext: ComputeActionContext, diff --git a/packages/utils/src/promotion/index.ts b/packages/utils/src/promotion/index.ts index b416a7cd96..f3dc9d2409 100644 --- a/packages/utils/src/promotion/index.ts +++ b/packages/utils/src/promotion/index.ts @@ -33,3 +33,11 @@ export enum CampaignBudgetType { SPEND = "spend", USAGE = "usage", } + +export enum ComputedActions { + ADD_ITEM_ADJUSTMENT = "addItemAdjustment", + ADD_SHIPPING_METHOD_ADJUSTMENT = "addShippingMethodAdjustment", + REMOVE_ITEM_ADJUSTMENT = "removeItemAdjustment", + REMOVE_SHIPPING_METHOD_ADJUSTMENT = "removeShippingMethodAdjustment", + CAMPAIGN_BUDGET_EXCEEDED = "campaignBudgetExceeded", +}