Files
medusa-store/packages/modules/pricing/src/services/pricing-module.ts
2024-07-14 18:03:44 +02:00

1535 lines
44 KiB
TypeScript

import {
AddPricesDTO,
Context,
CreatePricePreferenceDTO,
CreatePriceRuleDTO,
CreatePricesDTO,
CreatePriceSetDTO,
DAL,
FindConfig,
InternalModuleDeclaration,
ModuleJoinerConfig,
ModulesSdkTypes,
PricePreferenceDTO,
PriceSetDTO,
PricingContext,
PricingFilters,
PricingRepositoryService,
PricingTypes,
UpsertPricePreferenceDTO,
UpsertPriceSetDTO,
} from "@medusajs/types"
import {
arrayDifference,
deduplicate,
EmitEvents,
GetIsoStringFromDate,
groupBy,
InjectManager,
InjectTransactionManager,
isPresent,
isString,
MedusaContext,
MedusaError,
ModulesSdkUtils,
PriceListType,
promiseAll,
removeNullish,
simpleHash,
} from "@medusajs/utils"
import {
Price,
PriceList,
PriceListRule,
PricePreference,
PriceRule,
PriceSet,
} from "@models"
import { ServiceTypes } from "@types"
import { eventBuilders, validatePriceListDates } from "@utils"
import { CreatePriceListDTO, UpsertPriceDTO } from "src/types/services"
import { joinerConfig } from "../joiner-config"
type InjectedDependencies = {
baseRepository: DAL.RepositoryService
pricingRepository: PricingRepositoryService
priceSetService: ModulesSdkTypes.IMedusaInternalService<any>
priceRuleService: ModulesSdkTypes.IMedusaInternalService<any>
priceService: ModulesSdkTypes.IMedusaInternalService<any>
priceListService: ModulesSdkTypes.IMedusaInternalService<any>
pricePreferenceService: ModulesSdkTypes.IMedusaInternalService<any>
priceListRuleService: ModulesSdkTypes.IMedusaInternalService<any>
}
const generateMethodForModels = {
PriceSet,
PriceList,
PriceListRule,
PriceRule,
Price,
PricePreference,
}
export default class PricingModuleService
extends 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)
implements PricingTypes.IPricingModuleService
{
protected baseRepository_: DAL.RepositoryService
protected readonly pricingRepository_: PricingRepositoryService
protected readonly priceSetService_: ModulesSdkTypes.IMedusaInternalService<PriceSet>
protected readonly priceRuleService_: ModulesSdkTypes.IMedusaInternalService<PriceRule>
protected readonly priceService_: ModulesSdkTypes.IMedusaInternalService<Price>
protected readonly priceListService_: ModulesSdkTypes.IMedusaInternalService<PriceList>
protected readonly priceListRuleService_: ModulesSdkTypes.IMedusaInternalService<PriceListRule>
protected readonly pricePreferenceService_: ModulesSdkTypes.IMedusaInternalService<PricePreference>
constructor(
{
baseRepository,
pricingRepository,
priceSetService,
priceRuleService,
priceService,
pricePreferenceService,
priceListService,
priceListRuleService,
}: InjectedDependencies,
protected readonly moduleDeclaration: InternalModuleDeclaration
) {
// @ts-ignore
super(...arguments)
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
}
@InjectManager("baseRepository_")
// @ts-expect-error
async retrievePriceSet(
id: string,
config?: FindConfig<PriceSetDTO> | undefined,
sharedContext?: Context | undefined
): Promise<PriceSetDTO> {
const priceSet = await this.priceSetService_.retrieve(
id,
this.normalizePriceSetConfig(config),
sharedContext
)
return await this.baseRepository_.serialize<PriceSetDTO>(priceSet)
}
@InjectManager("baseRepository_")
// @ts-expect-error
async listPriceSets(
filters: PricingTypes.FilterablePriceSetProps = {},
config: FindConfig<PricingTypes.PriceSetDTO> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<PriceSetDTO[]> {
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 priceSets
}
@InjectManager("baseRepository_")
// @ts-expect-error
async listAndCountPriceSets(
filters: PricingTypes.FilterablePriceSetProps = {},
config: FindConfig<PricingTypes.PriceSetDTO> = {},
@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) {
return [priceSets, 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
}
return [priceSets, count]
}
@InjectManager("baseRepository_")
async calculatePrices(
pricingFilters: PricingFilters,
pricingContext: PricingContext = { context: {} },
@MedusaContext() sharedContext: Context = {}
): Promise<PricingTypes.CalculatedPriceSet[]> {
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
if (priceListPrice) {
calculatedPrice = priceListPrice
if (priceListPrice.price_list_type === PriceListType.OVERRIDE) {
originalPrice = priceListPrice
}
}
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.priceRuleService_.list(
{ price_id: priceIds },
{ take: null }
)
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 pricingPreferences = await this.pricePreferenceService_.list(
{
$or: Object.entries(pricingContext)
.filter(([key, val]) => {
return key === "region_id" || key === "currency_code"
})
.map(([key, val]) => ({
attribute: key,
value: val,
})),
},
{},
sharedContext
)
const calculatedPrices: PricingTypes.CalculatedPriceSet[] =
pricingFilters.id
.map((priceSetId: string): PricingTypes.CalculatedPriceSet | null => {
const prices = pricesSetPricesMap.get(priceSetId)
if (!prices) {
return null
}
const {
calculatedPrice,
originalPrice,
}: {
calculatedPrice: PricingTypes.CalculatedPriceSetDTO
originalPrice: PricingTypes.CalculatedPriceSetDTO | undefined
} = prices
return {
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:
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:
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,
},
}
})
.filter(Boolean) as PricingTypes.CalculatedPriceSet[]
return JSON.parse(JSON.stringify(calculatedPrices))
}
// @ts-expect-error
async createPriceSets(
data: PricingTypes.CreatePriceSetDTO,
sharedContext?: Context
): Promise<PriceSetDTO>
async createPriceSets(
data: PricingTypes.CreatePriceSetDTO[],
sharedContext?: Context
): Promise<PriceSetDTO[]>
@InjectManager("baseRepository_")
@EmitEvents()
async createPriceSets(
data: PricingTypes.CreatePriceSetDTO | PricingTypes.CreatePriceSetDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<PriceSetDTO | PriceSetDTO[]> {
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)!
})
return await this.baseRepository_.serialize<PriceSetDTO[] | PriceSetDTO>(
Array.isArray(data) ? results : results[0]
)
}
async upsertPriceSets(
data: UpsertPriceSetDTO[],
sharedContext?: Context
): Promise<PriceSetDTO[]>
async upsertPriceSets(
data: UpsertPriceSetDTO,
sharedContext?: Context
): Promise<PriceSetDTO>
@InjectManager("baseRepository_")
async upsertPriceSets(
data: UpsertPriceSetDTO | UpsertPriceSetDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<PriceSetDTO | PriceSetDTO[]> {
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<PriceSet[]>[] = []
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 await this.baseRepository_.serialize<PriceSetDTO[] | PriceSetDTO>(
Array.isArray(data) ? result : result[0]
)
}
// @ts-expect-error
async updatePriceSets(
id: string,
data: PricingTypes.UpdatePriceSetDTO,
sharedContext?: Context
): Promise<PriceSetDTO>
async updatePriceSets(
selector: PricingTypes.FilterablePriceSetProps,
data: PricingTypes.UpdatePriceSetDTO,
sharedContext?: Context
): Promise<PriceSetDTO[]>
@InjectManager("baseRepository_")
async updatePriceSets(
idOrSelector: string | PricingTypes.FilterablePriceSetProps,
data: PricingTypes.UpdatePriceSetDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<PriceSetDTO | PriceSetDTO[]> {
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 priceSets = await this.baseRepository_.serialize<
PriceSetDTO[] | PriceSetDTO
>(updateResult)
return isString(idOrSelector) ? priceSets[0] : priceSets
}
@InjectTransactionManager("baseRepository_")
protected async updatePriceSets_(
data: ServiceTypes.UpdatePriceSetInput[],
@MedusaContext() sharedContext: Context = {}
): Promise<PriceSet[]> {
// TODO: Since money IDs are rarely passed, this will delete all previous data and insert new entries.
// We can make the `insert` inside upsertWithReplace do an `upsert` instead to avoid this
const normalizedData = await this.normalizeUpdateData(data)
const prices = normalizedData.flatMap((priceSet) => priceSet.prices || [])
const { entities: upsertedPrices } =
await this.priceService_.upsertWithReplace(
prices,
{ relations: ["price_rules"] },
sharedContext
)
const priceSetsToUpsert = normalizedData.map((priceSet) => {
const { prices, ...rest } = priceSet
return {
...rest,
prices: upsertedPrices
.filter((p) => p.price_set_id === priceSet.id)
.map((price) => {
// @ts-ignore
delete price.price_rules
return price
}),
}
})
const { entities: priceSets } =
await this.priceSetService_.upsertWithReplace(
priceSetsToUpsert,
{ relations: ["prices"] },
sharedContext
)
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<string, PricingTypes.PriceDTO>()
existingPrices?.forEach((price) => {
existingPricesMap.set(hashPrice(price), price)
})
data?.forEach((price) => {
const cleanRules = price.rules ? removeNullish(price.rules) : {}
const ruleEntries = Object.entries(cleanRules)
const rules = ruleEntries.map(([attribute, value]) => {
return {
attribute,
value,
}
})
const hasRulesInput = isPresent(price.rules)
const entry = {
...price,
price_list_id: priceListId,
price_rules: hasRulesInput ? rules : undefined,
rules_count: hasRulesInput ? ruleEntries.length : undefined,
} as UpsertPriceDTO
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)
pricesToUpsert.set(entryHash, {
...entry,
id: existing?.id ?? entry.id,
price_rules: existing?.price_rules ?? entry.price_rules,
})
return entry
})
return Array.from(pricesToUpsert.values())
}
async addPrices(
data: AddPricesDTO,
sharedContext?: Context
): Promise<PricingTypes.PriceSetDTO>
async addPrices(
data: AddPricesDTO[],
sharedContext?: Context
): Promise<PricingTypes.PriceSetDTO[]>
@InjectManager("baseRepository_")
@EmitEvents()
async addPrices(
data: AddPricesDTO | AddPricesDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<PricingTypes.PriceSetDTO[] | PricingTypes.PriceSetDTO> {
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)!
})
return Array.isArray(data) ? orderedPriceSets : orderedPriceSets[0]
}
@InjectManager("baseRepository_")
@EmitEvents()
// @ts-ignore
async createPriceLists(
data: PricingTypes.CreatePriceListDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<PricingTypes.PriceListDTO[]> {
const priceLists = await this.createPriceLists_(data, sharedContext)
return await this.baseRepository_.serialize<PricingTypes.PriceListDTO[]>(
priceLists
)
}
@InjectTransactionManager("baseRepository_")
// @ts-ignore
async updatePriceLists(
data: PricingTypes.UpdatePriceListDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<PricingTypes.PriceListDTO[]> {
const priceLists = await this.updatePriceLists_(data, sharedContext)
return await this.baseRepository_.serialize<PricingTypes.PriceListDTO[]>(
priceLists
)
}
@InjectManager("baseRepository_")
async updatePriceListPrices(
data: PricingTypes.UpdatePriceListPricesDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<PricingTypes.PriceDTO[]> {
const prices = await this.updatePriceListPrices_(data, sharedContext)
return await this.baseRepository_.serialize<PricingTypes.PriceDTO[]>(prices)
}
@InjectManager("baseRepository_")
async removePrices(
ids: string[],
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
await this.removePrices_(ids, sharedContext)
}
@InjectManager("baseRepository_")
@EmitEvents()
async addPriceListPrices(
data: PricingTypes.AddPriceListPricesDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<PricingTypes.PriceDTO[]> {
const prices = await this.addPriceListPrices_(data, sharedContext)
return await this.baseRepository_.serialize<PricingTypes.PriceDTO[]>(prices)
}
@InjectManager("baseRepository_")
async setPriceListRules(
data: PricingTypes.SetPriceListRulesDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<PricingTypes.PriceListDTO> {
const [priceList] = await this.setPriceListRules_([data], sharedContext)
return await this.baseRepository_.serialize<PricingTypes.PriceListDTO>(
priceList
)
}
@InjectManager("baseRepository_")
async removePriceListRules(
data: PricingTypes.RemovePriceListRulesDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<PricingTypes.PriceListDTO> {
const [priceList] = await this.removePriceListRules_([data], sharedContext)
return await this.baseRepository_.serialize<PricingTypes.PriceListDTO>(
priceList
)
}
// @ts-expect-error
async createPricePreferences(
data: PricingTypes.CreatePricePreferenceDTO,
sharedContext?: Context
): Promise<PricePreferenceDTO>
async createPricePreferences(
data: PricingTypes.CreatePricePreferenceDTO[],
sharedContext?: Context
): Promise<PricePreferenceDTO[]>
@InjectManager("baseRepository_")
@EmitEvents()
async createPricePreferences(
data:
| PricingTypes.CreatePricePreferenceDTO
| PricingTypes.CreatePricePreferenceDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<PricePreferenceDTO | PricePreferenceDTO[]> {
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<PricePreferenceDTO[]>
async upsertPricePreferences(
data: UpsertPricePreferenceDTO,
sharedContext?: Context
): Promise<PricePreferenceDTO>
@InjectManager("baseRepository_")
async upsertPricePreferences(
data: UpsertPricePreferenceDTO | UpsertPricePreferenceDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<PricePreferenceDTO | PricePreferenceDTO[]> {
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<PricePreference[]>[] = []
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 await this.baseRepository_.serialize<
PricePreferenceDTO[] | PricePreferenceDTO
>(Array.isArray(data) ? result : result[0])
}
// @ts-expect-error
async updatePricePreferences(
id: string,
data: PricingTypes.UpdatePricePreferenceDTO,
sharedContext?: Context
): Promise<PricePreferenceDTO>
async updatePricePreferences(
selector: PricingTypes.FilterablePricePreferenceProps,
data: PricingTypes.UpdatePricePreferenceDTO,
sharedContext?: Context
): Promise<PricePreferenceDTO[]>
@InjectManager("baseRepository_")
async updatePricePreferences(
idOrSelector: string | PricingTypes.FilterablePricePreferenceProps,
data: PricingTypes.UpdatePricePreferenceDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<PricePreferenceDTO | PricePreferenceDTO[]> {
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("baseRepository_")
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("baseRepository_")
protected async updatePricePreferences_(
data: PricingTypes.UpdatePricePreferenceDTO[],
@MedusaContext() sharedContext: Context = {}
) {
const preferences = await this.pricePreferenceService_.update(
data,
sharedContext
)
return preferences
}
@InjectTransactionManager("baseRepository_")
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 createdPriceSets = await this.priceSetService_.create(
toCreate,
sharedContext
)
const eventsData = createdPriceSets.reduce(
(eventsData, priceSet) => {
eventsData.priceSets.push({
id: priceSet.id,
})
priceSet.prices.map((price) => {
eventsData.prices.push({
id: price.id,
})
price.price_rules.map((priceRule) => {
eventsData.priceRules.push({
id: priceRule.id,
})
})
})
return eventsData
},
{
priceSets: [],
priceRules: [],
prices: [],
} as {
priceSets: { id: string }[]
priceRules: { id: string }[]
prices: { id: string }[]
}
)
eventBuilders.createdPriceSet({
data: eventsData.priceSets,
sharedContext,
})
eventBuilders.createdPrice({
data: eventsData.prices,
sharedContext,
})
eventBuilders.createdPriceRule({
data: eventsData.priceRules,
sharedContext,
})
return createdPriceSets
}
@InjectTransactionManager("baseRepository_")
protected async addPrices_(
input: AddPricesDTO[],
@MedusaContext() sharedContext: Context = {}
) {
const priceSets = await this.listPriceSets(
{ id: input.map((d) => d.priceSetId) },
{ take: null, relations: ["prices", "prices.price_rules"] },
sharedContext
)
const existingPrices = priceSets
.map((p) => p.prices)
.flat() as PricingTypes.PriceDTO[]
const pricesToUpsert = input
.map((addPrice) =>
this.normalizePrices(
addPrice.prices?.map((p) => ({
...p,
price_set_id: addPrice.priceSetId,
})),
existingPrices
)
)
.filter(Boolean)
.flat() as UpsertPriceDTO[]
const priceSetMap = new Map<string, PriceSetDTO>(
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, performedActions } =
await this.priceService_.upsertWithReplace(
pricesToUpsert,
{ relations: ["price_rules"] },
sharedContext
)
eventBuilders.createdPrice({
data: performedActions.created[Price.name] ?? [],
sharedContext,
})
eventBuilders.updatedPrice({
data: performedActions.updated[Price.name] ?? [],
sharedContext,
})
eventBuilders.deletedPrice({
data: performedActions.deleted[Price.name] ?? [],
sharedContext,
})
eventBuilders.createdPriceRule({
data: performedActions.created[PriceRule.name] ?? [],
sharedContext,
})
eventBuilders.updatedPriceRule({
data: performedActions.updated[PriceRule.name] ?? [],
sharedContext,
})
eventBuilders.deletedPriceRule({
data: performedActions.deleted[PriceRule.name] ?? [],
sharedContext,
})
return entities
}
@InjectTransactionManager("baseRepository_")
protected async createPriceLists_(
data: PricingTypes.CreatePriceListDTO[],
@MedusaContext() sharedContext: Context = {}
) {
const normalized = this.normalizePriceListDate(data)
const priceListsToCreate: CreatePriceListDTO[] = normalized.map(
(priceListData) => {
const entry = {
...priceListData,
rules: undefined,
} as CreatePriceListDTO
if (priceListData.prices) {
entry.prices = this.normalizePrices(
priceListData.prices,
[]
) as 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
)
/**
* Preparing data for emitting events
*/
const eventsData = priceLists.reduce(
(eventsData, priceList) => {
eventsData.priceList.push({
id: priceList.id,
})
priceList.price_list_rules.map((listRule) => {
eventsData.priceListRules.push({
id: listRule.id,
})
})
priceList.prices.map((price) => {
eventsData.prices.push({
id: price.id,
})
price.price_rules.map((priceRule) => {
eventsData.priceRules.push({
id: priceRule.id,
})
})
})
return eventsData
},
{
priceList: [],
priceListRules: [],
priceRules: [],
prices: [],
} as {
priceList: { id: string }[]
priceListRules: { id: string }[]
priceRules: { id: string }[]
prices: { id: string }[]
}
)
/**
* Emitting events for all created entities
*/
eventBuilders.createdPriceList({
data: eventsData.priceList,
sharedContext,
})
eventBuilders.createdPriceListRule({
data: eventsData.priceListRules,
sharedContext,
})
eventBuilders.createdPrice({
data: eventsData.prices,
sharedContext,
})
eventBuilders.createdPriceRule({
data: eventsData.priceRules,
sharedContext,
})
return priceLists
}
@InjectTransactionManager("baseRepository_")
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<CreatePriceListDTO> = {
...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("baseRepository_")
protected async updatePriceListPrices_(
data: PricingTypes.UpdatePriceListPricesDTO[],
sharedContext: Context = {}
): Promise<Price[]> {
const priceLists = await this.listPriceLists(
{ id: data.map((p) => p.price_list_id) },
{ take: null, relations: ["prices", "prices.price_rules"] },
sharedContext
)
const existingPrices = priceLists
.map((p) => p.prices ?? [])
.flat() as PricingTypes.PriceDTO[]
const pricesToUpsert = data
.map((addPrice) =>
this.normalizePrices(
addPrice.prices as UpsertPriceDTO[],
existingPrices,
addPrice.price_list_id
)
)
.filter(Boolean)
.flat() as UpsertPriceDTO[]
const priceListMap = new Map(priceLists.map((p) => [p.id, p]))
for (const { price_list_id: priceListId, prices } 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("baseRepository_")
protected async removePrices_(
ids: string[],
sharedContext: Context = {}
): Promise<void> {
await this.priceService_.delete(ids, sharedContext)
}
@InjectTransactionManager("baseRepository_")
protected async addPriceListPrices_(
data: PricingTypes.AddPriceListPricesDTO[],
sharedContext: Context = {}
): Promise<Price[]> {
const priceLists = await this.listPriceLists(
{ id: data.map((p) => p.price_list_id) },
{ take: null, relations: ["prices", "prices.price_rules"] },
sharedContext
)
const existingPrices = priceLists
.map((p) => p.prices ?? [])
.flat() as PricingTypes.PriceDTO[]
const pricesToUpsert = data
.map((addPrice) =>
this.normalizePrices(
addPrice.prices,
existingPrices,
addPrice.price_list_id
)
)
.filter(Boolean)
.flat() as 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, performedActions } =
await this.priceService_.upsertWithReplace(
pricesToUpsert,
{ relations: ["price_rules"] },
sharedContext
)
eventBuilders.createdPrice({
data: performedActions.created[Price.name] ?? [],
sharedContext,
})
eventBuilders.updatedPrice({
data: performedActions.updated[Price.name] ?? [],
sharedContext,
})
eventBuilders.deletedPrice({
data: performedActions.deleted[Price.name] ?? [],
sharedContext,
})
eventBuilders.createdPriceRule({
data: performedActions.created[PriceRule.name] ?? [],
sharedContext,
})
eventBuilders.updatedPriceRule({
data: performedActions.updated[PriceRule.name] ?? [],
sharedContext,
})
eventBuilders.deletedPriceRule({
data: performedActions.deleted[PriceRule.name] ?? [],
sharedContext,
})
return entities
}
@InjectTransactionManager("baseRepository_")
protected async setPriceListRules_(
data: PricingTypes.SetPriceListRulesDTO[],
sharedContext: Context = {}
): Promise<PriceList[]> {
// 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 allRules = new Map(
priceList.price_list_rules
.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("baseRepository_")
protected async removePriceListRules_(
data: PricingTypes.RemovePriceListRulesDTO[],
sharedContext: Context = {}
): Promise<PriceList[]> {
// 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 allRules = new Map(
priceList.price_list_rules
.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
| 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<PricingTypes.PriceSetDTO> | undefined
) {
return {
options: {
populateWhere: { prices: { price_list_id: null } },
},
...config,
}
}
}
const isTaxInclusive = (
priceRules: PriceRule[],
preferences: PricePreference[],
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 => {
const data = Object.entries({
currency_code: price.currency_code,
price_set_id: "price_set_id" in price ? price.price_set_id ?? null : null,
price_list_id:
"price_list_id" in price ? price.price_list_id ?? null : null,
min_quantity: price.min_quantity ? price.min_quantity.toString() : null,
max_quantity: price.max_quantity ? price.max_quantity.toString() : null,
...("price_rules" in price
? price.price_rules?.reduce((agg, pr) => {
agg[pr.attribute] = pr.value
return agg
}, {})
: {}),
}).sort(([a], [b]) => a.localeCompare(b))
return simpleHash(JSON.stringify(data))
}