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 14:35:54 +02:00
committed by GitHub
parent 924564bee5
commit 7dc3b0c5ff
36 changed files with 2390 additions and 190 deletions
@@ -8371,9 +8371,30 @@
},
"used": {
"type": "string"
},
"budgetAttribute": {
"type": "string"
},
"budgetAttributeTooltip": {
"type": "string"
},
"limitBudgetAttributeCustomer": {
"type": "string"
},
"limitBudgetAttributeEmail": {
"type": "string"
}
},
"required": ["type", "currency", "limit", "used"],
"required": [
"type",
"currency",
"limit",
"used",
"budgetAttribute",
"budgetAttributeTooltip",
"limitBudgetAttributeCustomer",
"limitBudgetAttributeEmail"
],
"additionalProperties": false
},
"type": {
@@ -2204,7 +2204,7 @@
"delete": {
"title": "Are you sure?",
"description": "You are about to delete the campaign '{{name}}'. This action cannot be undone.",
"successToast": "Campaign '{{name}}' was successfully created."
"successToast": "Campaign '{{name}}' was successfully deleted."
},
"edit": {
"header": "Edit Campaign",
@@ -2248,7 +2248,11 @@
"type": "Type",
"currency": "Currency",
"limit": "Limit",
"used": "Used"
"used": "Used",
"budgetAttribute": "Limit usage per",
"budgetAttributeTooltip": "Define how many times the promotion can be used by a specific customer or email.",
"limitBudgetAttributeCustomer": "Budget limit per customer",
"limitBudgetAttributeEmail": "Budget limit per email"
},
"type": {
"spend": {
@@ -21,8 +21,9 @@ export const CreateCampaignSchema = zod.object({
starts_at: zod.date().nullable(),
ends_at: zod.date().nullable(),
budget: zod.object({
attribute: zod.string().nullish(),
limit: zod.number().min(0).nullish(),
type: zod.enum(["spend", "usage"]),
type: zod.enum(["spend", "usage", "use_by_attribute"]),
currency_code: zod.string().nullish(),
}),
})
@@ -38,6 +39,9 @@ export const CreateCampaignForm = () => {
})
const handleSubmit = form.handleSubmit(async (data) => {
const attribute = data.budget.attribute || null
const type = attribute ? "use_by_attribute" : data.budget.type
await mutateAsync(
{
name: data.name,
@@ -46,7 +50,8 @@ export const CreateCampaignForm = () => {
starts_at: data.starts_at,
ends_at: data.ends_at,
budget: {
type: data.budget.type,
type,
attribute,
limit: data.budget.limit ? data.budget.limit : undefined,
currency_code: data.budget.currency_code,
},
@@ -25,7 +25,11 @@ export const CampaignBudget = ({ campaign }: CampaignBudgetProps) => {
className="text-ui-fg-subtle ms-10 mt-[1.5px] font-normal"
level="h3"
>
{t("campaigns.fields.budget_limit")}
{campaign.budget?.type === "use_by_attribute"
? campaign.budget?.attribute === "customer_id"
? t("campaigns.budget.fields.limitBudgetAttributeCustomer")
: t("campaigns.budget.fields.limitBudgetAttributeEmail")
: t("campaigns.fields.budget_limit")}
</Heading>
</div>
@@ -19,6 +19,7 @@ import {
currencies,
getCurrencySymbol,
} from "../../../../../lib/data/currencies"
import { Combobox } from "../../../../../components/inputs/combobox"
export const CreateCampaignFormFields = ({ form, fieldScope = "" }) => {
const { t } = useTranslation()
@@ -209,17 +210,19 @@ export const CreateCampaignFormFields = ({ form, fieldScope = "" }) => {
<Form.Control>
<RadioGroup
dir={direction}
className="flex gap-y-3"
className="flex gap-x-4 gap-y-3"
{...field}
onValueChange={field.onChange}
>
<RadioGroup.ChoiceBox
className="flex-1"
value={"usage"}
label={t("campaigns.budget.type.usage.title")}
description={t("campaigns.budget.type.usage.description")}
/>
<RadioGroup.ChoiceBox
className="flex-1"
value={"spend"}
label={t("campaigns.budget.type.spend.title")}
description={t("campaigns.budget.type.spend.description")}
@@ -342,6 +345,52 @@ export const CreateCampaignFormFields = ({ form, fieldScope = "" }) => {
)
}}
/>
{!isTypeSpend && (
<Form.Field
control={form.control}
name={`${fieldScope}budget.attribute`}
render={({ field }) => {
return (
<Form.Item className="basis-1/2">
<Form.Label
tooltip={t(
"campaigns.budget.fields.budgetAttributeTooltip"
)}
>
{t("campaigns.budget.fields.budgetAttribute")}
</Form.Label>
<Form.Control>
<Combobox
key="attribute"
{...field}
onChange={(e) => {
if (typeof e === "undefined") {
field.onChange(null)
} else {
field.onChange(e)
}
}}
allowClear
options={[
{
label: t("fields.customer"),
value: "customer_id",
},
{
label: t("fields.email"),
value: "customer_email",
},
]}
></Combobox>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
)}
</div>
</div>
)
@@ -10,5 +10,6 @@ export const DEFAULT_CAMPAIGN_VALUES = {
type: "usage" as CampaignBudgetTypeValues,
currency_code: null,
limit: null,
attribute: null,
},
}
@@ -139,6 +139,13 @@ export const CreatePromotionForm = () => {
}))
}
if (data.campaign) {
data.campaign.budget.attribute = data.campaign.budget.attribute || null
data.campaign.budget.type = data.campaign.budget.attribute
? "use_by_attribute"
: data.campaign.budget.type
}
createPromotion(
{
...promotionData,