From 536a3f802c92960b1eab48c37db25a8c542fd231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frane=20Poli=C4=87?= <16856471+fPolic@users.noreply.github.com> Date: Sun, 30 Nov 2025 19:43:36 +0100 Subject: [PATCH] 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> --- .changeset/proud-clouds-bow.md | 9 + .../promotions/admin/promotion-limit.spec.ts | 980 ++++++++++++++++++ .../src/i18n/translations/$schema.json | 24 +- .../dashboard/src/i18n/translations/en.json | 6 + .../create-promotion-form.tsx | 41 +- .../create-promotion-form/form-schema.ts | 1 + .../promotion-general-section.tsx | 14 + .../src/http/promotion/admin/payloads.ts | 8 + .../core/types/src/http/promotion/common.ts | 2 + .../src/promotion/common/compute-actions.ts | 16 + .../types/src/promotion/common/promotion.ts | 20 + packages/core/utils/src/promotion/index.ts | 1 + .../src/api/admin/promotions/query-config.ts | 2 + .../src/api/admin/promotions/validators.ts | 54 +- .../.snapshot-medusa-promotion.json | 22 +- .../src/migrations/Migration20251015113934.ts | 15 + .../modules/promotion/src/models/promotion.ts | 2 + .../src/services/promotion-module.ts | 63 ++ 18 files changed, 1269 insertions(+), 11 deletions(-) create mode 100644 .changeset/proud-clouds-bow.md create mode 100644 integration-tests/http/__tests__/promotions/admin/promotion-limit.spec.ts create mode 100644 packages/modules/promotion/src/migrations/Migration20251015113934.ts diff --git a/.changeset/proud-clouds-bow.md b/.changeset/proud-clouds-bow.md new file mode 100644 index 0000000000..ed6870b4ba --- /dev/null +++ b/.changeset/proud-clouds-bow.md @@ -0,0 +1,9 @@ +--- +"@medusajs/promotion": patch +"@medusajs/dashboard": patch +"@medusajs/types": patch +"@medusajs/utils": patch +"@medusajs/medusa": patch +--- + +feat: promotion usage limit diff --git a/integration-tests/http/__tests__/promotions/admin/promotion-limit.spec.ts b/integration-tests/http/__tests__/promotions/admin/promotion-limit.spec.ts new file mode 100644 index 0000000000..3f824d4dc5 --- /dev/null +++ b/integration-tests/http/__tests__/promotions/admin/promotion-limit.spec.ts @@ -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) + }) + }) + }) + }, +}) diff --git a/packages/admin/dashboard/src/i18n/translations/$schema.json b/packages/admin/dashboard/src/i18n/translations/$schema.json index 9b6147c0f7..91df7475d9 100644 --- a/packages/admin/dashboard/src/i18n/translations/$schema.json +++ b/packages/admin/dashboard/src/i18n/translations/$schema.json @@ -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 }, diff --git a/packages/admin/dashboard/src/i18n/translations/en.json b/packages/admin/dashboard/src/i18n/translations/en.json index 1bfd787c58..b3eefb9a31 100644 --- a/packages/admin/dashboard/src/i18n/translations/en.json +++ b/packages/admin/dashboard/src/i18n/translations/en.json @@ -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.", diff --git a/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/create-promotion-form.tsx b/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/create-promotion-form.tsx index c31e45d739..d823263943 100644 --- a/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/create-promotion-form.tsx +++ b/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/create-promotion-form.tsx @@ -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 ( {t("promotions.fields.allocation")} @@ -987,6 +990,42 @@ export const CreatePromotionForm = () => { /> )} + + + { + return ( + + + {t("promotions.form.limit.title")} + + + { + const val = e.target.value + onChange(val === "" ? null : parseInt(val, 10)) + }} + placeholder="100" + /> + + + {t("promotions.form.limit.description")} + + + + ) + }} + /> diff --git a/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/form-schema.ts b/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/form-schema.ts index 2b4372be45..22363dbf7c 100644 --- a/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/form-schema.ts +++ b/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/form-schema.ts @@ -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)), diff --git a/packages/admin/dashboard/src/routes/promotions/promotion-detail/components/promotion-general-section/promotion-general-section.tsx b/packages/admin/dashboard/src/routes/promotions/promotion-detail/components/promotion-general-section/promotion-general-section.tsx index 9415698c06..16f1c670bb 100644 --- a/packages/admin/dashboard/src/routes/promotions/promotion-detail/components/promotion-general-section/promotion-general-section.tsx +++ b/packages/admin/dashboard/src/routes/promotions/promotion-detail/components/promotion-general-section/promotion-general-section.tsx @@ -196,6 +196,20 @@ export const PromotionGeneralSection = ({ )} + + {typeof promotion.limit === "number" && ( +
+ + Usage Limit + + +
+ + {promotion.used || 0} / {promotion.limit} + +
+
+ )} ) } diff --git a/packages/core/types/src/http/promotion/admin/payloads.ts b/packages/core/types/src/http/promotion/admin/payloads.ts index 0334edf510..284e182b7c 100644 --- a/packages/core/types/src/http/promotion/admin/payloads.ts +++ b/packages/core/types/src/http/promotion/admin/payloads.ts @@ -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. */ diff --git a/packages/core/types/src/http/promotion/common.ts b/packages/core/types/src/http/promotion/common.ts index 685d420b3c..1312faf56c 100644 --- a/packages/core/types/src/http/promotion/common.ts +++ b/packages/core/types/src/http/promotion/common.ts @@ -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 diff --git a/packages/core/types/src/promotion/common/compute-actions.ts b/packages/core/types/src/promotion/common/compute-actions.ts index 21c1b4bfad..24e7bc9016 100644 --- a/packages/core/types/src/promotion/common/compute-actions.ts +++ b/packages/core/types/src/promotion/common/compute-actions.ts @@ -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. */ diff --git a/packages/core/types/src/promotion/common/promotion.ts b/packages/core/types/src/promotion/common/promotion.ts index 6f37c5598c..fe289b92a6 100644 --- a/packages/core/types/src/promotion/common/promotion.ts +++ b/packages/core/types/src/promotion/common/promotion.ts @@ -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: * diff --git a/packages/core/utils/src/promotion/index.ts b/packages/core/utils/src/promotion/index.ts index 14039a51c4..50e8ce793e 100644 --- a/packages/core/utils/src/promotion/index.ts +++ b/packages/core/utils/src/promotion/index.ts @@ -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 { diff --git a/packages/medusa/src/api/admin/promotions/query-config.ts b/packages/medusa/src/api/admin/promotions/query-config.ts index 3223b140ac..309ab4a37a 100644 --- a/packages/medusa/src/api/admin/promotions/query-config.ts +++ b/packages/medusa/src/api/admin/promotions/query-config.ts @@ -4,6 +4,8 @@ export const defaultAdminPromotionFields = [ "is_automatic", "is_tax_inclusive", "type", + "limit", + "used", "status", "created_at", "updated_at", diff --git a/packages/medusa/src/api/admin/promotions/validators.ts b/packages/medusa/src/api/admin/promotions/validators.ts index effd1283e4..88c5bbf224 100644 --- a/packages/medusa/src/api/admin/promotions/validators.ts +++ b/packages/medusa/src/api/admin/promotions/validators.ts @@ -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"], + } + ) } ) diff --git a/packages/modules/promotion/src/migrations/.snapshot-medusa-promotion.json b/packages/modules/promotion/src/migrations/.snapshot-medusa-promotion.json index 0ff8e91727..7ecbed6cca 100644 --- a/packages/modules/promotion/src/migrations/.snapshot-medusa-promotion.json +++ b/packages/modules/promotion/src/migrations/.snapshot-medusa-promotion.json @@ -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" }, diff --git a/packages/modules/promotion/src/migrations/Migration20251015113934.ts b/packages/modules/promotion/src/migrations/Migration20251015113934.ts new file mode 100644 index 0000000000..5949434e6c --- /dev/null +++ b/packages/modules/promotion/src/migrations/Migration20251015113934.ts @@ -0,0 +1,15 @@ +import { Migration } from "@mikro-orm/migrations" + +export class Migration20251015113934 extends Migration { + override async up(): Promise { + 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 { + this.addSql( + `alter table if exists "promotion" drop column if exists "limit", drop column if exists "used";` + ) + } +} diff --git a/packages/modules/promotion/src/models/promotion.ts b/packages/modules/promotion/src/models/promotion.ts index bdcb9010fc..ac1a0d80a0 100644 --- a/packages/modules/promotion/src/models/promotion.ts +++ b/packages/modules/promotion/src/models/promotion.ts @@ -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) diff --git a/packages/modules/promotion/src/services/promotion-module.ts b/packages/modules/promotion/src/services/promotion-module.ts index 5666dd48a0..19f41663fc 100644 --- a/packages/modules/promotion/src/services/promotion-module.ts +++ b/packages/modules/promotion/src/services/promotion-module.ts @@ -307,6 +307,7 @@ export default class PromotionModuleService const campaignBudgetMap = new Map() const promotionCodeUsageMap = new Map() + const promotionUsageMap = new Map() 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 { const promotionCodeUsageMap = new Map() const campaignBudgetMap = new Map() + const promotionUsageMap = new Map() 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,