Skip to content

Commit 008d415

Browse files
authored
fix(dashboard): don't mark promotions with per-attribute campaign budgets as expired (#16018)
## What Stop the dashboard from showing a red "Campaign expired" badge on active promotions whose campaign uses a per-attribute budget (`use_by_attribute` / `spend_by_attribute`). Fixes #15960 ## Why `getPromotionStatus` compares `campaignBudget.used > campaignBudget.limit` without looking at the budget type. For per-attribute budgets, `used` is the aggregate across all attribute values (e.g. every customer email) while `limit` applies to each attribute value individually, so the comparison is meaningless — as soon as more distinct customers than the per-customer limit use the promotion, the list and detail views flag it as expired even though the backend keeps redeeming it correctly. Display-only bug, exactly as the reporter diagnosed. ## How Skip the over-budget check when the budget type is `use_by_attribute` or `spend_by_attribute` (same string-literal comparison the dashboard already uses elsewhere, e.g. the campaign detail widgets). Global `spend`/`usage` budgets and the `ends_at` check behave exactly as before. ## Testing Added `src/lib/__tests__/promotions.spec.ts` covering: per-attribute budgets over the per-value "limit" stay active (both types — these two fail on current develop and pass with the fix), global `usage` budgets over the limit still show expired, and an ended campaign shows expired regardless of budget type. Ran with vitest in the dashboard workspace, plus eslint on the changed files. Changeset included (patch, `@medusajs/dashboard`).
1 parent af4ab2f commit 008d415

3 files changed

Lines changed: 93 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@medusajs/dashboard": patch
3+
---
4+
5+
fix(dashboard): don't mark promotions with per-attribute campaign budgets as expired
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { HttpTypes } from "@medusajs/types"
2+
import i18next from "i18next"
3+
import { describe, expect, it } from "vitest"
4+
5+
// promotionStatusMap resolves its labels through i18n.t at module load, so
6+
// i18next has to be initialized before the module under test is imported.
7+
await i18next.init({ lng: "en", fallbackLng: "en", resources: {} })
8+
9+
const { getPromotionStatus, promotionStatusMap, PromotionStatus } =
10+
await import("../promotions")
11+
12+
const buildPromotion = (
13+
campaign: Partial<HttpTypes.AdminCampaign>
14+
): HttpTypes.AdminPromotion =>
15+
({
16+
status: "active",
17+
campaign: {
18+
starts_at: null,
19+
ends_at: null,
20+
...campaign,
21+
},
22+
} as HttpTypes.AdminPromotion)
23+
24+
describe("getPromotionStatus", () => {
25+
it("should not mark a promotion as expired when a use_by_attribute budget's aggregate usage exceeds the per-attribute limit", () => {
26+
const promotion = buildPromotion({
27+
budget: {
28+
type: "use_by_attribute",
29+
limit: 1,
30+
used: 5,
31+
} as HttpTypes.AdminCampaign["budget"],
32+
})
33+
34+
expect(getPromotionStatus(promotion)).toEqual(
35+
promotionStatusMap[PromotionStatus.ACTIVE]
36+
)
37+
})
38+
39+
it("should not mark a promotion as expired when a spend_by_attribute budget's aggregate usage exceeds the per-attribute limit", () => {
40+
const promotion = buildPromotion({
41+
budget: {
42+
type: "spend_by_attribute",
43+
limit: 100,
44+
used: 500,
45+
} as HttpTypes.AdminCampaign["budget"],
46+
})
47+
48+
expect(getPromotionStatus(promotion)).toEqual(
49+
promotionStatusMap[PromotionStatus.ACTIVE]
50+
)
51+
})
52+
53+
it("should mark a promotion as expired when a global budget is exceeded", () => {
54+
const promotion = buildPromotion({
55+
budget: {
56+
type: "usage",
57+
limit: 1,
58+
used: 5,
59+
} as HttpTypes.AdminCampaign["budget"],
60+
})
61+
62+
expect(getPromotionStatus(promotion)).toEqual(
63+
promotionStatusMap[PromotionStatus.EXPIRED]
64+
)
65+
})
66+
67+
it("should mark a promotion as expired when its campaign has ended, regardless of budget type", () => {
68+
const promotion = buildPromotion({
69+
ends_at: "2020-01-01T00:00:00.000Z",
70+
budget: {
71+
type: "use_by_attribute",
72+
limit: 1,
73+
used: 0,
74+
} as HttpTypes.AdminCampaign["budget"],
75+
})
76+
77+
expect(getPromotionStatus(promotion)).toEqual(
78+
promotionStatusMap[PromotionStatus.EXPIRED]
79+
)
80+
})
81+
})

packages/admin/dashboard/src/lib/promotions.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,15 @@ export const getPromotionStatus = (promotion: HttpTypes.AdminPromotion) => {
4242
}
4343

4444
const campaignBudget = campaign.budget
45+
// For per-attribute budgets, `used` aggregates usage across all attribute
46+
// values while `limit` applies to each attribute value individually, so
47+
// comparing them cannot tell whether the budget is exhausted.
48+
const isPerAttributeBudget =
49+
campaignBudget?.type === "use_by_attribute" ||
50+
campaignBudget?.type === "spend_by_attribute"
4551
const overBudget =
4652
campaignBudget &&
53+
!isPerAttributeBudget &&
4754
campaignBudget.limit &&
4855
campaignBudget.used! > campaignBudget.limit!
4956

0 commit comments

Comments
 (0)