Files
medusa-store/packages/modules/promotion/src/services/promotion-module.ts

1668 lines
47 KiB
TypeScript

import {
CampaignBudgetTypeValues,
Context,
DAL,
FilterablePromotionProps,
FindConfig,
InferEntityType,
InternalModuleDeclaration,
ModuleJoinerConfig,
ModulesSdkTypes,
PromotionDTO,
PromotionTypes,
} from "@medusajs/framework/types"
import {
ApplicationMethodAllocation,
ApplicationMethodTargetType,
arrayDifference,
CampaignBudgetType,
ComputedActions,
deduplicate,
InjectManager,
InjectTransactionManager,
isDefined,
isPresent,
isString,
MathBN,
MedusaContext,
MedusaError,
MedusaService,
PromotionStatus,
PromotionType,
toMikroORMEntity,
transformPropertiesToBigNumber,
} from "@medusajs/framework/utils"
import {
ApplicationMethod,
Campaign,
CampaignBudget,
Promotion,
PromotionRule,
PromotionRuleValue,
} from "@models"
import {
ApplicationMethodRuleTypes,
CreateApplicationMethodDTO,
CreateCampaignBudgetDTO,
CreateCampaignDTO,
CreatePromotionDTO,
CreatePromotionRuleDTO,
UpdateApplicationMethodDTO,
UpdateCampaignBudgetDTO,
UpdateCampaignDTO,
UpdatePromotionDTO,
} from "@types"
import {
allowedAllocationForQuantity,
areRulesValidForContext,
ComputeActionUtils,
validateApplicationMethodAttributes,
validatePromotionRuleAttributes,
} from "@utils"
import { joinerConfig } from "../joiner-config"
import { CreatePromotionRuleValueDTO } from "../types/promotion-rule-value"
type InjectedDependencies = {
baseRepository: DAL.RepositoryService
promotionService: ModulesSdkTypes.IMedusaInternalService<any>
applicationMethodService: ModulesSdkTypes.IMedusaInternalService<any>
promotionRuleService: ModulesSdkTypes.IMedusaInternalService<any>
promotionRuleValueService: ModulesSdkTypes.IMedusaInternalService<any>
campaignService: ModulesSdkTypes.IMedusaInternalService<any>
campaignBudgetService: ModulesSdkTypes.IMedusaInternalService<any>
}
export default class PromotionModuleService
extends MedusaService<{
Promotion: { dto: PromotionTypes.PromotionDTO }
ApplicationMethod: { dto: PromotionTypes.ApplicationMethodDTO }
Campaign: { dto: PromotionTypes.CampaignDTO }
CampaignBudget: { dto: PromotionTypes.CampaignBudgetDTO }
PromotionRule: { dto: PromotionTypes.PromotionRuleDTO }
PromotionRuleValue: { dto: PromotionTypes.PromotionRuleValueDTO }
}>({
Promotion,
ApplicationMethod,
Campaign,
CampaignBudget,
PromotionRule,
PromotionRuleValue,
})
implements PromotionTypes.IPromotionModuleService
{
protected baseRepository_: DAL.RepositoryService
protected promotionService_: ModulesSdkTypes.IMedusaInternalService<
InferEntityType<typeof Promotion>
>
protected applicationMethodService_: ModulesSdkTypes.IMedusaInternalService<
InferEntityType<typeof ApplicationMethod>
>
protected promotionRuleService_: ModulesSdkTypes.IMedusaInternalService<
InferEntityType<typeof PromotionRule>
>
protected promotionRuleValueService_: ModulesSdkTypes.IMedusaInternalService<
InferEntityType<typeof PromotionRuleValue>
>
protected campaignService_: ModulesSdkTypes.IMedusaInternalService<
InferEntityType<typeof Campaign>
>
protected campaignBudgetService_: ModulesSdkTypes.IMedusaInternalService<
InferEntityType<typeof CampaignBudget>
>
constructor(
{
baseRepository,
promotionService,
applicationMethodService,
promotionRuleService,
promotionRuleValueService,
campaignService,
campaignBudgetService,
}: InjectedDependencies,
protected readonly moduleDeclaration: InternalModuleDeclaration
) {
// @ts-ignore
super(...arguments)
this.baseRepository_ = baseRepository
this.promotionService_ = promotionService
this.applicationMethodService_ = applicationMethodService
this.promotionRuleService_ = promotionRuleService
this.promotionRuleValueService_ = promotionRuleValueService
this.campaignService_ = campaignService
this.campaignBudgetService_ = campaignBudgetService
}
__joinerConfig(): ModuleJoinerConfig {
return joinerConfig
}
@InjectManager()
listActivePromotions(
filters?: FilterablePromotionProps,
config?: FindConfig<PromotionDTO>,
sharedContext?: Context
): Promise<PromotionDTO[]> {
// Ensure we share the same now date across all filters
const now = new Date()
const activeFilters = {
status: PromotionStatus.ACTIVE,
$or: [
{
campaign_id: null,
...filters,
},
{
...filters,
campaign: {
...filters?.campaign,
$and: [
{
$or: [{ starts_at: null }, { starts_at: { $lte: now } }],
},
{
$or: [{ ends_at: null }, { ends_at: { $gt: now } }],
},
],
},
},
],
}
return this.listPromotions(activeFilters, config, sharedContext)
}
@InjectTransactionManager()
async registerUsage(
computedActions: PromotionTypes.UsageComputedActions[],
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
const promotionCodes = computedActions
.map((computedAction) => computedAction.code)
.filter(Boolean)
const campaignBudgetMap = new Map<string, UpdateCampaignBudgetDTO>()
const promotionCodeUsageMap = new Map<string, boolean>()
const existingPromotions = await this.listActivePromotions(
{ code: promotionCodes },
{ relations: ["campaign", "campaign.budget"] },
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
}
// Calculate the new budget value
const newUsedValue = MathBN.add(
campaignBudgetData.used ?? 0,
computedAction.amount
)
if (
campaignBudget.limit &&
MathBN.gt(newUsedValue, campaignBudget.limit)
) {
continue
} else {
campaignBudgetData.used = newUsedValue
}
campaignBudgetMap.set(campaignBudget.id, campaignBudgetData)
}
if (campaignBudget.type === CampaignBudgetType.USAGE) {
const promotionAlreadyUsed =
promotionCodeUsageMap.get(promotion.code!) || false
if (promotionAlreadyUsed) {
continue
}
const newUsedValue = MathBN.add(campaignBudget.used ?? 0, 1)
// Check if it exceeds the limit and cap it if necessary
if (
campaignBudget.limit &&
MathBN.gt(newUsedValue, campaignBudget.limit)
) {
campaignBudgetMap.set(campaignBudget.id, {
id: campaignBudget.id,
used: campaignBudget.limit,
})
} else {
campaignBudgetMap.set(campaignBudget.id, {
id: campaignBudget.id,
used: newUsedValue,
})
}
promotionCodeUsageMap.set(promotion.code!, true)
}
}
if (campaignBudgetMap.size > 0) {
const campaignBudgetsData: UpdateCampaignBudgetDTO[] = []
for (const [_, campaignBudgetData] of campaignBudgetMap) {
campaignBudgetsData.push(campaignBudgetData)
}
await this.campaignBudgetService_.update(
campaignBudgetsData,
sharedContext
)
}
}
@InjectTransactionManager()
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.listActivePromotions(
{
code: computedActions
.map((computedAction) => computedAction.code)
.filter(Boolean),
},
{ relations: ["campaign", "campaign.budget"] },
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
}
// Calculate new used value and ensure it doesn't go below 0
const newUsedValue = MathBN.sub(
campaignBudgetData.used ?? 0,
computedAction.amount
)
campaignBudgetData.used = MathBN.lt(newUsedValue, 0) ? 0 : newUsedValue
campaignBudgetMap.set(campaignBudget.id, campaignBudgetData)
}
if (campaignBudget.type === CampaignBudgetType.USAGE) {
const promotionAlreadyUsed =
promotionCodeUsageMap.get(promotion.code!) || false
if (promotionAlreadyUsed) {
continue
}
// Calculate new used value and ensure it doesn't go below 0
const newUsedValue = MathBN.sub(campaignBudget.used ?? 0, 1)
const usedValue = MathBN.lt(newUsedValue, 0) ? 0 : newUsedValue
campaignBudgetMap.set(campaignBudget.id, {
id: campaignBudget.id,
used: usedValue,
})
promotionCodeUsageMap.set(promotion.code!, true)
}
}
if (campaignBudgetMap.size > 0) {
const campaignBudgetsData: UpdateCampaignBudgetDTO[] = []
for (const [_, campaignBudgetData] of campaignBudgetMap) {
campaignBudgetsData.push(campaignBudgetData)
}
await this.campaignBudgetService_.update(
campaignBudgetsData,
sharedContext
)
}
}
@InjectManager()
async computeActions(
promotionCodes: string[],
applicationContext: PromotionTypes.ComputeActionContext,
options: PromotionTypes.ComputeActionOptions = {},
@MedusaContext() sharedContext: Context = {}
): Promise<PromotionTypes.ComputeActions[]> {
const { prevent_auto_promotions: preventAutoPromotions } = options
const computedActions: PromotionTypes.ComputeActions[] = []
const { items = [], shipping_methods: shippingMethods = [] } =
applicationContext
const codeAdjustmentMap = new Map<
string,
{
items: PromotionTypes.ComputeActionAdjustmentLine[]
shipping: PromotionTypes.ComputeActionAdjustmentLine[]
}
>()
// Pre-process items and shipping methods to build adjustment map efficiently
for (const item of items) {
if (!item.adjustments?.length) continue
for (const adjustment of item.adjustments) {
if (!isString(adjustment.code)) continue
if (!codeAdjustmentMap.has(adjustment.code)) {
codeAdjustmentMap.set(adjustment.code, { items: [], shipping: [] })
}
codeAdjustmentMap.get(adjustment.code)!.items.push(adjustment)
}
}
for (const shippingMethod of shippingMethods) {
if (!shippingMethod.adjustments?.length) continue
for (const adjustment of shippingMethod.adjustments) {
if (!isString(adjustment.code)) continue
if (!codeAdjustmentMap.has(adjustment.code)) {
codeAdjustmentMap.set(adjustment.code, { items: [], shipping: [] })
}
codeAdjustmentMap.get(adjustment.code)!.shipping.push(adjustment)
}
}
const appliedCodes = Array.from(codeAdjustmentMap.keys())
const methodIdPromoValueMap = new Map<string, number>()
const automaticPromotions = preventAutoPromotions
? []
: await this.listActivePromotions(
{ is_automatic: true },
{ select: ["code"] },
sharedContext
)
const automaticPromotionCodes = automaticPromotions.map((p) => p.code!)
const promotionCodesToApply = [
...promotionCodes,
...automaticPromotionCodes,
...appliedCodes,
]
const uniquePromotionCodes = Array.from(new Set(promotionCodesToApply))
const promotions = await this.listActivePromotions(
{ code: uniquePromotionCodes },
{
take: null,
order: { application_method: { value: "DESC" } },
relations: [
"application_method",
"application_method.target_rules",
"application_method.target_rules.values",
"application_method.buy_rules",
"application_method.buy_rules.values",
"rules",
"rules.values",
"campaign",
"campaign.budget",
],
},
sharedContext
)
const existingPromotionsMap = new Map<string, PromotionTypes.PromotionDTO>(
promotions.map((promotion) => [promotion.code!, promotion])
)
for (const [code, adjustments] of codeAdjustmentMap.entries()) {
for (const adjustment of adjustments.items) {
computedActions.push({
action: ComputedActions.REMOVE_ITEM_ADJUSTMENT,
adjustment_id: adjustment.id,
code,
})
}
for (const adjustment of adjustments.shipping) {
computedActions.push({
action: ComputedActions.REMOVE_SHIPPING_METHOD_ADJUSTMENT,
adjustment_id: adjustment.id,
code,
})
}
}
const sortedPromotionsToApply = promotions
.filter(
(p) =>
promotionCodes.includes(p.code!) ||
automaticPromotionCodes.includes(p.code!)
)
.sort(ComputeActionUtils.sortByBuyGetType)
const eligibleBuyItemMap = new Map<
string,
ComputeActionUtils.EligibleItem[]
>()
const eligibleTargetItemMap = new Map<
string,
ComputeActionUtils.EligibleItem[]
>()
for (const promotionToApply of sortedPromotionsToApply) {
const promotion = existingPromotionsMap.get(promotionToApply.code!)!
const {
application_method: applicationMethod,
rules: promotionRules = [],
} = promotion
if (!applicationMethod) {
continue
}
const isCurrencyCodeValid =
!isPresent(applicationMethod.currency_code) ||
applicationContext.currency_code === applicationMethod.currency_code
const isPromotionApplicable = areRulesValidForContext(
promotionRules,
applicationContext,
ApplicationMethodTargetType.ORDER
)
if (!isPromotionApplicable || !isCurrencyCodeValid) {
continue
}
if (promotion.type === PromotionType.BUYGET) {
const computedActionsForItems =
ComputeActionUtils.getComputedActionsForBuyGet(
promotion,
applicationContext[ApplicationMethodTargetType.ITEMS]!,
methodIdPromoValueMap,
eligibleBuyItemMap,
eligibleTargetItemMap
)
computedActions.push(...computedActionsForItems)
} else if (promotion.type === PromotionType.STANDARD) {
const isTargetOrder =
applicationMethod.target_type === ApplicationMethodTargetType.ORDER
const isTargetItems =
applicationMethod.target_type === ApplicationMethodTargetType.ITEMS
const isTargetShipping =
applicationMethod.target_type ===
ApplicationMethodTargetType.SHIPPING_METHODS
const allocationOverride = isTargetOrder
? ApplicationMethodAllocation.ACROSS
: undefined
if (isTargetOrder || isTargetItems) {
const computedActionsForItems =
ComputeActionUtils.getComputedActionsForItems(
promotion,
applicationContext[ApplicationMethodTargetType.ITEMS],
methodIdPromoValueMap,
allocationOverride
)
computedActions.push(...computedActionsForItems)
}
if (isTargetShipping) {
const computedActionsForShippingMethods =
ComputeActionUtils.getComputedActionsForShippingMethods(
promotion,
applicationContext[ApplicationMethodTargetType.SHIPPING_METHODS],
methodIdPromoValueMap
)
computedActions.push(...computedActionsForShippingMethods)
}
}
}
transformPropertiesToBigNumber(computedActions, { include: ["amount"] })
return computedActions
}
// @ts-expect-error
async createPromotions(
data: PromotionTypes.CreatePromotionDTO,
sharedContext?: Context
): Promise<PromotionTypes.PromotionDTO>
// @ts-expect-error
async createPromotions(
data: PromotionTypes.CreatePromotionDTO[],
sharedContext?: Context
): Promise<PromotionTypes.PromotionDTO[]>
@InjectManager()
// @ts-expect-error
async createPromotions(
data:
| PromotionTypes.CreatePromotionDTO
| PromotionTypes.CreatePromotionDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<PromotionTypes.PromotionDTO | PromotionTypes.PromotionDTO[]> {
const input = Array.isArray(data) ? data : [data]
const createdPromotions = await this.createPromotions_(input, sharedContext)
const promotions = await this.listPromotions(
{ id: createdPromotions.map((p) => p!.id) },
{
relations: [
"application_method",
"application_method.target_rules",
"application_method.target_rules.values",
"application_method.buy_rules",
"application_method.buy_rules.values",
"rules",
"rules.values",
"campaign",
"campaign.budget",
],
},
sharedContext
)
return Array.isArray(data) ? promotions : promotions[0]
}
@InjectTransactionManager()
protected async createPromotions_(
data: PromotionTypes.CreatePromotionDTO[],
@MedusaContext() sharedContext: Context = {}
) {
const promotionsData: CreatePromotionDTO[] = []
const applicationMethodsData: CreateApplicationMethodDTO[] = []
const campaignsData: CreateCampaignDTO[] = []
const campaignIds = data
.filter((d) => d.campaign_id)
.map((d) => d.campaign_id)
.filter((id): id is string => isString(id))
const existingCampaigns =
campaignIds.length > 0
? await this.campaignService_.list(
{ id: campaignIds },
{ relations: ["budget"] },
sharedContext
)
: []
const promotionCodeApplicationMethodDataMap = new Map<
string,
PromotionTypes.CreateApplicationMethodDTO
>()
const promotionCodeRulesDataMap = new Map<
string,
PromotionTypes.CreatePromotionRuleDTO[]
>()
const methodTargetRulesMap = new Map<
string,
PromotionTypes.CreatePromotionRuleDTO[]
>()
const methodBuyRulesMap = new Map<
string,
PromotionTypes.CreatePromotionRuleDTO[]
>()
const promotionCodeCampaignMap = new Map<
string,
PromotionTypes.CreateCampaignDTO
>()
for (const {
application_method: applicationMethodData,
rules: rulesData,
campaign: campaignData,
campaign_id: campaignId,
...promotionData
} of data) {
promotionCodeApplicationMethodDataMap.set(
promotionData.code,
applicationMethodData
)
if (rulesData) {
promotionCodeRulesDataMap.set(promotionData.code, rulesData)
}
if (campaignData && campaignId) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Provide either the 'campaign' or 'campaign_id' parameter; both cannot be used simultaneously.`
)
}
if (!campaignData && !campaignId) {
promotionsData.push({ ...promotionData })
continue
}
const existingCampaign = existingCampaigns.find(
(c) => c.id === campaignId
)
if (campaignId && !existingCampaign) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Could not find campaign with id - ${campaignId}`
)
}
const campaignCurrency =
campaignData?.budget?.currency_code ||
existingCampaigns.find((c) => c.id === campaignId)?.budget
?.currency_code
if (
campaignData?.budget?.type === CampaignBudgetType.SPEND &&
campaignCurrency !== applicationMethodData?.currency_code
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Currency between promotion and campaigns should match`
)
}
if (campaignData) {
promotionCodeCampaignMap.set(promotionData.code, campaignData)
}
promotionsData.push({
...promotionData,
campaign_id: campaignId,
})
}
const createdPromotions = await this.promotionService_.create(
promotionsData,
sharedContext
)
for (const promotion of createdPromotions) {
const applMethodData = promotionCodeApplicationMethodDataMap.get(
promotion.code
)
const campaignData = promotionCodeCampaignMap.get(promotion.code)
if (campaignData) {
campaignsData.push({
...campaignData,
promotions: [promotion],
})
}
if (applMethodData) {
const {
target_rules: targetRulesData = [],
buy_rules: buyRulesData = [],
...applicationMethodWithoutRules
} = applMethodData
const applicationMethodData = {
...applicationMethodWithoutRules,
promotion,
}
if (
applicationMethodData.target_type ===
ApplicationMethodTargetType.ORDER &&
targetRulesData.length
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Target rules for application method with target type (${ApplicationMethodTargetType.ORDER}) is not allowed`
)
}
if (promotion.type === PromotionType.BUYGET && !buyRulesData.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Buy rules are required for ${PromotionType.BUYGET} promotion type`
)
}
if (
promotion.type === PromotionType.BUYGET &&
!targetRulesData.length
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Target rules are required for ${PromotionType.BUYGET} promotion type`
)
}
validateApplicationMethodAttributes(applicationMethodData, promotion)
applicationMethodsData.push(applicationMethodData)
if (targetRulesData.length) {
methodTargetRulesMap.set(promotion.id, targetRulesData)
}
if (buyRulesData.length) {
methodBuyRulesMap.set(promotion.id, buyRulesData)
}
}
if (promotionCodeRulesDataMap.has(promotion.code)) {
await this.createPromotionRulesAndValues_(
promotionCodeRulesDataMap.get(promotion.code) || [],
"promotions",
promotion,
sharedContext
)
}
}
const createdApplicationMethods =
applicationMethodsData.length > 0
? await this.applicationMethodService_.create(
applicationMethodsData,
sharedContext
)
: []
const createdCampaigns =
campaignsData.length > 0
? await this.createCampaigns(campaignsData, sharedContext)
: []
for (const campaignData of campaignsData) {
const promotions = campaignData.promotions
const campaign = createdCampaigns.find(
(c) => c.campaign_identifier === campaignData.campaign_identifier
)
if (campaign && promotions && promotions.length) {
await this.addPromotionsToCampaign(
{ id: campaign.id, promotion_ids: promotions.map((p) => p.id) },
sharedContext
)
}
}
for (const applicationMethod of createdApplicationMethods) {
const targetRules = methodTargetRulesMap.get(
applicationMethod.promotion.id
)
if (targetRules && targetRules.length > 0) {
await this.createPromotionRulesAndValues_(
targetRules,
"method_target_rules",
applicationMethod,
sharedContext
)
}
const buyRules = methodBuyRulesMap.get(applicationMethod.promotion.id)
if (buyRules && buyRules.length > 0) {
await this.createPromotionRulesAndValues_(
buyRules,
"method_buy_rules",
applicationMethod,
sharedContext
)
}
}
return createdPromotions
}
// @ts-expect-error
async updatePromotions(
data: PromotionTypes.UpdatePromotionDTO,
sharedContext?: Context
): Promise<PromotionTypes.PromotionDTO>
// @ts-expect-error
async updatePromotions(
data: PromotionTypes.UpdatePromotionDTO[],
sharedContext?: Context
): Promise<PromotionTypes.PromotionDTO[]>
@InjectManager()
// @ts-expect-error
async updatePromotions(
data:
| PromotionTypes.UpdatePromotionDTO
| PromotionTypes.UpdatePromotionDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<PromotionTypes.PromotionDTO | PromotionTypes.PromotionDTO[]> {
const input = Array.isArray(data) ? data : [data]
const updatedPromotions = await this.updatePromotions_(input, sharedContext)
const promotions = await this.listPromotions(
{ id: updatedPromotions.map((p) => p!.id) },
{
relations: [
"application_method",
"application_method.target_rules",
"application_method.target_rules.values",
"rules",
"rules.values",
"campaign",
"campaign.budget",
],
},
sharedContext
)
return Array.isArray(data) ? promotions : promotions[0]
}
@InjectTransactionManager()
protected async updatePromotions_(
data: PromotionTypes.UpdatePromotionDTO[],
@MedusaContext() sharedContext: Context = {}
) {
const promotionIds = data.map((d) => d.id)
const existingPromotions = await this.promotionService_.list(
{ id: promotionIds },
{ relations: ["application_method"] }
)
const existingCampaigns = await this.campaignService_.list(
{ id: data.map((d) => d.campaign_id).filter((d) => isPresent(d)) },
{ relations: ["budget"] }
)
const existingPromotionsMap = new Map<
string,
InferEntityType<typeof Promotion>
>(existingPromotions.map((promotion) => [promotion.id, promotion]))
const promotionsData: UpdatePromotionDTO[] = []
const applicationMethodsData: UpdateApplicationMethodDTO[] = []
for (const {
application_method: applicationMethodData,
campaign_id: campaignId,
...promotionData
} of data) {
const existingCampaign = existingCampaigns.find(
(c) => c.id === campaignId
)
const existingPromotion = existingPromotionsMap.get(promotionData.id)!
const existingApplicationMethod = existingPromotion?.application_method
const promotionCurrencyCode =
existingApplicationMethod?.currency_code ||
applicationMethodData?.currency_code
if (campaignId && !existingCampaign) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Could not find campaign with id ${campaignId}`
)
}
if (
campaignId &&
existingCampaign?.budget?.type === CampaignBudgetType.SPEND &&
existingCampaign.budget.currency_code !== promotionCurrencyCode
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Currency code doesn't match for campaign (${campaignId}) and promotion (${existingPromotion.id})`
)
}
if (isDefined(campaignId)) {
promotionsData.push({ ...promotionData, campaign_id: campaignId })
} else {
promotionsData.push(promotionData)
}
if (!applicationMethodData || !existingApplicationMethod) {
continue
}
if (
applicationMethodData.allocation &&
!allowedAllocationForQuantity.includes(applicationMethodData.allocation)
) {
applicationMethodData.max_quantity = null
existingApplicationMethod.max_quantity = null
}
validateApplicationMethodAttributes(
applicationMethodData,
existingPromotion
)
applicationMethodsData.push({
...applicationMethodData,
id: existingApplicationMethod.id,
})
}
const updatedPromotions = this.promotionService_.update(
promotionsData,
sharedContext
)
if (applicationMethodsData.length) {
await this.applicationMethodService_.update(
applicationMethodsData,
sharedContext
)
}
return updatedPromotions
}
@InjectManager()
// @ts-ignore
async updatePromotionRules(
data: PromotionTypes.UpdatePromotionRuleDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<PromotionTypes.PromotionRuleDTO[]> {
const updatedPromotionRules = await this.updatePromotionRules_(
data,
sharedContext
)
return await this.listPromotionRules(
{ id: updatedPromotionRules.map((r) => r.id) },
{ relations: ["values"] },
sharedContext
)
}
@InjectTransactionManager()
protected async updatePromotionRules_(
data: PromotionTypes.UpdatePromotionRuleDTO[],
@MedusaContext() sharedContext: Context = {}
) {
const promotionRuleIds = data.map((d) => d.id)
const promotionRules = await this.listPromotionRules(
{ id: promotionRuleIds },
{ relations: ["values"] },
sharedContext
)
const invalidRuleId = arrayDifference(
deduplicate(promotionRuleIds),
promotionRules.map((pr) => pr.id)
)
if (invalidRuleId.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Promotion rules with id - ${invalidRuleId.join(", ")} not found`
)
}
const promotionRulesMap = new Map<string, PromotionTypes.PromotionRuleDTO>(
promotionRules.map((pr) => [pr.id, pr])
)
const rulesToUpdate: PromotionTypes.UpdatePromotionRuleDTO[] = []
const ruleValueIdsToDelete: string[] = []
const ruleValuesToCreate: CreatePromotionRuleValueDTO[] = []
for (const promotionRuleData of data) {
const { values, ...rest } = promotionRuleData
const normalizedValues = Array.isArray(values) ? values : [values]
rulesToUpdate.push(rest)
if (isDefined(values)) {
const promotionRule = promotionRulesMap.get(promotionRuleData.id)!
ruleValueIdsToDelete.push(...promotionRule.values.map((v) => v.id))
ruleValuesToCreate.push(
...normalizedValues.map((value) => ({
value,
promotion_rule: promotionRule,
}))
)
}
}
const [updatedRules] = await Promise.all([
this.promotionRuleService_.update(rulesToUpdate, sharedContext),
this.promotionRuleValueService_.delete(
ruleValueIdsToDelete,
sharedContext
),
this.promotionRuleValueService_.create(ruleValuesToCreate, sharedContext),
])
return updatedRules
}
@InjectManager()
async addPromotionRules(
promotionId: string,
rulesData: PromotionTypes.CreatePromotionRuleDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<PromotionTypes.PromotionRuleDTO[]> {
const promotion = await this.promotionService_.retrieve(promotionId)
const createdPromotionRules = await this.createPromotionRulesAndValues_(
rulesData,
"promotions",
promotion,
sharedContext
)
return this.listPromotionRules(
{ id: createdPromotionRules.map((r) => r.id) },
{ relations: ["values"] },
sharedContext
)
}
@InjectManager()
async addPromotionTargetRules(
promotionId: string,
rulesData: PromotionTypes.CreatePromotionRuleDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<PromotionTypes.PromotionRuleDTO[]> {
const promotion = await this.promotionService_.retrieve(promotionId, {
relations: ["application_method"],
})
const applicationMethod = promotion.application_method
if (!applicationMethod) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`application_method for promotion not found`
)
}
const createdPromotionRules = await this.createPromotionRulesAndValues_(
rulesData,
"method_target_rules",
applicationMethod,
sharedContext
)
return await this.listPromotionRules(
{ id: createdPromotionRules.map((pr) => pr.id) },
{ relations: ["values"] },
sharedContext
)
}
@InjectManager()
async addPromotionBuyRules(
promotionId: string,
rulesData: PromotionTypes.CreatePromotionRuleDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<PromotionTypes.PromotionRuleDTO[]> {
const promotion = await this.promotionService_.retrieve(
promotionId,
{ relations: ["application_method"] },
sharedContext
)
const applicationMethod = promotion.application_method
if (!applicationMethod) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`application_method for promotion not found`
)
}
const createdPromotionRules = await this.createPromotionRulesAndValues_(
rulesData,
"method_buy_rules",
applicationMethod,
sharedContext
)
return await this.listPromotionRules(
{ id: createdPromotionRules.map((pr) => pr.id) },
{ relations: ["values"] },
sharedContext
)
}
@InjectTransactionManager()
protected async createPromotionRulesAndValues_(
rulesData: PromotionTypes.CreatePromotionRuleDTO[],
relationName: "promotions" | "method_target_rules" | "method_buy_rules",
relation:
| InferEntityType<typeof Promotion>
| InferEntityType<typeof ApplicationMethod>,
@MedusaContext() sharedContext: Context = {}
): Promise<InferEntityType<typeof PromotionRule>[]> {
const MikroORMApplicationMethod = toMikroORMEntity(ApplicationMethod)
const createdPromotionRules: InferEntityType<typeof PromotionRule>[] = []
const promotion =
relation instanceof MikroORMApplicationMethod
? relation.promotion
: relation
if (!rulesData.length) {
return []
}
if (
relationName === "method_buy_rules" &&
promotion.type === PromotionType.STANDARD
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Can't add buy rules to a ${PromotionType.STANDARD} promotion`
)
}
validatePromotionRuleAttributes(rulesData)
for (const ruleData of rulesData) {
const { values, ...rest } = ruleData
const promotionRuleData: CreatePromotionRuleDTO = {
...rest,
[relationName]: [relation],
}
const [createdPromotionRule] = await this.promotionRuleService_.create(
[promotionRuleData],
sharedContext
)
createdPromotionRules.push(createdPromotionRule)
const ruleValues = Array.isArray(values) ? values : [values]
const promotionRuleValuesData = ruleValues.map((ruleValue) => ({
value: ruleValue,
promotion_rule: createdPromotionRule,
}))
await this.promotionRuleValueService_.create(
promotionRuleValuesData,
sharedContext
)
}
return createdPromotionRules
}
@InjectManager()
async removePromotionRules(
promotionId: string,
ruleIds: string[],
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
await this.removePromotionRules_(promotionId, ruleIds, sharedContext)
}
@InjectTransactionManager()
protected async removePromotionRules_(
promotionId: string,
ruleIds: string[],
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
const promotion = await this.promotionService_.retrieve(
promotionId,
{ relations: ["rules"] },
sharedContext
)
const existingRuleIds = promotion.rules.map((rule) => rule.id)
const idsToRemove = ruleIds.filter((id) => existingRuleIds.includes(id))
await this.promotionRuleService_.delete(idsToRemove, sharedContext)
}
@InjectManager()
async removePromotionTargetRules(
promotionId: string,
ruleIds: string[],
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
await this.removeApplicationMethodRules_(
promotionId,
ruleIds,
ApplicationMethodRuleTypes.TARGET_RULES,
sharedContext
)
}
@InjectManager()
async removePromotionBuyRules(
promotionId: string,
ruleIds: string[],
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
await this.removeApplicationMethodRules_(
promotionId,
ruleIds,
ApplicationMethodRuleTypes.BUY_RULES,
sharedContext
)
}
@InjectTransactionManager()
protected async removeApplicationMethodRules_(
promotionId: string,
ruleIds: string[],
relation:
| ApplicationMethodRuleTypes.TARGET_RULES
| ApplicationMethodRuleTypes.BUY_RULES,
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
const promotion = await this.promotionService_.retrieve(
promotionId,
{ relations: [`application_method.${relation}`] },
sharedContext
)
const applicationMethod = promotion.application_method
if (!applicationMethod) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`application_method for promotion not found`
)
}
const targetRuleIdsToRemove = applicationMethod[relation]
.filter((rule) => ruleIds.includes(rule.id))
.map((rule) => rule.id)
await this.promotionRuleService_.delete(
targetRuleIdsToRemove,
sharedContext
)
}
// @ts-expect-error
async createCampaigns(
data: PromotionTypes.CreateCampaignDTO,
sharedContext?: Context
): Promise<PromotionTypes.CampaignDTO>
// @ts-expect-error
async createCampaigns(
data: PromotionTypes.CreateCampaignDTO[],
sharedContext?: Context
): Promise<PromotionTypes.CampaignDTO[]>
@InjectManager()
// @ts-expect-error
async createCampaigns(
data: PromotionTypes.CreateCampaignDTO | PromotionTypes.CreateCampaignDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<PromotionTypes.CampaignDTO | PromotionTypes.CampaignDTO[]> {
const input = Array.isArray(data) ? data : [data]
const createdCampaigns = await this.createCampaigns_(input, sharedContext)
const campaigns = await this.listCampaigns(
{ id: createdCampaigns.map((p) => p!.id) },
{
relations: ["budget", "promotions"],
},
sharedContext
)
return Array.isArray(data) ? campaigns : campaigns[0]
}
@InjectTransactionManager()
protected async createCampaigns_(
data: PromotionTypes.CreateCampaignDTO[],
@MedusaContext() sharedContext: Context = {}
) {
const campaignsData: CreateCampaignDTO[] = []
const campaignBudgetsData: CreateCampaignBudgetDTO[] = []
const campaignIdentifierBudgetMap = new Map<
string,
CreateCampaignBudgetDTO
>()
for (const createCampaignData of data) {
const { budget: campaignBudgetData, ...campaignData } = createCampaignData
if (campaignBudgetData) {
campaignIdentifierBudgetMap.set(
campaignData.campaign_identifier,
campaignBudgetData
)
}
campaignsData.push({
...campaignData,
})
}
const createdCampaigns = await this.campaignService_.create(
campaignsData,
sharedContext
)
for (const createdCampaign of createdCampaigns) {
const campaignBudgetData = campaignIdentifierBudgetMap.get(
createdCampaign.campaign_identifier
)
if (campaignBudgetData) {
this.validateCampaignBudgetData(campaignBudgetData)
campaignBudgetsData.push({
...campaignBudgetData,
campaign: createdCampaign.id,
})
}
}
if (campaignBudgetsData.length) {
await this.campaignBudgetService_.create(
campaignBudgetsData,
sharedContext
)
}
return createdCampaigns
}
protected validateCampaignBudgetData(data: {
type?: CampaignBudgetTypeValues
currency_code?: string | null
}) {
if (!data.type) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Campaign Budget type is a required field`
)
}
if (
data.type === CampaignBudgetType.SPEND &&
!isPresent(data.currency_code)
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Campaign Budget type is a required field`
)
}
}
// @ts-expect-error
async updateCampaigns(
data: PromotionTypes.UpdateCampaignDTO,
sharedContext?: Context
): Promise<PromotionTypes.CampaignDTO>
// @ts-expect-error
async updateCampaigns(
data: PromotionTypes.UpdateCampaignDTO[],
sharedContext?: Context
): Promise<PromotionTypes.CampaignDTO[]>
@InjectManager()
// @ts-expect-error
async updateCampaigns(
data: PromotionTypes.UpdateCampaignDTO | PromotionTypes.UpdateCampaignDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<PromotionTypes.CampaignDTO | PromotionTypes.CampaignDTO[]> {
const input = Array.isArray(data) ? data : [data]
const updatedCampaigns = await this.updateCampaigns_(input, sharedContext)
const campaigns = await this.listCampaigns(
{ id: updatedCampaigns.map((p) => p!.id) },
{
relations: ["budget", "promotions"],
},
sharedContext
)
return Array.isArray(data) ? campaigns : campaigns[0]
}
@InjectTransactionManager()
protected async updateCampaigns_(
data: PromotionTypes.UpdateCampaignDTO[],
@MedusaContext() sharedContext: Context = {}
) {
const campaignIds = data.map((d) => d.id)
const campaignsData: UpdateCampaignDTO[] = []
const updateBudgetData: UpdateCampaignBudgetDTO[] = []
const createBudgetData: CreateCampaignBudgetDTO[] = []
const existingCampaigns = await this.listCampaigns(
{ id: campaignIds },
{ relations: ["budget"] },
sharedContext
)
const existingCampaignsMap = new Map<string, PromotionTypes.CampaignDTO>(
existingCampaigns.map((campaign) => [campaign.id, campaign])
)
for (const updateCampaignData of data) {
const { budget: budgetData, ...campaignData } = updateCampaignData
const existingCampaign = existingCampaignsMap.get(campaignData.id)!
campaignsData.push(campaignData)
// Type & currency code of the budget is immutable, we don't allow for it to be updated.
// If an existing budget is present, we remove the type and currency from being updated
if (
(existingCampaign?.budget && budgetData?.type) ||
budgetData?.currency_code
) {
delete budgetData?.type
delete budgetData?.currency_code
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Campaign budget attributes (type, currency_code) are immutable`
)
}
if (budgetData) {
if (existingCampaign?.budget) {
updateBudgetData.push({
id: existingCampaign.budget.id,
...budgetData,
})
} else {
createBudgetData.push({
...budgetData,
campaign: existingCampaign.id,
})
}
}
}
const updatedCampaigns = await this.campaignService_.update(
campaignsData,
sharedContext
)
if (updateBudgetData.length) {
await this.campaignBudgetService_.update(updateBudgetData, sharedContext)
}
if (createBudgetData.length) {
await this.campaignBudgetService_.create(createBudgetData, sharedContext)
}
return updatedCampaigns
}
@InjectManager()
async addPromotionsToCampaign(
data: PromotionTypes.AddPromotionsToCampaignDTO,
sharedContext?: Context
): Promise<{ ids: string[] }> {
const ids = await this.addPromotionsToCampaign_(data, sharedContext)
return { ids }
}
// TODO:
// - introduce currency_code to promotion
// - allow promotions to be queried by currency code
// - when the above is present, validate adding promotion to campaign based on currency code
@InjectTransactionManager()
protected async addPromotionsToCampaign_(
data: PromotionTypes.AddPromotionsToCampaignDTO,
@MedusaContext() sharedContext: Context = {}
) {
const { id, promotion_ids: promotionIds = [] } = data
const campaign = await this.campaignService_.retrieve(id, {}, sharedContext)
const promotionsToAdd = await this.promotionService_.list(
{ id: promotionIds, campaign_id: null },
{ relations: ["application_method"] },
sharedContext
)
const diff = arrayDifference(
promotionsToAdd.map((p) => p.id),
promotionIds
)
if (diff.length > 0) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Cannot add promotions (${diff.join(
","
)}) to campaign. These promotions are either already part of a campaign or not found.`
)
}
const promotionsWithInvalidCurrency = promotionsToAdd.filter(
(promotion) =>
campaign.budget?.type === CampaignBudgetType.SPEND &&
promotion.application_method?.currency_code !==
campaign?.budget?.currency_code
)
if (promotionsWithInvalidCurrency.length > 0) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Cannot add promotions to campaign where currency_code don't match.`
)
}
await this.promotionService_.update(
promotionsToAdd.map((promotion) => ({
id: promotion.id,
campaign_id: campaign.id,
})),
sharedContext
)
return promotionsToAdd.map((promo) => promo.id)
}
@InjectManager()
async removePromotionsFromCampaign(
data: PromotionTypes.AddPromotionsToCampaignDTO,
sharedContext?: Context
): Promise<{ ids: string[] }> {
const ids = await this.removePromotionsFromCampaign_(data, sharedContext)
return { ids }
}
@InjectTransactionManager()
protected async removePromotionsFromCampaign_(
data: PromotionTypes.AddPromotionsToCampaignDTO,
@MedusaContext() sharedContext: Context = {}
) {
const { id, promotion_ids: promotionIds = [] } = data
await this.campaignService_.retrieve(id, {}, sharedContext)
const promotionsToRemove = await this.promotionService_.list(
{ id: promotionIds },
{},
sharedContext
)
const diff = arrayDifference(
promotionsToRemove.map((p) => p.id),
promotionIds
)
if (diff.length > 0) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Promotions with ids (${diff.join(",")}) not found.`
)
}
await this.promotionService_.update(
promotionsToRemove.map((promotion) => ({
id: promotion.id,
campaign_id: null,
})),
sharedContext
)
return promotionsToRemove.map((promo) => promo.id)
}
}