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 ThamirandOli Juhl authored and GitHub committed 2024-09-05 10:43:29 +02:00
1 parent 2a055b71ef
commit 5d7179b7d0
8 files changed
+421 -56

No files matched your search

@@ -1812,6 +1812,7 @@ medusaIntegrationTestRunner({
let paymentSession let paymentSession
let stockLocation let stockLocation
let inventoryItem let inventoryItem
let promotion
beforeEach(async () => { beforeEach(async () => {
await setupTaxStructure(taxModule) 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( await api.post(
`/admin/stock-locations/${stockLocation.id}/fulfillment-providers`, `/admin/stock-locations/${stockLocation.id}/fulfillment-providers`,
{ add: ["manual_test-provider"] }, { add: ["manual_test-provider"] },
@@ -2015,16 +2053,16 @@ medusaIntegrationTestRunner({
type: "order", type: "order",
order: expect.objectContaining({ order: expect.objectContaining({
id: expect.any(String), id: expect.any(String),
total: 106, total: 94.764,
subtotal: 100, subtotal: 100,
tax_total: 6, tax_total: 5.364,
discount_total: 0, discount_total: 10.6,
discount_tax_total: 0, discount_tax_total: 0.636,
original_total: 106, original_total: 95.4,
original_tax_total: 6, original_tax_total: 6,
item_total: 106, item_total: 94.764,
item_subtotal: 100, item_subtotal: 100,
item_tax_total: 6, item_tax_total: 5.364,
original_item_total: 106, original_item_total: 106,
original_item_subtotal: 100, original_item_subtotal: 100,
original_item_tax_total: 6, original_item_tax_total: 6,
@@ -2040,17 +2078,27 @@ medusaIntegrationTestRunner({
product_id: product.id, product_id: product.id,
unit_price: 100, unit_price: 100,
quantity: 1, quantity: 1,
tax_total: 6, tax_total: 5.364,
total: 94.764,
subtotal: 100, subtotal: 100,
total: 106,
original_total: 106, original_total: 106,
discount_total: 0, discount_total: 10.6,
discount_tax_total: 0.636,
original_tax_total: 6,
tax_lines: [ tax_lines: [
expect.objectContaining({ expect.objectContaining({
rate: 6, rate: 6,
}), }),
], ],
adjustments: [], adjustments: [
expect.objectContaining({
amount: 10,
promotion_id: promotion.id,
code: promotion.code,
subtotal: 10,
total: 10.6,
}),
],
}), }),
], ],
shipping_address: expect.objectContaining({ 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 reservation = await api.get(`/admin/reservations`, adminHeaders)
const reservationItem = reservation.data.reservations[0] const reservationItem = reservation.data.reservations[0]
@@ -2086,7 +2140,7 @@ medusaIntegrationTestRunner({
payment_collections: [ payment_collections: [
expect.objectContaining({ expect.objectContaining({
currency_code: "usd", currency_code: "usd",
amount: 106, amount: 94.764,
status: "authorized", status: "authorized",
}), }),
], ],
@@ -1,11 +1,15 @@
import { OrderDTO } from "@medusajs/types" import {
CartWorkflowDTO,
OrderDTO,
UsageComputedActions,
} from "@medusajs/types"
import { Modules, OrderStatus, OrderWorkflowEvents } from "@medusajs/utils" import { Modules, OrderStatus, OrderWorkflowEvents } from "@medusajs/utils"
import { import {
WorkflowData,
WorkflowResponse,
createWorkflow, createWorkflow,
parallelize, parallelize,
transform, transform,
WorkflowData,
WorkflowResponse,
} from "@medusajs/workflows-sdk" } from "@medusajs/workflows-sdk"
import { import {
createRemoteLinkStep, createRemoteLinkStep,
@@ -14,6 +18,7 @@ import {
} from "../../common" } from "../../common"
import { createOrdersStep } from "../../order/steps/create-orders" import { createOrdersStep } from "../../order/steps/create-orders"
import { authorizePaymentSessionStep } from "../../payment/steps/authorize-payment-session" import { authorizePaymentSessionStep } from "../../payment/steps/authorize-payment-session"
import { registerUsageStep } from "../../promotion/steps/register-usage"
import { updateCartsStep, validateCartPaymentsStep } from "../steps" import { updateCartsStep, validateCartPaymentsStep } from "../steps"
import { reserveInventoryStep } from "../steps/reserve-inventory" import { reserveInventoryStep } from "../steps/reserve-inventory"
import { validateCartStep } from "../steps/validate-cart" import { validateCartStep } from "../steps/validate-cart"
@@ -61,7 +66,8 @@ export const completeCartWorkflow = createWorkflow(
(data) => { (data) => {
const allItems: any[] = [] const allItems: any[] = []
const allVariants: any[] = [] const allVariants: any[] = []
data.cart.items.forEach((item) => {
data.cart?.items?.forEach((item) => {
allItems.push({ allItems.push({
id: item.id, id: item.id,
variant_id: item.variant_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) return new WorkflowResponse(order)
} }
) )
@@ -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)
}
)
@@ -13,9 +13,17 @@ export type ComputeActions =
/** /**
* These computed action types can affect a campaign's budget. * These computed action types can affect a campaign's budget.
*/ */
export type UsageComputedActions = export type UsageComputedActions = {
| AddShippingMethodAdjustment /**
| AddItemAdjustmentAction * 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 * This action indicates that the promotions within a campaign can no longer be used
+23 -6
View File
@@ -15,6 +15,7 @@ import {
PromotionRuleDTO, PromotionRuleDTO,
UpdatePromotionDTO, UpdatePromotionDTO,
UpdatePromotionRuleDTO, UpdatePromotionRuleDTO,
UsageComputedActions,
} from "./common" } from "./common"
import { import {
AddPromotionsToCampaignDTO, 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 * It adjusts the `used` property of a `CampaignBudget` to account for the adjustment amounts in the specified associated
* computed actions. * 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. * @returns {Promise<void>} Resolves when the campaign budgets have been adjusted successfully.
* *
* @example * @example
* await promotionModuleService.registerUsage([ * await promotionModuleService.registerUsage([
* { * {
* action: "addItemAdjustment",
* item_id: "cali_123",
* amount: 50, * amount: 50,
* code: "50OFF", * code: "50OFF",
* }, * },
* { * {
* action: "addShippingMethodAdjustment",
* shipping_method_id: "casm_123",
* amount: 5000, * amount: 5000,
* code: "FREESHIPPING", * 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 * This method provides the actions to perform on a cart based on the specified promotions
@@ -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)
})
})
})
},
})
@@ -123,33 +123,39 @@ export default class PromotionModuleService
@InjectManager("baseRepository_") @InjectManager("baseRepository_")
async registerUsage( async registerUsage(
computedActions: PromotionTypes.UsageComputedActions[], computedActions: PromotionTypes.UsageComputedActions[],
@MedusaContext()
@MedusaContext() sharedContext: Context = {} @MedusaContext() sharedContext: Context = {}
): Promise<void> { ): Promise<void> {
const promotionCodes = computedActions const promotionCodes = computedActions
.map((computedAction) => computedAction.code) .map((computedAction) => computedAction.code)
.filter(Boolean) .filter(Boolean)
const promotionCodeCampaignBudgetMap = new Map< const campaignBudgetMap = new Map<string, UpdateCampaignBudgetDTO>()
string,
UpdateCampaignBudgetDTO
>()
const promotionCodeUsageMap = new Map<string, boolean>() const promotionCodeUsageMap = new Map<string, boolean>()
const existingPromotions = await this.listPromotions( const existingPromotions = await this.listPromotions(
{ code: promotionCodes }, { code: promotionCodes },
{ relations: ["application_method", "campaign", "campaign.budget"] }, {
relations: ["campaign", "campaign.budget"],
take: null,
},
sharedContext 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>( const existingPromotionsMap = new Map<string, PromotionTypes.PromotionDTO>(
existingPromotions.map((promotion) => [promotion.code!, promotion]) existingPromotions.map((promotion) => [promotion.code!, promotion])
) )
for (let computedAction of computedActions) { for (let computedAction of computedActions) {
if (!ComputeActionUtils.canRegisterUsage(computedAction)) {
continue
}
const promotion = existingPromotionsMap.get(computedAction.code) const promotion = existingPromotionsMap.get(computedAction.code)
if (!promotion) { if (!promotion) {
@@ -163,9 +169,11 @@ export default class PromotionModuleService
} }
if (campaignBudget.type === CampaignBudgetType.SPEND) { if (campaignBudget.type === CampaignBudgetType.SPEND) {
const campaignBudgetData = promotionCodeCampaignBudgetMap.get( const campaignBudgetData = campaignBudgetMap.get(campaignBudget.id)
campaignBudget.id
) || { id: campaignBudget.id, used: campaignBudget.used ?? 0 } if (!campaignBudgetData) {
continue
}
campaignBudgetData.used = MathBN.add( campaignBudgetData.used = MathBN.add(
campaignBudgetData.used ?? 0, campaignBudgetData.used ?? 0,
@@ -179,10 +187,7 @@ export default class PromotionModuleService
continue continue
} }
promotionCodeCampaignBudgetMap.set( campaignBudgetMap.set(campaignBudget.id, campaignBudgetData)
campaignBudget.id,
campaignBudgetData
)
} }
if (campaignBudget.type === CampaignBudgetType.USAGE) { if (campaignBudget.type === CampaignBudgetType.USAGE) {
@@ -205,17 +210,105 @@ export default class PromotionModuleService
continue continue
} }
promotionCodeCampaignBudgetMap.set( campaignBudgetMap.set(campaignBudget.id, campaignBudgetData)
campaignBudget.id,
campaignBudgetData
)
promotionCodeUsageMap.set(promotion.code!, true) promotionCodeUsageMap.set(promotion.code!, true)
} }
const campaignBudgetsData: UpdateCampaignBudgetDTO[] = [] 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) campaignBudgetsData.push(campaignBudgetData)
} }
@@ -1,20 +1,10 @@
import { import {
BigNumberInput, BigNumberInput,
CampaignBudgetExceededAction, CampaignBudgetExceededAction,
ComputeActions,
PromotionDTO, PromotionDTO,
} from "@medusajs/types" } from "@medusajs/types"
import { CampaignBudgetType, ComputedActions, MathBN } from "@medusajs/utils" 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( export function computeActionForBudgetExceeded(
promotion: PromotionDTO, promotion: PromotionDTO,
amount: BigNumberInput amount: BigNumberInput