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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user