From 5d7179b7d009ac98055a0511d0520233ca2e4f6e Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Thu, 5 Sep 2024 10:43:29 +0200 Subject: [PATCH] feat(core-flows,types,promotion): register promotion campaign usage upon cart completion (#8970) * feat(core-flows,types,promotion): register promotion campaign usage upon cart completion * Apply suggestions from code review Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> --------- Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> --- .../__tests__/cart/store/carts.spec.ts | 78 ++++++++-- .../src/cart/workflows/complete-cart.ts | 47 +++++- .../src/promotion/steps/register-usage.ts | 35 +++++ .../src/promotion/common/compute-actions.ts | 14 +- packages/core/types/src/promotion/service.ts | 29 +++- .../promotion-module/revert-usage.spec.ts | 129 +++++++++++++++++ .../src/services/promotion-module.ts | 135 +++++++++++++++--- .../src/utils/compute-actions/usage.ts | 10 -- 8 files changed, 421 insertions(+), 56 deletions(-) create mode 100644 packages/core/core-flows/src/promotion/steps/register-usage.ts create mode 100644 packages/modules/promotion/integration-tests/__tests__/services/promotion-module/revert-usage.spec.ts diff --git a/integration-tests/modules/__tests__/cart/store/carts.spec.ts b/integration-tests/modules/__tests__/cart/store/carts.spec.ts index ab1aed0098..1da0877175 100644 --- a/integration-tests/modules/__tests__/cart/store/carts.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/carts.spec.ts @@ -1812,6 +1812,7 @@ medusaIntegrationTestRunner({ let paymentSession let stockLocation let inventoryItem + let promotion beforeEach(async () => { await setupTaxStructure(taxModule) @@ -1925,6 +1926,43 @@ medusaIntegrationTestRunner({ }, ]) + promotion = ( + await api.post( + `/admin/promotions`, + { + code: "TEST", + type: PromotionType.STANDARD, + is_automatic: true, + campaign: { + campaign_identifier: "test", + name: "test", + budget: { + type: "spend", + limit: 1000, + currency_code: region.currency_code, + }, + }, + application_method: { + target_type: "items", + type: "fixed", + allocation: "each", + currency_code: region.currency_code, + value: 10, + max_quantity: 1, + target_rules: [ + { + attribute: "product_id", + operator: "eq", + values: [product.id], + }, + ], + }, + rules: [], + }, + adminHeaders + ) + ).data.promotion + await api.post( `/admin/stock-locations/${stockLocation.id}/fulfillment-providers`, { add: ["manual_test-provider"] }, @@ -2015,16 +2053,16 @@ medusaIntegrationTestRunner({ type: "order", order: expect.objectContaining({ id: expect.any(String), - total: 106, + total: 94.764, subtotal: 100, - tax_total: 6, - discount_total: 0, - discount_tax_total: 0, - original_total: 106, + tax_total: 5.364, + discount_total: 10.6, + discount_tax_total: 0.636, + original_total: 95.4, original_tax_total: 6, - item_total: 106, + item_total: 94.764, item_subtotal: 100, - item_tax_total: 6, + item_tax_total: 5.364, original_item_total: 106, original_item_subtotal: 100, original_item_tax_total: 6, @@ -2040,17 +2078,27 @@ medusaIntegrationTestRunner({ product_id: product.id, unit_price: 100, quantity: 1, - tax_total: 6, + tax_total: 5.364, + total: 94.764, subtotal: 100, - total: 106, original_total: 106, - discount_total: 0, + discount_total: 10.6, + discount_tax_total: 0.636, + original_tax_total: 6, tax_lines: [ expect.objectContaining({ rate: 6, }), ], - adjustments: [], + adjustments: [ + expect.objectContaining({ + amount: 10, + promotion_id: promotion.id, + code: promotion.code, + subtotal: 10, + total: 10.6, + }), + ], }), ], shipping_address: expect.objectContaining({ @@ -2062,6 +2110,12 @@ medusaIntegrationTestRunner({ }), }) + promotion = ( + await api.get(`/admin/promotions/${promotion.id}`, adminHeaders) + ).data.promotion + + expect(promotion.campaign.budget.used).toEqual(10) + const reservation = await api.get(`/admin/reservations`, adminHeaders) const reservationItem = reservation.data.reservations[0] @@ -2086,7 +2140,7 @@ medusaIntegrationTestRunner({ payment_collections: [ expect.objectContaining({ currency_code: "usd", - amount: 106, + amount: 94.764, status: "authorized", }), ], diff --git a/packages/core/core-flows/src/cart/workflows/complete-cart.ts b/packages/core/core-flows/src/cart/workflows/complete-cart.ts index fd1032c4e4..c5f8b01633 100644 --- a/packages/core/core-flows/src/cart/workflows/complete-cart.ts +++ b/packages/core/core-flows/src/cart/workflows/complete-cart.ts @@ -1,11 +1,15 @@ -import { OrderDTO } from "@medusajs/types" +import { + CartWorkflowDTO, + OrderDTO, + UsageComputedActions, +} from "@medusajs/types" import { Modules, OrderStatus, OrderWorkflowEvents } from "@medusajs/utils" import { - WorkflowData, - WorkflowResponse, createWorkflow, parallelize, transform, + WorkflowData, + WorkflowResponse, } from "@medusajs/workflows-sdk" import { createRemoteLinkStep, @@ -14,6 +18,7 @@ import { } from "../../common" import { createOrdersStep } from "../../order/steps/create-orders" import { authorizePaymentSessionStep } from "../../payment/steps/authorize-payment-session" +import { registerUsageStep } from "../../promotion/steps/register-usage" import { updateCartsStep, validateCartPaymentsStep } from "../steps" import { reserveInventoryStep } from "../steps/reserve-inventory" import { validateCartStep } from "../steps/validate-cart" @@ -61,7 +66,8 @@ export const completeCartWorkflow = createWorkflow( (data) => { const allItems: any[] = [] const allVariants: any[] = [] - data.cart.items.forEach((item) => { + + data.cart?.items?.forEach((item) => { allItems.push({ id: item.id, variant_id: item.variant_id, @@ -188,6 +194,39 @@ export const completeCartWorkflow = createWorkflow( }) ) + const promotionUsage = transform( + { cart }, + ({ cart }: { cart: CartWorkflowDTO }) => { + const promotionUsage: UsageComputedActions[] = [] + + const itemAdjustments = (cart.items ?? []) + .map((item) => item.adjustments ?? []) + .flat(1) + + const shippingAdjustments = (cart.shipping_methods ?? []) + .map((item) => item.adjustments ?? []) + .flat(1) + + for (const adjustment of itemAdjustments) { + promotionUsage.push({ + amount: adjustment.amount, + code: adjustment.code!, + }) + } + + for (const adjustment of shippingAdjustments) { + promotionUsage.push({ + amount: adjustment.amount, + code: adjustment.code!, + }) + } + + return promotionUsage + } + ) + + registerUsageStep(promotionUsage) + return new WorkflowResponse(order) } ) diff --git a/packages/core/core-flows/src/promotion/steps/register-usage.ts b/packages/core/core-flows/src/promotion/steps/register-usage.ts new file mode 100644 index 0000000000..3b1e271f8a --- /dev/null +++ b/packages/core/core-flows/src/promotion/steps/register-usage.ts @@ -0,0 +1,35 @@ +import { IPromotionModuleService, UsageComputedActions } from "@medusajs/types" +import { ModuleRegistrationName } from "@medusajs/utils" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +export const registerUsageStepId = "register-usage" +/** + * This step registers usage for promotion campaigns + */ +export const registerUsageStep = createStep( + registerUsageStepId, + async (data: UsageComputedActions[], { container }) => { + if (!data.length) { + return new StepResponse(null, []) + } + + const promotionModule = container.resolve( + ModuleRegistrationName.PROMOTION + ) + + await promotionModule.registerUsage(data) + + return new StepResponse(null, data) + }, + async (revertData, { container }) => { + if (!revertData?.length) { + return + } + + const promotionModule = container.resolve( + ModuleRegistrationName.PROMOTION + ) + + await promotionModule.revertUsage(revertData) + } +) diff --git a/packages/core/types/src/promotion/common/compute-actions.ts b/packages/core/types/src/promotion/common/compute-actions.ts index 4a665f55b6..065bc2506b 100644 --- a/packages/core/types/src/promotion/common/compute-actions.ts +++ b/packages/core/types/src/promotion/common/compute-actions.ts @@ -13,9 +13,17 @@ export type ComputeActions = /** * These computed action types can affect a campaign's budget. */ -export type UsageComputedActions = - | AddShippingMethodAdjustment - | AddItemAdjustmentAction +export type UsageComputedActions = { + /** + * The amount to remove off the shipping method's total. + */ + amount: BigNumberInput + + /** + * The promotion's code. + */ + code: string +} /** * This action indicates that the promotions within a campaign can no longer be used diff --git a/packages/core/types/src/promotion/service.ts b/packages/core/types/src/promotion/service.ts index 1b264b0d5c..2266cf1379 100644 --- a/packages/core/types/src/promotion/service.ts +++ b/packages/core/types/src/promotion/service.ts @@ -15,6 +15,7 @@ import { PromotionRuleDTO, UpdatePromotionDTO, UpdatePromotionRuleDTO, + UsageComputedActions, } from "./common" import { AddPromotionsToCampaignDTO, @@ -32,26 +33,42 @@ export interface IPromotionModuleService extends IModuleService { * It adjusts the `used` property of a `CampaignBudget` to account for the adjustment amounts in the specified associated * computed actions. * - * @param {ComputeActions[]} computedActions - The computed actions to adjust their promotion's campaign budget. + * @param {UsageComputedActions[]} computedActions - The computed actions to adjust their promotion's campaign budget. * @returns {Promise} Resolves when the campaign budgets have been adjusted successfully. * * @example * await promotionModuleService.registerUsage([ * { - * action: "addItemAdjustment", - * item_id: "cali_123", * amount: 50, * code: "50OFF", * }, * { - * action: "addShippingMethodAdjustment", - * shipping_method_id: "casm_123", * amount: 5000, * code: "FREESHIPPING", * }, * ]) */ - registerUsage(computedActions: ComputeActions[]): Promise + registerUsage(computedActions: UsageComputedActions[]): Promise + + /** + * This method is used to revert the changes made by registerUsage action + * + * @param {UsageComputedActions[]} computedActions - The computed actions to adjust their promotion's campaign budget. + * @returns {Promise} Resolves when the campaign budgets have been adjusted successfully. + * + * @example + * await promotionModuleService.revertUsage([ + * { + * amount: 50, + * code: "50OFF", + * }, + * { + * amount: 5000, + * code: "FREESHIPPING", + * }, + * ]) + */ + revertUsage(computedActions: UsageComputedActions[]): Promise /** * This method provides the actions to perform on a cart based on the specified promotions diff --git a/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/revert-usage.spec.ts b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/revert-usage.spec.ts new file mode 100644 index 0000000000..08253f6145 --- /dev/null +++ b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/revert-usage.spec.ts @@ -0,0 +1,129 @@ +import { IPromotionModuleService } from "@medusajs/types" +import { Modules } from "@medusajs/utils" +import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils" +import { createCampaigns } from "../../../__fixtures__/campaigns" +import { createDefaultPromotion } from "../../../__fixtures__/promotion" + +jest.setTimeout(30000) + +moduleIntegrationTestRunner({ + moduleName: Modules.PROMOTION, + testSuite: ({ + MikroOrmWrapper, + service, + }: SuiteOptions) => { + describe("Promotion Service: campaign usage", () => { + beforeEach(async () => { + await createCampaigns(MikroOrmWrapper.forkManager()) + }) + + describe("revertUsage", () => { + it("should revert usage for type spend", async () => { + const createdPromotion = await createDefaultPromotion(service, {}) + const createdPromotion2 = await createDefaultPromotion(service, { + code: "PROMO_2", + campaign_id: createdPromotion.campaign?.id, + }) + const createdPromotion3 = await createDefaultPromotion(service, { + code: "PROMO_3", + campaign_id: createdPromotion.campaign?.id, + }) + + await service.registerUsage([ + { amount: 200, code: createdPromotion.code! }, + { amount: 100, code: createdPromotion.code! }, + ]) + + await service.registerUsage([ + { amount: 100, code: createdPromotion2.code! }, + { amount: 200, code: createdPromotion2.code! }, + ]) + + await service.registerUsage([ + { amount: 50, code: createdPromotion3.code! }, + { amount: 250, code: createdPromotion3.code! }, + ]) + + await service.revertUsage([ + { amount: 200, code: createdPromotion.code! }, + { amount: 100, code: createdPromotion.code! }, + ]) + + await service.revertUsage([ + { amount: 50, code: createdPromotion3.code! }, + { amount: 250, code: createdPromotion3.code! }, + ]) + + const campaign = await service.retrieveCampaign( + createdPromotion.campaign?.id!, + { relations: ["budget"] } + ) + + expect(campaign.budget).toEqual( + expect.objectContaining({ + type: "spend", + limit: 1000, + used: 300, + }) + ) + }) + + it("should revert usage for type usage", async () => { + const campaignId = "campaign-id-2" + const createdPromotion = await createDefaultPromotion(service, { + code: "PROMO_1", + campaign_id: campaignId, + }) + const createdPromotion2 = await createDefaultPromotion(service, { + code: "PROMO_2", + campaign_id: campaignId, + }) + const createdPromotion3 = await createDefaultPromotion(service, { + code: "PROMO_3", + campaign_id: campaignId, + }) + + await service.registerUsage([ + { amount: 200, code: createdPromotion.code! }, + { amount: 500, code: createdPromotion.code! }, + ]) + + await service.registerUsage([ + { amount: 200, code: createdPromotion2.code! }, + { amount: 500, code: createdPromotion3.code! }, + ]) + + await service.revertUsage([ + { amount: 200, code: createdPromotion.code! }, + { amount: 500, code: createdPromotion.code! }, + ]) + + await service.revertUsage([ + { amount: 200, code: createdPromotion2.code! }, + { amount: 500, code: createdPromotion3.code! }, + ]) + + const campaign = await service.retrieveCampaign(campaignId, { + relations: ["budget"], + }) + + expect(campaign.budget).toEqual( + expect.objectContaining({ + type: "usage", + limit: 1000, + used: 0, + }) + ) + }) + + it("should not throw an error when compute action with code does not exist", async () => { + const response = await service + .revertUsage([{ amount: 200, code: "DOESNOTEXIST" }]) + .catch((e) => e) + + expect(response).toEqual(undefined) + }) + }) + }) + }, +}) diff --git a/packages/modules/promotion/src/services/promotion-module.ts b/packages/modules/promotion/src/services/promotion-module.ts index 6406473491..c6e290832a 100644 --- a/packages/modules/promotion/src/services/promotion-module.ts +++ b/packages/modules/promotion/src/services/promotion-module.ts @@ -123,33 +123,39 @@ export default class PromotionModuleService @InjectManager("baseRepository_") async registerUsage( computedActions: PromotionTypes.UsageComputedActions[], + @MedusaContext() @MedusaContext() sharedContext: Context = {} ): Promise { const promotionCodes = computedActions .map((computedAction) => computedAction.code) .filter(Boolean) - const promotionCodeCampaignBudgetMap = new Map< - string, - UpdateCampaignBudgetDTO - >() + const campaignBudgetMap = new Map() const promotionCodeUsageMap = new Map() const existingPromotions = await this.listPromotions( { code: promotionCodes }, - { relations: ["application_method", "campaign", "campaign.budget"] }, + { + relations: ["campaign", "campaign.budget"], + take: null, + }, sharedContext ) + for (const promotion of existingPromotions) { + if (promotion.campaign?.budget) { + campaignBudgetMap.set( + promotion.campaign?.budget.id, + promotion.campaign?.budget + ) + } + } + 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) { @@ -163,9 +169,11 @@ export default class PromotionModuleService } if (campaignBudget.type === CampaignBudgetType.SPEND) { - const campaignBudgetData = promotionCodeCampaignBudgetMap.get( - campaignBudget.id - ) || { id: campaignBudget.id, used: campaignBudget.used ?? 0 } + const campaignBudgetData = campaignBudgetMap.get(campaignBudget.id) + + if (!campaignBudgetData) { + continue + } campaignBudgetData.used = MathBN.add( campaignBudgetData.used ?? 0, @@ -179,10 +187,7 @@ export default class PromotionModuleService continue } - promotionCodeCampaignBudgetMap.set( - campaignBudget.id, - campaignBudgetData - ) + campaignBudgetMap.set(campaignBudget.id, campaignBudgetData) } if (campaignBudget.type === CampaignBudgetType.USAGE) { @@ -205,17 +210,105 @@ export default class PromotionModuleService continue } - promotionCodeCampaignBudgetMap.set( - campaignBudget.id, - campaignBudgetData - ) + campaignBudgetMap.set(campaignBudget.id, campaignBudgetData) promotionCodeUsageMap.set(promotion.code!, true) } const campaignBudgetsData: UpdateCampaignBudgetDTO[] = [] - for (const [_, campaignBudgetData] of promotionCodeCampaignBudgetMap) { + for (const [_, campaignBudgetData] of campaignBudgetMap) { + campaignBudgetsData.push(campaignBudgetData) + } + + await this.campaignBudgetService_.update( + campaignBudgetsData, + sharedContext + ) + } + } + + @InjectManager("baseRepository_") + async revertUsage( + computedActions: PromotionTypes.UsageComputedActions[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + const promotionCodeUsageMap = new Map() + const campaignBudgetMap = new Map() + + const existingPromotions = await this.listPromotions( + { + code: computedActions + .map((computedAction) => computedAction.code) + .filter(Boolean), + }, + { + relations: ["campaign", "campaign.budget"], + take: null, + }, + sharedContext + ) + + for (const promotion of existingPromotions) { + if (promotion.campaign?.budget) { + campaignBudgetMap.set( + promotion.campaign?.budget.id, + promotion.campaign?.budget + ) + } + } + + const existingPromotionsMap = new Map( + existingPromotions.map((promotion) => [promotion.code!, promotion]) + ) + + for (let computedAction of computedActions) { + const promotion = existingPromotionsMap.get(computedAction.code) + + if (!promotion) { + continue + } + + const campaignBudget = promotion.campaign?.budget + + if (!campaignBudget) { + continue + } + + if (campaignBudget.type === CampaignBudgetType.SPEND) { + const campaignBudgetData = campaignBudgetMap.get(campaignBudget.id) + + if (!campaignBudgetData) { + continue + } + + campaignBudgetData.used = MathBN.sub( + campaignBudgetData.used ?? 0, + computedAction.amount + ) + + campaignBudgetMap.set(campaignBudget.id, campaignBudgetData) + } + + if (campaignBudget.type === CampaignBudgetType.USAGE) { + const promotionAlreadyUsed = + promotionCodeUsageMap.get(promotion.code!) || false + + if (promotionAlreadyUsed) { + continue + } + + campaignBudgetMap.set(campaignBudget.id, { + id: campaignBudget.id, + used: MathBN.sub(campaignBudget.used ?? 0, 1), + }) + + promotionCodeUsageMap.set(promotion.code!, true) + } + + const campaignBudgetsData: UpdateCampaignBudgetDTO[] = [] + + for (const [_, campaignBudgetData] of campaignBudgetMap) { campaignBudgetsData.push(campaignBudgetData) } diff --git a/packages/modules/promotion/src/utils/compute-actions/usage.ts b/packages/modules/promotion/src/utils/compute-actions/usage.ts index c037dc873d..e1d1e72763 100644 --- a/packages/modules/promotion/src/utils/compute-actions/usage.ts +++ b/packages/modules/promotion/src/utils/compute-actions/usage.ts @@ -1,20 +1,10 @@ import { BigNumberInput, CampaignBudgetExceededAction, - ComputeActions, PromotionDTO, } from "@medusajs/types" import { CampaignBudgetType, ComputedActions, MathBN } 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: BigNumberInput