feat: promotion usage limit (#13760)

* feat: promotion usage limit

* fix: update, refactor tests, parallel case

* fix: batch update, cleanup unused map

* feat: paralel campaign and promotion tests

* chore: changesets, fix i18 schema

* fix: ui tweaks

* chore: refactor

---------

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Frane Polić
2025-11-30 19:43:36 +01:00
committed by GitHub
co-authored by Oli Juhl
parent 5da51064d7
commit 536a3f802c
18 changed files with 1269 additions and 11 deletions
@@ -493,6 +493,25 @@
"default": "false",
"mappedType": "boolean"
},
"limit": {
"name": "limit",
"type": "integer",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "integer"
},
"used": {
"name": "used",
"type": "integer",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"default": "0",
"mappedType": "integer"
},
"type": {
"name": "type",
"type": "text",
@@ -750,7 +769,8 @@
"nullable": true,
"enumItems": [
"each",
"across"
"across",
"once"
],
"mappedType": "enum"
},
@@ -0,0 +1,15 @@
import { Migration } from "@mikro-orm/migrations"
export class Migration20251015113934 extends Migration {
override async up(): Promise<void> {
this.addSql(
`alter table if exists "promotion" add column if not exists "limit" integer null, add column if not exists "used" integer not null default 0;`
)
}
override async down(): Promise<void> {
this.addSql(
`alter table if exists "promotion" drop column if exists "limit", drop column if exists "used";`
)
}
}
@@ -9,6 +9,8 @@ const Promotion = model
code: model.text().searchable(),
is_automatic: model.boolean().default(false),
is_tax_inclusive: model.boolean().default(false),
limit: model.number().nullable(),
used: model.number().default(0),
type: model.enum(PromotionUtils.PromotionType).index("IDX_promotion_type"),
status: model
.enum(PromotionUtils.PromotionStatus)
@@ -307,6 +307,7 @@ export default class PromotionModuleService
const campaignBudgetMap = new Map<string, UpdateCampaignBudgetDTO>()
const promotionCodeUsageMap = new Map<string, boolean>()
const promotionUsageMap = new Map<string, { id: string; used: number }>()
const existingPromotions = await this.listActivePromotions_(
{ code: promotionCodes },
@@ -335,6 +336,22 @@ export default class PromotionModuleService
continue
}
if (typeof promotion.limit === "number") {
const newUsedValue = (promotion.used ?? 0) + 1
if (newUsedValue > promotion.limit) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Promotion usage exceeds the limit."
)
}
promotionUsageMap.set(promotion.id, {
id: promotion.id,
used: newUsedValue,
})
}
const campaignBudget = promotion.campaign?.budget
if (!campaignBudget) {
@@ -430,6 +447,13 @@ export default class PromotionModuleService
}
}
if (promotionUsageMap.size > 0) {
await this.promotionService_.update(
Array.from(promotionUsageMap.values()),
sharedContext
)
}
if (campaignBudgetMap.size > 0) {
const campaignBudgetsData: UpdateCampaignBudgetDTO[] = []
for (const [_, campaignBudgetData] of campaignBudgetMap) {
@@ -459,6 +483,7 @@ export default class PromotionModuleService
): Promise<void> {
const promotionCodeUsageMap = new Map<string, boolean>()
const campaignBudgetMap = new Map<string, UpdateCampaignBudgetDTO>()
const promotionUsageMap = new Map<string, { id: string; used: number }>()
const existingPromotions = await this.listActivePromotions_(
{
@@ -491,6 +516,15 @@ export default class PromotionModuleService
continue
}
if (typeof promotion.limit === "number") {
const newUsedValue = Math.max(0, (promotion.used ?? 0) - 1)
promotionUsageMap.set(promotion.id, {
id: promotion.id,
used: newUsedValue,
})
}
const campaignBudget = promotion.campaign?.budget
if (!campaignBudget) {
@@ -567,6 +601,13 @@ export default class PromotionModuleService
}
}
if (promotionUsageMap.size > 0) {
await this.promotionService_.update(
Array.from(promotionUsageMap.values()),
sharedContext
)
}
if (campaignBudgetMap.size > 0) {
const campaignBudgetsData: UpdateCampaignBudgetDTO[] = []
for (const [_, campaignBudgetData] of campaignBudgetMap) {
@@ -805,6 +846,17 @@ export default class PromotionModuleService
}
}
// Check promotion usage limit
if (typeof promotion.limit === "number") {
if ((promotion.used ?? 0) >= promotion.limit) {
computedActions.push({
action: ComputedActions.PROMOTION_LIMIT_EXCEEDED,
code: promotion.code!,
})
continue
}
}
const isCurrencyCodeValid =
!isPresent(applicationMethod.currency_code) ||
applicationContext.currency_code === applicationMethod.currency_code
@@ -1242,6 +1294,17 @@ export default class PromotionModuleService
existingApplicationMethod?.currency_code ||
applicationMethodData?.currency_code
// Validate promotion limit cannot be less than current usage
if (isDefined(promotionData.limit) && promotionData.limit !== null) {
const currentUsed = existingPromotion.used ?? 0
if (promotionData.limit < currentUsed) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Promotion limit (${promotionData.limit}) cannot be less than current usage (${currentUsed})`
)
}
}
if (campaignId && !existingCampaign) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,