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:
@@ -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.",
|
||||
|
||||
+40
-1
@@ -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>
|
||||
|
||||
+1
@@ -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)),
|
||||
|
||||
+14
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user