Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

disallow applying same discount code to a cart more than once #6482

Closed
bayareacoder opened this issue Nov 2, 2020 · 11 comments · May be fixed by reactioncommerce/api-plugin-discounts-codes#26

Comments

@bayareacoder
Copy link

bayareacoder commented Nov 2, 2020

It seems there is no protection against a customer applying the same discount code more than once to a cart. So he can receive the same discount multiple times...

The discount conditions are being checked against past transactions that applied the discount from discount.transactions in function applyDiscountCodeToCart:

  // existing usage count
  if (discount.transactions) {
    const users = Array.from(discount.transactions, (trans) => trans.userId);
    const transactionCount = new Map([...new Set(users)].map((userX) => [userX, users.filter((userY) => userY === userX).length]));
    const orders = Array.from(discount.transactions, (trans) => trans.cartId);
    userCount = transactionCount.get(userId);
    orderCount = orders.length;
  }
  // check limits
  if (conditions) {
    if (conditions.accountLimit) accountLimitExceeded = conditions.accountLimit <= userCount;
    if (conditions.redemptionLimit) discountLimitExceeded = conditions.redemptionLimit <= orderCount;
  }

  // validate basic limit handling
  if (accountLimitExceeded === true || discountLimitExceeded === true) {
    throw new ReactionError("error-occurred", "Code is expired");
  }

However discount.transactions is only updated after an order is placed (from afterOrderCreate event) in this plugin's startup code:

  appEvents.on("afterOrderCreate", async ({ order }) => {
    if (Array.isArray(order.discounts)) {
      await Promise.all(order.discounts.map(async (orderDiscount) => {
        const { discountId } = orderDiscount;
        const transaction = {
          appliedAt: new Date(),
          cartId: order.cartId,
          userId: order.accountId
        };

        await Discounts.updateOne({ _id: discountId }, { $addToSet: { transactions: transaction } });
      }));
    }
  });

Thus this does not prevent applying a discount code multiple times before order is placed even if accountLimit would be set to 1. It would only prevent it being used on a next order.

Solution: add a check in function applyDiscountCodeToCart to check if the same discountId is already present in cart.billing[]. If so, do not add it again.

@karaarda
Copy link

Adding the transaction after the order is created is not a good option. It leads to an exploit were more than one user adds the discount code to their carts exceeding the usage limits of the discount. There needs to be another check then placing the order to prevent this exploit, which leads to a more complex code base and a confusing experience for the users.

Transaction should be added when the discount is applied to the cart as follows:

  const transaction = { cartId, userId, appliedAt };

  // Instead of directly updating cart, we add the discount billing
  // object from the existing cart, then pass to `saveCart`
  // to re-run cart through all transforms and validations.
  const savedCart = await context.mutations.saveCart(context, cart);
  await Discounts.updateOne({
    _id: discount._id
  }, {
    $addToSet: {
      transactions: transaction
    }
  });

and it needs to be removed when the discount code is removed from the cart as follows:

  const transactionToRemove = discount.transactions.filter((transaction) => transaction.cartId === cartId);

  await Discounts.updateOne({
    _id: discountId,
    shopId
  }, {
    $pull: {
      transactions: transactionToRemove
    }
  });

and the afterOrderCreate event should be removed.

I didn't test the code yet so it might have problems. @bayareacoder let me know if there are any problems.

@bayareacoder
Copy link
Author

bayareacoder commented Nov 21, 2020

"Adding the transaction after the order is created is not a good option. It leads to an exploit were more than one user adds the discount code to their carts exceeding the usage limits of the discount."

I'm not sure I understand. The current implementation checks, at the time of applying a discount code to a cart, that the limits are not exceeded, based on actually placed orders. It is only when the order is placed that counts as a 'usage' of the discount. Otherwise abandoned shopping carts that apply the code would also be counted towards the limits which doesn't make sense. Since discounts and orders are in separate plugins, the only way to communicate between both is via app events and it happens immediately after order is placed. This seems all correct to me. The issue I mentioned is that there is no check currently for the same user applying the discount more than once to the same cart/order.

@karaarda
Copy link

Yes, I understood the issue you mentioned. However; imagine that a discount code is limited to be used only once and it is added to four carts with success. If they try to place an order they will all be able to use the discount code because there is no check for that. Also, carts have an optional expire date; hence, when a cart is abandoned it shouldn't be counted towards the limit once it expires.

@bayareacoder
Copy link
Author

bayareacoder commented Nov 21, 2020

OK. In the interest of avoiding too much complexity I think the current solution is good enough for typical scenarios since code is only entered at the end of the checkout process. So the time between applying code to cart and placing order is short, so the chance of many customers entering it at same time is low.
It's also not likely you would promote a code that only can be used by 1 or a few customers (would lead to frustration with many shoppers who cannot use it). More likely you'll have something for the 'first 100' customers, and then it doesn't matter much if it's used 100 or 101,102 times. Checking again at the time of placing the order can lead to bad UX (can enter code but then not use it when placing order) and is probably not worth the customer frustration vs having the code used a few times more.
But I agree in a "rush sale" scenario, when everyone could be ordering at the same time, it could lead to significant more usage.

@karaarda
Copy link

Yes, I was addressing to that bad UX, hence adding the usage at the time the code is used is better I believe but it is a matter of taste. However, customers does not have to place an order right after using the code, they can come back later to make the purchase, or they can continue adding new items however they please. Nevertheless; we have implemented our own discount plugin as it is not certain when they will get back to developing reaction.

@bayareacoder
Copy link
Author

Just curious... how do you detect an abandoned shopping cart to remove the discount transaction if you record a discount transaction as soon as they apply the discount to their cart? What if they just close their browser prior to placing order?

@karaarda
Copy link

As I have mentioned there is an expiration date on the cart objects. If the cart is not used to place an order before the expiration date, then you can assume that the discount is not used.

@bayareacoder
Copy link
Author

bayareacoder commented Nov 22, 2020

I get that but the cart is deleted by the server code of the storefront at expiration. Haven't looked into it but i assume it's just a server cookie that expires.
How do you go from that to removing the discount transaction on Reaction, at the time of expiration?

@karaarda
Copy link

We didn't implement that part yet as we are not concerned with that case as much as you however you can follow several approaches. I am not sure what purpose cart expiration date serves, it might be only used for anonymous carts but you can easily tweak it to use for other purposes. As workarounds you can add an event for cart expiration, you can filter the transactions whose cart are expired and has no related order placed etc.. Let me know if you have other questions, we can discuss in private on another platform.

@bayareacoder
Copy link
Author

bayareacoder commented Nov 22, 2020

Ok. So your proposal is not really fully worked out yet. While the current approach may undercount usage (allow some more usage), your proposal will overcount since it will include any abandoned cart. No need to discuss more as we'll stay with the current implementation. Thank you for the clarification.

@brent-hoover
Copy link
Collaborator

Closing this issue as the existing discount code implementation is being deprecated and replaced with a new implementation which should take this into account.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants