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
parent 5da51064d7
commit 536a3f802c
18 changed files with 1269 additions and 11 deletions

View File

@@ -0,0 +1,9 @@
---
"@medusajs/promotion": patch
"@medusajs/dashboard": patch
"@medusajs/types": patch
"@medusajs/utils": patch
"@medusajs/medusa": patch
---
feat: promotion usage limit

View File

@@ -0,0 +1,980 @@
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { Modules, PromotionStatus, PromotionType } from "@medusajs/utils"
import {
createAdminUser,
generatePublishableKey,
generateStoreHeaders,
} from "../../../../helpers/create-admin-user"
import { setupTaxStructure } from "../../../../modules/__tests__/fixtures/tax"
import { medusaTshirtProduct } from "../../../__fixtures__/product"
jest.setTimeout(500000)
const adminHeaders = {
headers: { "x-medusa-access-token": "test_token" },
}
medusaIntegrationTestRunner({
testSuite: ({ dbConnection, getContainer, api }) => {
describe("Admin Promotions API - Promotion Limits", () => {
let appContainer
let promotion
let product
let region
let salesChannel
let storeHeaders
let shippingProfile
let stockLocation
let fulfillmentSet
let shippingOption
beforeAll(async () => {
appContainer = getContainer()
})
beforeEach(async () => {
await createAdminUser(dbConnection, adminHeaders, appContainer)
await setupTaxStructure(appContainer.resolve(Modules.TAX))
shippingProfile = (
await api.post(
`/admin/shipping-profiles`,
{ name: "default", type: "default" },
adminHeaders
)
).data.shipping_profile
stockLocation = (
await api.post(
`/admin/stock-locations`,
{ name: "test location" },
adminHeaders
)
).data.stock_location
const fulfillmentSets = (
await api.post(
`/admin/stock-locations/${stockLocation.id}/fulfillment-sets?fields=*fulfillment_sets`,
{
name: `Test-inventory`,
type: "test-type",
},
adminHeaders
)
).data.stock_location.fulfillment_sets
fulfillmentSet = (
await api.post(
`/admin/fulfillment-sets/${fulfillmentSets[0].id}/service-zones`,
{
name: `Test-inventory`,
geo_zones: [{ type: "country", country_code: "US" }],
},
adminHeaders
)
).data.fulfillment_set
await api.post(
`/admin/stock-locations/${stockLocation.id}/fulfillment-providers`,
{ add: ["manual_test-provider"] },
adminHeaders
)
shippingOption = (
await api.post(
`/admin/shipping-options`,
{
name: `Test shipping option ${fulfillmentSet.id}`,
service_zone_id: fulfillmentSet.service_zones[0].id,
shipping_profile_id: shippingProfile.id,
provider_id: "manual_test-provider",
price_type: "flat",
type: {
label: "Test type",
description: "Test description",
code: "test-code",
},
prices: [{ currency_code: "usd", amount: 1000 }],
rules: [],
},
adminHeaders
)
).data.shipping_option
product = (
await api.post(
`/admin/products`,
{ ...medusaTshirtProduct, shipping_profile_id: shippingProfile.id },
adminHeaders
)
).data.product
region = (
await api.post(
`/admin/regions`,
{
name: "Test Region",
currency_code: "usd",
countries: ["us"],
},
adminHeaders
)
).data.region
salesChannel = (
await api.post(
`/admin/sales-channels`,
{ name: "Test Sales Channel" },
adminHeaders
)
).data.sales_channel
await api.post(
`/admin/stock-locations/${stockLocation.id}/sales-channels`,
{ add: [salesChannel.id] },
adminHeaders
)
const publishableKey = await generatePublishableKey(appContainer)
storeHeaders = generateStoreHeaders({ publishableKey })
})
describe("Create promotion with limit", () => {
it("should create a promotion with a usage limit", async () => {
const response = await api.post(
`/admin/promotions`,
{
code: "LIMITED_PROMO",
type: PromotionType.STANDARD,
status: PromotionStatus.ACTIVE,
limit: 5,
application_method: {
type: "fixed",
target_type: "items",
allocation: "across",
value: 100,
currency_code: "usd",
},
},
adminHeaders
)
expect(response.data.promotion).toEqual(
expect.objectContaining({
code: "LIMITED_PROMO",
limit: 5,
used: 0,
})
)
})
it("should create a promotion without a limit (unlimited)", async () => {
const response = await api.post(
`/admin/promotions`,
{
code: "UNLIMITED_PROMO",
type: PromotionType.STANDARD,
status: PromotionStatus.ACTIVE,
application_method: {
type: "fixed",
target_type: "items",
allocation: "across",
value: 100,
currency_code: "usd",
},
},
adminHeaders
)
expect(response.data.promotion).toEqual(
expect.objectContaining({
code: "UNLIMITED_PROMO",
limit: null,
used: 0,
})
)
})
it("should prevent creating automatic promotion with limit", async () => {
const response = await api
.post(
`/admin/promotions`,
{
code: "AUTO_PROMO",
type: PromotionType.STANDARD,
status: PromotionStatus.ACTIVE,
is_automatic: true,
limit: 5,
application_method: {
type: "fixed",
target_type: "items",
allocation: "across",
value: 100,
currency_code: "usd",
},
},
adminHeaders
)
.catch((err) => {
return err.response
})
expect(response.status).toBe(400)
expect(response.data.message).toContain(
"Automatic promotions cannot have a usage limit"
)
})
})
describe("Complete order increments usage", () => {
beforeEach(async () => {
promotion = (
await api.post(
`/admin/promotions`,
{
code: "TEST_LIMIT",
type: PromotionType.STANDARD,
status: PromotionStatus.ACTIVE,
limit: 3,
application_method: {
type: "fixed",
target_type: "items",
allocation: "across",
value: 100,
currency_code: "usd",
},
},
adminHeaders
)
).data.promotion
})
it("should increment used count when order is completed", async () => {
const cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: {
address_1: "test address 1",
address_2: "test address 2",
city: "SF",
country_code: "US",
province: "CA",
postal_code: "94016",
},
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
promo_codes: [promotion.code],
},
storeHeaders
)
).data.cart
expect(cart.promotions).toHaveLength(1)
expect(cart.promotions[0].code).toBe(promotion.code)
await api.post(
`/store/carts/${cart.id}/shipping-methods`,
{ option_id: shippingOption.id },
storeHeaders
)
const paymentCollection = (
await api.post(
`/store/payment-collections`,
{ cart_id: cart.id },
storeHeaders
)
).data.payment_collection
await api.post(
`/store/payment-collections/${paymentCollection.id}/payment-sessions`,
{ provider_id: "pp_system_default" },
storeHeaders
)
const order = (
await api.post(`/store/carts/${cart.id}/complete`, {}, storeHeaders)
).data.order
expect(order).toBeDefined()
const updatedPromotion = (
await api.get(`/admin/promotions/${promotion.id}`, adminHeaders)
).data.promotion
expect(updatedPromotion.used).toBe(1)
})
it("should not increment used count when promotion is only added to cart", async () => {
// Create cart with promotion but don't complete
const cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: {
address_1: "test address 1",
address_2: "test address 2",
city: "SF",
country_code: "US",
province: "CA",
postal_code: "94016",
},
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
promo_codes: [promotion.code],
},
storeHeaders
)
).data.cart
expect(cart.promotions).toHaveLength(1)
// Check promotion usage was NOT incremented
const updatedPromotion = (
await api.get(`/admin/promotions/${promotion.id}`, adminHeaders)
).data.promotion
expect(updatedPromotion.used).toBe(0)
})
})
describe("Limit enforcement on cart completion", () => {
beforeEach(async () => {
promotion = (
await api.post(
`/admin/promotions`,
{
code: "LIMIT_2",
type: PromotionType.STANDARD,
status: PromotionStatus.ACTIVE,
limit: 2,
application_method: {
type: "fixed",
target_type: "items",
allocation: "across",
value: 100,
currency_code: "usd",
},
},
adminHeaders
)
).data.promotion
})
it("should allow completing 2 orders successfully", async () => {
// Complete first cart
const cart1 = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: {
address_1: "test address 1",
address_2: "test address 2",
city: "SF",
country_code: "US",
province: "CA",
postal_code: "94016",
},
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
promo_codes: [promotion.code],
},
storeHeaders
)
).data.cart
// Setup first cart
await api.post(
`/store/carts/${cart1.id}/shipping-methods`,
{ option_id: shippingOption.id },
storeHeaders
)
const paymentCollection1 = (
await api.post(
`/store/payment-collections`,
{ cart_id: cart1.id },
storeHeaders
)
).data.payment_collection
await api.post(
`/store/payment-collections/${paymentCollection1.id}/payment-sessions`,
{ provider_id: "pp_system_default" },
storeHeaders
)
await api.post(`/store/carts/${cart1.id}/complete`, {}, storeHeaders)
// Complete second cart
const cart2 = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: {
address_1: "test address 1",
address_2: "test address 2",
city: "SF",
country_code: "US",
province: "CA",
postal_code: "94016",
},
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
promo_codes: [promotion.code],
},
storeHeaders
)
).data.cart
// Setup second cart
await api.post(
`/store/carts/${cart2.id}/shipping-methods`,
{ option_id: shippingOption.id },
storeHeaders
)
const paymentCollection2 = (
await api.post(
`/store/payment-collections`,
{ cart_id: cart2.id },
storeHeaders
)
).data.payment_collection
await api.post(
`/store/payment-collections/${paymentCollection2.id}/payment-sessions`,
{ provider_id: "pp_system_default" },
storeHeaders
)
await api.post(`/store/carts/${cart2.id}/complete`, {}, storeHeaders)
const updatedPromotion = (
await api.get(`/admin/promotions/${promotion.id}`, adminHeaders)
).data.promotion
expect(updatedPromotion.used).toBe(2)
})
it("should not add promotion to the third cart when limit is exceeded", async () => {
// Complete first two orders
for (let i = 0; i < 2; i++) {
const cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: {
address_1: "test address 1",
address_2: "test address 2",
city: "SF",
country_code: "US",
province: "CA",
postal_code: "94016",
},
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
promo_codes: [promotion.code],
},
storeHeaders
)
).data.cart
await api.post(
`/store/carts/${cart.id}/shipping-methods`,
{ option_id: shippingOption.id },
storeHeaders
)
const paymentCollection = (
await api.post(
`/store/payment-collections`,
{ cart_id: cart.id },
storeHeaders
)
).data.payment_collection
await api.post(
`/store/payment-collections/${paymentCollection.id}/payment-sessions`,
{ provider_id: "pp_system_default" },
storeHeaders
)
await api.post(`/store/carts/${cart.id}/complete`, {}, storeHeaders)
}
// Third cart should fail
const cart3 = (
await api.post(
`/store/carts?fields=*promotions.*`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: {
address_1: "test address 1",
address_2: "test address 2",
city: "SF",
country_code: "US",
province: "CA",
postal_code: "94016",
},
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
promo_codes: [promotion.code],
},
storeHeaders
)
).data.cart
expect(cart3.promotions).toHaveLength(0) // promotion cannot be appleied since action "PROMOTION EXCEEDED LIMIT" is returned
})
it("should fail third cart completion with limit exceeded", async () => {
const carts = [] as any[]
// Complete first two orders
for (let i = 0; i < 3; i++) {
const cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: {
address_1: "test address 1",
address_2: "test address 2",
city: "SF",
country_code: "US",
province: "CA",
postal_code: "94016",
},
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
promo_codes: [promotion.code],
},
storeHeaders
)
).data.cart
await api.post(
`/store/carts/${cart.id}/shipping-methods`,
{ option_id: shippingOption.id },
storeHeaders
)
const paymentCollection = (
await api.post(
`/store/payment-collections`,
{ cart_id: cart.id },
storeHeaders
)
).data.payment_collection
await api.post(
`/store/payment-collections/${paymentCollection.id}/payment-sessions`,
{ provider_id: "pp_system_default" },
storeHeaders
)
carts.push(cart)
}
// complete first 2 carts
for (let i = 0; i < 2; i++) {
await api.post(
`/store/carts/${carts[i].id}/complete`,
{},
storeHeaders
)
}
// Third cart should fail
const cart3 = carts[2]
const response = await api
.post(`/store/carts/${cart3.id}/complete`, {}, storeHeaders)
.catch((err) => {
return err.response
})
expect(response.status).toBe(400)
expect(response.data.message).toContain(
"Promotion usage exceeds the limit"
)
})
})
describe("Update limit validation", () => {
beforeEach(async () => {
promotion = (
await api.post(
`/admin/promotions`,
{
code: "UPDATE_LIMIT",
type: PromotionType.STANDARD,
status: PromotionStatus.ACTIVE,
limit: 10,
application_method: {
type: "fixed",
target_type: "items",
allocation: "across",
value: 100,
currency_code: "usd",
},
},
adminHeaders
)
).data.promotion
})
it("should allow updating limit to higher value", async () => {
const response = await api.post(
`/admin/promotions/${promotion.id}`,
{
limit: 20,
},
adminHeaders
)
expect(response.data.promotion.limit).toBe(20)
})
it("should prevent updating limit to less than current usage", async () => {
// Complete two order to set used = 2
for (let i = 0; i < 2; i++) {
const cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: {
address_1: "test address 1",
address_2: "test address 2",
city: "SF",
country_code: "US",
province: "CA",
postal_code: "94016",
},
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
promo_codes: [promotion.code],
},
storeHeaders
)
).data.cart
await api.post(
`/store/carts/${cart.id}/shipping-methods`,
{ option_id: shippingOption.id },
storeHeaders
)
const paymentCollection = (
await api.post(
`/store/payment-collections`,
{ cart_id: cart.id },
storeHeaders
)
).data.payment_collection
await api.post(
`/store/payment-collections/${paymentCollection.id}/payment-sessions`,
{ provider_id: "pp_system_default" },
storeHeaders
)
await api.post(`/store/carts/${cart.id}/complete`, {}, storeHeaders)
}
// Try to update limit to 0 (less than used = 2)
const response = await api
.post(
`/admin/promotions/${promotion.id}`,
{
limit: 1,
},
adminHeaders
)
.catch((err) => {
return err.response
})
expect(response.status).toBe(400)
expect(response.data.message).toContain(
"cannot be less than current usage"
)
})
it("should allow updating limit to 2 when used is 1", async () => {
// Complete one order to set used = 1
const cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: {
address_1: "test address 1",
address_2: "test address 2",
city: "SF",
country_code: "US",
province: "CA",
postal_code: "94016",
},
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
promo_codes: [promotion.code],
},
storeHeaders
)
).data.cart
await api.post(
`/store/carts/${cart.id}/shipping-methods`,
{ option_id: shippingOption.id },
storeHeaders
)
const paymentCollection = (
await api.post(
`/store/payment-collections`,
{ cart_id: cart.id },
storeHeaders
)
).data.payment_collection
await api.post(
`/store/payment-collections/${paymentCollection.id}/payment-sessions`,
{ provider_id: "pp_system_default" },
storeHeaders
)
await api.post(`/store/carts/${cart.id}/complete`, {}, storeHeaders)
// Update limit to 2
const response = await api.post(
`/admin/promotions/${promotion.id}`,
{
limit: 2,
},
adminHeaders
)
expect(response.data.promotion.limit).toBe(2)
expect(response.data.promotion.used).toBe(1)
})
})
describe("Both campaign and promotion limits", () => {
let campaign
let campaignPromotion
beforeEach(async () => {
// Create campaign with budget limit of 3
campaign = (
await api.post(
`/admin/campaigns`,
{
name: "Test Campaign",
campaign_identifier: "test-campaign",
budget: {
type: "usage",
limit: 3,
},
},
adminHeaders
)
).data.campaign
// Create promotion with limit of 2
campaignPromotion = (
await api.post(
`/admin/promotions`,
{
code: "CAMPAIGN_LIMIT",
type: PromotionType.STANDARD,
status: PromotionStatus.ACTIVE,
limit: 2,
campaign_id: campaign.id,
application_method: {
type: "fixed",
target_type: "items",
allocation: "across",
value: 100,
currency_code: "usd",
},
},
adminHeaders
)
).data.promotion
})
it("should hit promotion limit first ", async () => {
// Complete 2 orders - should hit promotion limit
for (let i = 0; i < 2; i++) {
const cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: {
address_1: "test address 1",
address_2: "test address 2",
city: "SF",
country_code: "US",
province: "CA",
postal_code: "94016",
},
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
promo_codes: [campaignPromotion.code],
},
storeHeaders
)
).data.cart
await api.post(
`/store/carts/${cart.id}/shipping-methods`,
{ option_id: shippingOption.id },
storeHeaders
)
const paymentCollection = (
await api.post(
`/store/payment-collections`,
{ cart_id: cart.id },
storeHeaders
)
).data.payment_collection
await api.post(
`/store/payment-collections/${paymentCollection.id}/payment-sessions`,
{ provider_id: "pp_system_default" },
storeHeaders
)
await api.post(`/store/carts/${cart.id}/complete`, {}, storeHeaders)
}
// Third order should fail with promotion limit exceeded
const cart3 = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: {
address_1: "test address 1",
address_2: "test address 2",
city: "SF",
country_code: "US",
province: "CA",
postal_code: "94016",
},
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
promo_codes: [campaignPromotion.code],
},
storeHeaders
)
).data.cart
expect(cart3.promotions).toHaveLength(0)
})
it("should hit campaign limit first", async () => {
await api.post(
`/admin/promotions/${campaignPromotion.id}`,
{
limit: 5,
},
adminHeaders
)
// Complete 3 orders - should hit campaign limit
for (let i = 0; i < 3; i++) {
const cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: {
address_1: "test address 1",
address_2: "test address 2",
city: "SF",
country_code: "US",
province: "CA",
postal_code: "94016",
},
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
promo_codes: [campaignPromotion.code],
},
storeHeaders
)
).data.cart
await api.post(
`/store/carts/${cart.id}/shipping-methods`,
{ option_id: shippingOption.id },
storeHeaders
)
const paymentCollection = (
await api.post(
`/store/payment-collections`,
{ cart_id: cart.id },
storeHeaders
)
).data.payment_collection
await api.post(
`/store/payment-collections/${paymentCollection.id}/payment-sessions`,
{ provider_id: "pp_system_default" },
storeHeaders
)
await api.post(`/store/carts/${cart.id}/complete`, {}, storeHeaders)
}
const cart4 = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: {
address_1: "test address 1",
address_2: "test address 2",
city: "SF",
country_code: "US",
province: "CA",
postal_code: "94016",
},
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
promo_codes: [campaignPromotion.code],
},
storeHeaders
)
).data.cart
expect(cart4.promotions).toHaveLength(0)
})
})
})
},
})

View File

@@ -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
},

View File

@@ -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.",

View File

@@ -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>

View File

@@ -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)),

View File

@@ -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>
)
}

View File

@@ -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.
*/

View File

@@ -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

View File

@@ -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.
*/

View File

@@ -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:
*

View File

@@ -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 {

View File

@@ -4,6 +4,8 @@ export const defaultAdminPromotionFields = [
"is_automatic",
"is_tax_inclusive",
"type",
"limit",
"used",
"status",
"created_at",
"updated_at",

View File

@@ -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"],
}
)
}
)

View File

@@ -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"
},

View File

@@ -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";`
)
}
}

View File

@@ -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)

View File

@@ -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,