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:
9
.changeset/proud-clouds-bow.md
Normal file
9
.changeset/proud-clouds-bow.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
"@medusajs/promotion": patch
|
||||
"@medusajs/dashboard": patch
|
||||
"@medusajs/types": patch
|
||||
"@medusajs/utils": patch
|
||||
"@medusajs/medusa": patch
|
||||
---
|
||||
|
||||
feat: promotion usage limit
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user