diff --git a/.changeset/curly-apples-kick.md b/.changeset/curly-apples-kick.md
new file mode 100644
index 0000000000..4c68f692d4
--- /dev/null
+++ b/.changeset/curly-apples-kick.md
@@ -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
diff --git a/integration-tests/http/__tests__/cart/store/cart.spec.ts b/integration-tests/http/__tests__/cart/store/cart.spec.ts
index f99fa57863..6ed7f34f33 100644
--- a/integration-tests/http/__tests__/cart/store/cart.spec.ts
+++ b/integration-tests/http/__tests__/cart/store/cart.spec.ts
@@ -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
diff --git a/integration-tests/http/__tests__/promotions/admin/promotions.spec.ts b/integration-tests/http/__tests__/promotions/admin/promotions.spec.ts
index 6cd4bb1639..0a98b73e52 100644
--- a/integration-tests/http/__tests__/promotions/admin/promotions.spec.ts
+++ b/integration-tests/http/__tests__/promotions/admin/promotions.spec.ts
@@ -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 })
diff --git a/packages/admin/dashboard/src/i18n/translations/$schema.json b/packages/admin/dashboard/src/i18n/translations/$schema.json
index 21f6e6d61e..472b2d7606 100644
--- a/packages/admin/dashboard/src/i18n/translations/$schema.json
+++ b/packages/admin/dashboard/src/i18n/translations/$schema.json
@@ -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": {
diff --git a/packages/admin/dashboard/src/i18n/translations/en.json b/packages/admin/dashboard/src/i18n/translations/en.json
index 87506a8e03..1b4c96427f 100644
--- a/packages/admin/dashboard/src/i18n/translations/en.json
+++ b/packages/admin/dashboard/src/i18n/translations/en.json
@@ -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": {
diff --git a/packages/admin/dashboard/src/routes/campaigns/campaign-create/components/create-campaign-form/create-campaign-form.tsx b/packages/admin/dashboard/src/routes/campaigns/campaign-create/components/create-campaign-form/create-campaign-form.tsx
index 057149dd11..8503c9a2f6 100644
--- a/packages/admin/dashboard/src/routes/campaigns/campaign-create/components/create-campaign-form/create-campaign-form.tsx
+++ b/packages/admin/dashboard/src/routes/campaigns/campaign-create/components/create-campaign-form/create-campaign-form.tsx
@@ -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,
},
diff --git a/packages/admin/dashboard/src/routes/campaigns/campaign-detail/components/campaign-budget/campaign-budget.tsx b/packages/admin/dashboard/src/routes/campaigns/campaign-detail/components/campaign-budget/campaign-budget.tsx
index 66bfa2c308..77d88b6eba 100644
--- a/packages/admin/dashboard/src/routes/campaigns/campaign-detail/components/campaign-budget/campaign-budget.tsx
+++ b/packages/admin/dashboard/src/routes/campaigns/campaign-detail/components/campaign-budget/campaign-budget.tsx
@@ -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")}
diff --git a/packages/admin/dashboard/src/routes/campaigns/common/components/create-campaign-form-fields/create-campaign-form-fields.tsx b/packages/admin/dashboard/src/routes/campaigns/common/components/create-campaign-form-fields/create-campaign-form-fields.tsx
index 169b623f94..678ec1597a 100644
--- a/packages/admin/dashboard/src/routes/campaigns/common/components/create-campaign-form-fields/create-campaign-form-fields.tsx
+++ b/packages/admin/dashboard/src/routes/campaigns/common/components/create-campaign-form-fields/create-campaign-form-fields.tsx
@@ -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 = "" }) => {
{
)
}}
/>
+
+ {!isTypeSpend && (
+ {
+ return (
+
+
+ {t("campaigns.budget.fields.budgetAttribute")}
+
+
+
+ {
+ 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",
+ },
+ ]}
+ >
+
+
+
+ )
+ }}
+ />
+ )}
)
diff --git a/packages/admin/dashboard/src/routes/campaigns/common/constants.ts b/packages/admin/dashboard/src/routes/campaigns/common/constants.ts
index 401d086f84..308911353a 100644
--- a/packages/admin/dashboard/src/routes/campaigns/common/constants.ts
+++ b/packages/admin/dashboard/src/routes/campaigns/common/constants.ts
@@ -10,5 +10,6 @@ export const DEFAULT_CAMPAIGN_VALUES = {
type: "usage" as CampaignBudgetTypeValues,
currency_code: null,
limit: null,
+ attribute: null,
},
}
diff --git a/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/create-promotion-form.tsx b/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/create-promotion-form.tsx
index fac61500e7..ea1dbdfd67 100644
--- a/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/create-promotion-form.tsx
+++ b/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/create-promotion-form.tsx
@@ -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,
diff --git a/packages/core/core-flows/src/cart/utils/fields.ts b/packages/core/core-flows/src/cart/utils/fields.ts
index 21e3524781..54be16c48e 100644
--- a/packages/core/core-flows/src/cart/utils/fields.ts
+++ b/packages/core/core-flows/src/cart/utils/fields.ts
@@ -2,6 +2,7 @@
// Always ensure that cartFieldsForCalculateShippingOptionsPrices is present in cartFieldsForRefreshSteps
export const cartFieldsForRefreshSteps = [
"id",
+ "email",
"currency_code",
"quantity",
"subtotal",
diff --git a/packages/core/core-flows/src/cart/workflows/complete-cart.ts b/packages/core/core-flows/src/cart/workflows/complete-cart.ts
index 39453b3f6f..0689268e29 100644
--- a/packages/core/core-flows/src/cart/workflows/complete-cart.ts
+++ b/packages/core/core-flows/src/cart/workflows/complete-cart.ts
@@ -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,
+ },
+ }
}
)
diff --git a/packages/core/core-flows/src/promotion/steps/register-usage.ts b/packages/core/core-flows/src/promotion/steps/register-usage.ts
index 835666aa46..3946b1b56e 100644
--- a/packages/core/core-flows/src/promotion/steps/register-usage.ts
+++ b/packages/core/core-flows/src/promotion/steps/register-usage.ts
@@ -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(
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
+ )
}
)
diff --git a/packages/core/js-sdk/src/admin/campaign.ts b/packages/core/js-sdk/src/admin/campaign.ts
index 5150c1d384..68d5c965b4 100644
--- a/packages/core/js-sdk/src/admin/campaign.ts
+++ b/packages/core/js-sdk/src/admin/campaign.ts
@@ -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"],
diff --git a/packages/core/types/src/http/campaign/admin/payloads.ts b/packages/core/types/src/http/campaign/admin/payloads.ts
index 21c1295421..e02e00d8fe 100644
--- a/packages/core/types/src/http/campaign/admin/payloads.ts
+++ b/packages/core/types/src/http/campaign/admin/payloads.ts
@@ -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
*/
diff --git a/packages/core/types/src/http/campaign/admin/responses.ts b/packages/core/types/src/http/campaign/admin/responses.ts
index fe770e6581..e17280ce36 100644
--- a/packages/core/types/src/http/campaign/admin/responses.ts
+++ b/packages/core/types/src/http/campaign/admin/responses.ts
@@ -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
diff --git a/packages/core/types/src/promotion/common/campaign-budget.ts b/packages/core/types/src/promotion/common/campaign-budget.ts
index 041b230115..c37a0c4778 100644
--- a/packages/core/types/src/promotion/common/campaign-budget.ts
+++ b/packages/core/types/src/promotion/common/campaign-budget.ts
@@ -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[]
}
/**
diff --git a/packages/core/types/src/promotion/common/campaing-budget-usage.ts b/packages/core/types/src/promotion/common/campaing-budget-usage.ts
new file mode 100644
index 0000000000..bc51c51751
--- /dev/null
+++ b/packages/core/types/src/promotion/common/campaing-budget-usage.ts
@@ -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
+ /**
+ * 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
+}
diff --git a/packages/core/types/src/promotion/common/compute-actions.ts b/packages/core/types/src/promotion/common/compute-actions.ts
index 1ea6cb5ab6..fd0bd08ef2 100644
--- a/packages/core/types/src/promotion/common/compute-actions.ts
+++ b/packages/core/types/src/promotion/common/compute-actions.ts
@@ -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 {
*/
currency_code: string
+ /**
+ * The cart's email
+ */
+ email?: string
+
/**
* The cart's line items.
*/
diff --git a/packages/core/types/src/promotion/common/index.ts b/packages/core/types/src/promotion/common/index.ts
index bac7566457..d8b0a4c00e 100644
--- a/packages/core/types/src/promotion/common/index.ts
+++ b/packages/core/types/src/promotion/common/index.ts
@@ -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"
diff --git a/packages/core/types/src/promotion/mutations.ts b/packages/core/types/src/promotion/mutations.ts
index 843cea7624..0867972190 100644
--- a/packages/core/types/src/promotion/mutations.ts
+++ b/packages/core/types/src/promotion/mutations.ts
@@ -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
}
/**
diff --git a/packages/core/types/src/promotion/service.ts b/packages/core/types/src/promotion/service.ts
index 49d68396dc..3e015e225b 100644
--- a/packages/core/types/src/promotion/service.ts
+++ b/packages/core/types/src/promotion/service.ts
@@ -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} Resolves when the campaign budgets have been adjusted successfully.
*
* @example
@@ -48,13 +50,17 @@ export interface IPromotionModuleService extends IModuleService {
* },
* ])
*/
- registerUsage(computedActions: UsageComputedActions[]): Promise
+ registerUsage(
+ computedActions: UsageComputedActions[],
+ registrationContext: CampaignBudgetUsageContext
+ ): Promise
/**
* 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} Resolves when the campaign budgets have been adjusted successfully.
+ * @param {CampaignBudgetUsageContext} registrationContext - The context of the campaign budget usage.
+ * @returns {Promise} 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
+ revertUsage(
+ computedActions: UsageComputedActions[],
+ registrationContext: CampaignBudgetUsageContext
+ ): Promise
/**
* 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
diff --git a/packages/core/utils/src/promotion/index.ts b/packages/core/utils/src/promotion/index.ts
index 5dd3a497f9..362feef3d9 100644
--- a/packages/core/utils/src/promotion/index.ts
+++ b/packages/core/utils/src/promotion/index.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 {
diff --git a/packages/core/utils/src/totals/promotion/index.ts b/packages/core/utils/src/totals/promotion/index.ts
index 41be77d56f..5638b93139 100644
--- a/packages/core/utils/src/totals/promotion/index.ts
+++ b/packages/core/utils/src/totals/promotion/index.ts
@@ -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"
diff --git a/packages/medusa/src/api/admin/campaigns/validators.ts b/packages/medusa/src/api/admin/campaigns/validators.ts
index b72fa86caf..dddc78be97 100644
--- a/packages/medusa/src/api/admin/campaigns/validators.ts
+++ b/packages/medusa/src/api/admin/campaigns/validators.ts
@@ -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({
diff --git a/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/campaign.spec.ts b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/campaign.spec.ts
index 76a452b188..30bf88ac7c 100644
--- a/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/campaign.spec.ts
+++ b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/campaign.spec.ts
@@ -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({
)
})
})
+
+ 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,
+ }),
+ })
+ )
+ })
+ })
})
},
})
diff --git a/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts
index 6ebac6856a..570356ddc9 100644
--- a/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts
+++ b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts
@@ -4,6 +4,7 @@ import {
} from "@medusajs/framework/types"
import {
ApplicationMethodType,
+ CampaignBudgetType,
Modules,
PromotionStatus,
PromotionType,
@@ -786,6 +787,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 1,
subtotal: 100,
+ original_total: 100,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -797,6 +800,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_sweater",
quantity: 5,
subtotal: 750,
+ original_total: 750,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -824,6 +829,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 1,
subtotal: 100,
+ original_total: 100,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -835,6 +842,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_sweater",
quantity: 5,
subtotal: 750,
+ original_total: 750,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -888,6 +897,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 1,
subtotal: 100,
+ original_total: 100,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -899,6 +910,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_sweater",
quantity: 5,
subtotal: 750,
+ original_total: 750,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -935,6 +948,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 1,
subtotal: 100,
+ original_total: 100,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -946,6 +961,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_sweater",
quantity: 5,
subtotal: 750,
+ original_total: 750,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -1026,6 +1043,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 1,
subtotal: 50,
+ original_total: 50,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -1037,6 +1056,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_sweater",
quantity: 1,
subtotal: 150,
+ original_total: 150,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -1137,6 +1158,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 1,
subtotal: 50,
+ original_total: 50,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -1148,6 +1171,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_sweater",
quantity: 1,
subtotal: 150,
+ original_total: 150,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -1214,6 +1239,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 5,
subtotal: 5000,
+ original_total: 5000,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -1277,6 +1304,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 5,
subtotal: 5000,
+ original_total: 5000,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -1331,6 +1360,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 1,
subtotal: 100,
+ original_total: 100,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -1342,6 +1373,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_sweater",
quantity: 5,
subtotal: 750,
+ original_total: 750,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -1434,6 +1467,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 3,
subtotal: 150,
+ original_total: 150,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -1445,6 +1480,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_sweater",
quantity: 1,
subtotal: 150,
+ original_total: 150,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -1536,6 +1573,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 4,
subtotal: 200,
+ original_total: 200,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -1629,6 +1668,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 1,
subtotal: 50,
+ original_total: 50,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -1640,6 +1681,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_sweater",
quantity: 1,
subtotal: 150,
+ original_total: 150,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -1706,6 +1749,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 5,
subtotal: 10000,
+ original_total: 10000,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -1764,6 +1809,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 5,
subtotal: 5000,
+ original_total: 5000,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -1820,6 +1867,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 2,
subtotal: 200,
+ original_total: 200,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -1831,6 +1880,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_sweater",
quantity: 2,
subtotal: 600,
+ original_total: 600,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -1896,6 +1947,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 2,
subtotal: 200,
+ original_total: 200,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -1907,6 +1960,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_sweater",
quantity: 2,
subtotal: 600,
+ original_total: 600,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -1998,6 +2053,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 1,
subtotal: 50,
+ original_total: 50,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -2009,6 +2066,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_sweater",
quantity: 1,
subtotal: 150,
+ original_total: 150,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -2115,6 +2174,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 1,
subtotal: 50,
+ original_total: 50,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -2126,6 +2187,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_sweater",
quantity: 1,
subtotal: 150,
+ original_total: 150,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -2191,6 +2254,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 5,
subtotal: 5000,
+ original_total: 5000,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -2248,6 +2313,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 5,
subtotal: 5000,
+ original_total: 5000,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -2301,6 +2368,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 2,
subtotal: 200,
+ original_total: 200,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -2312,6 +2381,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_sweater",
quantity: 2,
subtotal: 600,
+ original_total: 600,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -2377,6 +2448,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 2,
subtotal: 200,
+ original_total: 200,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -2388,6 +2461,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_sweater",
quantity: 2,
subtotal: 600,
+ original_total: 600,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -2478,6 +2553,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 1,
subtotal: 50,
+ original_total: 50,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -2489,6 +2566,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_sweater",
quantity: 1,
subtotal: 150,
+ original_total: 150,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -2578,6 +2657,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 4,
subtotal: 300,
+ original_total: 300,
+ is_discountable: true,
product: {
id: "prod_tshirt",
},
@@ -2586,6 +2667,8 @@ moduleIntegrationTestRunner({
id: "item_wool_tshirt",
quantity: 4,
subtotal: 100,
+ original_total: 100,
+ is_discountable: true,
product: {
id: "prod_tshirt",
},
@@ -2656,6 +2739,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 4,
subtotal: 300,
+ original_total: 300,
+ is_discountable: true,
product: {
id: "prod_tshirt",
},
@@ -2664,6 +2749,8 @@ moduleIntegrationTestRunner({
id: "item_wool_tshirt",
quantity: 4,
subtotal: 100,
+ original_total: 100,
+ is_discountable: true,
product: {
id: "prod_tshirt",
},
@@ -2754,6 +2841,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 1,
subtotal: 50,
+ original_total: 50,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -2765,6 +2854,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_sweater",
quantity: 1,
subtotal: 150,
+ original_total: 150,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -2844,6 +2935,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 5,
subtotal: 5000,
+ original_total: 5000,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -2901,6 +2994,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 5,
subtotal: 5000,
+ original_total: 5000,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -2915,6 +3010,147 @@ moduleIntegrationTestRunner({
{ action: "campaignBudgetExceeded", code: "PROMOTION_TEST" },
])
})
+
+ it("should compute budget exceeded action when usage by attribute exceeds campaign budget for type use_by_attribute", async () => {
+ const testCampaign = await service.createCampaigns({
+ name: "test",
+ campaign_identifier: "test",
+ budget: {
+ type: CampaignBudgetType.USE_BY_ATTRIBUTE,
+ attribute: "customer_email",
+ limit: 2,
+ },
+ })
+
+ await createDefaultPromotion(service, {
+ campaign_id: testCampaign.id,
+ application_method: {
+ type: ApplicationMethodType.PERCENTAGE,
+ target_type: "items",
+ allocation: "across",
+ value: 10,
+ target_rules: [
+ {
+ attribute: "product_category.id",
+ operator: "eq",
+ values: ["catg_cotton"],
+ },
+ ],
+ } as any,
+ })
+
+ let result = await service.computeActions(["PROMOTION_TEST"], {
+ currency_code: "usd",
+ email: "test@test.com",
+ customer: {
+ email: "test@test.com",
+ },
+ items: [
+ {
+ id: "item_cotton_tshirt",
+ quantity: 1,
+ subtotal: 100,
+ original_total: 100,
+ is_discountable: true,
+ product_category: {
+ id: "catg_cotton",
+ },
+ product: {
+ id: "prod_tshirt",
+ },
+ },
+ ],
+ })
+
+ expect(JSON.parse(JSON.stringify(result))).toEqual([
+ {
+ action: "addItemAdjustment",
+ code: "PROMOTION_TEST",
+ amount: 10,
+ is_tax_inclusive: false,
+ item_id: "item_cotton_tshirt",
+ },
+ ])
+
+ await service.registerUsage(
+ [{ amount: 10, code: "PROMOTION_TEST" }],
+ {
+ customer_id: null,
+ customer_email: "test@test.com",
+ }
+ )
+
+ await service.registerUsage(
+ [{ amount: 10, code: "PROMOTION_TEST" }],
+ {
+ customer_id: null,
+ customer_email: "test@test.com",
+ }
+ )
+
+ result = await service.computeActions(["PROMOTION_TEST"], {
+ currency_code: "usd",
+ email: "test@test.com",
+ customer: {
+ email: "test@test.com",
+ },
+ items: [
+ {
+ id: "item_cotton_tshirt",
+ quantity: 1,
+ subtotal: 100,
+ original_total: 100,
+ is_discountable: true,
+ product_category: {
+ id: "catg_cotton",
+ },
+ product: {
+ id: "prod_tshirt",
+ },
+ },
+ ],
+ })
+
+ expect(JSON.parse(JSON.stringify(result))).toEqual([
+ {
+ action: "campaignBudgetExceeded",
+ code: "PROMOTION_TEST",
+ },
+ ])
+
+ result = await service.computeActions(["PROMOTION_TEST"], {
+ currency_code: "usd",
+ email: "another@test.com", // another email can sucessfully use the promotion
+ customer: {
+ email: "another@test.com",
+ },
+ items: [
+ {
+ id: "item_cotton_tshirt",
+ quantity: 1,
+ subtotal: 100,
+ original_total: 100,
+ is_discountable: true,
+ product_category: {
+ id: "catg_cotton",
+ },
+ product: {
+ id: "prod_tshirt",
+ },
+ },
+ ],
+ })
+
+ expect(JSON.parse(JSON.stringify(result))).toEqual([
+ {
+ action: "addItemAdjustment",
+ code: "PROMOTION_TEST",
+ amount: 10,
+ is_tax_inclusive: false,
+ item_id: "item_cotton_tshirt",
+ },
+ ])
+ })
})
})
@@ -2956,6 +3192,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_express",
subtotal: 250,
+ original_total: 250,
+ is_discountable: true,
shipping_option: {
id: "express",
},
@@ -2963,6 +3201,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_standard",
subtotal: 150,
+ original_total: 150,
+ is_discountable: true,
shipping_option: {
id: "standard",
},
@@ -2970,6 +3210,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_snail",
subtotal: 200,
+ original_total: 200,
+ is_discountable: true,
shipping_option: {
id: "snail",
},
@@ -3030,6 +3272,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_express",
subtotal: 250,
+ original_total: 250,
+ is_discountable: true,
shipping_option: {
id: "express",
},
@@ -3037,6 +3281,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_standard",
subtotal: 150,
+ original_total: 150,
+ is_discountable: true,
shipping_option: {
id: "standard",
},
@@ -3044,6 +3290,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_snail",
subtotal: 200,
+ original_total: 200,
+ is_discountable: true,
shipping_option: {
id: "snail",
},
@@ -3106,6 +3354,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_express",
subtotal: 250,
+ original_total: 250,
+ is_discountable: true,
shipping_option: {
id: "express",
},
@@ -3113,6 +3363,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_standard",
subtotal: 150,
+ original_total: 150,
+ is_discountable: true,
shipping_option: {
id: "standard",
},
@@ -3120,6 +3372,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_snail",
subtotal: 200,
+ original_total: 200,
+ is_discountable: true,
shipping_option: {
id: "snail",
},
@@ -3195,6 +3449,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_express",
subtotal: 250,
+ original_total: 250,
+ is_discountable: true,
shipping_option: {
id: "express",
},
@@ -3202,6 +3458,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_standard",
subtotal: 150,
+ original_total: 150,
+ is_discountable: true,
shipping_option: {
id: "standard",
},
@@ -3209,6 +3467,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_snail",
subtotal: 200,
+ original_total: 200,
+ is_discountable: true,
shipping_option: {
id: "snail",
},
@@ -3302,6 +3562,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_express",
subtotal: 250,
+ original_total: 250,
+ is_discountable: true,
shipping_option: {
id: "express",
},
@@ -3309,6 +3571,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_standard",
subtotal: 150,
+ original_total: 150,
+ is_discountable: true,
shipping_option: {
id: "standard",
},
@@ -3316,6 +3580,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_snail",
subtotal: 200,
+ original_total: 200,
+ is_discountable: true,
shipping_option: {
id: "snail",
},
@@ -3376,6 +3642,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_express",
subtotal: 1200,
+ original_total: 1200,
+ is_discountable: true,
shipping_option: {
id: "express",
},
@@ -3430,6 +3698,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_express",
subtotal: 1200,
+ original_total: 1200,
+ is_discountable: true,
shipping_option: {
id: "express",
},
@@ -3480,6 +3750,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_express",
subtotal: 250,
+ original_total: 250,
+ is_discountable: true,
shipping_option: {
id: "express",
},
@@ -3487,6 +3759,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_standard",
subtotal: 150,
+ original_total: 150,
+ is_discountable: true,
shipping_option: {
id: "standard",
},
@@ -3494,6 +3768,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_snail",
subtotal: 200,
+ original_total: 200,
+ is_discountable: true,
shipping_option: {
id: "snail",
},
@@ -3554,6 +3830,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_express",
subtotal: 250,
+ original_total: 250,
+ is_discountable: true,
shipping_option: {
id: "express",
},
@@ -3561,6 +3839,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_standard",
subtotal: 150,
+ original_total: 150,
+ is_discountable: true,
shipping_option: {
id: "standard",
},
@@ -3568,6 +3848,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_snail",
subtotal: 200,
+ original_total: 200,
+ is_discountable: true,
shipping_option: {
id: "snail",
},
@@ -3630,6 +3912,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_express",
subtotal: 250,
+ original_total: 250,
+ is_discountable: true,
shipping_option: {
id: "express",
},
@@ -3637,6 +3921,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_standard",
subtotal: 150,
+ original_total: 150,
+ is_discountable: true,
shipping_option: {
id: "standard",
},
@@ -3644,6 +3930,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_snail",
subtotal: 200,
+ original_total: 200,
+ is_discountable: true,
shipping_option: {
id: "snail",
},
@@ -3720,6 +4008,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_express",
subtotal: 250,
+ original_total: 250,
+ is_discountable: true,
shipping_option: {
id: "express",
},
@@ -3727,6 +4017,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_standard",
subtotal: 150,
+ original_total: 150,
+ is_discountable: true,
shipping_option: {
id: "standard",
},
@@ -3734,6 +4026,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_snail",
subtotal: 200,
+ original_total: 200,
+ is_discountable: true,
shipping_option: {
id: "snail",
},
@@ -3833,6 +4127,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_express",
subtotal: 250,
+ original_total: 250,
+ is_discountable: true,
shipping_option: {
id: "express",
},
@@ -3840,6 +4136,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_standard",
subtotal: 150,
+ original_total: 150,
+ is_discountable: true,
shipping_option: {
id: "standard",
},
@@ -3847,6 +4145,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_snail",
subtotal: 200,
+ original_total: 200,
+ is_discountable: true,
shipping_option: {
id: "snail",
},
@@ -3919,6 +4219,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_express",
subtotal: 1200,
+ original_total: 1200,
+ is_discountable: true,
shipping_option: {
id: "express",
},
@@ -3973,6 +4275,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_express",
subtotal: 1200,
+ original_total: 1200,
+ is_discountable: true,
shipping_option: {
id: "express",
},
@@ -4024,6 +4328,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_express",
subtotal: 500,
+ original_total: 500,
+ is_discountable: true,
shipping_option: {
id: "express",
},
@@ -4031,6 +4337,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_standard",
subtotal: 100,
+ original_total: 100,
+ is_discountable: true,
shipping_option: {
id: "standard",
},
@@ -4038,6 +4346,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_snail",
subtotal: 200,
+ original_total: 200,
+ is_discountable: true,
shipping_option: {
id: "snail",
},
@@ -4097,6 +4407,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_express",
subtotal: 500,
+ original_total: 500,
+ is_discountable: true,
shipping_option: {
id: "express",
},
@@ -4104,6 +4416,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_standard",
subtotal: 100,
+ original_total: 100,
+ is_discountable: true,
shipping_option: {
id: "standard",
},
@@ -4111,6 +4425,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_snail",
subtotal: 200,
+ original_total: 200,
+ is_discountable: true,
shipping_option: {
id: "snail",
},
@@ -4195,6 +4511,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_express",
subtotal: 500,
+ original_total: 500,
+ is_discountable: true,
shipping_option: {
id: "express",
},
@@ -4202,6 +4520,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_standard",
subtotal: 100,
+ original_total: 100,
+ is_discountable: true,
shipping_option: {
id: "standard",
},
@@ -4209,6 +4529,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_snail",
subtotal: 200,
+ original_total: 200,
+ is_discountable: true,
shipping_option: {
id: "snail",
},
@@ -4306,6 +4628,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_express",
subtotal: 500,
+ original_total: 500,
+ is_discountable: true,
shipping_option: {
id: "express",
},
@@ -4313,6 +4637,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_standard",
subtotal: 100,
+ original_total: 100,
+ is_discountable: true,
shipping_option: {
id: "standard",
},
@@ -4320,6 +4646,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_snail",
subtotal: 200,
+ original_total: 200,
+ is_discountable: true,
shipping_option: {
id: "snail",
},
@@ -4379,6 +4707,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_express",
subtotal: 1200,
+ original_total: 1200,
+ is_discountable: true,
shipping_option: {
id: "express",
},
@@ -4432,6 +4762,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_express",
subtotal: 1200,
+ original_total: 1200,
+ is_discountable: true,
shipping_option: {
id: "express",
},
@@ -4481,6 +4813,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_express",
subtotal: 500,
+ original_total: 500,
+ is_discountable: true,
shipping_option: {
id: "express",
},
@@ -4488,6 +4822,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_standard",
subtotal: 100,
+ original_total: 100,
+ is_discountable: true,
shipping_option: {
id: "standard",
},
@@ -4495,6 +4831,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_snail",
subtotal: 200,
+ original_total: 200,
+ is_discountable: true,
shipping_option: {
id: "snail",
},
@@ -4554,6 +4892,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_express",
subtotal: 500,
+ original_total: 500,
+ is_discountable: true,
shipping_option: {
id: "express",
},
@@ -4561,6 +4901,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_standard",
subtotal: 100,
+ original_total: 100,
+ is_discountable: true,
shipping_option: {
id: "standard",
},
@@ -4568,6 +4910,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_snail",
subtotal: 200,
+ original_total: 200,
+ is_discountable: true,
shipping_option: {
id: "snail",
},
@@ -4652,6 +4996,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_express",
subtotal: 500,
+ original_total: 500,
+ is_discountable: true,
shipping_option: {
id: "express",
},
@@ -4659,6 +5005,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_standard",
subtotal: 100,
+ original_total: 100,
+ is_discountable: true,
shipping_option: {
id: "standard",
},
@@ -4666,6 +5014,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_snail",
subtotal: 200,
+ original_total: 200,
+ is_discountable: true,
shipping_option: {
id: "snail",
},
@@ -4763,6 +5113,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_express",
subtotal: 500,
+ original_total: 500,
+ is_discountable: true,
shipping_option: {
id: "express",
},
@@ -4770,6 +5122,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_standard",
subtotal: 100,
+ original_total: 100,
+ is_discountable: true,
shipping_option: {
id: "standard",
},
@@ -4777,6 +5131,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_snail",
subtotal: 200,
+ original_total: 200,
+ is_discountable: true,
shipping_option: {
id: "snail",
},
@@ -4842,6 +5198,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_express",
subtotal: 1200,
+ original_total: 1200,
+ is_discountable: true,
shipping_option: {
id: "express",
},
@@ -4895,6 +5253,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_express",
subtotal: 1200,
+ original_total: 1200,
+ is_discountable: true,
shipping_option: {
id: "express",
},
@@ -4940,6 +5300,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 1,
subtotal: 100,
+ original_total: 100,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -5008,9 +5370,10 @@ moduleIntegrationTestRunner({
items: [
{
id: "item_cotton_tshirt",
- is_discountable: true,
quantity: 1,
subtotal: 100,
+ original_total: 100,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -5020,9 +5383,10 @@ moduleIntegrationTestRunner({
},
{
id: "item_cotton_sweater",
- is_discountable: true,
quantity: 2,
subtotal: 300,
+ original_total: 300,
+ is_discountable: true,
product_category: {
id: "catg_cotton",
},
@@ -5101,6 +5465,7 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 1,
subtotal: 50,
+ original_total: 50,
is_discountable: true,
product_category: {
id: "catg_cotton",
@@ -5113,6 +5478,7 @@ moduleIntegrationTestRunner({
id: "item_cotton_sweater",
quantity: 1,
subtotal: 150,
+ original_total: 150,
is_discountable: true,
product_category: {
id: "catg_cotton",
@@ -5207,6 +5573,7 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 1,
subtotal: 50,
+ original_total: 50,
product_category: {
id: "catg_cotton",
},
@@ -5219,6 +5586,7 @@ moduleIntegrationTestRunner({
id: "item_cotton_sweater",
quantity: 1,
subtotal: 150,
+ original_total: 150,
product_category: {
id: "catg_cotton",
},
@@ -5292,6 +5660,7 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 1,
subtotal: 100,
+ original_total: 100,
is_discountable: true,
product_category: {
id: "catg_cotton",
@@ -5310,6 +5679,7 @@ moduleIntegrationTestRunner({
id: "item_cotton_sweater",
quantity: 5,
subtotal: 750,
+ original_total: 750,
is_discountable: true,
product_category: {
id: "catg_cotton",
@@ -5384,6 +5754,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_express",
subtotal: 500,
+ original_total: 500,
+ is_discountable: true,
shipping_option: {
id: "express",
},
@@ -5397,6 +5769,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_standard",
subtotal: 100,
+ original_total: 100,
+ is_discountable: true,
shipping_option: {
id: "standard",
},
@@ -5404,6 +5778,8 @@ moduleIntegrationTestRunner({
{
id: "shipping_method_snail",
subtotal: 200,
+ original_total: 200,
+ is_discountable: true,
shipping_option: {
id: "snail",
},
@@ -5447,6 +5823,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 2,
subtotal: 1000,
+ original_total: 1000,
+ is_discountable: true,
product_category: {
id: "catg_tshirt",
},
@@ -5458,6 +5836,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt2",
quantity: 2,
subtotal: 2000,
+ original_total: 2000,
+ is_discountable: true,
product_category: {
id: "catg_tshirt",
},
@@ -5469,6 +5849,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_sweater",
quantity: 2,
subtotal: 2000,
+ original_total: 2000,
+ is_discountable: true,
product_category: {
id: "catg_sweater",
},
@@ -5541,6 +5923,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 2,
subtotal: 1000,
+ original_total: 1000,
+ is_discountable: true,
product_category: {
id: "catg_tshirt",
},
@@ -5552,6 +5936,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt2",
quantity: 2,
subtotal: 2000,
+ original_total: 2000,
+ is_discountable: true,
product_category: {
id: "catg_tshirt",
},
@@ -5563,6 +5949,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_sweater",
quantity: 2,
subtotal: 2000,
+ original_total: 2000,
+ is_discountable: true,
product_category: {
id: "catg_sweater",
},
@@ -5628,6 +6016,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 2,
subtotal: 1000,
+ original_total: 1000,
+ is_discountable: true,
product_category: {
id: "catg_tshirt",
},
@@ -5639,6 +6029,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt2",
quantity: 2,
subtotal: 2000,
+ original_total: 2000,
+ is_discountable: true,
product_category: {
id: "catg_tshirt",
},
@@ -5650,6 +6042,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_sweater",
quantity: 2,
subtotal: 2000,
+ original_total: 2000,
+ is_discountable: true,
product_category: {
id: "catg_sweater",
},
@@ -5729,6 +6123,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 2,
subtotal: 1000,
+ original_total: 1000,
+ is_discountable: true,
product_category: {
id: "catg_tshirt",
},
@@ -5740,6 +6136,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt2",
quantity: 2,
subtotal: 2000,
+ original_total: 2000,
+ is_discountable: true,
product_category: {
id: "catg_tshirt",
},
@@ -5751,6 +6149,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_sweater",
quantity: 2,
subtotal: 2000,
+ original_total: 2000,
+ is_discountable: true,
product_category: {
id: "catg_sweater",
},
@@ -5846,12 +6246,16 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 4,
subtotal: 1000,
+ original_total: 1000,
+ is_discountable: true,
product: { id: product1 },
},
{
id: "item_cotton_tshirt2",
quantity: 2,
subtotal: 2000,
+ original_total: 2000,
+ is_discountable: true,
product: { id: product2 },
},
],
@@ -5912,6 +6316,8 @@ moduleIntegrationTestRunner({
id: "item_1",
quantity: 2,
subtotal: 1000,
+ original_total: 1000,
+ is_discountable: true,
product: { id: product1 },
},
],
@@ -5932,6 +6338,8 @@ moduleIntegrationTestRunner({
id: "item_1",
quantity: 3,
subtotal: 1500,
+ original_total: 1500,
+ is_discountable: true,
product: { id: product1 },
},
],
@@ -5959,6 +6367,8 @@ moduleIntegrationTestRunner({
id: "item_1",
quantity: 5,
subtotal: 2500,
+ original_total: 2500,
+ is_discountable: true,
product: { id: product1 },
},
],
@@ -5986,6 +6396,8 @@ moduleIntegrationTestRunner({
id: "item_1",
quantity: 6,
subtotal: 3000,
+ original_total: 3000,
+ is_discountable: true,
product: { id: product1 },
},
],
@@ -6079,6 +6491,8 @@ moduleIntegrationTestRunner({
id: "item_1",
quantity: 3,
subtotal: 1500,
+ original_total: 1500,
+ is_discountable: true,
product: { id: product1 },
},
],
@@ -6110,6 +6524,8 @@ moduleIntegrationTestRunner({
id: "item_1",
quantity: 6,
subtotal: 3000,
+ original_total: 3000,
+ is_discountable: true,
product: { id: product1 },
},
],
@@ -6147,6 +6563,8 @@ moduleIntegrationTestRunner({
id: "item_1",
quantity: 7,
subtotal: 3500,
+ original_total: 3500,
+ is_discountable: true,
product: { id: product1 },
},
],
@@ -6184,6 +6602,8 @@ moduleIntegrationTestRunner({
id: "item_1",
quantity: 9,
subtotal: 4500,
+ original_total: 4500,
+ is_discountable: true,
product: { id: product1 },
},
],
@@ -6220,6 +6640,8 @@ moduleIntegrationTestRunner({
id: "item_1",
quantity: 1000,
subtotal: 500000,
+ original_total: 500000,
+ is_discountable: true,
product: { id: product1 },
},
],
@@ -6256,7 +6678,7 @@ moduleIntegrationTestRunner({
{
code: "BUY50GET1000",
type: PromotionType.BUYGET,
- campaign_id: null,
+ campaign_id: undefined,
application_method: {
type: "percentage",
target_type: "items",
@@ -6288,7 +6710,7 @@ moduleIntegrationTestRunner({
{
code: "BUY10GET200",
type: PromotionType.BUYGET,
- campaign_id: null,
+ campaign_id: undefined,
application_method: {
type: "percentage",
target_type: "items",
@@ -6322,6 +6744,8 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 1080,
subtotal: 2700,
+ original_total: 2700,
+ is_discountable: true,
product: { id: product1 },
},
],
@@ -6354,7 +6778,7 @@ moduleIntegrationTestRunner({
{
code: "BUY50GET1000",
type: PromotionType.BUYGET,
- campaign_id: null,
+ campaign_id: undefined,
application_method: {
type: "percentage",
target_type: "items",
@@ -6386,7 +6810,7 @@ moduleIntegrationTestRunner({
{
code: "BUY10GET200",
type: PromotionType.BUYGET,
- campaign_id: null,
+ campaign_id: undefined,
application_method: {
type: "percentage",
target_type: "items",
@@ -6420,12 +6844,16 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 540,
subtotal: 1350,
+ original_total: 1350,
+ is_discountable: true,
product: { id: product1 },
},
{
id: "item_cotton_tshirt2",
quantity: 540,
subtotal: 1350,
+ original_total: 1350,
+ is_discountable: true,
product: { id: product1 },
},
],
@@ -6847,6 +7275,7 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 1,
subtotal: 500,
+ original_total: 500,
product: { id: product1 },
is_discountable: true,
},
@@ -6854,6 +7283,7 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt1",
quantity: 1,
subtotal: 500,
+ original_total: 500,
product: { id: product1 },
is_discountable: true,
},
@@ -6861,15 +7291,17 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt2",
quantity: 1,
subtotal: 1000,
- product: { id: product1 },
+ original_total: 1000,
is_discountable: true,
+ product: { id: product1 },
},
{
id: "item_cotton_tshirt3",
quantity: 1,
subtotal: 1000,
- product: { id: product1 },
+ original_total: 1000,
is_discountable: true,
+ product: { id: product1 },
},
],
}
@@ -6903,8 +7335,9 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 3,
subtotal: 1000,
- product: { id: product1 },
+ original_total: 1000,
is_discountable: true,
+ product: { id: product1 },
},
],
}
@@ -6925,22 +7358,25 @@ moduleIntegrationTestRunner({
id: "item_cotton_tshirt",
quantity: 1,
subtotal: 1000,
- product: { id: product1 },
+ original_total: 1000,
is_discountable: true,
+ product: { id: product1 },
},
{
id: "item_cotton_tshirt1",
quantity: 1,
subtotal: 1000,
- product: { id: product1 },
+ original_total: 1000,
is_discountable: true,
+ product: { id: product1 },
},
{
id: "item_cotton_tshirt2",
quantity: 1,
subtotal: 1000,
- product: { id: product1 },
+ original_total: 1000,
is_discountable: true,
+ product: { id: product1 },
},
],
}
diff --git a/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/register-usage.spec.ts b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/register-usage.spec.ts
index a475706d55..58e51841de 100644
--- a/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/register-usage.spec.ts
+++ b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/register-usage.spec.ts
@@ -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,
+ }),
+ ]),
+ }),
+ })
+ )
+ })
})
})
},
diff --git a/packages/modules/promotion/src/migrations/.snapshot-medusa-promotion.json b/packages/modules/promotion/src/migrations/.snapshot-medusa-promotion.json
index 480f092ff6..3077698417 100644
--- a/packages/modules/promotion/src/migrations/.snapshot-medusa-promotion.json
+++ b/packages/modules/promotion/src/migrations/.snapshot-medusa-promotion.json
@@ -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": {
diff --git a/packages/modules/promotion/src/migrations/Migration20250909083125.ts b/packages/modules/promotion/src/migrations/Migration20250909083125.ts
new file mode 100644
index 0000000000..c7ba4c6358
--- /dev/null
+++ b/packages/modules/promotion/src/migrations/Migration20250909083125.ts
@@ -0,0 +1,54 @@
+import { Migration } from "@mikro-orm/migrations"
+
+export class Migration20250909083125 extends Migration {
+ override async up(): Promise {
+ 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 {
+ 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'));`
+ )
+ }
+}
diff --git a/packages/modules/promotion/src/models/campaign-budget-usage.ts b/packages/modules/promotion/src/models/campaign-budget-usage.ts
new file mode 100644
index 0000000000..9d3d2cccf1
--- /dev/null
+++ b/packages/modules/promotion/src/models/campaign-budget-usage.ts
@@ -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
diff --git a/packages/modules/promotion/src/models/campaign-budget.ts b/packages/modules/promotion/src/models/campaign-budget.ts
index e2a4f87420..0c43c95d70 100644
--- a/packages/modules/promotion/src/models/campaign-budget.ts
+++ b/packages/modules/promotion/src/models/campaign-budget.ts
@@ -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
diff --git a/packages/modules/promotion/src/models/index.ts b/packages/modules/promotion/src/models/index.ts
index 057dc73c77..3f76b87092 100644
--- a/packages/modules/promotion/src/models/index.ts
+++ b/packages/modules/promotion/src/models/index.ts
@@ -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"
diff --git a/packages/modules/promotion/src/services/promotion-module.ts b/packages/modules/promotion/src/services/promotion-module.ts
index bef3ae7008..c687730765 100644
--- a/packages/modules/promotion/src/services/promotion-module.ts
+++ b/packages/modules/promotion/src/services/promotion-module.ts
@@ -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
campaignService: ModulesSdkTypes.IMedusaInternalService
campaignBudgetService: ModulesSdkTypes.IMedusaInternalService
+ campaignBudgetUsageService: ModulesSdkTypes.IMedusaInternalService
}
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
>
+ protected campaignBudgetUsageService_: ModulesSdkTypes.IMedusaInternalService<
+ InferEntityType
+ >
+
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 {
+ 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 {
+ 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 {
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 {
const promotionCodeUsageMap = new Map()
@@ -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
diff --git a/packages/modules/promotion/src/types/campaign-budget.ts b/packages/modules/promotion/src/types/campaign-budget.ts
index 7b37f429b9..01bd978a2c 100644
--- a/packages/modules/promotion/src/types/campaign-budget.ts
+++ b/packages/modules/promotion/src/types/campaign-budget.ts
@@ -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
}
diff --git a/packages/modules/promotion/src/utils/compute-actions/usage.ts b/packages/modules/promotion/src/utils/compute-actions/usage.ts
index 60271060da..bd24ab33b3 100644
--- a/packages/modules/promotion/src/utils/compute-actions/usage.ts
+++ b/packages/modules/promotion/src/utils/compute-actions/usage.ts
@@ -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,
- 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,
+ }
+}