fix: Disallow creating duplicate prices (#7866)

* fix: Disallow creating duplicate prices

* fix: Don't pass id to manager create in upsertWithReplace
This commit is contained in:
Stevche Radevski authored and GitHub committed 2024-07-02 17:06:58 +02:00
1 parent 87375db9ef
commit b4aa7fb9a7
12 files changed
+560 -350

No files matched your search

@@ -113,7 +113,9 @@ medusaIntegrationTestRunner({
{ {
amount: 105, amount: 105,
currency_code: region1.currency_code, currency_code: region1.currency_code,
region_id: region1.id, rules: {
region_id: region1.id,
},
variant_id: product1.variants[0].id, variant_id: product1.variants[0].id,
}, },
], ],
@@ -508,84 +510,61 @@ medusaIntegrationTestRunner({
) )
}) })
it.skip("Adds a batch of new prices to a price list overriding existing prices", async () => { it("Adds a batch of new prices to a price list overriding existing prices", async () => {
// BREAKING: There is no support for overriding configuration // BREAKING: The payload of the batch request changed
// BREAKING: The create dataset does an upsert (before an explicit `override` flag was passed)
const payload = { const payload = {
prices: [ create: [
{ {
amount: 45, amount: 45,
currency_code: "usd", currency_code: "usd",
variant_id: "test-variant", variant_id: product1.variants[0].id,
min_quantity: 1001, min_quantity: 1,
max_quantity: 2000, max_quantity: 100,
}, },
{ {
amount: 35, amount: 35,
currency_code: "usd", currency_code: "usd",
variant_id: "test-variant", variant_id: product1.variants[0].id,
min_quantity: 2001, min_quantity: 101,
max_quantity: 3000, max_quantity: 500,
},
{
amount: 25,
currency_code: "usd",
variant_id: "test-variant",
min_quantity: 3001,
max_quantity: 4000,
}, },
], ],
override: true,
} }
const response = await api.post( await api.post(
"/admin/price-lists/pl_no_customer_groups/prices/batch", `/admin/price-lists/${pricelist1.id}/prices/batch`,
payload, payload,
adminHeaders adminHeaders
) )
const response = await api.get(
`/admin/price-lists/${pricelist1.id}`,
adminHeaders
)
expect(response.status).toEqual(200) expect(response.status).toEqual(200)
expect(response.data.price_list.prices.length).toEqual(3) expect(response.data.price_list.prices.length).toEqual(2)
expect(response.data.price_list.prices).toMatchSnapshot([ expect(response.data.price_list.prices).toEqual(
{ expect.arrayContaining([
id: expect.any(String), expect.objectContaining({
price_list_id: "pl_no_customer_groups", id: expect.any(String),
amount: 45, amount: 45,
currency_code: "usd", currency_code: "usd",
variant_id: "test-variant", min_quantity: "1",
min_quantity: 1001, max_quantity: "100",
max_quantity: 2000, variant_id: product1.variants[0].id,
created_at: expect.any(String), }),
updated_at: expect.any(String), expect.objectContaining({
variant: expect.any(Object), id: expect.any(String),
variants: expect.any(Array), amount: 35,
}, currency_code: "usd",
{ min_quantity: "101",
id: expect.any(String), max_quantity: "500",
price_list_id: "pl_no_customer_groups", variant_id: product1.variants[0].id,
amount: 35, }),
currency_code: "usd", ])
variant_id: "test-variant", )
min_quantity: 2001,
max_quantity: 3000,
created_at: expect.any(String),
updated_at: expect.any(String),
variant: expect.any(Object),
variants: expect.any(Array),
},
{
id: expect.any(String),
price_list_id: "pl_no_customer_groups",
amount: 25,
currency_code: "usd",
variant_id: "test-variant",
min_quantity: 3001,
max_quantity: 4000,
created_at: expect.any(String),
updated_at: expect.any(String),
variant: expect.any(Object),
variants: expect.any(Array),
},
])
}) })
it("Adds a batch of new prices where a MA record have a `region_id` instead of `currency_code`", async () => { it("Adds a batch of new prices where a MA record have a `region_id` instead of `currency_code`", async () => {
@@ -596,7 +575,9 @@ medusaIntegrationTestRunner({
amount: 100, amount: 100,
variant_id: product1.variants[0].id, variant_id: product1.variants[0].id,
currency_code: "eur", currency_code: "eur",
region_id: region1.id, rules: {
region_id: region1.id,
},
}, },
{ {
amount: 200, amount: 200,
@@ -640,7 +621,7 @@ medusaIntegrationTestRunner({
id: expect.any(String), id: expect.any(String),
amount: 100, amount: 100,
currency_code: "eur", currency_code: "eur",
// region_id: region1.id, rules: { region_id: region1.id },
variant_id: product1.variants[0].id, variant_id: product1.variants[0].id,
}), }),
expect.objectContaining({ expect.objectContaining({
@@ -2,6 +2,7 @@ import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { import {
AddPriceListPricesDTO, AddPriceListPricesDTO,
CreatePriceListPriceDTO, CreatePriceListPriceDTO,
CreatePriceListPriceWorkflowDTO,
CreatePriceListPricesWorkflowStepDTO, CreatePriceListPricesWorkflowStepDTO,
IPricingModuleService, IPricingModuleService,
} from "@medusajs/types" } from "@medusajs/types"
@@ -22,10 +23,12 @@ export const createPriceListPricesStep = createStep(
const pricesToAdd: CreatePriceListPriceDTO[] = [] const pricesToAdd: CreatePriceListPriceDTO[] = []
for (const price of prices) { for (const price of prices) {
pricesToAdd.push({ const toPush = {
...price, ...price,
price_set_id: variantPriceSetMap[price.variant_id!], price_set_id: variantPriceSetMap[price.variant_id!],
}) } as CreatePriceListPriceDTO
delete (toPush as Partial<CreatePriceListPriceWorkflowDTO>).variant_id
pricesToAdd.push(toPush)
} }
if (pricesToAdd.length) { if (pricesToAdd.length) {
@@ -4,6 +4,7 @@ import {
PriceDTO, PriceDTO,
UpdatePriceListPriceDTO, UpdatePriceListPriceDTO,
UpdatePriceListPricesDTO, UpdatePriceListPricesDTO,
UpdatePriceListPriceWorkflowDTO,
UpdatePriceListPriceWorkflowStepDTO, UpdatePriceListPriceWorkflowStepDTO,
} from "@medusajs/types" } from "@medusajs/types"
import { buildPriceSetPricesForModule } from "@medusajs/utils" import { buildPriceSetPricesForModule } from "@medusajs/utils"
@@ -25,10 +26,13 @@ export const updatePriceListPricesStep = createStep(
const { prices = [], id } = priceListData const { prices = [], id } = priceListData
for (const price of prices) { for (const price of prices) {
pricesToUpdate.push({ const toPush = {
...price, ...price,
price_set_id: variantPriceSetMap[price.variant_id!], price_set_id: variantPriceSetMap[price.variant_id!],
}) } as UpdatePriceListPriceDTO
delete (toPush as Partial<UpdatePriceListPriceWorkflowDTO>).variant_id
pricesToUpdate.push(toPush)
if (price.id) { if (price.id) {
priceIds.push(price.id) priceIds.push(price.id)
@@ -747,16 +747,22 @@ export function mikroOrmBaseRepositoryFactory<T extends object = object>(
entityName: string, entityName: string,
data: any data: any
): Record<string, any> & { id: string } { ): Record<string, any> & { id: string } {
const created = manager.create(entityName, data, { // We set the id to undefined to make sure the entity isn't fetched from the entity map if it is an update,
managed: false, // giving us incorrect data for the bignumberdata field (I though managed: false and persist: false would already do this)
persist: false, const created = manager.create(
}) entityName,
{ ...data, id: undefined },
{
managed: false,
persist: false,
}
)
const resp = { const resp = {
// `create` will omit non-existent fields, but we want to pass the data the user provided through so the correct errors get thrown // `create` will omit non-existent fields, but we want to pass the data the user provided through so the correct errors get thrown
...data, ...data,
...(created as any).__helper.__bignumberdata, ...(created as any).__helper.__bignumberdata,
id: (created as any).id, id: data.id ?? (created as any).id,
} }
// Non-persist relation columns should be removed before we do the upsert. // Non-persist relation columns should be removed before we do the upsert.
@@ -799,7 +799,7 @@ moduleIntegrationTestRunner<IPricingModuleService>({
{ {
id: "price-set-PLN", id: "price-set-PLN",
is_calculated_price_price_list: true, is_calculated_price_price_list: true,
calculated_amount: 116, calculated_amount: 232,
is_original_price_price_list: false, is_original_price_price_list: false,
original_amount: 400, original_amount: 400,
currency_code: "PLN", currency_code: "PLN",
@@ -586,6 +586,63 @@ moduleIntegrationTestRunner<IPricingModuleService>({
}) })
) )
}) })
it("should take the later price when passing two equivalent prices twice", async () => {
const [created] = await service.createPriceLists([
{
title: "test",
description: "test",
starts_at: "10/10/2010",
ends_at: "10/20/2030",
prices: [
{
amount: 400,
currency_code: "EUR",
price_set_id: "price-set-1",
rules: {
region_id: "DE",
},
},
{
amount: 600,
currency_code: "EUR",
price_set_id: "price-set-1",
rules: {
region_id: "DE",
},
},
],
},
])
const [priceList] = await service.listPriceLists(
{
id: [created.id],
},
{
relations: ["prices", "prices.price_rules"],
}
)
expect(priceList).toEqual(
expect.objectContaining({
id: expect.any(String),
prices: [
expect.objectContaining({
rules_count: 1,
price_rules: [
expect.objectContaining({
id: expect.any(String),
value: "DE",
}),
],
amount: 600,
currency_code: "EUR",
}),
],
})
)
})
}) })
describe("addPriceListPrices", () => { describe("addPriceListPrices", () => {
@@ -810,6 +867,69 @@ moduleIntegrationTestRunner<IPricingModuleService>({
}) })
) )
}) })
it("should do an update to the price if an equivalent price already exists", async () => {
const [priceSet] = await service.createPriceSets([{}])
await service.addPriceListPrices([
{
price_list_id: "price-list-1",
prices: [
{
id: "test-price-id",
amount: 123,
currency_code: "EUR",
price_set_id: priceSet.id,
rules: {
region_id: "test",
},
},
],
},
])
await service.addPriceListPrices([
{
price_list_id: "price-list-1",
prices: [
{
id: "test-price-id",
amount: 234,
currency_code: "EUR",
price_set_id: priceSet.id,
rules: {
region_id: "test",
},
},
],
},
])
const [priceList] = await service.listPriceLists(
{ id: ["price-list-1"] },
{
relations: ["prices", "prices.price_rules"],
}
)
expect(priceList).toEqual(
expect.objectContaining({
id: expect.any(String),
prices: expect.arrayContaining([
expect.objectContaining({
price_rules: [
expect.objectContaining({
value: "test",
attribute: "region_id",
}),
],
amount: 234,
currency_code: "EUR",
}),
]),
})
)
})
}) })
describe("removePrices", () => { describe("removePrices", () => {
@@ -398,6 +398,40 @@ moduleIntegrationTestRunner<IPricingModuleService>({
]) ])
) )
}) })
it("should upsert the later price when setting a price set with existing equivalent rules", async () => {
await service.updatePriceSets(id, {
prices: [
{
amount: 100,
currency_code: "USD",
rules: { region_id: "1234" },
},
{
amount: 200,
currency_code: "USD",
rules: { region_id: "1234" },
},
],
})
const priceSet = await service.retrievePriceSet(id, {
relations: ["prices", "prices.price_rules"],
})
expect(priceSet.prices).toEqual([
expect.objectContaining({
amount: 200,
currency_code: "USD",
price_rules: [
expect.objectContaining({
attribute: "region_id",
value: "1234",
}),
],
}),
])
})
}) })
describe("create", () => { describe("create", () => {
@@ -512,6 +546,43 @@ moduleIntegrationTestRunner<IPricingModuleService>({
}) })
) )
}) })
it("should take the later price when passing two prices with equivalent rules", async () => {
await service.createPriceSets([
{
id: "price-set-new",
prices: [
{
amount: 100,
currency_code: "USD",
rules: { region_id: "1234" },
},
{
amount: 200,
currency_code: "USD",
rules: { region_id: "1234" },
},
],
} as unknown as CreatePriceSetDTO,
])
const priceSet = await service.retrievePriceSet("price-set-new", {
relations: ["prices", "prices.price_rules"],
})
expect(priceSet.prices).toEqual([
expect.objectContaining({
amount: 200,
currency_code: "USD",
price_rules: [
expect.objectContaining({
attribute: "region_id",
value: "1234",
}),
],
}),
])
})
}) })
describe("addPrices", () => { describe("addPrices", () => {
@@ -523,7 +594,7 @@ moduleIntegrationTestRunner<IPricingModuleService>({
{ {
amount: 100, amount: 100,
currency_code: "USD", currency_code: "USD",
rules: { currency_code: "USD" }, rules: { region_id: "1234" },
}, },
], ],
}, },
@@ -574,7 +645,7 @@ moduleIntegrationTestRunner<IPricingModuleService>({
{ {
amount: 100, amount: 100,
currency_code: "USD", currency_code: "USD",
rules: { currency_code: "USD" }, rules: { region_id: "region-1" },
}, },
], ],
}, },
@@ -616,6 +687,57 @@ moduleIntegrationTestRunner<IPricingModuleService>({
}), }),
]) ])
}) })
it("should do an update if a price exists with the equivalent rules", async () => {
await service.addPrices([
{
priceSetId: "price-set-1",
prices: [
{
amount: 100,
currency_code: "USD",
rules: { region_id: "123" },
},
],
},
])
await service.addPrices([
{
priceSetId: "price-set-1",
prices: [
{
amount: 200,
currency_code: "USD",
rules: { region_id: "123" },
},
],
},
])
const priceSet = await service.retrievePriceSet("price-set-1", {
relations: ["prices", "prices.price_rules"],
})
expect(
priceSet.prices?.sort((a: any, b: any) => a.amount - b.amount)
).toEqual([
expect.objectContaining({
amount: 200,
currency_code: "USD",
price_rules: [
expect.objectContaining({
attribute: "region_id",
value: "123",
}),
],
}),
expect.objectContaining({
amount: 500,
currency_code: "USD",
}),
])
})
}) })
}) })
}, },
@@ -163,6 +163,9 @@ export class PricingRepository
pl_rules_count: "price.pl_rules_count", pl_rules_count: "price.pl_rules_count",
price_list_type: "price.pl_type", price_list_type: "price.pl_type",
price_list_id: "price.price_list_id", price_list_id: "price.price_list_id",
all_rules_count: knex.raw(
"COALESCE(price.rules_count, 0) + COALESCE(price.pl_rules_count, 0)"
),
}) })
.join(priceSubQueryKnex.as("price"), "price.price_set_id", "ps.id") .join(priceSubQueryKnex.as("price"), "price.price_set_id", "ps.id")
.leftJoin("price_rule as pr", "pr.price_id", "price.id") .leftJoin("price_rule as pr", "pr.price_id", "price.id")
@@ -171,8 +174,8 @@ export class PricingRepository
.orderBy([ .orderBy([
{ column: "price.has_price_list", order: "asc" }, { column: "price.has_price_list", order: "asc" },
{ column: "all_rules_count", order: "desc" },
{ column: "amount", order: "asc" }, { column: "amount", order: "asc" },
{ column: "rules_count", order: "desc" },
]) ])
if (quantity) { if (quantity) {
@@ -9,7 +9,6 @@ import {
InternalModuleDeclaration, InternalModuleDeclaration,
ModuleJoinerConfig, ModuleJoinerConfig,
ModulesSdkTypes, ModulesSdkTypes,
PriceDTO,
PriceSetDTO, PriceSetDTO,
PricingContext, PricingContext,
PricingFilters, PricingFilters,
@@ -32,6 +31,7 @@ import {
PriceListType, PriceListType,
promiseAll, promiseAll,
removeNullish, removeNullish,
simpleHash,
} from "@medusajs/utils" } from "@medusajs/utils"
import { Price, PriceList, PriceListRule, PriceRule, PriceSet } from "@models" import { Price, PriceList, PriceListRule, PriceRule, PriceSet } from "@models"
@@ -39,7 +39,7 @@ import { Price, PriceList, PriceListRule, PriceRule, PriceSet } from "@models"
import { ServiceTypes } from "@types" import { ServiceTypes } from "@types"
import { eventBuilders, validatePriceListDates } from "@utils" import { eventBuilders, validatePriceListDates } from "@utils"
import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config" import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config"
import { CreatePriceListDTO } from "src/types/services" import { CreatePriceListDTO, UpsertPriceDTO } from "src/types/services"
type InjectedDependencies = { type InjectedDependencies = {
baseRepository: DAL.RepositoryService baseRepository: DAL.RepositoryService
@@ -426,35 +426,6 @@ export default class PricingModuleService
return isString(idOrSelector) ? priceSets[0] : priceSets return isString(idOrSelector) ? priceSets[0] : priceSets
} }
private async normalizeUpdateData(data: ServiceTypes.UpdatePriceSetInput[]) {
return data.map((priceSet) => {
const prices = priceSet.prices?.map((price) => {
const rules = Object.entries(price.rules ?? {}).map(
([attribute, value]) => {
return {
attribute,
value,
}
}
)
const hasRulesInput = isPresent(price.rules)
delete price.rules
return {
...price,
price_set_id: priceSet.id,
price_rules: hasRulesInput ? rules : undefined,
rules_count: hasRulesInput ? rules.length : undefined,
}
})
return {
...priceSet,
prices,
}
})
}
@InjectTransactionManager("baseRepository_") @InjectTransactionManager("baseRepository_")
protected async updatePriceSets_( protected async updatePriceSets_(
data: ServiceTypes.UpdatePriceSetInput[], data: ServiceTypes.UpdatePriceSetInput[],
@@ -496,6 +467,67 @@ export default class PricingModuleService
return priceSets 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( async addPrices(
data: AddPricesDTO, data: AddPricesDTO,
sharedContext?: Context sharedContext?: Context
@@ -619,35 +651,8 @@ export default class PricingModuleService
const toCreate = input.map((inputData) => { const toCreate = input.map((inputData) => {
const entry = { const entry = {
...inputData, ...inputData,
prices: this.normalizePrices(inputData.prices, []),
} }
if (!inputData.prices) {
return entry
}
const pricesData: CreatePricesDTO[] = inputData.prices.map((price) => {
let { rules: priceRules = {}, ...rest } = price
const cleanRules = priceRules ? removeNullish(priceRules) : {}
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)
})
return {
...rest,
rules_count: numberOfRules,
price_rules: Array.from(rulesDataMap.values()),
}
})
entry.prices = pricesData
return entry return entry
}) })
@@ -710,86 +715,74 @@ export default class PricingModuleService
) { ) {
const priceSets = await this.listPriceSets( const priceSets = await this.listPriceSets(
{ id: input.map((d) => d.priceSetId) }, { id: input.map((d) => d.priceSetId) },
{}, { take: null, relations: ["prices", "prices.price_rules"] },
sharedContext sharedContext
) )
const priceSetMap = new Map(priceSets.map((p) => [p.id, p])) const existingPrices = priceSets
input.forEach(({ priceSetId }) => { .map((p) => p.prices)
const priceSet = priceSetMap.get(priceSetId) .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) { if (!priceSet) {
throw new MedusaError( throw new MedusaError(
MedusaError.Types.INVALID_DATA, MedusaError.Types.INVALID_DATA,
`Price set with id: ${priceSetId} not found` `Price set with id: ${price.price_set_id} not found`
) )
} }
}) })
const pricesToCreate: PricingTypes.CreatePriceDTO[] = input.flatMap( const { entities, performedActions } =
({ priceSetId, prices }) => await this.priceService_.upsertWithReplace(
prices.map((price) => { pricesToUpsert,
const numberOfRules = Object.entries(price?.rules ?? {}).length { relations: ["price_rules"] },
sharedContext
const priceRules = Object.entries(price.rules ?? {}).map( )
([attribute, value]) => ({
price_set_id: priceSetId,
attribute: attribute,
value,
})
)
return {
...price,
price_set_id: priceSetId,
rules_count: numberOfRules,
price_rules: priceRules,
}
})
)
const prices = await this.priceService_.create(
pricesToCreate,
sharedContext
)
/**
* Preparing data for emitting events
*/
const eventsData = prices.reduce(
(eventsData, price) => {
eventsData.prices.push({
id: price.id,
})
price.price_rules.map((priceRule) => {
eventsData.priceRules.push({
id: priceRule.id,
})
})
return eventsData
},
{
priceRules: [],
prices: [],
} as {
priceRules: { id: string }[]
prices: { id: string }[]
}
)
/**
* Emitting events for all created entities
*/
eventBuilders.createdPrice({ eventBuilders.createdPrice({
data: eventsData.prices, data: performedActions.created[Price.name] ?? [],
sharedContext, sharedContext,
}) })
eventBuilders.createdPriceRule({ eventBuilders.updatedPrice({
data: eventsData.priceRules, data: performedActions.updated[Price.name] ?? [],
sharedContext,
})
eventBuilders.deletedPrice({
data: performedActions.deleted[Price.name] ?? [],
sharedContext, sharedContext,
}) })
return prices 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_") @InjectTransactionManager("baseRepository_")
@@ -807,29 +800,10 @@ export default class PricingModuleService
} as CreatePriceListDTO } as CreatePriceListDTO
if (priceListData.prices) { if (priceListData.prices) {
const pricesData = priceListData.prices.map((price) => { entry.prices = this.normalizePrices(
let { rules: priceRules = {}, ...rest } = price priceListData.prices,
const cleanRules = priceRules ? removeNullish(priceRules) : {} []
const rules = Object.entries(cleanRules) ) as UpsertPriceDTO[]
const numberOfRules = rules.length
const rulesDataMap = new Map()
rules.map(([attribute, value]) => {
const rule = {
attribute,
value,
}
rulesDataMap.set(JSON.stringify(rule), rule)
})
return {
...rest,
rules_count: numberOfRules,
price_rules: Array.from(rulesDataMap.values()),
}
})
entry.prices = pricesData
} }
if (priceListData.rules) { if (priceListData.rules) {
@@ -994,39 +968,29 @@ export default class PricingModuleService
data: PricingTypes.UpdatePriceListPricesDTO[], data: PricingTypes.UpdatePriceListPricesDTO[],
sharedContext: Context = {} sharedContext: Context = {}
): Promise<Price[]> { ): Promise<Price[]> {
const priceListIds: string[] = []
const priceIds: string[] = []
for (const priceListData of data) {
priceListIds.push(priceListData.price_list_id)
for (const price of priceListData.prices) {
priceIds.push(price.id)
}
}
const prices = await this.listPrices(
{ id: priceIds },
{ take: null, relations: ["price_rules"] },
sharedContext
)
const priceMap: Map<string, PricingTypes.PriceDTO> = new Map(
prices.map((price) => [price.id, price])
)
const priceLists = await this.listPriceLists( const priceLists = await this.listPriceLists(
{ id: priceListIds }, { id: data.map((p) => p.price_list_id) },
{ take: null }, { take: null, relations: ["prices", "prices.price_rules"] },
sharedContext 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])) const priceListMap = new Map(priceLists.map((p) => [p.id, p]))
const pricesToUpdate: Partial<Price>[] = []
const priceRuleIdsToDelete: string[] = []
const priceRulesToCreate: CreatePriceRuleDTO[] = []
for (const { price_list_id: priceListId, prices } of data) { for (const { price_list_id: priceListId, prices } of data) {
const priceList = priceListMap.get(priceListId) const priceList = priceListMap.get(priceListId)
@@ -1036,38 +1000,15 @@ export default class PricingModuleService
`Price list with id: ${priceListId} not found` `Price list with id: ${priceListId} not found`
) )
} }
for (const priceData of prices) {
const { rules = {}, price_set_id, ...rest } = priceData
const price = priceMap.get(rest.id)!
const priceRules = price.price_rules!
priceRulesToCreate.push(
...Object.entries(rules).map(([ruleAttribute, ruleValue]) => ({
price_set_id,
attribute: ruleAttribute,
value: ruleValue,
price_id: price.id,
}))
)
pricesToUpdate.push({
...rest,
rules_count: Object.keys(rules).length,
} as unknown as Price)
priceRuleIdsToDelete.push(...priceRules.map((pr) => pr.id))
}
} }
const [_deletedPriceRule, _createdPriceRule, updatedPrices] = const { entities } = await this.priceService_.upsertWithReplace(
await promiseAll([ pricesToUpsert,
this.priceRuleService_.delete(priceRuleIdsToDelete), { relations: ["price_rules"] },
this.priceRuleService_.create(priceRulesToCreate), sharedContext
this.priceService_.update(pricesToUpdate), )
])
return updatedPrices return entities
} }
@InjectTransactionManager("baseRepository_") @InjectTransactionManager("baseRepository_")
@@ -1083,97 +1024,73 @@ export default class PricingModuleService
data: PricingTypes.AddPriceListPricesDTO[], data: PricingTypes.AddPriceListPricesDTO[],
sharedContext: Context = {} sharedContext: Context = {}
): Promise<Price[]> { ): Promise<Price[]> {
const priceListIds: string[] = []
for (const priceListData of data) {
priceListIds.push(priceListData.price_list_id)
}
const priceLists = await this.listPriceLists( const priceLists = await this.listPriceLists(
{ id: priceListIds }, { id: data.map((p) => p.price_list_id) },
{}, { take: null, relations: ["prices", "prices.price_rules"] },
sharedContext 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])) const priceListMap = new Map(priceLists.map((p) => [p.id, p]))
pricesToUpsert.forEach((price) => {
const pricesToCreate: Partial<Price>[] = [] const priceList = priceListMap.get(price.price_list_id!)
for (const { price_list_id: priceListId, prices } of data) {
const priceList = priceListMap.get(priceListId)
if (!priceList) { if (!priceList) {
throw new MedusaError( throw new MedusaError(
MedusaError.Types.INVALID_DATA, MedusaError.Types.INVALID_DATA,
`Price list with id: ${priceListId} not found` `Price list with id: ${price.price_list_id} not found`
) )
} }
})
const priceListPricesToCreate = prices.map((priceData) => { const { entities, performedActions } =
const priceRules = priceData.rules || {} await this.priceService_.upsertWithReplace(
const noOfRules = Object.keys(priceRules).length pricesToUpsert,
{ relations: ["price_rules"] },
const priceRulesToCreate = Object.entries(priceRules).map( sharedContext
([ruleAttribute, ruleValue]) => { )
return {
price_list_id: priceData.price_set_id,
attribute: ruleAttribute,
value: ruleValue,
}
}
)
return {
...priceData,
price_set_id: priceData.price_set_id,
title: "test",
price_list_id: priceList.id,
rules_count: noOfRules,
price_rules: priceRulesToCreate,
} as unknown as Price
})
pricesToCreate.push(...priceListPricesToCreate)
}
const createdPrices = await this.priceService_.create(
pricesToCreate,
sharedContext
)
const eventsData = createdPrices.reduce(
(eventsData, price) => {
eventsData.prices.push({
id: price.id,
})
price.price_rules.map((priceRule) => {
eventsData.priceRules.push({
id: priceRule.id,
})
})
return eventsData
},
{
priceRules: [],
prices: [],
} as {
priceRules: { id: string }[]
prices: { id: string }[]
}
)
eventBuilders.createdPrice({ eventBuilders.createdPrice({
data: eventsData.prices, data: performedActions.created[Price.name] ?? [],
sharedContext, sharedContext,
}) })
eventBuilders.createdPriceRule({ eventBuilders.updatedPrice({
data: eventsData.priceRules, data: performedActions.updated[Price.name] ?? [],
sharedContext,
})
eventBuilders.deletedPrice({
data: performedActions.deleted[Price.name] ?? [],
sharedContext, sharedContext,
}) })
return createdPrices 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_") @InjectTransactionManager("baseRepository_")
@@ -1335,3 +1252,24 @@ export default class PricingModuleService
} }
} }
} }
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))
}
@@ -1,2 +1,3 @@
export * from "./price-list" export * from "./price-list"
export * from "./price-set" export * from "./price-set"
export * from "./price"
@@ -0,0 +1,8 @@
import { PricingTypes } from "@medusajs/types"
export interface UpsertPriceDTO
extends Omit<PricingTypes.CreatePriceDTO, "rules"> {
id?: string
price_list_id?: string
price_rules: PricingTypes.CreatePriceRuleDTO[]
}
@@ -42,4 +42,28 @@ export const eventBuilders = {
object: "price_list_rule", object: "price_list_rule",
eventsEnum: PricingEvents, eventsEnum: PricingEvents,
}), }),
updatedPrice: eventBuilderFactory({
source: Modules.PRICING,
action: CommonEvents.UPDATED,
object: "price",
eventsEnum: PricingEvents,
}),
updatedPriceRule: eventBuilderFactory({
source: Modules.PRICING,
action: CommonEvents.UPDATED,
object: "price_rule",
eventsEnum: PricingEvents,
}),
deletedPrice: eventBuilderFactory({
source: Modules.PRICING,
action: CommonEvents.DELETED,
object: "price",
eventsEnum: PricingEvents,
}),
deletedPriceRule: eventBuilderFactory({
source: Modules.PRICING,
action: CommonEvents.DELETED,
object: "price_rule",
eventsEnum: PricingEvents,
}),
} }