feat(pricing, types): add price rule operators to price calculations (#10350)

what:

- adds price rule operators when doing price calculations
- rules now accepts a key where the value can be an array of objects `({ operator: string, value: number })`
  - validation for available types of operator and value to be a number
```
await service.createPriceSets({
  prices: [
    {
      amount: 50,
      currency_code: "usd",
      rules: {
        region_id: "de",
        cart_total: [
          { operator: "gte", value: 400 },
          { operator: "lte", value: 500 },
        ]
      },
    },
  ]
})
```
- price calculations will now account for the operators - lte, gte, lt, gt when the price context is a number

RESOLVES CMRC-747
This commit is contained in:
Riqwan Thamir authored and GitHub committed 2024-11-28 20:48:00 +00:00
1 parent 805fe4b1db
commit 324b4ab438
8 files changed
+462 -33

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
"@medusajs/pricing": patch
"@medusajs/types": patch
---
feat(pricing, types): add price rule operators to price calculations
@@ -7,6 +7,7 @@ import {
MoneyAmountDTO, MoneyAmountDTO,
UpdateMoneyAmountDTO, UpdateMoneyAmountDTO,
} from "./money-amount" } from "./money-amount"
import { PricingRuleOperatorValues } from "./price-rule"
export interface PricingRepositoryService { export interface PricingRepositoryService {
calculatePrices( calculatePrices(
@@ -206,6 +207,11 @@ export interface CalculatedPriceSet {
} }
} }
export interface RuleWithOperator {
operator: PricingRuleOperatorValues
value: number
}
/** /**
* @interface * @interface
* *
@@ -214,7 +220,8 @@ export interface CalculatedPriceSet {
* Each key of the object is a the attribute, and its value * Each key of the object is a the attribute, and its value
* is the values of the rule. * is the values of the rule.
*/ */
export interface CreatePriceSetPriceRules extends Record<string, string> {} export interface CreatePriceSetPriceRules
extends Record<string, string | RuleWithOperator[]> {}
/** /**
* @interface * @interface
@@ -5,6 +5,7 @@ import { SqlEntityManager } from "@mikro-orm/postgresql"
import { defaultPriceRuleData } from "./data" import { defaultPriceRuleData } from "./data"
export * from "./data" export * from "./data"
export * from "./operators"
export async function createPriceRules( export async function createPriceRules(
manager: SqlEntityManager, manager: SqlEntityManager,
@@ -0,0 +1,39 @@
import { RuleWithOperator } from "@medusajs/types"
export const withOperator = (
border,
min = 400,
max = 800
): RuleWithOperator[] => {
if (border === "betweenEquals") {
return [
{ operator: "gte", value: min },
{ operator: "lte", value: max },
]
} else if (border === "between") {
return [
{ operator: "gt", value: min },
{ operator: "lt", value: max },
]
} else if (border === "excludingMin") {
return [
{ operator: "gt", value: min },
{ operator: "lte", value: max },
]
} else if (border === "excludingMax") {
return [
{ operator: "gte", value: min },
{ operator: "lt", value: max },
]
} else if (border === "gt") {
return [{ operator: "gt", value: min }]
} else if (border === "lt") {
return [{ operator: "lt", value: min }]
} else if (border === "lte") {
return [{ operator: "lte", value: min }]
} else if (border === "gte") {
return [{ operator: "gte", value: min }]
} else {
return []
}
}
@@ -10,6 +10,7 @@ import {
PriceListType, PriceListType,
} from "@medusajs/framework/utils" } from "@medusajs/framework/utils"
import { moduleIntegrationTestRunner } from "@medusajs/test-utils" import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
import { withOperator } from "../../../__fixtures__/price-rule"
import { seedPriceData } from "../../../__fixtures__/seed-price-data" import { seedPriceData } from "../../../__fixtures__/seed-price-data"
jest.setTimeout(30000) jest.setTimeout(30000)
@@ -1857,6 +1858,180 @@ moduleIntegrationTestRunner<IPricingModuleService>({
}) })
}) })
}) })
describe("calculatePrices", () => {
let priceSet1
it("should return accurate prices when using custom price rule operators", async () => {
priceSet1 = await service.createPriceSets({
prices: [
{
amount: 50,
currency_code: "usd",
rules: {
region_id: "de",
cart_total: withOperator("between", 300, 400),
},
},
{
amount: 100,
currency_code: "usd",
rules: {
region_id: "de",
cart_total: withOperator("betweenEquals", 400, 500),
},
},
{
amount: 150,
currency_code: "usd",
rules: {
region_id: "de",
cart_total: withOperator("excludingMin", 500, 600),
},
},
{
amount: 200,
currency_code: "usd",
rules: {
region_id: "de",
cart_total: withOperator("excludingMax", 600, 700),
},
},
],
})
let priceSetsResult = await service.calculatePrices(
{ id: [priceSet1.id] },
{
context: {
currency_code: "usd",
region_id: "de",
cart_total: 350,
},
}
)
expect(priceSetsResult).toEqual([
expect.objectContaining({
is_calculated_price_price_list: false,
is_calculated_price_tax_inclusive: false,
calculated_amount: 50,
raw_calculated_amount: {
value: "50",
precision: 20,
},
is_original_price_price_list: false,
is_original_price_tax_inclusive: false,
original_amount: 50,
raw_original_amount: {
value: "50",
precision: 20,
},
currency_code: "usd",
calculated_price: expect.objectContaining({
id: expect.any(String),
price_list_id: null,
price_list_type: null,
min_quantity: null,
max_quantity: null,
}),
original_price: {
id: expect.any(String),
price_list_id: null,
price_list_type: null,
min_quantity: null,
max_quantity: null,
},
}),
])
priceSetsResult = await service.calculatePrices(
{ id: [priceSet1.id] },
{
context: {
currency_code: "usd",
region_id: "de",
cart_total: 300,
},
}
)
expect(priceSetsResult).toEqual([])
priceSetsResult = await service.calculatePrices(
{ id: [priceSet1.id] },
{
context: {
currency_code: "usd",
region_id: "de",
cart_total: 400,
},
}
)
expect(priceSetsResult).toEqual([
expect.objectContaining({ calculated_amount: 100 }),
])
priceSetsResult = await service.calculatePrices(
{ id: [priceSet1.id] },
{
context: {
currency_code: "usd",
region_id: "de",
cart_total: 500,
},
}
)
expect(priceSetsResult).toEqual([
expect.objectContaining({ calculated_amount: 100 }),
])
priceSetsResult = await service.calculatePrices(
{ id: [priceSet1.id] },
{
context: {
currency_code: "usd",
region_id: "de",
cart_total: 501,
},
}
)
expect(priceSetsResult).toEqual([
expect.objectContaining({ calculated_amount: 150 }),
])
priceSetsResult = await service.calculatePrices(
{ id: [priceSet1.id] },
{
context: {
currency_code: "usd",
region_id: "de",
cart_total: 601,
},
}
)
expect(priceSetsResult).toEqual([
expect.objectContaining({ calculated_amount: 200 }),
])
priceSetsResult = await service.calculatePrices(
{ id: [priceSet1.id] },
{
context: {
currency_code: "usd",
region_id: "de",
cart_total: 900,
},
}
)
expect(priceSetsResult).toEqual([])
})
})
}) })
}, },
}) })
@@ -619,6 +619,119 @@ moduleIntegrationTestRunner<IPricingModuleService>({
) )
}) })
it("should create price set with prices including rule operators", async () => {
const [priceSet] = await service.createPriceSets([
{
prices: [
{
amount: 100,
currency_code: "USD",
rules: {
region_id: "1",
custom_rule: [
{
operator: "gt",
value: 500,
},
{
operator: "lt",
value: 1000,
},
],
},
},
{
amount: 150,
currency_code: "USD",
},
],
},
])
expect(priceSet.prices).toHaveLength(2)
expect(priceSet).toEqual(
expect.objectContaining({
prices: expect.arrayContaining([
expect.objectContaining({
amount: 100,
currency_code: "USD",
price_rules: expect.arrayContaining([
expect.objectContaining({
attribute: "region_id",
operator: "eq",
value: "1",
}),
expect.objectContaining({
attribute: "custom_rule",
operator: "gt",
value: "500",
}),
expect.objectContaining({
attribute: "custom_rule",
operator: "lt",
value: "1000",
}),
]),
}),
expect.objectContaining({
amount: 150,
currency_code: "USD",
}),
]),
})
)
})
it("should throw error when creating price with invalid rules", async () => {
let error = await service
.createPriceSets([
{
prices: [
{
amount: 100,
currency_code: "USD",
rules: {
custom_rule: [
{
operator: "unknown" as any,
value: 500,
},
],
},
},
],
},
])
.catch((e) => e)
expect(error.message).toBe(
"operator should be one of gte, lte, gt, lt, eq"
)
error = await service
.createPriceSets([
{
prices: [
{
amount: 100,
currency_code: "USD",
rules: {
custom_rule: [
{
operator: "gt",
value: "string" as any,
},
],
},
},
],
},
])
.catch((e) => e)
expect(error.message).toBe("value should be a number")
})
it("should create a priceSet successfully", async () => { it("should create a priceSet successfully", async () => {
await service.createPriceSets([ await service.createPriceSets([
{ {
@@ -786,7 +899,9 @@ moduleIntegrationTestRunner<IPricingModuleService>({
{ {
amount: 100, amount: 100,
currency_code: "USD", currency_code: "USD",
rules: { region_id: "123" }, rules: {
region_id: "123",
},
}, },
], ],
}, },
@@ -799,7 +914,10 @@ moduleIntegrationTestRunner<IPricingModuleService>({
{ {
amount: 200, amount: 200,
currency_code: "USD", currency_code: "USD",
rules: { region_id: "123" }, rules: {
region_id: "123",
test: [{ value: 500, operator: "gte" }],
},
}, },
], ],
}, },
@@ -812,6 +930,10 @@ moduleIntegrationTestRunner<IPricingModuleService>({
expect( expect(
priceSet.prices?.sort((a: any, b: any) => a.amount - b.amount) priceSet.prices?.sort((a: any, b: any) => a.amount - b.amount)
).toEqual([ ).toEqual([
expect.objectContaining({
amount: 100,
currency_code: "USD",
}),
expect.objectContaining({ expect.objectContaining({
amount: 200, amount: 200,
currency_code: "USD", currency_code: "USD",
@@ -820,6 +942,11 @@ moduleIntegrationTestRunner<IPricingModuleService>({
attribute: "region_id", attribute: "region_id",
value: "123", value: "123",
}), }),
expect.objectContaining({
attribute: "test",
operator: "gte",
value: "500",
}),
], ],
}), }),
expect.objectContaining({ expect.objectContaining({
@@ -11,7 +11,7 @@ import {
PricingFilters, PricingFilters,
PricingRepositoryService, PricingRepositoryService,
} from "@medusajs/framework/types" } from "@medusajs/framework/types"
import { SqlEntityManager } from "@mikro-orm/postgresql" import { Knex, SqlEntityManager } from "@mikro-orm/postgresql"
export class PricingRepository export class PricingRepository
extends MikroOrmBase extends MikroOrmBase
@@ -58,7 +58,7 @@ export class PricingRepository
return [] return []
} }
// Gets all the price set money amounts where rules match for each of the contexts // Gets all the prices where rules match for each of the contexts
// that the price set is configured for // that the price set is configured for
const priceSubQueryKnex = knex({ const priceSubQueryKnex = knex({
price: "price", price: "price",
@@ -90,7 +90,7 @@ export class PricingRepository
.groupBy("price.id", "pl.id") .groupBy("price.id", "pl.id")
.having( .having(
knex.raw( knex.raw(
"count(DISTINCT pr.attribute) = price.rules_count AND price.price_list_id IS NULL" "count(pr.attribute) = price.rules_count AND price.price_list_id IS NULL"
) )
) )
.orHaving( .orHaving(
@@ -99,17 +99,57 @@ export class PricingRepository
) )
) )
priceSubQueryKnex.orWhere((q) => { const buildOperatorQueries = (
const nullPLq = q.whereNull("price.price_list_id") operatorGroupBuilder: Knex.QueryBuilder,
nullPLq.andWhere((q) => { value
for (const [key, value] of Object.entries(context)) { ) => {
q.orWhere({ operatorGroupBuilder
"pr.attribute": key, .where((operatorBuilder) => {
"pr.value": value, operatorBuilder
}) .where("pr.operator", "gte")
} .whereRaw("? >= pr.value::numeric", [value])
q.orWhere("price.rules_count", "=", 0) })
}) .orWhere((operatorBuilder) => {
operatorBuilder
.where("pr.operator", "gt")
.whereRaw("? > pr.value::numeric", [value])
})
.orWhere((operatorBuilder) => {
operatorBuilder
.where("pr.operator", "lt")
.whereRaw("? < pr.value::numeric", [value])
})
.orWhere((operatorBuilder) => {
operatorBuilder
.where("pr.operator", "lte")
.whereRaw("? <= pr.value::numeric", [value])
})
.orWhere((operatorBuilder) => {
operatorBuilder
.where("pr.operator", "eq")
.whereRaw("? = pr.value::numeric", [value])
})
}
priceSubQueryKnex.orWhere((priceBuilder) => {
priceBuilder
.whereNull("price.price_list_id")
.andWhere((withoutPriceListBuilder) => {
for (const [key, value] of Object.entries(context)) {
withoutPriceListBuilder.orWhere((orBuilder) => {
orBuilder.where("pr.attribute", key)
if (typeof value === "number") {
orBuilder.where((operatorGroupBuilder) => {
buildOperatorQueries(operatorGroupBuilder, value)
})
} else {
orBuilder.where({ "pr.value": value })
}
})
}
withoutPriceListBuilder.orWhere("price.rules_count", "=", 0)
})
}) })
priceSubQueryKnex.orWhere((q) => { priceSubQueryKnex.orWhere((q) => {
@@ -132,9 +172,7 @@ export class PricingRepository
.andWhere(function () { .andWhere(function () {
this.andWhere(function () { this.andWhere(function () {
for (const [key, value] of Object.entries(context)) { for (const [key, value] of Object.entries(context)) {
this.orWhere({ this.orWhere({ "plr.attribute": key })
"plr.attribute": key,
})
this.where( this.where(
"plr.value", "plr.value",
"@>", "@>",
@@ -146,14 +184,20 @@ export class PricingRepository
}) })
this.andWhere(function () { this.andWhere(function () {
this.andWhere(function () { this.andWhere((contextBuilder) => {
for (const [key, value] of Object.entries(context)) { for (const [key, value] of Object.entries(context)) {
this.orWhere({ contextBuilder.orWhere((orBuilder) => {
"pr.attribute": key, orBuilder.where("pr.attribute", key)
"pr.value": value,
if (typeof value === "number") {
buildOperatorQueries(orBuilder, value)
} else {
orBuilder.where({ "pr.value": value })
}
}) })
} }
this.andWhere("price.rules_count", ">", 0)
contextBuilder.andWhere("price.rules_count", ">", 0)
}) })
this.orWhere("price.rules_count", "=", 0) this.orWhere("price.rules_count", "=", 0)
}) })
@@ -15,6 +15,7 @@ import {
PricingContext, PricingContext,
PricingFilters, PricingFilters,
PricingRepositoryService, PricingRepositoryService,
PricingRuleOperatorValues,
PricingTypes, PricingTypes,
UpsertPricePreferenceDTO, UpsertPricePreferenceDTO,
UpsertPriceSetDTO, UpsertPriceSetDTO,
@@ -33,6 +34,7 @@ import {
MedusaError, MedusaError,
ModulesSdkUtils, ModulesSdkUtils,
PriceListType, PriceListType,
PricingRuleOperator,
promiseAll, promiseAll,
removeNullish, removeNullish,
simpleHash, simpleHash,
@@ -596,20 +598,48 @@ export default class PricingModuleService
data?.forEach((price) => { data?.forEach((price) => {
const cleanRules = price.rules ? removeNullish(price.rules) : {} const cleanRules = price.rules ? removeNullish(price.rules) : {}
const ruleEntries = Object.entries(cleanRules) const ruleOperators: PricingRuleOperatorValues[] =
const rules = ruleEntries.map(([attribute, value]) => { Object.values(PricingRuleOperator)
return {
attribute, const rules = Object.entries(cleanRules)
value, .map(([attribute, value]) => {
} if (Array.isArray(value)) {
}) return value.map((customRule) => {
if (!ruleOperators.includes(customRule.operator)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`operator should be one of ${ruleOperators.join(", ")}`
)
}
if (typeof customRule.value !== "number") {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`value should be a number`
)
}
return {
attribute,
operator: customRule.operator,
value: customRule.value,
}
})
}
return {
attribute,
value,
}
})
.flat(1)
const hasRulesInput = isPresent(price.rules) const hasRulesInput = isPresent(price.rules)
const entry = { const entry = {
...price, ...price,
price_list_id: priceListId, price_list_id: priceListId,
price_rules: hasRulesInput ? rules : undefined, price_rules: hasRulesInput ? rules : undefined,
rules_count: hasRulesInput ? ruleEntries.length : undefined, rules_count: hasRulesInput ? rules.length : undefined,
} as ServiceTypes.UpsertPriceDTO } as ServiceTypes.UpsertPriceDTO
delete (entry as CreatePricesDTO).rules delete (entry as CreatePricesDTO).rules