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
@@ -7683,6 +7683,12 @@
"taxInclusive": {
"type": "string"
},
"usageLimit": {
"type": "string"
},
"usage": {
"type": "string"
},
"amount": {
"type": "object",
"properties": {
@@ -7784,6 +7790,8 @@
"addCondition",
"clearAll",
"taxInclusive",
"usageLimit",
"usage",
"amount",
"conditions"
],
@@ -8230,6 +8238,19 @@
},
"required": ["fixed", "percentage"],
"additionalProperties": false
},
"limit": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"description": {
"type": "string"
}
},
"required": ["title", "description"],
"additionalProperties": false
}
},
"required": [
@@ -8245,7 +8266,8 @@
"allocation",
"code",
"value",
"value_type"
"value_type",
"limit"
],
"additionalProperties": false
},
@@ -2051,6 +2051,8 @@
"addCondition": "Add condition",
"clearAll": "Clear all",
"taxInclusive": "Tax Inclusive",
"usageLimit": "Usage Limit",
"usage": "Usage",
"amount": {
"tooltip": "Select the currency code to enable setting the amount"
},
@@ -2212,6 +2214,10 @@
"title": "Percentage",
"description": "The percentage to discount off the amount. eg. 8%"
}
},
"limit": {
"title": "Usage Limit",
"description": "Maximum number of times this promotion can be used across all orders. Leave empty for unlimited usage."
}
},
"deleteWarning": "You are about to delete the promotion {{code}}. This action cannot be undone.",
@@ -58,6 +58,7 @@ const defaultValues = {
status: "draft" as PromotionStatusValues,
rules: [],
is_tax_inclusive: false,
limit: undefined,
application_method: {
allocation: "each" as ApplicationMethodAllocationValues,
type: "fixed" as ApplicationMethodTypeValues,
@@ -901,7 +902,9 @@ export const CreatePromotionForm = () => {
return (
<Form.Item>
<Form.Label
tooltip={t("promotions.fields.allocationTooltip")}
tooltip={t(
"promotions.fields.allocationTooltip"
)}
>
{t("promotions.fields.allocation")}
</Form.Label>
@@ -987,6 +990,42 @@ export const CreatePromotionForm = () => {
/>
</>
)}
<Divider />
<Form.Field
control={form.control}
name="limit"
render={({ field: { onChange, value, ...field } }) => {
return (
<Form.Item className="basis-1/2">
<Form.Label>
{t("promotions.form.limit.title")}
</Form.Label>
<Form.Control>
<Input
{...field}
type="number"
min={1}
value={value ?? ""}
onChange={(e) => {
const val = e.target.value
onChange(val === "" ? null : parseInt(val, 10))
}}
placeholder="100"
/>
</Form.Control>
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
{t("promotions.form.limit.description")}
</Text>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
</ProgressTabs.Content>
@@ -28,6 +28,7 @@ export const CreatePromotionSchema = z
status: z.enum(["draft", "active", "inactive"]),
rules: RuleSchema,
is_tax_inclusive: z.boolean().optional(),
limit: z.number().int().min(1).nullable().optional(),
application_method: z.object({
allocation: z.enum(["each", "across", "once"]),
value: z.number().min(0).or(z.string().min(1)),
@@ -196,6 +196,20 @@ export const PromotionGeneralSection = ({
</div>
</div>
)}
{typeof promotion.limit === "number" && (
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
<Text size="small" weight="plus" leading="compact">
Usage Limit
</Text>
<div className="flex items-center gap-x-2">
<Text className="inline" size="small" leading="compact">
{promotion.used || 0} / {promotion.limit}
</Text>
</div>
</div>
)}
</Container>
)
}
@@ -180,6 +180,10 @@ export interface AdminCreatePromotion {
* The application method of the promotion.
*/
application_method: AdminCreateApplicationMethod
/**
* The maximum number of times this promotion can be used.
*/
limit?: number | null
/**
* The rules of the promotion.
*/
@@ -221,6 +225,10 @@ export interface AdminUpdatePromotion {
* The application method of the promotion.
*/
application_method?: AdminUpdateApplicationMethod
/**
* The maximum number of times this promotion can be used.
*/
limit?: number | null
/**
* The rules of the promotion.
*/
@@ -63,6 +63,8 @@ export interface BasePromotion {
type?: PromotionTypeValues
is_automatic?: boolean
is_tax_inclusive?: boolean
limit?: number | null
used?: number
application_method?: BaseApplicationMethod
rules?: BasePromotionRule[]
status?: PromotionStatusValues
@@ -9,6 +9,7 @@ export type ComputeActions =
| AddShippingMethodAdjustment
| RemoveShippingMethodAdjustment
| CampaignBudgetExceededAction
| PromotionLimitExceededAction
/**
* These computed action types can affect a campaign's budget.
@@ -41,6 +42,21 @@ export interface CampaignBudgetExceededAction {
code: string
}
/**
* This action indicates that a promotion usage limit has been exceeded.
*/
export interface PromotionLimitExceededAction {
/**
* The type of action.
*/
action: "promotionLimitExceeded"
/**
* The promotion's code.
*/
code: string
}
/**
* This action indicates that an adjustment must be made to an item. For example, removing $5 off its amount.
*/
@@ -65,6 +65,16 @@ export interface PromotionDTO {
*/
is_tax_inclusive?: boolean
/**
* The maximum number of times this promotion can be used across all orders.
*/
limit?: number | null
/**
* The number of times this promotion has been used in completed orders.
*/
used?: number
/**
* The associated application method.
*/
@@ -123,6 +133,11 @@ export interface CreatePromotionDTO {
*/
is_tax_inclusive?: boolean
/**
* The maximum number of times this promotion can be used.
*/
limit?: number | null
/**
* The associated application method.
*/
@@ -173,6 +188,11 @@ export interface UpdatePromotionDTO {
*/
is_tax_inclusive?: boolean
/**
* The maximum number of times this promotion can be used.
*/
limit?: number | null
/**
* The status of the promotion:
*
@@ -49,6 +49,7 @@ export enum ComputedActions {
REMOVE_ITEM_ADJUSTMENT = "removeItemAdjustment",
REMOVE_SHIPPING_METHOD_ADJUSTMENT = "removeShippingMethodAdjustment",
CAMPAIGN_BUDGET_EXCEEDED = "campaignBudgetExceeded",
PROMOTION_LIMIT_EXCEEDED = "promotionLimitExceeded",
}
export enum PromotionActions {
@@ -4,6 +4,8 @@ export const defaultAdminPromotionFields = [
"is_automatic",
"is_tax_inclusive",
"type",
"limit",
"used",
"status",
"created_at",
"updated_at",
@@ -175,16 +175,35 @@ export const CreatePromotion = z
campaign: CreateCampaign.optional(),
application_method: AdminCreateApplicationMethod,
rules: z.array(AdminCreatePromotionRule).optional(),
limit: z.number().int().min(1).nullable().optional(),
})
.strict()
export const AdminCreatePromotion = WithAdditionalData(
CreatePromotion,
(schema) => {
return schema.refine(promoRefinement, {
message:
"Buyget promotions require at least one buy rule and quantities to be defined",
})
return schema
.refine(promoRefinement, {
message:
"Buyget promotions require at least one buy rule and quantities to be defined",
})
.refine(
(data) => {
// Automatic promotions cannot have a limit
if (
data.is_automatic &&
data.limit !== null &&
data.limit !== undefined
) {
return false
}
return true
},
{
message: "Automatic promotions cannot have a usage limit",
path: ["limit"],
}
)
}
)
@@ -198,15 +217,34 @@ export const UpdatePromotion = z
status: z.nativeEnum(PromotionStatus).optional(),
campaign_id: z.string().nullish(),
application_method: AdminUpdateApplicationMethod.optional(),
limit: z.number().int().min(1).nullable().optional(),
})
.strict()
export const AdminUpdatePromotion = WithAdditionalData(
UpdatePromotion,
(schema) => {
return schema.refine(promoRefinement, {
message:
"Buyget promotions require at least one buy rule and quantities to be defined",
})
return schema
.refine(promoRefinement, {
message:
"Buyget promotions require at least one buy rule and quantities to be defined",
})
.refine(
(data) => {
// Automatic promotions cannot have a limit
if (
data.is_automatic &&
data.limit !== null &&
data.limit !== undefined
) {
return false
}
return true
},
{
message: "Automatic promotions cannot have a usage limit",
path: ["limit"],
}
)
}
)
@@ -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,