feat(utils,types): add registerUsages for promotions + computeActions consider usage (#6094)

RESOLVES CORE-1639
RESOLVES CORE-1640
RESOLVES CORE-1634
This commit is contained in:
Riqwan Thamir
2024-01-16 21:06:06 +01:00
committed by GitHub
parent 90328ca02b
commit 68ddd866a5
15 changed files with 943 additions and 54 deletions

View File

@@ -0,0 +1,6 @@
---
"@medusajs/types": patch
"@medusajs/utils": patch
---
feat(utils,types): add registerUsages for promotion's computed actions

View File

@@ -2,6 +2,7 @@ import { IPromotionModuleService } from "@medusajs/types"
import { PromotionType } from "@medusajs/utils"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { initialize } from "../../../../src"
import { createCampaigns } from "../../../__fixtures__/campaigns"
import { DB_URL, MikroOrmWrapper } from "../../../utils"
jest.setTimeout(30000)
@@ -459,10 +460,10 @@ describe("Promotion Service: computeActions", () => {
},
])
})
})
describe("when promotion is for items and allocation is across", () => {
it("should compute the correct item amendments", async () => {
it("should compute budget exceeded action when applicable total exceeds campaign budget for type spend", async () => {
await createCampaigns(repositoryManager)
const [createdPromotion] = await service.create([
{
code: "PROMOTION_TEST",
@@ -474,12 +475,13 @@ describe("Promotion Service: computeActions", () => {
values: ["VIP", "top100"],
},
],
campaign_id: "campaign-id-1",
application_method: {
type: "fixed",
target_type: "items",
allocation: "across",
value: "200",
max_quantity: 2,
allocation: "each",
value: "500",
max_quantity: 5,
target_rules: [
{
attribute: "product_category.id",
@@ -500,7 +502,126 @@ describe("Promotion Service: computeActions", () => {
items: [
{
id: "item_cotton_tshirt",
quantity: 1,
quantity: 5,
unit_price: 1000,
product_category: {
id: "catg_cotton",
},
product: {
id: "prod_tshirt",
},
},
],
})
expect(result).toEqual([
{ action: "campaignBudgetExceeded", code: "PROMOTION_TEST" },
])
})
it("should compute budget exceeded action when applicable total exceeds campaign budget for type usage", async () => {
await createCampaigns(repositoryManager)
const [createdPromotion] = await service.create([
{
code: "PROMOTION_TEST",
type: PromotionType.STANDARD,
rules: [
{
attribute: "customer.customer_group.id",
operator: "in",
values: ["VIP", "top100"],
},
],
campaign_id: "campaign-id-2",
application_method: {
type: "fixed",
target_type: "items",
allocation: "each",
value: "500",
max_quantity: 5,
target_rules: [
{
attribute: "product_category.id",
operator: "eq",
values: ["catg_cotton"],
},
],
},
},
])
await service.updateCampaigns({
id: "campaign-id-2",
budget: { used: 1000 },
})
const result = await service.computeActions(["PROMOTION_TEST"], {
customer: {
customer_group: {
id: "VIP",
},
},
items: [
{
id: "item_cotton_tshirt",
quantity: 5,
unit_price: 1000,
product_category: {
id: "catg_cotton",
},
product: {
id: "prod_tshirt",
},
},
],
})
expect(result).toEqual([
{ action: "campaignBudgetExceeded", code: "PROMOTION_TEST" },
])
})
})
describe("when promotion is for items and allocation is across", () => {
it("should compute the correct item amendments", async () => {
const [createdPromotion] = await service.create([
{
code: "PROMOTION_TEST",
type: PromotionType.STANDARD,
rules: [
{
attribute: "customer.customer_group.id",
operator: "in",
values: ["VIP", "top100"],
},
],
application_method: {
type: "fixed",
target_type: "items",
allocation: "across",
value: "400",
target_rules: [
{
attribute: "product_category.id",
operator: "eq",
values: ["catg_cotton"],
},
],
},
},
])
const result = await service.computeActions(["PROMOTION_TEST"], {
customer: {
customer_group: {
id: "VIP",
},
},
items: [
{
id: "item_cotton_tshirt",
quantity: 2,
unit_price: 100,
product_category: {
id: "catg_cotton",
@@ -512,7 +633,7 @@ describe("Promotion Service: computeActions", () => {
{
id: "item_cotton_sweater",
quantity: 2,
unit_price: 150,
unit_price: 300,
product_category: {
id: "catg_cotton",
},
@@ -527,13 +648,13 @@ describe("Promotion Service: computeActions", () => {
{
action: "addItemAdjustment",
item_id: "item_cotton_tshirt",
amount: 50,
amount: 100,
code: "PROMOTION_TEST",
},
{
action: "addItemAdjustment",
item_id: "item_cotton_sweater",
amount: 150,
amount: 300,
code: "PROMOTION_TEST",
},
])
@@ -556,7 +677,6 @@ describe("Promotion Service: computeActions", () => {
target_type: "items",
allocation: "across",
value: "30",
max_quantity: 2,
target_rules: [
{
attribute: "product_category.id",
@@ -584,7 +704,6 @@ describe("Promotion Service: computeActions", () => {
target_type: "items",
allocation: "across",
value: "50",
max_quantity: 1,
target_rules: [
{
attribute: "product_category.id",
@@ -676,7 +795,6 @@ describe("Promotion Service: computeActions", () => {
target_type: "items",
allocation: "across",
value: "500",
max_quantity: 2,
target_rules: [
{
attribute: "product_category.id",
@@ -704,7 +822,6 @@ describe("Promotion Service: computeActions", () => {
target_type: "items",
allocation: "across",
value: "50",
max_quantity: 1,
target_rules: [
{
attribute: "product_category.id",
@@ -778,6 +895,125 @@ describe("Promotion Service: computeActions", () => {
},
])
})
it("should compute budget exceeded action when applicable total exceeds campaign budget for type spend", async () => {
await createCampaigns(repositoryManager)
const [createdPromotion] = await service.create([
{
code: "PROMOTION_TEST",
type: PromotionType.STANDARD,
rules: [
{
attribute: "customer.customer_group.id",
operator: "in",
values: ["VIP", "top100"],
},
],
campaign_id: "campaign-id-1",
application_method: {
type: "fixed",
target_type: "items",
allocation: "across",
value: "1500",
target_rules: [
{
attribute: "product_category.id",
operator: "eq",
values: ["catg_cotton"],
},
],
},
},
])
const result = await service.computeActions(["PROMOTION_TEST"], {
customer: {
customer_group: {
id: "VIP",
},
},
items: [
{
id: "item_cotton_tshirt",
quantity: 5,
unit_price: 1000,
product_category: {
id: "catg_cotton",
},
product: {
id: "prod_tshirt",
},
},
],
})
expect(result).toEqual([
{ action: "campaignBudgetExceeded", code: "PROMOTION_TEST" },
])
})
it("should compute budget exceeded action when applicable total exceeds campaign budget for type usage", async () => {
await createCampaigns(repositoryManager)
const [createdPromotion] = await service.create([
{
code: "PROMOTION_TEST",
type: PromotionType.STANDARD,
rules: [
{
attribute: "customer.customer_group.id",
operator: "in",
values: ["VIP", "top100"],
},
],
campaign_id: "campaign-id-2",
application_method: {
type: "fixed",
target_type: "items",
allocation: "across",
value: "500",
target_rules: [
{
attribute: "product_category.id",
operator: "eq",
values: ["catg_cotton"],
},
],
},
},
])
await service.updateCampaigns({
id: "campaign-id-2",
budget: { used: 1000 },
})
const result = await service.computeActions(["PROMOTION_TEST"], {
customer: {
customer_group: {
id: "VIP",
},
},
items: [
{
id: "item_cotton_tshirt",
quantity: 5,
unit_price: 1000,
product_category: {
id: "catg_cotton",
},
product: {
id: "prod_tshirt",
},
},
],
})
expect(result).toEqual([
{ action: "campaignBudgetExceeded", code: "PROMOTION_TEST" },
])
})
})
describe("when promotion is for shipping_method and allocation is each", () => {
@@ -1076,6 +1312,119 @@ describe("Promotion Service: computeActions", () => {
},
])
})
it("should compute budget exceeded action when applicable total exceeds campaign budget for type spend", async () => {
await createCampaigns(repositoryManager)
const [createdPromotion] = await service.create([
{
code: "PROMOTION_TEST",
type: PromotionType.STANDARD,
rules: [
{
attribute: "customer.customer_group.id",
operator: "in",
values: ["VIP", "top100"],
},
],
campaign_id: "campaign-id-1",
application_method: {
type: "fixed",
target_type: "shipping_methods",
allocation: "each",
value: "1200",
max_quantity: 2,
target_rules: [
{
attribute: "shipping_option.id",
operator: "in",
values: ["express", "standard"],
},
],
},
},
])
const result = await service.computeActions(["PROMOTION_TEST"], {
customer: {
customer_group: {
id: "VIP",
},
},
shipping_methods: [
{
id: "shipping_method_express",
unit_price: 1200,
shipping_option: {
id: "express",
},
},
],
})
expect(result).toEqual([
{ action: "campaignBudgetExceeded", code: "PROMOTION_TEST" },
])
})
it("should compute budget exceeded action when applicable total exceeds campaign budget for type usage", async () => {
await createCampaigns(repositoryManager)
const [createdPromotion] = await service.create([
{
code: "PROMOTION_TEST",
type: PromotionType.STANDARD,
rules: [
{
attribute: "customer.customer_group.id",
operator: "in",
values: ["VIP", "top100"],
},
],
campaign_id: "campaign-id-2",
application_method: {
type: "fixed",
target_type: "shipping_methods",
allocation: "each",
value: "1200",
max_quantity: 2,
target_rules: [
{
attribute: "shipping_option.id",
operator: "in",
values: ["express", "standard"],
},
],
},
},
])
await service.updateCampaigns({
id: "campaign-id-2",
budget: { used: 1000 },
})
const result = await service.computeActions(["PROMOTION_TEST"], {
customer: {
customer_group: {
id: "VIP",
},
},
shipping_methods: [
{
id: "shipping_method_express",
unit_price: 1200,
shipping_option: {
id: "express",
},
},
],
})
expect(result).toEqual([
{ action: "campaignBudgetExceeded", code: "PROMOTION_TEST" },
])
})
})
describe("when promotion is for shipping_method and allocation is across", () => {
@@ -1096,7 +1445,6 @@ describe("Promotion Service: computeActions", () => {
target_type: "shipping_methods",
allocation: "across",
value: "200",
max_quantity: 2,
target_rules: [
{
attribute: "shipping_option.id",
@@ -1172,7 +1520,6 @@ describe("Promotion Service: computeActions", () => {
target_type: "shipping_methods",
allocation: "across",
value: "200",
max_quantity: 2,
target_rules: [
{
attribute: "shipping_option.id",
@@ -1200,7 +1547,6 @@ describe("Promotion Service: computeActions", () => {
target_type: "shipping_methods",
allocation: "across",
value: "200",
max_quantity: 2,
target_rules: [
{
attribute: "shipping_option.id",
@@ -1291,7 +1637,6 @@ describe("Promotion Service: computeActions", () => {
target_type: "shipping_methods",
allocation: "across",
value: "1000",
max_quantity: 2,
target_rules: [
{
attribute: "shipping_option.id",
@@ -1319,7 +1664,6 @@ describe("Promotion Service: computeActions", () => {
target_type: "shipping_methods",
allocation: "across",
value: "200",
max_quantity: 2,
target_rules: [
{
attribute: "shipping_option.id",
@@ -1380,6 +1724,117 @@ describe("Promotion Service: computeActions", () => {
},
])
})
it("should compute budget exceeded action when applicable total exceeds campaign budget for type spend", async () => {
await createCampaigns(repositoryManager)
const [createdPromotion] = await service.create([
{
code: "PROMOTION_TEST",
type: PromotionType.STANDARD,
rules: [
{
attribute: "customer.customer_group.id",
operator: "in",
values: ["VIP", "top100"],
},
],
campaign_id: "campaign-id-1",
application_method: {
type: "fixed",
target_type: "shipping_methods",
allocation: "across",
value: "1200",
target_rules: [
{
attribute: "shipping_option.id",
operator: "in",
values: ["express", "standard"],
},
],
},
},
])
const result = await service.computeActions(["PROMOTION_TEST"], {
customer: {
customer_group: {
id: "VIP",
},
},
shipping_methods: [
{
id: "shipping_method_express",
unit_price: 1200,
shipping_option: {
id: "express",
},
},
],
})
expect(result).toEqual([
{ action: "campaignBudgetExceeded", code: "PROMOTION_TEST" },
])
})
it("should compute budget exceeded action when applicable total exceeds campaign budget for type usage", async () => {
await createCampaigns(repositoryManager)
const [createdPromotion] = await service.create([
{
code: "PROMOTION_TEST",
type: PromotionType.STANDARD,
rules: [
{
attribute: "customer.customer_group.id",
operator: "in",
values: ["VIP", "top100"],
},
],
campaign_id: "campaign-id-2",
application_method: {
type: "fixed",
target_type: "shipping_methods",
allocation: "across",
value: "1200",
target_rules: [
{
attribute: "shipping_option.id",
operator: "in",
values: ["express", "standard"],
},
],
},
},
])
await service.updateCampaigns({
id: "campaign-id-2",
budget: { used: 1000 },
})
const result = await service.computeActions(["PROMOTION_TEST"], {
customer: {
customer_group: {
id: "VIP",
},
},
shipping_methods: [
{
id: "shipping_method_express",
unit_price: 1200,
shipping_option: {
id: "express",
},
},
],
})
expect(result).toEqual([
{ action: "campaignBudgetExceeded", code: "PROMOTION_TEST" },
])
})
})
describe("when promotion is for the entire order", () => {
@@ -1740,6 +2195,7 @@ describe("Promotion Service: computeActions", () => {
{
action: "removeItemAdjustment",
adjustment_id: "test-adjustment",
code: "ADJUSTMENT_CODE",
},
{
action: "addItemAdjustment",
@@ -1780,7 +2236,6 @@ describe("Promotion Service: computeActions", () => {
target_type: "shipping_methods",
allocation: "across",
value: "200",
max_quantity: 2,
target_rules: [
{
attribute: "shipping_option.id",
@@ -1833,6 +2288,7 @@ describe("Promotion Service: computeActions", () => {
{
action: "removeShippingMethodAdjustment",
adjustment_id: "test-adjustment",
code: "ADJUSTMENT_CODE",
},
{
action: "addShippingMethodAdjustment",

View File

@@ -0,0 +1,199 @@
import { IPromotionModuleService } from "@medusajs/types"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { initialize } from "../../../../src"
import { createCampaigns } from "../../../__fixtures__/campaigns"
import { DB_URL, MikroOrmWrapper } from "../../../utils"
jest.setTimeout(30000)
describe("Promotion Service: campaign usage", () => {
let service: IPromotionModuleService
let repositoryManager: SqlEntityManager
beforeEach(async () => {
await MikroOrmWrapper.setupDatabase()
repositoryManager = MikroOrmWrapper.forkManager()
await createCampaigns(repositoryManager)
service = await initialize({
database: {
clientUrl: DB_URL,
schema: process.env.MEDUSA_PROMOTION_DB_SCHEMA,
},
})
})
afterEach(async () => {
await MikroOrmWrapper.clearDatabase()
})
describe("registerUsage", () => {
it("should register usage for type spend", async () => {
const createdPromotion = await service.create({
code: "TEST_PROMO_SPEND",
type: "standard",
campaign_id: "campaign-id-1",
})
await service.registerUsage([
{
action: "addShippingMethodAdjustment",
shipping_method_id: "shipping_method_express",
amount: 200,
code: createdPromotion.code!,
},
{
action: "addShippingMethodAdjustment",
shipping_method_id: "shipping_method_standard",
amount: 500,
code: createdPromotion.code!,
},
])
const campaign = await service.retrieveCampaign("campaign-id-1", {
relations: ["budget"],
})
expect(campaign.budget).toEqual(
expect.objectContaining({
type: "spend",
limit: 1000,
used: 700,
})
)
})
it("should register usage for type usage", async () => {
const createdPromotion = await service.create({
code: "TEST_PROMO_USAGE",
type: "standard",
campaign_id: "campaign-id-2",
})
await service.registerUsage([
{
action: "addShippingMethodAdjustment",
shipping_method_id: "shipping_method_express",
amount: 200,
code: createdPromotion.code!,
},
{
action: "addShippingMethodAdjustment",
shipping_method_id: "shipping_method_standard",
amount: 500,
code: createdPromotion.code!,
},
])
const campaign = await service.retrieveCampaign("campaign-id-2", {
relations: ["budget"],
})
expect(campaign.budget).toEqual(
expect.objectContaining({
type: "usage",
limit: 1000,
used: 1,
})
)
})
it("should not throw an error when compute action with code does not exist", async () => {
const response = await service
.registerUsage([
{
action: "addShippingMethodAdjustment",
shipping_method_id: "shipping_method_express",
amount: 200,
code: "DOESNOTEXIST",
},
])
.catch((e) => e)
expect(response).toEqual(undefined)
})
it("should not register usage when limit is exceed for type usage", async () => {
const createdPromotion = await service.create({
code: "TEST_PROMO_USAGE",
type: "standard",
campaign_id: "campaign-id-2",
})
await service.updateCampaigns({
id: "campaign-id-2",
budget: { used: 1000, limit: 1000 },
})
await service.registerUsage([
{
action: "addShippingMethodAdjustment",
shipping_method_id: "shipping_method_express",
amount: 200,
code: createdPromotion.code!,
},
{
action: "addShippingMethodAdjustment",
shipping_method_id: "shipping_method_standard",
amount: 500,
code: createdPromotion.code!,
},
])
const campaign = await service.retrieveCampaign("campaign-id-2", {
relations: ["budget"],
})
expect(campaign).toEqual(
expect.objectContaining({
budget: expect.objectContaining({
limit: 1000,
used: 1000,
}),
})
)
})
it("should not register usage above limit when exceeded for type spend", async () => {
const createdPromotion = await service.create({
code: "TEST_PROMO_SPEND",
type: "standard",
campaign_id: "campaign-id-1",
})
await service.updateCampaigns({
id: "campaign-id-1",
budget: { used: 900, limit: 1000 },
})
await service.registerUsage([
{
action: "addShippingMethodAdjustment",
shipping_method_id: "shipping_method_express",
amount: 100,
code: createdPromotion.code!,
},
{
action: "addShippingMethodAdjustment",
shipping_method_id: "shipping_method_standard",
amount: 100,
code: createdPromotion.code!,
},
])
const campaign = await service.retrieveCampaign("campaign-id-1", {
relations: ["budget"],
})
expect(campaign).toEqual(
expect.objectContaining({
budget: expect.objectContaining({
limit: 1000,
used: 1000,
}),
})
)
})
})
})

View File

@@ -73,12 +73,14 @@ export class CampaignRepository extends DALUtils.mikroOrmBaseRepositoryFactory<
const campaignPromotionIdsMap = new Map<string, string[]>()
data.forEach((campaignData) => {
const campaignPromotionIds =
campaignData.promotions?.map((p) => p.id) || []
const campaignPromotionIds = campaignData.promotions?.map((p) => p.id)
campaignIds.push(campaignData.id)
promotionIdsToUpsert.push(...campaignPromotionIds)
campaignPromotionIdsMap.set(campaignData.id, campaignPromotionIds)
if (campaignPromotionIds) {
promotionIdsToUpsert.push(...campaignPromotionIds)
campaignPromotionIdsMap.set(campaignData.id, campaignPromotionIds)
}
delete campaignData.promotions
})
@@ -109,8 +111,12 @@ export class CampaignRepository extends DALUtils.mikroOrmBaseRepositoryFactory<
const updatedCampaigns = await super.update(data, context)
for (const updatedCampaign of updatedCampaigns) {
const upsertPromotionIds =
campaignPromotionIdsMap.get(updatedCampaign.id) || []
const upsertPromotionIds = campaignPromotionIdsMap.get(updatedCampaign.id)
if (!upsertPromotionIds) {
continue
}
const existingPromotionIds = (
existingCampaignsMap.get(updatedCampaign.id)?.promotions || []
).map((p) => p.id)

View File

@@ -8,6 +8,7 @@ import {
} from "@medusajs/types"
import {
ApplicationMethodTargetType,
CampaignBudgetType,
InjectManager,
InjectTransactionManager,
MedusaContext,
@@ -90,6 +91,110 @@ export default class PromotionModuleService<
return joinerConfig
}
@InjectManager("baseRepository_")
async registerUsage(
computedActions: PromotionTypes.UsageComputedActions[],
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
const promotionCodes = computedActions
.map((computedAction) => computedAction.code)
.filter(Boolean)
const promotionCodeCampaignBudgetMap = new Map<
string,
UpdateCampaignBudgetDTO
>()
const promotionCodeUsageMap = new Map<string, boolean>()
const existingPromotions = await this.list(
{ code: promotionCodes },
{ relations: ["application_method", "campaign", "campaign.budget"] },
sharedContext
)
const existingPromotionsMap = new Map<string, PromotionTypes.PromotionDTO>(
existingPromotions.map((promotion) => [promotion.code!, promotion])
)
for (let computedAction of computedActions) {
if (!ComputeActionUtils.canRegisterUsage(computedAction)) {
continue
}
const promotion = existingPromotionsMap.get(computedAction.code)
if (!promotion) {
continue
}
const campaignBudget = promotion.campaign?.budget
if (!campaignBudget) {
continue
}
if (campaignBudget.type === CampaignBudgetType.SPEND) {
const campaignBudgetData = promotionCodeCampaignBudgetMap.get(
campaignBudget.id
) || { id: campaignBudget.id, used: campaignBudget.used || 0 }
campaignBudgetData.used =
(campaignBudgetData.used || 0) + computedAction.amount
if (
campaignBudget.limit &&
campaignBudgetData.used > campaignBudget.limit
) {
continue
}
promotionCodeCampaignBudgetMap.set(
campaignBudget.id,
campaignBudgetData
)
}
if (campaignBudget.type === CampaignBudgetType.USAGE) {
const promotionAlreadyUsed =
promotionCodeUsageMap.get(promotion.code!) || false
if (promotionAlreadyUsed) {
continue
}
const campaignBudgetData = {
id: campaignBudget.id,
used: (campaignBudget.used || 0) + 1,
}
if (
campaignBudget.limit &&
campaignBudgetData.used > campaignBudget.limit
) {
continue
}
promotionCodeCampaignBudgetMap.set(
campaignBudget.id,
campaignBudgetData
)
promotionCodeUsageMap.set(promotion.code!, true)
}
const campaignBudgetsData: UpdateCampaignBudgetDTO[] = []
for (const [_, campaignBudgetData] of promotionCodeCampaignBudgetMap) {
campaignBudgetsData.push(campaignBudgetData)
}
await this.campaignBudgetService_.update(
campaignBudgetsData,
sharedContext
)
}
}
async computeActions(
promotionCodesToApply: string[],
applicationContext: PromotionTypes.ComputeActionContext,
@@ -140,6 +245,8 @@ export default class PromotionModuleService<
"application_method.target_rules.values",
"rules",
"rules.values",
"campaign",
"campaign.budget",
],
}
)
@@ -166,6 +273,7 @@ export default class PromotionModuleService<
computedActions.push({
action: "removeItemAdjustment",
adjustment_id: codeAdjustmentMap.get(appliedCode)!.id,
code: appliedCode,
})
}
@@ -173,6 +281,7 @@ export default class PromotionModuleService<
computedActions.push({
action: "removeShippingMethodAdjustment",
adjustment_id: codeAdjustmentMap.get(appliedCode)!.id,
code: appliedCode,
})
}
}
@@ -544,6 +653,7 @@ export default class PromotionModuleService<
!allowedAllocationForQuantity.includes(applicationMethodData.allocation)
) {
applicationMethodData.max_quantity = null
existingApplicationMethod.max_quantity = null
}
validateApplicationMethodAttributes({
@@ -952,12 +1062,8 @@ export default class PromotionModuleService<
const campaignBudgetsData: UpdateCampaignBudgetDTO[] = []
const existingCampaigns = await this.listCampaigns(
{
id: campaignIds,
},
{
relations: ["budget"],
},
{ id: campaignIds },
{ relations: ["budget"] },
sharedContext
)

View File

@@ -1,3 +1,4 @@
export * from "./items"
export * from "./order"
export * from "./shipping-methods"
export * from "./usage"

View File

@@ -5,9 +5,11 @@ import {
import {
ApplicationMethodAllocation,
ApplicationMethodTargetType,
ComputedActions,
MedusaError,
} from "@medusajs/utils"
import { areRulesValidForContext } from "../validations"
import { computeActionForBudgetExceeded } from "./usage"
export function getComputedActionsForItems(
promotion: PromotionTypes.PromotionDTO,
@@ -61,22 +63,35 @@ export function applyPromotionToItems(
) {
for (const method of items!) {
const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0
const promotionValue = parseFloat(applicationMethod!.value!)
const quantityMultiplier = Math.min(
method.quantity,
applicationMethod?.max_quantity!
)
const promotionValue =
parseFloat(applicationMethod!.value!) * quantityMultiplier
const applicableTotal =
method.unit_price *
Math.min(method.quantity, applicationMethod?.max_quantity!) -
appliedPromoValue
method.unit_price * quantityMultiplier - appliedPromoValue
const amount = Math.min(promotionValue, applicableTotal)
if (amount <= 0) {
continue
}
const budgetExceededAction = computeActionForBudgetExceeded(
promotion,
amount
)
if (budgetExceededAction) {
computedActions.push(budgetExceededAction)
continue
}
methodIdPromoValueMap.set(method.id, appliedPromoValue + amount)
computedActions.push({
action: "addItemAdjustment",
action: ComputedActions.ADD_ITEM_ADJUSTMENT,
item_id: method.id,
amount,
code: promotion.code!,
@@ -91,35 +106,37 @@ export function applyPromotionToItems(
) {
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
)
return acc + method.unit_price * method.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
method.unit_price * method.quantity - appliedPromoValue
// TODO: should we worry about precision here?
const applicablePromotionValue =
(applicableTotal / totalApplicableValue) * promotionValue
const amount = Math.min(applicablePromotionValue, applicableTotal)
if (amount <= 0) {
continue
}
const budgetExceededAction = computeActionForBudgetExceeded(
promotion,
amount
)
if (budgetExceededAction) {
computedActions.push(budgetExceededAction)
continue
}
computedActions.push({
action: "addItemAdjustment",
action: ComputedActions.ADD_ITEM_ADJUSTMENT,
item_id: method.id,
amount,
code: promotion.code!,

View File

@@ -2,9 +2,11 @@ import { PromotionTypes } from "@medusajs/types"
import {
ApplicationMethodAllocation,
ApplicationMethodTargetType,
ComputedActions,
MedusaError,
} from "@medusajs/utils"
import { areRulesValidForContext } from "../validations"
import { computeActionForBudgetExceeded } from "./usage"
export function getComputedActionsForShippingMethods(
promotion: PromotionTypes.PromotionDTO,
@@ -61,10 +63,21 @@ export function applyPromotionToShippingMethods(
continue
}
const budgetExceededAction = computeActionForBudgetExceeded(
promotion,
amount
)
if (budgetExceededAction) {
computedActions.push(budgetExceededAction)
continue
}
methodIdPromoValueMap.set(method.id, appliedPromoValue + amount)
computedActions.push({
action: "addShippingMethodAdjustment",
action: ComputedActions.ADD_SHIPPING_METHOD_ADJUSTMENT,
shipping_method_id: method.id,
amount,
code: promotion.code!,
@@ -99,10 +112,21 @@ export function applyPromotionToShippingMethods(
continue
}
const budgetExceededAction = computeActionForBudgetExceeded(
promotion,
amount
)
if (budgetExceededAction) {
computedActions.push(budgetExceededAction)
continue
}
methodIdPromoValueMap.set(method.id, appliedPromoValue + amount)
computedActions.push({
action: "addShippingMethodAdjustment",
action: ComputedActions.ADD_SHIPPING_METHOD_ADJUSTMENT,
shipping_method_id: method.id,
amount,
code: promotion.code!,

View File

@@ -0,0 +1,39 @@
import {
CampaignBudgetExceededAction,
ComputeActions,
PromotionDTO,
} from "@medusajs/types"
import { CampaignBudgetType, ComputedActions } from "@medusajs/utils"
export function canRegisterUsage(computedAction: ComputeActions): boolean {
return (
[
ComputedActions.ADD_ITEM_ADJUSTMENT,
ComputedActions.ADD_SHIPPING_METHOD_ADJUSTMENT,
] as string[]
).includes(computedAction.action)
}
export function computeActionForBudgetExceeded(
promotion: PromotionDTO,
amount: number
): CampaignBudgetExceededAction | void {
const campaignBudget = promotion.campaign?.budget
if (!campaignBudget) {
return
}
const campaignBudgetUsed = campaignBudget.used || 0
const totalUsed =
campaignBudget.type === CampaignBudgetType.SPEND
? campaignBudgetUsed + amount
: campaignBudgetUsed + 1
if (campaignBudget.limit && totalUsed > campaignBudget.limit) {
return {
action: ComputedActions.CAMPAIGN_BUDGET_EXCEEDED,
code: promotion.code!,
}
}
}

View File

@@ -9,6 +9,7 @@ import {
ApplicationMethodType,
MedusaError,
isDefined,
isPresent,
} from "@medusajs/utils"
export const allowedAllocationTargetTypes: string[] = [
@@ -33,6 +34,16 @@ export function validateApplicationMethodAttributes(data: {
}) {
const allTargetTypes: string[] = Object.values(ApplicationMethodTargetType)
if (
data.allocation === ApplicationMethodAllocation.ACROSS &&
isPresent(data.max_quantity)
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`application_method.max_quantity is not allowed to be set for allocation (${ApplicationMethodAllocation.ACROSS})`
)
}
if (!allTargetTypes.includes(data.target_type)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,

View File

@@ -5,8 +5,8 @@ export type CampaignBudgetTypeValues = "spend" | "usage"
export interface CampaignBudgetDTO {
id: string
type?: CampaignBudgetTypeValues
limit?: string | null
used?: string
limit?: number | null
used?: number
}
export interface FilterableCampaignBudgetProps

View File

@@ -3,6 +3,16 @@ export type ComputeActions =
| RemoveItemAdjustmentAction
| AddShippingMethodAdjustment
| RemoveShippingMethodAdjustment
| CampaignBudgetExceededAction
export type UsageComputedActions =
| AddShippingMethodAdjustment
| AddItemAdjustmentAction
export interface CampaignBudgetExceededAction {
action: "campaignBudgetExceeded"
code: string
}
export interface AddItemAdjustmentAction {
action: "addItemAdjustment"
@@ -16,6 +26,7 @@ export interface RemoveItemAdjustmentAction {
action: "removeItemAdjustment"
adjustment_id: string
description?: string
code: string
}
export interface AddShippingMethodAdjustment {
@@ -29,6 +40,7 @@ export interface AddShippingMethodAdjustment {
export interface RemoveShippingMethodAdjustment {
action: "removeShippingMethodAdjustment"
adjustment_id: string
code: string
}
export interface ComputeActionAdjustmentLine extends Record<string, unknown> {

View File

@@ -5,6 +5,7 @@ import {
CreateApplicationMethodDTO,
UpdateApplicationMethodDTO,
} from "./application-method"
import { CampaignDTO } from "./campaign"
import { CreatePromotionRuleDTO, PromotionRuleDTO } from "./promotion-rule"
export type PromotionType = "standard" | "buyget"
@@ -16,6 +17,7 @@ export interface PromotionDTO {
is_automatic?: boolean
application_method?: ApplicationMethodDTO
rules?: PromotionRuleDTO[]
campaign?: CampaignDTO
}
export interface CreatePromotionDTO {

View File

@@ -17,6 +17,8 @@ import {
import { CreateCampaignDTO, UpdateCampaignDTO } from "./mutations"
export interface IPromotionModuleService extends IModuleService {
registerUsage(computedActions: ComputeActions[]): Promise<void>
computeActions(
promotionCodesToApply: string[],
applicationContext: ComputeActionContext,

View File

@@ -33,3 +33,11 @@ export enum CampaignBudgetType {
SPEND = "spend",
USAGE = "usage",
}
export enum ComputedActions {
ADD_ITEM_ADJUSTMENT = "addItemAdjustment",
ADD_SHIPPING_METHOD_ADJUSTMENT = "addShippingMethodAdjustment",
REMOVE_ITEM_ADJUSTMENT = "removeItemAdjustment",
REMOVE_SHIPPING_METHOD_ADJUSTMENT = "removeShippingMethodAdjustment",
CAMPAIGN_BUDGET_EXCEEDED = "campaignBudgetExceeded",
}