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:
11
.changeset/curly-apples-kick.md
Normal file
11
.changeset/curly-apples-kick.md
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
"@medusajs/promotion": patch
|
||||
"@medusajs/dashboard": patch
|
||||
"@medusajs/core-flows": patch
|
||||
"@medusajs/js-sdk": patch
|
||||
"@medusajs/types": patch
|
||||
"@medusajs/utils": patch
|
||||
"@medusajs/medusa": patch
|
||||
---
|
||||
|
||||
feat: support limiting promotion usage by attribute
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -8371,9 +8371,30 @@
|
||||
},
|
||||
"used": {
|
||||
"type": "string"
|
||||
},
|
||||
"budgetAttribute": {
|
||||
"type": "string"
|
||||
},
|
||||
"budgetAttributeTooltip": {
|
||||
"type": "string"
|
||||
},
|
||||
"limitBudgetAttributeCustomer": {
|
||||
"type": "string"
|
||||
},
|
||||
"limitBudgetAttributeEmail": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["type", "currency", "limit", "used"],
|
||||
"required": [
|
||||
"type",
|
||||
"currency",
|
||||
"limit",
|
||||
"used",
|
||||
"budgetAttribute",
|
||||
"budgetAttributeTooltip",
|
||||
"limitBudgetAttributeCustomer",
|
||||
"limitBudgetAttributeEmail"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"type": {
|
||||
|
||||
@@ -2204,7 +2204,7 @@
|
||||
"delete": {
|
||||
"title": "Are you sure?",
|
||||
"description": "You are about to delete the campaign '{{name}}'. This action cannot be undone.",
|
||||
"successToast": "Campaign '{{name}}' was successfully created."
|
||||
"successToast": "Campaign '{{name}}' was successfully deleted."
|
||||
},
|
||||
"edit": {
|
||||
"header": "Edit Campaign",
|
||||
@@ -2248,7 +2248,11 @@
|
||||
"type": "Type",
|
||||
"currency": "Currency",
|
||||
"limit": "Limit",
|
||||
"used": "Used"
|
||||
"used": "Used",
|
||||
"budgetAttribute": "Limit usage per",
|
||||
"budgetAttributeTooltip": "Define how many times the promotion can be used by a specific customer or email.",
|
||||
"limitBudgetAttributeCustomer": "Budget limit per customer",
|
||||
"limitBudgetAttributeEmail": "Budget limit per email"
|
||||
},
|
||||
"type": {
|
||||
"spend": {
|
||||
|
||||
@@ -21,8 +21,9 @@ export const CreateCampaignSchema = zod.object({
|
||||
starts_at: zod.date().nullable(),
|
||||
ends_at: zod.date().nullable(),
|
||||
budget: zod.object({
|
||||
attribute: zod.string().nullish(),
|
||||
limit: zod.number().min(0).nullish(),
|
||||
type: zod.enum(["spend", "usage"]),
|
||||
type: zod.enum(["spend", "usage", "use_by_attribute"]),
|
||||
currency_code: zod.string().nullish(),
|
||||
}),
|
||||
})
|
||||
@@ -38,6 +39,9 @@ export const CreateCampaignForm = () => {
|
||||
})
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (data) => {
|
||||
const attribute = data.budget.attribute || null
|
||||
const type = attribute ? "use_by_attribute" : data.budget.type
|
||||
|
||||
await mutateAsync(
|
||||
{
|
||||
name: data.name,
|
||||
@@ -46,7 +50,8 @@ export const CreateCampaignForm = () => {
|
||||
starts_at: data.starts_at,
|
||||
ends_at: data.ends_at,
|
||||
budget: {
|
||||
type: data.budget.type,
|
||||
type,
|
||||
attribute,
|
||||
limit: data.budget.limit ? data.budget.limit : undefined,
|
||||
currency_code: data.budget.currency_code,
|
||||
},
|
||||
|
||||
@@ -25,7 +25,11 @@ export const CampaignBudget = ({ campaign }: CampaignBudgetProps) => {
|
||||
className="text-ui-fg-subtle ms-10 mt-[1.5px] font-normal"
|
||||
level="h3"
|
||||
>
|
||||
{t("campaigns.fields.budget_limit")}
|
||||
{campaign.budget?.type === "use_by_attribute"
|
||||
? campaign.budget?.attribute === "customer_id"
|
||||
? t("campaigns.budget.fields.limitBudgetAttributeCustomer")
|
||||
: t("campaigns.budget.fields.limitBudgetAttributeEmail")
|
||||
: t("campaigns.fields.budget_limit")}
|
||||
</Heading>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
currencies,
|
||||
getCurrencySymbol,
|
||||
} from "../../../../../lib/data/currencies"
|
||||
import { Combobox } from "../../../../../components/inputs/combobox"
|
||||
|
||||
export const CreateCampaignFormFields = ({ form, fieldScope = "" }) => {
|
||||
const { t } = useTranslation()
|
||||
@@ -209,17 +210,19 @@ export const CreateCampaignFormFields = ({ form, fieldScope = "" }) => {
|
||||
<Form.Control>
|
||||
<RadioGroup
|
||||
dir={direction}
|
||||
className="flex gap-y-3"
|
||||
className="flex gap-x-4 gap-y-3"
|
||||
{...field}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<RadioGroup.ChoiceBox
|
||||
className="flex-1"
|
||||
value={"usage"}
|
||||
label={t("campaigns.budget.type.usage.title")}
|
||||
description={t("campaigns.budget.type.usage.description")}
|
||||
/>
|
||||
|
||||
<RadioGroup.ChoiceBox
|
||||
className="flex-1"
|
||||
value={"spend"}
|
||||
label={t("campaigns.budget.type.spend.title")}
|
||||
description={t("campaigns.budget.type.spend.description")}
|
||||
@@ -342,6 +345,52 @@ export const CreateCampaignFormFields = ({ form, fieldScope = "" }) => {
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
{!isTypeSpend && (
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name={`${fieldScope}budget.attribute`}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item className="basis-1/2">
|
||||
<Form.Label
|
||||
tooltip={t(
|
||||
"campaigns.budget.fields.budgetAttributeTooltip"
|
||||
)}
|
||||
>
|
||||
{t("campaigns.budget.fields.budgetAttribute")}
|
||||
</Form.Label>
|
||||
|
||||
<Form.Control>
|
||||
<Combobox
|
||||
key="attribute"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
if (typeof e === "undefined") {
|
||||
field.onChange(null)
|
||||
} else {
|
||||
field.onChange(e)
|
||||
}
|
||||
}}
|
||||
allowClear
|
||||
options={[
|
||||
{
|
||||
label: t("fields.customer"),
|
||||
value: "customer_id",
|
||||
},
|
||||
{
|
||||
label: t("fields.email"),
|
||||
value: "customer_email",
|
||||
},
|
||||
]}
|
||||
></Combobox>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -10,5 +10,6 @@ export const DEFAULT_CAMPAIGN_VALUES = {
|
||||
type: "usage" as CampaignBudgetTypeValues,
|
||||
currency_code: null,
|
||||
limit: null,
|
||||
attribute: null,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -139,6 +139,13 @@ export const CreatePromotionForm = () => {
|
||||
}))
|
||||
}
|
||||
|
||||
if (data.campaign) {
|
||||
data.campaign.budget.attribute = data.campaign.budget.attribute || null
|
||||
data.campaign.budget.type = data.campaign.budget.attribute
|
||||
? "use_by_attribute"
|
||||
: data.campaign.budget.type
|
||||
}
|
||||
|
||||
createPromotion(
|
||||
{
|
||||
...promotionData,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Always ensure that cartFieldsForCalculateShippingOptionsPrices is present in cartFieldsForRefreshSteps
|
||||
export const cartFieldsForRefreshSteps = [
|
||||
"id",
|
||||
"email",
|
||||
"currency_code",
|
||||
"quantity",
|
||||
"subtotal",
|
||||
|
||||
@@ -338,7 +338,13 @@ export const completeCartWorkflow = createWorkflow(
|
||||
})
|
||||
}
|
||||
|
||||
return promotionUsage
|
||||
return {
|
||||
computedActions: promotionUsage,
|
||||
registrationContext: {
|
||||
customer_id: cart.customer?.id || null,
|
||||
customer_email: cart.email || null,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
CampaignBudgetUsageContext,
|
||||
IPromotionModuleService,
|
||||
UsageComputedActions,
|
||||
} from "@medusajs/framework/types"
|
||||
@@ -6,26 +7,37 @@ import { Modules } from "@medusajs/framework/utils"
|
||||
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
|
||||
|
||||
export const registerUsageStepId = "register-usage"
|
||||
|
||||
type RegisterUsageStepInput = {
|
||||
computedActions: UsageComputedActions[]
|
||||
registrationContext: CampaignBudgetUsageContext
|
||||
}
|
||||
/**
|
||||
* This step registers usage for a promotion.
|
||||
*/
|
||||
export const registerUsageStep = createStep(
|
||||
registerUsageStepId,
|
||||
async (data: UsageComputedActions[], { container }) => {
|
||||
if (!data.length) {
|
||||
return new StepResponse(null, [])
|
||||
async (data: RegisterUsageStepInput, { container }) => {
|
||||
if (!data.computedActions.length) {
|
||||
return new StepResponse(null, {
|
||||
computedActions: [],
|
||||
registrationContext: data.registrationContext,
|
||||
})
|
||||
}
|
||||
|
||||
const promotionModule = container.resolve<IPromotionModuleService>(
|
||||
Modules.PROMOTION
|
||||
)
|
||||
|
||||
await promotionModule.registerUsage(data)
|
||||
await promotionModule.registerUsage(
|
||||
data.computedActions,
|
||||
data.registrationContext
|
||||
)
|
||||
|
||||
return new StepResponse(null, data)
|
||||
},
|
||||
async (revertData, { container }) => {
|
||||
if (!revertData?.length) {
|
||||
if (!revertData?.computedActions.length) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -33,6 +45,9 @@ export const registerUsageStep = createStep(
|
||||
Modules.PROMOTION
|
||||
)
|
||||
|
||||
await promotionModule.revertUsage(revertData)
|
||||
await promotionModule.revertUsage(
|
||||
revertData.computedActions,
|
||||
revertData.registrationContext
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -15,26 +15,26 @@ export class Campaign {
|
||||
}
|
||||
|
||||
/**
|
||||
* This method retrieves a campaign by its ID. It sends a request to the
|
||||
* This method retrieves a campaign by its ID. It sends a request to the
|
||||
* [Get Campaign](https://docs.medusajs.com/api/admin#campaigns_getcampaignsid) API route.
|
||||
*
|
||||
*
|
||||
* @param id - The campaign's ID.
|
||||
* @param query - Configure the fields to retrieve in the campaign.
|
||||
* @param headers - Headers to pass in the request
|
||||
* @returns The campaign's details.
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* To retrieve a campaign by its ID:
|
||||
*
|
||||
*
|
||||
* ```ts
|
||||
* sdk.admin.campaign.retrieve("procamp_123")
|
||||
* .then(({ campaign }) => {
|
||||
* console.log(campaign)
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
*
|
||||
* To specify the fields and relations to retrieve:
|
||||
*
|
||||
*
|
||||
* ```ts
|
||||
* sdk.admin.campaign.retrieve("procamp_123", {
|
||||
* fields: "id,*budget"
|
||||
@@ -43,7 +43,7 @@ export class Campaign {
|
||||
* console.log(campaign)
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
*
|
||||
* Learn more about the `fields` property in the [API reference](https://docs.medusajs.com/api/store#select-fields-and-relations).
|
||||
*/
|
||||
async retrieve(
|
||||
@@ -61,27 +61,27 @@ export class Campaign {
|
||||
}
|
||||
|
||||
/**
|
||||
* This method retrieves a paginated list of campaigns. It sends a request to the
|
||||
* This method retrieves a paginated list of campaigns. It sends a request to the
|
||||
* [List Campaigns](https://docs.medusajs.com/api/admin#campaigns_getcampaigns) API route.
|
||||
*
|
||||
*
|
||||
* @param query - Filters and pagination configurations.
|
||||
* @param headers - Headers to pass in the request.
|
||||
* @returns The paginated list of campaigns.
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* To retrieve the list of campaigns:
|
||||
*
|
||||
*
|
||||
* ```ts
|
||||
* sdk.admin.campaign.list()
|
||||
* .then(({ campaigns, count, limit, offset }) => {
|
||||
* console.log(campaigns)
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
*
|
||||
* To configure the pagination, pass the `limit` and `offset` query parameters.
|
||||
*
|
||||
*
|
||||
* For example, to retrieve only 10 items and skip 10 items:
|
||||
*
|
||||
*
|
||||
* ```ts
|
||||
* sdk.admin.campaign.list({
|
||||
* limit: 10,
|
||||
@@ -91,10 +91,10 @@ export class Campaign {
|
||||
* console.log(campaigns)
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
*
|
||||
* Using the `fields` query parameter, you can specify the fields and relations to retrieve
|
||||
* in each campaign:
|
||||
*
|
||||
*
|
||||
* ```ts
|
||||
* sdk.admin.campaign.list({
|
||||
* fields: "id,*budget"
|
||||
@@ -103,7 +103,7 @@ export class Campaign {
|
||||
* console.log(campaigns)
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
*
|
||||
* Learn more about the `fields` property in the [API reference](https://docs.medusajs.com/api/store#select-fields-and-relations).
|
||||
*/
|
||||
async list(
|
||||
@@ -120,13 +120,13 @@ export class Campaign {
|
||||
}
|
||||
|
||||
/**
|
||||
* This method creates a campaign. It sends a request to the
|
||||
* This method creates a campaign. It sends a request to the
|
||||
* [Create Campaign](https://docs.medusajs.com/api/admin#campaigns_postcampaigns) API route.
|
||||
*
|
||||
*
|
||||
* @param payload - The details of the campaign to create.
|
||||
* @param headers - Headers to pass in the request
|
||||
* @returns The campaign's details.
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* sdk.admin.campaign.create({
|
||||
* name: "Summer Campaign"
|
||||
@@ -150,14 +150,14 @@ export class Campaign {
|
||||
}
|
||||
|
||||
/**
|
||||
* This method updates a campaign. It sends a request to the
|
||||
* This method updates a campaign. It sends a request to the
|
||||
* [Update Campaign](https://docs.medusajs.com/api/admin#campaigns_postcampaignsid) API route.
|
||||
*
|
||||
*
|
||||
* @param id - The campaign's ID.
|
||||
* @param payload - The data to update in the campaign.
|
||||
* @param headers - Headers to pass in the request
|
||||
* @returns The campaign's details.
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* sdk.admin.campaign.update("procamp_123", {
|
||||
* name: "Summer Campaign"
|
||||
@@ -184,11 +184,11 @@ export class Campaign {
|
||||
/**
|
||||
* This method deletes a campaign by its ID. It sends a request to the
|
||||
* [Delete Campaign](https://docs.medusajs.com/api/admin#campaigns_deletecampaignsid) API route.
|
||||
*
|
||||
*
|
||||
* @param id - The campaign's ID.
|
||||
* @param headers - Headers to pass in the request
|
||||
* @returns The deletion's details.
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* sdk.admin.campaign.delete("procamp_123")
|
||||
* .then(({ deleted }) => {
|
||||
@@ -209,12 +209,12 @@ export class Campaign {
|
||||
* This method manages the promotions of a campaign to either add or remove the association between them.
|
||||
* It sends a request to the [Manage Promotions](https://docs.medusajs.com/api/admin#campaigns_postcampaignsidpromotions)
|
||||
* API route.
|
||||
*
|
||||
*
|
||||
* @param id - The campaign's ID.
|
||||
* @param payload - The promotions to add or remove associations to them.
|
||||
* @param headers - Headers to pass in the request
|
||||
* @returns The campaign's details.
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* sdk.admin.campaign.batchPromotions("procamp_123", {
|
||||
* add: ["prom_123", "prom_456"],
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface AdminCreateCampaign {
|
||||
description?: string
|
||||
/**
|
||||
* The campaign's currency code.
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* usd
|
||||
*/
|
||||
@@ -33,13 +33,13 @@ export interface AdminCreateCampaign {
|
||||
*/
|
||||
budget?: {
|
||||
/**
|
||||
* The budget's type. `spend` means the limit is set on the total amount discounted by the campaign's promotions;
|
||||
* The budget's type. `spend` means the limit is set on the total amount discounted by the campaign's promotions;
|
||||
* `usage` means the limit is set on the total number of times the campaign's promotions can be used.
|
||||
*/
|
||||
type?: CampaignBudgetTypeValues
|
||||
/**
|
||||
* The budget's currency code.
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* usd
|
||||
*/
|
||||
@@ -48,6 +48,10 @@ export interface AdminCreateCampaign {
|
||||
* The budget's limit.
|
||||
*/
|
||||
limit?: number | null
|
||||
/**
|
||||
* The budget's attribute.
|
||||
*/
|
||||
attribute?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
@@ -62,7 +66,7 @@ export interface AdminUpdateCampaign {
|
||||
description?: string
|
||||
/**
|
||||
* The campaign's currency code.
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* usd
|
||||
*/
|
||||
@@ -84,13 +88,13 @@ export interface AdminUpdateCampaign {
|
||||
*/
|
||||
budget?: {
|
||||
/**
|
||||
* The budget's type. `spend` means the limit is set on the total amount discounted by the campaign's promotions;
|
||||
* The budget's type. `spend` means the limit is set on the total amount discounted by the campaign's promotions;
|
||||
* `usage` means the limit is set on the total number of times the campaign's promotions can be used.
|
||||
*/
|
||||
type?: CampaignBudgetTypeValues
|
||||
/**
|
||||
* The budget's currency code.
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* usd
|
||||
*/
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface AdminCampaign {
|
||||
description: string
|
||||
/**
|
||||
* The campaign's currency code.
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* usd
|
||||
*/
|
||||
@@ -42,13 +42,13 @@ export interface AdminCampaign {
|
||||
*/
|
||||
id: string
|
||||
/**
|
||||
* The budget's type. `spend` means the limit is set on the total amount discounted by the campaign's promotions;
|
||||
* The budget's type. `spend` means the limit is set on the total amount discounted by the campaign's promotions;
|
||||
* `usage` means the limit is set on the total number of times the campaign's promotions can be used.
|
||||
*/
|
||||
type: CampaignBudgetTypeValues
|
||||
/**
|
||||
* The budget's currency code.
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* usd
|
||||
*/
|
||||
@@ -58,11 +58,15 @@ export interface AdminCampaign {
|
||||
*/
|
||||
limit: number
|
||||
/**
|
||||
* How much of the budget has been used. If the limit is `spend`, this property holds the total amount
|
||||
* How much of the budget has been used. If the limit is `spend`, this property holds the total amount
|
||||
* discounted so far. If the limit is `usage`, it holds the number of times the campaign's
|
||||
* promotions have been used so far.
|
||||
*/
|
||||
used: number
|
||||
/**
|
||||
* The budget's attribute if type is `use_by_attribute`.
|
||||
*/
|
||||
attribute: string
|
||||
}
|
||||
created_at: string
|
||||
updated_at: string
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { BaseFilterable } from "../../dal"
|
||||
import { CampaignBudgetUsageDTO } from "./campaing-budget-usage"
|
||||
|
||||
/**
|
||||
* The campaign budget's possible types.
|
||||
*/
|
||||
export type CampaignBudgetTypeValues = "spend" | "usage"
|
||||
export type CampaignBudgetTypeValues =
|
||||
| "spend"
|
||||
| "usage"
|
||||
| "use_by_attribute"
|
||||
| "spend_by_attribute"
|
||||
|
||||
/**
|
||||
* The campaign budget details.
|
||||
@@ -19,6 +24,8 @@ export interface CampaignBudgetDTO {
|
||||
*
|
||||
* - `spend` indicates that the budget is limited by the amount discounted by the promotions in the associated campaign.
|
||||
* - `usage` indicates that the budget is limited by the number of times the promotions of the associated campaign have been used.
|
||||
* - `use_by_attribute` indicates that the budget is limited by the number of times the promotions of the associated campaign have been used by a specific attribute value.
|
||||
* - `spend_by_attribute` indicates that the budget is limited by the amount discounted by the promotions in the associated campaign by a specific attribute value.
|
||||
*
|
||||
*/
|
||||
type?: CampaignBudgetTypeValues
|
||||
@@ -41,6 +48,16 @@ export interface CampaignBudgetDTO {
|
||||
* The currency of the campaign.
|
||||
*/
|
||||
currency_code?: string
|
||||
|
||||
/**
|
||||
* The attribute of the campaign budget.
|
||||
*/
|
||||
attribute?: string
|
||||
|
||||
/**
|
||||
* The usages of the campaign budget.
|
||||
*/
|
||||
usages?: CampaignBudgetUsageDTO[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* The context passed when promotion use is registered, reverted or limit is checked.
|
||||
*/
|
||||
export type CampaignBudgetUsageContext = {
|
||||
/**
|
||||
* The ID of the customer.
|
||||
*/
|
||||
customer_id: string | null
|
||||
/**
|
||||
* The email of the customer.
|
||||
*/
|
||||
customer_email: string | null
|
||||
}
|
||||
/**
|
||||
* Record of promotion usage as part of a campaign
|
||||
*/
|
||||
export interface CampaignBudgetUsageDTO {
|
||||
/**
|
||||
* The ID of the campaign budget usage.
|
||||
*/
|
||||
id: string
|
||||
/**
|
||||
* The value of the attribute that the promotion was used by.
|
||||
* e.g. if budget campaign is defined on `email` as a useage attribute,
|
||||
* `attribute_value` could contains email addresses
|
||||
*/
|
||||
attribute_value: string
|
||||
/**
|
||||
* The amount of times the promotion was used or
|
||||
* the amount of money discounted by the promotion.
|
||||
* Depends on the CampaignBudget type.
|
||||
*/
|
||||
used: number
|
||||
/**
|
||||
* The ID of the campaign budget.
|
||||
*/
|
||||
budget_id: string
|
||||
/**
|
||||
* The raw used value.
|
||||
*/
|
||||
raw_used: Record<string, any>
|
||||
/**
|
||||
* The date and time the campaign budget usage was created.
|
||||
*/
|
||||
created_at: string
|
||||
/**
|
||||
* The date and time the campaign budget usage was updated.
|
||||
*/
|
||||
updated_at: string
|
||||
/**
|
||||
* The date and time the campaign budget usage was deleted.
|
||||
*/
|
||||
deleted_at: string
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export type ComputeActions =
|
||||
*/
|
||||
export type UsageComputedActions = {
|
||||
/**
|
||||
* The amount to remove off the shipping method's total.
|
||||
* The amount (of usage or money) to adjust the campaign budget by.
|
||||
*/
|
||||
amount: BigNumberInput
|
||||
|
||||
@@ -242,6 +242,11 @@ export interface ComputeActionContext extends Record<string, unknown> {
|
||||
*/
|
||||
currency_code: string
|
||||
|
||||
/**
|
||||
* The cart's email
|
||||
*/
|
||||
email?: string
|
||||
|
||||
/**
|
||||
* The cart's line items.
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from "./application-method"
|
||||
export * from "./campaign"
|
||||
export * from "./campaign-budget"
|
||||
export * from "./compute-actions"
|
||||
export * from "./campaing-budget-usage"
|
||||
export * from "./promotion"
|
||||
export * from "./promotion-rule"
|
||||
export * from "./promotion-rule-value"
|
||||
|
||||
@@ -23,6 +23,11 @@ export interface CreateCampaignBudgetDTO {
|
||||
* The currency of the campaign.
|
||||
*/
|
||||
currency_code?: string | null
|
||||
|
||||
/**
|
||||
* The attribute by which the campaign budget usage is limited.
|
||||
*/
|
||||
attribute?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ import { IModuleService } from "../modules-sdk"
|
||||
import { Context } from "../shared-context"
|
||||
import {
|
||||
CampaignDTO,
|
||||
CampaignBudgetUsageContext,
|
||||
ComputeActionContext,
|
||||
ComputeActions,
|
||||
CreatePromotionDTO,
|
||||
@@ -34,6 +35,7 @@ export interface IPromotionModuleService extends IModuleService {
|
||||
* computed actions.
|
||||
*
|
||||
* @param {UsageComputedActions[]} computedActions - The computed actions to adjust their promotion's campaign budget.
|
||||
* @param {CampaignBudgetUsageContext} registrationContext - The context of the campaign budget usage.
|
||||
* @returns {Promise<void>} Resolves when the campaign budgets have been adjusted successfully.
|
||||
*
|
||||
* @example
|
||||
@@ -48,13 +50,17 @@ export interface IPromotionModuleService extends IModuleService {
|
||||
* },
|
||||
* ])
|
||||
*/
|
||||
registerUsage(computedActions: UsageComputedActions[]): Promise<void>
|
||||
registerUsage(
|
||||
computedActions: UsageComputedActions[],
|
||||
registrationContext: CampaignBudgetUsageContext
|
||||
): Promise<void>
|
||||
|
||||
/**
|
||||
* This method is used to revert the changes made by registerUsage action
|
||||
*
|
||||
* @param {UsageComputedActions[]} computedActions - The computed actions to adjust their promotion's campaign budget.
|
||||
* @returns {Promise<void>} Resolves when the campaign budgets have been adjusted successfully.
|
||||
* @param {CampaignBudgetUsageContext} registrationContext - The context of the campaign budget usage.
|
||||
* @returns {Promise<void>} Resolves when the campaign budgets have been reverted successfully.
|
||||
*
|
||||
* @example
|
||||
* await promotionModuleService.revertUsage([
|
||||
@@ -68,7 +74,10 @@ export interface IPromotionModuleService extends IModuleService {
|
||||
* },
|
||||
* ])
|
||||
*/
|
||||
revertUsage(computedActions: UsageComputedActions[]): Promise<void>
|
||||
revertUsage(
|
||||
computedActions: UsageComputedActions[],
|
||||
registrationContext: CampaignBudgetUsageContext
|
||||
): Promise<void>
|
||||
|
||||
/**
|
||||
* This method provides the actions to perform on a cart based on the specified promotions
|
||||
@@ -276,12 +285,12 @@ export interface IPromotionModuleService extends IModuleService {
|
||||
* ```
|
||||
*
|
||||
* To specify relations that should be retrieved within the promotions:
|
||||
*
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
*
|
||||
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
||||
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
||||
*
|
||||
*
|
||||
* :::
|
||||
*
|
||||
* ```ts
|
||||
@@ -336,12 +345,12 @@ export interface IPromotionModuleService extends IModuleService {
|
||||
* ```
|
||||
*
|
||||
* To specify relations that should be retrieved within the promotions:
|
||||
*
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
*
|
||||
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
||||
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
||||
*
|
||||
*
|
||||
* :::
|
||||
*
|
||||
* ```ts
|
||||
@@ -396,12 +405,12 @@ export interface IPromotionModuleService extends IModuleService {
|
||||
* ```
|
||||
*
|
||||
* To specify relations that should be retrieved:
|
||||
*
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
*
|
||||
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
||||
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
||||
*
|
||||
*
|
||||
* :::
|
||||
*
|
||||
* ```ts
|
||||
@@ -744,12 +753,12 @@ export interface IPromotionModuleService extends IModuleService {
|
||||
* ```
|
||||
*
|
||||
* To specify relations that should be retrieved within the promotion rules:
|
||||
*
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
*
|
||||
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
||||
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
||||
*
|
||||
*
|
||||
* :::
|
||||
*
|
||||
* ```ts
|
||||
@@ -826,12 +835,12 @@ export interface IPromotionModuleService extends IModuleService {
|
||||
* ```
|
||||
*
|
||||
* To specify relations that should be retrieved within the campaigns:
|
||||
*
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
*
|
||||
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
||||
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
||||
*
|
||||
*
|
||||
* :::
|
||||
*
|
||||
* ```ts
|
||||
@@ -886,12 +895,12 @@ export interface IPromotionModuleService extends IModuleService {
|
||||
* ```
|
||||
*
|
||||
* To specify relations that should be retrieved within the campaigns:
|
||||
*
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
*
|
||||
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
||||
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
||||
*
|
||||
*
|
||||
* :::
|
||||
*
|
||||
* ```ts
|
||||
@@ -946,12 +955,12 @@ export interface IPromotionModuleService extends IModuleService {
|
||||
* ```
|
||||
*
|
||||
* To specify relations that should be retrieved:
|
||||
*
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
*
|
||||
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
||||
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
||||
*
|
||||
*
|
||||
* :::
|
||||
*
|
||||
* ```ts
|
||||
|
||||
@@ -38,6 +38,8 @@ export enum PromotionRuleOperator {
|
||||
export enum CampaignBudgetType {
|
||||
SPEND = "spend",
|
||||
USAGE = "usage",
|
||||
USE_BY_ATTRIBUTE = "use_by_attribute",
|
||||
SPEND_BY_ATTRIBUTE = "spend_by_attribute",
|
||||
}
|
||||
|
||||
export enum ComputedActions {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { BigNumberInput } from "@medusajs/types"
|
||||
import { ApplicationMethodAllocation, ApplicationMethodType, } from "../../promotion"
|
||||
import {
|
||||
ApplicationMethodAllocation,
|
||||
ApplicationMethodType,
|
||||
} from "../../promotion"
|
||||
import { MathBN } from "../math"
|
||||
import { MEDUSA_EPSILON } from "../big-number"
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ const CreateCampaignBudget = z
|
||||
type: z.nativeEnum(CampaignBudgetType),
|
||||
limit: z.number().nullish(),
|
||||
currency_code: z.string().nullish(),
|
||||
attribute: z.string().nullish(),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
@@ -54,6 +55,18 @@ const CreateCampaignBudget = z
|
||||
message: `currency_code should not be present when budget type is ${CampaignBudgetType.USAGE}`,
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) =>
|
||||
isPresent(data.attribute) ||
|
||||
![
|
||||
CampaignBudgetType.USE_BY_ATTRIBUTE,
|
||||
CampaignBudgetType.SPEND_BY_ATTRIBUTE,
|
||||
].includes(data.type),
|
||||
(data) => ({
|
||||
path: ["attribute"],
|
||||
message: `campaign budget attribute is required when budget type is ${data.type}`,
|
||||
})
|
||||
)
|
||||
|
||||
export const UpdateCampaignBudget = z
|
||||
.object({
|
||||
|
||||
@@ -3,7 +3,10 @@ import { Modules } from "@medusajs/framework/utils"
|
||||
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
|
||||
import { CampaignBudgetType } from "../../../../../../core/utils/src/promotion/index"
|
||||
import { createCampaigns } from "../../../__fixtures__/campaigns"
|
||||
import { createPromotions } from "../../../__fixtures__/promotion"
|
||||
import {
|
||||
createDefaultPromotion,
|
||||
createPromotions,
|
||||
} from "../../../__fixtures__/promotion"
|
||||
|
||||
jest.setTimeout(30000)
|
||||
|
||||
@@ -488,6 +491,41 @@ moduleIntegrationTestRunner<IPromotionModuleService>({
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("campaignBudgetUsage", () => {
|
||||
it("should create a campaign budget by attribute usage successfully", async () => {
|
||||
const [createdCampaign] = await service.createCampaigns([
|
||||
{
|
||||
name: "test",
|
||||
campaign_identifier: "test",
|
||||
budget: {
|
||||
type: CampaignBudgetType.USE_BY_ATTRIBUTE,
|
||||
attribute: "customer_id",
|
||||
limit: 5,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
let campaigns = await service.listCampaigns(
|
||||
{
|
||||
id: [createdCampaign.id],
|
||||
},
|
||||
{ relations: ["budget", "budget.usages"] }
|
||||
)
|
||||
|
||||
expect(campaigns).toHaveLength(1)
|
||||
|
||||
expect(campaigns[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
budget: expect.objectContaining({
|
||||
usages: [],
|
||||
limit: 5,
|
||||
type: CampaignBudgetType.USE_BY_ATTRIBUTE,
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { IPromotionModuleService } from "@medusajs/framework/types"
|
||||
import { Modules } from "@medusajs/framework/utils"
|
||||
import { CampaignBudgetType, Modules } from "@medusajs/framework/utils"
|
||||
import { moduleIntegrationTestRunner, SuiteOptions } from "@medusajs/test-utils"
|
||||
import { createCampaigns } from "../../../__fixtures__/campaigns"
|
||||
import { createDefaultPromotion } from "../../../__fixtures__/promotion"
|
||||
@@ -21,20 +21,19 @@ moduleIntegrationTestRunner({
|
||||
it("should register usage for type spend", async () => {
|
||||
const createdPromotion = await createDefaultPromotion(service, {})
|
||||
|
||||
await service.registerUsage([
|
||||
{
|
||||
action: "addShippingMethodAdjustment",
|
||||
shipping_method_id: "shipping_method_express",
|
||||
amount: 200,
|
||||
code: createdPromotion.code!,
|
||||
},
|
||||
{
|
||||
action: "addShippingMethodAdjustment",
|
||||
shipping_method_id: "shipping_method_standard",
|
||||
amount: 500,
|
||||
code: createdPromotion.code!,
|
||||
},
|
||||
])
|
||||
await service.registerUsage(
|
||||
[
|
||||
{
|
||||
amount: 200,
|
||||
code: createdPromotion.code!,
|
||||
},
|
||||
{
|
||||
amount: 500,
|
||||
code: createdPromotion.code!,
|
||||
},
|
||||
],
|
||||
{ customer_email: null, customer_id: null }
|
||||
)
|
||||
|
||||
const campaign = await service.retrieveCampaign("campaign-id-1", {
|
||||
relations: ["budget"],
|
||||
@@ -54,20 +53,19 @@ moduleIntegrationTestRunner({
|
||||
campaign_id: "campaign-id-2",
|
||||
})
|
||||
|
||||
await service.registerUsage([
|
||||
{
|
||||
action: "addShippingMethodAdjustment",
|
||||
shipping_method_id: "shipping_method_express",
|
||||
amount: 200,
|
||||
code: createdPromotion.code!,
|
||||
},
|
||||
{
|
||||
action: "addShippingMethodAdjustment",
|
||||
shipping_method_id: "shipping_method_standard",
|
||||
amount: 500,
|
||||
code: createdPromotion.code!,
|
||||
},
|
||||
])
|
||||
await service.registerUsage(
|
||||
[
|
||||
{
|
||||
amount: 200,
|
||||
code: createdPromotion.code!,
|
||||
},
|
||||
{
|
||||
amount: 500,
|
||||
code: createdPromotion.code!,
|
||||
},
|
||||
],
|
||||
{ customer_email: null, customer_id: null }
|
||||
)
|
||||
|
||||
const campaign = await service.retrieveCampaign("campaign-id-2", {
|
||||
relations: ["budget"],
|
||||
@@ -84,20 +82,21 @@ moduleIntegrationTestRunner({
|
||||
|
||||
it("should not throw an error when compute action with code does not exist", async () => {
|
||||
const response = await service
|
||||
.registerUsage([
|
||||
{
|
||||
action: "addShippingMethodAdjustment",
|
||||
shipping_method_id: "shipping_method_express",
|
||||
amount: 200,
|
||||
code: "DOESNOTEXIST",
|
||||
},
|
||||
])
|
||||
.registerUsage(
|
||||
[
|
||||
{
|
||||
amount: 200,
|
||||
code: "DOESNOTEXIST",
|
||||
},
|
||||
],
|
||||
{ customer_email: null, customer_id: null }
|
||||
)
|
||||
.catch((e) => e)
|
||||
|
||||
expect(response).toEqual(undefined)
|
||||
})
|
||||
|
||||
it("should not register usage when limit is exceed for type usage", async () => {
|
||||
it("should throw if limit is exceeded for type usage", async () => {
|
||||
const createdPromotion = await createDefaultPromotion(service, {
|
||||
campaign_id: "campaign-id-2",
|
||||
})
|
||||
@@ -107,24 +106,37 @@ moduleIntegrationTestRunner({
|
||||
budget: { used: 1000, limit: 1000 },
|
||||
})
|
||||
|
||||
await service.registerUsage([
|
||||
{
|
||||
action: "addShippingMethodAdjustment",
|
||||
shipping_method_id: "shipping_method_express",
|
||||
amount: 200,
|
||||
code: createdPromotion.code!,
|
||||
},
|
||||
{
|
||||
action: "addShippingMethodAdjustment",
|
||||
shipping_method_id: "shipping_method_standard",
|
||||
amount: 500,
|
||||
code: createdPromotion.code!,
|
||||
},
|
||||
])
|
||||
const error = await service
|
||||
.registerUsage(
|
||||
[
|
||||
{
|
||||
amount: 200,
|
||||
code: createdPromotion.code!,
|
||||
},
|
||||
{
|
||||
amount: 500,
|
||||
code: createdPromotion.code!,
|
||||
},
|
||||
],
|
||||
{ customer_email: null, customer_id: null }
|
||||
)
|
||||
.catch((e) => e)
|
||||
|
||||
const campaign = await service.retrieveCampaign("campaign-id-2", {
|
||||
relations: ["budget"],
|
||||
})
|
||||
expect(error).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "not_allowed",
|
||||
message: "Promotion usage exceeds the budget limit.",
|
||||
})
|
||||
)
|
||||
|
||||
const [campaign] = await service.listCampaigns(
|
||||
{
|
||||
id: ["campaign-id-2"],
|
||||
},
|
||||
{
|
||||
relations: ["budget"],
|
||||
}
|
||||
)
|
||||
|
||||
expect(campaign).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -136,7 +148,7 @@ moduleIntegrationTestRunner({
|
||||
)
|
||||
})
|
||||
|
||||
it("should not register usage above limit when exceeded for type spend", async () => {
|
||||
it("should throw if limit is exceeded for type spend", async () => {
|
||||
const createdPromotion = await createDefaultPromotion(service, {})
|
||||
|
||||
await service.updateCampaigns({
|
||||
@@ -144,20 +156,114 @@ moduleIntegrationTestRunner({
|
||||
budget: { used: 900, limit: 1000 },
|
||||
})
|
||||
|
||||
await service.registerUsage([
|
||||
const error = await service
|
||||
.registerUsage(
|
||||
[
|
||||
{
|
||||
amount: 50,
|
||||
code: createdPromotion.code!,
|
||||
},
|
||||
{
|
||||
amount: 100,
|
||||
code: createdPromotion.code!,
|
||||
},
|
||||
],
|
||||
{ customer_email: null, customer_id: null }
|
||||
)
|
||||
.catch((e) => e)
|
||||
|
||||
expect(error).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "not_allowed",
|
||||
message: "Promotion usage exceeds the budget limit.",
|
||||
})
|
||||
)
|
||||
|
||||
const campaign = await service.retrieveCampaign("campaign-id-1", {
|
||||
relations: ["budget"],
|
||||
})
|
||||
|
||||
expect(campaign).toEqual(
|
||||
expect.objectContaining({
|
||||
budget: expect.objectContaining({
|
||||
used: 900,
|
||||
limit: 1000,
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("should throw if limit is exceeded for type spend (one amount exceeds the limit)", async () => {
|
||||
const createdPromotion = await createDefaultPromotion(service, {})
|
||||
|
||||
await service.updateCampaigns({
|
||||
id: "campaign-id-1",
|
||||
budget: { used: 900, limit: 1000 },
|
||||
})
|
||||
|
||||
const error = await service
|
||||
.registerUsage(
|
||||
[
|
||||
{
|
||||
amount: 75,
|
||||
code: createdPromotion.code!,
|
||||
},
|
||||
{
|
||||
amount: 75,
|
||||
code: createdPromotion.code!,
|
||||
},
|
||||
],
|
||||
{ customer_email: null, customer_id: null }
|
||||
)
|
||||
.catch((e) => e)
|
||||
|
||||
expect(error).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "not_allowed",
|
||||
message: "Promotion usage exceeds the budget limit.",
|
||||
})
|
||||
)
|
||||
|
||||
const [campaign] = await service.listCampaigns(
|
||||
{
|
||||
action: "addShippingMethodAdjustment",
|
||||
shipping_method_id: "shipping_method_express",
|
||||
amount: 100,
|
||||
code: createdPromotion.code!,
|
||||
id: ["campaign-id-1"],
|
||||
},
|
||||
{
|
||||
action: "addShippingMethodAdjustment",
|
||||
shipping_method_id: "shipping_method_standard",
|
||||
amount: 100,
|
||||
code: createdPromotion.code!,
|
||||
},
|
||||
])
|
||||
relations: ["budget"],
|
||||
}
|
||||
)
|
||||
|
||||
expect(campaign).toEqual(
|
||||
expect.objectContaining({
|
||||
budget: expect.objectContaining({
|
||||
limit: 1000,
|
||||
used: 900,
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("should not throw if the spent amount exactly matches the limit", async () => {
|
||||
const createdPromotion = await createDefaultPromotion(service, {})
|
||||
|
||||
await service.updateCampaigns({
|
||||
id: "campaign-id-1",
|
||||
budget: { used: 900, limit: 1000 },
|
||||
})
|
||||
|
||||
await service.registerUsage(
|
||||
[
|
||||
{
|
||||
amount: 50,
|
||||
code: createdPromotion.code!,
|
||||
},
|
||||
{
|
||||
amount: 50,
|
||||
code: createdPromotion.code!,
|
||||
},
|
||||
],
|
||||
{ customer_email: null, customer_id: null }
|
||||
)
|
||||
|
||||
const campaign = await service.retrieveCampaign("campaign-id-1", {
|
||||
relations: ["budget"],
|
||||
@@ -172,6 +278,128 @@ moduleIntegrationTestRunner({
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("should requister usage for attribute budget successfully and revert it successfully", async () => {
|
||||
const [createdCampaign] = await service.createCampaigns([
|
||||
{
|
||||
name: "test",
|
||||
campaign_identifier: "test",
|
||||
budget: {
|
||||
type: CampaignBudgetType.USE_BY_ATTRIBUTE,
|
||||
attribute: "customer_id",
|
||||
limit: 5,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const createdPromotion = await createDefaultPromotion(service, {
|
||||
campaign_id: createdCampaign.id,
|
||||
})
|
||||
|
||||
await service.registerUsage(
|
||||
[{ amount: 1, code: createdPromotion.code! }],
|
||||
{
|
||||
customer_id: "customer-id-1",
|
||||
customer_email: "customer1@email.com",
|
||||
}
|
||||
)
|
||||
|
||||
await service.registerUsage(
|
||||
[{ amount: 1, code: createdPromotion.code! }],
|
||||
{
|
||||
customer_id: "customer-id-2",
|
||||
customer_email: "customer2@email.com",
|
||||
}
|
||||
)
|
||||
|
||||
await service.registerUsage(
|
||||
[{ amount: 1, code: createdPromotion.code! }],
|
||||
{
|
||||
customer_id: "customer-id-1",
|
||||
customer_email: "customer1@email.com",
|
||||
}
|
||||
)
|
||||
|
||||
let campaign = await service.retrieveCampaign(createdCampaign.id, {
|
||||
relations: ["budget", "budget.usages"],
|
||||
})
|
||||
|
||||
expect(campaign).toEqual(
|
||||
expect.objectContaining({
|
||||
budget: expect.objectContaining({
|
||||
used: 3, // used 3 times overall
|
||||
usages: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
attribute_value: "customer-id-1",
|
||||
used: 2,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
attribute_value: "customer-id-2",
|
||||
used: 1,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
await service.revertUsage(
|
||||
[{ amount: 1, code: createdPromotion.code! }],
|
||||
{
|
||||
customer_id: "customer-id-1",
|
||||
customer_email: "customer1@email.com",
|
||||
}
|
||||
)
|
||||
|
||||
campaign = await service.retrieveCampaign(createdCampaign.id, {
|
||||
relations: ["budget", "budget.usages"],
|
||||
})
|
||||
|
||||
expect(campaign).toEqual(
|
||||
expect.objectContaining({
|
||||
budget: expect.objectContaining({
|
||||
used: 2,
|
||||
usages: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
attribute_value: "customer-id-1",
|
||||
used: 1,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
attribute_value: "customer-id-2",
|
||||
used: 1,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
await service.revertUsage(
|
||||
[{ amount: 1, code: createdPromotion.code! }],
|
||||
{
|
||||
customer_id: "customer-id-2",
|
||||
customer_email: "customer2@email.com",
|
||||
}
|
||||
)
|
||||
|
||||
campaign = await service.retrieveCampaign(createdCampaign.id, {
|
||||
relations: ["budget", "budget.usages"],
|
||||
})
|
||||
|
||||
expect(campaign.budget!.usages!).toHaveLength(1)
|
||||
|
||||
expect(campaign).toEqual(
|
||||
expect.objectContaining({
|
||||
budget: expect.objectContaining({
|
||||
used: 1,
|
||||
usages: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
attribute_value: "customer-id-1",
|
||||
used: 1,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
@@ -151,7 +151,9 @@
|
||||
"nullable": false,
|
||||
"enumItems": [
|
||||
"spend",
|
||||
"usage"
|
||||
"usage",
|
||||
"use_by_attribute",
|
||||
"spend_by_attribute"
|
||||
],
|
||||
"mappedType": "enum"
|
||||
},
|
||||
@@ -192,6 +194,15 @@
|
||||
"nullable": false,
|
||||
"mappedType": "text"
|
||||
},
|
||||
"attribute": {
|
||||
"name": "attribute",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": true,
|
||||
"mappedType": "text"
|
||||
},
|
||||
"raw_limit": {
|
||||
"name": "raw_limit",
|
||||
"type": "jsonb",
|
||||
@@ -302,6 +313,146 @@
|
||||
},
|
||||
"nativeEnums": {}
|
||||
},
|
||||
{
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"mappedType": "text"
|
||||
},
|
||||
"attribute_value": {
|
||||
"name": "attribute_value",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"mappedType": "text"
|
||||
},
|
||||
"used": {
|
||||
"name": "used",
|
||||
"type": "numeric",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"default": "0",
|
||||
"mappedType": "decimal"
|
||||
},
|
||||
"budget_id": {
|
||||
"name": "budget_id",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"mappedType": "text"
|
||||
},
|
||||
"raw_used": {
|
||||
"name": "raw_used",
|
||||
"type": "jsonb",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"mappedType": "json"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamptz",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"length": 6,
|
||||
"default": "now()",
|
||||
"mappedType": "datetime"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamptz",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"length": 6,
|
||||
"default": "now()",
|
||||
"mappedType": "datetime"
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "timestamptz",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": true,
|
||||
"length": 6,
|
||||
"mappedType": "datetime"
|
||||
}
|
||||
},
|
||||
"name": "promotion_campaign_budget_usage",
|
||||
"schema": "public",
|
||||
"indexes": [
|
||||
{
|
||||
"keyName": "IDX_promotion_campaign_budget_usage_budget_id",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_promotion_campaign_budget_usage_budget_id\" ON \"promotion_campaign_budget_usage\" (budget_id) WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "IDX_promotion_campaign_budget_usage_deleted_at",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_promotion_campaign_budget_usage_deleted_at\" ON \"promotion_campaign_budget_usage\" (deleted_at) WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "IDX_promotion_campaign_budget_usage_attribute_value_budget_id_unique",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_promotion_campaign_budget_usage_attribute_value_budget_id_unique\" ON \"promotion_campaign_budget_usage\" (attribute_value, budget_id) WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "promotion_campaign_budget_usage_pkey",
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"composite": false,
|
||||
"constraint": true,
|
||||
"primary": true,
|
||||
"unique": true
|
||||
}
|
||||
],
|
||||
"checks": [],
|
||||
"foreignKeys": {
|
||||
"promotion_campaign_budget_usage_budget_id_foreign": {
|
||||
"constraintName": "promotion_campaign_budget_usage_budget_id_foreign",
|
||||
"columnNames": [
|
||||
"budget_id"
|
||||
],
|
||||
"localTableName": "public.promotion_campaign_budget_usage",
|
||||
"referencedColumnNames": [
|
||||
"id"
|
||||
],
|
||||
"referencedTableName": "public.promotion_campaign_budget",
|
||||
"deleteRule": "cascade",
|
||||
"updateRule": "cascade"
|
||||
}
|
||||
},
|
||||
"nativeEnums": {}
|
||||
},
|
||||
{
|
||||
"columns": {
|
||||
"id": {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Migration } from "@mikro-orm/migrations"
|
||||
|
||||
export class Migration20250909083125 extends Migration {
|
||||
override async up(): Promise<void> {
|
||||
this.addSql(
|
||||
`alter table if exists "promotion_campaign_budget_usage" drop constraint if exists "promotion_campaign_budget_usage_attribute_value_budget_id_unique";`
|
||||
)
|
||||
this.addSql(
|
||||
`create table if not exists "promotion_campaign_budget_usage" ("id" text not null, "attribute_value" text not null, "used" numeric not null default 0, "budget_id" text not null, "raw_used" jsonb not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "promotion_campaign_budget_usage_pkey" primary key ("id"));`
|
||||
)
|
||||
this.addSql(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_promotion_campaign_budget_usage_budget_id" ON "promotion_campaign_budget_usage" (budget_id) WHERE deleted_at IS NULL;`
|
||||
)
|
||||
this.addSql(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_promotion_campaign_budget_usage_deleted_at" ON "promotion_campaign_budget_usage" (deleted_at) WHERE deleted_at IS NULL;`
|
||||
)
|
||||
this.addSql(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_promotion_campaign_budget_usage_attribute_value_budget_id_unique" ON "promotion_campaign_budget_usage" (attribute_value, budget_id) WHERE deleted_at IS NULL;`
|
||||
)
|
||||
|
||||
this.addSql(
|
||||
`alter table if exists "promotion_campaign_budget_usage" add constraint "promotion_campaign_budget_usage_budget_id_foreign" foreign key ("budget_id") references "promotion_campaign_budget" ("id") on update cascade on delete cascade;`
|
||||
)
|
||||
|
||||
this.addSql(
|
||||
`alter table if exists "promotion_campaign_budget" drop constraint if exists "promotion_campaign_budget_type_check";`
|
||||
)
|
||||
|
||||
this.addSql(
|
||||
`alter table if exists "promotion_campaign_budget" add column if not exists "attribute" text null;`
|
||||
)
|
||||
this.addSql(
|
||||
`alter table if exists "promotion_campaign_budget" add constraint "promotion_campaign_budget_type_check" check("type" in ('spend', 'usage', 'use_by_attribute', 'spend_by_attribute'));`
|
||||
)
|
||||
}
|
||||
|
||||
override async down(): Promise<void> {
|
||||
this.addSql(
|
||||
`drop table if exists "promotion_campaign_budget_usage" cascade;`
|
||||
)
|
||||
|
||||
this.addSql(
|
||||
`alter table if exists "promotion_campaign_budget" drop constraint if exists "promotion_campaign_budget_type_check";`
|
||||
)
|
||||
|
||||
this.addSql(
|
||||
`alter table if exists "promotion_campaign_budget" drop column if exists "attribute";`
|
||||
)
|
||||
|
||||
this.addSql(
|
||||
`alter table if exists "promotion_campaign_budget" add constraint "promotion_campaign_budget_type_check" check("type" in ('spend', 'usage'));`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { model } from "@medusajs/framework/utils"
|
||||
import CampaignBudget from "./campaign-budget"
|
||||
|
||||
const CampaignBudgetUsage = model
|
||||
.define(
|
||||
{
|
||||
name: "CampaignBudgetUsage",
|
||||
tableName: "promotion_campaign_budget_usage",
|
||||
},
|
||||
{
|
||||
id: model.id({ prefix: "probudgus" }).primaryKey(),
|
||||
attribute_value: model.text(), // e.g. "cus_123" | "john.smith@gmail.com"
|
||||
used: model.bigNumber().default(0),
|
||||
budget: model.belongsTo(() => CampaignBudget, {
|
||||
mappedBy: "usages",
|
||||
}),
|
||||
}
|
||||
)
|
||||
.indexes([
|
||||
{
|
||||
on: ["attribute_value", "budget_id"],
|
||||
unique: true,
|
||||
where: "deleted_at IS NULL",
|
||||
},
|
||||
])
|
||||
|
||||
export default CampaignBudgetUsage
|
||||
@@ -1,20 +1,32 @@
|
||||
import { PromotionUtils, model } from "@medusajs/framework/utils"
|
||||
import Campaign from "./campaign"
|
||||
import CampaignBudgetUsage from "./campaign-budget-usage"
|
||||
|
||||
const CampaignBudget = model.define(
|
||||
{ name: "CampaignBudget", tableName: "promotion_campaign_budget" },
|
||||
{
|
||||
id: model.id({ prefix: "probudg" }).primaryKey(),
|
||||
type: model
|
||||
.enum(PromotionUtils.CampaignBudgetType)
|
||||
.index("IDX_campaign_budget_type"),
|
||||
currency_code: model.text().nullable(),
|
||||
limit: model.bigNumber().nullable(),
|
||||
used: model.bigNumber().default(0),
|
||||
campaign: model.belongsTo(() => Campaign, {
|
||||
mappedBy: "budget",
|
||||
}),
|
||||
}
|
||||
)
|
||||
const CampaignBudget = model
|
||||
.define(
|
||||
{ name: "CampaignBudget", tableName: "promotion_campaign_budget" },
|
||||
{
|
||||
id: model.id({ prefix: "probudg" }).primaryKey(),
|
||||
type: model
|
||||
.enum(PromotionUtils.CampaignBudgetType)
|
||||
.index("IDX_campaign_budget_type"),
|
||||
currency_code: model.text().nullable(),
|
||||
limit: model.bigNumber().nullable(),
|
||||
used: model.bigNumber().default(0),
|
||||
campaign: model.belongsTo(() => Campaign, {
|
||||
mappedBy: "budget",
|
||||
}),
|
||||
|
||||
attribute: model.text().nullable(), // e.g. "customer_id", "customer_email"
|
||||
|
||||
// usages when budget type is "limit/use by attribute"
|
||||
usages: model.hasMany(() => CampaignBudgetUsage, {
|
||||
mappedBy: "budget",
|
||||
}),
|
||||
}
|
||||
)
|
||||
.cascades({
|
||||
delete: ["usages"],
|
||||
})
|
||||
|
||||
export default CampaignBudget
|
||||
|
||||
@@ -4,3 +4,4 @@ export { default as CampaignBudget } from "./campaign-budget"
|
||||
export { default as Promotion } from "./promotion"
|
||||
export { default as PromotionRule } from "./promotion-rule"
|
||||
export { default as PromotionRuleValue } from "./promotion-rule-value"
|
||||
export { default as CampaignBudgetUsage } from "./campaign-budget-usage"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
CampaignBudgetTypeValues,
|
||||
CampaignBudgetUsageDTO,
|
||||
Context,
|
||||
DAL,
|
||||
FilterablePromotionProps,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
ApplicationMethod,
|
||||
Campaign,
|
||||
CampaignBudget,
|
||||
CampaignBudgetUsage,
|
||||
Promotion,
|
||||
PromotionRule,
|
||||
PromotionRuleValue,
|
||||
@@ -72,6 +74,7 @@ type InjectedDependencies = {
|
||||
promotionRuleValueService: ModulesSdkTypes.IMedusaInternalService<any>
|
||||
campaignService: ModulesSdkTypes.IMedusaInternalService<any>
|
||||
campaignBudgetService: ModulesSdkTypes.IMedusaInternalService<any>
|
||||
campaignBudgetUsageService: ModulesSdkTypes.IMedusaInternalService<any>
|
||||
}
|
||||
|
||||
export default class PromotionModuleService
|
||||
@@ -80,6 +83,7 @@ export default class PromotionModuleService
|
||||
ApplicationMethod: { dto: PromotionTypes.ApplicationMethodDTO }
|
||||
Campaign: { dto: PromotionTypes.CampaignDTO }
|
||||
CampaignBudget: { dto: PromotionTypes.CampaignBudgetDTO }
|
||||
CampaignBudgetUsage: { dto: PromotionTypes.CampaignBudgetUsageDTO }
|
||||
PromotionRule: { dto: PromotionTypes.PromotionRuleDTO }
|
||||
PromotionRuleValue: { dto: PromotionTypes.PromotionRuleValueDTO }
|
||||
}>({
|
||||
@@ -87,6 +91,7 @@ export default class PromotionModuleService
|
||||
ApplicationMethod,
|
||||
Campaign,
|
||||
CampaignBudget,
|
||||
CampaignBudgetUsage,
|
||||
PromotionRule,
|
||||
PromotionRuleValue,
|
||||
})
|
||||
@@ -112,6 +117,10 @@ export default class PromotionModuleService
|
||||
InferEntityType<typeof CampaignBudget>
|
||||
>
|
||||
|
||||
protected campaignBudgetUsageService_: ModulesSdkTypes.IMedusaInternalService<
|
||||
InferEntityType<typeof CampaignBudgetUsage>
|
||||
>
|
||||
|
||||
constructor(
|
||||
{
|
||||
baseRepository,
|
||||
@@ -121,6 +130,7 @@ export default class PromotionModuleService
|
||||
promotionRuleValueService,
|
||||
campaignService,
|
||||
campaignBudgetService,
|
||||
campaignBudgetUsageService,
|
||||
}: InjectedDependencies,
|
||||
protected readonly moduleDeclaration: InternalModuleDeclaration
|
||||
) {
|
||||
@@ -134,6 +144,7 @@ export default class PromotionModuleService
|
||||
this.promotionRuleValueService_ = promotionRuleValueService
|
||||
this.campaignService_ = campaignService
|
||||
this.campaignBudgetService_ = campaignBudgetService
|
||||
this.campaignBudgetUsageService_ = campaignBudgetUsageService
|
||||
}
|
||||
|
||||
__joinerConfig(): ModuleJoinerConfig {
|
||||
@@ -194,10 +205,106 @@ export default class PromotionModuleService
|
||||
)
|
||||
}
|
||||
|
||||
@InjectTransactionManager()
|
||||
protected async registerCampaignBudgetUsageByAttribute_(
|
||||
budgetId: string,
|
||||
attributeValue: string,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<void> {
|
||||
const [campaignBudgetUsagePerAttributeValue] =
|
||||
await this.campaignBudgetUsageService_.list(
|
||||
{
|
||||
budget_id: budgetId,
|
||||
attribute_value: attributeValue,
|
||||
},
|
||||
{ relations: ["budget"] },
|
||||
sharedContext
|
||||
)
|
||||
|
||||
if (!campaignBudgetUsagePerAttributeValue) {
|
||||
await this.campaignBudgetUsageService_.create(
|
||||
{
|
||||
budget_id: budgetId,
|
||||
attribute_value: attributeValue,
|
||||
used: MathBN.convert(1),
|
||||
},
|
||||
sharedContext
|
||||
)
|
||||
} else {
|
||||
const limit = campaignBudgetUsagePerAttributeValue.budget.limit
|
||||
const newUsedValue = MathBN.add(
|
||||
campaignBudgetUsagePerAttributeValue.used ?? 0,
|
||||
1
|
||||
)
|
||||
|
||||
if (limit && MathBN.gt(newUsedValue, limit)) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_ALLOWED,
|
||||
"Promotion usage exceeds the budget limit."
|
||||
)
|
||||
}
|
||||
|
||||
await this.campaignBudgetUsageService_.update(
|
||||
{
|
||||
id: campaignBudgetUsagePerAttributeValue.id,
|
||||
used: newUsedValue,
|
||||
},
|
||||
sharedContext
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@InjectTransactionManager()
|
||||
protected async revertCampaignBudgetUsageByAttribute_(
|
||||
budgetId: string,
|
||||
attributeValue: string,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<void> {
|
||||
const [campaignBudgetUsagePerAttributeValue] =
|
||||
await this.campaignBudgetUsageService_.list(
|
||||
{
|
||||
budget_id: budgetId,
|
||||
attribute_value: attributeValue,
|
||||
},
|
||||
{},
|
||||
sharedContext
|
||||
)
|
||||
|
||||
if (!campaignBudgetUsagePerAttributeValue) {
|
||||
return
|
||||
}
|
||||
|
||||
if (MathBN.lte(campaignBudgetUsagePerAttributeValue.used ?? 0, 1)) {
|
||||
await this.campaignBudgetUsageService_.delete(
|
||||
campaignBudgetUsagePerAttributeValue.id,
|
||||
sharedContext
|
||||
)
|
||||
} else {
|
||||
await this.campaignBudgetUsageService_.update(
|
||||
{
|
||||
id: campaignBudgetUsagePerAttributeValue.id,
|
||||
used: MathBN.sub(campaignBudgetUsagePerAttributeValue.used ?? 0, 1),
|
||||
},
|
||||
sharedContext
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@InjectTransactionManager()
|
||||
@EmitEvents()
|
||||
/**
|
||||
* Register the usage of promotions in the campaign budget and
|
||||
* increment the used value if the budget is not exceeded,
|
||||
* throws an error if the budget is exceeded.
|
||||
*
|
||||
* @param computedActions - The computed actions to register usage for.
|
||||
* @param registrationContext - The context of the campaign budget usage.
|
||||
* @returns void
|
||||
* @throws {MedusaError} - If the promotion usage exceeds the budget limit.
|
||||
*/
|
||||
async registerUsage(
|
||||
computedActions: PromotionTypes.UsageComputedActions[],
|
||||
registrationContext: PromotionTypes.CampaignBudgetUsageContext,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<void> {
|
||||
const promotionCodes = computedActions
|
||||
@@ -209,7 +316,7 @@ export default class PromotionModuleService
|
||||
|
||||
const existingPromotions = await this.listActivePromotions_(
|
||||
{ code: promotionCodes },
|
||||
{ relations: ["campaign", "campaign.budget"] },
|
||||
{ relations: ["campaign", "campaign.budget", "campaign.budget.usages"] },
|
||||
sharedContext
|
||||
)
|
||||
|
||||
@@ -257,11 +364,14 @@ export default class PromotionModuleService
|
||||
campaignBudget.limit &&
|
||||
MathBN.gt(newUsedValue, campaignBudget.limit)
|
||||
) {
|
||||
continue
|
||||
} else {
|
||||
campaignBudgetData.used = newUsedValue
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_ALLOWED,
|
||||
"Promotion usage exceeds the budget limit."
|
||||
)
|
||||
}
|
||||
|
||||
campaignBudgetData.used = newUsedValue
|
||||
|
||||
campaignBudgetMap.set(campaignBudget.id, campaignBudgetData)
|
||||
}
|
||||
|
||||
@@ -275,22 +385,53 @@ export default class PromotionModuleService
|
||||
|
||||
const newUsedValue = MathBN.add(campaignBudget.used ?? 0, 1)
|
||||
|
||||
// Check if it exceeds the limit and cap it if necessary
|
||||
if (
|
||||
campaignBudget.limit &&
|
||||
MathBN.gt(newUsedValue, campaignBudget.limit)
|
||||
) {
|
||||
campaignBudgetMap.set(campaignBudget.id, {
|
||||
id: campaignBudget.id,
|
||||
used: campaignBudget.limit,
|
||||
})
|
||||
} else {
|
||||
campaignBudgetMap.set(campaignBudget.id, {
|
||||
id: campaignBudget.id,
|
||||
used: newUsedValue,
|
||||
})
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_ALLOWED,
|
||||
"Promotion usage exceeds the budget limit."
|
||||
)
|
||||
}
|
||||
|
||||
campaignBudgetMap.set(campaignBudget.id, {
|
||||
id: campaignBudget.id,
|
||||
used: newUsedValue,
|
||||
})
|
||||
|
||||
promotionCodeUsageMap.set(promotion.code!, true)
|
||||
}
|
||||
|
||||
if (campaignBudget.type === CampaignBudgetType.USE_BY_ATTRIBUTE) {
|
||||
const promotionAlreadyUsed =
|
||||
promotionCodeUsageMap.get(promotion.code!) || false
|
||||
|
||||
if (promotionAlreadyUsed) {
|
||||
continue
|
||||
}
|
||||
|
||||
const attribute = campaignBudget.attribute!
|
||||
const attributeValue = registrationContext[attribute]
|
||||
|
||||
if (!attributeValue) {
|
||||
continue
|
||||
}
|
||||
|
||||
await this.registerCampaignBudgetUsageByAttribute_(
|
||||
campaignBudget.id,
|
||||
attributeValue,
|
||||
sharedContext
|
||||
)
|
||||
|
||||
const newUsedValue = MathBN.add(campaignBudget.used ?? 0, 1)
|
||||
|
||||
// update the global budget usage to keep track but it is not used anywhere atm
|
||||
campaignBudgetMap.set(campaignBudget.id, {
|
||||
id: campaignBudget.id,
|
||||
used: newUsedValue,
|
||||
})
|
||||
|
||||
promotionCodeUsageMap.set(promotion.code!, true)
|
||||
}
|
||||
}
|
||||
@@ -298,6 +439,13 @@ export default class PromotionModuleService
|
||||
if (campaignBudgetMap.size > 0) {
|
||||
const campaignBudgetsData: UpdateCampaignBudgetDTO[] = []
|
||||
for (const [_, campaignBudgetData] of campaignBudgetMap) {
|
||||
// usages by attribute are updated separatley
|
||||
if (campaignBudgetData.usages) {
|
||||
const { usages, ...campaignBudgetDataWithoutUsages } =
|
||||
campaignBudgetData
|
||||
campaignBudgetsData.push(campaignBudgetDataWithoutUsages)
|
||||
continue
|
||||
}
|
||||
campaignBudgetsData.push(campaignBudgetData)
|
||||
}
|
||||
|
||||
@@ -312,6 +460,7 @@ export default class PromotionModuleService
|
||||
@EmitEvents()
|
||||
async revertUsage(
|
||||
computedActions: PromotionTypes.UsageComputedActions[],
|
||||
registrationContext: PromotionTypes.CampaignBudgetUsageContext,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<void> {
|
||||
const promotionCodeUsageMap = new Map<string, boolean>()
|
||||
@@ -390,11 +539,49 @@ export default class PromotionModuleService
|
||||
|
||||
promotionCodeUsageMap.set(promotion.code!, true)
|
||||
}
|
||||
|
||||
if (campaignBudget.type === CampaignBudgetType.USE_BY_ATTRIBUTE) {
|
||||
const promotionAlreadyUsed =
|
||||
promotionCodeUsageMap.get(promotion.code!) || false
|
||||
|
||||
if (promotionAlreadyUsed) {
|
||||
continue
|
||||
}
|
||||
|
||||
const attribute = campaignBudget.attribute!
|
||||
const attributeValue = registrationContext[attribute]
|
||||
|
||||
if (!attributeValue) {
|
||||
continue
|
||||
}
|
||||
|
||||
await this.revertCampaignBudgetUsageByAttribute_(
|
||||
campaignBudget.id,
|
||||
attributeValue,
|
||||
sharedContext
|
||||
)
|
||||
const newUsedValue = MathBN.sub(campaignBudget.used ?? 0, 1)
|
||||
const usedValue = MathBN.lt(newUsedValue, 0) ? 0 : newUsedValue
|
||||
|
||||
// update the global budget usage to keep track but it is not used anywhere atm
|
||||
campaignBudgetMap.set(campaignBudget.id, {
|
||||
id: campaignBudget.id,
|
||||
used: usedValue,
|
||||
})
|
||||
|
||||
promotionCodeUsageMap.set(promotion.code!, true)
|
||||
}
|
||||
}
|
||||
|
||||
if (campaignBudgetMap.size > 0) {
|
||||
const campaignBudgetsData: UpdateCampaignBudgetDTO[] = []
|
||||
for (const [_, campaignBudgetData] of campaignBudgetMap) {
|
||||
if (campaignBudgetData.usages) {
|
||||
const { usages, ...campaignBudgetDataWithoutUsages } =
|
||||
campaignBudgetData
|
||||
campaignBudgetsData.push(campaignBudgetDataWithoutUsages)
|
||||
continue
|
||||
}
|
||||
campaignBudgetsData.push(campaignBudgetData)
|
||||
}
|
||||
|
||||
@@ -581,6 +768,47 @@ export default class PromotionModuleService
|
||||
rules: promotionRules = [],
|
||||
} = promotion
|
||||
|
||||
if (
|
||||
promotion.campaign?.budget?.type === CampaignBudgetType.USE_BY_ATTRIBUTE
|
||||
) {
|
||||
const attribute = promotion.campaign?.budget?.attribute!
|
||||
const budgetUsageContext =
|
||||
ComputeActionUtils.getBudgetUsageContextFromComputeActionContext(
|
||||
applicationContext
|
||||
)
|
||||
const attributeValue = budgetUsageContext[attribute]
|
||||
|
||||
if (!attributeValue) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Attribute value for "${attribute}" is required by promotion campaing budget`
|
||||
)
|
||||
}
|
||||
|
||||
const [campaignBudgetUsagePerAttribute] =
|
||||
(await this.campaignBudgetUsageService_.list(
|
||||
{
|
||||
budget_id: promotion.campaign?.budget?.id,
|
||||
attribute_value: attributeValue,
|
||||
},
|
||||
{},
|
||||
sharedContext
|
||||
)) as unknown as CampaignBudgetUsageDTO[]
|
||||
|
||||
if (campaignBudgetUsagePerAttribute) {
|
||||
const action = ComputeActionUtils.computeActionForBudgetExceeded(
|
||||
promotion,
|
||||
1,
|
||||
campaignBudgetUsagePerAttribute
|
||||
)
|
||||
|
||||
if (action) {
|
||||
computedActions.push(action)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isCurrencyCodeValid =
|
||||
!isPresent(applicationMethod.currency_code) ||
|
||||
applicationContext.currency_code === applicationMethod.currency_code
|
||||
|
||||
@@ -19,4 +19,16 @@ export interface UpdateCampaignBudgetDTO {
|
||||
limit?: BigNumberInput | null
|
||||
currency_code?: string | null
|
||||
used?: BigNumberInput
|
||||
usages?: CreateCampaignBudgetUsageDTO[]
|
||||
}
|
||||
|
||||
export interface CreateCampaignBudgetUsageDTO {
|
||||
budget_id: string
|
||||
attribute_value: string
|
||||
used: BigNumberInput
|
||||
}
|
||||
|
||||
export interface UpdateCampaignBudgetUsageDTO {
|
||||
id: string
|
||||
used: BigNumberInput
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import {
|
||||
BigNumberInput,
|
||||
CampaignBudgetExceededAction,
|
||||
CampaignBudgetUsageContext,
|
||||
CampaignBudgetUsageDTO,
|
||||
ComputeActionContext,
|
||||
InferEntityType,
|
||||
PromotionDTO,
|
||||
} from "@medusajs/framework/types"
|
||||
@@ -11,9 +14,20 @@ import {
|
||||
} from "@medusajs/framework/utils"
|
||||
import { Promotion } from "@models"
|
||||
|
||||
/**
|
||||
* Compute the action for a budget exceeded.
|
||||
* @param promotion - the promotion being applied
|
||||
* @param amount - amount can be:
|
||||
* 1. discounted amount in case of spend budget
|
||||
* 2. number of times the promotion has been used in case of usage budget
|
||||
* 3. number of times the promotion has been used by a specific attribute value in case of use_by_attribute budget
|
||||
* @param attributeUsage - the attribute usage in case of use_by_attribute budget
|
||||
* @returns the exceeded action if the budget is exceeded, otherwise undefined
|
||||
*/
|
||||
export function computeActionForBudgetExceeded(
|
||||
promotion: PromotionDTO | InferEntityType<typeof Promotion>,
|
||||
amount: BigNumberInput
|
||||
amount: BigNumberInput,
|
||||
attributeUsage?: CampaignBudgetUsageDTO
|
||||
): CampaignBudgetExceededAction | void {
|
||||
const campaignBudget = promotion.campaign?.budget
|
||||
|
||||
@@ -21,7 +35,17 @@ export function computeActionForBudgetExceeded(
|
||||
return
|
||||
}
|
||||
|
||||
const campaignBudgetUsed = campaignBudget.used ?? 0
|
||||
if (
|
||||
campaignBudget.type === CampaignBudgetType.USE_BY_ATTRIBUTE &&
|
||||
!attributeUsage
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const campaignBudgetUsed = attributeUsage
|
||||
? attributeUsage.used
|
||||
: campaignBudget.used ?? 0
|
||||
|
||||
const totalUsed =
|
||||
campaignBudget.type === CampaignBudgetType.SPEND
|
||||
? MathBN.add(campaignBudgetUsed, amount)
|
||||
@@ -34,3 +58,16 @@ export function computeActionForBudgetExceeded(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getBudgetUsageContextFromComputeActionContext(
|
||||
computeActionContext: ComputeActionContext
|
||||
): CampaignBudgetUsageContext {
|
||||
return {
|
||||
customer_id:
|
||||
computeActionContext.customer_id ??
|
||||
(computeActionContext.customer as any)?.id ??
|
||||
null,
|
||||
customer_email:
|
||||
(computeActionContext.email as string | undefined | null) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user