feat(utils,types): added item/shipping adjustments for order/items/shipping_methods (#6050)
what: - adds compute actions for the following cases: - items => each & across - shipping_method => each & across - order - adds a remove compute actions when code is no longer present in adjustments array RESOLVES CORE-1625 RESOLVES CORE-1626 RESOLVES CORE-1627 RESOLVES CORE-1628 RESOLVES CORE-1585
This commit is contained in:
6
.changeset/lucky-ducks-chew.md
Normal file
6
.changeset/lucky-ducks-chew.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@medusajs/types": patch
|
||||
"@medusajs/utils": patch
|
||||
---
|
||||
|
||||
feat(utils,types): added item/shipping adjustments for order/items/shipping_methods
|
||||
File diff suppressed because it is too large
Load Diff
@@ -99,63 +99,6 @@ describe("Promotion Service", () => {
|
||||
)
|
||||
})
|
||||
|
||||
it("should create a promotion with order application method with rules successfully", async () => {
|
||||
const [createdPromotion] = await service.create([
|
||||
{
|
||||
code: "PROMOTION_TEST",
|
||||
type: PromotionType.STANDARD,
|
||||
application_method: {
|
||||
type: "fixed",
|
||||
target_type: "order",
|
||||
value: "100",
|
||||
target_rules: [
|
||||
{
|
||||
attribute: "product_id",
|
||||
operator: "eq",
|
||||
values: ["prod_tshirt"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const [promotion] = await service.list(
|
||||
{
|
||||
id: [createdPromotion.id],
|
||||
},
|
||||
{
|
||||
relations: [
|
||||
"application_method",
|
||||
"application_method.target_rules.values",
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
expect(promotion).toEqual(
|
||||
expect.objectContaining({
|
||||
code: "PROMOTION_TEST",
|
||||
is_automatic: false,
|
||||
type: "standard",
|
||||
application_method: expect.objectContaining({
|
||||
type: "fixed",
|
||||
target_type: "order",
|
||||
value: 100,
|
||||
target_rules: [
|
||||
expect.objectContaining({
|
||||
attribute: "product_id",
|
||||
operator: "eq",
|
||||
values: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
value: "prod_tshirt",
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("should throw error when creating an item application method without allocation", async () => {
|
||||
const error = await service
|
||||
.create([
|
||||
@@ -164,7 +107,7 @@ describe("Promotion Service", () => {
|
||||
type: PromotionType.STANDARD,
|
||||
application_method: {
|
||||
type: "fixed",
|
||||
target_type: "item",
|
||||
target_type: "items",
|
||||
value: "100",
|
||||
},
|
||||
},
|
||||
@@ -172,7 +115,7 @@ describe("Promotion Service", () => {
|
||||
.catch((e) => e)
|
||||
|
||||
expect(error.message).toContain(
|
||||
"application_method.allocation should be either 'across OR each' when application_method.target_type is either 'shipping OR item'"
|
||||
"application_method.allocation should be either 'across OR each' when application_method.target_type is either 'shipping_methods OR items'"
|
||||
)
|
||||
})
|
||||
|
||||
@@ -185,7 +128,7 @@ describe("Promotion Service", () => {
|
||||
application_method: {
|
||||
type: "fixed",
|
||||
allocation: "each",
|
||||
target_type: "shipping",
|
||||
target_type: "shipping_methods",
|
||||
value: "100",
|
||||
},
|
||||
},
|
||||
@@ -197,6 +140,33 @@ describe("Promotion Service", () => {
|
||||
)
|
||||
})
|
||||
|
||||
it("should throw error when creating an order application method with rules", async () => {
|
||||
const error = await service
|
||||
.create([
|
||||
{
|
||||
code: "PROMOTION_TEST",
|
||||
type: PromotionType.STANDARD,
|
||||
application_method: {
|
||||
type: "fixed",
|
||||
target_type: "order",
|
||||
value: "100",
|
||||
target_rules: [
|
||||
{
|
||||
attribute: "product_id",
|
||||
operator: "eq",
|
||||
values: ["prod_tshirt"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
.catch((e) => e)
|
||||
|
||||
expect(error.message).toContain(
|
||||
"Target rules for application method with target type (order) is not allowed"
|
||||
)
|
||||
})
|
||||
|
||||
it("should create a promotion with rules successfully", async () => {
|
||||
const [createdPromotion] = await service.create([
|
||||
{
|
||||
@@ -390,7 +360,7 @@ describe("Promotion Service", () => {
|
||||
type: PromotionType.STANDARD,
|
||||
application_method: {
|
||||
type: "fixed",
|
||||
target_type: "item",
|
||||
target_type: "items",
|
||||
allocation: "across",
|
||||
value: "100",
|
||||
},
|
||||
@@ -424,7 +394,7 @@ describe("Promotion Service", () => {
|
||||
type: PromotionType.STANDARD,
|
||||
application_method: {
|
||||
type: "fixed",
|
||||
target_type: "item",
|
||||
target_type: "items",
|
||||
allocation: "each",
|
||||
value: "100",
|
||||
max_quantity: 500,
|
||||
@@ -483,7 +453,7 @@ describe("Promotion Service", () => {
|
||||
.catch((e) => e)
|
||||
|
||||
expect(error.message).toContain(
|
||||
`application_method.target_type should be one of order, shipping, item`
|
||||
`application_method.target_type should be one of order, shipping_methods, items`
|
||||
)
|
||||
|
||||
error = await service
|
||||
@@ -604,7 +574,7 @@ describe("Promotion Service", () => {
|
||||
type: PromotionType.STANDARD,
|
||||
application_method: {
|
||||
type: "fixed",
|
||||
target_type: "item",
|
||||
target_type: "items",
|
||||
allocation: "each",
|
||||
value: "100",
|
||||
max_quantity: 500,
|
||||
@@ -676,7 +646,7 @@ describe("Promotion Service", () => {
|
||||
type: PromotionType.STANDARD,
|
||||
application_method: {
|
||||
type: "fixed",
|
||||
target_type: "item",
|
||||
target_type: "items",
|
||||
allocation: "each",
|
||||
value: "100",
|
||||
max_quantity: 500,
|
||||
@@ -760,7 +730,7 @@ describe("Promotion Service", () => {
|
||||
],
|
||||
application_method: {
|
||||
type: "fixed",
|
||||
target_type: "item",
|
||||
target_type: "items",
|
||||
allocation: "each",
|
||||
value: "100",
|
||||
max_quantity: 500,
|
||||
@@ -821,7 +791,7 @@ describe("Promotion Service", () => {
|
||||
type: PromotionType.STANDARD,
|
||||
application_method: {
|
||||
type: "fixed",
|
||||
target_type: "item",
|
||||
target_type: "items",
|
||||
allocation: "each",
|
||||
value: "100",
|
||||
max_quantity: 500,
|
||||
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
PromotionTypes,
|
||||
} from "@medusajs/types"
|
||||
import {
|
||||
ApplicationMethodTargetType,
|
||||
InjectManager,
|
||||
InjectTransactionManager,
|
||||
MedusaContext,
|
||||
MedusaError,
|
||||
isString,
|
||||
} from "@medusajs/utils"
|
||||
import { ApplicationMethod, Promotion } from "@models"
|
||||
import {
|
||||
@@ -23,11 +25,14 @@ import { joinerConfig } from "../joiner-config"
|
||||
import {
|
||||
CreateApplicationMethodDTO,
|
||||
CreatePromotionDTO,
|
||||
CreatePromotionRuleDTO,
|
||||
UpdateApplicationMethodDTO,
|
||||
UpdatePromotionDTO,
|
||||
} from "../types"
|
||||
import {
|
||||
ComputeActionUtils,
|
||||
allowedAllocationForQuantity,
|
||||
areRulesValidForContext,
|
||||
validateApplicationMethodAttributes,
|
||||
validatePromotionRuleAttributes,
|
||||
} from "../utils"
|
||||
@@ -71,6 +76,161 @@ export default class PromotionModuleService<
|
||||
return joinerConfig
|
||||
}
|
||||
|
||||
async computeActions(
|
||||
promotionCodesToApply: string[],
|
||||
applicationContext: PromotionTypes.ComputeActionContext,
|
||||
// TODO: specify correct type with options
|
||||
options: Record<string, any> = {}
|
||||
): Promise<PromotionTypes.ComputeActions[]> {
|
||||
const computedActions: PromotionTypes.ComputeActions[] = []
|
||||
const { items = [], shipping_methods: shippingMethods = [] } =
|
||||
applicationContext
|
||||
const appliedItemCodes: string[] = []
|
||||
const appliedShippingCodes: string[] = []
|
||||
const codeAdjustmentMap = new Map<
|
||||
string,
|
||||
PromotionTypes.ComputeActionAdjustmentLine
|
||||
>()
|
||||
const methodIdPromoValueMap = new Map<string, number>()
|
||||
|
||||
items.forEach((item) => {
|
||||
item.adjustments?.forEach((adjustment) => {
|
||||
if (isString(adjustment.code)) {
|
||||
codeAdjustmentMap.set(adjustment.code, adjustment)
|
||||
appliedItemCodes.push(adjustment.code)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
shippingMethods.forEach((shippingMethod) => {
|
||||
shippingMethod.adjustments?.forEach((adjustment) => {
|
||||
if (isString(adjustment.code)) {
|
||||
codeAdjustmentMap.set(adjustment.code, adjustment)
|
||||
appliedShippingCodes.push(adjustment.code)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const promotions = await this.list(
|
||||
{
|
||||
code: [
|
||||
...promotionCodesToApply,
|
||||
...appliedItemCodes,
|
||||
...appliedShippingCodes,
|
||||
],
|
||||
},
|
||||
{
|
||||
relations: [
|
||||
"application_method",
|
||||
"application_method.target_rules",
|
||||
"application_method.target_rules.values",
|
||||
"rules",
|
||||
"rules.values",
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
const existingPromotionsMap = new Map<string, PromotionTypes.PromotionDTO>(
|
||||
promotions.map((promotion) => [promotion.code!, promotion])
|
||||
)
|
||||
|
||||
for (const appliedCode of [...appliedShippingCodes, ...appliedItemCodes]) {
|
||||
const promotion = existingPromotionsMap.get(appliedCode)
|
||||
|
||||
if (!promotion) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Applied Promotion for code (${appliedCode}) not found`
|
||||
)
|
||||
}
|
||||
|
||||
if (promotionCodesToApply.includes(appliedCode)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (appliedItemCodes.includes(appliedCode)) {
|
||||
computedActions.push({
|
||||
action: "removeItemAdjustment",
|
||||
adjustment_id: codeAdjustmentMap.get(appliedCode)!.id,
|
||||
})
|
||||
}
|
||||
|
||||
if (appliedShippingCodes.includes(appliedCode)) {
|
||||
computedActions.push({
|
||||
action: "removeShippingMethodAdjustment",
|
||||
adjustment_id: codeAdjustmentMap.get(appliedCode)!.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const promotionCode of promotionCodesToApply) {
|
||||
const promotion = existingPromotionsMap.get(promotionCode)
|
||||
|
||||
if (!promotion) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Promotion for code (${promotionCode}) not found`
|
||||
)
|
||||
}
|
||||
|
||||
const {
|
||||
application_method: applicationMethod,
|
||||
rules: promotionRules = [],
|
||||
} = promotion
|
||||
|
||||
if (!applicationMethod) {
|
||||
continue
|
||||
}
|
||||
|
||||
const isPromotionApplicable = areRulesValidForContext(
|
||||
promotionRules,
|
||||
applicationContext
|
||||
)
|
||||
|
||||
if (!isPromotionApplicable) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (applicationMethod.target_type === ApplicationMethodTargetType.ORDER) {
|
||||
const computedActionsForItems =
|
||||
ComputeActionUtils.getComputedActionsForOrder(
|
||||
promotion,
|
||||
applicationContext,
|
||||
methodIdPromoValueMap
|
||||
)
|
||||
|
||||
computedActions.push(...computedActionsForItems)
|
||||
}
|
||||
|
||||
if (applicationMethod.target_type === ApplicationMethodTargetType.ITEMS) {
|
||||
const computedActionsForItems =
|
||||
ComputeActionUtils.getComputedActionsForItems(
|
||||
promotion,
|
||||
applicationContext[ApplicationMethodTargetType.ITEMS],
|
||||
methodIdPromoValueMap
|
||||
)
|
||||
|
||||
computedActions.push(...computedActionsForItems)
|
||||
}
|
||||
|
||||
if (
|
||||
applicationMethod.target_type ===
|
||||
ApplicationMethodTargetType.SHIPPING_METHODS
|
||||
) {
|
||||
const computedActionsForShippingMethods =
|
||||
ComputeActionUtils.getComputedActionsForShippingMethods(
|
||||
promotion,
|
||||
applicationContext[ApplicationMethodTargetType.SHIPPING_METHODS],
|
||||
methodIdPromoValueMap
|
||||
)
|
||||
|
||||
computedActions.push(...computedActionsForShippingMethods)
|
||||
}
|
||||
}
|
||||
|
||||
return computedActions
|
||||
}
|
||||
|
||||
@InjectManager("baseRepository_")
|
||||
async retrieve(
|
||||
id: string,
|
||||
@@ -194,6 +354,17 @@ export default class PromotionModuleService<
|
||||
promotion,
|
||||
}
|
||||
|
||||
if (
|
||||
applicationMethodData.target_type ===
|
||||
ApplicationMethodTargetType.ORDER &&
|
||||
targetRulesData.length
|
||||
) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Target rules for application method with target type (${ApplicationMethodTargetType.ORDER}) is not allowed`
|
||||
)
|
||||
}
|
||||
|
||||
validateApplicationMethodAttributes(applicationMethodData)
|
||||
applicationMethodsData.push(applicationMethodData)
|
||||
|
||||
@@ -394,7 +565,7 @@ export default class PromotionModuleService<
|
||||
|
||||
for (const ruleData of rulesData) {
|
||||
const { values, ...rest } = ruleData
|
||||
const promotionRuleData = {
|
||||
const promotionRuleData: CreatePromotionRuleDTO = {
|
||||
...rest,
|
||||
[relationName]: [relation],
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { PromotionRuleDTO } from "@medusajs/types"
|
||||
import { PromotionRule } from "@models"
|
||||
|
||||
export interface CreatePromotionRuleValueDTO {
|
||||
value: any
|
||||
promotion_rule: string | PromotionRuleDTO
|
||||
promotion_rule: string | PromotionRuleDTO | PromotionRule
|
||||
}
|
||||
|
||||
export interface UpdatePromotionRuleValueDTO {
|
||||
id: string
|
||||
value: any
|
||||
promotion_rule: string | PromotionRuleDTO
|
||||
promotion_rule: string | PromotionRuleDTO | PromotionRule
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PromotionRuleOperatorValues } from "@medusajs/types"
|
||||
|
||||
export interface CreatePromotionRuleDTO {
|
||||
description?: string
|
||||
description?: string | null
|
||||
attribute: string
|
||||
operator: PromotionRuleOperatorValues
|
||||
}
|
||||
|
||||
3
packages/promotion/src/utils/compute-actions/index.ts
Normal file
3
packages/promotion/src/utils/compute-actions/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./items"
|
||||
export * from "./order"
|
||||
export * from "./shipping-methods"
|
||||
131
packages/promotion/src/utils/compute-actions/items.ts
Normal file
131
packages/promotion/src/utils/compute-actions/items.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
ApplicationMethodAllocationValues,
|
||||
PromotionTypes,
|
||||
} from "@medusajs/types"
|
||||
import {
|
||||
ApplicationMethodAllocation,
|
||||
ApplicationMethodTargetType,
|
||||
MedusaError,
|
||||
} from "@medusajs/utils"
|
||||
import { areRulesValidForContext } from "../validations"
|
||||
|
||||
export function getComputedActionsForItems(
|
||||
promotion: PromotionTypes.PromotionDTO,
|
||||
itemApplicationContext: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.ITEMS],
|
||||
methodIdPromoValueMap: Map<string, number>,
|
||||
allocationOverride?: ApplicationMethodAllocationValues
|
||||
): PromotionTypes.ComputeActions[] {
|
||||
const applicableItems: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.ITEMS] =
|
||||
[]
|
||||
|
||||
if (!itemApplicationContext) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`"items" should be present as an array in the context for computeActions`
|
||||
)
|
||||
}
|
||||
|
||||
for (const itemContext of itemApplicationContext) {
|
||||
const isPromotionApplicableToItem = areRulesValidForContext(
|
||||
promotion?.application_method?.target_rules!,
|
||||
itemContext
|
||||
)
|
||||
|
||||
if (!isPromotionApplicableToItem) {
|
||||
continue
|
||||
}
|
||||
|
||||
applicableItems.push(itemContext)
|
||||
}
|
||||
|
||||
return applyPromotionToItems(
|
||||
promotion,
|
||||
applicableItems,
|
||||
methodIdPromoValueMap,
|
||||
allocationOverride
|
||||
)
|
||||
}
|
||||
|
||||
export function applyPromotionToItems(
|
||||
promotion: PromotionTypes.PromotionDTO,
|
||||
items: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.ITEMS],
|
||||
methodIdPromoValueMap: Map<string, number>,
|
||||
allocationOverride?: ApplicationMethodAllocationValues
|
||||
): PromotionTypes.ComputeActions[] {
|
||||
const { application_method: applicationMethod } = promotion
|
||||
const allocation = applicationMethod?.allocation!
|
||||
const computedActions: PromotionTypes.ComputeActions[] = []
|
||||
|
||||
if (
|
||||
[allocation, allocationOverride].includes(ApplicationMethodAllocation.EACH)
|
||||
) {
|
||||
for (const method of items!) {
|
||||
const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0
|
||||
const promotionValue = parseFloat(applicationMethod!.value!)
|
||||
const applicableTotal =
|
||||
method.unit_price *
|
||||
Math.min(method.quantity, applicationMethod?.max_quantity!) -
|
||||
appliedPromoValue
|
||||
|
||||
const amount = Math.min(promotionValue, applicableTotal)
|
||||
|
||||
if (amount <= 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
methodIdPromoValueMap.set(method.id, appliedPromoValue + amount)
|
||||
|
||||
computedActions.push({
|
||||
action: "addItemAdjustment",
|
||||
item_id: method.id,
|
||||
amount,
|
||||
code: promotion.code!,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
[allocation, allocationOverride].includes(
|
||||
ApplicationMethodAllocation.ACROSS
|
||||
)
|
||||
) {
|
||||
const totalApplicableValue = items!.reduce((acc, method) => {
|
||||
const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0
|
||||
return (
|
||||
acc +
|
||||
method.unit_price *
|
||||
Math.min(method.quantity, applicationMethod?.max_quantity!) -
|
||||
appliedPromoValue
|
||||
)
|
||||
}, 0)
|
||||
|
||||
for (const method of items!) {
|
||||
const promotionValue = parseFloat(applicationMethod!.value!)
|
||||
const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0
|
||||
|
||||
const applicableTotal =
|
||||
method.unit_price *
|
||||
Math.min(method.quantity, applicationMethod?.max_quantity!) -
|
||||
appliedPromoValue
|
||||
|
||||
// TODO: should we worry about precision here?
|
||||
const applicablePromotionValue =
|
||||
(applicableTotal / totalApplicableValue) * promotionValue
|
||||
|
||||
const amount = Math.min(applicablePromotionValue, applicableTotal)
|
||||
|
||||
if (amount <= 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
computedActions.push({
|
||||
action: "addItemAdjustment",
|
||||
item_id: method.id,
|
||||
amount,
|
||||
code: promotion.code!,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return computedActions
|
||||
}
|
||||
19
packages/promotion/src/utils/compute-actions/order.ts
Normal file
19
packages/promotion/src/utils/compute-actions/order.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { PromotionTypes } from "@medusajs/types"
|
||||
import {
|
||||
ApplicationMethodAllocation,
|
||||
ApplicationMethodTargetType,
|
||||
} from "@medusajs/utils"
|
||||
import { getComputedActionsForItems } from "./items"
|
||||
|
||||
export function getComputedActionsForOrder(
|
||||
promotion: PromotionTypes.PromotionDTO,
|
||||
itemApplicationContext: PromotionTypes.ComputeActionContext,
|
||||
methodIdPromoValueMap: Map<string, number>
|
||||
): PromotionTypes.ComputeActions[] {
|
||||
return getComputedActionsForItems(
|
||||
promotion,
|
||||
itemApplicationContext[ApplicationMethodTargetType.ITEMS],
|
||||
methodIdPromoValueMap,
|
||||
ApplicationMethodAllocation.ACROSS
|
||||
)
|
||||
}
|
||||
114
packages/promotion/src/utils/compute-actions/shipping-methods.ts
Normal file
114
packages/promotion/src/utils/compute-actions/shipping-methods.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { PromotionTypes } from "@medusajs/types"
|
||||
import {
|
||||
ApplicationMethodAllocation,
|
||||
ApplicationMethodTargetType,
|
||||
MedusaError,
|
||||
} from "@medusajs/utils"
|
||||
import { areRulesValidForContext } from "../validations"
|
||||
|
||||
export function getComputedActionsForShippingMethods(
|
||||
promotion: PromotionTypes.PromotionDTO,
|
||||
shippingMethodApplicationContext: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.SHIPPING_METHODS],
|
||||
methodIdPromoValueMap: Map<string, number>
|
||||
): PromotionTypes.ComputeActions[] {
|
||||
const applicableShippingItems: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.SHIPPING_METHODS] =
|
||||
[]
|
||||
|
||||
if (!shippingMethodApplicationContext) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`"shipping_methods" should be present as an array in the context for computeActions`
|
||||
)
|
||||
}
|
||||
|
||||
for (const shippingMethodContext of shippingMethodApplicationContext) {
|
||||
const isPromotionApplicableToItem = areRulesValidForContext(
|
||||
promotion.application_method?.target_rules!,
|
||||
shippingMethodContext
|
||||
)
|
||||
|
||||
if (!isPromotionApplicableToItem) {
|
||||
continue
|
||||
}
|
||||
|
||||
applicableShippingItems.push(shippingMethodContext)
|
||||
}
|
||||
|
||||
return applyPromotionToShippingMethods(
|
||||
promotion,
|
||||
applicableShippingItems,
|
||||
methodIdPromoValueMap
|
||||
)
|
||||
}
|
||||
|
||||
export function applyPromotionToShippingMethods(
|
||||
promotion: PromotionTypes.PromotionDTO,
|
||||
shippingMethods: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.SHIPPING_METHODS],
|
||||
methodIdPromoValueMap: Map<string, number>
|
||||
): PromotionTypes.ComputeActions[] {
|
||||
const { application_method: applicationMethod } = promotion
|
||||
const allocation = applicationMethod?.allocation!
|
||||
const computedActions: PromotionTypes.ComputeActions[] = []
|
||||
|
||||
if (allocation === ApplicationMethodAllocation.EACH) {
|
||||
for (const method of shippingMethods!) {
|
||||
const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0
|
||||
const promotionValue = parseFloat(applicationMethod!.value!)
|
||||
const applicableTotal = method.unit_price - appliedPromoValue
|
||||
const amount = Math.min(promotionValue, applicableTotal)
|
||||
|
||||
if (amount <= 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
methodIdPromoValueMap.set(method.id, appliedPromoValue + amount)
|
||||
|
||||
computedActions.push({
|
||||
action: "addShippingMethodAdjustment",
|
||||
shipping_method_id: method.id,
|
||||
amount,
|
||||
code: promotion.code!,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (allocation === ApplicationMethodAllocation.ACROSS) {
|
||||
const totalApplicableValue = shippingMethods!.reduce((acc, method) => {
|
||||
const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0
|
||||
|
||||
return acc + method.unit_price - appliedPromoValue
|
||||
}, 0)
|
||||
|
||||
if (totalApplicableValue <= 0) {
|
||||
return computedActions
|
||||
}
|
||||
|
||||
for (const method of shippingMethods!) {
|
||||
const promotionValue = parseFloat(applicationMethod!.value!)
|
||||
const applicableTotal = method.unit_price
|
||||
const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0
|
||||
|
||||
// TODO: should we worry about precision here?
|
||||
const applicablePromotionValue =
|
||||
(applicableTotal / totalApplicableValue) * promotionValue -
|
||||
appliedPromoValue
|
||||
|
||||
const amount = Math.min(applicablePromotionValue, applicableTotal)
|
||||
|
||||
if (amount <= 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
methodIdPromoValueMap.set(method.id, appliedPromoValue + amount)
|
||||
|
||||
computedActions.push({
|
||||
action: "addShippingMethodAdjustment",
|
||||
shipping_method_id: method.id,
|
||||
amount,
|
||||
code: promotion.code!,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return computedActions
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * as ComputeActionUtils from "./compute-actions"
|
||||
export * from "./validations"
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
} from "@medusajs/utils"
|
||||
|
||||
export const allowedAllocationTargetTypes: string[] = [
|
||||
ApplicationMethodTargetType.SHIPPING,
|
||||
ApplicationMethodTargetType.ITEM,
|
||||
ApplicationMethodTargetType.SHIPPING_METHODS,
|
||||
ApplicationMethodTargetType.ITEMS,
|
||||
]
|
||||
|
||||
export const allowedAllocationTypes: string[] = [
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { PromotionRuleOperatorValues } from "@medusajs/types"
|
||||
import { MedusaError, PromotionRuleOperator, isPresent } from "@medusajs/utils"
|
||||
import { PromotionRuleDTO, PromotionRuleOperatorValues } from "@medusajs/types"
|
||||
import {
|
||||
MedusaError,
|
||||
PromotionRuleOperator,
|
||||
isPresent,
|
||||
isString,
|
||||
pickValueFromObject,
|
||||
} from "@medusajs/utils"
|
||||
import { CreatePromotionRuleDTO } from "../../types"
|
||||
|
||||
export function validatePromotionRuleAttributes(
|
||||
@@ -37,3 +43,62 @@ export function validatePromotionRuleAttributes(
|
||||
|
||||
throw new MedusaError(MedusaError.Types.INVALID_DATA, errors.join(", "))
|
||||
}
|
||||
|
||||
export function areRulesValidForContext(
|
||||
rules: PromotionRuleDTO[],
|
||||
context: Record<string, any>
|
||||
): boolean {
|
||||
return rules.every((rule) => {
|
||||
const validRuleValues = rule.values?.map((ruleValue) => ruleValue.value)
|
||||
|
||||
if (!rule.attribute) {
|
||||
return false
|
||||
}
|
||||
|
||||
const valuesToCheck = pickValueFromObject(rule.attribute, context)
|
||||
|
||||
return evaluateRuleValueCondition(
|
||||
validRuleValues.filter(isString),
|
||||
rule.operator!,
|
||||
valuesToCheck
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function evaluateRuleValueCondition(
|
||||
ruleValues: string[],
|
||||
operator: string,
|
||||
ruleValuesToCheck: string[] | string
|
||||
) {
|
||||
if (!Array.isArray(ruleValuesToCheck)) {
|
||||
ruleValuesToCheck = [ruleValuesToCheck]
|
||||
}
|
||||
|
||||
return ruleValuesToCheck.every((ruleValueToCheck: string) => {
|
||||
if (operator === "in" || operator === "eq") {
|
||||
return ruleValues.some((ruleValue) => ruleValue === ruleValueToCheck)
|
||||
}
|
||||
|
||||
if (operator === "ne") {
|
||||
return ruleValues.some((ruleValue) => ruleValue !== ruleValueToCheck)
|
||||
}
|
||||
|
||||
if (operator === "gt") {
|
||||
return ruleValues.some((ruleValue) => ruleValue > ruleValueToCheck)
|
||||
}
|
||||
|
||||
if (operator === "gte") {
|
||||
return ruleValues.some((ruleValue) => ruleValue >= ruleValueToCheck)
|
||||
}
|
||||
|
||||
if (operator === "lt") {
|
||||
return ruleValues.some((ruleValue) => ruleValue < ruleValueToCheck)
|
||||
}
|
||||
|
||||
if (operator === "lte") {
|
||||
return ruleValues.some((ruleValue) => ruleValue <= ruleValueToCheck)
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ import { PromotionDTO } from "./promotion"
|
||||
import { CreatePromotionRuleDTO, PromotionRuleDTO } from "./promotion-rule"
|
||||
|
||||
export type ApplicationMethodTypeValues = "fixed" | "percentage"
|
||||
export type ApplicationMethodTargetTypeValues = "order" | "shipping" | "item"
|
||||
export type ApplicationMethodTargetTypeValues =
|
||||
| "order"
|
||||
| "shipping_methods"
|
||||
| "items"
|
||||
export type ApplicationMethodAllocationValues = "each" | "across"
|
||||
|
||||
export interface ApplicationMethodDTO {
|
||||
|
||||
55
packages/types/src/promotion/common/compute-actions.ts
Normal file
55
packages/types/src/promotion/common/compute-actions.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export type ComputeActions =
|
||||
| AddItemAdjustmentAction
|
||||
| RemoveItemAdjustmentAction
|
||||
| AddShippingMethodAdjustment
|
||||
| RemoveShippingMethodAdjustment
|
||||
|
||||
export interface AddItemAdjustmentAction {
|
||||
action: "addItemAdjustment"
|
||||
item_id: string
|
||||
amount: number
|
||||
code: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface RemoveItemAdjustmentAction {
|
||||
action: "removeItemAdjustment"
|
||||
adjustment_id: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface AddShippingMethodAdjustment {
|
||||
action: "addShippingMethodAdjustment"
|
||||
shipping_method_id: string
|
||||
amount: number
|
||||
code: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface RemoveShippingMethodAdjustment {
|
||||
action: "removeShippingMethodAdjustment"
|
||||
adjustment_id: string
|
||||
}
|
||||
|
||||
export interface ComputeActionAdjustmentLine {
|
||||
id: string
|
||||
code: string
|
||||
}
|
||||
|
||||
export interface ComputeActionItemLine {
|
||||
id: string
|
||||
quantity: number
|
||||
unit_price: number
|
||||
adjustments?: ComputeActionAdjustmentLine[]
|
||||
}
|
||||
|
||||
export interface ComputeActionShippingLine {
|
||||
id: string
|
||||
unit_price: number
|
||||
adjustments?: ComputeActionAdjustmentLine[]
|
||||
}
|
||||
|
||||
export interface ComputeActionContext {
|
||||
items?: ComputeActionItemLine[]
|
||||
shipping_methods?: ComputeActionShippingLine[]
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./application-method"
|
||||
export * from "./compute-actions"
|
||||
export * from "./promotion"
|
||||
export * from "./promotion-rule"
|
||||
export * from "./promotion-rule-value"
|
||||
|
||||
@@ -3,10 +3,11 @@ import { PromotionRuleDTO } from "./promotion-rule"
|
||||
|
||||
export interface PromotionRuleValueDTO {
|
||||
id: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
export interface CreatePromotionRuleValueDTO {
|
||||
value: any
|
||||
value: string
|
||||
promotion_rule: PromotionRuleDTO
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BaseFilterable } from "../../dal"
|
||||
import { PromotionRuleValueDTO } from "./promotion-rule-value"
|
||||
|
||||
export type PromotionRuleOperatorValues =
|
||||
| "gt"
|
||||
@@ -11,10 +12,14 @@ export type PromotionRuleOperatorValues =
|
||||
|
||||
export interface PromotionRuleDTO {
|
||||
id: string
|
||||
description?: string | null
|
||||
attribute?: string
|
||||
operator?: PromotionRuleOperatorValues
|
||||
values: PromotionRuleValueDTO[]
|
||||
}
|
||||
|
||||
export interface CreatePromotionRuleDTO {
|
||||
description?: string
|
||||
description?: string | null
|
||||
attribute: string
|
||||
operator: PromotionRuleOperatorValues
|
||||
values: string[] | string
|
||||
@@ -31,4 +36,5 @@ export interface RemovePromotionRuleDTO {
|
||||
export interface FilterablePromotionRuleProps
|
||||
extends BaseFilterable<FilterablePromotionRuleProps> {
|
||||
id?: string[]
|
||||
code?: string[]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
CreateApplicationMethodDTO,
|
||||
UpdateApplicationMethodDTO,
|
||||
} from "./application-method"
|
||||
import { CreatePromotionRuleDTO } from "./promotion-rule"
|
||||
import { CreatePromotionRuleDTO, PromotionRuleDTO } from "./promotion-rule"
|
||||
|
||||
export type PromotionType = "standard" | "buyget"
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface PromotionDTO {
|
||||
type?: PromotionType
|
||||
is_automatic?: boolean
|
||||
application_method?: ApplicationMethodDTO
|
||||
rules?: PromotionRuleDTO[]
|
||||
}
|
||||
|
||||
export interface CreatePromotionDTO {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { FindConfig } from "../common"
|
||||
import { IModuleService } from "../modules-sdk"
|
||||
import { Context } from "../shared-context"
|
||||
import {
|
||||
ComputeActionContext,
|
||||
ComputeActions,
|
||||
CreatePromotionDTO,
|
||||
CreatePromotionRuleDTO,
|
||||
FilterablePromotionProps,
|
||||
@@ -11,6 +13,12 @@ import {
|
||||
} from "./common"
|
||||
|
||||
export interface IPromotionModuleService extends IModuleService {
|
||||
computeActions(
|
||||
promotionCodesToApply: string[],
|
||||
applicationContext: ComputeActionContext,
|
||||
options?: Record<string, any>
|
||||
): Promise<ComputeActions[]>
|
||||
|
||||
create(
|
||||
data: CreatePromotionDTO[],
|
||||
sharedContext?: Context
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { pickValueFromObject } from "../pick-value-from-object"
|
||||
|
||||
describe("pickValueFromObject", function () {
|
||||
it("should return true or false for different types of data", function () {
|
||||
const expectations = [
|
||||
{
|
||||
input: {
|
||||
1: "attribute.another_attribute",
|
||||
2: {
|
||||
attribute: {
|
||||
another_attribute: "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
output: "test",
|
||||
},
|
||||
{
|
||||
input: {
|
||||
1: "attribute.another_attribute.array_attribute",
|
||||
2: {
|
||||
attribute: {
|
||||
another_attribute: [
|
||||
{
|
||||
array_attribute: "test 1",
|
||||
},
|
||||
{
|
||||
array_attribute: "test 2",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
output: ["test 1", "test 2"],
|
||||
},
|
||||
{
|
||||
input: {
|
||||
1: "attribute.another_attribute.array_attribute.deep_array_attribute",
|
||||
2: {
|
||||
attribute: {
|
||||
another_attribute: [
|
||||
{
|
||||
array_attribute: [
|
||||
{
|
||||
deep_array_attribute: "test 1",
|
||||
},
|
||||
{
|
||||
deep_array_attribute: "test 2",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
array_attribute: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
output: ["test 1", "test 2"],
|
||||
},
|
||||
{
|
||||
input: {
|
||||
1: "attribute.another_attribute.array_attribute",
|
||||
2: {
|
||||
attribute: {
|
||||
another_attribute: [
|
||||
{
|
||||
array_attribute: [
|
||||
{
|
||||
deep_array_attribute: "test 1",
|
||||
},
|
||||
{
|
||||
deep_array_attribute: "test 2",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
array_attribute: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
output: [
|
||||
{
|
||||
deep_array_attribute: "test 1",
|
||||
},
|
||||
{
|
||||
deep_array_attribute: "test 2",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
input: {
|
||||
1: "attribute.missing_attribute",
|
||||
2: {
|
||||
attribute: {
|
||||
another_attribute: "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
output: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
expectations.forEach((expectation) => {
|
||||
expect(
|
||||
pickValueFromObject(expectation.input["1"], expectation.input["2"])
|
||||
).toEqual(expectation.output)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,7 @@ export * from "./medusa-container"
|
||||
export * from "./object-from-string-path"
|
||||
export * from "./object-to-string-path"
|
||||
export * from "./optional-numeric-serializer"
|
||||
export * from "./pick-value-from-object"
|
||||
export * from "./promise-all"
|
||||
export * from "./remote-query-object-from-string"
|
||||
export * from "./remote-query-object-to-string"
|
||||
|
||||
37
packages/utils/src/common/pick-value-from-object.ts
Normal file
37
packages/utils/src/common/pick-value-from-object.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { isObject } from "./is-object"
|
||||
|
||||
export function pickValueFromObject(
|
||||
path: string,
|
||||
object: Record<any, any>
|
||||
): any {
|
||||
const segments = path.split(".")
|
||||
let result: any = undefined
|
||||
|
||||
for (const segment of segments) {
|
||||
const segmentsLeft = [...segments].splice(1, segments.length - 1)
|
||||
const segmentOutput = object[segment]
|
||||
|
||||
if (segmentsLeft.length === 0) {
|
||||
result = segmentOutput
|
||||
break
|
||||
}
|
||||
|
||||
if (isObject(segmentOutput)) {
|
||||
result = pickValueFromObject(segmentsLeft.join("."), segmentOutput)
|
||||
break
|
||||
}
|
||||
|
||||
if (Array.isArray(segmentOutput)) {
|
||||
result = segmentOutput
|
||||
.map((segmentOutput_) =>
|
||||
pickValueFromObject(segmentsLeft.join("."), segmentOutput_)
|
||||
)
|
||||
.flat()
|
||||
break
|
||||
}
|
||||
|
||||
result = segmentOutput
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -10,8 +10,8 @@ export enum ApplicationMethodType {
|
||||
|
||||
export enum ApplicationMethodTargetType {
|
||||
ORDER = "order",
|
||||
SHIPPING = "shipping",
|
||||
ITEM = "item",
|
||||
SHIPPING_METHODS = "shipping_methods",
|
||||
ITEMS = "items",
|
||||
}
|
||||
|
||||
export enum ApplicationMethodAllocation {
|
||||
|
||||
Reference in New Issue
Block a user