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>
This commit is contained in:
Riqwan Thamir
2024-09-05 10:43:29 +02:00
committed by GitHub
parent 2a055b71ef
commit 5d7179b7d0
8 changed files with 421 additions and 56 deletions

View File

@@ -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",
}),
],

View File

@@ -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)
}
)

View File

@@ -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<IPromotionModuleService>(
ModuleRegistrationName.PROMOTION
)
await promotionModule.registerUsage(data)
return new StepResponse(null, data)
},
async (revertData, { container }) => {
if (!revertData?.length) {
return
}
const promotionModule = container.resolve<IPromotionModuleService>(
ModuleRegistrationName.PROMOTION
)
await promotionModule.revertUsage(revertData)
}
)

View File

@@ -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

View File

@@ -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<void>} 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<void>
registerUsage(computedActions: UsageComputedActions[]): Promise<void>
/**
* 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<void>} 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<void>
/**
* This method provides the actions to perform on a cart based on the specified promotions

View File

@@ -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<IPromotionModuleService>) => {
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)
})
})
})
},
})

View File

@@ -123,33 +123,39 @@ export default class PromotionModuleService
@InjectManager("baseRepository_")
async registerUsage(
computedActions: PromotionTypes.UsageComputedActions[],
@MedusaContext()
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
const promotionCodes = computedActions
.map((computedAction) => computedAction.code)
.filter(Boolean)
const promotionCodeCampaignBudgetMap = new Map<
string,
UpdateCampaignBudgetDTO
>()
const campaignBudgetMap = new Map<string, UpdateCampaignBudgetDTO>()
const promotionCodeUsageMap = new Map<string, boolean>()
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<string, PromotionTypes.PromotionDTO>(
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<void> {
const promotionCodeUsageMap = new Map<string, boolean>()
const campaignBudgetMap = new Map<string, UpdateCampaignBudgetDTO>()
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<string, PromotionTypes.PromotionDTO>(
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)
}

View File

@@ -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