From f65959b570d415ed62b890009b9633ab1ae95fc3 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Thu, 29 Dec 2022 16:02:24 +0700 Subject: [PATCH] feat: improve apply coupon mutation Signed-off-by: vanpho93 --- packages/api-plugin-orders/src/index.js | 2 + .../src/mutations/placeOrder.js | 6 + .../api-plugin-orders/src/registration.js | 25 ++ .../src/index.js | 26 +- .../src/mutations/applyCouponToCart.js | 67 +++-- .../src/mutations/applyCouponToCart.test.js | 238 +++++++++++++++++- .../mutations/createStandardCoupon.test.js | 28 +++ .../src/preStartup.js | 33 +++ .../resolvers/Mutation/applyCouponToCart.js | 14 +- .../Mutation/applyCouponToCart.test.js | 2 +- .../resolvers/Promotion/getPromotionCoupon.js | 13 + .../src/schemas/schema.graphql | 2 +- .../src/simpleSchemas.js | 29 +++ .../src/triggers/couponsTriggerHandler.js | 7 +- .../src/utils/updateOrderCoupon.js | 62 +++++ .../src/utils/updateOrderCoupon.test.js | 162 ++++++++++++ .../src/handlers/applyExplicitPromotion.js | 10 +- .../handlers/applyExplicitPromotion.test.js | 10 +- .../src/handlers/applyPromotions.js | 41 ++- .../src/handlers/applyPromotions.test.js | 63 ++++- 20 files changed, 785 insertions(+), 55 deletions(-) create mode 100644 packages/api-plugin-orders/src/registration.js create mode 100644 packages/api-plugin-promotions-coupons/src/preStartup.js create mode 100644 packages/api-plugin-promotions-coupons/src/resolvers/Promotion/getPromotionCoupon.js create mode 100644 packages/api-plugin-promotions-coupons/src/utils/updateOrderCoupon.js create mode 100644 packages/api-plugin-promotions-coupons/src/utils/updateOrderCoupon.test.js diff --git a/packages/api-plugin-orders/src/index.js b/packages/api-plugin-orders/src/index.js index 67ebd7cbdb3..987326e71ef 100644 --- a/packages/api-plugin-orders/src/index.js +++ b/packages/api-plugin-orders/src/index.js @@ -4,6 +4,7 @@ import mutations from "./mutations/index.js"; import policies from "./policies.json"; import preStartup from "./preStartup.js"; import queries from "./queries/index.js"; +import { registerPluginHandlerForOrder } from "./registration.js"; import resolvers from "./resolvers/index.js"; import schemas from "./schemas/index.js"; import { Order, OrderFulfillmentGroup, OrderItem, CommonOrder, SelectedFulfillmentOption } from "./simpleSchemas.js"; @@ -42,6 +43,7 @@ export default async function register(app) { } }, functionsByType: { + registerPluginHandler: [registerPluginHandlerForOrder], getDataForOrderEmail: [getDataForOrderEmail], preStartup: [preStartup], startup: [startup] diff --git a/packages/api-plugin-orders/src/mutations/placeOrder.js b/packages/api-plugin-orders/src/mutations/placeOrder.js index 60c0da78e8f..fa54e304e4b 100644 --- a/packages/api-plugin-orders/src/mutations/placeOrder.js +++ b/packages/api-plugin-orders/src/mutations/placeOrder.js @@ -7,6 +7,7 @@ import getAnonymousAccessToken from "@reactioncommerce/api-utils/getAnonymousAcc import buildOrderFulfillmentGroupFromInput from "../util/buildOrderFulfillmentGroupFromInput.js"; import verifyPaymentsMatchOrderTotal from "../util/verifyPaymentsMatchOrderTotal.js"; import { Order as OrderSchema, orderInputSchema, Payment as PaymentSchema, paymentInputSchema } from "../simpleSchemas.js"; +import { customOrderValidators } from "../registration.js"; const inputSchema = new SimpleSchema({ "order": orderInputSchema, @@ -286,6 +287,11 @@ export default async function placeOrder(context, input) { // Validate and save OrderSchema.validate(order); + + for (const customOrderValidateFunc of customOrderValidators) { + await customOrderValidateFunc.fn(context, order); // eslint-disable-line no-await-in-loop + } + await Orders.insertOne(order); await appEvents.emit("afterOrderCreate", { createdBy: userId, order }); diff --git a/packages/api-plugin-orders/src/registration.js b/packages/api-plugin-orders/src/registration.js new file mode 100644 index 00000000000..01c6075046e --- /dev/null +++ b/packages/api-plugin-orders/src/registration.js @@ -0,0 +1,25 @@ +import SimpleSchema from "simpl-schema"; + +const validatorSchema = new SimpleSchema({ + name: String, + fn: Function +}); + +// Objects with `name` and `fn` properties +export const customOrderValidators = []; + +/** + * @summary Will be called for every plugin + * @param {Object} options The options object that the plugin passed to registerPackage + * @returns {undefined} + */ +export function registerPluginHandlerForOrder({ name, order }) { + if (order) { + const { customValidators } = order; + + if (!Array.isArray(customValidators)) throw new Error(`In ${name} plugin registerPlugin object, order.customValidators must be an array`); + validatorSchema.validate(customValidators); + + customOrderValidators.push(...customValidators); + } +} diff --git a/packages/api-plugin-promotions-coupons/src/index.js b/packages/api-plugin-promotions-coupons/src/index.js index cd34c8f45e7..8d709799550 100644 --- a/packages/api-plugin-promotions-coupons/src/index.js +++ b/packages/api-plugin-promotions-coupons/src/index.js @@ -4,7 +4,9 @@ import mutations from "./mutations/index.js"; import queries from "./queries/index.js"; import resolvers from "./resolvers/index.js"; import triggers from "./triggers/index.js"; -import { Coupon } from "./simpleSchemas.js"; +import { Coupon, CouponLog } from "./simpleSchemas.js"; +import preStartupPromotionCoupon from "./preStartup.js"; +import updateOrderCoupon from "./utils/updateOrderCoupon.js"; const require = createRequire(import.meta.url); const pkg = require("../package.json"); @@ -26,8 +28,19 @@ export default async function register(app) { [{ shopId: 1, code: 1 }], [{ shopId: 1, promotionId: 1 }] ] + }, + CouponLogs: { + name: "CouponLogs", + indexes: [ + [{ couponId: 1 }], + [{ promotionId: 1 }], + [{ couponId: 1, accountId: 1 }, { unique: true }] + ] } }, + functionsByType: { + preStartup: [preStartupPromotionCoupon] + }, promotions: { triggers }, @@ -38,7 +51,16 @@ export default async function register(app) { mutations, queries, simpleSchemas: { - Coupon + Coupon, + CouponLog + }, + order: { + customValidators: [ + { + name: "updateOrderCoupon", + fn: updateOrderCoupon + } + ] } }); } diff --git a/packages/api-plugin-promotions-coupons/src/mutations/applyCouponToCart.js b/packages/api-plugin-promotions-coupons/src/mutations/applyCouponToCart.js index d1082751ffc..3c27b755a2c 100644 --- a/packages/api-plugin-promotions-coupons/src/mutations/applyCouponToCart.js +++ b/packages/api-plugin-promotions-coupons/src/mutations/applyCouponToCart.js @@ -3,7 +3,6 @@ import ReactionError from "@reactioncommerce/reaction-error"; import Logger from "@reactioncommerce/logger"; import hashToken from "@reactioncommerce/api-utils/hashToken.js"; import _ from "lodash"; -import isPromotionExpired from "../utils/isPromotionExpired.js"; const inputSchema = new SimpleSchema({ shopId: String, @@ -27,7 +26,7 @@ const inputSchema = new SimpleSchema({ export default async function applyCouponToCart(context, input) { inputSchema.validate(input); - const { collections: { Cart, Promotions, Accounts }, userId } = context; + const { collections: { Cart, Promotions, Accounts, Coupons, CouponLogs }, userId } = context; const { shopId, cartId, couponCode, cartToken } = input; const selector = { shopId }; @@ -42,8 +41,8 @@ export default async function applyCouponToCart(context, input) { const account = (userId && (await Accounts.findOne({ userId }))) || null; if (!account) { - Logger.error(`Cart not found for user with ID ${userId}`); - throw new ReactionError("not-found", "Cart not found"); + Logger.error(`Cart not found for user with ID ${account._id}`); + throw new ReactionError("invalid-params", "Cart not found"); } selector.accountId = account._id; @@ -52,33 +51,67 @@ export default async function applyCouponToCart(context, input) { const cart = await Cart.findOne(selector); if (!cart) { Logger.error(`Cart not found for user with ID ${userId}`); - throw new ReactionError("not-found", "Cart not found"); + throw new ReactionError("invalid-params", "Cart not found"); } const now = new Date(); + const coupons = await Coupons.find({ + code: couponCode, + $or: [ + { expirationDate: { $gte: now } }, + { expirationDate: null } + ] + }).toArray(); + if (coupons.length > 1) { + throw new ReactionError("invalid-params", "The coupon have duplicate with other promotion. Please contact admin for more information"); + } + + if (coupons.length === 0) { + Logger.error(`The coupon code ${couponCode} is not found`); + throw new ReactionError("invalid-params", `The coupon ${couponCode} is not found`); + } + + const coupon = coupons[0]; + + if (coupon.maxUsageTimes && coupon.maxUsageTimes > 0 && coupon.usedCount >= coupon.maxUsageTimes) { + Logger.error(`The coupon code ${couponCode} is expired`); + throw new ReactionError("invalid-params", "The coupon is expired"); + } + + if (coupon.maxUsageTimesPerUser && coupon.maxUsageTimesPerUser > 0) { + if (!userId) throw new ReactionError("invalid-params", "You must be logged in to apply this coupon"); + + const couponLog = await CouponLogs.findOne({ couponId: coupon._id, accountId: cart.accountId }); + if (couponLog && couponLog.usedCount >= coupon.maxUsageTimesPerUser) { + Logger.error(`The coupon code ${couponCode} has expired`); + throw new ReactionError("invalid-params", "The coupon is expired"); + } + } + const promotion = await Promotions.findOne({ + "_id": coupon.promotionId, shopId, "enabled": true, - "type": "explicit", - "startDate": { $lte: now }, - "triggers.triggerKey": "coupons", - "triggers.triggerParameters.couponCode": couponCode + "triggers.triggerKey": "coupons" }); if (!promotion) { Logger.error(`The promotion not found with coupon code ${couponCode}`); - throw new ReactionError("not-found", "The coupon is not available"); - } - - if (isPromotionExpired(promotion)) { - Logger.error(`The coupon code ${couponCode} is expired`); - throw new ReactionError("coupon-expired", "The coupon is expired"); + throw new ReactionError("invalid-params", "The coupon is not available"); } if (_.find(cart.appliedPromotions, { _id: promotion._id })) { Logger.error(`The coupon code ${couponCode} is already applied`); - throw new Error("coupon-already-exists", "The coupon already applied on the cart"); + throw new ReactionError("invalid-params", "The coupon already applied on the cart"); } - return context.mutations.applyExplicitPromotionToCart(context, cart, promotion); + const promotionWithCoupon = { + ...promotion, + relatedCoupon: { + couponCode, + couponId: coupon._id + } + }; + + return context.mutations.applyExplicitPromotionToCart(context, cart, promotionWithCoupon); } diff --git a/packages/api-plugin-promotions-coupons/src/mutations/applyCouponToCart.test.js b/packages/api-plugin-promotions-coupons/src/mutations/applyCouponToCart.test.js index 4c84067c095..5003dc71ad3 100644 --- a/packages/api-plugin-promotions-coupons/src/mutations/applyCouponToCart.test.js +++ b/packages/api-plugin-promotions-coupons/src/mutations/applyCouponToCart.test.js @@ -17,12 +17,22 @@ test("should call applyExplicitPromotionToCart mutation", async () => { type: "explicit", endDate: new Date(now.setMonth(now.getMonth() + 1)) }; + const coupon = { + _id: "couponId", + code: "CODE", + promotionId: "promotionId" + }; mockContext.collections.Cart = { findOne: jest.fn().mockResolvedValueOnce(cart) }; mockContext.collections.Promotions = { findOne: jest.fn().mockResolvedValueOnce(promotion) }; + mockContext.collections.Coupons = { + find: jest.fn().mockReturnValue({ + toArray: jest.fn().mockResolvedValueOnce([coupon]) + }) + }; mockContext.mutations.applyExplicitPromotionToCart = jest.fn().mockName("applyExplicitPromotionToCart").mockResolvedValueOnce(cart); await applyCouponToCart(mockContext, { @@ -32,7 +42,15 @@ test("should call applyExplicitPromotionToCart mutation", async () => { cartToken: "anonymousToken" }); - expect(mockContext.mutations.applyExplicitPromotionToCart).toHaveBeenCalledWith(mockContext, cart, promotion); + const expectedPromotion = { + ...promotion, + relatedCoupon: { + couponId: "couponId", + couponCode: "CODE" + } + }; + + expect(mockContext.mutations.applyExplicitPromotionToCart).toHaveBeenCalledWith(mockContext, cart, expectedPromotion); }); test("should throw error if cart not found", async () => { @@ -50,14 +68,23 @@ test("should throw error if cart not found", async () => { test("should throw error if promotion not found", async () => { const cart = { _id: "cartId" }; + const coupon = { + _id: "couponId", + code: "CODE", + promotionId: "promotionId" + }; mockContext.collections.Promotions = { findOne: jest.fn().mockResolvedValueOnce(undefined) }; - mockContext.collections.Cart = { findOne: jest.fn().mockResolvedValueOnce(cart) }; + mockContext.collections.Coupons = { + find: jest.fn().mockReturnValue({ + toArray: jest.fn().mockResolvedValueOnce([coupon]) + }) + }; const expectedError = new ReactionError("not-found", "The coupon is not available"); @@ -69,25 +96,129 @@ test("should throw error if promotion not found", async () => { })).rejects.toThrow(expectedError); }); +test("should throw error if coupon not found", async () => { + const cart = { _id: "cartId" }; + const promotion = { + _id: "promotionId", + type: "explicit" + }; + + mockContext.collections.Promotions = { + findOne: jest.fn().mockResolvedValueOnce(promotion) + }; + mockContext.collections.Cart = { + findOne: jest.fn().mockResolvedValueOnce(cart) + }; + mockContext.collections.Coupons = { + find: jest.fn().mockReturnValue({ + toArray: jest.fn().mockResolvedValueOnce([]) + }) + }; + + const expectedError = new ReactionError("not-found", "The coupon CODE is not found"); + + expect(applyCouponToCart(mockContext, { + shopId: "_shopId", + cartId: "_id", + couponCode: "CODE", + cartToken: "anonymousToken" + })).rejects.toThrow(expectedError); +}); + +test("should throw error when more than one coupon have same code", async () => { + const cart = { _id: "cartId" }; + const promotion = { + _id: "promotionId", + type: "explicit" + }; + const coupon = { + _id: "couponId", + code: "CODE", + promotionId: "promotionId" + }; + + mockContext.collections.Promotions = { + findOne: jest.fn().mockResolvedValueOnce(promotion) + }; + mockContext.collections.Cart = { + findOne: jest.fn().mockResolvedValueOnce(cart) + }; + mockContext.collections.Coupons = { + find: jest.fn().mockReturnValue({ + toArray: jest.fn().mockResolvedValueOnce([coupon, coupon]) + }) + }; + + const expectedError = new ReactionError("not-found", "The coupon have duplicate with other promotion. Please contact admin for more information"); + + expect(applyCouponToCart(mockContext, { + shopId: "_shopId", + cartId: "_id", + couponCode: "CODE", + cartToken: "anonymousToken" + })).rejects.toThrow(expectedError); +}); + test("should throw error if promotion expired", async () => { - const now = new Date(); const cart = { _id: "cartId" }; const promotion = { _id: "promotionId", - type: "explicit", - endDate: new Date(now.setMonth(now.getMonth() - 1)) + type: "explicit" + }; + const coupon = { + _id: "couponId", + code: "CODE", + promotionId: "promotionId" }; mockContext.collections.Promotions = { findOne: jest.fn().mockResolvedValueOnce(promotion) }; + mockContext.collections.Cart = { + findOne: jest.fn().mockResolvedValueOnce(cart) + }; + mockContext.collections.Coupons = { + find: jest.fn().mockReturnValue({ + toArray: jest.fn().mockResolvedValueOnce([coupon]) + }) + }; + + const expectedError = new ReactionError("not-found", "The coupon CODE is not found"); + expect(applyCouponToCart(mockContext, { + shopId: "_shopId", + cartId: "_id", + couponCode: "CODE", + cartToken: "anonymousToken" + })).rejects.toThrow(expectedError); +}); + +test("should throw error when more than one coupon have same code", async () => { + const cart = { _id: "cartId" }; + const promotion = { + _id: "promotionId", + type: "explicit" + }; + const coupon = { + _id: "couponId", + code: "CODE", + promotionId: "promotionId" + }; + + mockContext.collections.Promotions = { + findOne: jest.fn().mockResolvedValueOnce(promotion) + }; mockContext.collections.Cart = { findOne: jest.fn().mockResolvedValueOnce(cart) }; + mockContext.collections.Coupons = { + find: jest.fn().mockReturnValue({ + toArray: jest.fn().mockResolvedValueOnce([coupon, coupon]) + }) + }; - const expectedError = new ReactionError("coupon-expired", "The coupon is expired"); + const expectedError = new ReactionError("not-found", "The coupon have duplicate with other promotion. Please contact admin for more information"); - await expect(applyCouponToCart(mockContext, { + expect(applyCouponToCart(mockContext, { shopId: "_shopId", cartId: "_id", couponCode: "CODE", @@ -110,14 +241,24 @@ test("should throw error if promotion already exists on the cart", async () => { type: "explicit", endDate: new Date(now.setMonth(now.getMonth() + 1)) }; + const coupon = { + _id: "couponId", + code: "CODE", + promotionId: "promotionId" + }; mockContext.collections.Cart = { findOne: jest.fn().mockResolvedValueOnce(cart) }; mockContext.collections.Promotions = { findOne: jest.fn().mockResolvedValueOnce(promotion) }; + mockContext.collections.Coupons = { + find: jest.fn().mockReturnValue({ + toArray: jest.fn().mockResolvedValueOnce([coupon]) + }) + }; - const expectedError = new Error("coupon-already-exists", "The coupon already applied on the cart"); + const expectedError = new Error("The coupon already applied on the cart"); await expect(applyCouponToCart(mockContext, { shopId: "_shopId", @@ -127,6 +268,71 @@ test("should throw error if promotion already exists on the cart", async () => { })).rejects.toThrow(expectedError); }); +test("should throw error when coupon is expired", async () => { + const cart = { + _id: "cartId" + }; + const coupon = { + _id: "couponId", + code: "CODE", + promotionId: "promotionId", + maxUsageTimes: 10, + usedCount: 10 + }; + + mockContext.collections.Cart = { + findOne: jest.fn().mockResolvedValueOnce(cart) + }; + mockContext.collections.Coupons = { + find: jest.fn().mockReturnValue({ + toArray: jest.fn().mockResolvedValueOnce([coupon]) + }) + }; + + const expectedError = new ReactionError("not-found", "The coupon is expired"); + + expect(applyCouponToCart(mockContext, { + shopId: "_shopId", + cartId: "_id", + couponCode: "CODE", + cartToken: "anonymousToken" + })).rejects.toThrow(expectedError); +}); + +test("should throw an error when the coupon reaches the maximum usage limit per user", async () => { + const cart = { + _id: "cartId" + }; + const coupon = { + _id: "couponId", + code: "CODE", + promotionId: "promotionId", + maxUsageTimesPerUser: 1, + usedCount: 1 + }; + + mockContext.collections.Cart = { + findOne: jest.fn().mockResolvedValueOnce(cart) + }; + mockContext.collections.Coupons = { + find: jest.fn().mockReturnValue({ + toArray: jest.fn().mockResolvedValueOnce([coupon]) + }) + }; + mockContext.collections.CouponLogs = { + findOne: jest.fn().mockResolvedValueOnce({ _id: "couponLogId", usedCount: 1 }) + }; + + const expectedError = new ReactionError("not-found", "The coupon is expired"); + + expect(applyCouponToCart(mockContext, { + shopId: "_shopId", + cartId: "_id", + couponCode: "CODE", + cartToken: "anonymousToken" + })).rejects.toThrow(expectedError); +}); + test("should query cart with anonymous token when the input provided cartToken", () => { const cart = { _id: "cartId" }; const promotion = { @@ -137,10 +343,14 @@ test("should query cart with anonymous token when the input provided cartToken", mockContext.collections.Cart = { findOne: jest.fn().mockResolvedValueOnce(cart) }; - mockContext.collections.Promotions = { findOne: jest.fn().mockResolvedValueOnce(promotion) }; + mockContext.collections.Coupons = { + find: jest.fn().mockReturnValue({ + toArray: jest.fn().mockResolvedValueOnce([]) + }) + }; applyCouponToCart(mockContext, { shopId: "_shopId", cartId: "_id", couponCode: "CODE", cartToken: "anonymousToken" }); @@ -157,6 +367,11 @@ test("should query cart with accountId when request is authenticated user", asyn _id: "promotionId", type: "explicit" }; + const coupon = { + _id: "couponId", + code: "CODE", + promotionId: "promotionId" + }; mockContext.collections.Cart = { findOne: jest.fn().mockResolvedValueOnce(cart) }; @@ -166,6 +381,11 @@ test("should query cart with accountId when request is authenticated user", asyn mockContext.collections.Promotions = { findOne: jest.fn().mockResolvedValueOnce(promotion) }; + mockContext.collections.Coupons = { + find: jest.fn().mockReturnValue({ + toArray: jest.fn().mockResolvedValueOnce([coupon]) + }) + }; mockContext.userId = "_userId"; diff --git a/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.test.js b/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.test.js index 1f6c450b140..d486c8889eb 100644 --- a/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.test.js +++ b/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.test.js @@ -11,6 +11,34 @@ test("throws if validation check fails", async () => { } }); +test("throws error when coupon code already created", async () => { + const input = { code: "CODE", shopId: "123", promotionId: "123", canUseInStore: true }; + const coupon = { _id: "123", code: "CODE", promotionId: "promotionId" }; + const promotion = { _id: "promotionId" }; + mockContext.collections = { + Promotions: { + findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(promotion)), + find: jest.fn().mockReturnValue({ + toArray: jest.fn().mockResolvedValueOnce(Promise.resolve([promotion])) + }) + }, + Coupons: { + findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(coupon)), + find: jest.fn().mockReturnValue({ + toArray: jest.fn().mockResolvedValueOnce(Promise.resolve([coupon])) + }), + // eslint-disable-next-line id-length + insertOne: jest.fn().mockResolvedValueOnce(Promise.resolve({ insertedId: "123", result: { n: 1 } })) + } + }; + + try { + await createStandardCoupon(mockContext, input); + } catch (error) { + expect(error.message).toEqual("Coupon code already created"); + } +}); + test("throws error when promotion does not exist", async () => { const input = { code: "CODE", shopId: "123", promotionId: "123", canUseInStore: true }; mockContext.collections = { diff --git a/packages/api-plugin-promotions-coupons/src/preStartup.js b/packages/api-plugin-promotions-coupons/src/preStartup.js new file mode 100644 index 00000000000..06aacf3e9b2 --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/preStartup.js @@ -0,0 +1,33 @@ +import _ from "lodash"; +import SimpleSchema from "simpl-schema"; + +/** + * @summary This is a preStartup function that is called before the app starts up. + * @param {Object} context - The application context + * @returns {undefined} + */ +export default async function preStartupPromotionCoupon(context) { + const { simpleSchemas: { Cart, Promotion }, promotions: pluginPromotions } = context; + + // because we're reusing the offer trigger, we need to promotion-discounts plugin to be installed first + const offerTrigger = pluginPromotions.triggers.find((trigger) => trigger.key === "offers"); + if (!offerTrigger) throw new Error("No offer trigger found. Need to register offers trigger first."); + + const copiedPromotion = _.cloneDeep(Promotion); + + const relatedCoupon = new SimpleSchema({ + couponCode: String, + couponId: String + }); + + copiedPromotion.extend({ + relatedCoupon: { + type: relatedCoupon, + optional: true + } + }); + + Cart.extend({ + "appliedPromotions.$": copiedPromotion + }); +} diff --git a/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/applyCouponToCart.js b/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/applyCouponToCart.js index 3a3b240bed1..4860e3647dc 100644 --- a/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/applyCouponToCart.js +++ b/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/applyCouponToCart.js @@ -1,5 +1,3 @@ -import { decodeCartOpaqueId, decodeShopOpaqueId } from "../../xforms/id.js"; - /** * @method applyCouponToCart * @summary Apply a coupon to the cart @@ -11,16 +9,6 @@ import { decodeCartOpaqueId, decodeShopOpaqueId } from "../../xforms/id.js"; * @returns {Promise} with updated cart */ export default async function applyCouponToCart(_, { input }, context) { - const { shopId, cartId, couponCode, token } = input; - const decodedCartId = decodeCartOpaqueId(cartId); - const decodedShopId = decodeShopOpaqueId(shopId); - - const appliedCart = await context.mutations.applyCouponToCart(context, { - shopId: decodedShopId, - cartId: decodedCartId, - cartToken: token, - couponCode - }); - + const appliedCart = await context.mutations.applyCouponToCart(context, input); return { cart: appliedCart }; } diff --git a/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/applyCouponToCart.test.js b/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/applyCouponToCart.test.js index 02005c9dcec..702444404fa 100644 --- a/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/applyCouponToCart.test.js +++ b/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/applyCouponToCart.test.js @@ -12,6 +12,6 @@ test("should call applyCouponToCart mutation", async () => { shopId: "_shopId", cartId: "_id", couponCode: "CODE", - cartToken: "anonymousToken" + token: "anonymousToken" }); }); diff --git a/packages/api-plugin-promotions-coupons/src/resolvers/Promotion/getPromotionCoupon.js b/packages/api-plugin-promotions-coupons/src/resolvers/Promotion/getPromotionCoupon.js new file mode 100644 index 00000000000..e6fdcbd77ca --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/resolvers/Promotion/getPromotionCoupon.js @@ -0,0 +1,13 @@ +/** + * @summary Get a coupon for a promotion + * @param {Object} promotion - The promotion object + * @param {String} promotion._id - The promotion ID + * @param {Object} args - unused + * @param {Object} context - The context object + * @returns {Promise} A coupon object + */ +export default async function getPromotionCoupon(promotion, args, context) { + const { collections: { Coupons } } = context; + const coupon = await Coupons.findOne({ promotionId: promotion._id }); + return coupon; +} diff --git a/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql b/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql index 8b62fb83cde..d500d89491a 100644 --- a/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql +++ b/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql @@ -53,7 +53,7 @@ input ApplyCouponToCartInput { accountId: ID "Cart token, if anonymous" - token: String + cartToken: String } "The input for the createStandardCoupon mutation" diff --git a/packages/api-plugin-promotions-coupons/src/simpleSchemas.js b/packages/api-plugin-promotions-coupons/src/simpleSchemas.js index 050b36a0eee..825fee4cfe7 100644 --- a/packages/api-plugin-promotions-coupons/src/simpleSchemas.js +++ b/packages/api-plugin-promotions-coupons/src/simpleSchemas.js @@ -45,3 +45,32 @@ export const Coupon = new SimpleSchema({ type: Date } }); + +export const CouponLog = new SimpleSchema({ + "_id": String, + "couponId": String, + "promotionId": String, + "orderId": { + type: String, + optional: true + }, + "accountId": { + type: String, + optional: true + }, + "usedCount": { + type: Number, + defaultValue: 0 + }, + "createdAt": { + type: Date + }, + "usedLogs": { + type: Array, + optional: true + }, + "usedLogs.$": { + type: Object, + blackbox: true + } +}); diff --git a/packages/api-plugin-promotions-coupons/src/triggers/couponsTriggerHandler.js b/packages/api-plugin-promotions-coupons/src/triggers/couponsTriggerHandler.js index f575d4c42e5..d6f6f01ea8a 100644 --- a/packages/api-plugin-promotions-coupons/src/triggers/couponsTriggerHandler.js +++ b/packages/api-plugin-promotions-coupons/src/triggers/couponsTriggerHandler.js @@ -9,8 +9,11 @@ import { CouponTriggerParameters } from "../simpleSchemas.js"; * @returns {Boolean} - Whether the promotion can be applied to the cart */ export async function couponTriggerHandler(context, enhancedCart, { triggerParameters }) { - // TODO: add the logic to check ownership or limitation of the coupon - return true; + const { promotions: pluginPromotions } = context; + const offerTrigger = pluginPromotions.triggers.find((trigger) => trigger.key === "offers"); + if (!offerTrigger) throw new Error("No offer trigger found. Need to register offers trigger first."); + const triggerResult = await offerTrigger.handler(context, enhancedCart, { triggerParameters }); + return triggerResult; } export default { diff --git a/packages/api-plugin-promotions-coupons/src/utils/updateOrderCoupon.js b/packages/api-plugin-promotions-coupons/src/utils/updateOrderCoupon.js new file mode 100644 index 00000000000..6a8eb2df87c --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/utils/updateOrderCoupon.js @@ -0,0 +1,62 @@ +/* eslint-disable no-await-in-loop */ +import ReactionError from "@reactioncommerce/reaction-error"; +import Random from "@reactioncommerce/random"; + +/** + * @summary Rollback coupon that has used count changed + * @param {Object} context - The application context + * @param {String} couponId - The coupon id + * @returns {undefined} + */ +async function rollbackCoupon(context, couponId) { + const { collections: { Coupons } } = context; + await Coupons.findOneAndUpdate({ _id: couponId }, { $inc: { usedCount: -1 } }); +} + +/** + * @summary Update a coupon before order created + * @param {Object} context - The application context + * @param {Object} order - The order that was created + * @returns {undefined} + */ +export default async function updateOrderCoupon(context, order) { + const { collections: { Coupons, CouponLogs } } = context; + + const appliedPromotions = order.appliedPromotions || []; + + for (const promotion of appliedPromotions) { + if (!promotion.relatedCoupon) continue; + + const { _id: promotionId, relatedCoupon: { couponId } } = promotion; + + const coupon = await Coupons.findOne({ _id: couponId }); + if (!coupon) continue; + + const { maxUsageTimes, maxUsageTimesPerUser } = coupon; + + const { value: updatedCoupon } = await Coupons.findOneAndUpdate({ _id: couponId }, { $inc: { usedCount: 1 } }, { returnOriginal: false }); + if (updatedCoupon && maxUsageTimes && maxUsageTimes > 0 && updatedCoupon.usedCount > maxUsageTimes) { + await rollbackCoupon(context, couponId); + throw new ReactionError("invalid-params", "Coupon no longer available."); + } + + const couponLog = await CouponLogs.findOne({ couponId, promotionId, accountId: order.accountId }); + if (!couponLog) { + await CouponLogs.insertOne({ + _id: Random.id(), + couponId, + promotionId: promotion._id, + accountId: order.accountId, + createdAt: new Date(), + usedCount: 1 + }); + continue; + } + + if (maxUsageTimesPerUser && maxUsageTimesPerUser > 0 && couponLog.usedCount >= maxUsageTimesPerUser) { + await rollbackCoupon(context, couponId); + throw new ReactionError("invalid-params", "Your coupon has been used the maximum number of times."); + } + await CouponLogs.findOneAndUpdate({ _id: couponLog._id }, { $inc: { usedCount: 1 } }); + } +} diff --git a/packages/api-plugin-promotions-coupons/src/utils/updateOrderCoupon.test.js b/packages/api-plugin-promotions-coupons/src/utils/updateOrderCoupon.test.js new file mode 100644 index 00000000000..66d321b8828 --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/utils/updateOrderCoupon.test.js @@ -0,0 +1,162 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import updateOrderCoupon from "./updateOrderCoupon.js"; + +test("shouldn't do anything if there are no related coupons", async () => { + const order = { + appliedPromotions: [ + { + _id: "promotionId", + type: "explicit", + endDate: new Date() + } + ] + }; + mockContext.collections.Coupons = { + findOne: jest.fn().mockResolvedValueOnce(null) + }; + mockContext.collections.CouponLogs = { + findOne: jest.fn().mockResolvedValueOnce(null) + }; + + await updateOrderCoupon(mockContext, order); + + expect(mockContext.collections.Coupons.findOne).not.toHaveBeenCalled(); + expect(mockContext.collections.CouponLogs.findOne).not.toHaveBeenCalled(); +}); + +test("shouldn't do anything if there are no coupon found ", async () => { + const order = { + appliedPromotions: [ + { + _id: "promotionId", + type: "explicit", + endDate: new Date(), + relatedCoupon: { + couponId: "couponId", + couponCode: "CODE" + } + } + ] + }; + mockContext.collections.Coupons = { + findOne: jest.fn().mockResolvedValueOnce(null) + }; + mockContext.collections.CouponLogs = { + findOne: jest.fn().mockResolvedValueOnce(null) + }; + + await updateOrderCoupon(mockContext, order); + + expect(mockContext.collections.Coupons.findOne).toHaveBeenCalled(); + expect(mockContext.collections.CouponLogs.findOne).not.toHaveBeenCalled(); +}); + +test("should throw error if coupon has been used the maximum number of times", async () => { + const order = { + appliedPromotions: [ + { + _id: "promotionId", + type: "explicit", + endDate: new Date(), + relatedCoupon: { + couponId: "couponId", + couponCode: "CODE" + } + } + ] + }; + mockContext.collections.Coupons = { + findOne: jest.fn().mockResolvedValueOnce({ + _id: "couponId", + maxUsageTimes: 1 + }), + findOneAndUpdate: jest.fn().mockResolvedValue({ + value: { + usedCount: 2 + } + }) + }; + mockContext.collections.CouponLogs = { + findOne: jest.fn().mockResolvedValueOnce(null), + insertOne: jest.fn().mockResolvedValueOnce({}) + }; + + await expect(updateOrderCoupon(mockContext, order)).rejects.toThrow("Coupon no longer available."); + expect(mockContext.collections.Coupons.findOneAndUpdate).toHaveBeenNthCalledWith(2, { _id: "couponId" }, { $inc: { usedCount: -1 } }); +}); + +test("should throw error if coupon has been used the maximum number of times per user", async () => { + const order = { + appliedPromotions: [ + { + _id: "promotionId", + type: "explicit", + endDate: new Date(), + relatedCoupon: { + couponId: "couponId", + couponCode: "CODE" + } + } + ] + }; + mockContext.collections.Coupons = { + findOne: jest.fn().mockResolvedValueOnce({ + _id: "couponId", + maxUsageTimesPerUser: 1 + }), + findOneAndUpdate: jest.fn().mockResolvedValue({ + value: { + usedCount: 1 + } + }) + }; + mockContext.collections.CouponLogs = { + findOne: jest.fn().mockResolvedValueOnce({ + usedCount: 1 + }), + insertOne: jest.fn().mockResolvedValueOnce({}), + findOneAndUpdate: jest.fn().mockResolvedValue({ + value: { + usedCount: 1 + } + }) + }; + + await expect(updateOrderCoupon(mockContext, order)).rejects.toThrow("Your coupon has been used the maximum number of times."); + expect(mockContext.collections.Coupons.findOneAndUpdate).toHaveBeenNthCalledWith(2, { _id: "couponId" }, { $inc: { usedCount: -1 } }); +}); + +test("should create new coupon log if there is no coupon log found", async () => { + const order = { + appliedPromotions: [ + { + _id: "promotionId", + type: "explicit", + endDate: new Date(), + relatedCoupon: { + couponId: "couponId", + couponCode: "CODE" + } + } + ] + }; + mockContext.collections.Coupons = { + findOne: jest.fn().mockResolvedValueOnce({ + _id: "couponId", + maxUsageTimesPerUser: 1 + }), + findOneAndUpdate: jest.fn().mockResolvedValue({ + value: { + usedCount: 1 + } + }) + }; + mockContext.collections.CouponLogs = { + findOne: jest.fn().mockResolvedValueOnce(null), + insertOne: jest.fn().mockResolvedValueOnce({}) + }; + + await updateOrderCoupon(mockContext, order); + + expect(mockContext.collections.CouponLogs.insertOne).toHaveBeenCalled(); +}); diff --git a/packages/api-plugin-promotions/src/handlers/applyExplicitPromotion.js b/packages/api-plugin-promotions/src/handlers/applyExplicitPromotion.js index 8134960cd2e..4077461bd02 100644 --- a/packages/api-plugin-promotions/src/handlers/applyExplicitPromotion.js +++ b/packages/api-plugin-promotions/src/handlers/applyExplicitPromotion.js @@ -3,12 +3,16 @@ * @param {Object} context - The application context * @param {Object} cart - The cart to apply promotions to * @param {Object} promotion - The promotion to apply - * @returns {Object} - The cart with promotions applied and applied promotions + * @returns {Promise} - The cart with promotions applied and applied promotions */ export default async function applyExplicitPromotion(context, cart, promotion) { if (!Array.isArray(cart.appliedPromotions)) { cart.appliedPromotions = []; } - cart.appliedPromotions.push(promotion); - await context.mutations.saveCart(context, cart); + cart.appliedPromotions.push({ + ...promotion, + newlyAdded: true + }); + const updatedCart = await context.mutations.saveCart(context, cart); + return updatedCart; } diff --git a/packages/api-plugin-promotions/src/handlers/applyExplicitPromotion.test.js b/packages/api-plugin-promotions/src/handlers/applyExplicitPromotion.test.js index f686cb51c81..5dc291d6c46 100644 --- a/packages/api-plugin-promotions/src/handlers/applyExplicitPromotion.test.js +++ b/packages/api-plugin-promotions/src/handlers/applyExplicitPromotion.test.js @@ -13,6 +13,14 @@ test("call applyPromotions function", async () => { applyExplicitPromotion(context, cart, promotion); - const expectedCart = { ...cart, appliedPromotions: [promotion] }; + const expectedCart = { + ...cart, + appliedPromotions: [ + { + ...promotion, + newlyAdded: true + } + ] + }; expect(mockSaveCartMutation).toHaveBeenCalledWith(context, expectedCart); }); diff --git a/packages/api-plugin-promotions/src/handlers/applyPromotions.js b/packages/api-plugin-promotions/src/handlers/applyPromotions.js index ce2f5674c61..9a38ce59075 100644 --- a/packages/api-plugin-promotions/src/handlers/applyPromotions.js +++ b/packages/api-plugin-promotions/src/handlers/applyPromotions.js @@ -2,6 +2,7 @@ import { createRequire } from "module"; import Logger from "@reactioncommerce/logger"; import Random from "@reactioncommerce/random"; +import ReactionError from "@reactioncommerce/reaction-error"; import _ from "lodash"; import canBeApplied from "../utils/canBeApplied.js"; import enhanceCart from "../utils/enhanceCart.js"; @@ -36,6 +37,26 @@ async function getImplicitPromotions(context, shopId) { return promotions; } +/** + * @summary get all explicit promotions by Ids + * @param {Object} context - The application context + * @param {String} shopId - The shop ID + * @param {Array} promotionIds - The promotion IDs + * @returns {Promise>} - An array of promotions + */ +async function getExplicitPromotionsByIds(context, shopId, promotionIds) { + const now = new Date(); + const { collections: { Promotions } } = context; + const promotions = await Promotions.find({ + _id: { $in: promotionIds }, + shopId, + enabled: true, + triggerType: "explicit", + startDate: { $lt: now } + }).toArray(); + return promotions; +} + /** * @summary create the cart message * @param {String} params.title - The message title @@ -69,11 +90,19 @@ export default async function applyPromotions(context, cart) { const actionHandleByKey = _.keyBy(pluginPromotions.actions, "key"); const appliedPromotions = []; - const appliedExplicitPromotions = _.filter(cart.appliedPromotions || [], ["triggerType", "explicit"]); + const appliedExplicitPromotionsIds = _.map(_.filter(cart.appliedPromotions || [], ["triggerType", "explicit"]), "_id"); + const explicitPromotions = await getExplicitPromotionsByIds(context, cart.shopId, appliedExplicitPromotionsIds); const cartMessages = cart.messages || []; - const unqualifiedPromotions = promotions.concat(appliedExplicitPromotions); + const unqualifiedPromotions = promotions.concat(_.map(explicitPromotions, (promotion) => { + const existsPromotion = _.find(cart.appliedPromotions || [], { _id: promotion._id }); + if (existsPromotion) promotion.relatedCoupon = existsPromotion.relatedCoupon || undefined; + if (typeof existsPromotion?.newlyAdded !== "undefined") promotion.newlyAdded = existsPromotion.newlyAdded; + return promotion; + })); + + const newlyAddedPromotionId = _.find(unqualifiedPromotions, "newlyAdded")?._id; for (const { cleanup } of pluginPromotions.actions) { cleanup && await cleanup(context, cart); @@ -170,7 +199,13 @@ export default async function applyPromotions(context, cart) { } } - enhancedCart.appliedPromotions = appliedPromotions; + // If a explicit promotion was just applied, throw an error so that the client can display the message + if (newlyAddedPromotionId) { + const message = _.find(cartMessages, ({ metaFields }) => metaFields.promotionId === newlyAddedPromotionId); + if (message) throw new ReactionError("invalid-params", message.message); + } + + enhancedCart.appliedPromotions = _.map(appliedPromotions, (promotion) => _.omit(promotion, "newlyAdded")); // Remove messages that are no longer relevant const cleanedMessages = _.filter(cartMessages, (message) => { diff --git a/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js b/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js index abef60a4c77..ff9ec46a1d3 100644 --- a/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js +++ b/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js @@ -22,6 +22,7 @@ const testPromotion = { _id: "test id", actions: [{ actionKey: "test" }], triggers: [{ triggerKey: "test", triggerParameters: { name: "test trigger" } }], + triggerType: "implicit", stackability: { key: "none", parameters: {} @@ -37,14 +38,21 @@ test("should save cart with implicit promotions are applied", async () => { _id: "cartId" }; mockContext.collections.Promotions = { - find: () => ({ toArray: jest.fn().mockResolvedValueOnce([testPromotion]) }) + find: ({ triggerType }) => ({ + toArray: jest.fn().mockImplementation(() => { + if (triggerType === "implicit") { + return [testPromotion]; + } + return []; + }) + }) }; mockContext.promotions = pluginPromotion; mockContext.simpleSchemas = { Cart: { clean: jest.fn() } }; - canBeApplied.mockReturnValueOnce({ qualifies: true }); - testAction.mockReturnValue({ affected: true }); + canBeApplied.mockResolvedValue({ qualifies: true }); + testAction.mockResolvedValue({ affected: true }); await applyPromotions(mockContext, cart); @@ -280,3 +288,52 @@ test("should not have promotion message when the promotion already message added expect(cart.messages.length).toEqual(1); }); + +test("throw error when explicit promotion is newly applied and conflict with other", async () => { + isPromotionExpired.mockReturnValue(false); + canBeApplied.mockReturnValue({ qualifies: false }); + + const promotion = { + ...testPromotion, + _id: "promotionId", + triggerType: "implicit" + }; + const secondPromotion = { + ...testPromotion, + _id: "promotionId2", + triggerType: "explicit", + newlyApplied: true, + relatedCoupon: { + couponCode: "couponCode", + couponId: "couponId" + }, + stackability: { + key: "none", + parameters: {} + } + }; + const cart = { + _id: "cartId", + appliedPromotions: [promotion, secondPromotion] + }; + + mockContext.collections.Promotions = { + find: () => ({ + toArray: jest.fn().mockResolvedValueOnce([promotion, secondPromotion]) + }) + }; + + testTrigger.mockReturnValue(Promise.resolve(true)); + testAction.mockReturnValue(Promise.resolve({ affected: true })); + + mockContext.promotions = { ...pluginPromotion }; + mockContext.simpleSchemas = { + Cart: { clean: jest.fn() } + }; + + try { + await applyPromotions(mockContext, cart); + } catch (error) { + expect(error.error).toEqual("invalid-params"); + } +});