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:
@@ -2104,6 +2104,220 @@ medusaIntegrationTestRunner({
|
||||
)
|
||||
})
|
||||
|
||||
it("should fail to complete a cart if that would exceed the promotion limit", async () => {
|
||||
const product = (
|
||||
await api.post(
|
||||
`/admin/products`,
|
||||
{
|
||||
status: ProductStatus.PUBLISHED,
|
||||
title: "Product for camapign",
|
||||
description: "test",
|
||||
options: [
|
||||
{
|
||||
title: "Type",
|
||||
values: ["L"],
|
||||
},
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
title: "L",
|
||||
sku: "campaign-product-l",
|
||||
options: {
|
||||
Type: "L",
|
||||
},
|
||||
manage_inventory: false,
|
||||
prices: [
|
||||
{
|
||||
amount: 300,
|
||||
currency_code: "usd",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
).data.product
|
||||
|
||||
const campaign = (
|
||||
await api.post(
|
||||
`/admin/campaigns`,
|
||||
{
|
||||
name: "TEST-1",
|
||||
budget: {
|
||||
type: "spend",
|
||||
currency_code: "usd",
|
||||
limit: 100, // -> promotions value can't exceed 100$
|
||||
},
|
||||
campaign_identifier: "PROMO_CAMPAIGN",
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
).data.campaign
|
||||
|
||||
const promotion = (
|
||||
await api
|
||||
.post(
|
||||
`/admin/promotions`,
|
||||
{
|
||||
code: "TEST_PROMO",
|
||||
type: PromotionType.STANDARD,
|
||||
status: PromotionStatus.ACTIVE,
|
||||
is_automatic: false,
|
||||
is_tax_inclusive: true,
|
||||
application_method: {
|
||||
target_type: "items",
|
||||
type: "fixed",
|
||||
allocation: "across",
|
||||
currency_code: "usd",
|
||||
value: 100, // -> promotion applies 100$ fixed discount on the entire order
|
||||
},
|
||||
campaign_id: campaign.id,
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
.catch((e) => console.log(e))
|
||||
).data.promotion
|
||||
|
||||
const cart1 = (
|
||||
await api.post(
|
||||
`/store/carts`,
|
||||
{
|
||||
currency_code: "usd",
|
||||
sales_channel_id: salesChannel.id,
|
||||
region_id: region.id,
|
||||
shipping_address: shippingAddressData,
|
||||
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
|
||||
promo_codes: [promotion.code],
|
||||
},
|
||||
storeHeadersWithCustomer
|
||||
)
|
||||
).data.cart
|
||||
|
||||
expect(cart1).toEqual(
|
||||
expect.objectContaining({
|
||||
promotions: [
|
||||
expect.objectContaining({
|
||||
code: promotion.code,
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
const cart2 = (
|
||||
await api.post(
|
||||
`/store/carts`,
|
||||
{
|
||||
currency_code: "usd",
|
||||
sales_channel_id: salesChannel.id,
|
||||
region_id: region.id,
|
||||
shipping_address: shippingAddressData,
|
||||
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
|
||||
promo_codes: [promotion.code],
|
||||
},
|
||||
storeHeadersWithCustomer
|
||||
)
|
||||
).data.cart
|
||||
|
||||
expect(cart2).toEqual(
|
||||
expect.objectContaining({
|
||||
promotions: [
|
||||
expect.objectContaining({
|
||||
code: promotion.code,
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* At this point both carts have the same promotion applied successfully
|
||||
*/
|
||||
|
||||
const paymentCollection1 = (
|
||||
await api.post(
|
||||
`/store/payment-collections`,
|
||||
{ cart_id: cart1.id },
|
||||
storeHeaders
|
||||
)
|
||||
).data.payment_collection
|
||||
|
||||
await api.post(
|
||||
`/store/payment-collections/${paymentCollection1.id}/payment-sessions`,
|
||||
{ provider_id: "pp_system_default" },
|
||||
storeHeaders
|
||||
)
|
||||
|
||||
const order1 = (
|
||||
await api.post(
|
||||
`/store/carts/${cart1.id}/complete`,
|
||||
{},
|
||||
storeHeaders
|
||||
)
|
||||
).data.order
|
||||
|
||||
expect(order1).toEqual(
|
||||
expect.objectContaining({ discount_total: 100 })
|
||||
)
|
||||
|
||||
let campaignAfter = (
|
||||
await api.get(
|
||||
`/admin/campaigns/${campaign.id}?fields=budget.*`,
|
||||
adminHeaders
|
||||
)
|
||||
).data.campaign
|
||||
|
||||
expect(campaignAfter).toEqual(
|
||||
expect.objectContaining({
|
||||
budget: expect.objectContaining({
|
||||
used: 100,
|
||||
limit: 100,
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
const paymentCollection2 = (
|
||||
await api.post(
|
||||
`/store/payment-collections`,
|
||||
{ cart_id: cart2.id },
|
||||
storeHeaders
|
||||
)
|
||||
).data.payment_collection
|
||||
|
||||
await api.post(
|
||||
`/store/payment-collections/${paymentCollection2.id}/payment-sessions`,
|
||||
{ provider_id: "pp_system_default" },
|
||||
storeHeaders
|
||||
)
|
||||
|
||||
const response2 = await api
|
||||
.post(`/store/carts/${cart2.id}/complete`, {}, storeHeaders)
|
||||
.catch((e) => e)
|
||||
|
||||
expect(response2.response.status).toEqual(400)
|
||||
expect(response2.response.data).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "not_allowed",
|
||||
message: "Promotion usage exceeds the budget limit.",
|
||||
})
|
||||
)
|
||||
|
||||
campaignAfter = (
|
||||
await api.get(
|
||||
`/admin/campaigns/${campaign.id}?fields=budget.*`,
|
||||
adminHeaders
|
||||
)
|
||||
).data.campaign
|
||||
|
||||
expect(campaignAfter).toEqual(
|
||||
expect.objectContaining({
|
||||
budget: expect.objectContaining({
|
||||
used: 100,
|
||||
limit: 100,
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("should successfully complete cart without shipping for digital products", async () => {
|
||||
/**
|
||||
* Product has a shipping profile so cart item should not require shipping
|
||||
|
||||
@@ -787,6 +787,527 @@ medusaIntegrationTestRunner({
|
||||
)
|
||||
})
|
||||
|
||||
it("should limit usage of promotion per email attribute as defined in campaign budget", async () => {
|
||||
const publishableKey = await generatePublishableKey(appContainer)
|
||||
const storeHeaders = generateStoreHeaders({ publishableKey })
|
||||
|
||||
const salesChannel = (
|
||||
await api.post(
|
||||
"/admin/sales-channels",
|
||||
{ name: "Webshop", description: "channel" },
|
||||
adminHeaders
|
||||
)
|
||||
).data.sales_channel
|
||||
|
||||
const region = (
|
||||
await api.post(
|
||||
"/admin/regions",
|
||||
{ name: "US", currency_code: "usd", countries: ["us"] },
|
||||
adminHeaders
|
||||
)
|
||||
).data.region
|
||||
|
||||
const product = (
|
||||
await api.post(
|
||||
"/admin/products",
|
||||
{
|
||||
...medusaTshirtProduct,
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
).data.product
|
||||
|
||||
const campaign = (
|
||||
await api.post(
|
||||
`/admin/campaigns`,
|
||||
{
|
||||
name: "TEST",
|
||||
budget: {
|
||||
type: "use_by_attribute",
|
||||
limit: 2,
|
||||
attribute: "customer_email",
|
||||
},
|
||||
campaign_identifier: "PROMO_CAMPAIGN",
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
).data.campaign
|
||||
|
||||
const response = await api.post(
|
||||
`/admin/promotions`,
|
||||
{
|
||||
code: "TEST_PROMO",
|
||||
type: PromotionType.STANDARD,
|
||||
status: PromotionStatus.ACTIVE,
|
||||
is_automatic: false,
|
||||
application_method: {
|
||||
target_type: "items",
|
||||
type: "fixed",
|
||||
allocation: "each",
|
||||
currency_code: "usd",
|
||||
value: 100,
|
||||
max_quantity: 100,
|
||||
},
|
||||
campaign_id: campaign.id,
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
let cart = (
|
||||
await api.post(
|
||||
`/store/carts`,
|
||||
{
|
||||
currency_code: "usd",
|
||||
sales_channel_id: salesChannel.id,
|
||||
region_id: region.id,
|
||||
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
|
||||
email: "canusethistwice@test.com",
|
||||
promo_codes: [response.data.promotion.code],
|
||||
},
|
||||
storeHeaders
|
||||
)
|
||||
).data.cart
|
||||
|
||||
expect(cart).toEqual(
|
||||
expect.objectContaining({
|
||||
promotions: [
|
||||
expect.objectContaining({
|
||||
code: response.data.promotion.code,
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
let promotionCampaign = (
|
||||
await api.get(
|
||||
`/admin/campaigns/${campaign.id}?fields=budget.*,budget.usages.*`,
|
||||
adminHeaders
|
||||
)
|
||||
).data.campaign
|
||||
|
||||
expect(promotionCampaign).toEqual(
|
||||
expect.objectContaining({
|
||||
budget: expect.objectContaining({
|
||||
used: 0,
|
||||
limit: 2,
|
||||
attribute: "customer_email",
|
||||
type: "use_by_attribute",
|
||||
usages: [],
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
let paymentCollection = (
|
||||
await api.post(
|
||||
`/store/payment-collections`,
|
||||
{ cart_id: cart.id },
|
||||
storeHeaders
|
||||
)
|
||||
).data.payment_collection
|
||||
|
||||
await api.post(
|
||||
`/store/payment-collections/${paymentCollection.id}/payment-sessions`,
|
||||
{ provider_id: "pp_system_default" },
|
||||
storeHeaders
|
||||
)
|
||||
|
||||
await api.post(`/store/carts/${cart.id}/complete`, {}, storeHeaders)
|
||||
|
||||
cart = (
|
||||
await api.post(
|
||||
`/store/carts`,
|
||||
{
|
||||
currency_code: "usd",
|
||||
sales_channel_id: salesChannel.id,
|
||||
region_id: region.id,
|
||||
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
|
||||
email: "canusethistwice@test.com",
|
||||
promo_codes: [response.data.promotion.code],
|
||||
},
|
||||
storeHeaders
|
||||
)
|
||||
).data.cart
|
||||
|
||||
expect(cart).toEqual(
|
||||
expect.objectContaining({
|
||||
promotions: [
|
||||
expect.objectContaining({
|
||||
code: response.data.promotion.code,
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
promotionCampaign = (
|
||||
await api.get(
|
||||
`/admin/campaigns/${campaign.id}?fields=budget.*,budget.usages.*`,
|
||||
adminHeaders
|
||||
)
|
||||
).data.campaign
|
||||
|
||||
expect(promotionCampaign).toEqual(
|
||||
expect.objectContaining({
|
||||
budget: expect.objectContaining({
|
||||
used: 1,
|
||||
limit: 2,
|
||||
attribute: "customer_email",
|
||||
type: "use_by_attribute",
|
||||
usages: [
|
||||
// usage recorder after first complete
|
||||
expect.objectContaining({
|
||||
attribute_value: "canusethistwice@test.com",
|
||||
used: 1,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
paymentCollection = (
|
||||
await api.post(
|
||||
`/store/payment-collections`,
|
||||
{ cart_id: cart.id },
|
||||
storeHeaders
|
||||
)
|
||||
).data.payment_collection
|
||||
|
||||
await api.post(
|
||||
`/store/payment-collections/${paymentCollection.id}/payment-sessions`,
|
||||
{ provider_id: "pp_system_default" },
|
||||
storeHeaders
|
||||
)
|
||||
|
||||
// complete for the second time
|
||||
await api.post(`/store/carts/${cart.id}/complete`, {}, storeHeaders)
|
||||
|
||||
promotionCampaign = (
|
||||
await api.get(
|
||||
`/admin/campaigns/${campaign.id}?fields=budget.*,budget.usages.*`,
|
||||
adminHeaders
|
||||
)
|
||||
).data.campaign
|
||||
|
||||
expect(promotionCampaign).toEqual(
|
||||
expect.objectContaining({
|
||||
budget: expect.objectContaining({
|
||||
used: 2,
|
||||
limit: 2,
|
||||
attribute: "customer_email",
|
||||
type: "use_by_attribute",
|
||||
usages: [
|
||||
expect.objectContaining({
|
||||
attribute_value: "canusethistwice@test.com",
|
||||
used: 2,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
cart = (
|
||||
await api.post(
|
||||
`/store/carts`,
|
||||
{
|
||||
currency_code: "usd",
|
||||
sales_channel_id: salesChannel.id,
|
||||
region_id: region.id,
|
||||
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
|
||||
email: "canusethistwice@test.com",
|
||||
},
|
||||
storeHeaders
|
||||
)
|
||||
).data.cart
|
||||
|
||||
cart = (
|
||||
await api.post(
|
||||
`/store/carts/${cart.id}`,
|
||||
{
|
||||
promo_codes: [response.data.promotion.code],
|
||||
},
|
||||
storeHeaders
|
||||
)
|
||||
).data.cart
|
||||
|
||||
expect(cart.promotions.length).toEqual(0) // prmotion is not applied
|
||||
|
||||
cart = (
|
||||
await api.post(
|
||||
`/store/carts/${cart.id}`,
|
||||
{
|
||||
email: "canuseit@test.com",
|
||||
promo_codes: [response.data.promotion.code],
|
||||
},
|
||||
storeHeaders
|
||||
)
|
||||
).data.cart
|
||||
|
||||
// promotion is successfully applied with different email
|
||||
expect(cart.promotions.length).toEqual(1)
|
||||
expect(cart.promotions[0].code).toEqual(
|
||||
response.data.promotion.code
|
||||
)
|
||||
})
|
||||
|
||||
it("should remove promotion after email is replaced by already used email for that promotion", async () => {
|
||||
const publishableKey = await generatePublishableKey(appContainer)
|
||||
const storeHeaders = generateStoreHeaders({ publishableKey })
|
||||
|
||||
const salesChannel = (
|
||||
await api.post(
|
||||
"/admin/sales-channels",
|
||||
{ name: "Webshop", description: "channel" },
|
||||
adminHeaders
|
||||
)
|
||||
).data.sales_channel
|
||||
|
||||
const region = (
|
||||
await api.post(
|
||||
"/admin/regions",
|
||||
{ name: "US", currency_code: "usd", countries: ["us"] },
|
||||
adminHeaders
|
||||
)
|
||||
).data.region
|
||||
|
||||
const product = (
|
||||
await api.post(
|
||||
"/admin/products",
|
||||
{
|
||||
...medusaTshirtProduct,
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
).data.product
|
||||
|
||||
const campaign = (
|
||||
await api.post(
|
||||
`/admin/campaigns`,
|
||||
{
|
||||
name: "TEST",
|
||||
budget: {
|
||||
type: "use_by_attribute",
|
||||
limit: 1,
|
||||
attribute: "customer_email",
|
||||
},
|
||||
campaign_identifier: "PROMO_CAMPAIGN",
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
).data.campaign
|
||||
|
||||
const response = await api.post(
|
||||
`/admin/promotions`,
|
||||
{
|
||||
code: "TEST_PROMO",
|
||||
type: PromotionType.STANDARD,
|
||||
status: PromotionStatus.ACTIVE,
|
||||
is_automatic: false,
|
||||
application_method: {
|
||||
target_type: "items",
|
||||
type: "fixed",
|
||||
allocation: "each",
|
||||
currency_code: "usd",
|
||||
value: 100,
|
||||
max_quantity: 100,
|
||||
},
|
||||
campaign_id: campaign.id,
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
let cart = (
|
||||
await api.post(
|
||||
`/store/carts`,
|
||||
{
|
||||
currency_code: "usd",
|
||||
sales_channel_id: salesChannel.id,
|
||||
region_id: region.id,
|
||||
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
|
||||
email: "canuseitonce@test.com",
|
||||
promo_codes: [response.data.promotion.code],
|
||||
},
|
||||
storeHeaders
|
||||
)
|
||||
).data.cart
|
||||
|
||||
expect(cart).toEqual(
|
||||
expect.objectContaining({
|
||||
promotions: [
|
||||
expect.objectContaining({
|
||||
code: response.data.promotion.code,
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
let promotionCampaign = (
|
||||
await api.get(
|
||||
`/admin/campaigns/${campaign.id}?fields=budget.*,budget.usages.*`,
|
||||
adminHeaders
|
||||
)
|
||||
).data.campaign
|
||||
|
||||
expect(promotionCampaign).toEqual(
|
||||
expect.objectContaining({
|
||||
budget: expect.objectContaining({
|
||||
used: 0,
|
||||
limit: 1,
|
||||
attribute: "customer_email",
|
||||
type: "use_by_attribute",
|
||||
usages: [],
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
let paymentCollection = (
|
||||
await api.post(
|
||||
`/store/payment-collections`,
|
||||
{ cart_id: cart.id },
|
||||
storeHeaders
|
||||
)
|
||||
).data.payment_collection
|
||||
|
||||
await api.post(
|
||||
`/store/payment-collections/${paymentCollection.id}/payment-sessions`,
|
||||
{ provider_id: "pp_system_default" },
|
||||
storeHeaders
|
||||
)
|
||||
|
||||
await api.post(`/store/carts/${cart.id}/complete`, {}, storeHeaders)
|
||||
|
||||
cart = (
|
||||
await api.post(
|
||||
`/store/carts`,
|
||||
{
|
||||
currency_code: "usd",
|
||||
sales_channel_id: salesChannel.id,
|
||||
region_id: region.id,
|
||||
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
|
||||
promo_codes: [response.data.promotion.code],
|
||||
email: "fakeemail@test.com",
|
||||
},
|
||||
storeHeaders
|
||||
)
|
||||
).data.cart
|
||||
|
||||
expect(cart).toEqual(
|
||||
expect.objectContaining({
|
||||
promotions: [
|
||||
expect.objectContaining({
|
||||
code: response.data.promotion.code,
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
cart = (
|
||||
await api.post(
|
||||
`/store/carts/${cart.id}`,
|
||||
{
|
||||
email: "canuseitonce@test.com",
|
||||
},
|
||||
storeHeaders
|
||||
)
|
||||
).data.cart
|
||||
|
||||
expect(cart.promotions.length).toEqual(0) // prmotion is removed
|
||||
})
|
||||
|
||||
it("should throw if email is not provided when campaign budget type is use_by_attribute", async () => {
|
||||
const publishableKey = await generatePublishableKey(appContainer)
|
||||
const storeHeaders = generateStoreHeaders({ publishableKey })
|
||||
|
||||
const salesChannel = (
|
||||
await api.post(
|
||||
"/admin/sales-channels",
|
||||
{ name: "Webshop", description: "channel" },
|
||||
adminHeaders
|
||||
)
|
||||
).data.sales_channel
|
||||
|
||||
const region = (
|
||||
await api.post(
|
||||
"/admin/regions",
|
||||
{ name: "US", currency_code: "usd", countries: ["us"] },
|
||||
adminHeaders
|
||||
)
|
||||
).data.region
|
||||
|
||||
const product = (
|
||||
await api.post(
|
||||
"/admin/products",
|
||||
{
|
||||
...medusaTshirtProduct,
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
).data.product
|
||||
|
||||
const campaign = (
|
||||
await api.post(
|
||||
`/admin/campaigns`,
|
||||
{
|
||||
name: "TEST",
|
||||
budget: {
|
||||
type: "use_by_attribute",
|
||||
limit: 1,
|
||||
attribute: "customer_email",
|
||||
},
|
||||
campaign_identifier: "PROMO_CAMPAIGN",
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
).data.campaign
|
||||
|
||||
const response = await api.post(
|
||||
`/admin/promotions`,
|
||||
{
|
||||
code: "TEST_PROMO",
|
||||
type: PromotionType.STANDARD,
|
||||
status: PromotionStatus.ACTIVE,
|
||||
is_automatic: false,
|
||||
application_method: {
|
||||
target_type: "items",
|
||||
type: "fixed",
|
||||
allocation: "each",
|
||||
currency_code: "usd",
|
||||
value: 100,
|
||||
max_quantity: 100,
|
||||
},
|
||||
campaign_id: campaign.id,
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
let cart = (
|
||||
await api.post(
|
||||
`/store/carts`,
|
||||
{
|
||||
currency_code: "usd",
|
||||
sales_channel_id: salesChannel.id,
|
||||
region_id: region.id,
|
||||
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
|
||||
},
|
||||
storeHeaders
|
||||
)
|
||||
).data.cart
|
||||
|
||||
const err = await api
|
||||
.post(
|
||||
`/store/carts/${cart.id}`,
|
||||
{
|
||||
promo_codes: [response.data.promotion.code],
|
||||
},
|
||||
storeHeaders
|
||||
)
|
||||
.catch((e) => e)
|
||||
|
||||
expect(err.response.status).toEqual(400)
|
||||
expect(err.response.data).toEqual({
|
||||
type: "invalid_data",
|
||||
message: `Attribute value for "customer_email" is required by promotion campaing budget`,
|
||||
})
|
||||
})
|
||||
|
||||
it("should add promotion and remove it from cart using update", async () => {
|
||||
const publishableKey = await generatePublishableKey(appContainer)
|
||||
const storeHeaders = generateStoreHeaders({ publishableKey })
|
||||
|
||||
Reference in New Issue
Block a user