import { CampaignBudgetTypeValues, Context, DAL, InternalModuleDeclaration, ModuleJoinerConfig, ModulesSdkTypes, PromotionTypes, } from "@medusajs/types" import { ApplicationMethodAllocation, ApplicationMethodTargetType, arrayDifference, CampaignBudgetType, ComputedActions, deduplicate, InjectManager, InjectTransactionManager, isDefined, isPresent, isString, MathBN, MedusaContext, MedusaError, MedusaService, PromotionType, transformPropertiesToBigNumber, } from "@medusajs/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 applicationMethodService: ModulesSdkTypes.IMedusaInternalService promotionRuleService: ModulesSdkTypes.IMedusaInternalService promotionRuleValueService: ModulesSdkTypes.IMedusaInternalService campaignService: ModulesSdkTypes.IMedusaInternalService campaignBudgetService: ModulesSdkTypes.IMedusaInternalService } 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 protected applicationMethodService_: ModulesSdkTypes.IMedusaInternalService protected promotionRuleService_: ModulesSdkTypes.IMedusaInternalService protected promotionRuleValueService_: ModulesSdkTypes.IMedusaInternalService protected campaignService_: ModulesSdkTypes.IMedusaInternalService protected campaignBudgetService_: ModulesSdkTypes.IMedusaInternalService 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("baseRepository_") async registerUsage( computedActions: PromotionTypes.UsageComputedActions[], @MedusaContext() sharedContext: Context = {} ): Promise { const promotionCodes = computedActions .map((computedAction) => computedAction.code) .filter(Boolean) const promotionCodeCampaignBudgetMap = new Map< string, UpdateCampaignBudgetDTO >() const promotionCodeUsageMap = new Map() const existingPromotions = await this.listPromotions( { code: promotionCodes }, { relations: ["application_method", "campaign", "campaign.budget"] }, sharedContext ) const existingPromotionsMap = new Map( existingPromotions.map((promotion) => [promotion.code!, promotion]) ) for (let computedAction of computedActions) { if (!ComputeActionUtils.canRegisterUsage(computedAction)) { continue } const promotion = existingPromotionsMap.get(computedAction.code) if (!promotion) { continue } const campaignBudget = promotion.campaign?.budget if (!campaignBudget) { continue } if (campaignBudget.type === CampaignBudgetType.SPEND) { const campaignBudgetData = promotionCodeCampaignBudgetMap.get( campaignBudget.id ) || { id: campaignBudget.id, used: campaignBudget.used ?? 0 } campaignBudgetData.used = MathBN.add( campaignBudgetData.used ?? 0, computedAction.amount ) if ( campaignBudget.limit && MathBN.gt(campaignBudgetData.used, campaignBudget.limit) ) { continue } promotionCodeCampaignBudgetMap.set( campaignBudget.id, campaignBudgetData ) } if (campaignBudget.type === CampaignBudgetType.USAGE) { const promotionAlreadyUsed = promotionCodeUsageMap.get(promotion.code!) || false if (promotionAlreadyUsed) { continue } const campaignBudgetData = { id: campaignBudget.id, used: MathBN.add(campaignBudget.used ?? 0, 1), } if ( campaignBudget.limit && MathBN.gt(campaignBudgetData.used, campaignBudget.limit) ) { continue } promotionCodeCampaignBudgetMap.set( campaignBudget.id, campaignBudgetData ) promotionCodeUsageMap.set(promotion.code!, true) } const campaignBudgetsData: UpdateCampaignBudgetDTO[] = [] for (const [_, campaignBudgetData] of promotionCodeCampaignBudgetMap) { campaignBudgetsData.push(campaignBudgetData) } await this.campaignBudgetService_.update( campaignBudgetsData, sharedContext ) } } @InjectManager("baseRepository_") async computeActions( promotionCodes: string[], applicationContext: PromotionTypes.ComputeActionContext, options: PromotionTypes.ComputeActionOptions = {}, @MedusaContext() sharedContext: Context = {} ): Promise { const { prevent_auto_promotions: preventAutoPromotions } = options const computedActions: PromotionTypes.ComputeActions[] = [] const { items = [], shipping_methods: shippingMethods = [] } = applicationContext const appliedItemCodes: string[] = [] const appliedShippingCodes: string[] = [] const codeAdjustmentMap = new Map< string, PromotionTypes.ComputeActionAdjustmentLine[] >() const methodIdPromoValueMap = new Map() const automaticPromotions = preventAutoPromotions ? [] : await this.listPromotions( { is_automatic: true }, { select: ["code"], take: null }, sharedContext ) // Promotions we need to apply includes all the codes that are passed as an argument // to this method, along with any automatic promotions that can be applied to the context const automaticPromotionCodes = automaticPromotions.map((p) => p.code!) const promotionCodesToApply = [ ...promotionCodes, ...automaticPromotionCodes, ] items.forEach((item) => { item.adjustments?.forEach((adjustment) => { if (isString(adjustment.code)) { const adjustments = codeAdjustmentMap.get(adjustment.code) || [] adjustments.push(adjustment) codeAdjustmentMap.set(adjustment.code, adjustments) appliedItemCodes.push(adjustment.code) } }) }) shippingMethods.forEach((shippingMethod) => { shippingMethod.adjustments?.forEach((adjustment) => { if (isString(adjustment.code)) { const adjustments = codeAdjustmentMap.get(adjustment.code) || [] adjustments.push(adjustment) codeAdjustmentMap.set(adjustment.code, adjustments) appliedShippingCodes.push(adjustment.code) } }) }) const promotions = await this.listPromotions( { code: [ ...promotionCodesToApply, ...appliedItemCodes, ...appliedShippingCodes, ], }, { 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", ], take: null, } ) const existingPromotionsMap = new Map( promotions.map((promotion) => [promotion.code!, promotion]) ) // We look at any existing promo codes applied in the context and recommend // them to be removed to start calculations from the beginning and refresh // the adjustments if they are requested to be applied again const appliedCodes = [...appliedShippingCodes, ...appliedItemCodes] for (const appliedCode of appliedCodes) { const promotion = existingPromotionsMap.get(appliedCode) const adjustments = codeAdjustmentMap.get(appliedCode) || [] const action = appliedShippingCodes.includes(appliedCode) ? ComputedActions.REMOVE_SHIPPING_METHOD_ADJUSTMENT : ComputedActions.REMOVE_ITEM_ADJUSTMENT if (!promotion) { throw new MedusaError( MedusaError.Types.INVALID_DATA, `Applied Promotion for code (${appliedCode}) not found` ) } adjustments.forEach((adjustment) => computedActions.push({ action, adjustment_id: adjustment.id, code: appliedCode, }) ) } // We sort the promo codes to apply with buy get type first as they // are likely to be most valuable. const sortedPermissionsToApply = promotions .filter((p) => promotionCodesToApply.includes(p.code!)) .sort(ComputeActionUtils.sortByBuyGetType) for (const promotionToApply of sortedPermissionsToApply) { const promotion = existingPromotionsMap.get(promotionToApply.code!)! const { application_method: applicationMethod, rules: promotionRules = [], } = promotion if (!applicationMethod) { continue } const isPromotionApplicable = areRulesValidForContext( promotionRules, applicationContext ) if (!isPromotionApplicable) { continue } if (promotion.type === PromotionType.BUYGET) { const computedActionsForItems = ComputeActionUtils.getComputedActionsForBuyGet( promotion, applicationContext[ApplicationMethodTargetType.ITEMS], methodIdPromoValueMap ) computedActions.push(...computedActionsForItems) } 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 async createPromotions( data: PromotionTypes.CreatePromotionDTO[], sharedContext?: Context ): Promise @InjectManager("baseRepository_") async createPromotions( data: | PromotionTypes.CreatePromotionDTO | PromotionTypes.CreatePromotionDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { 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", ], take: null, }, sharedContext ) return Array.isArray(data) ? promotions : promotions[0] } @InjectTransactionManager("baseRepository_") protected async createPromotions_( data: PromotionTypes.CreatePromotionDTO[], @MedusaContext() sharedContext: Context = {} ) { const promotionsData: CreatePromotionDTO[] = [] const applicationMethodsData: CreateApplicationMethodDTO[] = [] const campaignsData: CreateCampaignDTO[] = [] const existingCampaigns = await this.campaignService_.list( { id: data.map((d) => d.campaign_id).filter((id) => isString(id)) }, { 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) } } await this.createPromotionRulesAndValues_( promotionCodeRulesDataMap.get(promotion.code) || [], "promotions", promotion, sharedContext ) } const createdApplicationMethods = await this.applicationMethodService_.create( applicationMethodsData, sharedContext ) const createdCampaigns = 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) { continue } await this.addPromotionsToCampaign( { id: campaign.id, promotion_ids: promotions.map((p) => p.id) }, sharedContext ) } for (const applicationMethod of createdApplicationMethods) { await this.createPromotionRulesAndValues_( methodTargetRulesMap.get(applicationMethod.promotion.id) || [], "method_target_rules", applicationMethod, sharedContext ) await this.createPromotionRulesAndValues_( methodBuyRulesMap.get(applicationMethod.promotion.id) || [], "method_buy_rules", applicationMethod, sharedContext ) } return createdPromotions } // @ts-expect-error async updatePromotions( data: PromotionTypes.UpdatePromotionDTO, sharedContext?: Context ): Promise async updatePromotions( data: PromotionTypes.UpdatePromotionDTO[], sharedContext?: Context ): Promise @InjectManager("baseRepository_") async updatePromotions( data: | PromotionTypes.UpdatePromotionDTO | PromotionTypes.UpdatePromotionDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { 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", ], take: null, }, sharedContext ) return Array.isArray(data) ? promotions : promotions[0] } @InjectTransactionManager("baseRepository_") 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( 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("baseRepository_") // @ts-ignore async updatePromotionRules( data: PromotionTypes.UpdatePromotionRuleDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { const updatedPromotionRules = await this.updatePromotionRules_( data, sharedContext ) return await this.listPromotionRules( { id: updatedPromotionRules.map((r) => r.id) }, { relations: ["values"] }, sharedContext ) } @InjectTransactionManager("baseRepository_") 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( 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("baseRepository_") async addPromotionRules( promotionId: string, rulesData: PromotionTypes.CreatePromotionRuleDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { 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("baseRepository_") async addPromotionTargetRules( promotionId: string, rulesData: PromotionTypes.CreatePromotionRuleDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { 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("baseRepository_") async addPromotionBuyRules( promotionId: string, rulesData: PromotionTypes.CreatePromotionRuleDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { 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("baseRepository_") protected async createPromotionRulesAndValues_( rulesData: PromotionTypes.CreatePromotionRuleDTO[], relationName: "promotions" | "method_target_rules" | "method_buy_rules", relation: Promotion | ApplicationMethod, @MedusaContext() sharedContext: Context = {} ): Promise { const createdPromotionRules: PromotionRule[] = [] const promotion = relation instanceof ApplicationMethod ? 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("baseRepository_") async removePromotionRules( promotionId: string, ruleIds: string[], @MedusaContext() sharedContext: Context = {} ): Promise { await this.removePromotionRules_(promotionId, ruleIds, sharedContext) } @InjectTransactionManager("baseRepository_") protected async removePromotionRules_( promotionId: string, ruleIds: string[], @MedusaContext() sharedContext: Context = {} ): Promise { 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("baseRepository_") async removePromotionTargetRules( promotionId: string, ruleIds: string[], @MedusaContext() sharedContext: Context = {} ): Promise { await this.removeApplicationMethodRules_( promotionId, ruleIds, ApplicationMethodRuleTypes.TARGET_RULES, sharedContext ) } @InjectManager("baseRepository_") async removePromotionBuyRules( promotionId: string, ruleIds: string[], @MedusaContext() sharedContext: Context = {} ): Promise { await this.removeApplicationMethodRules_( promotionId, ruleIds, ApplicationMethodRuleTypes.BUY_RULES, sharedContext ) } @InjectTransactionManager("baseRepository_") protected async removeApplicationMethodRules_( promotionId: string, ruleIds: string[], relation: | ApplicationMethodRuleTypes.TARGET_RULES | ApplicationMethodRuleTypes.BUY_RULES, @MedusaContext() sharedContext: Context = {} ): Promise { 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-ignore async createCampaigns( data: PromotionTypes.CreateCampaignDTO, sharedContext?: Context ): Promise async createCampaigns( data: PromotionTypes.CreateCampaignDTO[], sharedContext?: Context ): Promise @InjectManager("baseRepository_") async createCampaigns( data: PromotionTypes.CreateCampaignDTO | PromotionTypes.CreateCampaignDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { 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"], take: null, }, sharedContext ) return Array.isArray(data) ? campaigns : campaigns[0] } @InjectTransactionManager("baseRepository_") 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-ignore async updateCampaigns( data: PromotionTypes.UpdateCampaignDTO, sharedContext?: Context ): Promise async updateCampaigns( data: PromotionTypes.UpdateCampaignDTO[], sharedContext?: Context ): Promise @InjectManager("baseRepository_") async updateCampaigns( data: PromotionTypes.UpdateCampaignDTO | PromotionTypes.UpdateCampaignDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { 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"], take: null, }, sharedContext ) return Array.isArray(data) ? campaigns : campaigns[0] } @InjectTransactionManager("baseRepository_") 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"], take: null }, sharedContext ) const existingCampaignsMap = new Map( 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("baseRepository_") 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("baseRepository_") 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 }, { take: 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("baseRepository_") async removePromotionsFromCampaign( data: PromotionTypes.AddPromotionsToCampaignDTO, sharedContext?: Context ): Promise<{ ids: string[] }> { const ids = await this.removePromotionsFromCampaign_(data, sharedContext) return { ids } } @InjectTransactionManager("baseRepository_") 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 }, { take: null }, 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) } }