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

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

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

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

---

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


Co-authored-by: Adrien de Peretti <25098370+adrien2p@users.noreply.github.com>
This commit is contained in:
Frane Polić
2025-10-09 12:35:54 +00:00
committed by GitHub
co-authored by Adrien de Peretti
parent 924564bee5
commit 7dc3b0c5ff
36 changed files with 2390 additions and 190 deletions
@@ -2,6 +2,7 @@
// Always ensure that cartFieldsForCalculateShippingOptionsPrices is present in cartFieldsForRefreshSteps
export const cartFieldsForRefreshSteps = [
"id",
"email",
"currency_code",
"quantity",
"subtotal",
@@ -338,7 +338,13 @@ export const completeCartWorkflow = createWorkflow(
})
}
return promotionUsage
return {
computedActions: promotionUsage,
registrationContext: {
customer_id: cart.customer?.id || null,
customer_email: cart.email || null,
},
}
}
)
@@ -1,4 +1,5 @@
import {
CampaignBudgetUsageContext,
IPromotionModuleService,
UsageComputedActions,
} from "@medusajs/framework/types"
@@ -6,26 +7,37 @@ import { Modules } from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
export const registerUsageStepId = "register-usage"
type RegisterUsageStepInput = {
computedActions: UsageComputedActions[]
registrationContext: CampaignBudgetUsageContext
}
/**
* This step registers usage for a promotion.
*/
export const registerUsageStep = createStep(
registerUsageStepId,
async (data: UsageComputedActions[], { container }) => {
if (!data.length) {
return new StepResponse(null, [])
async (data: RegisterUsageStepInput, { container }) => {
if (!data.computedActions.length) {
return new StepResponse(null, {
computedActions: [],
registrationContext: data.registrationContext,
})
}
const promotionModule = container.resolve<IPromotionModuleService>(
Modules.PROMOTION
)
await promotionModule.registerUsage(data)
await promotionModule.registerUsage(
data.computedActions,
data.registrationContext
)
return new StepResponse(null, data)
},
async (revertData, { container }) => {
if (!revertData?.length) {
if (!revertData?.computedActions.length) {
return
}
@@ -33,6 +45,9 @@ export const registerUsageStep = createStep(
Modules.PROMOTION
)
await promotionModule.revertUsage(revertData)
await promotionModule.revertUsage(
revertData.computedActions,
revertData.registrationContext
)
}
)
+27 -27
View File
@@ -15,26 +15,26 @@ export class Campaign {
}
/**
* This method retrieves a campaign by its ID. It sends a request to the
* This method retrieves a campaign by its ID. It sends a request to the
* [Get Campaign](https://docs.medusajs.com/api/admin#campaigns_getcampaignsid) API route.
*
*
* @param id - The campaign's ID.
* @param query - Configure the fields to retrieve in the campaign.
* @param headers - Headers to pass in the request
* @returns The campaign's details.
*
*
* @example
* To retrieve a campaign by its ID:
*
*
* ```ts
* sdk.admin.campaign.retrieve("procamp_123")
* .then(({ campaign }) => {
* console.log(campaign)
* })
* ```
*
*
* To specify the fields and relations to retrieve:
*
*
* ```ts
* sdk.admin.campaign.retrieve("procamp_123", {
* fields: "id,*budget"
@@ -43,7 +43,7 @@ export class Campaign {
* console.log(campaign)
* })
* ```
*
*
* Learn more about the `fields` property in the [API reference](https://docs.medusajs.com/api/store#select-fields-and-relations).
*/
async retrieve(
@@ -61,27 +61,27 @@ export class Campaign {
}
/**
* This method retrieves a paginated list of campaigns. It sends a request to the
* This method retrieves a paginated list of campaigns. It sends a request to the
* [List Campaigns](https://docs.medusajs.com/api/admin#campaigns_getcampaigns) API route.
*
*
* @param query - Filters and pagination configurations.
* @param headers - Headers to pass in the request.
* @returns The paginated list of campaigns.
*
*
* @example
* To retrieve the list of campaigns:
*
*
* ```ts
* sdk.admin.campaign.list()
* .then(({ campaigns, count, limit, offset }) => {
* console.log(campaigns)
* })
* ```
*
*
* To configure the pagination, pass the `limit` and `offset` query parameters.
*
*
* For example, to retrieve only 10 items and skip 10 items:
*
*
* ```ts
* sdk.admin.campaign.list({
* limit: 10,
@@ -91,10 +91,10 @@ export class Campaign {
* console.log(campaigns)
* })
* ```
*
*
* Using the `fields` query parameter, you can specify the fields and relations to retrieve
* in each campaign:
*
*
* ```ts
* sdk.admin.campaign.list({
* fields: "id,*budget"
@@ -103,7 +103,7 @@ export class Campaign {
* console.log(campaigns)
* })
* ```
*
*
* Learn more about the `fields` property in the [API reference](https://docs.medusajs.com/api/store#select-fields-and-relations).
*/
async list(
@@ -120,13 +120,13 @@ export class Campaign {
}
/**
* This method creates a campaign. It sends a request to the
* This method creates a campaign. It sends a request to the
* [Create Campaign](https://docs.medusajs.com/api/admin#campaigns_postcampaigns) API route.
*
*
* @param payload - The details of the campaign to create.
* @param headers - Headers to pass in the request
* @returns The campaign's details.
*
*
* @example
* sdk.admin.campaign.create({
* name: "Summer Campaign"
@@ -150,14 +150,14 @@ export class Campaign {
}
/**
* This method updates a campaign. It sends a request to the
* This method updates a campaign. It sends a request to the
* [Update Campaign](https://docs.medusajs.com/api/admin#campaigns_postcampaignsid) API route.
*
*
* @param id - The campaign's ID.
* @param payload - The data to update in the campaign.
* @param headers - Headers to pass in the request
* @returns The campaign's details.
*
*
* @example
* sdk.admin.campaign.update("procamp_123", {
* name: "Summer Campaign"
@@ -184,11 +184,11 @@ export class Campaign {
/**
* This method deletes a campaign by its ID. It sends a request to the
* [Delete Campaign](https://docs.medusajs.com/api/admin#campaigns_deletecampaignsid) API route.
*
*
* @param id - The campaign's ID.
* @param headers - Headers to pass in the request
* @returns The deletion's details.
*
*
* @example
* sdk.admin.campaign.delete("procamp_123")
* .then(({ deleted }) => {
@@ -209,12 +209,12 @@ export class Campaign {
* This method manages the promotions of a campaign to either add or remove the association between them.
* It sends a request to the [Manage Promotions](https://docs.medusajs.com/api/admin#campaigns_postcampaignsidpromotions)
* API route.
*
*
* @param id - The campaign's ID.
* @param payload - The promotions to add or remove associations to them.
* @param headers - Headers to pass in the request
* @returns The campaign's details.
*
*
* @example
* sdk.admin.campaign.batchPromotions("procamp_123", {
* add: ["prom_123", "prom_456"],
@@ -11,7 +11,7 @@ export interface AdminCreateCampaign {
description?: string
/**
* The campaign's currency code.
*
*
* @example
* usd
*/
@@ -33,13 +33,13 @@ export interface AdminCreateCampaign {
*/
budget?: {
/**
* The budget's type. `spend` means the limit is set on the total amount discounted by the campaign's promotions;
* The budget's type. `spend` means the limit is set on the total amount discounted by the campaign's promotions;
* `usage` means the limit is set on the total number of times the campaign's promotions can be used.
*/
type?: CampaignBudgetTypeValues
/**
* The budget's currency code.
*
*
* @example
* usd
*/
@@ -48,6 +48,10 @@ export interface AdminCreateCampaign {
* The budget's limit.
*/
limit?: number | null
/**
* The budget's attribute.
*/
attribute?: string | null
} | null
}
@@ -62,7 +66,7 @@ export interface AdminUpdateCampaign {
description?: string
/**
* The campaign's currency code.
*
*
* @example
* usd
*/
@@ -84,13 +88,13 @@ export interface AdminUpdateCampaign {
*/
budget?: {
/**
* The budget's type. `spend` means the limit is set on the total amount discounted by the campaign's promotions;
* The budget's type. `spend` means the limit is set on the total amount discounted by the campaign's promotions;
* `usage` means the limit is set on the total number of times the campaign's promotions can be used.
*/
type?: CampaignBudgetTypeValues
/**
* The budget's currency code.
*
*
* @example
* usd
*/
@@ -16,7 +16,7 @@ export interface AdminCampaign {
description: string
/**
* The campaign's currency code.
*
*
* @example
* usd
*/
@@ -42,13 +42,13 @@ export interface AdminCampaign {
*/
id: string
/**
* The budget's type. `spend` means the limit is set on the total amount discounted by the campaign's promotions;
* The budget's type. `spend` means the limit is set on the total amount discounted by the campaign's promotions;
* `usage` means the limit is set on the total number of times the campaign's promotions can be used.
*/
type: CampaignBudgetTypeValues
/**
* The budget's currency code.
*
*
* @example
* usd
*/
@@ -58,11 +58,15 @@ export interface AdminCampaign {
*/
limit: number
/**
* How much of the budget has been used. If the limit is `spend`, this property holds the total amount
* How much of the budget has been used. If the limit is `spend`, this property holds the total amount
* discounted so far. If the limit is `usage`, it holds the number of times the campaign's
* promotions have been used so far.
*/
used: number
/**
* The budget's attribute if type is `use_by_attribute`.
*/
attribute: string
}
created_at: string
updated_at: string
@@ -1,9 +1,14 @@
import { BaseFilterable } from "../../dal"
import { CampaignBudgetUsageDTO } from "./campaing-budget-usage"
/**
* The campaign budget's possible types.
*/
export type CampaignBudgetTypeValues = "spend" | "usage"
export type CampaignBudgetTypeValues =
| "spend"
| "usage"
| "use_by_attribute"
| "spend_by_attribute"
/**
* The campaign budget details.
@@ -19,6 +24,8 @@ export interface CampaignBudgetDTO {
*
* - `spend` indicates that the budget is limited by the amount discounted by the promotions in the associated campaign.
* - `usage` indicates that the budget is limited by the number of times the promotions of the associated campaign have been used.
* - `use_by_attribute` indicates that the budget is limited by the number of times the promotions of the associated campaign have been used by a specific attribute value.
* - `spend_by_attribute` indicates that the budget is limited by the amount discounted by the promotions in the associated campaign by a specific attribute value.
*
*/
type?: CampaignBudgetTypeValues
@@ -41,6 +48,16 @@ export interface CampaignBudgetDTO {
* The currency of the campaign.
*/
currency_code?: string
/**
* The attribute of the campaign budget.
*/
attribute?: string
/**
* The usages of the campaign budget.
*/
usages?: CampaignBudgetUsageDTO[]
}
/**
@@ -0,0 +1,54 @@
/**
* The context passed when promotion use is registered, reverted or limit is checked.
*/
export type CampaignBudgetUsageContext = {
/**
* The ID of the customer.
*/
customer_id: string | null
/**
* The email of the customer.
*/
customer_email: string | null
}
/**
* Record of promotion usage as part of a campaign
*/
export interface CampaignBudgetUsageDTO {
/**
* The ID of the campaign budget usage.
*/
id: string
/**
* The value of the attribute that the promotion was used by.
* e.g. if budget campaign is defined on `email` as a useage attribute,
* `attribute_value` could contains email addresses
*/
attribute_value: string
/**
* The amount of times the promotion was used or
* the amount of money discounted by the promotion.
* Depends on the CampaignBudget type.
*/
used: number
/**
* The ID of the campaign budget.
*/
budget_id: string
/**
* The raw used value.
*/
raw_used: Record<string, any>
/**
* The date and time the campaign budget usage was created.
*/
created_at: string
/**
* The date and time the campaign budget usage was updated.
*/
updated_at: string
/**
* The date and time the campaign budget usage was deleted.
*/
deleted_at: string
}
@@ -15,7 +15,7 @@ export type ComputeActions =
*/
export type UsageComputedActions = {
/**
* The amount to remove off the shipping method's total.
* The amount (of usage or money) to adjust the campaign budget by.
*/
amount: BigNumberInput
@@ -242,6 +242,11 @@ export interface ComputeActionContext extends Record<string, unknown> {
*/
currency_code: string
/**
* The cart's email
*/
email?: string
/**
* The cart's line items.
*/
@@ -2,6 +2,7 @@ export * from "./application-method"
export * from "./campaign"
export * from "./campaign-budget"
export * from "./compute-actions"
export * from "./campaing-budget-usage"
export * from "./promotion"
export * from "./promotion-rule"
export * from "./promotion-rule-value"
@@ -23,6 +23,11 @@ export interface CreateCampaignBudgetDTO {
* The currency of the campaign.
*/
currency_code?: string | null
/**
* The attribute by which the campaign budget usage is limited.
*/
attribute?: string | null
}
/**
+33 -24
View File
@@ -4,6 +4,7 @@ import { IModuleService } from "../modules-sdk"
import { Context } from "../shared-context"
import {
CampaignDTO,
CampaignBudgetUsageContext,
ComputeActionContext,
ComputeActions,
CreatePromotionDTO,
@@ -34,6 +35,7 @@ export interface IPromotionModuleService extends IModuleService {
* computed actions.
*
* @param {UsageComputedActions[]} computedActions - The computed actions to adjust their promotion's campaign budget.
* @param {CampaignBudgetUsageContext} registrationContext - The context of the campaign budget usage.
* @returns {Promise<void>} Resolves when the campaign budgets have been adjusted successfully.
*
* @example
@@ -48,13 +50,17 @@ export interface IPromotionModuleService extends IModuleService {
* },
* ])
*/
registerUsage(computedActions: UsageComputedActions[]): Promise<void>
registerUsage(
computedActions: UsageComputedActions[],
registrationContext: CampaignBudgetUsageContext
): Promise<void>
/**
* This method is used to revert the changes made by registerUsage action
*
* @param {UsageComputedActions[]} computedActions - The computed actions to adjust their promotion's campaign budget.
* @returns {Promise<void>} Resolves when the campaign budgets have been adjusted successfully.
* @param {CampaignBudgetUsageContext} registrationContext - The context of the campaign budget usage.
* @returns {Promise<void>} Resolves when the campaign budgets have been reverted successfully.
*
* @example
* await promotionModuleService.revertUsage([
@@ -68,7 +74,10 @@ export interface IPromotionModuleService extends IModuleService {
* },
* ])
*/
revertUsage(computedActions: UsageComputedActions[]): Promise<void>
revertUsage(
computedActions: UsageComputedActions[],
registrationContext: CampaignBudgetUsageContext
): Promise<void>
/**
* This method provides the actions to perform on a cart based on the specified promotions
@@ -276,12 +285,12 @@ export interface IPromotionModuleService extends IModuleService {
* ```
*
* To specify relations that should be retrieved within the promotions:
*
*
* :::note
*
*
* You can only retrieve data models defined in the same module. To retrieve linked data models
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
*
*
* :::
*
* ```ts
@@ -336,12 +345,12 @@ export interface IPromotionModuleService extends IModuleService {
* ```
*
* To specify relations that should be retrieved within the promotions:
*
*
* :::note
*
*
* You can only retrieve data models defined in the same module. To retrieve linked data models
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
*
*
* :::
*
* ```ts
@@ -396,12 +405,12 @@ export interface IPromotionModuleService extends IModuleService {
* ```
*
* To specify relations that should be retrieved:
*
*
* :::note
*
*
* You can only retrieve data models defined in the same module. To retrieve linked data models
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
*
*
* :::
*
* ```ts
@@ -744,12 +753,12 @@ export interface IPromotionModuleService extends IModuleService {
* ```
*
* To specify relations that should be retrieved within the promotion rules:
*
*
* :::note
*
*
* You can only retrieve data models defined in the same module. To retrieve linked data models
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
*
*
* :::
*
* ```ts
@@ -826,12 +835,12 @@ export interface IPromotionModuleService extends IModuleService {
* ```
*
* To specify relations that should be retrieved within the campaigns:
*
*
* :::note
*
*
* You can only retrieve data models defined in the same module. To retrieve linked data models
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
*
*
* :::
*
* ```ts
@@ -886,12 +895,12 @@ export interface IPromotionModuleService extends IModuleService {
* ```
*
* To specify relations that should be retrieved within the campaigns:
*
*
* :::note
*
*
* You can only retrieve data models defined in the same module. To retrieve linked data models
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
*
*
* :::
*
* ```ts
@@ -946,12 +955,12 @@ export interface IPromotionModuleService extends IModuleService {
* ```
*
* To specify relations that should be retrieved:
*
*
* :::note
*
*
* You can only retrieve data models defined in the same module. To retrieve linked data models
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
*
*
* :::
*
* ```ts
@@ -38,6 +38,8 @@ export enum PromotionRuleOperator {
export enum CampaignBudgetType {
SPEND = "spend",
USAGE = "usage",
USE_BY_ATTRIBUTE = "use_by_attribute",
SPEND_BY_ATTRIBUTE = "spend_by_attribute",
}
export enum ComputedActions {
@@ -1,5 +1,8 @@
import { BigNumberInput } from "@medusajs/types"
import { ApplicationMethodAllocation, ApplicationMethodType, } from "../../promotion"
import {
ApplicationMethodAllocation,
ApplicationMethodType,
} from "../../promotion"
import { MathBN } from "../math"
import { MEDUSA_EPSILON } from "../big-number"