import { AddPricesDTO, Context, CreatePricePreferenceDTO, CreatePriceRuleDTO, CreatePricesDTO, CreatePriceSetDTO, DAL, FindConfig, InferEntityType, InternalModuleDeclaration, MedusaContainer, ModuleJoinerConfig, ModulesSdkTypes, PricePreferenceDTO, PriceSetDTO, PricingContext, PricingFilters, PricingRepositoryService, PricingRuleOperatorValues, PricingTypes, UpsertPricePreferenceDTO, UpsertPriceSetDTO, } from "@medusajs/framework/types" import { arrayDifference, deduplicate, EmitEvents, GetIsoStringFromDate, groupBy, InjectManager, InjectTransactionManager, isDefined, isPresent, isString, MathBN, MedusaContext, MedusaError, ModulesSdkUtils, PriceListType, PricingRuleOperator, promiseAll, removeNullish, } from "@medusajs/framework/utils" import { Price, PriceList, PriceListRule, PricePreference, PriceRule, PriceSet, } from "@models" import { Collection } from "@medusajs/framework/mikro-orm/core" import { ServiceTypes } from "@types" import { validatePriceListDates } from "@utils" import { joinerConfig } from "../joiner-config" type InjectedDependencies = { baseRepository: DAL.RepositoryService pricingRepository: PricingRepositoryService priceSetService: ModulesSdkTypes.IMedusaInternalService priceRuleService: ModulesSdkTypes.IMedusaInternalService priceService: ModulesSdkTypes.IMedusaInternalService priceListService: ModulesSdkTypes.IMedusaInternalService pricePreferenceService: ModulesSdkTypes.IMedusaInternalService priceListRuleService: ModulesSdkTypes.IMedusaInternalService } const generateMethodForModels = { PriceSet, PriceList, PriceListRule, PriceRule, Price, PricePreference, } const BaseClass = ModulesSdkUtils.MedusaService<{ PriceSet: { dto: PricingTypes.PriceSetDTO } Price: { dto: PricingTypes.PriceDTO } PriceRule: { dto: PricingTypes.PriceRuleDTO create: PricingTypes.CreatePriceRuleDTO update: PricingTypes.UpdatePriceRuleDTO } PriceList: { dto: PricingTypes.PriceListDTO } PriceListRule: { dto: PricingTypes.PriceListRuleDTO } // PricePreference: { dto: PricingTypes.PricePreferenceDTO } PricePreference: { dto: any } }>(generateMethodForModels) export default class PricingModuleService extends BaseClass implements PricingTypes.IPricingModuleService { protected readonly container_: MedusaContainer protected baseRepository_: DAL.RepositoryService protected readonly pricingRepository_: PricingRepositoryService & { clearAvailableAttributes?: () => Promise } protected readonly priceSetService_: ModulesSdkTypes.IMedusaInternalService< InferEntityType > protected readonly priceRuleService_: ModulesSdkTypes.IMedusaInternalService< InferEntityType > protected readonly priceService_: ModulesSdkTypes.IMedusaInternalService< InferEntityType > protected readonly priceListService_: ModulesSdkTypes.IMedusaInternalService< InferEntityType > protected readonly priceListRuleService_: ModulesSdkTypes.IMedusaInternalService< InferEntityType > protected readonly pricePreferenceService_: ModulesSdkTypes.IMedusaInternalService< InferEntityType > constructor( { baseRepository, pricingRepository, priceSetService, priceRuleService, priceService, pricePreferenceService, priceListService, priceListRuleService, }: InjectedDependencies, protected readonly moduleDeclaration: InternalModuleDeclaration ) { // @ts-ignore super(...arguments) this.container_ = arguments[0] this.baseRepository_ = baseRepository this.pricingRepository_ = pricingRepository this.priceSetService_ = priceSetService this.priceRuleService_ = priceRuleService this.priceService_ = priceService this.pricePreferenceService_ = pricePreferenceService this.priceListService_ = priceListService this.priceListRuleService_ = priceListRuleService } __joinerConfig(): ModuleJoinerConfig { return joinerConfig } private setupCalculatedPriceConfig_( filters, config ): PricingContext["context"] | undefined { const fieldIdx = config.relations?.indexOf("calculated_price") const shouldCalculatePrice = fieldIdx > -1 const pricingContext = filters.context ?? {} delete filters.context if (!shouldCalculatePrice) { return } // cleanup virtual field "calculated_price" config.relations?.splice(fieldIdx, 1) return pricingContext } @InjectTransactionManager() @EmitEvents() // @ts-expect-error async createPriceRules( ...args: Parameters ): Promise { try { return await super.createPriceRules(...args) } finally { this.pricingRepository_.clearAvailableAttributes?.() } } @InjectTransactionManager() @EmitEvents() // @ts-expect-error async updatePriceRules( ...args: Parameters ): Promise { try { return await super.updatePriceRules(...args) } finally { this.pricingRepository_.clearAvailableAttributes?.() } } @InjectTransactionManager() @EmitEvents() // @ts-expect-error async createPriceListRules( ...args: any[] ): Promise { try { // @ts-ignore return await super.createPriceListRules(...args) } finally { this.pricingRepository_.clearAvailableAttributes?.() } } @InjectTransactionManager() @EmitEvents() // @ts-expect-error async updatePriceListRules( ...args: any[] ): Promise { try { // @ts-ignore return await super.updatePriceListRules(...args) } finally { this.pricingRepository_.clearAvailableAttributes?.() } } @InjectManager() // @ts-expect-error async retrievePriceSet( id: string, config?: FindConfig | undefined, sharedContext?: Context | undefined ): Promise { const priceSet = await this.priceSetService_.retrieve( id, this.normalizePriceSetConfig(config), sharedContext ) return await this.baseRepository_.serialize(priceSet) } @InjectManager() // @ts-expect-error async listPriceSets( filters: PricingTypes.FilterablePriceSetProps = {}, config: FindConfig = {}, @MedusaContext() sharedContext: Context = {} ): Promise { const normalizedConfig = this.normalizePriceSetConfig(config) const pricingContext = this.setupCalculatedPriceConfig_( filters, normalizedConfig ) const priceSets = await super.listPriceSets( filters, normalizedConfig, sharedContext ) if (!pricingContext || !priceSets.length) { return priceSets } const calculatedPrices = await this.calculatePrices( { id: priceSets.map((p) => p.id) }, { context: pricingContext }, sharedContext ) const calculatedPricesMap = new Map() for (const calculatedPrice of calculatedPrices) { calculatedPricesMap.set(calculatedPrice.id, calculatedPrice) } for (const priceSet of priceSets) { const calculatedPrice = calculatedPricesMap.get(priceSet.id) priceSet.calculated_price = calculatedPrice ?? null } return await this.baseRepository_.serialize(priceSets) } @InjectManager() // @ts-expect-error async listAndCountPriceSets( filters: PricingTypes.FilterablePriceSetProps = {}, config: FindConfig = {}, @MedusaContext() sharedContext: Context = {} ): Promise<[PriceSetDTO[], number]> { const normalizedConfig = this.normalizePriceSetConfig(config) const pricingContext = this.setupCalculatedPriceConfig_( filters, normalizedConfig ) const [priceSets, count] = await super.listAndCountPriceSets( filters, normalizedConfig, sharedContext ) if (!pricingContext || !priceSets.length) { const serializedPriceSets = await this.baseRepository_.serialize< PriceSetDTO[] >(priceSets) return [serializedPriceSets, count] } const calculatedPrices = await this.calculatePrices( { id: priceSets.map((p) => p.id) }, { context: pricingContext }, sharedContext ) const calculatedPricesMap = new Map() for (const calculatedPrice of calculatedPrices) { calculatedPricesMap.set(calculatedPrice.id, calculatedPrice) } for (const priceSet of priceSets) { const calculatedPrice = calculatedPricesMap.get(priceSet.id) priceSet.calculated_price = calculatedPrice ?? null } const serializedPriceSets = await this.baseRepository_.serialize< PriceSetDTO[] >(priceSets) return [serializedPriceSets, count] } @InjectManager() // @ts-expect-error async listPriceRules( filters: PricingTypes.FilterablePriceRuleProps, config: FindConfig = {}, sharedContext?: Context ): Promise { const priceRules = await this.listPriceRules_( filters, config, sharedContext ) return await this.baseRepository_.serialize( priceRules ) } protected async listPriceRules_( filters: PricingTypes.FilterablePriceRuleProps, config: FindConfig = {}, sharedContext?: Context ): Promise[]> { return await this.priceRuleService_.list(filters, config, sharedContext) } @InjectManager() // @ts-expect-error async listPricePreferences( filters: PricingTypes.FilterablePricePreferenceProps, config: FindConfig = {}, sharedContext?: Context ): Promise { const pricePreferences = await this.listPricePreferences_( filters, config, sharedContext ) return await this.baseRepository_.serialize< PricingTypes.PricePreferenceDTO[] >(pricePreferences) } protected async listPricePreferences_( filters: PricingTypes.FilterablePricePreferenceProps, config: FindConfig = {}, sharedContext?: Context ): Promise[]> { return await this.pricePreferenceService_.list( filters, config, sharedContext ) } @InjectManager() async calculatePrices( pricingFilters: PricingFilters, pricingContext: PricingContext = { context: {} }, @MedusaContext() sharedContext: Context = {} ): Promise { const results = await this.pricingRepository_.calculatePrices( pricingFilters, pricingContext, sharedContext ) const pricesSetPricesMap = groupBy(results, "price_set_id") const priceIds: string[] = [] pricesSetPricesMap.forEach( (prices: PricingTypes.CalculatedPriceSetDTO[], key) => { const priceListPrice = prices.find((p) => p.price_list_id) const defaultPrice = prices?.find((p) => !p.price_list_id) if (!prices.length || (!priceListPrice && !defaultPrice)) { pricesSetPricesMap.delete(key) return } let calculatedPrice: PricingTypes.CalculatedPriceSetDTO | undefined = defaultPrice let originalPrice: PricingTypes.CalculatedPriceSetDTO | undefined = defaultPrice /** * When deciding which price to use we follow the following logic: * - If the price list is of type OVERRIDE, we always use the price list price. * - If there are multiple price list prices of type OVERRIDE, we use the one with the lowest amount. * - If the price list is of type SALE, we use the lowest price between the price list price and the default price */ if (priceListPrice) { switch (priceListPrice.price_list_type) { case PriceListType.OVERRIDE: calculatedPrice = priceListPrice originalPrice = priceListPrice break case PriceListType.SALE: { let lowestPrice = priceListPrice if (defaultPrice?.amount && priceListPrice.amount) { lowestPrice = MathBN.lte( priceListPrice.amount, defaultPrice.amount ) ? priceListPrice : defaultPrice } calculatedPrice = lowestPrice break } } } pricesSetPricesMap.set(key, { calculatedPrice, originalPrice }) priceIds.push( ...(deduplicate( [calculatedPrice?.id, originalPrice?.id].filter(Boolean) ) as string[]) ) } ) // We use the price rules to get the right preferences for the price const priceRulesForPrices = await this.listPriceRules( { price_id: priceIds }, {}, sharedContext ) const priceRulesPriceMap = groupBy(priceRulesForPrices, "price_id") // Note: For now the preferences are intentionally kept very simple and explicit - they use either the region or currency, // so we hard-code those as the possible filters here. This can be made more flexible if needed later on. const preferenceContext = Object.entries( pricingContext.context ?? {} ).filter(([key, val]) => { return key === "region_id" || key === "currency_code" }) let pricingPreferences: InferEntityType[] = [] if (preferenceContext.length) { const preferenceFilters = preferenceContext.length ? { $or: preferenceContext.map(([key, val]) => ({ attribute: key, value: val, })), } : {} pricingPreferences = await this.listPricePreferences_( preferenceFilters as PricingTypes.FilterablePricePreferenceProps, {}, sharedContext ) } const calculatedPrices: PricingTypes.CalculatedPriceSet[] = [] for (const priceSetId of pricingFilters.id) { const prices = pricesSetPricesMap.get(priceSetId) if (!prices) { continue } const { calculatedPrice, originalPrice, }: { calculatedPrice: PricingTypes.CalculatedPriceSetDTO originalPrice: PricingTypes.CalculatedPriceSetDTO | undefined } = prices const calculatedPrice_: PricingTypes.CalculatedPriceSet = { id: priceSetId, is_calculated_price_price_list: !!calculatedPrice?.price_list_id, is_calculated_price_tax_inclusive: isTaxInclusive( priceRulesPriceMap.get(calculatedPrice.id), pricingPreferences, calculatedPrice.currency_code!, pricingContext.context?.region_id as string ), calculated_amount: isPresent(calculatedPrice?.amount) ? parseFloat(calculatedPrice?.amount as string) : null, raw_calculated_amount: calculatedPrice?.raw_amount || null, is_original_price_price_list: !!originalPrice?.price_list_id, is_original_price_tax_inclusive: originalPrice?.id ? isTaxInclusive( priceRulesPriceMap.get(originalPrice.id), pricingPreferences, originalPrice.currency_code || calculatedPrice.currency_code!, pricingContext.context?.region_id as string ) : false, original_amount: isPresent(originalPrice?.amount) ? parseFloat(originalPrice?.amount as string) : null, raw_original_amount: originalPrice?.raw_amount || null, currency_code: calculatedPrice?.currency_code || null, calculated_price: { id: calculatedPrice?.id || null, price_list_id: calculatedPrice?.price_list_id || null, price_list_type: calculatedPrice?.price_list_type || null, min_quantity: parseInt(calculatedPrice?.min_quantity || "") || null, max_quantity: parseInt(calculatedPrice?.max_quantity || "") || null, }, original_price: { id: originalPrice?.id || null, price_list_id: originalPrice?.price_list_id || null, price_list_type: originalPrice?.price_list_type || null, min_quantity: parseInt(originalPrice?.min_quantity || "") || null, max_quantity: parseInt(originalPrice?.max_quantity || "") || null, }, } calculatedPrices.push(calculatedPrice_) } return calculatedPrices } // @ts-expect-error async createPriceSets( data: PricingTypes.CreatePriceSetDTO, sharedContext?: Context ): Promise // @ts-expect-error async createPriceSets( data: PricingTypes.CreatePriceSetDTO[], sharedContext?: Context ): Promise @InjectManager() @EmitEvents() // @ts-expect-error async createPriceSets( data: PricingTypes.CreatePriceSetDTO | PricingTypes.CreatePriceSetDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { const input = Array.isArray(data) ? data : [data] const priceSets = await this.createPriceSets_(input, sharedContext) // TODO: Remove the need to refetch the data here const dbPriceSets = await this.listPriceSets( { id: priceSets.map((p) => p.id) }, this.normalizePriceSetConfig({ relations: ["prices", "prices.price_rules"], }), sharedContext ) // Ensure the output to be in the same order as the input const results = priceSets.map((priceSet) => { return dbPriceSets.find((p) => p.id === priceSet.id)! }) try { return await this.baseRepository_.serialize( Array.isArray(data) ? results : results[0] ) } finally { this.pricingRepository_.clearAvailableAttributes?.() } } async upsertPriceSets( data: UpsertPriceSetDTO[], sharedContext?: Context ): Promise async upsertPriceSets( data: UpsertPriceSetDTO, sharedContext?: Context ): Promise @InjectManager() @EmitEvents() async upsertPriceSets( data: UpsertPriceSetDTO | UpsertPriceSetDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { const result = await this.upsertPriceSets_(data, sharedContext) try { return await this.baseRepository_.serialize( Array.isArray(data) ? result : result[0] ) } finally { this.pricingRepository_.clearAvailableAttributes?.() } } @InjectTransactionManager() protected async upsertPriceSets_( data: UpsertPriceSetDTO | UpsertPriceSetDTO[], @MedusaContext() sharedContext: Context = {} ): Promise[]> { const input = Array.isArray(data) ? data : [data] const forUpdate = input.filter( (priceSet): priceSet is ServiceTypes.UpdatePriceSetInput => !!priceSet.id ) const forCreate = input.filter( (priceSet): priceSet is CreatePriceSetDTO => !priceSet.id ) const operations: Promise[]>[] = [] if (forCreate.length) { operations.push(this.createPriceSets_(forCreate, sharedContext)) } if (forUpdate.length) { operations.push(this.updatePriceSets_(forUpdate, sharedContext)) } const result = (await promiseAll(operations)).flat() return result } // @ts-expect-error async updatePriceSets( id: string, data: PricingTypes.UpdatePriceSetDTO, sharedContext?: Context ): Promise // @ts-expect-error async updatePriceSets( selector: PricingTypes.FilterablePriceSetProps, data: PricingTypes.UpdatePriceSetDTO, sharedContext?: Context ): Promise @InjectManager() @EmitEvents() // @ts-expect-error async updatePriceSets( idOrSelector: string | PricingTypes.FilterablePriceSetProps, data: PricingTypes.UpdatePriceSetDTO, @MedusaContext() sharedContext: Context = {} ): Promise { let normalizedInput: ServiceTypes.UpdatePriceSetInput[] = [] if (isString(idOrSelector)) { // Check if the ID exists, it will throw if not. await this.priceSetService_.retrieve(idOrSelector, {}, sharedContext) normalizedInput = [{ id: idOrSelector, ...data }] } else { const priceSets = await this.priceSetService_.list( idOrSelector, {}, sharedContext ) normalizedInput = priceSets.map((priceSet) => ({ id: priceSet.id, ...data, })) } const updateResult = await this.updatePriceSets_( normalizedInput, sharedContext ) const serializedUpdateResult = await this.baseRepository_.serialize< PriceSetDTO[] | PriceSetDTO >(isString(idOrSelector) ? updateResult[0] : updateResult) try { return serializedUpdateResult } finally { this.pricingRepository_.clearAvailableAttributes?.() } } @InjectTransactionManager() protected async updatePriceSets_( data: ServiceTypes.UpdatePriceSetInput[], @MedusaContext() sharedContext: Context = {} ): Promise[]> { const normalizedData = await this.normalizeUpdateData(data) const priceSetIds = normalizedData.map(({ id }) => id) const existingPrices = await this.priceService_.list( { price_set_id: priceSetIds, price_list_id: null, }, { relations: ["price_rules"], take: null, }, sharedContext ) const existingPricesMap = new Map>( existingPrices.map((p) => [p.id, p]) ) const prices = normalizedData.flatMap((priceSet) => priceSet.prices || []) const pricesToCreate = prices.filter( (price) => !price.id || !existingPricesMap.has(price.id) ) const pricesToUpdate = prices.filter( (price) => price.id && existingPricesMap.has(price.id) ) const incomingPriceIds = new Set(prices.map((p) => p.id).filter(Boolean)) const pricesToDelete = existingPrices .filter((existingPrice) => !incomingPriceIds.has(existingPrice.id)) .map((p) => p.id) let createdPrices: InferEntityType[] = [] let updatedPrices: InferEntityType[] = [] if (pricesToCreate.length > 0) { createdPrices = await this.priceService_.create( pricesToCreate.map((price) => { price.price_rules ??= [] return price }), sharedContext ) } if (pricesToUpdate.length > 0) { // Handle price rules for updated prices for (const priceToUpdate of pricesToUpdate) { const existingPrice = existingPricesMap.get(priceToUpdate.id!) if (priceToUpdate.price_rules?.length) { const existingPriceRules = existingPrice?.price_rules || [] // Separate price rules for create, update, delete const priceRulesToCreate = priceToUpdate.price_rules.filter( (rule) => !("id" in rule) ) const priceRulesToUpdate = priceToUpdate.price_rules.filter( (rule) => "id" in rule ) const incomingPriceRuleIds = new Set( priceToUpdate.price_rules .map((r) => "id" in r && r.id) .filter(Boolean) ) const priceRulesToDelete = existingPriceRules .filter( (existingRule) => !incomingPriceRuleIds.has(existingRule.id) ) .map((r) => r.id) let createdPriceRules: InferEntityType[] = [] let updatedPriceRules: InferEntityType[] = [] // Bulk operations for price rules if (priceRulesToCreate.length > 0) { createdPriceRules = await this.priceRuleService_.create( priceRulesToCreate.map((rule) => ({ ...rule, price_id: priceToUpdate.id, })), sharedContext ) } if (priceRulesToUpdate.length > 0) { updatedPriceRules = await this.priceRuleService_.update( priceRulesToUpdate, sharedContext ) } if (priceRulesToDelete.length > 0) { await this.priceRuleService_.delete( priceRulesToDelete, sharedContext ) } const upsertedPriceRules = [ ...createdPriceRules, ...updatedPriceRules, ] priceToUpdate.price_rules = upsertedPriceRules ;(priceToUpdate as InferEntityType).rules_count = upsertedPriceRules.length } else if ( // In the case price_rules is provided but without any rules, we delete the existing rules isDefined(priceToUpdate.price_rules) && priceToUpdate.price_rules.length === 0 ) { const priceRuleToDelete = existingPrice?.price_rules?.map((r) => r.id) if (priceRuleToDelete?.length) { await this.priceRuleService_.delete( priceRuleToDelete, sharedContext ) } ;(priceToUpdate as InferEntityType).rules_count = 0 } else { // @ts-expect-error - we want to delete the rules_count property in any case even if provided by mistake delete (priceToUpdate as InferEntityType).rules_count } // We don't want to persist the price_rules in the database through the price service as it would not work delete priceToUpdate.price_rules } updatedPrices = await this.priceService_.update( pricesToUpdate, sharedContext ) } if (pricesToDelete.length > 0) { await this.priceService_.delete(pricesToDelete, sharedContext) } const priceSets = await this.priceSetService_.list( { id: normalizedData.map(({ id }) => id) }, { relations: ["prices", "prices.price_rules"], }, sharedContext ) const upsertedPricesMap = new Map[]>() const upsertedPrices = [...createdPrices, ...updatedPrices] upsertedPrices.forEach((price) => { upsertedPricesMap.set(price.price_set_id, [ ...(upsertedPricesMap.get(price.price_set_id) || []), price, ]) }) // re assign the prices to the price sets to not have to refetch after the transaction and keep the bahaviour the same as expected. If the user needs more data, he can still re list the price set with the expected fields and relations that he needs priceSets.forEach((ps) => { ps.prices = upsertedPricesMap.get(ps.id) || [] }) return priceSets } private async normalizeUpdateData(data: ServiceTypes.UpdatePriceSetInput[]) { return data.map((priceSet) => { return { ...priceSet, prices: this.normalizePrices( priceSet.prices?.map((p) => ({ ...p, price_set_id: priceSet.id })), [] ), } }) } private normalizePrices( data: CreatePricesDTO[] | undefined, existingPrices: PricingTypes.PriceDTO[], priceListId?: string | undefined ) { const pricesToUpsert = new Map< string, CreatePricesDTO & { price_rules?: CreatePriceRuleDTO[] } >() const existingPricesMap = new Map() Array.from(existingPrices ?? []).forEach((price) => { existingPricesMap.set(hashPrice(price), price) }) const ruleOperatorsSet = new Set( Object.values(PricingRuleOperator) ) const validOperatorsList = Array.from(ruleOperatorsSet).join(", ") const invalidOperatorError = `operator should be one of ${validOperatorsList}` data?.forEach((price) => { const cleanRules = price.rules ? removeNullish(price.rules) : {} const rules = Object.entries(cleanRules).flatMap(([attribute, value]) => { if (Array.isArray(value)) { return value.map((customRule) => { if (!ruleOperatorsSet.has(customRule.operator)) { throw new MedusaError( MedusaError.Types.INVALID_DATA, invalidOperatorError ) } if (typeof customRule.value !== "number") { throw new MedusaError( MedusaError.Types.INVALID_DATA, `value should be a number` ) } return { attribute, operator: customRule.operator, // TODO: we throw above if value is not a number, but the model expect the value to be a string value: customRule.value.toString(), } }) } return { attribute, value, } }) const entry = price as ServiceTypes.UpsertPriceDTO const hasRulesInput = isPresent(price.rules) entry.price_list_id = priceListId if (hasRulesInput) { entry.price_rules = rules entry.rules_count = rules.length delete (entry as CreatePricesDTO).rules } const entryHash = hashPrice(entry) // We want to keep the existing rules as they might already have ids, but any other data should come from the updated input const existing = existingPricesMap.get(entryHash) if (existing) { entry.id = existing.id ?? entry.id entry.price_rules = existing.price_rules ?? entry.price_rules } pricesToUpsert.set(entryHash, entry) }) return Array.from(pricesToUpsert.values()) } async addPrices( data: AddPricesDTO, sharedContext?: Context ): Promise async addPrices( data: AddPricesDTO[], sharedContext?: Context ): Promise @InjectManager() @EmitEvents() async addPrices( data: AddPricesDTO | AddPricesDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { const input = Array.isArray(data) ? data : [data] await this.addPrices_(input, sharedContext) const dbPrices = await this.listPriceSets( { id: input.map((d) => d.priceSetId) }, { relations: ["prices"] }, sharedContext ) const orderedPriceSets = input.map((inputItem) => { return dbPrices.find((p) => p.id === inputItem.priceSetId)! }) const serializedOrderedPriceSets = await this.baseRepository_.serialize< PricingTypes.PriceSetDTO[] >(Array.isArray(data) ? orderedPriceSets : orderedPriceSets[0]) try { return serializedOrderedPriceSets } finally { this.pricingRepository_.clearAvailableAttributes?.() } } @InjectManager() @EmitEvents() // @ts-ignore async createPriceLists( data: PricingTypes.CreatePriceListDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { const priceLists = await this.createPriceLists_(data, sharedContext) try { return await this.baseRepository_.serialize( priceLists ) } finally { this.pricingRepository_.clearAvailableAttributes?.() } } @InjectManager() @EmitEvents() // @ts-ignore async updatePriceLists( data: PricingTypes.UpdatePriceListDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { const priceLists = await this.updatePriceLists_(data, sharedContext) try { return await this.baseRepository_.serialize( priceLists ) } finally { this.pricingRepository_.clearAvailableAttributes?.() } } @InjectManager() @EmitEvents() async updatePriceListPrices( data: PricingTypes.UpdatePriceListPricesDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { const prices = await this.updatePriceListPrices_(data, sharedContext) try { return await this.baseRepository_.serialize( prices ) } finally { this.pricingRepository_.clearAvailableAttributes?.() } } @InjectManager() @EmitEvents() async removePrices( ids: string[], @MedusaContext() sharedContext: Context = {} ): Promise { try { await this.removePrices_(ids, sharedContext) } finally { this.pricingRepository_.clearAvailableAttributes?.() } } @InjectManager() @EmitEvents() async addPriceListPrices( data: PricingTypes.AddPriceListPricesDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { const prices = await this.addPriceListPrices_(data, sharedContext) try { return await this.baseRepository_.serialize( prices ) } finally { this.pricingRepository_.clearAvailableAttributes?.() } } @InjectManager() @EmitEvents() async setPriceListRules( data: PricingTypes.SetPriceListRulesDTO, @MedusaContext() sharedContext: Context = {} ): Promise { const [priceList] = await this.setPriceListRules_([data], sharedContext) try { return await this.baseRepository_.serialize( priceList ) } finally { this.pricingRepository_.clearAvailableAttributes?.() } } @InjectManager() @EmitEvents() async removePriceListRules( data: PricingTypes.RemovePriceListRulesDTO, @MedusaContext() sharedContext: Context = {} ): Promise { const [priceList] = await this.removePriceListRules_([data], sharedContext) return await this.baseRepository_.serialize( priceList ) } // @ts-expect-error async createPricePreferences( data: PricingTypes.CreatePricePreferenceDTO, sharedContext?: Context ): Promise async createPricePreferences( data: PricingTypes.CreatePricePreferenceDTO[], sharedContext?: Context ): Promise @InjectManager() @EmitEvents() async createPricePreferences( data: | PricingTypes.CreatePricePreferenceDTO | PricingTypes.CreatePricePreferenceDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { const normalized = Array.isArray(data) ? data : [data] const preferences = await this.createPricePreferences_( normalized, sharedContext ) const serialized = await this.baseRepository_.serialize< PricePreferenceDTO[] >(preferences) return Array.isArray(data) ? serialized : serialized[0] } async upsertPricePreferences( data: UpsertPricePreferenceDTO[], sharedContext?: Context ): Promise async upsertPricePreferences( data: UpsertPricePreferenceDTO, sharedContext?: Context ): Promise @InjectManager() @EmitEvents() async upsertPricePreferences( data: UpsertPricePreferenceDTO | UpsertPricePreferenceDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { const result = await this.upsertPricePreferences_(data, sharedContext) return await this.baseRepository_.serialize< PricePreferenceDTO[] | PricePreferenceDTO >(Array.isArray(data) ? result : result[0]) } @InjectTransactionManager() protected async upsertPricePreferences_( data: UpsertPricePreferenceDTO | UpsertPricePreferenceDTO[], @MedusaContext() sharedContext: Context = {} ): Promise[]> { const input = Array.isArray(data) ? data : [data] const forUpdate = input.filter( ( pricePreference ): pricePreference is ServiceTypes.UpdatePricePreferenceInput => !!pricePreference.id ) const forCreate = input.filter( (pricePreference): pricePreference is CreatePricePreferenceDTO => !pricePreference.id ) const operations: Promise[]>[] = [] if (forCreate.length) { operations.push(this.createPricePreferences_(forCreate, sharedContext)) } if (forUpdate.length) { operations.push(this.updatePricePreferences_(forUpdate, sharedContext)) } const result = (await promiseAll(operations)).flat() return result } // @ts-expect-error async updatePricePreferences( id: string, data: PricingTypes.UpdatePricePreferenceDTO, sharedContext?: Context ): Promise // @ts-expect-error async updatePricePreferences( selector: PricingTypes.FilterablePricePreferenceProps, data: PricingTypes.UpdatePricePreferenceDTO, sharedContext?: Context ): Promise @InjectManager() @EmitEvents() // @ts-expect-error async updatePricePreferences( idOrSelector: string | PricingTypes.FilterablePricePreferenceProps, data: PricingTypes.UpdatePricePreferenceDTO, @MedusaContext() sharedContext: Context = {} ): Promise { let normalizedInput: ServiceTypes.UpdatePricePreferenceInput[] = [] if (isString(idOrSelector)) { // Check if the ID exists, it will throw if not. await this.pricePreferenceService_.retrieve( idOrSelector, {}, sharedContext ) normalizedInput = [{ id: idOrSelector, ...data }] } else { const pricePreferences = await this.pricePreferenceService_.list( idOrSelector, {}, sharedContext ) normalizedInput = pricePreferences.map((pricePreference) => ({ id: pricePreference.id, ...data, })) } const updateResult = await this.updatePricePreferences_( normalizedInput, sharedContext ) const pricePreferences = await this.baseRepository_.serialize< PricePreferenceDTO[] | PricePreferenceDTO >(updateResult) return isString(idOrSelector) ? pricePreferences[0] : pricePreferences } @InjectTransactionManager() protected async createPricePreferences_( data: PricingTypes.CreatePricePreferenceDTO[], @MedusaContext() sharedContext: Context = {} ) { const preferences = await this.pricePreferenceService_.create( data.map((d) => ({ ...d, is_tax_inclusive: d.is_tax_inclusive ?? false, })), sharedContext ) return preferences } @InjectTransactionManager() protected async updatePricePreferences_( data: PricingTypes.UpdatePricePreferenceDTO[], @MedusaContext() sharedContext: Context = {} ) { const preferences = await this.pricePreferenceService_.update( data, sharedContext ) return preferences } @InjectTransactionManager() protected async createPriceSets_( data: PricingTypes.CreatePriceSetDTO[], @MedusaContext() sharedContext: Context = {} ) { const input = Array.isArray(data) ? data : [data] const toCreate = input.map((inputData) => { const entry = { ...inputData, prices: this.normalizePrices(inputData.prices, []), } return entry }) // Bulk create price sets const priceSets = await this.priceSetService_.create( toCreate, sharedContext ) return priceSets } @InjectTransactionManager() protected async addPrices_( input: AddPricesDTO[], @MedusaContext() sharedContext: Context = {} ) { const prices = input.flatMap((data) => { return data.prices.map((price) => { ;(price as PricingTypes.PriceDTO).price_set_id = data.priceSetId return price }) }) const priceConstraints = buildPreNormalizationPriceConstraintsFromData(prices) const [priceSets, priceSetPrices] = await promiseAll([ this.listPriceSets( { id: input.map((d) => d.priceSetId) }, {}, sharedContext ), this.priceService_.list( priceConstraints, { relations: ["price_rules"] }, sharedContext ), ]) const existingPrices = priceSetPrices as unknown as PricingTypes.PriceDTO[] const pricesToUpsert = input .flatMap((addPrice) => this.normalizePrices(addPrice.prices, existingPrices) ) .filter(Boolean) as ServiceTypes.UpsertPriceDTO[] const priceSetMap = new Map( priceSets.map((p) => [p.id, p]) ) pricesToUpsert.forEach((price) => { const priceSet = priceSetMap.get(price.price_set_id) if (!priceSet) { throw new MedusaError( MedusaError.Types.INVALID_DATA, `Price set with id: ${price.price_set_id} not found` ) } }) const { entities } = await this.priceService_.upsertWithReplace( pricesToUpsert, { relations: ["price_rules"] }, sharedContext ) return entities } @InjectTransactionManager() protected async createPriceLists_( data: PricingTypes.CreatePriceListDTO[], @MedusaContext() sharedContext: Context = {} ) { const normalized = this.normalizePriceListDate(data) const priceListsToCreate: ServiceTypes.CreatePriceListDTO[] = normalized.map((priceListData) => { const entry = { ...priceListData, rules: undefined, } as ServiceTypes.CreatePriceListDTO if (priceListData.prices) { entry.prices = this.normalizePrices( priceListData.prices, [] ) as ServiceTypes.UpsertPriceDTO[] } if (priceListData.rules) { const cleanRules = priceListData.rules ? removeNullish(priceListData.rules) : {} const rules = Object.entries(cleanRules) const numberOfRules = rules.length const rulesDataMap = new Map() rules.map(([attribute, value]) => { const rule = { attribute, value, } rulesDataMap.set(JSON.stringify(rule), rule) }) entry.price_list_rules = Array.from(rulesDataMap.values()) entry.rules_count = numberOfRules } return entry }) const priceLists = await this.priceListService_.create( priceListsToCreate, sharedContext ) return priceLists } @InjectTransactionManager() protected async updatePriceLists_( data: PricingTypes.UpdatePriceListDTO[], @MedusaContext() sharedContext: Context = {} ) { const existingPriceLists = await this.priceListService_.list( { id: data.map((d) => d.id) }, {}, sharedContext ) if (existingPriceLists.length !== data.length) { const diff = arrayDifference( data.map((d) => d.id), existingPriceLists.map((p) => p.id) ) throw new MedusaError( MedusaError.Types.INVALID_DATA, `Price lists with ids: '${diff.join(", ")}' not found` ) } const normalizedData = this.normalizePriceListDate(data).map( (priceList) => { const entry: Partial = { ...priceList, rules: undefined, price_list_rules: undefined, } if (typeof priceList.rules === "object") { const cleanRules = priceList.rules ? removeNullish(priceList.rules) : {} const rules = Object.entries(cleanRules) const numberOfRules = rules.length const rulesDataMap = new Map() rules.map(([attribute, value]) => { const rule = { attribute, value, } rulesDataMap.set(JSON.stringify(rule), rule) }) entry.price_list_rules = Array.from(rulesDataMap.values()) entry.rules_count = numberOfRules } return entry } ) const { entities } = await this.priceListService_.upsertWithReplace( normalizedData, { relations: ["price_list_rules"], } ) return entities } @InjectTransactionManager() protected async updatePriceListPrices_( data: PricingTypes.UpdatePriceListPricesDTO[], sharedContext: Context = {} ): Promise[]> { const priceListIds = data.map((p) => p.price_list_id) const prices = data.flatMap((p) => p.prices) const priceConstraints = buildPreNormalizationPriceConstraintsFromData( prices, priceListIds ) const [priceLists, priceListPrices] = await promiseAll([ this.priceListService_.list({ id: priceListIds }, {}, sharedContext), this.priceService_.list( priceConstraints, { relations: ["price_rules"] }, sharedContext ), ]) const existingPrices = priceListPrices as unknown as PricingTypes.PriceDTO[] const pricesToUpsert = data .flatMap((addPrice) => this.normalizePrices( addPrice.prices as ServiceTypes.UpsertPriceDTO[], existingPrices, addPrice.price_list_id ) ) .filter(Boolean) as ServiceTypes.UpsertPriceDTO[] const priceListMap = new Map(priceLists.map((p) => [p.id, p])) for (const { price_list_id: priceListId } of data) { const priceList = priceListMap.get(priceListId) if (!priceList) { throw new MedusaError( MedusaError.Types.INVALID_DATA, `Price list with id: ${priceListId} not found` ) } } const { entities } = await this.priceService_.upsertWithReplace( pricesToUpsert, { relations: ["price_rules"] }, sharedContext ) return entities } @InjectTransactionManager() protected async removePrices_( ids: string[], sharedContext: Context = {} ): Promise { await this.priceService_.delete(ids, sharedContext) } @InjectTransactionManager() protected async addPriceListPrices_( data: PricingTypes.AddPriceListPricesDTO[], sharedContext: Context = {} ): Promise[]> { const priceListIds = data.map((p) => p.price_list_id) const prices = data.flatMap((p) => p.prices) const priceConstraints = buildPreNormalizationPriceConstraintsFromData( prices, priceListIds ) const [priceLists, priceListPrices] = await promiseAll([ this.priceListService_.list({ id: priceListIds }, {}, sharedContext), this.priceService_.list( priceConstraints, { relations: ["price_rules"] }, sharedContext ), ]) const existingPrices = priceListPrices as unknown as PricingTypes.PriceDTO[] const pricesToUpsert = data .flatMap((addPrice) => this.normalizePrices( addPrice.prices, existingPrices, addPrice.price_list_id ) ) .filter(Boolean) as ServiceTypes.UpsertPriceDTO[] const priceListMap = new Map(priceLists.map((p) => [p.id, p])) pricesToUpsert.forEach((price) => { const priceList = priceListMap.get(price.price_list_id!) if (!priceList) { throw new MedusaError( MedusaError.Types.INVALID_DATA, `Price list with id: ${price.price_list_id} not found` ) } }) const { entities } = await this.priceService_.upsertWithReplace( pricesToUpsert, { relations: ["price_rules"] }, sharedContext ) return entities } @InjectTransactionManager() protected async setPriceListRules_( data: PricingTypes.SetPriceListRulesDTO[], sharedContext: Context = {} ): Promise[]> { // TODO: re think this method const priceLists = await this.priceListService_.list( { id: data.map((d) => d.price_list_id) }, { relations: ["price_list_rules"], }, sharedContext ) const rulesMap = new Map() data.forEach((rule) => { if (!rulesMap.has(rule.price_list_id)) { rulesMap.set(rule.price_list_id, []) } Object.entries(rule.rules).forEach(([key, value]) => { rulesMap.get(rule.price_list_id).push([key, value]) }) }) const priceListsUpsert = priceLists .map((priceList) => { const priceListRules = priceList.price_list_rules as unknown as Collection< InferEntityType > const allRules = new Map( priceListRules.toArray().map((r) => [r.attribute, r.value]) ) const rules = rulesMap.get(priceList.id) if (!rules?.length) { return } rules.forEach(([key, value]) => { allRules.set(key, value) }) return { ...priceList, rules_count: allRules.size, price_list_rules: Array.from(allRules).map(([attribute, value]) => ({ attribute, value, })), } }) .filter(Boolean) const { entities } = await this.priceListService_.upsertWithReplace( priceListsUpsert, { relations: ["price_list_rules"] }, sharedContext ) return entities } @InjectTransactionManager() protected async removePriceListRules_( data: PricingTypes.RemovePriceListRulesDTO[], sharedContext: Context = {} ): Promise[]> { // TODO: re think this method const priceLists = await this.priceListService_.list( { id: data.map((d) => d.price_list_id) }, { relations: ["price_list_rules"], }, sharedContext ) const rulesMap = new Map() data.forEach((rule) => { if (!rulesMap.has(rule.price_list_id)) { rulesMap.set(rule.price_list_id, []) } rule.rules.forEach((key) => { rulesMap.get(rule.price_list_id).push([key, undefined]) }) }) const priceListsUpsert = priceLists .map((priceList) => { const priceListRules = priceList.price_list_rules as unknown as Collection< InferEntityType > const allRules = new Map( priceListRules.toArray().map((r) => [r.attribute, r.value]) ) const rules = rulesMap.get(priceList.id) if (!rules?.length) { return } rules.forEach(([key, value]) => { allRules.set(key, value) }) return { ...priceList, rules_count: allRules.size, price_list_rules: Array.from(allRules) .map(([attribute, value]) => ({ attribute, value, })) .filter((r) => !!r.value), } }) .filter(Boolean) const { entities } = await this.priceListService_.upsertWithReplace( priceListsUpsert, { relations: ["price_list_rules"] }, sharedContext ) return entities } protected normalizePriceListDate( data: ( | ServiceTypes.UpdatePriceListDTO | ServiceTypes.CreatePriceListDTO | ServiceTypes.CreatePriceListDTO )[] ) { return data.map((priceListData: any) => { validatePriceListDates(priceListData) if (!!priceListData.starts_at) { priceListData.starts_at = GetIsoStringFromDate(priceListData.starts_at) } if (!!priceListData.ends_at) { priceListData.ends_at = GetIsoStringFromDate(priceListData.ends_at) } return priceListData }) } protected normalizePriceSetConfig( config: FindConfig | undefined ) { return { options: { populateWhere: { prices: { price_list_id: null } }, }, ...config, } } } const isTaxInclusive = ( priceRules: InferEntityType[], preferences: InferEntityType[], currencyCode: string, regionId?: string ) => { const regionRule = priceRules?.find( (rule) => rule.attribute === "region_id" && rule.value === regionId ) const regionPreference = preferences.find( (p) => p.attribute === "region_id" && p.value === regionId ) const currencyPreference = preferences.find( (p) => p.attribute === "currency_code" && p.value === currencyCode ) if (regionRule && regionPreference) { return regionPreference.is_tax_inclusive } if (currencyPreference) { return currencyPreference.is_tax_inclusive } return false } const hashPrice = ( price: PricingTypes.PriceDTO | PricingTypes.CreatePricesDTO ): string => { // Build hash using deterministic property order to avoid expensive sort operation // Using a direct string concatenation approach for better performance const parts: string[] = [] if ("currency_code" in price) { parts.push(`cc:${price.currency_code ?? ""}`) } if ("price_set_id" in price) { parts.push(`ps:${price.price_set_id ?? ""}`) } if ("price_list_id" in price) { parts.push(`pl:${price.price_list_id ?? ""}`) } if ("min_quantity" in price) { parts.push(`min:${price.min_quantity ?? ""}`) } if ("max_quantity" in price) { parts.push(`max:${price.max_quantity ?? ""}`) } // Add price rules in a deterministic way if present if ("price_rules" in price && price.price_rules) { const sortedRules = price.price_rules.map( (pr) => `${pr.attribute}=${pr.value}` ) if (sortedRules.length) { parts.push(`rules:${sortedRules.join(",")}`) } } return parts.sort().join("|") } /** * Build targeted database constraints to fetch only prices that could match the incoming data. * Uses the same properties as hashPrice to ensure consistency. */ const buildPreNormalizationPriceConstraintsFromData = ( prices: ( | PricingTypes.CreatePricesDTO | ServiceTypes.UpsertPriceDTO | PricingTypes.UpdatePriceListPriceDTO )[], priceListIds?: string | string[] ): any => { // Separate prices with explicit IDs from those without const pricesWithIds = prices.filter((p) => p.id).map((p) => p.id) // Build unique constraints based on hash properties const constraintSet = new Set() const constraints: any[] = [] for (const price of prices) { // Skip if price has explicit ID (will be queried separately) if (price.id) continue const constraint: any = {} if (price.currency_code !== undefined) { constraint.currency_code = price.currency_code } if ("price_set_id" in price && price.price_set_id !== undefined) { constraint.price_set_id = price.price_set_id } if ("price_list_id" in price && price.price_list_id !== undefined) { constraint.price_list_id = price.price_list_id } if (price.min_quantity !== undefined) { constraint.min_quantity = price.min_quantity } if (price.max_quantity !== undefined) { constraint.max_quantity = price.max_quantity } // Use hash to deduplicate constraints const constraintHash = JSON.stringify(constraint) if (!constraintSet.has(constraintHash)) { constraintSet.add(constraintHash) constraints.push(constraint) } } // Build final query const query: any = {} if (priceListIds) { if (Array.isArray(priceListIds) && priceListIds.length > 0) { query.price_list_id = { $in: priceListIds, } } else { query.price_list_id = priceListIds } } // Combine ID-based and property-based constraints if (pricesWithIds.length && constraints.length) { query.$or = [{ id: pricesWithIds }, ...constraints] } else if (pricesWithIds.length) { query.id = pricesWithIds } else if (constraints.length) { if (constraints.length === 1) { Object.assign(query, constraints[0]) } else { query.$or = constraints } } return query }