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:
@@ -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": {
|
||||
|
||||
+7
-2
@@ -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,
|
||||
},
|
||||
|
||||
+5
-1
@@ -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>
|
||||
|
||||
|
||||
+50
-1
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
+7
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user