feat(core-flows,dashboard,js-sdk,promotion,medusa,types,utils): limit promotion usage per customer (#13451)

**What**
- implement promotion usage limits per customer/email
- fix registering spend usage over the limit
- fix type errors in promotion module tests

**How**
- introduce a new type of campaign budget that can be defined by an attribute such as customer id or email
- add `CampaignBudgetUsage` entity to keep track of the number of uses per attribute value
- update `registerUsage` and `computeActions` in the promotion module to work with the new type
- update `core-flows` to pass context needed for usage calculation to the promotion module

**Breaking**
- registering promotion usage now throws (and cart complete fails) if the budget limit is exceeded or if the cart completion would result in a breached limit

---

CLOSES CORE-1172
CLOSES CORE-1173
CLOSES CORE-1174
CLOSES CORE-1175


Co-authored-by: Adrien de Peretti <25098370+adrien2p@users.noreply.github.com>
This commit is contained in:
Frane Polić
2025-10-09 14:35:54 +02:00
committed by GitHub
parent 924564bee5
commit 7dc3b0c5ff
36 changed files with 2390 additions and 190 deletions

View File

@@ -2104,6 +2104,220 @@ medusaIntegrationTestRunner({
)
})
it("should fail to complete a cart if that would exceed the promotion limit", async () => {
const product = (
await api.post(
`/admin/products`,
{
status: ProductStatus.PUBLISHED,
title: "Product for camapign",
description: "test",
options: [
{
title: "Type",
values: ["L"],
},
],
variants: [
{
title: "L",
sku: "campaign-product-l",
options: {
Type: "L",
},
manage_inventory: false,
prices: [
{
amount: 300,
currency_code: "usd",
},
],
},
],
},
adminHeaders
)
).data.product
const campaign = (
await api.post(
`/admin/campaigns`,
{
name: "TEST-1",
budget: {
type: "spend",
currency_code: "usd",
limit: 100, // -> promotions value can't exceed 100$
},
campaign_identifier: "PROMO_CAMPAIGN",
},
adminHeaders
)
).data.campaign
const promotion = (
await api
.post(
`/admin/promotions`,
{
code: "TEST_PROMO",
type: PromotionType.STANDARD,
status: PromotionStatus.ACTIVE,
is_automatic: false,
is_tax_inclusive: true,
application_method: {
target_type: "items",
type: "fixed",
allocation: "across",
currency_code: "usd",
value: 100, // -> promotion applies 100$ fixed discount on the entire order
},
campaign_id: campaign.id,
},
adminHeaders
)
.catch((e) => console.log(e))
).data.promotion
const cart1 = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: shippingAddressData,
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
promo_codes: [promotion.code],
},
storeHeadersWithCustomer
)
).data.cart
expect(cart1).toEqual(
expect.objectContaining({
promotions: [
expect.objectContaining({
code: promotion.code,
}),
],
})
)
const cart2 = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: shippingAddressData,
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
promo_codes: [promotion.code],
},
storeHeadersWithCustomer
)
).data.cart
expect(cart2).toEqual(
expect.objectContaining({
promotions: [
expect.objectContaining({
code: promotion.code,
}),
],
})
)
/**
* At this point both carts have the same promotion applied successfully
*/
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
)
const order1 = (
await api.post(
`/store/carts/${cart1.id}/complete`,
{},
storeHeaders
)
).data.order
expect(order1).toEqual(
expect.objectContaining({ discount_total: 100 })
)
let campaignAfter = (
await api.get(
`/admin/campaigns/${campaign.id}?fields=budget.*`,
adminHeaders
)
).data.campaign
expect(campaignAfter).toEqual(
expect.objectContaining({
budget: expect.objectContaining({
used: 100,
limit: 100,
}),
})
)
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
)
const response2 = await api
.post(`/store/carts/${cart2.id}/complete`, {}, storeHeaders)
.catch((e) => e)
expect(response2.response.status).toEqual(400)
expect(response2.response.data).toEqual(
expect.objectContaining({
type: "not_allowed",
message: "Promotion usage exceeds the budget limit.",
})
)
campaignAfter = (
await api.get(
`/admin/campaigns/${campaign.id}?fields=budget.*`,
adminHeaders
)
).data.campaign
expect(campaignAfter).toEqual(
expect.objectContaining({
budget: expect.objectContaining({
used: 100,
limit: 100,
}),
})
)
})
it("should successfully complete cart without shipping for digital products", async () => {
/**
* Product has a shipping profile so cart item should not require shipping

View File

@@ -787,6 +787,527 @@ medusaIntegrationTestRunner({
)
})
it("should limit usage of promotion per email attribute as defined in campaign budget", async () => {
const publishableKey = await generatePublishableKey(appContainer)
const storeHeaders = generateStoreHeaders({ publishableKey })
const salesChannel = (
await api.post(
"/admin/sales-channels",
{ name: "Webshop", description: "channel" },
adminHeaders
)
).data.sales_channel
const region = (
await api.post(
"/admin/regions",
{ name: "US", currency_code: "usd", countries: ["us"] },
adminHeaders
)
).data.region
const product = (
await api.post(
"/admin/products",
{
...medusaTshirtProduct,
},
adminHeaders
)
).data.product
const campaign = (
await api.post(
`/admin/campaigns`,
{
name: "TEST",
budget: {
type: "use_by_attribute",
limit: 2,
attribute: "customer_email",
},
campaign_identifier: "PROMO_CAMPAIGN",
},
adminHeaders
)
).data.campaign
const response = await api.post(
`/admin/promotions`,
{
code: "TEST_PROMO",
type: PromotionType.STANDARD,
status: PromotionStatus.ACTIVE,
is_automatic: false,
application_method: {
target_type: "items",
type: "fixed",
allocation: "each",
currency_code: "usd",
value: 100,
max_quantity: 100,
},
campaign_id: campaign.id,
},
adminHeaders
)
let cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
email: "canusethistwice@test.com",
promo_codes: [response.data.promotion.code],
},
storeHeaders
)
).data.cart
expect(cart).toEqual(
expect.objectContaining({
promotions: [
expect.objectContaining({
code: response.data.promotion.code,
}),
],
})
)
let promotionCampaign = (
await api.get(
`/admin/campaigns/${campaign.id}?fields=budget.*,budget.usages.*`,
adminHeaders
)
).data.campaign
expect(promotionCampaign).toEqual(
expect.objectContaining({
budget: expect.objectContaining({
used: 0,
limit: 2,
attribute: "customer_email",
type: "use_by_attribute",
usages: [],
}),
})
)
let 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)
cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
email: "canusethistwice@test.com",
promo_codes: [response.data.promotion.code],
},
storeHeaders
)
).data.cart
expect(cart).toEqual(
expect.objectContaining({
promotions: [
expect.objectContaining({
code: response.data.promotion.code,
}),
],
})
)
promotionCampaign = (
await api.get(
`/admin/campaigns/${campaign.id}?fields=budget.*,budget.usages.*`,
adminHeaders
)
).data.campaign
expect(promotionCampaign).toEqual(
expect.objectContaining({
budget: expect.objectContaining({
used: 1,
limit: 2,
attribute: "customer_email",
type: "use_by_attribute",
usages: [
// usage recorder after first complete
expect.objectContaining({
attribute_value: "canusethistwice@test.com",
used: 1,
}),
],
}),
})
)
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
)
// complete for the second time
await api.post(`/store/carts/${cart.id}/complete`, {}, storeHeaders)
promotionCampaign = (
await api.get(
`/admin/campaigns/${campaign.id}?fields=budget.*,budget.usages.*`,
adminHeaders
)
).data.campaign
expect(promotionCampaign).toEqual(
expect.objectContaining({
budget: expect.objectContaining({
used: 2,
limit: 2,
attribute: "customer_email",
type: "use_by_attribute",
usages: [
expect.objectContaining({
attribute_value: "canusethistwice@test.com",
used: 2,
}),
],
}),
})
)
cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
email: "canusethistwice@test.com",
},
storeHeaders
)
).data.cart
cart = (
await api.post(
`/store/carts/${cart.id}`,
{
promo_codes: [response.data.promotion.code],
},
storeHeaders
)
).data.cart
expect(cart.promotions.length).toEqual(0) // prmotion is not applied
cart = (
await api.post(
`/store/carts/${cart.id}`,
{
email: "canuseit@test.com",
promo_codes: [response.data.promotion.code],
},
storeHeaders
)
).data.cart
// promotion is successfully applied with different email
expect(cart.promotions.length).toEqual(1)
expect(cart.promotions[0].code).toEqual(
response.data.promotion.code
)
})
it("should remove promotion after email is replaced by already used email for that promotion", async () => {
const publishableKey = await generatePublishableKey(appContainer)
const storeHeaders = generateStoreHeaders({ publishableKey })
const salesChannel = (
await api.post(
"/admin/sales-channels",
{ name: "Webshop", description: "channel" },
adminHeaders
)
).data.sales_channel
const region = (
await api.post(
"/admin/regions",
{ name: "US", currency_code: "usd", countries: ["us"] },
adminHeaders
)
).data.region
const product = (
await api.post(
"/admin/products",
{
...medusaTshirtProduct,
},
adminHeaders
)
).data.product
const campaign = (
await api.post(
`/admin/campaigns`,
{
name: "TEST",
budget: {
type: "use_by_attribute",
limit: 1,
attribute: "customer_email",
},
campaign_identifier: "PROMO_CAMPAIGN",
},
adminHeaders
)
).data.campaign
const response = await api.post(
`/admin/promotions`,
{
code: "TEST_PROMO",
type: PromotionType.STANDARD,
status: PromotionStatus.ACTIVE,
is_automatic: false,
application_method: {
target_type: "items",
type: "fixed",
allocation: "each",
currency_code: "usd",
value: 100,
max_quantity: 100,
},
campaign_id: campaign.id,
},
adminHeaders
)
let cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
email: "canuseitonce@test.com",
promo_codes: [response.data.promotion.code],
},
storeHeaders
)
).data.cart
expect(cart).toEqual(
expect.objectContaining({
promotions: [
expect.objectContaining({
code: response.data.promotion.code,
}),
],
})
)
let promotionCampaign = (
await api.get(
`/admin/campaigns/${campaign.id}?fields=budget.*,budget.usages.*`,
adminHeaders
)
).data.campaign
expect(promotionCampaign).toEqual(
expect.objectContaining({
budget: expect.objectContaining({
used: 0,
limit: 1,
attribute: "customer_email",
type: "use_by_attribute",
usages: [],
}),
})
)
let 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)
cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
promo_codes: [response.data.promotion.code],
email: "fakeemail@test.com",
},
storeHeaders
)
).data.cart
expect(cart).toEqual(
expect.objectContaining({
promotions: [
expect.objectContaining({
code: response.data.promotion.code,
}),
],
})
)
cart = (
await api.post(
`/store/carts/${cart.id}`,
{
email: "canuseitonce@test.com",
},
storeHeaders
)
).data.cart
expect(cart.promotions.length).toEqual(0) // prmotion is removed
})
it("should throw if email is not provided when campaign budget type is use_by_attribute", async () => {
const publishableKey = await generatePublishableKey(appContainer)
const storeHeaders = generateStoreHeaders({ publishableKey })
const salesChannel = (
await api.post(
"/admin/sales-channels",
{ name: "Webshop", description: "channel" },
adminHeaders
)
).data.sales_channel
const region = (
await api.post(
"/admin/regions",
{ name: "US", currency_code: "usd", countries: ["us"] },
adminHeaders
)
).data.region
const product = (
await api.post(
"/admin/products",
{
...medusaTshirtProduct,
},
adminHeaders
)
).data.product
const campaign = (
await api.post(
`/admin/campaigns`,
{
name: "TEST",
budget: {
type: "use_by_attribute",
limit: 1,
attribute: "customer_email",
},
campaign_identifier: "PROMO_CAMPAIGN",
},
adminHeaders
)
).data.campaign
const response = await api.post(
`/admin/promotions`,
{
code: "TEST_PROMO",
type: PromotionType.STANDARD,
status: PromotionStatus.ACTIVE,
is_automatic: false,
application_method: {
target_type: "items",
type: "fixed",
allocation: "each",
currency_code: "usd",
value: 100,
max_quantity: 100,
},
campaign_id: campaign.id,
},
adminHeaders
)
let cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
},
storeHeaders
)
).data.cart
const err = await api
.post(
`/store/carts/${cart.id}`,
{
promo_codes: [response.data.promotion.code],
},
storeHeaders
)
.catch((e) => e)
expect(err.response.status).toEqual(400)
expect(err.response.data).toEqual({
type: "invalid_data",
message: `Attribute value for "customer_email" is required by promotion campaing budget`,
})
})
it("should add promotion and remove it from cart using update", async () => {
const publishableKey = await generatePublishableKey(appContainer)
const storeHeaders = generateStoreHeaders({ publishableKey })