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, + } +}