Skip to content

Commit 7dc3b0c

Browse files
fPolicadrien2p
andauthored
feat(core-flows,dashboard,js-sdk,promotion,medusa,types,utils): limit promotion usage per customer (medusajs#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 <[email protected]>
1 parent 924564b commit 7dc3b0c

File tree

36 files changed

+2386
-186
lines changed

36 files changed

+2386
-186
lines changed

.changeset/curly-apples-kick.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@medusajs/promotion": patch
3+
"@medusajs/dashboard": patch
4+
"@medusajs/core-flows": patch
5+
"@medusajs/js-sdk": patch
6+
"@medusajs/types": patch
7+
"@medusajs/utils": patch
8+
"@medusajs/medusa": patch
9+
---
10+
11+
feat: support limiting promotion usage by attribute

integration-tests/http/__tests__/cart/store/cart.spec.ts

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2104,6 +2104,220 @@ medusaIntegrationTestRunner({
21042104
)
21052105
})
21062106

2107+
it("should fail to complete a cart if that would exceed the promotion limit", async () => {
2108+
const product = (
2109+
await api.post(
2110+
`/admin/products`,
2111+
{
2112+
status: ProductStatus.PUBLISHED,
2113+
title: "Product for camapign",
2114+
description: "test",
2115+
options: [
2116+
{
2117+
title: "Type",
2118+
values: ["L"],
2119+
},
2120+
],
2121+
variants: [
2122+
{
2123+
title: "L",
2124+
sku: "campaign-product-l",
2125+
options: {
2126+
Type: "L",
2127+
},
2128+
manage_inventory: false,
2129+
prices: [
2130+
{
2131+
amount: 300,
2132+
currency_code: "usd",
2133+
},
2134+
],
2135+
},
2136+
],
2137+
},
2138+
adminHeaders
2139+
)
2140+
).data.product
2141+
2142+
const campaign = (
2143+
await api.post(
2144+
`/admin/campaigns`,
2145+
{
2146+
name: "TEST-1",
2147+
budget: {
2148+
type: "spend",
2149+
currency_code: "usd",
2150+
limit: 100, // -> promotions value can't exceed 100$
2151+
},
2152+
campaign_identifier: "PROMO_CAMPAIGN",
2153+
},
2154+
adminHeaders
2155+
)
2156+
).data.campaign
2157+
2158+
const promotion = (
2159+
await api
2160+
.post(
2161+
`/admin/promotions`,
2162+
{
2163+
code: "TEST_PROMO",
2164+
type: PromotionType.STANDARD,
2165+
status: PromotionStatus.ACTIVE,
2166+
is_automatic: false,
2167+
is_tax_inclusive: true,
2168+
application_method: {
2169+
target_type: "items",
2170+
type: "fixed",
2171+
allocation: "across",
2172+
currency_code: "usd",
2173+
value: 100, // -> promotion applies 100$ fixed discount on the entire order
2174+
},
2175+
campaign_id: campaign.id,
2176+
},
2177+
adminHeaders
2178+
)
2179+
.catch((e) => console.log(e))
2180+
).data.promotion
2181+
2182+
const cart1 = (
2183+
await api.post(
2184+
`/store/carts`,
2185+
{
2186+
currency_code: "usd",
2187+
sales_channel_id: salesChannel.id,
2188+
region_id: region.id,
2189+
shipping_address: shippingAddressData,
2190+
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
2191+
promo_codes: [promotion.code],
2192+
},
2193+
storeHeadersWithCustomer
2194+
)
2195+
).data.cart
2196+
2197+
expect(cart1).toEqual(
2198+
expect.objectContaining({
2199+
promotions: [
2200+
expect.objectContaining({
2201+
code: promotion.code,
2202+
}),
2203+
],
2204+
})
2205+
)
2206+
2207+
const cart2 = (
2208+
await api.post(
2209+
`/store/carts`,
2210+
{
2211+
currency_code: "usd",
2212+
sales_channel_id: salesChannel.id,
2213+
region_id: region.id,
2214+
shipping_address: shippingAddressData,
2215+
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
2216+
promo_codes: [promotion.code],
2217+
},
2218+
storeHeadersWithCustomer
2219+
)
2220+
).data.cart
2221+
2222+
expect(cart2).toEqual(
2223+
expect.objectContaining({
2224+
promotions: [
2225+
expect.objectContaining({
2226+
code: promotion.code,
2227+
}),
2228+
],
2229+
})
2230+
)
2231+
2232+
/**
2233+
* At this point both carts have the same promotion applied successfully
2234+
*/
2235+
2236+
const paymentCollection1 = (
2237+
await api.post(
2238+
`/store/payment-collections`,
2239+
{ cart_id: cart1.id },
2240+
storeHeaders
2241+
)
2242+
).data.payment_collection
2243+
2244+
await api.post(
2245+
`/store/payment-collections/${paymentCollection1.id}/payment-sessions`,
2246+
{ provider_id: "pp_system_default" },
2247+
storeHeaders
2248+
)
2249+
2250+
const order1 = (
2251+
await api.post(
2252+
`/store/carts/${cart1.id}/complete`,
2253+
{},
2254+
storeHeaders
2255+
)
2256+
).data.order
2257+
2258+
expect(order1).toEqual(
2259+
expect.objectContaining({ discount_total: 100 })
2260+
)
2261+
2262+
let campaignAfter = (
2263+
await api.get(
2264+
`/admin/campaigns/${campaign.id}?fields=budget.*`,
2265+
adminHeaders
2266+
)
2267+
).data.campaign
2268+
2269+
expect(campaignAfter).toEqual(
2270+
expect.objectContaining({
2271+
budget: expect.objectContaining({
2272+
used: 100,
2273+
limit: 100,
2274+
}),
2275+
})
2276+
)
2277+
2278+
const paymentCollection2 = (
2279+
await api.post(
2280+
`/store/payment-collections`,
2281+
{ cart_id: cart2.id },
2282+
storeHeaders
2283+
)
2284+
).data.payment_collection
2285+
2286+
await api.post(
2287+
`/store/payment-collections/${paymentCollection2.id}/payment-sessions`,
2288+
{ provider_id: "pp_system_default" },
2289+
storeHeaders
2290+
)
2291+
2292+
const response2 = await api
2293+
.post(`/store/carts/${cart2.id}/complete`, {}, storeHeaders)
2294+
.catch((e) => e)
2295+
2296+
expect(response2.response.status).toEqual(400)
2297+
expect(response2.response.data).toEqual(
2298+
expect.objectContaining({
2299+
type: "not_allowed",
2300+
message: "Promotion usage exceeds the budget limit.",
2301+
})
2302+
)
2303+
2304+
campaignAfter = (
2305+
await api.get(
2306+
`/admin/campaigns/${campaign.id}?fields=budget.*`,
2307+
adminHeaders
2308+
)
2309+
).data.campaign
2310+
2311+
expect(campaignAfter).toEqual(
2312+
expect.objectContaining({
2313+
budget: expect.objectContaining({
2314+
used: 100,
2315+
limit: 100,
2316+
}),
2317+
})
2318+
)
2319+
})
2320+
21072321
it("should successfully complete cart without shipping for digital products", async () => {
21082322
/**
21092323
* Product has a shipping profile so cart item should not require shipping

0 commit comments

Comments
 (0)