feat(promotion,dashboard,types,utils,medusa): Add statuses to promotions (#10950)

what:

- adds a status column to promotion table
- introduce active promotion query
- scope revert, register and compute actions to active promotions
- admin to create and update promotion with statuses

RESOLVES CMRC-845
RESOLVES CMRC-846
RESOLVES CMRC-847
RESOLVES CMRC-848
RESOLVES CMRC-849
RESOLVES CMRC-850
This commit is contained in:
Riqwan Thamir
2025-01-16 19:17:22 +00:00
committed by GitHub
parent effee5c8bb
commit 5eab9e7399
35 changed files with 1208 additions and 1743 deletions
@@ -3,7 +3,11 @@ import {
IPromotionModuleService,
PromotionDTO,
} from "@medusajs/framework/types"
import { isPresent, toMikroORMEntity } from "@medusajs/framework/utils"
import {
isPresent,
PromotionStatus,
toMikroORMEntity,
} from "@medusajs/framework/utils"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { Promotion } from "@models"
import { defaultPromotionsData } from "./data"
@@ -51,6 +55,7 @@ export async function createDefaultPromotion(
return await service.createPromotions({
code: "PROMOTION_TEST",
type: "standard",
status: PromotionStatus.ACTIVE,
campaign_id: "campaign-id-1",
...promotion,
application_method: {
@@ -2,6 +2,7 @@ import { IPromotionModuleService } from "@medusajs/framework/types"
import {
ApplicationMethodType,
Modules,
PromotionStatus,
PromotionType,
} from "@medusajs/framework/utils"
import { moduleIntegrationTestRunner, SuiteOptions } from "@medusajs/test-utils"
@@ -17,13 +18,85 @@ moduleIntegrationTestRunner({
service,
}: SuiteOptions<IPromotionModuleService>) => {
describe("Promotion Service: computeActions", () => {
beforeAll(() => {
jest.useFakeTimers()
jest.setSystemTime(new Date("02/02/2023"))
})
afterAll(() => {
jest.useRealTimers()
})
beforeEach(async () => {
await createCampaigns(MikroOrmWrapper.forkManager())
})
it("should return empty array when promotion is not active (draft or inactive)", async () => {
const promotion = await createDefaultPromotion(service, {
status: PromotionStatus.DRAFT,
rules: [
{
attribute: "customer.customer_group.id",
operator: "in",
values: ["VIP", "top100"],
},
],
application_method: {
type: "fixed",
target_type: "items",
allocation: "each",
value: 200,
max_quantity: 1,
target_rules: [
{
attribute: "product_category.id",
operator: "eq",
values: ["catg_cotton"],
},
],
},
})
const result = await service.computeActions([promotion.code!], {
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
},
},
items: [
{
id: "item_cotton_tshirt",
quantity: 1,
subtotal: 100,
product_category: {
id: "catg_cotton",
},
product: {
id: "prod_tshirt",
},
},
{
id: "item_cotton_sweater",
quantity: 5,
subtotal: 750,
product_category: {
id: "catg_cotton",
},
product: {
id: "prod_sweater",
},
},
],
})
expect(result).toEqual([])
})
describe("when code is not present in database", () => {
it("should return empty array when promotion does not exist", async () => {
const response = await service.computeActions(["DOES_NOT_EXIST"], {
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -87,6 +160,7 @@ moduleIntegrationTestRunner({
})
const result = await service.computeActions(["PROMOTION_TEST"], {
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -136,6 +210,7 @@ moduleIntegrationTestRunner({
const resultWithoutCustomer = await service.computeActions(
["PROMOTION_TEST"],
{
currency_code: "usd",
items: [
{
id: "item_cotton_tshirt",
@@ -221,6 +296,7 @@ moduleIntegrationTestRunner({
const result = await service.computeActions(
["PROMOTION_TEST", "PROMOTION_TEST_2"],
{
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -257,19 +333,7 @@ moduleIntegrationTestRunner({
{
action: "addItemAdjustment",
item_id: "item_cotton_tshirt",
amount: 30,
code: "PROMOTION_TEST",
},
{
action: "addItemAdjustment",
item_id: "item_cotton_sweater",
amount: 30,
code: "PROMOTION_TEST",
},
{
action: "addItemAdjustment",
item_id: "item_cotton_tshirt",
amount: 20,
amount: 50,
code: "PROMOTION_TEST_2",
},
{
@@ -278,6 +342,12 @@ moduleIntegrationTestRunner({
amount: 50,
code: "PROMOTION_TEST_2",
},
{
action: "addItemAdjustment",
item_id: "item_cotton_sweater",
amount: 30,
code: "PROMOTION_TEST",
},
])
})
@@ -334,6 +404,7 @@ moduleIntegrationTestRunner({
const result = await service.computeActions(
["PROMOTION_TEST", "PROMOTION_TEST_2"],
{
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -408,6 +479,7 @@ moduleIntegrationTestRunner({
})
const result = await service.computeActions(["PROMOTION_TEST"], {
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -469,6 +541,7 @@ moduleIntegrationTestRunner({
})
const result = await service.computeActions(["PROMOTION_TEST"], {
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -522,6 +595,7 @@ moduleIntegrationTestRunner({
})
const result = await service.computeActions(["PROMOTION_TEST"], {
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -622,6 +696,7 @@ moduleIntegrationTestRunner({
const result = await service.computeActions(
["PROMOTION_TEST", "PROMOTION_TEST_2"],
{
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -735,6 +810,7 @@ moduleIntegrationTestRunner({
const result = await service.computeActions(
["PROMOTION_TEST", "PROMOTION_TEST_2"],
{
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -809,6 +885,7 @@ moduleIntegrationTestRunner({
})
const result = await service.computeActions(["PROMOTION_TEST"], {
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -866,6 +943,7 @@ moduleIntegrationTestRunner({
})
const result = await service.computeActions(["PROMOTION_TEST"], {
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -921,6 +999,7 @@ moduleIntegrationTestRunner({
})
const result = await service.computeActions(["PROMOTION_TEST"], {
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -994,6 +1073,7 @@ moduleIntegrationTestRunner({
})
const result = await service.computeActions([], {
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -1093,6 +1173,7 @@ moduleIntegrationTestRunner({
const result = await service.computeActions(
["PROMOTION_TEST", "PROMOTION_TEST_2"],
{
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -1126,18 +1207,6 @@ moduleIntegrationTestRunner({
)
expect(JSON.parse(JSON.stringify(result))).toEqual([
{
action: "addItemAdjustment",
item_id: "item_cotton_tshirt",
amount: 7.5,
code: "PROMOTION_TEST",
},
{
action: "addItemAdjustment",
item_id: "item_cotton_sweater",
amount: 22.5,
code: "PROMOTION_TEST",
},
{
action: "addItemAdjustment",
item_id: "item_cotton_tshirt",
@@ -1150,6 +1219,18 @@ moduleIntegrationTestRunner({
amount: 37.5,
code: "PROMOTION_TEST_2",
},
{
action: "addItemAdjustment",
item_id: "item_cotton_tshirt",
amount: 7.5,
code: "PROMOTION_TEST",
},
{
action: "addItemAdjustment",
item_id: "item_cotton_sweater",
amount: 22.5,
code: "PROMOTION_TEST",
},
])
})
@@ -1205,6 +1286,7 @@ moduleIntegrationTestRunner({
const result = await service.computeActions(
["PROMOTION_TEST", "PROMOTION_TEST_2"],
{
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -1278,6 +1360,7 @@ moduleIntegrationTestRunner({
})
const result = await service.computeActions(["PROMOTION_TEST"], {
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -1334,6 +1417,7 @@ moduleIntegrationTestRunner({
})
const result = await service.computeActions(["PROMOTION_TEST"], {
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -1386,6 +1470,7 @@ moduleIntegrationTestRunner({
})
const result = await service.computeActions(["PROMOTION_TEST"], {
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -1459,6 +1544,7 @@ moduleIntegrationTestRunner({
})
const result = await service.computeActions([], {
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -1557,6 +1643,7 @@ moduleIntegrationTestRunner({
const result = await service.computeActions(
["PROMOTION_TEST", "PROMOTION_TEST_2"],
{
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -1668,6 +1755,7 @@ moduleIntegrationTestRunner({
const result = await service.computeActions(
["PROMOTION_TEST", "PROMOTION_TEST_2"],
{
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -1753,6 +1841,7 @@ moduleIntegrationTestRunner({
})
const result = await service.computeActions(["PROMOTION_TEST"], {
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -1809,6 +1898,7 @@ moduleIntegrationTestRunner({
})
const result = await service.computeActions(["PROMOTION_TEST"], {
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -1864,6 +1954,7 @@ moduleIntegrationTestRunner({
})
const result = await service.computeActions(["PROMOTION_TEST"], {
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -1937,6 +2028,7 @@ moduleIntegrationTestRunner({
})
const result = await service.computeActions([], {
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -2012,6 +2104,7 @@ moduleIntegrationTestRunner({
const result = await service.computeActions(
[],
{
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -2100,6 +2193,7 @@ moduleIntegrationTestRunner({
const result = await service.computeActions(
["PROMOTION_TEST", "PROMOTION_TEST_2"],
{
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -2206,6 +2300,7 @@ moduleIntegrationTestRunner({
const result = await service.computeActions(
["PROMOTION_TEST", "PROMOTION_TEST_2"],
{
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -2279,6 +2374,7 @@ moduleIntegrationTestRunner({
})
const result = await service.computeActions(["PROMOTION_TEST"], {
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -3974,6 +4070,7 @@ moduleIntegrationTestRunner({
const result = await service.computeActions(
["PROMOTION_TEST", "PROMOTION_TEST_2"],
{
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -4007,18 +4104,6 @@ moduleIntegrationTestRunner({
)
expect(JSON.parse(JSON.stringify(result))).toEqual([
{
action: "addItemAdjustment",
item_id: "item_cotton_tshirt",
amount: 7.5,
code: "PROMOTION_TEST",
},
{
action: "addItemAdjustment",
item_id: "item_cotton_sweater",
amount: 22.5,
code: "PROMOTION_TEST",
},
{
action: "addItemAdjustment",
item_id: "item_cotton_tshirt",
@@ -4031,6 +4116,18 @@ moduleIntegrationTestRunner({
amount: 37.5,
code: "PROMOTION_TEST_2",
},
{
action: "addItemAdjustment",
item_id: "item_cotton_tshirt",
amount: 7.5,
code: "PROMOTION_TEST",
},
{
action: "addItemAdjustment",
item_id: "item_cotton_sweater",
amount: 22.5,
code: "PROMOTION_TEST",
},
])
})
@@ -4073,6 +4170,7 @@ moduleIntegrationTestRunner({
const result = await service.computeActions(
["PROMOTION_TEST", "PROMOTION_TEST_2"],
{
currency_code: "usd",
customer: {
customer_group: {
id: "VIP",
@@ -6,8 +6,8 @@ import {
Modules,
PromotionType,
} from "@medusajs/framework/utils"
import { PromotionModuleService } from "@services"
import { SuiteOptions, moduleIntegrationTestRunner } from "@medusajs/test-utils"
import { PromotionModuleService } from "@services"
import { createCampaigns } from "../../../__fixtures__/campaigns"
import {
createDefaultPromotion,
@@ -24,6 +24,15 @@ moduleIntegrationTestRunner({
service,
}: SuiteOptions<IPromotionModuleService>) => {
describe("Promotion Service", () => {
beforeAll(() => {
jest.useFakeTimers()
jest.setSystemTime(new Date("02/02/2023"))
})
afterAll(() => {
jest.useRealTimers()
})
beforeEach(async () => {
await createCampaigns(MikroOrmWrapper.forkManager())
})
@@ -809,30 +818,32 @@ moduleIntegrationTestRunner({
expect(count).toEqual(2)
expect(promotions).toEqual([
{
expect.objectContaining({
id: "promotion-id-1",
code: "PROMOTION_1",
campaign_id: null,
campaign: null,
status: "draft",
is_automatic: false,
type: "standard",
application_method: expect.any(Object),
created_at: expect.any(Date),
updated_at: expect.any(Date),
deleted_at: null,
},
{
}),
expect.objectContaining({
id: "promotion-id-2",
code: "PROMOTION_2",
campaign_id: null,
campaign: null,
status: "draft",
is_automatic: false,
type: "standard",
application_method: null,
created_at: expect.any(Date),
updated_at: expect.any(Date),
deleted_at: null,
},
}),
])
})
@@ -1,8 +1,8 @@
import { IPromotionModuleService } from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"
import { moduleIntegrationTestRunner, SuiteOptions } from "@medusajs/test-utils"
import { createCampaigns } from "../../../__fixtures__/campaigns"
import { createDefaultPromotion } from "../../../__fixtures__/promotion"
import { Modules } from "@medusajs/framework/utils"
jest.setTimeout(30000)
@@ -13,6 +13,15 @@ moduleIntegrationTestRunner({
service,
}: SuiteOptions<IPromotionModuleService>) => {
describe("Promotion Service: campaign usage", () => {
beforeAll(() => {
jest.useFakeTimers()
jest.setSystemTime(new Date("02/02/2023"))
})
afterAll(() => {
jest.useRealTimers()
})
beforeEach(async () => {
await createCampaigns(MikroOrmWrapper.forkManager())
})
@@ -13,6 +13,15 @@ moduleIntegrationTestRunner({
service,
}: SuiteOptions<IPromotionModuleService>) => {
describe("Promotion Service: campaign usage", () => {
beforeAll(() => {
jest.useFakeTimers()
jest.setSystemTime(new Date("02/02/2023"))
})
afterAll(() => {
jest.useRealTimers()
})
beforeEach(async () => {
await createCampaigns(MikroOrmWrapper.forkManager())
})
@@ -345,6 +345,21 @@
],
"mappedType": "enum"
},
"status": {
"name": "status",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"default": "'draft'",
"enumItems": [
"draft",
"active",
"inactive"
],
"mappedType": "enum"
},
"campaign_id": {
"name": "campaign_id",
"type": "text",
@@ -414,6 +429,14 @@
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_promotion_type\" ON \"promotion\" (type) WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_promotion_status",
"columnNames": [],
"composite": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_promotion_status\" ON \"promotion\" (status) WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_promotion_campaign_id",
"columnNames": [],
@@ -0,0 +1,23 @@
import { Migration } from "@mikro-orm/migrations"
export class Migration20250113094144 extends Migration {
async up(): Promise<void> {
this.addSql(
"alter table if exists \"promotion\" add column if not exists \"status\" text check (\"status\" in ('draft', 'active', 'inactive')) not null default 'draft';"
)
this.addSql(
'CREATE INDEX IF NOT EXISTS "IDX_promotion_status" ON "promotion" (status) WHERE deleted_at IS NULL;'
)
// Data Migration
this.addSql(`UPDATE promotion SET status = 'active';`)
}
async down(): Promise<void> {
this.addSql('drop index if exists "IDX_promotion_status";')
this.addSql(
'alter table if exists "promotion" drop column if exists "status";'
)
}
}
@@ -13,6 +13,10 @@ const Promotion = model
.index("IDX_promotion_code"),
is_automatic: model.boolean().default(false),
type: model.enum(PromotionUtils.PromotionType).index("IDX_promotion_type"),
status: model
.enum(PromotionUtils.PromotionStatus)
.index("IDX_promotion_status")
.default(PromotionUtils.PromotionStatus.DRAFT),
campaign: model
.belongsTo(() => Campaign, {
mappedBy: "promotions",
@@ -2,10 +2,13 @@ import {
CampaignBudgetTypeValues,
Context,
DAL,
FilterablePromotionProps,
FindConfig,
InferEntityType,
InternalModuleDeclaration,
ModuleJoinerConfig,
ModulesSdkTypes,
PromotionDTO,
PromotionTypes,
} from "@medusajs/framework/types"
import {
@@ -24,6 +27,7 @@ import {
MedusaContext,
MedusaError,
MedusaService,
PromotionStatus,
PromotionType,
toMikroORMEntity,
transformPropertiesToBigNumber,
@@ -134,6 +138,40 @@ export default class PromotionModuleService
return joinerConfig
}
@InjectManager()
listActivePromotions(
filters?: FilterablePromotionProps,
config?: FindConfig<PromotionDTO>,
sharedContext?: Context
): Promise<PromotionDTO[]> {
const activeFilters = {
$or: [
{
status: PromotionStatus.ACTIVE,
campaign_id: null,
...filters,
},
{
status: PromotionStatus.ACTIVE,
...filters,
campaign: {
...filters?.campaign,
$and: [
{
$or: [{ starts_at: null }, { starts_at: { $lte: new Date() } }],
},
{
$or: [{ ends_at: null }, { ends_at: { $gt: new Date() } }],
},
],
},
},
],
}
return this.listPromotions(activeFilters, config, sharedContext)
}
@InjectManager()
async registerUsage(
computedActions: PromotionTypes.UsageComputedActions[],
@@ -148,11 +186,9 @@ export default class PromotionModuleService
const campaignBudgetMap = new Map<string, UpdateCampaignBudgetDTO>()
const promotionCodeUsageMap = new Map<string, boolean>()
const existingPromotions = await this.listPromotions(
const existingPromotions = await this.listActivePromotions(
{ code: promotionCodes },
{
relations: ["campaign", "campaign.budget"],
},
{ relations: ["campaign", "campaign.budget"] },
sharedContext
)
@@ -250,15 +286,13 @@ export default class PromotionModuleService
const promotionCodeUsageMap = new Map<string, boolean>()
const campaignBudgetMap = new Map<string, UpdateCampaignBudgetDTO>()
const existingPromotions = await this.listPromotions(
const existingPromotions = await this.listActivePromotions(
{
code: computedActions
.map((computedAction) => computedAction.code)
.filter(Boolean),
},
{
relations: ["campaign", "campaign.budget"],
},
{ relations: ["campaign", "campaign.budget"] },
sharedContext
)
@@ -362,7 +396,7 @@ export default class PromotionModuleService
>()
const automaticPromotions = preventAutoPromotions
? []
: await this.listPromotions(
: await this.listActivePromotions(
{ is_automatic: true },
{ select: ["code"] },
sharedContext
@@ -402,7 +436,7 @@ export default class PromotionModuleService
})
})
const promotions = await this.listPromotions(
const promotions = await this.listActivePromotions(
{
code: [
...promotionCodesToApply,
@@ -412,6 +446,7 @@ export default class PromotionModuleService
},
{
take: null,
order: { application_method: { value: "DESC" } },
relations: [
"application_method",
"application_method.target_rules",