feat(core-flows,dashboard,js-sdk,promotion,medusa,types,utils): limit promotion usage per customer (#13451)
**What** - implement promotion usage limits per customer/email - fix registering spend usage over the limit - fix type errors in promotion module tests **How** - introduce a new type of campaign budget that can be defined by an attribute such as customer id or email - add `CampaignBudgetUsage` entity to keep track of the number of uses per attribute value - update `registerUsage` and `computeActions` in the promotion module to work with the new type - update `core-flows` to pass context needed for usage calculation to the promotion module **Breaking** - registering promotion usage now throws (and cart complete fails) if the budget limit is exceeded or if the cart completion would result in a breached limit --- CLOSES CORE-1172 CLOSES CORE-1173 CLOSES CORE-1174 CLOSES CORE-1175 Co-authored-by: Adrien de Peretti <25098370+adrien2p@users.noreply.github.com>
This commit is contained in:
1 parent
924564bee5
commit
7dc3b0c5ff
36 files changed
+2390
-190
No files matched your search
+39
-1
@@ -3,7 +3,10 @@ import { Modules } from "@medusajs/framework/utils"
|
||||
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
|
||||
import { CampaignBudgetType } from "../../../../../../core/utils/src/promotion/index"
|
||||
import { createCampaigns } from "../../../__fixtures__/campaigns"
|
||||
import { createPromotions } from "../../../__fixtures__/promotion"
|
||||
import {
|
||||
createDefaultPromotion,
|
||||
createPromotions,
|
||||
} from "../../../__fixtures__/promotion"
|
||||
|
||||
jest.setTimeout(30000)
|
||||
|
||||
@@ -488,6 +491,41 @@ moduleIntegrationTestRunner<IPromotionModuleService>({
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("campaignBudgetUsage", () => {
|
||||
it("should create a campaign budget by attribute usage successfully", async () => {
|
||||
const [createdCampaign] = await service.createCampaigns([
|
||||
{
|
||||
name: "test",
|
||||
campaign_identifier: "test",
|
||||
budget: {
|
||||
type: CampaignBudgetType.USE_BY_ATTRIBUTE,
|
||||
attribute: "customer_id",
|
||||
limit: 5,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
let campaigns = await service.listCampaigns(
|
||||
{
|
||||
id: [createdCampaign.id],
|
||||
},
|
||||
{ relations: ["budget", "budget.usages"] }
|
||||
)
|
||||
|
||||
expect(campaigns).toHaveLength(1)
|
||||
|
||||
expect(campaigns[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
budget: expect.objectContaining({
|
||||
usages: [],
|
||||
limit: 5,
|
||||
type: CampaignBudgetType.USE_BY_ATTRIBUTE,
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
+448
-12
File diff suppressed because it is too large.
Load diff
+295
-67
@@ -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,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user