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:
1 parent
5da51064d7
commit
536a3f802c
18 files changed
+1269
-11
No files matched your search
@@ -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": {
|
"taxInclusive": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"usageLimit": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"amount": {
|
"amount": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -7784,6 +7790,8 @@
|
|||||||
"addCondition",
|
"addCondition",
|
||||||
"clearAll",
|
"clearAll",
|
||||||
"taxInclusive",
|
"taxInclusive",
|
||||||
|
"usageLimit",
|
||||||
|
"usage",
|
||||||
"amount",
|
"amount",
|
||||||
"conditions"
|
"conditions"
|
||||||
],
|
],
|
||||||
@@ -8230,6 +8238,19 @@
|
|||||||
},
|
},
|
||||||
"required": ["fixed", "percentage"],
|
"required": ["fixed", "percentage"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["title", "description"],
|
||||||
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -8245,7 +8266,8 @@
|
|||||||
"allocation",
|
"allocation",
|
||||||
"code",
|
"code",
|
||||||
"value",
|
"value",
|
||||||
"value_type"
|
"value_type",
|
||||||
|
"limit"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2051,6 +2051,8 @@
|
|||||||
"addCondition": "Add condition",
|
"addCondition": "Add condition",
|
||||||
"clearAll": "Clear all",
|
"clearAll": "Clear all",
|
||||||
"taxInclusive": "Tax Inclusive",
|
"taxInclusive": "Tax Inclusive",
|
||||||
|
"usageLimit": "Usage Limit",
|
||||||
|
"usage": "Usage",
|
||||||
"amount": {
|
"amount": {
|
||||||
"tooltip": "Select the currency code to enable setting the amount"
|
"tooltip": "Select the currency code to enable setting the amount"
|
||||||
},
|
},
|
||||||
@@ -2212,6 +2214,10 @@
|
|||||||
"title": "Percentage",
|
"title": "Percentage",
|
||||||
"description": "The percentage to discount off the amount. eg. 8%"
|
"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.",
|
"deleteWarning": "You are about to delete the promotion {{code}}. This action cannot be undone.",
|
||||||
|
|||||||
+40
-1
@@ -58,6 +58,7 @@ const defaultValues = {
|
|||||||
status: "draft" as PromotionStatusValues,
|
status: "draft" as PromotionStatusValues,
|
||||||
rules: [],
|
rules: [],
|
||||||
is_tax_inclusive: false,
|
is_tax_inclusive: false,
|
||||||
|
limit: undefined,
|
||||||
application_method: {
|
application_method: {
|
||||||
allocation: "each" as ApplicationMethodAllocationValues,
|
allocation: "each" as ApplicationMethodAllocationValues,
|
||||||
type: "fixed" as ApplicationMethodTypeValues,
|
type: "fixed" as ApplicationMethodTypeValues,
|
||||||
@@ -901,7 +902,9 @@ export const CreatePromotionForm = () => {
|
|||||||
return (
|
return (
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Form.Label
|
<Form.Label
|
||||||
tooltip={t("promotions.fields.allocationTooltip")}
|
tooltip={t(
|
||||||
|
"promotions.fields.allocationTooltip"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{t("promotions.fields.allocation")}
|
{t("promotions.fields.allocation")}
|
||||||
</Form.Label>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</ProgressTabs.Content>
|
</ProgressTabs.Content>
|
||||||
|
|||||||
+1
@@ -28,6 +28,7 @@ export const CreatePromotionSchema = z
|
|||||||
status: z.enum(["draft", "active", "inactive"]),
|
status: z.enum(["draft", "active", "inactive"]),
|
||||||
rules: RuleSchema,
|
rules: RuleSchema,
|
||||||
is_tax_inclusive: z.boolean().optional(),
|
is_tax_inclusive: z.boolean().optional(),
|
||||||
|
limit: z.number().int().min(1).nullable().optional(),
|
||||||
application_method: z.object({
|
application_method: z.object({
|
||||||
allocation: z.enum(["each", "across", "once"]),
|
allocation: z.enum(["each", "across", "once"]),
|
||||||
value: z.number().min(0).or(z.string().min(1)),
|
value: z.number().min(0).or(z.string().min(1)),
|
||||||
|
|||||||
+14
@@ -196,6 +196,20 @@ export const PromotionGeneralSection = ({
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -180,6 +180,10 @@ export interface AdminCreatePromotion {
|
|||||||
* The application method of the promotion.
|
* The application method of the promotion.
|
||||||
*/
|
*/
|
||||||
application_method: AdminCreateApplicationMethod
|
application_method: AdminCreateApplicationMethod
|
||||||
|
/**
|
||||||
|
* The maximum number of times this promotion can be used.
|
||||||
|
*/
|
||||||
|
limit?: number | null
|
||||||
/**
|
/**
|
||||||
* The rules of the promotion.
|
* The rules of the promotion.
|
||||||
*/
|
*/
|
||||||
@@ -221,6 +225,10 @@ export interface AdminUpdatePromotion {
|
|||||||
* The application method of the promotion.
|
* The application method of the promotion.
|
||||||
*/
|
*/
|
||||||
application_method?: AdminUpdateApplicationMethod
|
application_method?: AdminUpdateApplicationMethod
|
||||||
|
/**
|
||||||
|
* The maximum number of times this promotion can be used.
|
||||||
|
*/
|
||||||
|
limit?: number | null
|
||||||
/**
|
/**
|
||||||
* The rules of the promotion.
|
* The rules of the promotion.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export interface BasePromotion {
|
|||||||
type?: PromotionTypeValues
|
type?: PromotionTypeValues
|
||||||
is_automatic?: boolean
|
is_automatic?: boolean
|
||||||
is_tax_inclusive?: boolean
|
is_tax_inclusive?: boolean
|
||||||
|
limit?: number | null
|
||||||
|
used?: number
|
||||||
application_method?: BaseApplicationMethod
|
application_method?: BaseApplicationMethod
|
||||||
rules?: BasePromotionRule[]
|
rules?: BasePromotionRule[]
|
||||||
status?: PromotionStatusValues
|
status?: PromotionStatusValues
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type ComputeActions =
|
|||||||
| AddShippingMethodAdjustment
|
| AddShippingMethodAdjustment
|
||||||
| RemoveShippingMethodAdjustment
|
| RemoveShippingMethodAdjustment
|
||||||
| CampaignBudgetExceededAction
|
| CampaignBudgetExceededAction
|
||||||
|
| PromotionLimitExceededAction
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* These computed action types can affect a campaign's budget.
|
* These computed action types can affect a campaign's budget.
|
||||||
@@ -41,6 +42,21 @@ export interface CampaignBudgetExceededAction {
|
|||||||
code: string
|
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.
|
* 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
|
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.
|
* The associated application method.
|
||||||
*/
|
*/
|
||||||
@@ -123,6 +133,11 @@ export interface CreatePromotionDTO {
|
|||||||
*/
|
*/
|
||||||
is_tax_inclusive?: boolean
|
is_tax_inclusive?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum number of times this promotion can be used.
|
||||||
|
*/
|
||||||
|
limit?: number | null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The associated application method.
|
* The associated application method.
|
||||||
*/
|
*/
|
||||||
@@ -173,6 +188,11 @@ export interface UpdatePromotionDTO {
|
|||||||
*/
|
*/
|
||||||
is_tax_inclusive?: boolean
|
is_tax_inclusive?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum number of times this promotion can be used.
|
||||||
|
*/
|
||||||
|
limit?: number | null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The status of the promotion:
|
* The status of the promotion:
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export enum ComputedActions {
|
|||||||
REMOVE_ITEM_ADJUSTMENT = "removeItemAdjustment",
|
REMOVE_ITEM_ADJUSTMENT = "removeItemAdjustment",
|
||||||
REMOVE_SHIPPING_METHOD_ADJUSTMENT = "removeShippingMethodAdjustment",
|
REMOVE_SHIPPING_METHOD_ADJUSTMENT = "removeShippingMethodAdjustment",
|
||||||
CAMPAIGN_BUDGET_EXCEEDED = "campaignBudgetExceeded",
|
CAMPAIGN_BUDGET_EXCEEDED = "campaignBudgetExceeded",
|
||||||
|
PROMOTION_LIMIT_EXCEEDED = "promotionLimitExceeded",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum PromotionActions {
|
export enum PromotionActions {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export const defaultAdminPromotionFields = [
|
|||||||
"is_automatic",
|
"is_automatic",
|
||||||
"is_tax_inclusive",
|
"is_tax_inclusive",
|
||||||
"type",
|
"type",
|
||||||
|
"limit",
|
||||||
|
"used",
|
||||||
"status",
|
"status",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
|
|||||||
@@ -175,16 +175,35 @@ export const CreatePromotion = z
|
|||||||
campaign: CreateCampaign.optional(),
|
campaign: CreateCampaign.optional(),
|
||||||
application_method: AdminCreateApplicationMethod,
|
application_method: AdminCreateApplicationMethod,
|
||||||
rules: z.array(AdminCreatePromotionRule).optional(),
|
rules: z.array(AdminCreatePromotionRule).optional(),
|
||||||
|
limit: z.number().int().min(1).nullable().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|
||||||
export const AdminCreatePromotion = WithAdditionalData(
|
export const AdminCreatePromotion = WithAdditionalData(
|
||||||
CreatePromotion,
|
CreatePromotion,
|
||||||
(schema) => {
|
(schema) => {
|
||||||
return schema.refine(promoRefinement, {
|
return schema
|
||||||
message:
|
.refine(promoRefinement, {
|
||||||
"Buyget promotions require at least one buy rule and quantities to be defined",
|
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(),
|
status: z.nativeEnum(PromotionStatus).optional(),
|
||||||
campaign_id: z.string().nullish(),
|
campaign_id: z.string().nullish(),
|
||||||
application_method: AdminUpdateApplicationMethod.optional(),
|
application_method: AdminUpdateApplicationMethod.optional(),
|
||||||
|
limit: z.number().int().min(1).nullable().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|
||||||
export const AdminUpdatePromotion = WithAdditionalData(
|
export const AdminUpdatePromotion = WithAdditionalData(
|
||||||
UpdatePromotion,
|
UpdatePromotion,
|
||||||
(schema) => {
|
(schema) => {
|
||||||
return schema.refine(promoRefinement, {
|
return schema
|
||||||
message:
|
.refine(promoRefinement, {
|
||||||
"Buyget promotions require at least one buy rule and quantities to be defined",
|
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",
|
"default": "false",
|
||||||
"mappedType": "boolean"
|
"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": {
|
"type": {
|
||||||
"name": "type",
|
"name": "type",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -750,7 +769,8 @@
|
|||||||
"nullable": true,
|
"nullable": true,
|
||||||
"enumItems": [
|
"enumItems": [
|
||||||
"each",
|
"each",
|
||||||
"across"
|
"across",
|
||||||
|
"once"
|
||||||
],
|
],
|
||||||
"mappedType": "enum"
|
"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(),
|
code: model.text().searchable(),
|
||||||
is_automatic: model.boolean().default(false),
|
is_automatic: model.boolean().default(false),
|
||||||
is_tax_inclusive: 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"),
|
type: model.enum(PromotionUtils.PromotionType).index("IDX_promotion_type"),
|
||||||
status: model
|
status: model
|
||||||
.enum(PromotionUtils.PromotionStatus)
|
.enum(PromotionUtils.PromotionStatus)
|
||||||
|
|||||||
@@ -307,6 +307,7 @@ export default class PromotionModuleService
|
|||||||
|
|
||||||
const campaignBudgetMap = new Map<string, UpdateCampaignBudgetDTO>()
|
const campaignBudgetMap = new Map<string, UpdateCampaignBudgetDTO>()
|
||||||
const promotionCodeUsageMap = new Map<string, boolean>()
|
const promotionCodeUsageMap = new Map<string, boolean>()
|
||||||
|
const promotionUsageMap = new Map<string, { id: string; used: number }>()
|
||||||
|
|
||||||
const existingPromotions = await this.listActivePromotions_(
|
const existingPromotions = await this.listActivePromotions_(
|
||||||
{ code: promotionCodes },
|
{ code: promotionCodes },
|
||||||
@@ -335,6 +336,22 @@ export default class PromotionModuleService
|
|||||||
continue
|
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
|
const campaignBudget = promotion.campaign?.budget
|
||||||
|
|
||||||
if (!campaignBudget) {
|
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) {
|
if (campaignBudgetMap.size > 0) {
|
||||||
const campaignBudgetsData: UpdateCampaignBudgetDTO[] = []
|
const campaignBudgetsData: UpdateCampaignBudgetDTO[] = []
|
||||||
for (const [_, campaignBudgetData] of campaignBudgetMap) {
|
for (const [_, campaignBudgetData] of campaignBudgetMap) {
|
||||||
@@ -459,6 +483,7 @@ export default class PromotionModuleService
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const promotionCodeUsageMap = new Map<string, boolean>()
|
const promotionCodeUsageMap = new Map<string, boolean>()
|
||||||
const campaignBudgetMap = new Map<string, UpdateCampaignBudgetDTO>()
|
const campaignBudgetMap = new Map<string, UpdateCampaignBudgetDTO>()
|
||||||
|
const promotionUsageMap = new Map<string, { id: string; used: number }>()
|
||||||
|
|
||||||
const existingPromotions = await this.listActivePromotions_(
|
const existingPromotions = await this.listActivePromotions_(
|
||||||
{
|
{
|
||||||
@@ -491,6 +516,15 @@ export default class PromotionModuleService
|
|||||||
continue
|
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
|
const campaignBudget = promotion.campaign?.budget
|
||||||
|
|
||||||
if (!campaignBudget) {
|
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) {
|
if (campaignBudgetMap.size > 0) {
|
||||||
const campaignBudgetsData: UpdateCampaignBudgetDTO[] = []
|
const campaignBudgetsData: UpdateCampaignBudgetDTO[] = []
|
||||||
for (const [_, campaignBudgetData] of campaignBudgetMap) {
|
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 =
|
const isCurrencyCodeValid =
|
||||||
!isPresent(applicationMethod.currency_code) ||
|
!isPresent(applicationMethod.currency_code) ||
|
||||||
applicationContext.currency_code === applicationMethod.currency_code
|
applicationContext.currency_code === applicationMethod.currency_code
|
||||||
@@ -1242,6 +1294,17 @@ export default class PromotionModuleService
|
|||||||
existingApplicationMethod?.currency_code ||
|
existingApplicationMethod?.currency_code ||
|
||||||
applicationMethodData?.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) {
|
if (campaignId && !existingCampaign) {
|
||||||
throw new MedusaError(
|
throw new MedusaError(
|
||||||
MedusaError.Types.INVALID_DATA,
|
MedusaError.Types.INVALID_DATA,
|
||||||
|
|||||||
Reference in new issue
Block a user