feat(core-flows,dashboard,js-sdk,promotion,medusa,types,utils): limit promotion usage per customer (#13451)

**What**
- implement promotion usage limits per customer/email
- fix registering spend usage over the limit
- fix type errors in promotion module tests

**How**
- introduce a new type of campaign budget that can be defined by an attribute such as customer id or email
- add `CampaignBudgetUsage` entity to keep track of the number of uses per attribute value
- update `registerUsage` and `computeActions` in the promotion module to work with the new type
- update `core-flows` to pass context needed for usage calculation to the promotion module

**Breaking**
- registering promotion usage now throws (and cart complete fails) if the budget limit is exceeded or if the cart completion would result in a breached limit

---

CLOSES CORE-1172
CLOSES CORE-1173
CLOSES CORE-1174
CLOSES CORE-1175


Co-authored-by: Adrien de Peretti <25098370+adrien2p@users.noreply.github.com>
This commit is contained in:
Frane Polić
2025-10-09 14:35:54 +02:00
committed by GitHub
parent 924564bee5
commit 7dc3b0c5ff
36 changed files with 2390 additions and 190 deletions

View File

@@ -3,7 +3,10 @@ import { Modules } from "@medusajs/framework/utils"
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
import { CampaignBudgetType } from "../../../../../../core/utils/src/promotion/index"
import { createCampaigns } from "../../../__fixtures__/campaigns"
import { createPromotions } from "../../../__fixtures__/promotion"
import {
createDefaultPromotion,
createPromotions,
} from "../../../__fixtures__/promotion"
jest.setTimeout(30000)
@@ -488,6 +491,41 @@ moduleIntegrationTestRunner<IPromotionModuleService>({
)
})
})
describe("campaignBudgetUsage", () => {
it("should create a campaign budget by attribute usage successfully", async () => {
const [createdCampaign] = await service.createCampaigns([
{
name: "test",
campaign_identifier: "test",
budget: {
type: CampaignBudgetType.USE_BY_ATTRIBUTE,
attribute: "customer_id",
limit: 5,
},
},
])
let campaigns = await service.listCampaigns(
{
id: [createdCampaign.id],
},
{ relations: ["budget", "budget.usages"] }
)
expect(campaigns).toHaveLength(1)
expect(campaigns[0]).toEqual(
expect.objectContaining({
budget: expect.objectContaining({
usages: [],
limit: 5,
type: CampaignBudgetType.USE_BY_ATTRIBUTE,
}),
})
)
})
})
})
},
})

View File

@@ -1,5 +1,5 @@
import { IPromotionModuleService } from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"
import { CampaignBudgetType, Modules } from "@medusajs/framework/utils"
import { moduleIntegrationTestRunner, SuiteOptions } from "@medusajs/test-utils"
import { createCampaigns } from "../../../__fixtures__/campaigns"
import { createDefaultPromotion } from "../../../__fixtures__/promotion"
@@ -21,20 +21,19 @@ moduleIntegrationTestRunner({
it("should register usage for type spend", async () => {
const createdPromotion = await createDefaultPromotion(service, {})
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!,
},
])
await service.registerUsage(
[
{
amount: 200,
code: createdPromotion.code!,
},
{
amount: 500,
code: createdPromotion.code!,
},
],
{ customer_email: null, customer_id: null }
)
const campaign = await service.retrieveCampaign("campaign-id-1", {
relations: ["budget"],
@@ -54,20 +53,19 @@ moduleIntegrationTestRunner({
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!,
},
])
await service.registerUsage(
[
{
amount: 200,
code: createdPromotion.code!,
},
{
amount: 500,
code: createdPromotion.code!,
},
],
{ customer_email: null, customer_id: null }
)
const campaign = await service.retrieveCampaign("campaign-id-2", {
relations: ["budget"],
@@ -84,20 +82,21 @@ moduleIntegrationTestRunner({
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",
},
])
.registerUsage(
[
{
amount: 200,
code: "DOESNOTEXIST",
},
],
{ customer_email: null, customer_id: null }
)
.catch((e) => e)
expect(response).toEqual(undefined)
})
it("should not register usage when limit is exceed for type usage", async () => {
it("should throw if limit is exceeded for type usage", async () => {
const createdPromotion = await createDefaultPromotion(service, {
campaign_id: "campaign-id-2",
})
@@ -107,24 +106,37 @@ moduleIntegrationTestRunner({
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 error = await service
.registerUsage(
[
{
amount: 200,
code: createdPromotion.code!,
},
{
amount: 500,
code: createdPromotion.code!,
},
],
{ customer_email: null, customer_id: null }
)
.catch((e) => e)
const campaign = await service.retrieveCampaign("campaign-id-2", {
relations: ["budget"],
})
expect(error).toEqual(
expect.objectContaining({
type: "not_allowed",
message: "Promotion usage exceeds the budget limit.",
})
)
const [campaign] = await service.listCampaigns(
{
id: ["campaign-id-2"],
},
{
relations: ["budget"],
}
)
expect(campaign).toEqual(
expect.objectContaining({
@@ -136,7 +148,7 @@ moduleIntegrationTestRunner({
)
})
it("should not register usage above limit when exceeded for type spend", async () => {
it("should throw if limit is exceeded for type spend", async () => {
const createdPromotion = await createDefaultPromotion(service, {})
await service.updateCampaigns({
@@ -144,20 +156,114 @@ moduleIntegrationTestRunner({
budget: { used: 900, limit: 1000 },
})
await service.registerUsage([
const error = await service
.registerUsage(
[
{
amount: 50,
code: createdPromotion.code!,
},
{
amount: 100,
code: createdPromotion.code!,
},
],
{ customer_email: null, customer_id: null }
)
.catch((e) => e)
expect(error).toEqual(
expect.objectContaining({
type: "not_allowed",
message: "Promotion usage exceeds the budget limit.",
})
)
const campaign = await service.retrieveCampaign("campaign-id-1", {
relations: ["budget"],
})
expect(campaign).toEqual(
expect.objectContaining({
budget: expect.objectContaining({
used: 900,
limit: 1000,
}),
})
)
})
it("should throw if limit is exceeded for type spend (one amount exceeds the limit)", async () => {
const createdPromotion = await createDefaultPromotion(service, {})
await service.updateCampaigns({
id: "campaign-id-1",
budget: { used: 900, limit: 1000 },
})
const error = await service
.registerUsage(
[
{
amount: 75,
code: createdPromotion.code!,
},
{
amount: 75,
code: createdPromotion.code!,
},
],
{ customer_email: null, customer_id: null }
)
.catch((e) => e)
expect(error).toEqual(
expect.objectContaining({
type: "not_allowed",
message: "Promotion usage exceeds the budget limit.",
})
)
const [campaign] = await service.listCampaigns(
{
action: "addShippingMethodAdjustment",
shipping_method_id: "shipping_method_express",
amount: 100,
code: createdPromotion.code!,
id: ["campaign-id-1"],
},
{
action: "addShippingMethodAdjustment",
shipping_method_id: "shipping_method_standard",
amount: 100,
code: createdPromotion.code!,
},
])
relations: ["budget"],
}
)
expect(campaign).toEqual(
expect.objectContaining({
budget: expect.objectContaining({
limit: 1000,
used: 900,
}),
})
)
})
it("should not throw if the spent amount exactly matches the limit", async () => {
const createdPromotion = await createDefaultPromotion(service, {})
await service.updateCampaigns({
id: "campaign-id-1",
budget: { used: 900, limit: 1000 },
})
await service.registerUsage(
[
{
amount: 50,
code: createdPromotion.code!,
},
{
amount: 50,
code: createdPromotion.code!,
},
],
{ customer_email: null, customer_id: null }
)
const campaign = await service.retrieveCampaign("campaign-id-1", {
relations: ["budget"],
@@ -172,6 +278,128 @@ moduleIntegrationTestRunner({
})
)
})
it("should requister usage for attribute budget successfully and revert it successfully", async () => {
const [createdCampaign] = await service.createCampaigns([
{
name: "test",
campaign_identifier: "test",
budget: {
type: CampaignBudgetType.USE_BY_ATTRIBUTE,
attribute: "customer_id",
limit: 5,
},
},
])
const createdPromotion = await createDefaultPromotion(service, {
campaign_id: createdCampaign.id,
})
await service.registerUsage(
[{ amount: 1, code: createdPromotion.code! }],
{
customer_id: "customer-id-1",
customer_email: "customer1@email.com",
}
)
await service.registerUsage(
[{ amount: 1, code: createdPromotion.code! }],
{
customer_id: "customer-id-2",
customer_email: "customer2@email.com",
}
)
await service.registerUsage(
[{ amount: 1, code: createdPromotion.code! }],
{
customer_id: "customer-id-1",
customer_email: "customer1@email.com",
}
)
let campaign = await service.retrieveCampaign(createdCampaign.id, {
relations: ["budget", "budget.usages"],
})
expect(campaign).toEqual(
expect.objectContaining({
budget: expect.objectContaining({
used: 3, // used 3 times overall
usages: expect.arrayContaining([
expect.objectContaining({
attribute_value: "customer-id-1",
used: 2,
}),
expect.objectContaining({
attribute_value: "customer-id-2",
used: 1,
}),
]),
}),
})
)
await service.revertUsage(
[{ amount: 1, code: createdPromotion.code! }],
{
customer_id: "customer-id-1",
customer_email: "customer1@email.com",
}
)
campaign = await service.retrieveCampaign(createdCampaign.id, {
relations: ["budget", "budget.usages"],
})
expect(campaign).toEqual(
expect.objectContaining({
budget: expect.objectContaining({
used: 2,
usages: expect.arrayContaining([
expect.objectContaining({
attribute_value: "customer-id-1",
used: 1,
}),
expect.objectContaining({
attribute_value: "customer-id-2",
used: 1,
}),
]),
}),
})
)
await service.revertUsage(
[{ amount: 1, code: createdPromotion.code! }],
{
customer_id: "customer-id-2",
customer_email: "customer2@email.com",
}
)
campaign = await service.retrieveCampaign(createdCampaign.id, {
relations: ["budget", "budget.usages"],
})
expect(campaign.budget!.usages!).toHaveLength(1)
expect(campaign).toEqual(
expect.objectContaining({
budget: expect.objectContaining({
used: 1,
usages: expect.arrayContaining([
expect.objectContaining({
attribute_value: "customer-id-1",
used: 1,
}),
]),
}),
})
)
})
})
})
},

View File

@@ -151,7 +151,9 @@
"nullable": false,
"enumItems": [
"spend",
"usage"
"usage",
"use_by_attribute",
"spend_by_attribute"
],
"mappedType": "enum"
},
@@ -192,6 +194,15 @@
"nullable": false,
"mappedType": "text"
},
"attribute": {
"name": "attribute",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "text"
},
"raw_limit": {
"name": "raw_limit",
"type": "jsonb",
@@ -302,6 +313,146 @@
},
"nativeEnums": {}
},
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"attribute_value": {
"name": "attribute_value",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"used": {
"name": "used",
"type": "numeric",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"default": "0",
"mappedType": "decimal"
},
"budget_id": {
"name": "budget_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"raw_used": {
"name": "raw_used",
"type": "jsonb",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "json"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"updated_at": {
"name": "updated_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"mappedType": "datetime"
}
},
"name": "promotion_campaign_budget_usage",
"schema": "public",
"indexes": [
{
"keyName": "IDX_promotion_campaign_budget_usage_budget_id",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_promotion_campaign_budget_usage_budget_id\" ON \"promotion_campaign_budget_usage\" (budget_id) WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_promotion_campaign_budget_usage_deleted_at",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_promotion_campaign_budget_usage_deleted_at\" ON \"promotion_campaign_budget_usage\" (deleted_at) WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_promotion_campaign_budget_usage_attribute_value_budget_id_unique",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_promotion_campaign_budget_usage_attribute_value_budget_id_unique\" ON \"promotion_campaign_budget_usage\" (attribute_value, budget_id) WHERE deleted_at IS NULL"
},
{
"keyName": "promotion_campaign_budget_usage_pkey",
"columnNames": [
"id"
],
"composite": false,
"constraint": true,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {
"promotion_campaign_budget_usage_budget_id_foreign": {
"constraintName": "promotion_campaign_budget_usage_budget_id_foreign",
"columnNames": [
"budget_id"
],
"localTableName": "public.promotion_campaign_budget_usage",
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.promotion_campaign_budget",
"deleteRule": "cascade",
"updateRule": "cascade"
}
},
"nativeEnums": {}
},
{
"columns": {
"id": {

View File

@@ -0,0 +1,54 @@
import { Migration } from "@mikro-orm/migrations"
export class Migration20250909083125 extends Migration {
override async up(): Promise<void> {
this.addSql(
`alter table if exists "promotion_campaign_budget_usage" drop constraint if exists "promotion_campaign_budget_usage_attribute_value_budget_id_unique";`
)
this.addSql(
`create table if not exists "promotion_campaign_budget_usage" ("id" text not null, "attribute_value" text not null, "used" numeric not null default 0, "budget_id" text not null, "raw_used" jsonb not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "promotion_campaign_budget_usage_pkey" primary key ("id"));`
)
this.addSql(
`CREATE INDEX IF NOT EXISTS "IDX_promotion_campaign_budget_usage_budget_id" ON "promotion_campaign_budget_usage" (budget_id) WHERE deleted_at IS NULL;`
)
this.addSql(
`CREATE INDEX IF NOT EXISTS "IDX_promotion_campaign_budget_usage_deleted_at" ON "promotion_campaign_budget_usage" (deleted_at) WHERE deleted_at IS NULL;`
)
this.addSql(
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_promotion_campaign_budget_usage_attribute_value_budget_id_unique" ON "promotion_campaign_budget_usage" (attribute_value, budget_id) WHERE deleted_at IS NULL;`
)
this.addSql(
`alter table if exists "promotion_campaign_budget_usage" add constraint "promotion_campaign_budget_usage_budget_id_foreign" foreign key ("budget_id") references "promotion_campaign_budget" ("id") on update cascade on delete cascade;`
)
this.addSql(
`alter table if exists "promotion_campaign_budget" drop constraint if exists "promotion_campaign_budget_type_check";`
)
this.addSql(
`alter table if exists "promotion_campaign_budget" add column if not exists "attribute" text null;`
)
this.addSql(
`alter table if exists "promotion_campaign_budget" add constraint "promotion_campaign_budget_type_check" check("type" in ('spend', 'usage', 'use_by_attribute', 'spend_by_attribute'));`
)
}
override async down(): Promise<void> {
this.addSql(
`drop table if exists "promotion_campaign_budget_usage" cascade;`
)
this.addSql(
`alter table if exists "promotion_campaign_budget" drop constraint if exists "promotion_campaign_budget_type_check";`
)
this.addSql(
`alter table if exists "promotion_campaign_budget" drop column if exists "attribute";`
)
this.addSql(
`alter table if exists "promotion_campaign_budget" add constraint "promotion_campaign_budget_type_check" check("type" in ('spend', 'usage'));`
)
}
}

View File

@@ -0,0 +1,27 @@
import { model } from "@medusajs/framework/utils"
import CampaignBudget from "./campaign-budget"
const CampaignBudgetUsage = model
.define(
{
name: "CampaignBudgetUsage",
tableName: "promotion_campaign_budget_usage",
},
{
id: model.id({ prefix: "probudgus" }).primaryKey(),
attribute_value: model.text(), // e.g. "cus_123" | "john.smith@gmail.com"
used: model.bigNumber().default(0),
budget: model.belongsTo(() => CampaignBudget, {
mappedBy: "usages",
}),
}
)
.indexes([
{
on: ["attribute_value", "budget_id"],
unique: true,
where: "deleted_at IS NULL",
},
])
export default CampaignBudgetUsage

View File

@@ -1,20 +1,32 @@
import { PromotionUtils, model } from "@medusajs/framework/utils"
import Campaign from "./campaign"
import CampaignBudgetUsage from "./campaign-budget-usage"
const CampaignBudget = model.define(
{ name: "CampaignBudget", tableName: "promotion_campaign_budget" },
{
id: model.id({ prefix: "probudg" }).primaryKey(),
type: model
.enum(PromotionUtils.CampaignBudgetType)
.index("IDX_campaign_budget_type"),
currency_code: model.text().nullable(),
limit: model.bigNumber().nullable(),
used: model.bigNumber().default(0),
campaign: model.belongsTo(() => Campaign, {
mappedBy: "budget",
}),
}
)
const CampaignBudget = model
.define(
{ name: "CampaignBudget", tableName: "promotion_campaign_budget" },
{
id: model.id({ prefix: "probudg" }).primaryKey(),
type: model
.enum(PromotionUtils.CampaignBudgetType)
.index("IDX_campaign_budget_type"),
currency_code: model.text().nullable(),
limit: model.bigNumber().nullable(),
used: model.bigNumber().default(0),
campaign: model.belongsTo(() => Campaign, {
mappedBy: "budget",
}),
attribute: model.text().nullable(), // e.g. "customer_id", "customer_email"
// usages when budget type is "limit/use by attribute"
usages: model.hasMany(() => CampaignBudgetUsage, {
mappedBy: "budget",
}),
}
)
.cascades({
delete: ["usages"],
})
export default CampaignBudget

View File

@@ -4,3 +4,4 @@ export { default as CampaignBudget } from "./campaign-budget"
export { default as Promotion } from "./promotion"
export { default as PromotionRule } from "./promotion-rule"
export { default as PromotionRuleValue } from "./promotion-rule-value"
export { default as CampaignBudgetUsage } from "./campaign-budget-usage"

View File

@@ -1,5 +1,6 @@
import {
CampaignBudgetTypeValues,
CampaignBudgetUsageDTO,
Context,
DAL,
FilterablePromotionProps,
@@ -37,6 +38,7 @@ import {
ApplicationMethod,
Campaign,
CampaignBudget,
CampaignBudgetUsage,
Promotion,
PromotionRule,
PromotionRuleValue,
@@ -72,6 +74,7 @@ type InjectedDependencies = {
promotionRuleValueService: ModulesSdkTypes.IMedusaInternalService<any>
campaignService: ModulesSdkTypes.IMedusaInternalService<any>
campaignBudgetService: ModulesSdkTypes.IMedusaInternalService<any>
campaignBudgetUsageService: ModulesSdkTypes.IMedusaInternalService<any>
}
export default class PromotionModuleService
@@ -80,6 +83,7 @@ export default class PromotionModuleService
ApplicationMethod: { dto: PromotionTypes.ApplicationMethodDTO }
Campaign: { dto: PromotionTypes.CampaignDTO }
CampaignBudget: { dto: PromotionTypes.CampaignBudgetDTO }
CampaignBudgetUsage: { dto: PromotionTypes.CampaignBudgetUsageDTO }
PromotionRule: { dto: PromotionTypes.PromotionRuleDTO }
PromotionRuleValue: { dto: PromotionTypes.PromotionRuleValueDTO }
}>({
@@ -87,6 +91,7 @@ export default class PromotionModuleService
ApplicationMethod,
Campaign,
CampaignBudget,
CampaignBudgetUsage,
PromotionRule,
PromotionRuleValue,
})
@@ -112,6 +117,10 @@ export default class PromotionModuleService
InferEntityType<typeof CampaignBudget>
>
protected campaignBudgetUsageService_: ModulesSdkTypes.IMedusaInternalService<
InferEntityType<typeof CampaignBudgetUsage>
>
constructor(
{
baseRepository,
@@ -121,6 +130,7 @@ export default class PromotionModuleService
promotionRuleValueService,
campaignService,
campaignBudgetService,
campaignBudgetUsageService,
}: InjectedDependencies,
protected readonly moduleDeclaration: InternalModuleDeclaration
) {
@@ -134,6 +144,7 @@ export default class PromotionModuleService
this.promotionRuleValueService_ = promotionRuleValueService
this.campaignService_ = campaignService
this.campaignBudgetService_ = campaignBudgetService
this.campaignBudgetUsageService_ = campaignBudgetUsageService
}
__joinerConfig(): ModuleJoinerConfig {
@@ -194,10 +205,106 @@ export default class PromotionModuleService
)
}
@InjectTransactionManager()
protected async registerCampaignBudgetUsageByAttribute_(
budgetId: string,
attributeValue: string,
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
const [campaignBudgetUsagePerAttributeValue] =
await this.campaignBudgetUsageService_.list(
{
budget_id: budgetId,
attribute_value: attributeValue,
},
{ relations: ["budget"] },
sharedContext
)
if (!campaignBudgetUsagePerAttributeValue) {
await this.campaignBudgetUsageService_.create(
{
budget_id: budgetId,
attribute_value: attributeValue,
used: MathBN.convert(1),
},
sharedContext
)
} else {
const limit = campaignBudgetUsagePerAttributeValue.budget.limit
const newUsedValue = MathBN.add(
campaignBudgetUsagePerAttributeValue.used ?? 0,
1
)
if (limit && MathBN.gt(newUsedValue, limit)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Promotion usage exceeds the budget limit."
)
}
await this.campaignBudgetUsageService_.update(
{
id: campaignBudgetUsagePerAttributeValue.id,
used: newUsedValue,
},
sharedContext
)
}
}
@InjectTransactionManager()
protected async revertCampaignBudgetUsageByAttribute_(
budgetId: string,
attributeValue: string,
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
const [campaignBudgetUsagePerAttributeValue] =
await this.campaignBudgetUsageService_.list(
{
budget_id: budgetId,
attribute_value: attributeValue,
},
{},
sharedContext
)
if (!campaignBudgetUsagePerAttributeValue) {
return
}
if (MathBN.lte(campaignBudgetUsagePerAttributeValue.used ?? 0, 1)) {
await this.campaignBudgetUsageService_.delete(
campaignBudgetUsagePerAttributeValue.id,
sharedContext
)
} else {
await this.campaignBudgetUsageService_.update(
{
id: campaignBudgetUsagePerAttributeValue.id,
used: MathBN.sub(campaignBudgetUsagePerAttributeValue.used ?? 0, 1),
},
sharedContext
)
}
}
@InjectTransactionManager()
@EmitEvents()
/**
* Register the usage of promotions in the campaign budget and
* increment the used value if the budget is not exceeded,
* throws an error if the budget is exceeded.
*
* @param computedActions - The computed actions to register usage for.
* @param registrationContext - The context of the campaign budget usage.
* @returns void
* @throws {MedusaError} - If the promotion usage exceeds the budget limit.
*/
async registerUsage(
computedActions: PromotionTypes.UsageComputedActions[],
registrationContext: PromotionTypes.CampaignBudgetUsageContext,
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
const promotionCodes = computedActions
@@ -209,7 +316,7 @@ export default class PromotionModuleService
const existingPromotions = await this.listActivePromotions_(
{ code: promotionCodes },
{ relations: ["campaign", "campaign.budget"] },
{ relations: ["campaign", "campaign.budget", "campaign.budget.usages"] },
sharedContext
)
@@ -257,11 +364,14 @@ export default class PromotionModuleService
campaignBudget.limit &&
MathBN.gt(newUsedValue, campaignBudget.limit)
) {
continue
} else {
campaignBudgetData.used = newUsedValue
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Promotion usage exceeds the budget limit."
)
}
campaignBudgetData.used = newUsedValue
campaignBudgetMap.set(campaignBudget.id, campaignBudgetData)
}
@@ -275,22 +385,53 @@ export default class PromotionModuleService
const newUsedValue = MathBN.add(campaignBudget.used ?? 0, 1)
// Check if it exceeds the limit and cap it if necessary
if (
campaignBudget.limit &&
MathBN.gt(newUsedValue, campaignBudget.limit)
) {
campaignBudgetMap.set(campaignBudget.id, {
id: campaignBudget.id,
used: campaignBudget.limit,
})
} else {
campaignBudgetMap.set(campaignBudget.id, {
id: campaignBudget.id,
used: newUsedValue,
})
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Promotion usage exceeds the budget limit."
)
}
campaignBudgetMap.set(campaignBudget.id, {
id: campaignBudget.id,
used: newUsedValue,
})
promotionCodeUsageMap.set(promotion.code!, true)
}
if (campaignBudget.type === CampaignBudgetType.USE_BY_ATTRIBUTE) {
const promotionAlreadyUsed =
promotionCodeUsageMap.get(promotion.code!) || false
if (promotionAlreadyUsed) {
continue
}
const attribute = campaignBudget.attribute!
const attributeValue = registrationContext[attribute]
if (!attributeValue) {
continue
}
await this.registerCampaignBudgetUsageByAttribute_(
campaignBudget.id,
attributeValue,
sharedContext
)
const newUsedValue = MathBN.add(campaignBudget.used ?? 0, 1)
// update the global budget usage to keep track but it is not used anywhere atm
campaignBudgetMap.set(campaignBudget.id, {
id: campaignBudget.id,
used: newUsedValue,
})
promotionCodeUsageMap.set(promotion.code!, true)
}
}
@@ -298,6 +439,13 @@ export default class PromotionModuleService
if (campaignBudgetMap.size > 0) {
const campaignBudgetsData: UpdateCampaignBudgetDTO[] = []
for (const [_, campaignBudgetData] of campaignBudgetMap) {
// usages by attribute are updated separatley
if (campaignBudgetData.usages) {
const { usages, ...campaignBudgetDataWithoutUsages } =
campaignBudgetData
campaignBudgetsData.push(campaignBudgetDataWithoutUsages)
continue
}
campaignBudgetsData.push(campaignBudgetData)
}
@@ -312,6 +460,7 @@ export default class PromotionModuleService
@EmitEvents()
async revertUsage(
computedActions: PromotionTypes.UsageComputedActions[],
registrationContext: PromotionTypes.CampaignBudgetUsageContext,
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
const promotionCodeUsageMap = new Map<string, boolean>()
@@ -390,11 +539,49 @@ export default class PromotionModuleService
promotionCodeUsageMap.set(promotion.code!, true)
}
if (campaignBudget.type === CampaignBudgetType.USE_BY_ATTRIBUTE) {
const promotionAlreadyUsed =
promotionCodeUsageMap.get(promotion.code!) || false
if (promotionAlreadyUsed) {
continue
}
const attribute = campaignBudget.attribute!
const attributeValue = registrationContext[attribute]
if (!attributeValue) {
continue
}
await this.revertCampaignBudgetUsageByAttribute_(
campaignBudget.id,
attributeValue,
sharedContext
)
const newUsedValue = MathBN.sub(campaignBudget.used ?? 0, 1)
const usedValue = MathBN.lt(newUsedValue, 0) ? 0 : newUsedValue
// update the global budget usage to keep track but it is not used anywhere atm
campaignBudgetMap.set(campaignBudget.id, {
id: campaignBudget.id,
used: usedValue,
})
promotionCodeUsageMap.set(promotion.code!, true)
}
}
if (campaignBudgetMap.size > 0) {
const campaignBudgetsData: UpdateCampaignBudgetDTO[] = []
for (const [_, campaignBudgetData] of campaignBudgetMap) {
if (campaignBudgetData.usages) {
const { usages, ...campaignBudgetDataWithoutUsages } =
campaignBudgetData
campaignBudgetsData.push(campaignBudgetDataWithoutUsages)
continue
}
campaignBudgetsData.push(campaignBudgetData)
}
@@ -581,6 +768,47 @@ export default class PromotionModuleService
rules: promotionRules = [],
} = promotion
if (
promotion.campaign?.budget?.type === CampaignBudgetType.USE_BY_ATTRIBUTE
) {
const attribute = promotion.campaign?.budget?.attribute!
const budgetUsageContext =
ComputeActionUtils.getBudgetUsageContextFromComputeActionContext(
applicationContext
)
const attributeValue = budgetUsageContext[attribute]
if (!attributeValue) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Attribute value for "${attribute}" is required by promotion campaing budget`
)
}
const [campaignBudgetUsagePerAttribute] =
(await this.campaignBudgetUsageService_.list(
{
budget_id: promotion.campaign?.budget?.id,
attribute_value: attributeValue,
},
{},
sharedContext
)) as unknown as CampaignBudgetUsageDTO[]
if (campaignBudgetUsagePerAttribute) {
const action = ComputeActionUtils.computeActionForBudgetExceeded(
promotion,
1,
campaignBudgetUsagePerAttribute
)
if (action) {
computedActions.push(action)
continue
}
}
}
const isCurrencyCodeValid =
!isPresent(applicationMethod.currency_code) ||
applicationContext.currency_code === applicationMethod.currency_code

View File

@@ -19,4 +19,16 @@ export interface UpdateCampaignBudgetDTO {
limit?: BigNumberInput | null
currency_code?: string | null
used?: BigNumberInput
usages?: CreateCampaignBudgetUsageDTO[]
}
export interface CreateCampaignBudgetUsageDTO {
budget_id: string
attribute_value: string
used: BigNumberInput
}
export interface UpdateCampaignBudgetUsageDTO {
id: string
used: BigNumberInput
}

View File

@@ -1,6 +1,9 @@
import {
BigNumberInput,
CampaignBudgetExceededAction,
CampaignBudgetUsageContext,
CampaignBudgetUsageDTO,
ComputeActionContext,
InferEntityType,
PromotionDTO,
} from "@medusajs/framework/types"
@@ -11,9 +14,20 @@ import {
} from "@medusajs/framework/utils"
import { Promotion } from "@models"
/**
* Compute the action for a budget exceeded.
* @param promotion - the promotion being applied
* @param amount - amount can be:
* 1. discounted amount in case of spend budget
* 2. number of times the promotion has been used in case of usage budget
* 3. number of times the promotion has been used by a specific attribute value in case of use_by_attribute budget
* @param attributeUsage - the attribute usage in case of use_by_attribute budget
* @returns the exceeded action if the budget is exceeded, otherwise undefined
*/
export function computeActionForBudgetExceeded(
promotion: PromotionDTO | InferEntityType<typeof Promotion>,
amount: BigNumberInput
amount: BigNumberInput,
attributeUsage?: CampaignBudgetUsageDTO
): CampaignBudgetExceededAction | void {
const campaignBudget = promotion.campaign?.budget
@@ -21,7 +35,17 @@ export function computeActionForBudgetExceeded(
return
}
const campaignBudgetUsed = campaignBudget.used ?? 0
if (
campaignBudget.type === CampaignBudgetType.USE_BY_ATTRIBUTE &&
!attributeUsage
) {
return
}
const campaignBudgetUsed = attributeUsage
? attributeUsage.used
: campaignBudget.used ?? 0
const totalUsed =
campaignBudget.type === CampaignBudgetType.SPEND
? MathBN.add(campaignBudgetUsed, amount)
@@ -34,3 +58,16 @@ export function computeActionForBudgetExceeded(
}
}
}
export function getBudgetUsageContextFromComputeActionContext(
computeActionContext: ComputeActionContext
): CampaignBudgetUsageContext {
return {
customer_id:
computeActionContext.customer_id ??
(computeActionContext.customer as any)?.id ??
null,
customer_email:
(computeActionContext.email as string | undefined | null) ?? null,
}
}