From e1e82a2cae51374110bec01a92d53242a61ff27e Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Thu, 29 Dec 2022 15:18:43 +0700 Subject: [PATCH 01/37] feat: create standard coupon mutation Signed-off-by: vanpho93 --- .../src/index.js | 17 +- .../src/mutations/createStandardCoupon.js | 72 ++++++++ .../mutations/createStandardCoupon.test.js | 98 +++++++++++ .../src/mutations/index.js | 4 +- .../src/queries/coupon.js | 12 ++ .../src/queries/coupons.js | 34 ++++ .../src/queries/index.js | 7 + .../Mutation/createStandardCoupon.js | 18 ++ .../Mutation/createStandardCoupon.test.js | 26 +++ .../src/resolvers/Mutation/index.js | 4 +- .../Promotion/getPreviewPromotionCoupon.js | 13 ++ .../src/resolvers/Promotion/index.js | 5 + .../src/resolvers/Query/coupon.js | 16 ++ .../src/resolvers/Query/coupons.js | 23 +++ .../src/resolvers/Query/index.js | 7 + .../src/resolvers/index.js | 6 +- .../src/schemas/schema.graphql | 154 ++++++++++++++++++ .../src/simpleSchemas.js | 39 +++++ 18 files changed, 551 insertions(+), 4 deletions(-) create mode 100644 packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.js create mode 100644 packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.test.js create mode 100644 packages/api-plugin-promotions-coupons/src/queries/coupon.js create mode 100644 packages/api-plugin-promotions-coupons/src/queries/coupons.js create mode 100644 packages/api-plugin-promotions-coupons/src/queries/index.js create mode 100644 packages/api-plugin-promotions-coupons/src/resolvers/Mutation/createStandardCoupon.js create mode 100644 packages/api-plugin-promotions-coupons/src/resolvers/Mutation/createStandardCoupon.test.js create mode 100644 packages/api-plugin-promotions-coupons/src/resolvers/Promotion/getPreviewPromotionCoupon.js create mode 100644 packages/api-plugin-promotions-coupons/src/resolvers/Promotion/index.js create mode 100644 packages/api-plugin-promotions-coupons/src/resolvers/Query/coupon.js create mode 100644 packages/api-plugin-promotions-coupons/src/resolvers/Query/coupons.js create mode 100644 packages/api-plugin-promotions-coupons/src/resolvers/Query/index.js diff --git a/packages/api-plugin-promotions-coupons/src/index.js b/packages/api-plugin-promotions-coupons/src/index.js index b93b584ff67..cd34c8f45e7 100644 --- a/packages/api-plugin-promotions-coupons/src/index.js +++ b/packages/api-plugin-promotions-coupons/src/index.js @@ -1,8 +1,10 @@ import { createRequire } from "module"; import schemas from "./schemas/index.js"; 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"; const require = createRequire(import.meta.url); const pkg = require("../package.json"); @@ -17,6 +19,15 @@ export default async function register(app) { label: pkg.label, name: pkg.name, version: pkg.version, + collections: { + Coupons: { + name: "Coupons", + indexes: [ + [{ shopId: 1, code: 1 }], + [{ shopId: 1, promotionId: 1 }] + ] + } + }, promotions: { triggers }, @@ -24,6 +35,10 @@ export default async function register(app) { resolvers, schemas }, - mutations + mutations, + queries, + simpleSchemas: { + Coupon + } }); } diff --git a/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.js b/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.js new file mode 100644 index 00000000000..6c898fd27b6 --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.js @@ -0,0 +1,72 @@ +import _ from "lodash"; +import SimpleSchema from "simpl-schema"; +import Random from "@reactioncommerce/random"; +import ReactionError from "@reactioncommerce/reaction-error"; +import { Coupon } from "../simpleSchemas.js"; + +const inputSchema = new SimpleSchema({ + shopId: String, + promotionId: String, + code: String, + canUseInStore: Boolean, + maxUsageTimesPerUser: { + type: Number, + optional: true + }, + maxUsageTimes: { + type: Number, + optional: true + } +}); + +/** + * @method createStandardCoupon + * @summary Create a standard coupon mutation + * @param {Object} context - The application context + * @param {Object} input - The coupon input to create + * @returns {Promise} with created coupon result + */ +export default async function createStandardCoupon(context, input) { + inputSchema.validate(input); + + const { collections: { Coupons, Promotions } } = context; + const { shopId, promotionId, code } = input; + + const promotion = await Promotions.findOne({ _id: promotionId, shopId }); + if (!promotion) throw new ReactionError("not-found", "Promotion not found"); + + const existsCoupons = await Coupons.find({ code, shopId }).toArray(); + if (existsCoupons.length > 0) { + const promotionIds = _.map(existsCoupons, "promotionId"); + const promotions = await Promotions.find({ _id: { $in: promotionIds } }).toArray(); + + for (const existsPromotion of promotions) { + if (existsPromotion.startDate <= promotion.startDate && existsPromotion.endDate >= promotion.endDate) { + throw new ReactionError("invalid-params", `A coupon code ${code} already exists in this promotion window`); + } + } + } + + const now = new Date(); + const coupon = { + _id: Random.id(), + code: input.code, + shopId, + promotionId, + expirationDate: promotion.endDate, + canUseInStore: input.canUseInStore || false, + maxUsageTimesPerUser: input.maxUsageTimesPerUser || 0, + maxUsageTimes: input.maxUsageTimes || 0, + usedCount: 0, + createdAt: now, + updatedAt: now + }; + + Coupon.validate(coupon); + + const results = await Coupons.insertOne(coupon); + + const { insertedId, result } = results; + coupon._id = insertedId; + return { success: result.n === 1, coupon }; +} diff --git a/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.test.js b/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.test.js new file mode 100644 index 00000000000..1f6c450b140 --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.test.js @@ -0,0 +1,98 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import createStandardCoupon from "./createStandardCoupon.js"; + +test("throws if validation check fails", async () => { + const input = { code: "CODE" }; + + try { + await createStandardCoupon(mockContext, input); + } catch (error) { + expect(error.error).toEqual("validation-error"); + } +}); + +test("throws error when promotion does not exist", async () => { + const input = { code: "CODE", shopId: "123", promotionId: "123", canUseInStore: true }; + mockContext.collections = { + Coupons: { + findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(null)) + }, + Promotions: { + findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(null)) + } + }; + + try { + await createStandardCoupon(mockContext, input); + } catch (error) { + expect(error.message).toEqual("Promotion not found"); + } +}); + +test("throws error when coupon code already exists in promotion window", async () => { + const now = new Date(); + const input = { code: "CODE", shopId: "123", promotionId: "123", canUseInStore: true }; + const promotion = { _id: "123", startDate: now, endDate: now }; + const existsPromotion = { _id: "1234", startDate: now, endDate: now }; + const coupon = { _id: "123", code: "CODE", promotionId: "123" }; + mockContext.collections = { + Coupons: { + find: jest.fn().mockReturnValue({ + toArray: jest.fn().mockResolvedValueOnce(Promise.resolve([coupon])) + }) + }, + Promotions: { + findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(promotion)), + find: jest.fn().mockReturnValue({ + toArray: jest.fn().mockResolvedValueOnce(Promise.resolve([existsPromotion])) + }) + } + }; + + try { + await createStandardCoupon(mockContext, input); + } catch (error) { + expect(error.message).toEqual("A coupon code CODE already exists in this promotion window"); + } +}); + +test("should insert a new coupon and return the created results", async () => { + const now = new Date(); + const input = { code: "CODE", shopId: "123", promotionId: "123", canUseInStore: true }; + const promotion = { _id: "123", endDate: now }; + + mockContext.collections = { + Coupons: { + find: jest.fn().mockReturnValue({ + toArray: jest.fn().mockResolvedValueOnce([]) + }), + // eslint-disable-next-line id-length + insertOne: jest.fn().mockResolvedValueOnce(Promise.resolve({ insertedId: "123", result: { n: 1 } })) + }, + Promotions: { + findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(promotion)) + } + }; + + const result = await createStandardCoupon(mockContext, input); + + expect(mockContext.collections.Coupons.insertOne).toHaveBeenCalledTimes(1); + expect(mockContext.collections.Coupons.find).toHaveBeenCalledTimes(1); + + expect(result).toEqual({ + success: true, + coupon: { + _id: "123", + canUseInStore: true, + code: "CODE", + createdAt: jasmine.any(Date), + expirationDate: now, + maxUsageTimes: 0, + maxUsageTimesPerUser: 0, + promotionId: "123", + shopId: "123", + updatedAt: jasmine.any(Date), + usedCount: 0 + } + }); +}); diff --git a/packages/api-plugin-promotions-coupons/src/mutations/index.js b/packages/api-plugin-promotions-coupons/src/mutations/index.js index 99be6db7792..beaab1fbe59 100644 --- a/packages/api-plugin-promotions-coupons/src/mutations/index.js +++ b/packages/api-plugin-promotions-coupons/src/mutations/index.js @@ -1,5 +1,7 @@ import applyCouponToCart from "./applyCouponToCart.js"; +import createStandardCoupon from "./createStandardCoupon.js"; export default { - applyCouponToCart + applyCouponToCart, + createStandardCoupon }; diff --git a/packages/api-plugin-promotions-coupons/src/queries/coupon.js b/packages/api-plugin-promotions-coupons/src/queries/coupon.js new file mode 100644 index 00000000000..a69fad74a79 --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/queries/coupon.js @@ -0,0 +1,12 @@ +/** + * @summary return a single coupon based on shopId and _id + * @param {Object} context - the application context + * @param {String} shopId - The id of the shop + * @param {String} _id - The unencoded id of the coupon + * @return {Object} - The coupon or null + */ +export default async function coupon(context, { shopId, _id }) { + const { collections: { Coupons } } = context; + const singleCoupon = await Coupons.findOne({ shopId, _id }); + return singleCoupon; +} diff --git a/packages/api-plugin-promotions-coupons/src/queries/coupons.js b/packages/api-plugin-promotions-coupons/src/queries/coupons.js new file mode 100644 index 00000000000..994ec2c57df --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/queries/coupons.js @@ -0,0 +1,34 @@ +/** + * @summary return a possibly filtered list of coupons + * @param {Object} context - The application context + * @param {String} shopId - The shopId to query for + * @param {Object} filter - optional filter parameters + * @return {Promise>} - A list of coupons + */ +export default async function coupons(context, shopId, filter) { + const { collections: { Coupons } } = context; + + const selector = { shopId }; + + if (filter) { + const { expirationDate, promotionId, code, userId } = filter; + + if (expirationDate) { + selector.expirationDate = { $gte: expirationDate }; + } + + if (promotionId) { + selector.promotionId = promotionId; + } + + if (code) { + selector.code = code; + } + + if (userId) { + selector.userId = userId; + } + } + + return Coupons.find(selector); +} diff --git a/packages/api-plugin-promotions-coupons/src/queries/index.js b/packages/api-plugin-promotions-coupons/src/queries/index.js new file mode 100644 index 00000000000..4ab1be71056 --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/queries/index.js @@ -0,0 +1,7 @@ +import coupon from "./coupon.js"; +import coupons from "./coupons.js"; + +export default { + coupon, + coupons +}; diff --git a/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/createStandardCoupon.js b/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/createStandardCoupon.js new file mode 100644 index 00000000000..b0ba144df83 --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/createStandardCoupon.js @@ -0,0 +1,18 @@ +/** + * @method createStandardCoupon + * @summary Create a standard coupon mutation + * @param {Object} _ unused + * @param {Object} args.input - The input arguments + * @param {Object} args.input.shopId - The shopId + * @param {Object} args.input.promotionId - The promotion ID + * @param {Object} context - The application context + * @returns {Promise} with created coupon result + */ +export default async function createStandardCoupon(_, { input }, context) { + const { shopId } = input; + + await context.validatePermissions("reaction:legacy:promotions", "create", { shopId }); + + const createCouponResult = await context.mutations.createStandardCoupon(context, input); + return createCouponResult; +} diff --git a/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/createStandardCoupon.test.js b/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/createStandardCoupon.test.js new file mode 100644 index 00000000000..09c24b92b06 --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/createStandardCoupon.test.js @@ -0,0 +1,26 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import createStandardCoupon from "./createStandardCoupon.js"; + +test("throws if permission check fails", async () => { + const input = { name: "Test coupon", code: "CODE" }; + mockContext.validatePermissions.mockResolvedValue(Promise.reject(new Error("Access Denied"))); + + try { + await createStandardCoupon(null, { input }, mockContext); + } catch (error) { + expect(error.message).toEqual("Access Denied"); + } +}); + +test("calls mutations.createStandardCoupon and returns the result", async () => { + const input = { name: "Test coupon", code: "CODE" }; + const result = { _id: "123" }; + mockContext.validatePermissions.mockResolvedValue(Promise.resolve()); + mockContext.mutations = { + createStandardCoupon: jest.fn().mockName("mutations.createStandardCoupon").mockReturnValueOnce(Promise.resolve(result)) + }; + + const createdCoupon = await createStandardCoupon(null, { input }, mockContext); + + expect(createdCoupon).toEqual(result); +}); diff --git a/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/index.js b/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/index.js index 99be6db7792..beaab1fbe59 100644 --- a/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/index.js +++ b/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/index.js @@ -1,5 +1,7 @@ import applyCouponToCart from "./applyCouponToCart.js"; +import createStandardCoupon from "./createStandardCoupon.js"; export default { - applyCouponToCart + applyCouponToCart, + createStandardCoupon }; diff --git a/packages/api-plugin-promotions-coupons/src/resolvers/Promotion/getPreviewPromotionCoupon.js b/packages/api-plugin-promotions-coupons/src/resolvers/Promotion/getPreviewPromotionCoupon.js new file mode 100644 index 00000000000..e322d72a77f --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/resolvers/Promotion/getPreviewPromotionCoupon.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 getPreviewPromotionCoupon(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/resolvers/Promotion/index.js b/packages/api-plugin-promotions-coupons/src/resolvers/Promotion/index.js new file mode 100644 index 00000000000..fed14860bbd --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/resolvers/Promotion/index.js @@ -0,0 +1,5 @@ +import getPreviewPromotionCoupon from "./getPreviewPromotionCoupon.js"; + +export default { + coupon: getPreviewPromotionCoupon +}; diff --git a/packages/api-plugin-promotions-coupons/src/resolvers/Query/coupon.js b/packages/api-plugin-promotions-coupons/src/resolvers/Query/coupon.js new file mode 100644 index 00000000000..a25932fd3af --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/resolvers/Query/coupon.js @@ -0,0 +1,16 @@ +/** + * @summary query the coupons collection for a single coupon + * @param {Object} _ - unused + * @param {Object} args - an object of all arguments that were sent by the client + * @param {String} args.shopId - Shop id of the coupon + * @param {Object} context - an object containing the per-request state + * @returns {Promise} A coupon record or null + */ +export default async function coupon(_, args, context) { + const { input } = args; + const { shopId, _id } = input; + await context.validatePermissions("reaction:legacy:promotions", "read", { shopId }); + return context.queries.coupon(context, { + shopId, _id + }); +} diff --git a/packages/api-plugin-promotions-coupons/src/resolvers/Query/coupons.js b/packages/api-plugin-promotions-coupons/src/resolvers/Query/coupons.js new file mode 100644 index 00000000000..85155e9e6a6 --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/resolvers/Query/coupons.js @@ -0,0 +1,23 @@ +import getPaginatedResponse from "@reactioncommerce/api-utils/graphql/getPaginatedResponse.js"; +import wasFieldRequested from "@reactioncommerce/api-utils/graphql/wasFieldRequested.js"; + +/** + * @summary Query for a list of coupons + * @param {Object} _ - unused + * @param {Object} args - an object of all arguments that were sent by the client + * @param {String} args.shopId - id of user to query + * @param {Object} context - an object containing the per-request state + * @param {Object} info Info about the GraphQL request + * @returns {Promise} Products + */ +export default async function coupons(_, args, context, info) { + const { shopId, filter, ...connectionArgs } = args; + await context.validatePermissions("reaction:legacy:promotions", "read", { shopId }); + const query = await context.queries.coupons(context, shopId, filter); + + return getPaginatedResponse(query, connectionArgs, { + includeHasNextPage: wasFieldRequested("pageInfo.hasNextPage", info), + includeHasPreviousPage: wasFieldRequested("pageInfo.hasPreviousPage", info), + includeTotalCount: wasFieldRequested("totalCount", info) + }); +} diff --git a/packages/api-plugin-promotions-coupons/src/resolvers/Query/index.js b/packages/api-plugin-promotions-coupons/src/resolvers/Query/index.js new file mode 100644 index 00000000000..4ab1be71056 --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/resolvers/Query/index.js @@ -0,0 +1,7 @@ +import coupon from "./coupon.js"; +import coupons from "./coupons.js"; + +export default { + coupon, + coupons +}; diff --git a/packages/api-plugin-promotions-coupons/src/resolvers/index.js b/packages/api-plugin-promotions-coupons/src/resolvers/index.js index 6b9c90688a3..aeec9a3729b 100644 --- a/packages/api-plugin-promotions-coupons/src/resolvers/index.js +++ b/packages/api-plugin-promotions-coupons/src/resolvers/index.js @@ -1,5 +1,9 @@ +import Promotion from "./Promotion/index.js"; import Mutation from "./Mutation/index.js"; +import Query from "./Query/index.js"; export default { - Mutation + Promotion, + Mutation, + Query }; diff --git a/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql b/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql index 5425182fe12..8b62fb83cde 100644 --- a/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql +++ b/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql @@ -1,3 +1,43 @@ +type Coupon { + "The coupon ID" + _id: ID! + + "The shop ID" + shopId: ID! + + "The promotion ID" + promotionId: ID! + + "The coupon owner ID" + userId: ID + + "The coupon code" + code: String! + + "The promotion can be used in the store" + canUseInStore: Boolean + + "The number of times this coupon can be used per user" + maxUsageTimesPerUser: Int + + "The number of times this coupon can be used" + maxUsageTimes: Int + + "The number of times this coupon has been used" + usedCount: Int + + "Coupon created time" + createdAt: Date! + + "Coupon updated time" + updatedAt: Date! +} + +extend type Promotion { + "The coupon code" + coupon: Coupon +} + "Input for the applyCouponToCart mutation" input ApplyCouponToCartInput { @@ -16,15 +56,129 @@ input ApplyCouponToCartInput { token: String } +"The input for the createStandardCoupon mutation" +input CreateStandardCouponInput { + "The shop ID" + shopId: ID! + + "The promotion ID" + promotionId: ID! + + "The coupon code" + code: String! + + "Can use this coupon in the store" + canUseInStore: Boolean! + + "The number of times this coupon can be used per user" + maxUsageTimesPerUser: Int + + "The number of times this coupon can be used" + maxUsageTimes: Int +} + +input CouponQueryInput { + "The unique ID of the coupon" + _id: String! + + "The unique ID of the shop" + shopId: String! +} + +input CouponFilter { + "The expiration date of the coupon" + expirationDate: Date + + "The related promotion ID" + promotionId: ID + + "The coupon code" + code: String + + "The coupon name" + userId: ID +} + "The response for the applyCouponToCart mutation" type ApplyCouponToCartPayload { cart: Cart } +type StandardCouponPayload { + success: Boolean! + coupon: Coupon! +} + +"A connection edge in which each node is a `Coupon` object" +type CouponEdge { + "The cursor that represents this node in the paginated results" + cursor: ConnectionCursor! + + "The coupon node" + node: Coupon +} + +type CouponConnection { + "The list of nodes that match the query, wrapped in an edge to provide a cursor string for each" + edges: [CouponEdge] + + """ + You can request the `nodes` directly to avoid the extra wrapping that `NodeEdge` has, + if you know you will not need to paginate the results. + """ + nodes: [Coupon] + + "Information to help a client request the next or previous page" + pageInfo: PageInfo! + + "The total number of nodes that match your query" + totalCount: Int! +} + +extend type Query { + "Get a coupon" + coupon( + input: CouponQueryInput + ): Coupon + + "Get list of coupons" + coupons( + "The coupon ID" + shopId: ID! + + "Return only results that come after this cursor. Use this with `first` to specify the number of results to return." + after: ConnectionCursor + + "Return only results that come before this cursor. Use this with `last` to specify the number of results to return." + before: ConnectionCursor + + "Return at most this many results. This parameter may be used with either `after` or `offset` parameters." + first: ConnectionLimitInt + + "Return at most this many results. This parameter may be used with the `before` parameter." + last: ConnectionLimitInt + + "Return only results that come after the Nth result. This parameter may be used with the `first` parameter." + offset: Int + + filter: CouponFilter + + sortBy: String + + sortOrder: String + ): CouponConnection +} + extend type Mutation { "Apply a coupon to a cart" applyCouponToCart( "The applyCouponToCart mutation input" input: ApplyCouponToCartInput ): ApplyCouponToCartPayload + +"Create a standard coupon mutation" + createStandardCoupon( + "The createStandardCoupon mutation input" + input: CreateStandardCouponInput + ): StandardCouponPayload } diff --git a/packages/api-plugin-promotions-coupons/src/simpleSchemas.js b/packages/api-plugin-promotions-coupons/src/simpleSchemas.js index 76ae7864baa..050b36a0eee 100644 --- a/packages/api-plugin-promotions-coupons/src/simpleSchemas.js +++ b/packages/api-plugin-promotions-coupons/src/simpleSchemas.js @@ -6,3 +6,42 @@ export const CouponTriggerParameters = new SimpleSchema({ type: String } }); + +export const Coupon = new SimpleSchema({ + _id: String, + code: String, + shopId: String, + promotionId: String, + userId: { + type: String, + optional: true + }, + canUseInStore: { + type: Boolean, + defaultValue: false + }, + expirationDate: { + type: Date, + optional: true + }, + maxUsageTimesPerUser: { + type: Number, + optional: true, + defaultValue: 0 + }, + maxUsageTimes: { + type: Number, + optional: true, + defaultValue: 0 + }, + usedCount: { + type: Number, + defaultValue: 0 + }, + createdAt: { + type: Date + }, + updatedAt: { + type: Date + } +}); From f65959b570d415ed62b890009b9633ab1ae95fc3 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Thu, 29 Dec 2022 16:02:24 +0700 Subject: [PATCH 02/37] 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"); + } +}); From e1353bd2324167681feeda646d773acf7a5786b6 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Tue, 27 Dec 2022 13:35:55 +0700 Subject: [PATCH 03/37] feat: remove coupon from cart mutation Signed-off-by: vanpho93 --- .../src/mutations/index.js | 4 +- .../src/mutations/removeCouponFromCart.js | 62 +++++++++++++ .../mutations/removeCouponFromCart.test.js | 91 +++++++++++++++++++ .../src/resolvers/Mutation/index.js | 4 +- .../Mutation/removeCouponFromCart.js | 14 +++ .../Mutation/removeCouponFromCart.test.js | 15 +++ .../src/schemas/schema.graphql | 74 ++++++++++++++- 7 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 packages/api-plugin-promotions-coupons/src/mutations/removeCouponFromCart.js create mode 100644 packages/api-plugin-promotions-coupons/src/mutations/removeCouponFromCart.test.js create mode 100644 packages/api-plugin-promotions-coupons/src/resolvers/Mutation/removeCouponFromCart.js create mode 100644 packages/api-plugin-promotions-coupons/src/resolvers/Mutation/removeCouponFromCart.test.js diff --git a/packages/api-plugin-promotions-coupons/src/mutations/index.js b/packages/api-plugin-promotions-coupons/src/mutations/index.js index beaab1fbe59..9eb2b58e135 100644 --- a/packages/api-plugin-promotions-coupons/src/mutations/index.js +++ b/packages/api-plugin-promotions-coupons/src/mutations/index.js @@ -1,7 +1,9 @@ import applyCouponToCart from "./applyCouponToCart.js"; import createStandardCoupon from "./createStandardCoupon.js"; +import removeCouponFromCart from "./removeCouponFromCart.js"; export default { applyCouponToCart, - createStandardCoupon + createStandardCoupon, + removeCouponFromCart }; diff --git a/packages/api-plugin-promotions-coupons/src/mutations/removeCouponFromCart.js b/packages/api-plugin-promotions-coupons/src/mutations/removeCouponFromCart.js new file mode 100644 index 00000000000..aacb7444292 --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/mutations/removeCouponFromCart.js @@ -0,0 +1,62 @@ +import SimpleSchema from "simpl-schema"; +import ReactionError from "@reactioncommerce/reaction-error"; +import Logger from "@reactioncommerce/logger"; +import hashToken from "@reactioncommerce/api-utils/hashToken.js"; +import _ from "lodash"; + +const inputSchema = new SimpleSchema({ + shopId: String, + cartId: String, + promotionId: String, + cartToken: { + type: String, + optional: true + } +}); + +/** + * @summary Remove a coupon from a cart + * @param {Object} context - The application context + * @param {Object} input - The input + * @returns {Promise} - The updated cart + */ +export default async function removeCouponFromCart(context, input) { + inputSchema.validate(input); + + const { collections: { Cart, Accounts }, userId } = context; + const { shopId, cartId, promotionId, cartToken } = input; + + const selector = { shopId }; + + if (cartId) selector._id = cartId; + + if (cartToken) { + selector.anonymousAccessToken = hashToken(cartToken); + } else { + const account = (userId && (await Accounts.findOne({ userId }))) || null; + + if (!account) { + Logger.error(`Cart not found for user with ID ${userId}`); + throw new ReactionError("invalid-params", "Cart not found"); + } + + selector.accountId = account._id; + } + + const cart = await Cart.findOne(selector); + if (!cart) { + Logger.error(`Cart not found for user with ID ${userId}`); + throw new ReactionError("invalid-params", "Cart not found"); + } + + const newAppliedPromotions = _.filter(cart.appliedPromotions, (appliedPromotion) => appliedPromotion._id !== promotionId); + if (newAppliedPromotions.length === cart.appliedPromotions.length) { + Logger.error(`Promotion ${promotionId} not found on cart ${cartId}`); + throw new ReactionError("invalid-params", "Can't remove coupon because it's not on the cart"); + } + + cart.appliedPromotions = newAppliedPromotions; + + const updatedCart = await context.mutations.saveCart(context, cart); + return updatedCart; +} diff --git a/packages/api-plugin-promotions-coupons/src/mutations/removeCouponFromCart.test.js b/packages/api-plugin-promotions-coupons/src/mutations/removeCouponFromCart.test.js new file mode 100644 index 00000000000..731cb5e2bdc --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/mutations/removeCouponFromCart.test.js @@ -0,0 +1,91 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import removeCouponFromCart from "./removeCouponFromCart.js"; + +test("throws if validation check fails", async () => { + const input = { shopId: "123", cartId: "123" }; + + try { + await removeCouponFromCart(mockContext, input); + } catch (error) { + expect(error.error).toEqual("validation-error"); + } +}); + +test("throws error when cart does not exist with userId", async () => { + const input = { shopId: "123", cartId: "123", promotionId: "promotionId" }; + mockContext.collections = { + Accounts: { + findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(null)) + }, + Cart: { + findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(null)) + } + }; + + try { + await removeCouponFromCart(mockContext, input); + } catch (error) { + expect(error.message).toEqual("Cart not found"); + } +}); + +test("throws error when cart does not exist", async () => { + const input = { shopId: "123", cartId: "123", promotionId: "promotionId" }; + const account = { _id: "accountId" }; + mockContext.collections = { + Accounts: { + findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(account)) + }, + Cart: { + findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(null)) + } + }; + + try { + await removeCouponFromCart(mockContext, input); + } catch (error) { + expect(error.error).toEqual("invalid-params"); + expect(error.message).toEqual("Cart not found"); + } +}); + +test("throws error when promotionId is not found on cart", async () => { + const input = { shopId: "123", cartId: "123", promotionId: "promotionId" }; + const account = { _id: "accountId" }; + const cart = { appliedPromotions: [{ _id: "promotionId2" }] }; + mockContext.collections = { + Accounts: { + findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(account)) + }, + Cart: { + findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(cart)) + } + }; + + try { + await removeCouponFromCart(mockContext, input); + } catch (error) { + expect(error.error).toEqual("invalid-params"); + expect(error.message).toEqual("Can't remove coupon because it's not on the cart"); + } +}); + +test("removes coupon from cart", async () => { + const input = { shopId: "123", cartId: "123", promotionId: "promotionId" }; + const account = { _id: "accountId" }; + const cart = { appliedPromotions: [{ _id: "promotionId" }] }; + mockContext.collections = { + Accounts: { + findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(account)) + }, + Cart: { + findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(cart)) + } + }; + mockContext.mutations = { + saveCart: jest.fn().mockName("mutations.saveCart").mockReturnValueOnce(Promise.resolve({})) + }; + + const result = await removeCouponFromCart(mockContext, input); + expect(result).toEqual({}); +}); diff --git a/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/index.js b/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/index.js index beaab1fbe59..9eb2b58e135 100644 --- a/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/index.js +++ b/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/index.js @@ -1,7 +1,9 @@ import applyCouponToCart from "./applyCouponToCart.js"; import createStandardCoupon from "./createStandardCoupon.js"; +import removeCouponFromCart from "./removeCouponFromCart.js"; export default { applyCouponToCart, - createStandardCoupon + createStandardCoupon, + removeCouponFromCart }; diff --git a/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/removeCouponFromCart.js b/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/removeCouponFromCart.js new file mode 100644 index 00000000000..4b432ff9ad5 --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/removeCouponFromCart.js @@ -0,0 +1,14 @@ +/** + * @method removeCouponFromCart + * @summary Apply a coupon to the cart + * @param {Object} _ unused + * @param {Object} args.input - The input arguments + * @param {Object} args.input.cartId - The cart ID + * @param {Object} args.input.couponCode - The promotion IDs + * @param {Object} context - The application context + * @returns {Promise} with updated cart + */ +export default async function removeCouponFromCart(_, { input }, context) { + const updatedCart = await context.mutations.removeCouponFromCart(context, input); + return { cart: updatedCart }; +} diff --git a/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/removeCouponFromCart.test.js b/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/removeCouponFromCart.test.js new file mode 100644 index 00000000000..a7c86dbf65e --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/removeCouponFromCart.test.js @@ -0,0 +1,15 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import removeCouponFromCart from "./removeCouponFromCart.js"; + +test("calls mutations.removeCouponFromCart and returns the result", async () => { + const input = { cartId: "123", couponCode: "CODE" }; + const result = { _id: "123 " }; + mockContext.mutations = { + removeCouponFromCart: jest.fn().mockName("mutations.removeCouponFromCart").mockReturnValueOnce(Promise.resolve(result)) + }; + + const removedCoupon = await removeCouponFromCart(null, { input }, mockContext); + + expect(removedCoupon).toEqual({ cart: result }); + expect(mockContext.mutations.removeCouponFromCart).toHaveBeenCalledWith(mockContext, input); +}); diff --git a/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql b/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql index d500d89491a..75780164f0c 100644 --- a/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql +++ b/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql @@ -99,6 +99,67 @@ input CouponFilter { userId: ID } +"The input for the createStandardCoupon mutation" +input CreateStandardCouponInput { + "The shop ID" + shopId: ID! + + "The promotion ID" + promotionId: ID! + + "The coupon code" + code: String! + + "Can use this coupon in the store" + canUseInStore: Boolean! + + "The number of times this coupon can be used per user" + maxUsageTimesPerUser: Int + + "The number of times this coupon can be used" + maxUsageTimes: Int +} + +input CouponQueryInput { + "The unique ID of the coupon" + _id: String! + + "The unique ID of the shop" + shopId: String! +} + +input CouponFilter { + "The expiration date of the coupon" + expirationDate: Date + + "The related promotion ID" + promotionId: ID + + "The coupon code" + code: String + + "The coupon name" + userId: ID +} + +"Input for the removeCouponFromCart mutation" +input RemoveCouponFromCartInput { + + shopId: ID! + + "The ID of the Cart" + cartId: ID! + + "The promotion that contains the coupon to remove" + promotionId: ID! + + "The account ID of the user who is applying the coupon" + accountId: ID + + "Cart token, if anonymous" + token: String +} + "The response for the applyCouponToCart mutation" type ApplyCouponToCartPayload { cart: Cart @@ -109,6 +170,11 @@ type StandardCouponPayload { coupon: Coupon! } +"The response for the removeCouponFromCart mutation" +type RemoveCouponFromCartPayload { + cart: Cart +} + "A connection edge in which each node is a `Coupon` object" type CouponEdge { "The cursor that represents this node in the paginated results" @@ -176,9 +242,15 @@ extend type Mutation { input: ApplyCouponToCartInput ): ApplyCouponToCartPayload -"Create a standard coupon mutation" + "Create a standard coupon mutation" createStandardCoupon( "The createStandardCoupon mutation input" input: CreateStandardCouponInput ): StandardCouponPayload + + "Remove a coupon from a cart" + removeCouponFromCart( + "The removeCouponFromCart mutation input" + input: RemoveCouponFromCartInput + ): RemoveCouponFromCartPayload } From 343703af0f17ac557fb284efd07569c1d99dcbe9 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Mon, 30 Jan 2023 16:41:50 +0700 Subject: [PATCH 04/37] feat: update coupon trigger parameter schema Signed-off-by: vanpho93 --- .../src/preStartup.js | 11 ++++++++++- .../src/simpleSchemas.js | 19 ++++++++++++++++--- .../api-plugin-promotions-offers/src/index.js | 5 +++-- .../src/mutations/createPromotion.js | 2 ++ .../src/mutations/createPromotion.test.js | 11 +++++++++++ 5 files changed, 42 insertions(+), 6 deletions(-) diff --git a/packages/api-plugin-promotions-coupons/src/preStartup.js b/packages/api-plugin-promotions-coupons/src/preStartup.js index 06aacf3e9b2..8a59a5ae2e1 100644 --- a/packages/api-plugin-promotions-coupons/src/preStartup.js +++ b/packages/api-plugin-promotions-coupons/src/preStartup.js @@ -1,5 +1,6 @@ import _ from "lodash"; import SimpleSchema from "simpl-schema"; +import { CouponTriggerCondition, CouponTriggerParameters } from "./simpleSchemas.js"; /** * @summary This is a preStartup function that is called before the app starts up. @@ -7,7 +8,15 @@ import SimpleSchema from "simpl-schema"; * @returns {undefined} */ export default async function preStartupPromotionCoupon(context) { - const { simpleSchemas: { Cart, Promotion }, promotions: pluginPromotions } = context; + const { simpleSchemas: { Cart, Promotion, RuleExpression }, promotions: pluginPromotions } = context; + + CouponTriggerCondition.extend({ + conditions: RuleExpression + }); + + CouponTriggerParameters.extend({ + conditions: RuleExpression + }); // 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"); diff --git a/packages/api-plugin-promotions-coupons/src/simpleSchemas.js b/packages/api-plugin-promotions-coupons/src/simpleSchemas.js index 825fee4cfe7..957c2000de1 100644 --- a/packages/api-plugin-promotions-coupons/src/simpleSchemas.js +++ b/packages/api-plugin-promotions-coupons/src/simpleSchemas.js @@ -1,9 +1,22 @@ import SimpleSchema from "simpl-schema"; +export const CouponTriggerCondition = new SimpleSchema({ + conditions: { + type: Object + } +}); + export const CouponTriggerParameters = new SimpleSchema({ - name: String, - couponCode: { - type: String + conditions: { + type: Object + }, + inclusionRules: { + type: CouponTriggerCondition, + optional: true + }, + exclusionRules: { + type: CouponTriggerCondition, + optional: true } }); diff --git a/packages/api-plugin-promotions-offers/src/index.js b/packages/api-plugin-promotions-offers/src/index.js index 7b228dea862..c1ef574d440 100644 --- a/packages/api-plugin-promotions-offers/src/index.js +++ b/packages/api-plugin-promotions-offers/src/index.js @@ -3,7 +3,7 @@ import triggers from "./triggers/index.js"; import enhancers from "./enhancers/index.js"; import facts from "./facts/index.js"; import { promotionOfferFacts, registerPromotionOfferFacts } from "./registration.js"; -import { ConditionRule } from "./simpleSchemas.js"; +import { ConditionRule, RuleExpression } from "./simpleSchemas.js"; import preStartupPromotionOffer from "./preStartup.js"; const require = createRequire(import.meta.url); @@ -32,7 +32,8 @@ export default async function register(app) { }, promotionOfferFacts: facts, simpleSchemas: { - ConditionRule + ConditionRule, + RuleExpression } }); } diff --git a/packages/api-plugin-promotions/src/mutations/createPromotion.js b/packages/api-plugin-promotions/src/mutations/createPromotion.js index fc08a30fe9f..cc1c1cfa910 100644 --- a/packages/api-plugin-promotions/src/mutations/createPromotion.js +++ b/packages/api-plugin-promotions/src/mutations/createPromotion.js @@ -1,4 +1,5 @@ import Random from "@reactioncommerce/random"; +import ReactionError from "@reactioncommerce/reaction-error"; import validateActionParams from "./validateActionParams.js"; import validateTriggerParams from "./validateTriggerParams.js"; @@ -16,6 +17,7 @@ export default async function createPromotion(context, promotion) { const [firstTrigger] = promotion.triggers; // currently support only one trigger const { triggerKey } = firstTrigger; const trigger = promotions.triggers.find((tr) => tr.key === triggerKey); + if (!trigger) throw new ReactionError("invalid-params", `No trigger found with key ${triggerKey}`); promotion.triggerType = trigger.type; } promotion.state = "created"; diff --git a/packages/api-plugin-promotions/src/mutations/createPromotion.test.js b/packages/api-plugin-promotions/src/mutations/createPromotion.test.js index 4e4fde994b5..3ad3c0f46f3 100644 --- a/packages/api-plugin-promotions/src/mutations/createPromotion.test.js +++ b/packages/api-plugin-promotions/src/mutations/createPromotion.test.js @@ -153,3 +153,14 @@ test("will insert a record if it passes validation", async () => { expect(error).toBeUndefined(); } }); + +test("should throw error when triggerKey is not valid", async () => { + const promotion = _.cloneDeep(CreateOrderPromotion); + promotion.triggers[0].triggerKey = "invalid"; + try { + await createPromotion(mockContext, promotion); + } catch (error) { + expect(error.error).toEqual("invalid-params"); + expect(error.message).toEqual("No trigger found with key invalid"); + } +}); From 1d8309831592962643abcb4dcc5c4e913943a716 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Wed, 1 Feb 2023 13:38:44 +0700 Subject: [PATCH 05/37] feat: add name field to coupon Signed-off-by: vanpho93 --- .../src/mutations/createStandardCoupon.js | 2 + .../mutations/createStandardCoupon.test.js | 9 ++-- .../src/schemas/schema.graphql | 45 ++----------------- .../src/simpleSchemas.js | 1 + 4 files changed, 12 insertions(+), 45 deletions(-) diff --git a/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.js b/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.js index 6c898fd27b6..05ded1f4e83 100644 --- a/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.js +++ b/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.js @@ -7,6 +7,7 @@ import { Coupon } from "../simpleSchemas.js"; const inputSchema = new SimpleSchema({ shopId: String, promotionId: String, + name: String, code: String, canUseInStore: Boolean, maxUsageTimesPerUser: { @@ -50,6 +51,7 @@ export default async function createStandardCoupon(context, input) { const now = new Date(); const coupon = { _id: Random.id(), + name: input.name, code: input.code, shopId, promotionId, 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 d486c8889eb..ad1fd7af620 100644 --- a/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.test.js +++ b/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.test.js @@ -12,7 +12,7 @@ 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 input = { name: "test", code: "CODE", shopId: "123", promotionId: "123", canUseInStore: true }; const coupon = { _id: "123", code: "CODE", promotionId: "promotionId" }; const promotion = { _id: "promotionId" }; mockContext.collections = { @@ -40,7 +40,7 @@ test("throws error when coupon code already created", async () => { }); test("throws error when promotion does not exist", async () => { - const input = { code: "CODE", shopId: "123", promotionId: "123", canUseInStore: true }; + const input = { name: "test", code: "CODE", shopId: "123", promotionId: "123", canUseInStore: true }; mockContext.collections = { Coupons: { findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(null)) @@ -59,7 +59,7 @@ test("throws error when promotion does not exist", async () => { test("throws error when coupon code already exists in promotion window", async () => { const now = new Date(); - const input = { code: "CODE", shopId: "123", promotionId: "123", canUseInStore: true }; + const input = { name: "test", code: "CODE", shopId: "123", promotionId: "123", canUseInStore: true }; const promotion = { _id: "123", startDate: now, endDate: now }; const existsPromotion = { _id: "1234", startDate: now, endDate: now }; const coupon = { _id: "123", code: "CODE", promotionId: "123" }; @@ -86,7 +86,7 @@ test("throws error when coupon code already exists in promotion window", async ( test("should insert a new coupon and return the created results", async () => { const now = new Date(); - const input = { code: "CODE", shopId: "123", promotionId: "123", canUseInStore: true }; + const input = { name: "test", code: "CODE", shopId: "123", promotionId: "123", canUseInStore: true }; const promotion = { _id: "123", endDate: now }; mockContext.collections = { @@ -112,6 +112,7 @@ test("should insert a new coupon and return the created results", async () => { coupon: { _id: "123", canUseInStore: true, + name: "test", code: "CODE", createdAt: jasmine.any(Date), expirationDate: now, diff --git a/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql b/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql index 75780164f0c..ea943c7c465 100644 --- a/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql +++ b/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql @@ -11,6 +11,9 @@ type Coupon { "The coupon owner ID" userId: ID + "The coupon name" + name: String! + "The coupon code" code: String! @@ -64,48 +67,8 @@ input CreateStandardCouponInput { "The promotion ID" promotionId: ID! - "The coupon code" - code: String! - - "Can use this coupon in the store" - canUseInStore: Boolean! - - "The number of times this coupon can be used per user" - maxUsageTimesPerUser: Int - - "The number of times this coupon can be used" - maxUsageTimes: Int -} - -input CouponQueryInput { - "The unique ID of the coupon" - _id: String! - - "The unique ID of the shop" - shopId: String! -} - -input CouponFilter { - "The expiration date of the coupon" - expirationDate: Date - - "The related promotion ID" - promotionId: ID - - "The coupon code" - code: String - "The coupon name" - userId: ID -} - -"The input for the createStandardCoupon mutation" -input CreateStandardCouponInput { - "The shop ID" - shopId: ID! - - "The promotion ID" - promotionId: ID! + name: String! "The coupon code" code: String! diff --git a/packages/api-plugin-promotions-coupons/src/simpleSchemas.js b/packages/api-plugin-promotions-coupons/src/simpleSchemas.js index 957c2000de1..e3a0b6c9deb 100644 --- a/packages/api-plugin-promotions-coupons/src/simpleSchemas.js +++ b/packages/api-plugin-promotions-coupons/src/simpleSchemas.js @@ -22,6 +22,7 @@ export const CouponTriggerParameters = new SimpleSchema({ export const Coupon = new SimpleSchema({ _id: String, + name: String, code: String, shopId: String, promotionId: String, From 366a11a2d74f97e7aff309a8c013fd56ef195c20 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Wed, 1 Feb 2023 09:46:40 +0700 Subject: [PATCH 06/37] feat: add additional coupon validation Signed-off-by: vanpho93 --- .../src/mutations/createStandardCoupon.js | 6 +- .../mutations/createStandardCoupon.test.js | 3 +- .../src/mutations/index.js | 2 + .../src/mutations/updateStandardCoupon.js | 83 ++++++++++ .../mutations/updateStandardCoupon.test.js | 146 ++++++++++++++++++ .../src/resolvers/Mutation/index.js | 2 + .../Mutation/updateStandardCoupon.js | 19 +++ .../Mutation/updateStandardCoupon.test.js | 26 ++++ .../src/schemas/schema.graphql | 51 ++++++ 9 files changed, 336 insertions(+), 2 deletions(-) create mode 100644 packages/api-plugin-promotions-coupons/src/mutations/updateStandardCoupon.js create mode 100644 packages/api-plugin-promotions-coupons/src/mutations/updateStandardCoupon.test.js create mode 100644 packages/api-plugin-promotions-coupons/src/resolvers/Mutation/updateStandardCoupon.js create mode 100644 packages/api-plugin-promotions-coupons/src/resolvers/Mutation/updateStandardCoupon.test.js diff --git a/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.js b/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.js index 05ded1f4e83..eee46dbe0a8 100644 --- a/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.js +++ b/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.js @@ -43,7 +43,11 @@ export default async function createStandardCoupon(context, input) { for (const existsPromotion of promotions) { if (existsPromotion.startDate <= promotion.startDate && existsPromotion.endDate >= promotion.endDate) { - throw new ReactionError("invalid-params", `A coupon code ${code} already exists in this promotion window`); + throw new ReactionError( + "invalid-params", + // eslint-disable-next-line max-len + "A promotion with this coupon code is already set to be active during part of this promotion window. Please either adjust your coupon code or your Promotion Start and End Dates" + ); } } } 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 ad1fd7af620..2bd5f8305c6 100644 --- a/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.test.js +++ b/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.test.js @@ -80,7 +80,8 @@ test("throws error when coupon code already exists in promotion window", async ( try { await createStandardCoupon(mockContext, input); } catch (error) { - expect(error.message).toEqual("A coupon code CODE already exists in this promotion window"); + // eslint-disable-next-line max-len + expect(error.message).toEqual("A promotion with this coupon code is already set to be active during part of this promotion window. Please either adjust your coupon code or your Promotion Start and End Dates"); } }); diff --git a/packages/api-plugin-promotions-coupons/src/mutations/index.js b/packages/api-plugin-promotions-coupons/src/mutations/index.js index 9eb2b58e135..e8faea52fab 100644 --- a/packages/api-plugin-promotions-coupons/src/mutations/index.js +++ b/packages/api-plugin-promotions-coupons/src/mutations/index.js @@ -1,9 +1,11 @@ import applyCouponToCart from "./applyCouponToCart.js"; import createStandardCoupon from "./createStandardCoupon.js"; +import updateStandardCoupon from "./updateStandardCoupon.js"; import removeCouponFromCart from "./removeCouponFromCart.js"; export default { applyCouponToCart, createStandardCoupon, + updateStandardCoupon, removeCouponFromCart }; diff --git a/packages/api-plugin-promotions-coupons/src/mutations/updateStandardCoupon.js b/packages/api-plugin-promotions-coupons/src/mutations/updateStandardCoupon.js new file mode 100644 index 00000000000..8bb2c8e4f81 --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/mutations/updateStandardCoupon.js @@ -0,0 +1,83 @@ +import _ from "lodash"; +import SimpleSchema from "simpl-schema"; +import ReactionError from "@reactioncommerce/reaction-error"; +import { Coupon } from "../simpleSchemas.js"; + +const inputSchema = new SimpleSchema({ + _id: String, + shopId: String, + name: { + type: String, + optional: true + }, + code: { + type: String, + optional: true + }, + canUseInStore: { + type: Boolean, + optional: true + }, + maxUsageTimesPerUser: { + type: Number, + optional: true + }, + maxUsageTimes: { + type: Number, + optional: true + } +}); + +/** + * @method updateStandardCoupon + * @summary Update a standard coupon mutation + * @param {Object} context - The application context + * @param {Object} input - The coupon input to create + * @returns {Promise} with updated coupon result + */ +export default async function updateStandardCoupon(context, input) { + inputSchema.validate(input); + + const { collections: { Coupons, Promotions } } = context; + const { shopId, _id: couponId } = input; + + const coupon = await Coupons.findOne({ _id: couponId, shopId }); + if (!coupon) throw new ReactionError("not-found", "Coupon not found"); + + const promotion = await Promotions.findOne({ _id: coupon.promotionId, shopId }); + if (!promotion) throw new ReactionError("not-found", "Promotion not found"); + + const now = new Date(); + if (promotion.startDate <= now) { + throw new ReactionError("invalid-params", "This coupon cannot be edited because the promotion is on the window time"); + } + + if (input.code && coupon.code !== input.code) { + const existsCoupons = await Coupons.find({ code: input.code, shopId, _id: { $ne: coupon._id } }).toArray(); + if (existsCoupons.length > 0) { + const promotionIds = _.map(existsCoupons, "promotionId"); + const promotions = await Promotions.find({ _id: { $in: promotionIds } }).toArray(); + for (const existsPromotion of promotions) { + if (existsPromotion.startDate <= promotion.startDate && existsPromotion.endDate >= promotion.endDate) { + throw new ReactionError( + "invalid-params", + // eslint-disable-next-line max-len + "A promotion with this coupon code is already set to be active during part of this promotion window. Please either adjust your coupon code or your Promotion Start and End Dates" + ); + } + } + } + } + + const modifiedCoupon = _.merge(coupon, input); + modifiedCoupon.updatedAt = now; + + Coupon.clean(modifiedCoupon, { mutate: true }); + Coupon.validate(modifiedCoupon); + + const modifier = { $set: modifiedCoupon }; + const results = await Coupons.findOneAndUpdate({ _id: couponId, shopId }, modifier, { returnDocument: "after" }); + + const { modifiedCount, value } = results; + return { success: !!modifiedCount, coupon: value }; +} diff --git a/packages/api-plugin-promotions-coupons/src/mutations/updateStandardCoupon.test.js b/packages/api-plugin-promotions-coupons/src/mutations/updateStandardCoupon.test.js new file mode 100644 index 00000000000..4d0bf8b27db --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/mutations/updateStandardCoupon.test.js @@ -0,0 +1,146 @@ +import _ from "lodash"; +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import updateStandardCoupon from "./updateStandardCoupon.js"; + +const now = new Date(); +const mockCoupon = { + _id: "123", + code: "CODE", + promotionId: "123", + shopId: "123", + canUseInStore: false, + usedCount: 0, + createdAt: now, + updatedAt: now, + maxUsageTimes: 10, + maxUsageTimesPerUser: 1 +}; + +test("throws if validation check fails", async () => { + const input = { code: "CODE" }; + + try { + await updateStandardCoupon(mockContext, input); + } catch (error) { + expect(error.error).toEqual("validation-error"); + } +}); + +test("throws error when coupon does not exist", async () => { + const input = { code: "CODE", _id: "123", shopId: "123", canUseInStore: true }; + mockContext.collections = { + Coupons: { + findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(null)) + } + }; + try { + await updateStandardCoupon(mockContext, input); + } catch (error) { + expect(error.message).toEqual("Coupon not found"); + } +}); + +test("throws error when promotion does not exist", async () => { + const input = { code: "CODE", shopId: "123", _id: "123" }; + const coupon = _.cloneDeep(mockCoupon); + mockContext.collections = { + Coupons: { + findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(coupon)) + }, + Promotions: { + findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(null)) + } + }; + + try { + await updateStandardCoupon(mockContext, input); + } catch (error) { + expect(error.message).toEqual("Promotion not found"); + } +}); + +test("throws error when the related promotion is in promotion window", async () => { + const input = { code: "CODE", shopId: "123", _id: "123" }; + const promotion = { _id: "123", startDate: now, endDate: now }; + const coupon = _.cloneDeep(mockCoupon); + mockContext.collections = { + Coupons: { + findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(coupon)) + }, + Promotions: { + findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(promotion)) + } + }; + + try { + await updateStandardCoupon(mockContext, input); + } catch (error) { + expect(error.message).toEqual("This coupon cannot be edited because the promotion is on the window time"); + } +}); + +test("throws error when coupon code already exists in promotion window", async () => { + const input = { code: "NEW_CODE", shopId: "123", _id: "123" }; + const promotion = { + _id: "123", + startDate: new Date(now.getTime() + 1000 * 60 * 60 * 24 * 1), + endDate: new Date(now.getTime() + 1000 * 60 * 60 * 24 * 7) + }; + const existsPromotion = { + _id: "1234", + startDate: now, + endDate: new Date(now.getTime() + 1000 * 60 * 60 * 24 * 10) + }; + const coupon = _.cloneDeep(mockCoupon); + mockContext.collections = { + Coupons: { + find: jest.fn().mockReturnValue({ + toArray: jest.fn().mockResolvedValueOnce(Promise.resolve([coupon])) + }), + findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(coupon)) + }, + Promotions: { + findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(promotion)), + find: jest.fn().mockReturnValue({ + toArray: jest.fn().mockResolvedValueOnce(Promise.resolve([existsPromotion])) + }) + } + }; + + try { + await updateStandardCoupon(mockContext, input); + } catch (error) { + // eslint-disable-next-line max-len + expect(error.message).toEqual("A promotion with this coupon code is already set to be active during part of this promotion window. Please either adjust your coupon code or your Promotion Start and End Dates"); + } +}); + +test("should update coupon and return the updated results", async () => { + const input = { name: "test", code: "CODE", shopId: "123", _id: "123", canUseInStore: true }; + const promotion = { _id: "123", endDate: now }; + const coupon = _.cloneDeep(mockCoupon); + mockContext.collections = { + Coupons: { + find: jest.fn().mockReturnValue({ + toArray: jest.fn().mockResolvedValueOnce([]) + }), + findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(coupon)), + findOneAndUpdate: jest.fn().mockResolvedValueOnce(Promise.resolve({ modifiedCount: 1, value: { _id: "123" } })) + }, + Promotions: { + findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(promotion)) + } + }; + + const result = await updateStandardCoupon(mockContext, input); + + expect(mockContext.collections.Coupons.findOneAndUpdate).toHaveBeenCalledTimes(1); + expect(mockContext.collections.Coupons.findOne).toHaveBeenCalledTimes(1); + + expect(result).toEqual({ + success: true, + coupon: { + _id: "123" + } + }); +}); diff --git a/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/index.js b/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/index.js index 9eb2b58e135..e8faea52fab 100644 --- a/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/index.js +++ b/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/index.js @@ -1,9 +1,11 @@ import applyCouponToCart from "./applyCouponToCart.js"; import createStandardCoupon from "./createStandardCoupon.js"; +import updateStandardCoupon from "./updateStandardCoupon.js"; import removeCouponFromCart from "./removeCouponFromCart.js"; export default { applyCouponToCart, createStandardCoupon, + updateStandardCoupon, removeCouponFromCart }; diff --git a/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/updateStandardCoupon.js b/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/updateStandardCoupon.js new file mode 100644 index 00000000000..60281d811a7 --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/updateStandardCoupon.js @@ -0,0 +1,19 @@ +/** + * @method updateStandardCoupon + * @summary Update a standard coupon mutation + * @param {Object} _ unused + * @param {Object} args.input - The input arguments + * @param {Object} args.input.shopId - The shop ID + * @param {Object} args.input.couponId - The coupon ID + * @param {Object} args.input.promotionId - The promotion ID + * @param {Object} context - The application context + * @returns {Promise} with updated coupon result + */ +export default async function updateStandardCoupon(_, { input }, context) { + const { shopId } = input; + + await context.validatePermissions("reaction:legacy:promotions", "update", { shopId }); + + const updatedCouponResult = await context.mutations.updateStandardCoupon(context, input); + return updatedCouponResult; +} diff --git a/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/updateStandardCoupon.test.js b/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/updateStandardCoupon.test.js new file mode 100644 index 00000000000..6cb9b99cb9f --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/updateStandardCoupon.test.js @@ -0,0 +1,26 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import updateStandardCoupon from "./updateStandardCoupon.js"; + +test("throws if permission check fails", async () => { + const input = { name: "Test coupon", code: "CODE" }; + mockContext.validatePermissions.mockResolvedValue(Promise.reject(new Error("Access Denied"))); + + try { + await updateStandardCoupon(null, { input }, mockContext); + } catch (error) { + expect(error.message).toEqual("Access Denied"); + } +}); + +test("calls mutations.updateStandardCoupon and returns the result", async () => { + const input = { name: "Test coupon", code: "CODE", couponId: "testId" }; + const result = { _id: "123" }; + mockContext.validatePermissions.mockResolvedValue(Promise.resolve()); + mockContext.mutations = { + updateStandardCoupon: jest.fn().mockName("mutations.updateStandardCoupon").mockReturnValueOnce(Promise.resolve(result)) + }; + + const createdCoupon = await updateStandardCoupon(null, { input }, mockContext); + + expect(createdCoupon).toEqual(result); +}); diff --git a/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql b/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql index ea943c7c465..e3ec6018619 100644 --- a/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql +++ b/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql @@ -83,6 +83,51 @@ input CreateStandardCouponInput { maxUsageTimes: Int } +"Input for the updateStandardCoupon mutation" +input UpdateStandardCouponInput { + "The coupon ID" + _id: ID! + + "The shop ID" + shopId: ID! + + "The coupon name" + name: String + + "The coupon code" + code: String + + "Can use this coupon in the store" + canUseInStore: Boolean + + "The number of times this coupon can be used per user" + maxUsageTimesPerUser: Int + + "The number of times this coupon can be used" + maxUsageTimes: Int +} + +"The input for the createStandardCoupon mutation" +input CreateStandardCouponInput { + "The shop ID" + shopId: ID! + + "The promotion ID" + promotionId: ID! + + "The coupon code" + code: String! + + "Can use this coupon in the store" + canUseInStore: Boolean! + + "The number of times this coupon can be used per user" + maxUsageTimesPerUser: Int + + "The number of times this coupon can be used" + maxUsageTimes: Int +} + input CouponQueryInput { "The unique ID of the coupon" _id: String! @@ -211,6 +256,12 @@ extend type Mutation { input: CreateStandardCouponInput ): StandardCouponPayload + "Update a standard coupon mutation" + updateStandardCoupon( + "The updateStandardCoupon mutation input" + input: UpdateStandardCouponInput + ): StandardCouponPayload + "Remove a coupon from a cart" removeCouponFromCart( "The removeCouponFromCart mutation input" From 729b4a5e429539f49e9e3ed6512b8520e233d4e8 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Mon, 6 Feb 2023 16:39:45 +0700 Subject: [PATCH 07/37] feat: update promotion error message Signed-off-by: vanpho93 --- .../src/mutations/updateStandardCoupon.js | 2 +- .../src/mutations/updateStandardCoupon.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/api-plugin-promotions-coupons/src/mutations/updateStandardCoupon.js b/packages/api-plugin-promotions-coupons/src/mutations/updateStandardCoupon.js index 8bb2c8e4f81..a32b9273d37 100644 --- a/packages/api-plugin-promotions-coupons/src/mutations/updateStandardCoupon.js +++ b/packages/api-plugin-promotions-coupons/src/mutations/updateStandardCoupon.js @@ -49,7 +49,7 @@ export default async function updateStandardCoupon(context, input) { const now = new Date(); if (promotion.startDate <= now) { - throw new ReactionError("invalid-params", "This coupon cannot be edited because the promotion is on the window time"); + throw new ReactionError("invalid-params", "Cannot update a coupon for a promotion that has already started"); } if (input.code && coupon.code !== input.code) { diff --git a/packages/api-plugin-promotions-coupons/src/mutations/updateStandardCoupon.test.js b/packages/api-plugin-promotions-coupons/src/mutations/updateStandardCoupon.test.js index 4d0bf8b27db..5a60dabff6e 100644 --- a/packages/api-plugin-promotions-coupons/src/mutations/updateStandardCoupon.test.js +++ b/packages/api-plugin-promotions-coupons/src/mutations/updateStandardCoupon.test.js @@ -75,7 +75,7 @@ test("throws error when the related promotion is in promotion window", async () try { await updateStandardCoupon(mockContext, input); } catch (error) { - expect(error.message).toEqual("This coupon cannot be edited because the promotion is on the window time"); + expect(error.message).toEqual("Cannot update a coupon for a promotion that has already started"); } }); From da1de62b187c3e170ca28a55c07a0f7040241008 Mon Sep 17 00:00:00 2001 From: Chloe Date: Wed, 8 Feb 2023 14:29:46 +0700 Subject: [PATCH 08/37] fix: add coupon to promotion Signed-off-by: Chloe --- .../src/loaders/loadPromotions.js | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/api-plugin-sample-data/src/loaders/loadPromotions.js b/packages/api-plugin-sample-data/src/loaders/loadPromotions.js index 17852d8d08c..d2155c2ae81 100644 --- a/packages/api-plugin-sample-data/src/loaders/loadPromotions.js +++ b/packages/api-plugin-sample-data/src/loaders/loadPromotions.js @@ -110,8 +110,7 @@ const CouponPromotion = { { triggerKey: "coupons", triggerParameters: { - name: "Specific coupon code", - couponCode: "CODE" + conditions: {} } } ], @@ -121,8 +120,7 @@ const CouponPromotion = { actionParameters: {} } ], - startDate: now, - endDate: new Date(now.getTime() + 1000 * 60 * 60 * 24 * 7), + startDate: new Date(now.getTime() + 1000 * 60 * 60 * 24 * 7), stackability: { key: "all", parameters: {} @@ -131,6 +129,16 @@ const CouponPromotion = { updatedAt: new Date() }; +const Coupon = { + _id: "couponId", + code: "CODE", + name: "20% OFF coupon", + promotionId: CouponPromotion._id, + canUseInStore: false, + createdAt: new Date(), + updatedAt: new Date() +}; + const promotions = [OrderPromotion, OrderItemPromotion, CouponPromotion]; /** @@ -142,7 +150,7 @@ const promotions = [OrderPromotion, OrderItemPromotion, CouponPromotion]; export default async function loadPromotions(context, shopId) { const { simpleSchemas: { Promotion: PromotionSchema }, - collections: { Promotions } + collections: { Promotions, Coupons } } = context; for (const promotion of promotions) { promotion.shopId = shopId; @@ -150,4 +158,7 @@ export default async function loadPromotions(context, shopId) { // eslint-disable-next-line no-await-in-loop await Promotions.updateOne({ _id: promotion._id }, { $set: promotion }, { upsert: true }); } + + Coupon.shopId = shopId; + await Coupons.updateOne({ _id: Coupon._id }, { $set: Coupon }, { upsert: true }); } From f18b2e88fe9507d47077d3086680a6a36c1c100c Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Wed, 1 Feb 2023 13:32:16 +0700 Subject: [PATCH 09/37] feat: create migration for old discoupon Signed-off-by: vanpho93 --- package.json | 2 +- .../api-plugin-promotions-coupons/index.js | 2 + .../migrations/2.js | 194 ++++++++++++++++++ .../migrations/getCurrentShopTime.js | 31 +++ .../migrations/index.js | 13 ++ .../migrations/migrationsNamespace.js | 1 + .../api-plugin-promotions/src/preStartup.js | 4 + 7 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 packages/api-plugin-promotions-coupons/migrations/2.js create mode 100644 packages/api-plugin-promotions-coupons/migrations/getCurrentShopTime.js create mode 100644 packages/api-plugin-promotions-coupons/migrations/index.js create mode 100644 packages/api-plugin-promotions-coupons/migrations/migrationsNamespace.js diff --git a/package.json b/package.json index 776e2a88500..3be3f071141 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "engineStrict": true, "scripts": { - "start:dev": "npm run start:dev -w apps/reaction", + "start:dev": "pnpm --filter=reaction run start:dev", "start:meteor-blaze-app": "npm run start -w=apps/meteor-blaze-app", "build:packages": "pnpm -r run build", "test": "pnpm -r run test", diff --git a/packages/api-plugin-promotions-coupons/index.js b/packages/api-plugin-promotions-coupons/index.js index d7ea8b28c59..ff1789c8e87 100644 --- a/packages/api-plugin-promotions-coupons/index.js +++ b/packages/api-plugin-promotions-coupons/index.js @@ -1,3 +1,5 @@ import register from "./src/index.js"; +export { default as migrations } from "./migrations/index.js"; + export default register; diff --git a/packages/api-plugin-promotions-coupons/migrations/2.js b/packages/api-plugin-promotions-coupons/migrations/2.js new file mode 100644 index 00000000000..396a128a2b9 --- /dev/null +++ b/packages/api-plugin-promotions-coupons/migrations/2.js @@ -0,0 +1,194 @@ +/* eslint-disable no-await-in-loop */ +import Random from "@reactioncommerce/random"; +import getCurrentShopTime from "./getCurrentShopTime.js"; + +/** + * @summary returns an auto-incrementing integer id for a specific entity + * @param {Object} db - The db instance + * @param {String} shopId - The shop ID + * @param {String} entity - The entity (normally a collection) that you are tracking the ID for + * @return {Promise} - The auto-incrementing ID to use + */ +async function incrementSequence(db, shopId, entity) { + const { value: { value } } = await db.collection("Sequences").findOneAndUpdate( + { shopId, entity }, + { $inc: { value: 1 } }, + { returnDocument: "after" } + ); + return value; +} + +/** + * @summary Migration current discounts v2 to version 2 + * @param {Object} db MongoDB `Db` instance + * @return {undefined} + */ +async function migrationDiscounts(db) { + const discounts = await db.collection("Discounts").find({}, { _id: 1 }).toArray(); + + // eslint-disable-next-line require-jsdoc + function getDiscountCalculationType(discount) { + if (discount.calculation.method === "discount") return "percentage"; + if (discount.calculation.method === "shipping") return "shipping"; + if (discount.calculation.method === "sale") return "flat"; + return "fixed"; + } + + for (const { _id } of discounts) { + const discount = await db.collection("Discounts").findOne({ _id }); + const promotionId = Random.id(); + + const now = new Date(); + const shopTime = await getCurrentShopTime(db); + + // eslint-disable-next-line no-await-in-loop + await db.collection("Promotions").insertOne({ + _id: promotionId, + shopId: discount.shopId, + name: discount.code, + label: discount.code, + description: discount.code, + promotionType: "order-discount", + actions: [ + { + actionKey: "discounts", + actionParameters: { + discountType: discount.discountType === "sale" ? "order" : "item", + discountCalculationType: getDiscountCalculationType(discount), + discountValue: Number(discount.discount) + } + } + ], + triggers: [ + { + triggerKey: "coupons", + triggerParameters: { + conditions: { + all: [ + { + fact: "totalItemAmount", + operator: "greaterThanInclusive", + value: 0 + } + ] + } + } + } + ], + enabled: discount.conditions.enabled, + stackability: { + key: "all", + parameters: {} + }, + triggerType: "explicit", + state: "active", + startDate: shopTime[discount.shopId], + createdAt: now, + updatedAt: now, + referenceId: await incrementSequence(db, discount.shopId, "Promotions") + }); + + const couponId = Random.id(); + await db.collection("Coupons").insertOne({ + _id: couponId, + shopId: discount.shopId, + promotionId, + name: discount.code, + code: discount.code, + canUseInStore: false, + usedCount: 0, + expirationDate: null, + createdAt: now, + updatedAt: now, + maxUsageTimesPerUser: discount.conditions.accountLimit, + maxUsageTimes: discount.conditions.redemptionLimit, + discountId: discount._id + }); + } +} + +/** + * @summary Migration current discount to promotion and coupon + * @param {Object} db - The db instance + * @param {String} discountId - The discount ID + * @returns {Object} - The promotion + */ +async function getPromotionByDiscountId(db, discountId) { + const coupon = await db.collection("Coupons").findOne({ discountId }); + if (!coupon) return null; + const promotion = await db.collection("Promotions").findOne({ _id: coupon.promotionId }); + if (!promotion) return null; + + promotion.relatedCoupon = { + couponId: coupon._id, + couponCode: coupon.code + }; + + return promotion; +} + +/** + * @summary Migration current cart v1 to version 2 + * @param {Object} db - The db instance + * @returns {undefined} + */ +async function migrateCart(db) { + const carts = await db.collection("Cart").find({}, { _id: 1 }).toArray(); + + for (const { _id } of carts) { + const cart = await db.findOne({ _id }); + if (cart.version && cart.version === 2) continue; + + if (!cart.billing) continue; + + if (!cart.appliedPromotions) cart.appliedPromotions = []; + + for (const billing of cart.billing) { + if (!billing.data || !billing.data.discountId) continue; + const promotion = await getPromotionByDiscountId(db, billing.data.discountId); + cart.appliedPromotions.push(promotion); + } + + cart.version = 2; + await db.collection("Cart").updateOne({ _id: cart._id }, { $set: cart }); + } +} + +/** + * @summary Performs migration up from previous data version + * @param {Object} context Migration context + * @param {Object} context.db MongoDB `Db` instance + * @param {Function} context.progress A function to report progress, takes percent + * number as argument. + * @return {undefined} + */ +async function up({ db, progress }) { + try { + await migrationDiscounts(db); + } catch (err) { + throw new Error("Failed to migrate discounts", err.message); + } + + progress(50); + + try { + await migrateCart(db); + } catch (err) { + throw new Error("Failed to migrate cart", err.message); + } + progress(100); +} + +/** + * @summary Performs migration down from previous data version + * @param {Object} context Migration context + * @param {Object} context.db MongoDB `Db` instance + * @param {Function} context.progress A function to report progress, takes percent + * number as argument. + * @return {undefined} + */ +async function down({ progress }) { + progress(100); +} + +export default { down, up }; diff --git a/packages/api-plugin-promotions-coupons/migrations/getCurrentShopTime.js b/packages/api-plugin-promotions-coupons/migrations/getCurrentShopTime.js new file mode 100644 index 00000000000..2b2bcd738ce --- /dev/null +++ b/packages/api-plugin-promotions-coupons/migrations/getCurrentShopTime.js @@ -0,0 +1,31 @@ +/** + * @summary if no data in cache, repopulate + * @param {Object} db - The db instance + * @return {Promise<{Object}>} - The shop timezone object after pushing data to cache + */ +async function populateCache(db) { + const Shops = db.collection("Shops"); + const shopTzObject = {}; + const shops = await Shops.find({}).toArray(); + for (const shop of shops) { + const { _id: shopId } = shop; + shopTzObject[shopId] = shop.timezone; + } + return shopTzObject; +} + +/** + * @summary get the current time in the shops timezone + * @param {Object} db - The db instance + * @return {Promise<{Object}>} - Object of shops and their current time in their timezone + */ +export default async function getCurrentShopTime(db) { + const shopTzData = await populateCache(db); + const shopNow = {}; + for (const shop of Object.keys(shopTzData)) { + const now = new Date().toLocaleString("en-US", { timeZone: shopTzData[shop] }); + const nowDate = new Date(now); + shopNow[shop] = nowDate; + } + return shopNow; +} diff --git a/packages/api-plugin-promotions-coupons/migrations/index.js b/packages/api-plugin-promotions-coupons/migrations/index.js new file mode 100644 index 00000000000..d6ef9ab5586 --- /dev/null +++ b/packages/api-plugin-promotions-coupons/migrations/index.js @@ -0,0 +1,13 @@ +import { migrationsNamespace } from "./migrationsNamespace.js"; +import migration2 from "./2.js"; + +export default { + tracks: [ + { + namespace: migrationsNamespace, + migrations: { + 2: migration2 + } + } + ] +}; diff --git a/packages/api-plugin-promotions-coupons/migrations/migrationsNamespace.js b/packages/api-plugin-promotions-coupons/migrations/migrationsNamespace.js new file mode 100644 index 00000000000..7e4d90470cc --- /dev/null +++ b/packages/api-plugin-promotions-coupons/migrations/migrationsNamespace.js @@ -0,0 +1 @@ +export const migrationsNamespace = "promotion-coupons"; diff --git a/packages/api-plugin-promotions/src/preStartup.js b/packages/api-plugin-promotions/src/preStartup.js index 483926a8ff5..4f2eb28409e 100644 --- a/packages/api-plugin-promotions/src/preStartup.js +++ b/packages/api-plugin-promotions/src/preStartup.js @@ -22,6 +22,10 @@ function extendCartSchema(context) { const { simpleSchemas: { Cart, Promotion } } = context; // we get this here rather then importing it to get the extended version Cart.extend({ + "version": { + type: Number, + optional: true + }, "appliedPromotions": { type: Array, optional: true From 919caa1d537920047c7a871ff02e787b6add06db Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Thu, 9 Feb 2023 13:47:49 +0700 Subject: [PATCH 10/37] fix: add migration down method Signed-off-by: vanpho93 --- package.json | 2 +- .../migrations/2.js | 23 +++++++++++++++++-- .../package.json | 2 +- .../src/preStartup.js | 23 +++++++++++++++++++ .../src/schemas/schema.graphql | 3 +++ .../src/simpleSchemas.js | 4 ++++ pnpm-lock.yaml | 11 +++++---- 7 files changed, 59 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 3be3f071141..776e2a88500 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "engineStrict": true, "scripts": { - "start:dev": "pnpm --filter=reaction run start:dev", + "start:dev": "npm run start:dev -w apps/reaction", "start:meteor-blaze-app": "npm run start -w=apps/meteor-blaze-app", "build:packages": "pnpm -r run build", "test": "pnpm -r run test", diff --git a/packages/api-plugin-promotions-coupons/migrations/2.js b/packages/api-plugin-promotions-coupons/migrations/2.js index 396a128a2b9..b1917d4b24f 100644 --- a/packages/api-plugin-promotions-coupons/migrations/2.js +++ b/packages/api-plugin-promotions-coupons/migrations/2.js @@ -166,7 +166,7 @@ async function up({ db, progress }) { try { await migrationDiscounts(db); } catch (err) { - throw new Error("Failed to migrate discounts", err.message); + throw new Error(`Failed to migrate discounts: ${err.message}`); } progress(50); @@ -187,7 +187,26 @@ async function up({ db, progress }) { * number as argument. * @return {undefined} */ -async function down({ progress }) { +async function down({ db, progress }) { + const coupons = await db.collection("Coupons").find( + { discountId: { $exists: true } }, + { _id: 1, promotionId: 1 } + ).toArray(); + + const couponIds = coupons.map((coupon) => coupon._id); + await db.collection("Coupons").remove({ _id: { $in: couponIds } }); + + const promotionIds = coupons.map((coupon) => coupon.promotionId); + await db.collection("Promotions").remove({ _id: { $in: promotionIds } }); + + const carts = await db.collection("Cart").find({ version: 2 }, { _id: 1 }).toArray(); + for (const { _id } of carts) { + const cart = await db.collection("Cart").findOne({ _id }); + cart.appliedPromotions.length = 0; + cart.version = 1; + await db.collection("Cart").updateOne({ _id: cart._id }, { $set: cart }); + } + progress(100); } diff --git a/packages/api-plugin-promotions-coupons/package.json b/packages/api-plugin-promotions-coupons/package.json index c92577b8387..03cee59902a 100644 --- a/packages/api-plugin-promotions-coupons/package.json +++ b/packages/api-plugin-promotions-coupons/package.json @@ -26,6 +26,7 @@ "sideEffects": false, "dependencies": { "@reactioncommerce/api-utils": "^1.16.9", + "@reactioncommerce/db-version-check": "workspace:^1.0.0", "@reactioncommerce/logger": "^1.1.3", "@reactioncommerce/random": "^1.0.2", "@reactioncommerce/reaction-error": "^1.0.1", @@ -34,7 +35,6 @@ "lodash": "^4.17.21", "simpl-schema": "^1.12.2" }, - "devDependencies": {}, "scripts": { "lint": "npm run lint:eslint", "lint:eslint": "eslint .", diff --git a/packages/api-plugin-promotions-coupons/src/preStartup.js b/packages/api-plugin-promotions-coupons/src/preStartup.js index 8a59a5ae2e1..6a71c42388e 100644 --- a/packages/api-plugin-promotions-coupons/src/preStartup.js +++ b/packages/api-plugin-promotions-coupons/src/preStartup.js @@ -1,7 +1,11 @@ import _ from "lodash"; import SimpleSchema from "simpl-schema"; +import doesDatabaseVersionMatch from "@reactioncommerce/db-version-check"; +import { migrationsNamespace } from "../migrations/migrationsNamespace.js"; import { CouponTriggerCondition, CouponTriggerParameters } from "./simpleSchemas.js"; +const expectedVersion = 2; + /** * @summary This is a preStartup function that is called before the app starts up. * @param {Object} context - The application context @@ -39,4 +43,23 @@ export default async function preStartupPromotionCoupon(context) { Cart.extend({ "appliedPromotions.$": copiedPromotion }); + + const setToExpectedIfMissing = async () => { + const anyDiscount = await context.collections.Discounts.findOne(); + return !anyDiscount; + }; + const ok = await doesDatabaseVersionMatch({ + // `db` is a Db instance from the `mongodb` NPM package, + // such as what is returned when you do `client.db()` + db: context.app.db, + // These must match one of the namespaces and versions + // your package exports in the `migrations` named export + expectedVersion, + namespace: migrationsNamespace, + setToExpectedIfMissing + }); + + if (!ok) { + throw new Error(`Database needs migrating. The "${migrationsNamespace}" namespace must be at version ${expectedVersion}. See docs for more information on migrations: https://github.com/reactioncommerce/api-migrations`); + } } diff --git a/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql b/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql index ea943c7c465..13180ab101d 100644 --- a/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql +++ b/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql @@ -34,6 +34,9 @@ type Coupon { "Coupon updated time" updatedAt: Date! + + "Related discount ID" + discountId: ID } extend type Promotion { diff --git a/packages/api-plugin-promotions-coupons/src/simpleSchemas.js b/packages/api-plugin-promotions-coupons/src/simpleSchemas.js index e3a0b6c9deb..26e2eeef401 100644 --- a/packages/api-plugin-promotions-coupons/src/simpleSchemas.js +++ b/packages/api-plugin-promotions-coupons/src/simpleSchemas.js @@ -57,6 +57,10 @@ export const Coupon = new SimpleSchema({ }, updatedAt: { type: Date + }, + discountId: { + type: String, + optional: true } }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1bd2bd419dc..ef2798f5048 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -256,7 +256,7 @@ importers: '@reactioncommerce/file-collections-sa-gridfs': link:../../packages/file-collections-sa-gridfs '@reactioncommerce/logger': link:../../packages/logger '@reactioncommerce/random': link:../../packages/random - '@snyk/protect': 1.1081.0 + '@snyk/protect': 1.1100.0 graphql: 14.7.0 nodemailer: 6.8.0 semver: 6.3.0 @@ -1061,6 +1061,7 @@ importers: packages/api-plugin-promotions-coupons: specifiers: '@reactioncommerce/api-utils': ^1.16.9 + '@reactioncommerce/db-version-check': workspace:^1.0.0 '@reactioncommerce/logger': ^1.1.3 '@reactioncommerce/random': ^1.0.2 '@reactioncommerce/reaction-error': ^1.0.1 @@ -1070,6 +1071,7 @@ importers: simpl-schema: ^1.12.2 dependencies: '@reactioncommerce/api-utils': link:../api-utils + '@reactioncommerce/db-version-check': link:../db-version-check '@reactioncommerce/logger': link:../logger '@reactioncommerce/random': link:../random '@reactioncommerce/reaction-error': link:../reaction-error @@ -4861,8 +4863,8 @@ packages: '@sinonjs/commons': 1.8.3 dev: false - /@snyk/protect/1.1081.0: - resolution: {integrity: sha512-V+4DJPLorQph9j78PB3qpxOEREzXHJN/txg2Cxn2EGw+7IWOPPeLgUb4jO+tjVVmqMYmrvohMDQKErcjIxVqVg==} + /@snyk/protect/1.1100.0: + resolution: {integrity: sha512-nbdLbao8fqrgeWwO+RQj3Bdz0qoSeuYElHNjmH0fMiKJ1/MxR8tcma8P2xHXkDW6wYbYlUznf8gGhxAXDBe+wg==} engines: {node: '>=10'} hasBin: true dev: false @@ -9281,7 +9283,7 @@ packages: graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 dependencies: graphql: 14.7.0 - tslib: 2.4.0 + tslib: 2.4.1 /graphql-tools/4.0.5_graphql@14.7.0: resolution: {integrity: sha512-kQCh3IZsMqquDx7zfIGWBau42xe46gmqabwYkpPlCLIjcEY1XK+auP7iGRD9/205BPyoQdY8hT96MPpgERdC9Q==} @@ -14195,7 +14197,6 @@ packages: /tslib/2.4.1: resolution: {integrity: sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==} - dev: false /tsutils/3.21.0_typescript@2.9.2: resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} From ce0d11715af3131a5852e1c42e9988aa0da40140 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Wed, 1 Feb 2023 10:44:45 +0700 Subject: [PATCH 11/37] feat: add archive coupon mutation Signed-off-by: vanpho93 --- .../src/mutations/archiveCoupon.js | 27 ++++++++++++ .../src/mutations/archiveCoupon.test.js | 37 ++++++++++++++++ .../src/mutations/createStandardCoupon.js | 2 +- .../src/mutations/index.js | 2 + .../src/mutations/updateStandardCoupon.js | 4 +- .../src/queries/coupons.js | 6 ++- .../src/resolvers/Mutation/archiveCoupon.js | 18 ++++++++ .../resolvers/Mutation/archiveCoupon.test.js | 26 ++++++++++++ .../src/resolvers/Mutation/index.js | 2 + .../src/schemas/schema.graphql | 42 +++++++++---------- .../src/simpleSchemas.js | 5 +++ 11 files changed, 146 insertions(+), 25 deletions(-) create mode 100644 packages/api-plugin-promotions-coupons/src/mutations/archiveCoupon.js create mode 100644 packages/api-plugin-promotions-coupons/src/mutations/archiveCoupon.test.js create mode 100644 packages/api-plugin-promotions-coupons/src/resolvers/Mutation/archiveCoupon.js create mode 100644 packages/api-plugin-promotions-coupons/src/resolvers/Mutation/archiveCoupon.test.js diff --git a/packages/api-plugin-promotions-coupons/src/mutations/archiveCoupon.js b/packages/api-plugin-promotions-coupons/src/mutations/archiveCoupon.js new file mode 100644 index 00000000000..ee9434ffdd1 --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/mutations/archiveCoupon.js @@ -0,0 +1,27 @@ +import SimpleSchema from "simpl-schema"; + +const inputSchema = new SimpleSchema({ + shopId: String, + couponId: String +}); + +/** + * @method archiveCoupon + * @summary Archive a coupon mutation + * @param {Object} context - The application context + * @param {Object} input - The coupon input to create + * @returns {Promise} with updated coupon result + */ +export default async function archiveCoupon(context, input) { + inputSchema.validate(input); + + const { collections: { Coupons } } = context; + const { shopId, couponId: _id } = input; + + const now = new Date(); + const modifier = { $set: { isArchived: true, updatedAt: now } }; + const results = await Coupons.findOneAndUpdate({ _id, shopId }, modifier, { returnDocument: "after" }); + + const { modifiedCount, value } = results; + return { success: !!modifiedCount, coupon: value }; +} diff --git a/packages/api-plugin-promotions-coupons/src/mutations/archiveCoupon.test.js b/packages/api-plugin-promotions-coupons/src/mutations/archiveCoupon.test.js new file mode 100644 index 00000000000..8fdf1139eab --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/mutations/archiveCoupon.test.js @@ -0,0 +1,37 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import archiveCoupon from "./archiveCoupon.js"; + +test("throws if validation check fails", async () => { + const input = { shopId: "abc" }; + + try { + await archiveCoupon(mockContext, input); + } catch (error) { + expect(error.error).toEqual("validation-error"); + } +}); + +test("should call mutations.archiveCoupon and return the result", async () => { + const input = { shopId: "abc", couponId: "123" }; + mockContext.collections = { + Coupons: { + findOneAndUpdate: jest.fn().mockReturnValueOnce(Promise.resolve({ + modifiedCount: 1, + value: { + _id: "123", + shopId: "abc" + } + })) + } + }; + + const result = await archiveCoupon(mockContext, input); + + expect(result).toEqual({ + success: true, + coupon: { + _id: "123", + shopId: "abc" + } + }); +}); diff --git a/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.js b/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.js index eee46dbe0a8..b8ea63e3e65 100644 --- a/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.js +++ b/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.js @@ -36,7 +36,7 @@ export default async function createStandardCoupon(context, input) { const promotion = await Promotions.findOne({ _id: promotionId, shopId }); if (!promotion) throw new ReactionError("not-found", "Promotion not found"); - const existsCoupons = await Coupons.find({ code, shopId }).toArray(); + const existsCoupons = await Coupons.find({ code, shopId, isArchived: { $ne: true } }).toArray(); if (existsCoupons.length > 0) { const promotionIds = _.map(existsCoupons, "promotionId"); const promotions = await Promotions.find({ _id: { $in: promotionIds } }).toArray(); diff --git a/packages/api-plugin-promotions-coupons/src/mutations/index.js b/packages/api-plugin-promotions-coupons/src/mutations/index.js index e8faea52fab..81a2c8a638d 100644 --- a/packages/api-plugin-promotions-coupons/src/mutations/index.js +++ b/packages/api-plugin-promotions-coupons/src/mutations/index.js @@ -1,10 +1,12 @@ import applyCouponToCart from "./applyCouponToCart.js"; +import archiveCoupon from "./archiveCoupon.js"; import createStandardCoupon from "./createStandardCoupon.js"; import updateStandardCoupon from "./updateStandardCoupon.js"; import removeCouponFromCart from "./removeCouponFromCart.js"; export default { applyCouponToCart, + archiveCoupon, createStandardCoupon, updateStandardCoupon, removeCouponFromCart diff --git a/packages/api-plugin-promotions-coupons/src/mutations/updateStandardCoupon.js b/packages/api-plugin-promotions-coupons/src/mutations/updateStandardCoupon.js index a32b9273d37..a90dde44fba 100644 --- a/packages/api-plugin-promotions-coupons/src/mutations/updateStandardCoupon.js +++ b/packages/api-plugin-promotions-coupons/src/mutations/updateStandardCoupon.js @@ -41,7 +41,7 @@ export default async function updateStandardCoupon(context, input) { const { collections: { Coupons, Promotions } } = context; const { shopId, _id: couponId } = input; - const coupon = await Coupons.findOne({ _id: couponId, shopId }); + const coupon = await Coupons.findOne({ _id: couponId, shopId, isArchived: { $ne: true } }); if (!coupon) throw new ReactionError("not-found", "Coupon not found"); const promotion = await Promotions.findOne({ _id: coupon.promotionId, shopId }); @@ -53,7 +53,7 @@ export default async function updateStandardCoupon(context, input) { } if (input.code && coupon.code !== input.code) { - const existsCoupons = await Coupons.find({ code: input.code, shopId, _id: { $ne: coupon._id } }).toArray(); + const existsCoupons = await Coupons.find({ code: input.code, shopId, _id: { $ne: coupon._id }, isArchived: { $ne: true } }).toArray(); if (existsCoupons.length > 0) { const promotionIds = _.map(existsCoupons, "promotionId"); const promotions = await Promotions.find({ _id: { $in: promotionIds } }).toArray(); diff --git a/packages/api-plugin-promotions-coupons/src/queries/coupons.js b/packages/api-plugin-promotions-coupons/src/queries/coupons.js index 994ec2c57df..f5aaba19634 100644 --- a/packages/api-plugin-promotions-coupons/src/queries/coupons.js +++ b/packages/api-plugin-promotions-coupons/src/queries/coupons.js @@ -11,7 +11,7 @@ export default async function coupons(context, shopId, filter) { const selector = { shopId }; if (filter) { - const { expirationDate, promotionId, code, userId } = filter; + const { expirationDate, promotionId, code, userId, isArchived } = filter; if (expirationDate) { selector.expirationDate = { $gte: expirationDate }; @@ -28,6 +28,10 @@ export default async function coupons(context, shopId, filter) { if (userId) { selector.userId = userId; } + + if (typeof isArchived === "boolean") { + selector.isArchived = isArchived; + } } return Coupons.find(selector); diff --git a/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/archiveCoupon.js b/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/archiveCoupon.js new file mode 100644 index 00000000000..9921a58d78b --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/archiveCoupon.js @@ -0,0 +1,18 @@ +/** + * @method archiveCoupon + * @summary Archive a coupon mutation + * @param {Object} _ unused + * @param {Object} args.input - The input arguments + * @param {Object} args.input.shopId - The shopId + * @param {Object} args.input.promotionId - The promotion ID + * @param {Object} context - The application context + * @returns {Promise} with archived coupon result + */ +export default async function archiveCoupon(_, { input }, context) { + const { shopId } = input; + + await context.validatePermissions("reaction:legacy:promotions", "update", { shopId }); + + const archivedCouponResult = await context.mutations.archiveCoupon(context, input); + return archivedCouponResult; +} diff --git a/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/archiveCoupon.test.js b/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/archiveCoupon.test.js new file mode 100644 index 00000000000..6231c453ae4 --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/archiveCoupon.test.js @@ -0,0 +1,26 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import archiveCoupon from "./archiveCoupon.js"; + +test("throws if permission check fails", async () => { + const input = { name: "Test coupon", code: "CODE" }; + mockContext.validatePermissions.mockResolvedValue(Promise.reject(new Error("Access Denied"))); + + try { + await archiveCoupon(null, { input }, mockContext); + } catch (error) { + expect(error.message).toEqual("Access Denied"); + } +}); + +test("calls mutations.archiveCoupon and returns the result", async () => { + const input = { couponId: "123" }; + const result = { success: true }; + mockContext.validatePermissions.mockResolvedValue(Promise.resolve()); + mockContext.mutations = { + archiveCoupon: jest.fn().mockName("mutations.archiveCoupon").mockReturnValueOnce(Promise.resolve(result)) + }; + + const createdCoupon = await archiveCoupon(null, { input }, mockContext); + + expect(createdCoupon).toEqual(result); +}); diff --git a/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/index.js b/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/index.js index e8faea52fab..81a2c8a638d 100644 --- a/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/index.js +++ b/packages/api-plugin-promotions-coupons/src/resolvers/Mutation/index.js @@ -1,10 +1,12 @@ import applyCouponToCart from "./applyCouponToCart.js"; +import archiveCoupon from "./archiveCoupon.js"; import createStandardCoupon from "./createStandardCoupon.js"; import updateStandardCoupon from "./updateStandardCoupon.js"; import removeCouponFromCart from "./removeCouponFromCart.js"; export default { applyCouponToCart, + archiveCoupon, createStandardCoupon, updateStandardCoupon, removeCouponFromCart diff --git a/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql b/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql index e3ec6018619..66a874c544c 100644 --- a/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql +++ b/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql @@ -29,6 +29,9 @@ type Coupon { "The number of times this coupon has been used" usedCount: Int + "The coupon is archived" + isArchived: Boolean + "Coupon created time" createdAt: Date! @@ -107,27 +110,6 @@ input UpdateStandardCouponInput { maxUsageTimes: Int } -"The input for the createStandardCoupon mutation" -input CreateStandardCouponInput { - "The shop ID" - shopId: ID! - - "The promotion ID" - promotionId: ID! - - "The coupon code" - code: String! - - "Can use this coupon in the store" - canUseInStore: Boolean! - - "The number of times this coupon can be used per user" - maxUsageTimesPerUser: Int - - "The number of times this coupon can be used" - maxUsageTimes: Int -} - input CouponQueryInput { "The unique ID of the coupon" _id: String! @@ -148,6 +130,9 @@ input CouponFilter { "The coupon name" userId: ID + + "The coupon is archived" + isArchived: Boolean } "Input for the removeCouponFromCart mutation" @@ -168,6 +153,15 @@ input RemoveCouponFromCartInput { token: String } +"The input for the archiveCoupon mutation" +input ArchiveCouponInput { + "The coupon ID" + couponId: ID! + + "The shop ID" + shopId: ID! +} + "The response for the applyCouponToCart mutation" type ApplyCouponToCartPayload { cart: Cart @@ -262,6 +256,12 @@ extend type Mutation { input: UpdateStandardCouponInput ): StandardCouponPayload + "Archive coupon mutation" + archiveCoupon( + "The archiveCoupon mutation input" + input: ArchiveCouponInput + ): StandardCouponPayload + "Remove a coupon from a cart" removeCouponFromCart( "The removeCouponFromCart mutation input" diff --git a/packages/api-plugin-promotions-coupons/src/simpleSchemas.js b/packages/api-plugin-promotions-coupons/src/simpleSchemas.js index e3a0b6c9deb..b4e7fe8dd4e 100644 --- a/packages/api-plugin-promotions-coupons/src/simpleSchemas.js +++ b/packages/api-plugin-promotions-coupons/src/simpleSchemas.js @@ -52,6 +52,11 @@ export const Coupon = new SimpleSchema({ type: Number, defaultValue: 0 }, + isArchived: { + type: Boolean, + defaultValue: false, + optional: true + }, createdAt: { type: Date }, From f2a2321a3661d0cf53743e5c21dc2ac219beb112 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Fri, 10 Feb 2023 09:26:37 +0700 Subject: [PATCH 12/37] fix: migration up error Signed-off-by: vanpho93 --- packages/api-plugin-promotions-coupons/migrations/2.js | 4 ++-- pnpm-lock.yaml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/api-plugin-promotions-coupons/migrations/2.js b/packages/api-plugin-promotions-coupons/migrations/2.js index b1917d4b24f..3187006898c 100644 --- a/packages/api-plugin-promotions-coupons/migrations/2.js +++ b/packages/api-plugin-promotions-coupons/migrations/2.js @@ -136,7 +136,7 @@ async function migrateCart(db) { const carts = await db.collection("Cart").find({}, { _id: 1 }).toArray(); for (const { _id } of carts) { - const cart = await db.findOne({ _id }); + const cart = await db.collection("Cart").findOne({ _id }); if (cart.version && cart.version === 2) continue; if (!cart.billing) continue; @@ -174,7 +174,7 @@ async function up({ db, progress }) { try { await migrateCart(db); } catch (err) { - throw new Error("Failed to migrate cart", err.message); + throw new Error(`Failed to migrate cart: ${err.message}`); } progress(100); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef2798f5048..66b686bacde 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -256,7 +256,7 @@ importers: '@reactioncommerce/file-collections-sa-gridfs': link:../../packages/file-collections-sa-gridfs '@reactioncommerce/logger': link:../../packages/logger '@reactioncommerce/random': link:../../packages/random - '@snyk/protect': 1.1100.0 + '@snyk/protect': 1.1081.0 graphql: 14.7.0 nodemailer: 6.8.0 semver: 6.3.0 @@ -4863,8 +4863,8 @@ packages: '@sinonjs/commons': 1.8.3 dev: false - /@snyk/protect/1.1100.0: - resolution: {integrity: sha512-nbdLbao8fqrgeWwO+RQj3Bdz0qoSeuYElHNjmH0fMiKJ1/MxR8tcma8P2xHXkDW6wYbYlUznf8gGhxAXDBe+wg==} + /@snyk/protect/1.1081.0: + resolution: {integrity: sha512-V+4DJPLorQph9j78PB3qpxOEREzXHJN/txg2Cxn2EGw+7IWOPPeLgUb4jO+tjVVmqMYmrvohMDQKErcjIxVqVg==} engines: {node: '>=10'} hasBin: true dev: false From 4312af66958d1076797446ce8b7725889fdbcf3f Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Tue, 14 Feb 2023 18:20:26 +0700 Subject: [PATCH 13/37] fix: merge issue Signed-off-by: vanpho93 --- pnpm-lock.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61837800613..62f37138709 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9281,6 +9281,16 @@ packages: iterall: 1.3.0 dev: false + /graphql-tag/2.12.6_graphql@14.7.0: + resolution: {integrity: sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==} + engines: {node: '>=10'} + peerDependencies: + graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + dependencies: + graphql: 14.7.0 + tslib: 2.4.1 + dev: false + /graphql-tag/2.12.6_graphql@16.6.0: resolution: {integrity: sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==} engines: {node: '>=10'} From 4a614a31fa37946e55548f0aa667c33d557ae9a5 Mon Sep 17 00:00:00 2001 From: Chloe Date: Thu, 16 Feb 2023 15:41:44 +0700 Subject: [PATCH 14/37] fix: should query un-archived coupon in promotion Signed-off-by: Chloe --- .../src/resolvers/Promotion/getPreviewPromotionCoupon.js | 2 +- .../src/resolvers/Promotion/getPromotionCoupon.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/api-plugin-promotions-coupons/src/resolvers/Promotion/getPreviewPromotionCoupon.js b/packages/api-plugin-promotions-coupons/src/resolvers/Promotion/getPreviewPromotionCoupon.js index e322d72a77f..b4361d775d9 100644 --- a/packages/api-plugin-promotions-coupons/src/resolvers/Promotion/getPreviewPromotionCoupon.js +++ b/packages/api-plugin-promotions-coupons/src/resolvers/Promotion/getPreviewPromotionCoupon.js @@ -8,6 +8,6 @@ */ export default async function getPreviewPromotionCoupon(promotion, args, context) { const { collections: { Coupons } } = context; - const coupon = await Coupons.findOne({ promotionId: promotion._id }); + const coupon = await Coupons.findOne({ promotionId: promotion._id, isArchived: { $ne: true } }); return coupon; } diff --git a/packages/api-plugin-promotions-coupons/src/resolvers/Promotion/getPromotionCoupon.js b/packages/api-plugin-promotions-coupons/src/resolvers/Promotion/getPromotionCoupon.js index e6fdcbd77ca..15676974d44 100644 --- a/packages/api-plugin-promotions-coupons/src/resolvers/Promotion/getPromotionCoupon.js +++ b/packages/api-plugin-promotions-coupons/src/resolvers/Promotion/getPromotionCoupon.js @@ -8,6 +8,6 @@ */ export default async function getPromotionCoupon(promotion, args, context) { const { collections: { Coupons } } = context; - const coupon = await Coupons.findOne({ promotionId: promotion._id }); + const coupon = await Coupons.findOne({ promotionId: promotion._id, isArchived: { $ne: true } }); return coupon; } From 8da5f070495fc0edba3834058fd7a5f09b402441 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Thu, 16 Feb 2023 16:01:08 +0700 Subject: [PATCH 15/37] feat: shipping discount method Signed-off-by: vanpho93 --- .../src/xforms/xformCartCheckout.js | 7 +- .../src/actions/discountAction.js | 16 +- .../src/actions/discountAction.test.js | 24 +++ .../item/applyItemDiscountToCart.test.js | 33 ++- .../order/applyOrderDiscountToCart.test.js | 9 +- .../shipping/applyShippingDiscountToCart.js | 146 ++++++++++++- .../applyShippingDiscountToCart.test.js | 200 ++++++++++++++++++ .../src/preStartup.js | 13 +- .../src/queries/getDiscountsTotalForCart.js | 6 +- .../queries/getDiscountsTotalForCart.test.js | 3 +- .../src/schemas/schema.graphql | 18 +- .../src/simpleSchemas.js | 6 +- .../src/utils/getEligibleIShipping.js | 43 ++++ .../src/utils/getTotalDiscountOnCart.js | 5 +- .../src/utils/recalculateShippingDiscount.js | 40 ++++ .../utils/recalculateShippingDiscount.test.js | 36 ++++ 16 files changed, 582 insertions(+), 23 deletions(-) create mode 100644 packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js create mode 100644 packages/api-plugin-promotions-discounts/src/utils/getEligibleIShipping.js create mode 100644 packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js create mode 100644 packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.test.js diff --git a/packages/api-plugin-carts/src/xforms/xformCartCheckout.js b/packages/api-plugin-carts/src/xforms/xformCartCheckout.js index ad4a938bdc3..90c0f5e6d16 100644 --- a/packages/api-plugin-carts/src/xforms/xformCartCheckout.js +++ b/packages/api-plugin-carts/src/xforms/xformCartCheckout.js @@ -39,7 +39,9 @@ function xformCartFulfillmentGroup(fulfillmentGroup, cart) { displayName: fulfillmentGroup.shipmentMethod.label || fulfillmentGroup.shipmentMethod.name, group: fulfillmentGroup.shipmentMethod.group || null, name: fulfillmentGroup.shipmentMethod.name, - fulfillmentTypes: fulfillmentGroup.shipmentMethod.fulfillmentTypes + fulfillmentTypes: fulfillmentGroup.shipmentMethod.fulfillmentTypes, + discount: fulfillmentGroup.shipmentMethod.discount || 0, + undiscountedRate: fulfillmentGroup.shipmentMethod.rate || 0 }, handlingPrice: { amount: fulfillmentGroup.shipmentMethod.handling || 0, @@ -65,7 +67,8 @@ function xformCartFulfillmentGroup(fulfillmentGroup, cart) { shippingAddress: fulfillmentGroup.address, shopId: fulfillmentGroup.shopId, // For now, this is always shipping. Revisit when adding download, pickup, etc. types - type: "shipping" + type: "shipping", + discounts: fulfillmentGroup.discounts || [] }; } diff --git a/packages/api-plugin-promotions-discounts/src/actions/discountAction.js b/packages/api-plugin-promotions-discounts/src/actions/discountAction.js index 70b342ba329..36831c4205b 100644 --- a/packages/api-plugin-promotions-discounts/src/actions/discountAction.js +++ b/packages/api-plugin-promotions-discounts/src/actions/discountAction.js @@ -55,6 +55,11 @@ export const discountActionParameters = new SimpleSchema({ type: Boolean, optional: true, defaultValue: false + }, + neverStackWithOtherShippingDiscounts: { + type: Boolean, + optional: true, + defaultValue: false } }); @@ -76,8 +81,15 @@ export async function discountActionCleanup(context, cart) { return item; }); - // todo: add reset logic for the shipping - // cart.shipping = cart.shipping.map((shipping) => ({ ...shipping, discounts: [] })); + for (const shipping of cart.shipping) { + shipping.discounts = []; + const { shipmentMethod } = shipping; + if (shipmentMethod) { + shipmentMethod.shippingPrice = shipmentMethod.handling + shipmentMethod.rate; + shipmentMethod.discount = 0; + shipmentMethod.undiscountedRate = 0; + } + } return cart; } diff --git a/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js b/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js index 3a31493c227..2132f3b02bf 100644 --- a/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js +++ b/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js @@ -77,6 +77,17 @@ describe("cleanup", () => { undiscountedAmount: 12 } } + ], + shipping: [ + { + _id: "shipping1", + shipmentMethod: { + shippingPrice: 9, + discount: 2, + handling: 2, + rate: 9 + } + } ] }; @@ -98,6 +109,19 @@ describe("cleanup", () => { currencyCode: "USD" } } + ], + shipping: [ + { + _id: "shipping1", + discounts: [], + shipmentMethod: { + discount: 0, + handling: 2, + rate: 9, + shippingPrice: 11, + undiscountedRate: 0 + } + } ] }); }); diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/item/applyItemDiscountToCart.test.js b/packages/api-plugin-promotions-discounts/src/discountTypes/item/applyItemDiscountToCart.test.js index 06d8c097d58..dc9cb78895b 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/item/applyItemDiscountToCart.test.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/item/applyItemDiscountToCart.test.js @@ -41,9 +41,18 @@ test("should return cart with applied discount when parameters do not include ru discounts: [] }; + const shipping = { + _id: "shipping1", + shipmentMethod: { + shippingPrice: 10, + discount: 2 + } + }; + const cart = { _id: "cart1", - items: [item] + items: [item], + shipping: [shipping] }; const discountParameters = { @@ -88,9 +97,18 @@ test("should return cart with applied discount when parameters include rule", as discounts: [] }; + const shipping = { + _id: "shipping1", + shipmentMethod: { + shippingPrice: 10, + discount: 2 + } + }; + const cart = { _id: "cart1", - items: [item] + items: [item], + shipping: [shipping] }; const parameters = { @@ -150,9 +168,18 @@ test("should return affected is false with reason when have no items are discoun discounts: [] }; + const shipping = { + _id: "shipping1", + shipmentMethod: { + shippingPrice: 11, + discount: 2 + } + }; + const cart = { _id: "cart1", - items: [item] + items: [item], + shipping: [shipping] }; const parameters = { diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.test.js b/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.test.js index 4d1b55838fc..0822b356ae2 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.test.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.test.js @@ -78,7 +78,8 @@ test("should apply order discount to cart", async () => { }, discounts: [] } - ] + ], + shipping: [] }; const parameters = { @@ -263,7 +264,8 @@ test("should apply order discount to cart with discountMaxValue when estimate di }, discounts: [] } - ] + ], + shipping: [] }; const parameters = { @@ -301,7 +303,8 @@ test("should apply order discount to cart with discountMaxValue when estimate di test("should return affected is false with reason when have no items are discounted", async () => { const cart = { _id: "cart1", - items: [] + items: [], + shipping: [] }; const parameters = { diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js index baec8197ea7..753358a4902 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js @@ -1,5 +1,117 @@ /* eslint-disable no-unused-vars */ -import ReactionError from "@reactioncommerce/reaction-error"; +import { createRequire } from "module"; +import _ from "lodash"; +import Logger from "@reactioncommerce/logger"; +import recalculateShippingDiscount from "../../utils/recalculateShippingDiscount.js"; +import getTotalDiscountOnCart from "../../utils/getTotalDiscountOnCart.js"; +import formatMoney from "../../utils/formatMoney.js"; +import getEligibleShipping from "../../utils/getEligibleIShipping.js"; + +const require = createRequire(import.meta.url); + +const pkg = require("../../../package.json"); + +const { name, version } = pkg; +const logCtx = { + name, + version, + file: "shipping/applyShippingDiscountToCart.js" +}; + +/** + * @summary Map discount record to shipping discount + * @param {Object} params - The action parameters + * @param {Object} discountedItem - The item that were discounted + * @returns {Object} Shipping discount record + */ +export function createDiscountRecord(params, discountedItem) { + const { promotion, actionParameters } = params; + const shippingDiscount = { + promotionId: promotion._id, + discountType: actionParameters.discountType, + discountCalculationType: actionParameters.discountCalculationType, + discountValue: actionParameters.discountValue, + discountMaxValue: actionParameters.discountMaxValue, + dateApplied: new Date(), + discountedItemType: "shipping", + discountedAmount: discountedItem.amount, + stackability: promotion.stackability, + neverStackWithOtherShippingDiscounts: actionParameters.neverStackWithOtherShippingDiscounts + }; + return shippingDiscount; +} + +/** + * @summary Get the discount amount for a discount item + * @param {Object} context - The application context + * @param {Number} totalShippingPrice - The total shipping price + * @param {Object} actionParameters - The action parameters + * @returns {Number} - The discount amount + */ +export function getTotalShippingDiscount(context, totalShippingPrice, actionParameters) { + const { discountCalculationType, discountValue, discountMaxValue } = actionParameters; + const calculationMethod = context.discountCalculationMethods[discountCalculationType]; + + const total = formatMoney(calculationMethod(discountValue, totalShippingPrice)); + if (typeof discountMaxValue === "number" && discountMaxValue > 0) { + return Math.min(total, discountMaxValue); + } + return total; +} + +/** + * @summary Splits a discount across all shipping + * @param {Array} cartShipping - The shipping to split the discount across + * @param {Number} totalShippingPrice - The total shipping price + * @param {Number} discountAmount - The total discount to split + * @returns {Array} undefined + */ +export function splitDiscountForShipping(cartShipping, totalShippingPrice, discountAmount) { + let discounted = 0; + const discountedShipping = cartShipping.map((shipping, index) => { + if (index !== cartShipping.length - 1) { + const shippingPrice = shipping.shipmentMethod.rate + shipping.shipmentMethod.handling; + const discount = formatMoney((shippingPrice / totalShippingPrice) * discountAmount); + discounted += discount; + return { _id: shipping._id, amount: discount }; + } + return { _id: shipping._id, amount: formatMoney(discountAmount - discounted) }; + }); + + return discountedShipping; +} + +/** + * @summary Get the total shipping price + * @param {Array} cartShipping - The shipping array to get the total price for + * @returns {Number} - The total shipping price + */ +export function getTotalShippingPrice(cartShipping) { + const totalPrice = cartShipping + .map((shipping) => { + if (!shipping.shipmentMethod) return 0; + return shipping.shipmentMethod.shippingPrice; + }) + .reduce((sum, price) => sum + price, 0); + return totalPrice; +} + +/** + * @summary Check if the shipping is eligible for the discount + * @param {Object} shipping - The shipping object + * @param {Object} discount - The discount object + * @returns {Boolean} - Whether the item is eligible for the discount + */ +export function canBeApplyDiscountToShipping(shipping, discount) { + const shippingDiscounts = shipping.discounts || []; + if (shippingDiscounts.length === 0) return true; + + const containsDiscountNeverStackWithOrderItem = _.some(shippingDiscounts, "neverStackWithOtherShippingDiscounts"); + if (containsDiscountNeverStackWithOrderItem) return false; + + if (discount.neverStackWithOtherShippingDiscounts) return false; + return true; +} /** * @summary Add the discount to the shipping record @@ -9,5 +121,35 @@ import ReactionError from "@reactioncommerce/reaction-error"; * @returns {Promise} undefined */ export default async function applyShippingDiscountToCart(context, params, cart) { - throw new ReactionError("not-implemented", "Not implemented"); + if (!cart.shipping) cart.shipping = []; + const { actionParameters } = params; + const filteredShipping = await getEligibleShipping(context, cart.shipping, params.actionParameters); + const totalShippingPrice = getTotalShippingPrice(filteredShipping); + const totalShippingDiscount = getTotalShippingDiscount(context, totalShippingPrice, actionParameters); + const discountedItems = splitDiscountForShipping(filteredShipping, totalShippingDiscount, totalShippingDiscount); + + for (const discountedItem of discountedItems) { + const shipping = filteredShipping.find((item) => item._id === discountedItem._id); + if (!shipping) continue; + + const canBeDiscounted = canBeApplyDiscountToShipping(shipping, params.promotion); + if (!canBeDiscounted) continue; + + if (!shipping.discounts) shipping.discounts = []; + + const shippingDiscount = createDiscountRecord(params, discountedItem); + shipping.discounts.push(shippingDiscount); + recalculateShippingDiscount(context, shipping); + } + + cart.discount = getTotalDiscountOnCart(cart); + + if (discountedItems.length) { + Logger.info(logCtx, "Saved Discount to cart"); + } + + const affected = discountedItems.length > 0; + const reason = !affected ? "No shippings were discounted" : undefined; + + return { cart, affected, reason }; } diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js new file mode 100644 index 00000000000..0abe889104a --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js @@ -0,0 +1,200 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import * as applyShippingDiscountToCart from "./applyShippingDiscountToCart.js"; + +test("createDiscountRecord should create discount record", () => { + const parameters = { + actionKey: "test", + promotion: { + _id: "promotion1" + }, + actionParameters: { + discountType: "item", + discountCalculationType: "fixed", + discountValue: 10 + } + }; + + const discountedItem = { + _id: "item1", + amount: 2 + }; + + const discountRecord = applyShippingDiscountToCart.createDiscountRecord(parameters, discountedItem); + + expect(discountRecord).toEqual({ + promotionId: "promotion1", + discountType: "item", + discountCalculationType: "fixed", + discountValue: 10, + dateApplied: expect.any(Date), + discountedItemType: "shipping", + discountedAmount: 2, + stackability: undefined + }); +}); + +test("should apply shipping discount to cart", async () => { + const cart = { + _id: "cart1", + items: [], + shipping: [ + { + _id: "shipping1", + shipmentMethod: { + _id: "method1", + handling: 2, + rate: 9, + shippingPrice: 11 + }, + discounts: [] + } + ], + discounts: [] + }; + + const parameters = { + actionKey: "test", + promotion: { + _id: "promotion1" + }, + actionParameters: { + discountType: "shipping", + discountCalculationType: "fixed", + discountValue: 10 + } + }; + + mockContext.discountCalculationMethods = { + fixed: jest.fn().mockReturnValue(2) + }; + + const { cart: updatedCart, affected } = await applyShippingDiscountToCart.default(mockContext, parameters, cart); + + expect(affected).toEqual(true); + expect(updatedCart.shipping[0].shipmentMethod).toEqual({ + _id: "method1", + discount: 9, + handling: 2, + rate: 9, + shippingPrice: 2, + undiscountedRate: 11 + }); + expect(updatedCart.shipping[0].discounts).toHaveLength(1); +}); + +test("getTotalShippingPrice should return total shipping price", () => { + const cart = { + shipping: [ + { + shipmentMethod: { + rate: 9, + handling: 2, + shippingPrice: 11 + } + }, + { + shipmentMethod: { + rate: 10, + handling: 1, + shippingPrice: 11 + } + } + ] + }; + + const totalShippingPrice = applyShippingDiscountToCart.getTotalShippingPrice(cart.shipping); + + expect(totalShippingPrice).toEqual(22); +}); + +test("getTotalShippingDiscount should return total shipping discount", () => { + const totalShippingPrice = 22; + + const actionParameters = { + discountType: "shipping", + discountCalculationType: "fixed", + discountValue: 10 + }; + mockContext.discountCalculationMethods = { + fixed: jest.fn().mockImplementation((discountValue) => discountValue) + }; + const totalShippingDiscount = applyShippingDiscountToCart.getTotalShippingDiscount(mockContext, totalShippingPrice, actionParameters); + + expect(totalShippingDiscount).toEqual(10); +}); + +test("splitDiscountForShipping should split discount for shipping", () => { + const totalShippingPrice = 22; + const totalShippingDiscount = 10; + + const cart = { + _id: "cart1", + shipping: [ + { + _id: "shipping1", + shipmentMethod: { + rate: 9, + handling: 2 + } + }, + { + _id: "shipping2", + shipmentMethod: { + rate: 9, + handling: 2 + } + } + ] + }; + + const shippingDiscounts = applyShippingDiscountToCart.splitDiscountForShipping(cart.shipping, totalShippingPrice, totalShippingDiscount); + + expect(shippingDiscounts).toEqual([ + { + _id: "shipping1", + amount: 5 + }, + { + _id: "shipping2", + amount: 5 + } + ]); +}); + +test("canBeApplyDiscountToShipping should return true if discount can be applied to shipping", () => { + const shipping = { + discounts: [ + { + discountType: "shipping" + } + ] + }; + + const discount = { + discountType: "shipping", + neverStackWithOtherShippingDiscounts: false + }; + + const canBeApplyDiscountToShipping = applyShippingDiscountToCart.canBeApplyDiscountToShipping(shipping, discount); + + expect(canBeApplyDiscountToShipping).toEqual(true); +}); + +test("canBeApplyDiscountToShipping should return false if discount can not be applied to shipping", () => { + const shipping = { + discounts: [ + { + discountType: "shipping" + } + ] + }; + + const discount = { + discountType: "shipping", + neverStackWithOtherShippingDiscounts: true + }; + + const canBeApplyDiscountToShipping = applyShippingDiscountToCart.canBeApplyDiscountToShipping(shipping, discount); + + expect(canBeApplyDiscountToShipping).toEqual(false); +}); diff --git a/packages/api-plugin-promotions-discounts/src/preStartup.js b/packages/api-plugin-promotions-discounts/src/preStartup.js index ddf4fa95947..1d8aa61dc17 100644 --- a/packages/api-plugin-promotions-discounts/src/preStartup.js +++ b/packages/api-plugin-promotions-discounts/src/preStartup.js @@ -22,7 +22,7 @@ const discountSchema = new SimpleSchema({ * @returns {Promise} undefined */ async function extendCartSchemas(context) { - const { simpleSchemas: { Cart, CartItem, Shipment, ShippingMethod, ShipmentQuote } } = context; + const { simpleSchemas: { Cart, CartItem, Shipment, ShippingMethod } } = context; Cart.extend(discountSchema); Cart.extend({ "discounts": { @@ -69,13 +69,14 @@ async function extendCartSchemas(context) { undiscountedRate: { type: Number, optional: true - } - }); - - ShipmentQuote.extend({ - undiscountedRate: { + }, + discount: { type: Number, optional: true + }, + shippingPrice: { + type: Number, + defaultValue: 0 } }); } diff --git a/packages/api-plugin-promotions-discounts/src/queries/getDiscountsTotalForCart.js b/packages/api-plugin-promotions-discounts/src/queries/getDiscountsTotalForCart.js index 38304665db5..eb94cc3a244 100644 --- a/packages/api-plugin-promotions-discounts/src/queries/getDiscountsTotalForCart.js +++ b/packages/api-plugin-promotions-discounts/src/queries/getDiscountsTotalForCart.js @@ -15,7 +15,11 @@ export default async function getDiscountsTotalForCart(context, cart) { } } - // TODO: add discounts from shipping + for (const shipping of cart.shipping) { + if (Array.isArray(shipping.discounts)) { + discounts.push(...shipping.discounts); + } + } return { discounts, diff --git a/packages/api-plugin-promotions-discounts/src/queries/getDiscountsTotalForCart.test.js b/packages/api-plugin-promotions-discounts/src/queries/getDiscountsTotalForCart.test.js index 31d908d4906..99b9c8cd7d7 100644 --- a/packages/api-plugin-promotions-discounts/src/queries/getDiscountsTotalForCart.test.js +++ b/packages/api-plugin-promotions-discounts/src/queries/getDiscountsTotalForCart.test.js @@ -34,7 +34,8 @@ test("should return correct cart total discount when cart has no discounts", asy }, discounts: [] } - ] + ], + shipping: [] }; const results = await getDiscountsTotalForCart(mockContext, cart); diff --git a/packages/api-plugin-promotions-discounts/src/schemas/schema.graphql b/packages/api-plugin-promotions-discounts/src/schemas/schema.graphql index a27ee3027ee..646ca7a0791 100644 --- a/packages/api-plugin-promotions-discounts/src/schemas/schema.graphql +++ b/packages/api-plugin-promotions-discounts/src/schemas/schema.graphql @@ -39,8 +39,11 @@ type CartDiscount { " The items that were discounted. Only available if `discountedItemType` is `item`." discountedItems: [CartDiscountedItem] - "Should this discount be applied before other discounts?" + "Should this discount be applied before other item discounts?" neverStackWithOtherItemLevelDiscounts: Boolean + + "Should this discount be applied before other shipping discounts?" + neverStackWithOtherShippingDiscounts: Boolean } extend type Cart { @@ -90,3 +93,16 @@ extend type OrderFulfillmentGroup { "The array of discounts applied to the fulfillment group." discounts: [CartDiscount] } + +extend type FulfillmentMethod { + "The total discount amount of the fulfillment method. " + discount: Float + + "The total undiscounted rate of the fulfillment method. " + undiscountedRate: Float +} + +extend type FulfillmentGroup { + "The array of discounts applied to the fulfillment group." + discounts: [CartDiscount] +} diff --git a/packages/api-plugin-promotions-discounts/src/simpleSchemas.js b/packages/api-plugin-promotions-discounts/src/simpleSchemas.js index 302e4a7d1b7..e5da526cfd6 100644 --- a/packages/api-plugin-promotions-discounts/src/simpleSchemas.js +++ b/packages/api-plugin-promotions-discounts/src/simpleSchemas.js @@ -16,7 +16,7 @@ const allowOperators = [ export const ConditionRule = new SimpleSchema({ "fact": { type: String, - allowedValues: ["cart", "item"] + allowedValues: ["cart", "item", "shipping"] }, "operator": { type: String, @@ -147,5 +147,9 @@ export const CartDiscount = new SimpleSchema({ "neverStackWithOtherItemLevelDiscounts": { type: Boolean, defaultValue: true + }, + "neverStackWithOtherShippingDiscounts": { + type: Boolean, + defaultValue: true } }); diff --git a/packages/api-plugin-promotions-discounts/src/utils/getEligibleIShipping.js b/packages/api-plugin-promotions-discounts/src/utils/getEligibleIShipping.js new file mode 100644 index 00000000000..824aecfb883 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/utils/getEligibleIShipping.js @@ -0,0 +1,43 @@ +import createEngine from "./engineHelpers.js"; + +/** + * @summary return shipping from the cart that meet inclusion criteria + * @param {Object} context - The application context + * @param {Array} shipping - The cart shipping to evaluate for eligible shipping + * @param {Object} params - The parameters to evaluate against + * @return {Promise>} - An array of eligible cart shipping + */ +export default async function getEligibleShipping(context, shipping, params) { + const getCheckMethod = (inclusionRules, exclusionRules) => { + const includeEngine = inclusionRules ? createEngine(context, inclusionRules) : null; + const excludeEngine = exclusionRules ? createEngine(context, exclusionRules) : null; + + return async (shippingItem) => { + if (includeEngine) { + const results = await includeEngine.run({ shipping: shippingItem }); + const { failureResults } = results; + const failedIncludeTest = failureResults.length > 0; + if (failedIncludeTest) return false; + } + + if (excludeEngine) { + const { failureResults } = await excludeEngine.run({ shipping: shippingItem }); + const failedExcludeTest = failureResults.length > 0; + return failedExcludeTest; + } + + return true; + }; + }; + + const checkerMethod = getCheckMethod(params.inclusionRules, params.exclusionRules); + + const eligibleShipping = []; + for (const shippingItem of shipping) { + // eslint-disable-next-line no-await-in-loop + if (await checkerMethod(shippingItem)) { + eligibleShipping.push(shippingItem); + } + } + return eligibleShipping; +} diff --git a/packages/api-plugin-promotions-discounts/src/utils/getTotalDiscountOnCart.js b/packages/api-plugin-promotions-discounts/src/utils/getTotalDiscountOnCart.js index 1a9497b4f2f..0e207c4b042 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/getTotalDiscountOnCart.js +++ b/packages/api-plugin-promotions-discounts/src/utils/getTotalDiscountOnCart.js @@ -12,7 +12,10 @@ export default function getTotalDiscountOnCart(cart) { totalDiscount += item.subtotal.discount || 0; } - // TODO: Add the logic to calculate the total discount on shipping + if (!Array.isArray(cart.shipping)) cart.shipping = []; + for (const shipping of cart.shipping) { + totalDiscount += shipping.shipmentMethod?.discount || 0; + } return Number(formatMoney(totalDiscount)); } diff --git a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js new file mode 100644 index 00000000000..cfa66b426b9 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js @@ -0,0 +1,40 @@ +import formatMoney from "./formatMoney.js"; + +/** + * @summary Recalculate shipping discount + * @param {Object} context - The application context + * @param {Object} shipping - The shipping record + * @returns {Promise} undefined + */ +export default function recalculateShippingDiscount(context, shipping) { + let totalDiscount = 0; + const { shipmentMethod } = shipping; + if (!shipmentMethod) return; + + const undiscountedAmount = formatMoney(shipmentMethod.shippingPrice); + + shipping.discounts.forEach((discount) => { + const { discountCalculationType, discountValue, discountMaxValue } = discount; + const calculationMethod = context.discountCalculationMethods[discountCalculationType]; + + const shippingDiscountAmount = formatMoney(calculationMethod(discountValue, undiscountedAmount)); + + // eslint-disable-next-line require-jsdoc + function getDiscountAmount() { + const discountAmount = formatMoney(undiscountedAmount - shippingDiscountAmount); + if (typeof discountMaxValue === "number" && discountMaxValue > 0) { + return Math.min(discountAmount, discountMaxValue); + } + return discountAmount; + } + + const discountAmount = getDiscountAmount(); + + totalDiscount += discountAmount; + discount.discountedAmount = discountAmount; + }); + + shipmentMethod.discount = totalDiscount; + shipmentMethod.shippingPrice = undiscountedAmount - totalDiscount; + shipmentMethod.undiscountedRate = undiscountedAmount; +} diff --git a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.test.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.test.js new file mode 100644 index 00000000000..93a5f20ba6a --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.test.js @@ -0,0 +1,36 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import recalculateShippingDiscount from "./recalculateShippingDiscount.js"; + +test("should recalculate shipping discount", async () => { + const shipping = { + _id: "shipping1", + shipmentMethod: { + _id: "method1", + handling: 2, + rate: 9, + shippingPrice: 11 + }, + discounts: [ + { + discountType: "shipping", + discountCalculationType: "fixed", + discountValue: 10 + } + ] + }; + + mockContext.discountCalculationMethods = { + fixed: jest.fn().mockReturnValue(2) + }; + + recalculateShippingDiscount(mockContext, shipping); + + expect(shipping.shipmentMethod).toEqual({ + _id: "method1", + discount: 9, + handling: 2, + rate: 9, + shippingPrice: 2, + undiscountedRate: 11 + }); +}); From b2297cd9c2fcc59f38c2d53af20537efc4beb5c2 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Tue, 14 Feb 2023 17:34:05 +0700 Subject: [PATCH 16/37] fix: prevent applied coupon when is archived Signed-off-by: vanpho93 --- .../src/mutations/applyCouponToCart.js | 3 ++- .../src/mutations/createStandardCoupon.js | 4 +++ .../mutations/createStandardCoupon.test.js | 27 ++++++++++++++++--- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/api-plugin-promotions-coupons/src/mutations/applyCouponToCart.js b/packages/api-plugin-promotions-coupons/src/mutations/applyCouponToCart.js index 3c27b755a2c..22f8d7698d1 100644 --- a/packages/api-plugin-promotions-coupons/src/mutations/applyCouponToCart.js +++ b/packages/api-plugin-promotions-coupons/src/mutations/applyCouponToCart.js @@ -60,7 +60,8 @@ export default async function applyCouponToCart(context, input) { $or: [ { expirationDate: { $gte: now } }, { expirationDate: null } - ] + ], + isArchived: { $ne: true } }).toArray(); if (coupons.length > 1) { throw new ReactionError("invalid-params", "The coupon have duplicate with other promotion. Please contact admin for more information"); diff --git a/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.js b/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.js index b8ea63e3e65..1e327fe4e5a 100644 --- a/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.js +++ b/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.js @@ -36,6 +36,10 @@ export default async function createStandardCoupon(context, input) { const promotion = await Promotions.findOne({ _id: promotionId, shopId }); if (!promotion) throw new ReactionError("not-found", "Promotion not found"); + if (promotion.triggerType !== "explicit") { + throw new ReactionError("invalid-params", "Coupon can only be created for explicit promotions"); + } + const existsCoupons = await Coupons.find({ code, shopId, isArchived: { $ne: true } }).toArray(); if (existsCoupons.length > 0) { const promotionIds = _.map(existsCoupons, "promotionId"); 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 2bd5f8305c6..53a6d1ef1e0 100644 --- a/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.test.js +++ b/packages/api-plugin-promotions-coupons/src/mutations/createStandardCoupon.test.js @@ -14,7 +14,7 @@ test("throws if validation check fails", async () => { test("throws error when coupon code already created", async () => { const input = { name: "test", code: "CODE", shopId: "123", promotionId: "123", canUseInStore: true }; const coupon = { _id: "123", code: "CODE", promotionId: "promotionId" }; - const promotion = { _id: "promotionId" }; + const promotion = { _id: "promotionId", triggerType: "explicit" }; mockContext.collections = { Promotions: { findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(promotion)), @@ -57,11 +57,30 @@ test("throws error when promotion does not exist", async () => { } }); +test("throws error when promotion is not explicit", async () => { + const input = { name: "test", code: "CODE", shopId: "123", promotionId: "123", canUseInStore: true }; + const promotion = { _id: "123", triggerType: "automatic" }; + mockContext.collections = { + Coupons: { + findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(null)) + }, + Promotions: { + findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(promotion)) + } + }; + + try { + await createStandardCoupon(mockContext, input); + } catch (error) { + expect(error.message).toEqual("Coupon can only be created for explicit promotions"); + } +}); + test("throws error when coupon code already exists in promotion window", async () => { const now = new Date(); const input = { name: "test", code: "CODE", shopId: "123", promotionId: "123", canUseInStore: true }; - const promotion = { _id: "123", startDate: now, endDate: now }; - const existsPromotion = { _id: "1234", startDate: now, endDate: now }; + const promotion = { _id: "123", startDate: now, endDate: now, triggerType: "explicit" }; + const existsPromotion = { _id: "1234", startDate: now, endDate: now, triggerType: "explicit" }; const coupon = { _id: "123", code: "CODE", promotionId: "123" }; mockContext.collections = { Coupons: { @@ -88,7 +107,7 @@ test("throws error when coupon code already exists in promotion window", async ( test("should insert a new coupon and return the created results", async () => { const now = new Date(); const input = { name: "test", code: "CODE", shopId: "123", promotionId: "123", canUseInStore: true }; - const promotion = { _id: "123", endDate: now }; + const promotion = { _id: "123", endDate: now, triggerType: "explicit" }; mockContext.collections = { Coupons: { From fb551ee67d867b6a4047363007cbf2f6e6f5969b Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Mon, 20 Feb 2023 11:39:28 +0700 Subject: [PATCH 17/37] feat: add calculate discount amount util Signed-off-by: vanpho93 --- .../order/applyOrderDiscountToCart.js | 5 +++-- .../shipping/applyShippingDiscountToCart.js | 6 +++--- .../src/utils/calculateDiscountAmount.js | 16 ++++++++++++++++ .../src/utils/calculateDiscountAmount.test.js | 18 ++++++++++++++++++ .../src/utils/recalculateCartItemSubtotal.js | 3 ++- 5 files changed, 42 insertions(+), 6 deletions(-) create mode 100644 packages/api-plugin-promotions-discounts/src/utils/calculateDiscountAmount.js create mode 100644 packages/api-plugin-promotions-discounts/src/utils/calculateDiscountAmount.test.js diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.js index 0f83cab19b1..9144c0d2f66 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.js @@ -4,6 +4,7 @@ import getEligibleItems from "../../utils/getEligibleItems.js"; import getTotalEligibleItemsAmount from "../../utils/getTotalEligibleItemsAmount.js"; import getTotalDiscountOnCart from "../../utils/getTotalDiscountOnCart.js"; import recalculateCartItemSubtotal from "../../utils/recalculateCartItemSubtotal.js"; +import calculateDiscountAmount from "../../utils/calculateDiscountAmount.js"; /** * @summary Map discount record to cart discount @@ -38,8 +39,8 @@ export function createDiscountRecord(params, discountedItems, discountedAmount) */ export function getCartDiscountAmount(context, items, discount) { const totalEligibleItemsAmount = getTotalEligibleItemsAmount(items); - const { discountCalculationType, discountValue, discountMaxValue } = discount; - const cartDiscountedAmount = context.discountCalculationMethods[discountCalculationType](discountValue, totalEligibleItemsAmount); + const { discountMaxValue } = discount; + const cartDiscountedAmount = calculateDiscountAmount(context, totalEligibleItemsAmount, discount); const discountAmount = formatMoney(totalEligibleItemsAmount - cartDiscountedAmount); if (typeof discountMaxValue === "number" && discountMaxValue > 0) { return Math.min(discount.discountMaxValue, discountAmount); diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js index 753358a4902..a1af4254e1e 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js @@ -6,6 +6,7 @@ import recalculateShippingDiscount from "../../utils/recalculateShippingDiscount import getTotalDiscountOnCart from "../../utils/getTotalDiscountOnCart.js"; import formatMoney from "../../utils/formatMoney.js"; import getEligibleShipping from "../../utils/getEligibleIShipping.js"; +import calculateDiscountAmount from "../../utils/calculateDiscountAmount.js"; const require = createRequire(import.meta.url); @@ -49,10 +50,9 @@ export function createDiscountRecord(params, discountedItem) { * @returns {Number} - The discount amount */ export function getTotalShippingDiscount(context, totalShippingPrice, actionParameters) { - const { discountCalculationType, discountValue, discountMaxValue } = actionParameters; - const calculationMethod = context.discountCalculationMethods[discountCalculationType]; + const { discountMaxValue } = actionParameters; - const total = formatMoney(calculationMethod(discountValue, totalShippingPrice)); + const total = calculateDiscountAmount(context, totalShippingPrice, actionParameters); if (typeof discountMaxValue === "number" && discountMaxValue > 0) { return Math.min(total, discountMaxValue); } diff --git a/packages/api-plugin-promotions-discounts/src/utils/calculateDiscountAmount.js b/packages/api-plugin-promotions-discounts/src/utils/calculateDiscountAmount.js new file mode 100644 index 00000000000..9e7a39b6a62 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/utils/calculateDiscountAmount.js @@ -0,0 +1,16 @@ +import formatMoney from "./formatMoney.js"; + +/** + * @summary Calculate the discount amount + * @param {Object} context - The application context + * @param {Number} amount - The amount to calculate the discount for + * @param {Object} parameters - The discount parameters + * @returns {Number} - The discount amount + */ +export default function calculateDiscountAmount(context, amount, parameters) { + const { discountCalculationType, discountValue } = parameters; + const calculationMethod = context.discountCalculationMethods[discountCalculationType]; + + const discountAmount = formatMoney(calculationMethod(discountValue, amount)); + return discountAmount; +} diff --git a/packages/api-plugin-promotions-discounts/src/utils/calculateDiscountAmount.test.js b/packages/api-plugin-promotions-discounts/src/utils/calculateDiscountAmount.test.js new file mode 100644 index 00000000000..6322ab68c63 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/utils/calculateDiscountAmount.test.js @@ -0,0 +1,18 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import calculateDiscountAmount from "./calculateDiscountAmount.js"; + +test("should return the correct discount amount", () => { + const amount = 100; + const parameters = { + discountCalculationType: "fixed", + discountValue: 10 + }; + + mockContext.discountCalculationMethods = { + fixed: jest.fn().mockReturnValue(10) + }; + + const discountAmount = calculateDiscountAmount(mockContext, amount, parameters); + + expect(discountAmount).toEqual(10); +}); diff --git a/packages/api-plugin-promotions-discounts/src/utils/recalculateCartItemSubtotal.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateCartItemSubtotal.js index 2080b4103b4..8f03c645192 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/recalculateCartItemSubtotal.js +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateCartItemSubtotal.js @@ -1,3 +1,4 @@ +import calculateDiscountAmount from "./calculateDiscountAmount.js"; import formatMoney from "./formatMoney.js"; /** @@ -20,7 +21,7 @@ export default function recalculateCartItemSubtotal(context, item) { if (typeof discountMaxUnits === "number" && discountMaxUnits > 0 && discountMaxUnits < item.quantity) { const pricePerUnit = item.subtotal.amount / item.quantity; const amountCanBeDiscounted = pricePerUnit * discountMaxUnits; - const maxUnitsDiscountedAmount = calculationMethod(discountValue, amountCanBeDiscounted); + const maxUnitsDiscountedAmount = calculateDiscountAmount(context, amountCanBeDiscounted, discount); return formatMoney(maxUnitsDiscountedAmount + (item.subtotal.amount - amountCanBeDiscounted)); } return formatMoney(calculationMethod(discountValue, item.subtotal.amount)); From 9e3feec0f6bc51a3ea3b1d8fa52154ab1e8ce328 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Tue, 21 Feb 2023 14:37:49 +0700 Subject: [PATCH 18/37] fix: place order with shipping discount Signed-off-by: vanpho93 --- .../src/mutations/placeOrder.js | 2 + .../src/mutations/placeOrder.test.js | 3 +- .../api-plugin-orders/src/simpleSchemas.js | 5 +++ .../src/util/addShipmentMethodToGroup.js | 11 +++++- .../buildOrderFulfillmentGroupFromInput.js | 4 ++ .../shipping/applyShippingDiscountToCart.js | 35 ++++++++--------- .../applyShippingDiscountToCart.test.js | 38 ++++++++++++------- .../src/utils/getTotalDiscountOnCart.js | 5 --- .../src/utils/recalculateShippingDiscount.js | 38 +++++++++++-------- .../utils/recalculateShippingDiscount.test.js | 20 ++++++++-- .../src/simpleSchemas.js | 5 ++- 11 files changed, 106 insertions(+), 60 deletions(-) diff --git a/packages/api-plugin-orders/src/mutations/placeOrder.js b/packages/api-plugin-orders/src/mutations/placeOrder.js index 60c0da78e8f..eecc1863cc5 100644 --- a/packages/api-plugin-orders/src/mutations/placeOrder.js +++ b/packages/api-plugin-orders/src/mutations/placeOrder.js @@ -147,6 +147,8 @@ export default async function placeOrder(context, input) { if (!allCartMessageAreAcknowledged) { throw new ReactionError("invalid-cart", "Cart messages should be acknowledged before placing order"); } + + await context.mutations.transformAndValidateCart(context, cart); } diff --git a/packages/api-plugin-orders/src/mutations/placeOrder.test.js b/packages/api-plugin-orders/src/mutations/placeOrder.test.js index f789a3b9c46..d7006d09e3e 100644 --- a/packages/api-plugin-orders/src/mutations/placeOrder.test.js +++ b/packages/api-plugin-orders/src/mutations/placeOrder.test.js @@ -153,7 +153,8 @@ test("places an anonymous $0 order with no cartId and no payments", async () => group: undefined, currencyCode: orderInput.currencyCode, handling: 0, - rate: 0 + rate: 0, + discount: 0 }, shopId: orderInput.shopId, totalItemQuantity: 1, diff --git a/packages/api-plugin-orders/src/simpleSchemas.js b/packages/api-plugin-orders/src/simpleSchemas.js index 400893332ee..73504944af7 100644 --- a/packages/api-plugin-orders/src/simpleSchemas.js +++ b/packages/api-plugin-orders/src/simpleSchemas.js @@ -793,6 +793,11 @@ export const SelectedFulfillmentOption = new SimpleSchema({ rate: { type: Number, min: 0 + }, + discount: { + type: Number, + min: 0, + optional: true } }); diff --git a/packages/api-plugin-orders/src/util/addShipmentMethodToGroup.js b/packages/api-plugin-orders/src/util/addShipmentMethodToGroup.js index 3fb25620b7b..0ae46bd07cc 100644 --- a/packages/api-plugin-orders/src/util/addShipmentMethodToGroup.js +++ b/packages/api-plugin-orders/src/util/addShipmentMethodToGroup.js @@ -45,12 +45,18 @@ export default async function addShipmentMethodToGroup(context, { throw new ReactionError("invalid", errorResult.message); } + const { shipmentMethod: { rate: shipmentRate, undiscountedRate, discount, _id: shipmentMethodId } = {} } = group; const selectedFulfillmentMethod = rates.find((rate) => selectedFulfillmentMethodId === rate.method._id); - if (!selectedFulfillmentMethod) { + const hasShipmentMethodObject = shipmentMethodId && shipmentMethodId !== selectedFulfillmentMethodId; + if (!selectedFulfillmentMethod || hasShipmentMethodObject) { throw new ReactionError("invalid", "The selected fulfillment method is no longer available." + " Fetch updated fulfillment options and try creating the order again with a valid method."); } + if (undiscountedRate && undiscountedRate !== selectedFulfillmentMethod.rate) { + throw new ReactionError("invalid", "The selected fulfillment method has mismatch shipment rate."); + } + group.shipmentMethod = { _id: selectedFulfillmentMethod.method._id, carrier: selectedFulfillmentMethod.method.carrier, @@ -59,6 +65,7 @@ export default async function addShipmentMethodToGroup(context, { group: selectedFulfillmentMethod.method.group, name: selectedFulfillmentMethod.method.name, handling: selectedFulfillmentMethod.handlingPrice, - rate: selectedFulfillmentMethod.rate + rate: shipmentRate || selectedFulfillmentMethod.rate, + discount: discount || 0 }; } diff --git a/packages/api-plugin-orders/src/util/buildOrderFulfillmentGroupFromInput.js b/packages/api-plugin-orders/src/util/buildOrderFulfillmentGroupFromInput.js index a7ff513526c..a784e006afa 100644 --- a/packages/api-plugin-orders/src/util/buildOrderFulfillmentGroupFromInput.js +++ b/packages/api-plugin-orders/src/util/buildOrderFulfillmentGroupFromInput.js @@ -49,6 +49,10 @@ export default async function buildOrderFulfillmentGroupFromInput(context, { if (Array.isArray(additionalItems) && additionalItems.length) { group.items.push(...additionalItems); } + if (cart && Array.isArray(cart.shipping)) { + const cartShipping = cart.shipping.find((shipping) => shipping.shipmentMethod?._id === selectedFulfillmentMethodId); + group.shipmentMethod = cartShipping?.shipmentMethod; + } // Add some more properties for convenience group.itemIds = group.items.map((item) => item._id); diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js index a1af4254e1e..6123ec8ee18 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js @@ -3,7 +3,6 @@ import { createRequire } from "module"; import _ from "lodash"; import Logger from "@reactioncommerce/logger"; import recalculateShippingDiscount from "../../utils/recalculateShippingDiscount.js"; -import getTotalDiscountOnCart from "../../utils/getTotalDiscountOnCart.js"; import formatMoney from "../../utils/formatMoney.js"; import getEligibleShipping from "../../utils/getEligibleIShipping.js"; import calculateDiscountAmount from "../../utils/calculateDiscountAmount.js"; @@ -45,14 +44,14 @@ export function createDiscountRecord(params, discountedItem) { /** * @summary Get the discount amount for a discount item * @param {Object} context - The application context - * @param {Number} totalShippingPrice - The total shipping price + * @param {Number} totalShippingRate - The total shipping price * @param {Object} actionParameters - The action parameters * @returns {Number} - The discount amount */ -export function getTotalShippingDiscount(context, totalShippingPrice, actionParameters) { +export function getTotalShippingDiscount(context, totalShippingRate, actionParameters) { const { discountMaxValue } = actionParameters; - const total = calculateDiscountAmount(context, totalShippingPrice, actionParameters); + const total = calculateDiscountAmount(context, totalShippingRate, actionParameters); if (typeof discountMaxValue === "number" && discountMaxValue > 0) { return Math.min(total, discountMaxValue); } @@ -62,16 +61,16 @@ export function getTotalShippingDiscount(context, totalShippingPrice, actionPara /** * @summary Splits a discount across all shipping * @param {Array} cartShipping - The shipping to split the discount across - * @param {Number} totalShippingPrice - The total shipping price + * @param {Number} totalShippingRate - The total shipping price * @param {Number} discountAmount - The total discount to split * @returns {Array} undefined */ -export function splitDiscountForShipping(cartShipping, totalShippingPrice, discountAmount) { +export function splitDiscountForShipping(cartShipping, totalShippingRate, discountAmount) { let discounted = 0; const discountedShipping = cartShipping.map((shipping, index) => { if (index !== cartShipping.length - 1) { - const shippingPrice = shipping.shipmentMethod.rate + shipping.shipmentMethod.handling; - const discount = formatMoney((shippingPrice / totalShippingPrice) * discountAmount); + const rate = shipping.shipmentMethod.rate || 0; + const discount = formatMoney((rate / totalShippingRate) * discountAmount); discounted += discount; return { _id: shipping._id, amount: discount }; } @@ -82,18 +81,18 @@ export function splitDiscountForShipping(cartShipping, totalShippingPrice, disco } /** - * @summary Get the total shipping price - * @param {Array} cartShipping - The shipping array to get the total price for - * @returns {Number} - The total shipping price + * @summary Get the total shipping rate + * @param {Array} cartShipping - The shipping array to get the total rate for + * @returns {Number} - The total shipping rate */ -export function getTotalShippingPrice(cartShipping) { - const totalPrice = cartShipping +export function getTotalShippingRate(cartShipping) { + const totalRate = cartShipping .map((shipping) => { if (!shipping.shipmentMethod) return 0; - return shipping.shipmentMethod.shippingPrice; + return shipping.shipmentMethod.rate || 0; }) .reduce((sum, price) => sum + price, 0); - return totalPrice; + return totalRate; } /** @@ -124,8 +123,8 @@ export default async function applyShippingDiscountToCart(context, params, cart) if (!cart.shipping) cart.shipping = []; const { actionParameters } = params; const filteredShipping = await getEligibleShipping(context, cart.shipping, params.actionParameters); - const totalShippingPrice = getTotalShippingPrice(filteredShipping); - const totalShippingDiscount = getTotalShippingDiscount(context, totalShippingPrice, actionParameters); + const totalShippingRate = getTotalShippingRate(filteredShipping); + const totalShippingDiscount = getTotalShippingDiscount(context, totalShippingRate, actionParameters); const discountedItems = splitDiscountForShipping(filteredShipping, totalShippingDiscount, totalShippingDiscount); for (const discountedItem of discountedItems) { @@ -142,8 +141,6 @@ export default async function applyShippingDiscountToCart(context, params, cart) recalculateShippingDiscount(context, shipping); } - cart.discount = getTotalDiscountOnCart(cart); - if (discountedItems.length) { Logger.info(logCtx, "Saved Discount to cart"); } diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js index 0abe889104a..461263799b1 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js @@ -46,6 +46,18 @@ test("should apply shipping discount to cart", async () => { rate: 9, shippingPrice: 11 }, + shipmentQuotes: [ + { + method: { + _id: "method1", + handling: 2, + rate: 9, + shippingPrice: 11 + }, + handling: 2, + rate: 9 + } + ], discounts: [] } ], @@ -73,16 +85,16 @@ test("should apply shipping discount to cart", async () => { expect(affected).toEqual(true); expect(updatedCart.shipping[0].shipmentMethod).toEqual({ _id: "method1", - discount: 9, + discount: 7, handling: 2, - rate: 9, - shippingPrice: 2, - undiscountedRate: 11 + rate: 7, + shippingPrice: 7, + undiscountedRate: 9 }); expect(updatedCart.shipping[0].discounts).toHaveLength(1); }); -test("getTotalShippingPrice should return total shipping price", () => { +test("getTotalShippingRate should return total shipping price", () => { const cart = { shipping: [ { @@ -95,16 +107,16 @@ test("getTotalShippingPrice should return total shipping price", () => { { shipmentMethod: { rate: 10, - handling: 1, - shippingPrice: 11 + handling: 2, + shippingPrice: 12 } } ] }; - const totalShippingPrice = applyShippingDiscountToCart.getTotalShippingPrice(cart.shipping); + const totalShippingRate = applyShippingDiscountToCart.getTotalShippingRate(cart.shipping); - expect(totalShippingPrice).toEqual(22); + expect(totalShippingRate).toEqual(19); }); test("getTotalShippingDiscount should return total shipping discount", () => { @@ -124,8 +136,8 @@ test("getTotalShippingDiscount should return total shipping discount", () => { }); test("splitDiscountForShipping should split discount for shipping", () => { - const totalShippingPrice = 22; - const totalShippingDiscount = 10; + const totalShippingRate = 22; + const totalDiscountRate = 10; const cart = { _id: "cart1", @@ -133,7 +145,7 @@ test("splitDiscountForShipping should split discount for shipping", () => { { _id: "shipping1", shipmentMethod: { - rate: 9, + rate: 11, handling: 2 } }, @@ -147,7 +159,7 @@ test("splitDiscountForShipping should split discount for shipping", () => { ] }; - const shippingDiscounts = applyShippingDiscountToCart.splitDiscountForShipping(cart.shipping, totalShippingPrice, totalShippingDiscount); + const shippingDiscounts = applyShippingDiscountToCart.splitDiscountForShipping(cart.shipping, totalShippingRate, totalDiscountRate); expect(shippingDiscounts).toEqual([ { diff --git a/packages/api-plugin-promotions-discounts/src/utils/getTotalDiscountOnCart.js b/packages/api-plugin-promotions-discounts/src/utils/getTotalDiscountOnCart.js index 0e207c4b042..2357ec63d13 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/getTotalDiscountOnCart.js +++ b/packages/api-plugin-promotions-discounts/src/utils/getTotalDiscountOnCart.js @@ -12,10 +12,5 @@ export default function getTotalDiscountOnCart(cart) { totalDiscount += item.subtotal.discount || 0; } - if (!Array.isArray(cart.shipping)) cart.shipping = []; - for (const shipping of cart.shipping) { - totalDiscount += shipping.shipmentMethod?.discount || 0; - } - return Number(formatMoney(totalDiscount)); } diff --git a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js index cfa66b426b9..dea1d5b8af7 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js @@ -1,3 +1,5 @@ +import ReactionError from "@reactioncommerce/reaction-error"; +import calculateDiscountAmount from "./calculateDiscountAmount.js"; import formatMoney from "./formatMoney.js"; /** @@ -8,33 +10,39 @@ import formatMoney from "./formatMoney.js"; */ export default function recalculateShippingDiscount(context, shipping) { let totalDiscount = 0; - const { shipmentMethod } = shipping; - if (!shipmentMethod) return; + const { shipmentMethod, shipmentQuotes } = shipping; + if (!shipmentMethod || shipmentQuotes.length === 0) return; - const undiscountedAmount = formatMoney(shipmentMethod.shippingPrice); + const selectedShipmentQuote = shipmentQuotes.find((quote) => quote.method._id === shipmentMethod._id); + if (!selectedShipmentQuote) throw ReactionError("not-found", "Shipment quote not found in the cart"); + + const rate = selectedShipmentQuote.rate || 0; + const handling = selectedShipmentQuote.handlingPrice || 0; + shipmentMethod.rate = rate; + shipmentMethod.undiscountedRate = rate; shipping.discounts.forEach((discount) => { - const { discountCalculationType, discountValue, discountMaxValue } = discount; - const calculationMethod = context.discountCalculationMethods[discountCalculationType]; + const undiscountedRate = shipmentMethod.rate; + const { discountMaxValue } = discount; - const shippingDiscountAmount = formatMoney(calculationMethod(discountValue, undiscountedAmount)); + const discountRate = calculateDiscountAmount(context, undiscountedRate, discount); // eslint-disable-next-line require-jsdoc - function getDiscountAmount() { - const discountAmount = formatMoney(undiscountedAmount - shippingDiscountAmount); + function getDiscountedRate() { + const discountedRate = formatMoney(undiscountedRate - discountRate); if (typeof discountMaxValue === "number" && discountMaxValue > 0) { - return Math.min(discountAmount, discountMaxValue); + return Math.min(discountedRate, discountMaxValue); } - return discountAmount; + return discountedRate; } - const discountAmount = getDiscountAmount(); + const discountedRate = getDiscountedRate(); - totalDiscount += discountAmount; - discount.discountedAmount = discountAmount; + totalDiscount += discountedRate; + discount.discountedAmount = discountedRate; + shipmentMethod.rate = discountedRate; }); + shipmentMethod.shippingPrice = shipmentMethod.rate + handling; shipmentMethod.discount = totalDiscount; - shipmentMethod.shippingPrice = undiscountedAmount - totalDiscount; - shipmentMethod.undiscountedRate = undiscountedAmount; } diff --git a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.test.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.test.js index 93a5f20ba6a..4c29d69fb13 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.test.js +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.test.js @@ -16,6 +16,18 @@ test("should recalculate shipping discount", async () => { discountCalculationType: "fixed", discountValue: 10 } + ], + shipmentQuotes: [ + { + method: { + _id: "method1", + handling: 2, + rate: 9, + shippingPrice: 11 + }, + handling: 2, + rate: 9 + } ] }; @@ -27,10 +39,10 @@ test("should recalculate shipping discount", async () => { expect(shipping.shipmentMethod).toEqual({ _id: "method1", - discount: 9, + discount: 7, handling: 2, - rate: 9, - shippingPrice: 2, - undiscountedRate: 11 + rate: 7, + shippingPrice: 7, + undiscountedRate: 9 }); }); diff --git a/packages/api-plugin-promotions/src/simpleSchemas.js b/packages/api-plugin-promotions/src/simpleSchemas.js index 09920b2521a..cee652d3c98 100644 --- a/packages/api-plugin-promotions/src/simpleSchemas.js +++ b/packages/api-plugin-promotions/src/simpleSchemas.js @@ -136,7 +136,10 @@ export const CartPromotionItem = new SimpleSchema({ _id: String, name: String, label: String, - description: String, + description: { + type: String, + optional: true + }, triggerType: { type: String, allowedValues: ["implicit", "explicit"] From 234c47e0f3962c3592297dda7c7d75e590ca16c0 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Tue, 21 Feb 2023 15:23:55 +0700 Subject: [PATCH 19/37] fix: calculate shipping discount amount Signed-off-by: vanpho93 --- .../shipping/applyShippingDiscountToCart.test.js | 10 +++++----- .../src/utils/recalculateShippingDiscount.js | 12 ++++++------ .../src/utils/recalculateShippingDiscount.test.js | 12 ++++++------ 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js index 461263799b1..5ca65a7e983 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js @@ -54,7 +54,7 @@ test("should apply shipping discount to cart", async () => { rate: 9, shippingPrice: 11 }, - handling: 2, + handlingPrice: 2, rate: 9 } ], @@ -77,7 +77,7 @@ test("should apply shipping discount to cart", async () => { }; mockContext.discountCalculationMethods = { - fixed: jest.fn().mockReturnValue(2) + fixed: jest.fn().mockReturnValue(0) }; const { cart: updatedCart, affected } = await applyShippingDiscountToCart.default(mockContext, parameters, cart); @@ -85,10 +85,10 @@ test("should apply shipping discount to cart", async () => { expect(affected).toEqual(true); expect(updatedCart.shipping[0].shipmentMethod).toEqual({ _id: "method1", - discount: 7, + discount: 9, handling: 2, - rate: 7, - shippingPrice: 7, + rate: 0, + shippingPrice: 2, undiscountedRate: 9 }); expect(updatedCart.shipping[0].discounts).toHaveLength(1); diff --git a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js index dea1d5b8af7..0a4c72941ed 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js @@ -25,20 +25,20 @@ export default function recalculateShippingDiscount(context, shipping) { const undiscountedRate = shipmentMethod.rate; const { discountMaxValue } = discount; - const discountRate = calculateDiscountAmount(context, undiscountedRate, discount); + const discountedRate = calculateDiscountAmount(context, undiscountedRate, discount); // eslint-disable-next-line require-jsdoc function getDiscountedRate() { - const discountedRate = formatMoney(undiscountedRate - discountRate); + const discountRate = formatMoney(undiscountedRate - discountedRate); if (typeof discountMaxValue === "number" && discountMaxValue > 0) { - return Math.min(discountedRate, discountMaxValue); + return Math.min(discountRate, discountMaxValue); } - return discountedRate; + return discountRate; } - const discountedRate = getDiscountedRate(); + const discountRate = getDiscountedRate(); - totalDiscount += discountedRate; + totalDiscount += discountRate; discount.discountedAmount = discountedRate; shipmentMethod.rate = discountedRate; }); diff --git a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.test.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.test.js index 4c29d69fb13..5a17fd03b33 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.test.js +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.test.js @@ -25,24 +25,24 @@ test("should recalculate shipping discount", async () => { rate: 9, shippingPrice: 11 }, - handling: 2, - rate: 9 + rate: 9, + handlingPrice: 2 } ] }; mockContext.discountCalculationMethods = { - fixed: jest.fn().mockReturnValue(2) + fixed: jest.fn().mockReturnValue(0) }; recalculateShippingDiscount(mockContext, shipping); expect(shipping.shipmentMethod).toEqual({ _id: "method1", - discount: 7, + discount: 9, handling: 2, - rate: 7, - shippingPrice: 7, + rate: 0, + shippingPrice: 2, undiscountedRate: 9 }); }); From 7d9b8c11b0c72c737186262c3da8520de49cdc16 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Wed, 22 Feb 2023 11:25:03 +0700 Subject: [PATCH 20/37] feat: estimate discount amount for shipment quotes Signed-off-by: vanpho93 --- .../src/actions/discountAction.js | 23 ++++++++--- .../shipping/applyShippingDiscountToCart.js | 41 ++++++++++++++++++- .../src/preStartup.js | 21 +++++++++- .../src/utils/getEligibleIShipping.js | 23 ++++++++--- .../src/utils/recalculateQuoteDiscount.js | 41 +++++++++++++++++++ .../src/utils/recalculateShippingDiscount.js | 4 +- .../src/handlers/applyPromotions.js | 7 ++++ 7 files changed, 144 insertions(+), 16 deletions(-) create mode 100644 packages/api-plugin-promotions-discounts/src/utils/recalculateQuoteDiscount.js diff --git a/packages/api-plugin-promotions-discounts/src/actions/discountAction.js b/packages/api-plugin-promotions-discounts/src/actions/discountAction.js index 36831c4205b..2759b9e8e00 100644 --- a/packages/api-plugin-promotions-discounts/src/actions/discountAction.js +++ b/packages/api-plugin-promotions-discounts/src/actions/discountAction.js @@ -81,13 +81,26 @@ export async function discountActionCleanup(context, cart) { return item; }); + // eslint-disable-next-line require-jsdoc + function resetMethod(method) { + method.rate = method.undiscountedRate || method.rate; + method.discount = 0; + method.shippingPrice = method.rate + (method.handlingPrice || method.handling); + method.undiscountedRate = 0; + } + for (const shipping of cart.shipping) { shipping.discounts = []; - const { shipmentMethod } = shipping; - if (shipmentMethod) { - shipmentMethod.shippingPrice = shipmentMethod.handling + shipmentMethod.rate; - shipmentMethod.discount = 0; - shipmentMethod.undiscountedRate = 0; + + if (!shipping.shipmentQuotes) shipping.shipmentQuotes = []; + shipping.shipmentQuotes.forEach((quote) => { + resetMethod(quote.method); + resetMethod(quote); + quote.discounts = []; + }); + + if (shipping.shipmentMethod) { + resetMethod(shipping.shipmentMethod); } } diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js index 6123ec8ee18..0651a496431 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js @@ -6,6 +6,7 @@ import recalculateShippingDiscount from "../../utils/recalculateShippingDiscount import formatMoney from "../../utils/formatMoney.js"; import getEligibleShipping from "../../utils/getEligibleIShipping.js"; import calculateDiscountAmount from "../../utils/calculateDiscountAmount.js"; +import recalculateQuoteDiscount from "../../utils/recalculateQuoteDiscount.js"; const require = createRequire(import.meta.url); @@ -112,6 +113,38 @@ export function canBeApplyDiscountToShipping(shipping, discount) { return true; } +/** + * @summary Estimate the shipment quote discount + * @param {Object} context - The application context + * @param {object} cart - The cart to apply the discount to + * @param {Object} params - The parameters to apply + * @returns {Promise} - Has affected shipping + */ +export async function estimateShipmentQuoteDiscount(context, cart, params) { + const { actionParameters, promotion } = params; + const filteredItems = await getEligibleShipping(context, cart.shipping, { + ...actionParameters, + estimateShipmentQuote: true + }); + + const shipmentQuotes = cart.shipping[0]?.shipmentQuotes || []; + + for (const item of filteredItems) { + const shipmentQuote = shipmentQuotes.find((quote) => quote.method._id === item.method._id); + if (!shipmentQuote) continue; + + const canBeDiscounted = canBeApplyDiscountToShipping(shipmentQuote, promotion); + if (!canBeDiscounted) continue; + + if (!shipmentQuote.discounts) shipmentQuote.discounts = []; + shipmentQuote.discounts.push(createDiscountRecord(params, item)); + + recalculateQuoteDiscount(context, shipmentQuote, actionParameters); + } + + return filteredItems.length > 0; +} + /** * @summary Add the discount to the shipping record * @param {Object} context - The application context @@ -120,9 +153,13 @@ export function canBeApplyDiscountToShipping(shipping, discount) { * @returns {Promise} undefined */ export default async function applyShippingDiscountToCart(context, params, cart) { - if (!cart.shipping) cart.shipping = []; const { actionParameters } = params; - const filteredShipping = await getEligibleShipping(context, cart.shipping, params.actionParameters); + + if (!cart.shipping) cart.shipping = []; + + await estimateShipmentQuoteDiscount(context, cart, params); + + const filteredShipping = await getEligibleShipping(context, cart.shipping, actionParameters); const totalShippingRate = getTotalShippingRate(filteredShipping); const totalShippingDiscount = getTotalShippingDiscount(context, totalShippingRate, actionParameters); const discountedItems = splitDiscountForShipping(filteredShipping, totalShippingDiscount, totalShippingDiscount); diff --git a/packages/api-plugin-promotions-discounts/src/preStartup.js b/packages/api-plugin-promotions-discounts/src/preStartup.js index 1d8aa61dc17..9650197cf13 100644 --- a/packages/api-plugin-promotions-discounts/src/preStartup.js +++ b/packages/api-plugin-promotions-discounts/src/preStartup.js @@ -22,7 +22,7 @@ const discountSchema = new SimpleSchema({ * @returns {Promise} undefined */ async function extendCartSchemas(context) { - const { simpleSchemas: { Cart, CartItem, Shipment, ShippingMethod } } = context; + const { simpleSchemas: { Cart, CartItem, Shipment, ShippingMethod, ShipmentQuote } } = context; Cart.extend(discountSchema); Cart.extend({ "discounts": { @@ -65,6 +65,25 @@ async function extendCartSchemas(context) { } }); + ShipmentQuote.extend({ + "discounts": { + type: Array, + defaultValue: [], + optional: true + }, + "discounts.$": { + type: CartDiscount + }, + "undiscountedRate": { + type: Number, + optional: true + }, + "discount": { + type: Number, + optional: true + } + }); + ShippingMethod.extend({ undiscountedRate: { type: Number, diff --git a/packages/api-plugin-promotions-discounts/src/utils/getEligibleIShipping.js b/packages/api-plugin-promotions-discounts/src/utils/getEligibleIShipping.js index 824aecfb883..c823b51b318 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/getEligibleIShipping.js +++ b/packages/api-plugin-promotions-discounts/src/utils/getEligibleIShipping.js @@ -32,12 +32,23 @@ export default async function getEligibleShipping(context, shipping, params) { const checkerMethod = getCheckMethod(params.inclusionRules, params.exclusionRules); - const eligibleShipping = []; - for (const shippingItem of shipping) { - // eslint-disable-next-line no-await-in-loop - if (await checkerMethod(shippingItem)) { - eligibleShipping.push(shippingItem); + const eligibleItems = []; + if (params.estimateShipmentQuote) { + const shipmentQuotes = shipping[0]?.shipmentQuotes || []; + for (const quote of shipmentQuotes) { + // eslint-disable-next-line no-await-in-loop + if (await checkerMethod({ ...quote, shipmentMethod: quote.method || {} })) { + eligibleItems.push(quote); + } + } + } else { + for (const shippingItem of shipping) { + // eslint-disable-next-line no-await-in-loop + if (await checkerMethod(shippingItem)) { + eligibleItems.push(shippingItem); + } } } - return eligibleShipping; + + return eligibleItems; } diff --git a/packages/api-plugin-promotions-discounts/src/utils/recalculateQuoteDiscount.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateQuoteDiscount.js new file mode 100644 index 00000000000..2472ee4d9c1 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateQuoteDiscount.js @@ -0,0 +1,41 @@ +import calculateDiscountAmount from "./calculateDiscountAmount.js"; +import formatMoney from "./formatMoney.js"; + +/** + * @summary Recalculate shipping discount + * @param {Object} context - The application context + * @param {Object} quote - The quote record + * @returns {Promise} undefined + */ +export default function recalculateQuoteDiscount(context, quote) { + let totalDiscount = 0; + const { method, undiscountedRate } = quote; + + const rate = undiscountedRate || method.rate; + quote.undiscountedRate = rate; + + quote.discounts.forEach((discount) => { + const quoteRate = quote.rate; + const { discountMaxValue } = discount; + + const discountedRate = calculateDiscountAmount(context, quoteRate, discount); + + // eslint-disable-next-line require-jsdoc + function getDiscountedRate() { + const discountRate = formatMoney(quoteRate - discountedRate); + if (typeof discountMaxValue === "number" && discountMaxValue > 0) { + return Math.min(discountRate, discountMaxValue); + } + return discountRate; + } + + const discountRate = getDiscountedRate(); + + totalDiscount += discountRate; + discount.discountedAmount = discountedRate; + quote.rate = discountedRate; + }); + + quote.discount = totalDiscount; + quote.shippingPrice = quote.rate + quote.handlingPrice; +} diff --git a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js index 0a4c72941ed..a6c5075c607 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js @@ -16,8 +16,8 @@ export default function recalculateShippingDiscount(context, shipping) { const selectedShipmentQuote = shipmentQuotes.find((quote) => quote.method._id === shipmentMethod._id); if (!selectedShipmentQuote) throw ReactionError("not-found", "Shipment quote not found in the cart"); - const rate = selectedShipmentQuote.rate || 0; - const handling = selectedShipmentQuote.handlingPrice || 0; + const rate = selectedShipmentQuote.method.rate || 0; + const handling = selectedShipmentQuote.method.handling || 0; shipmentMethod.rate = rate; shipmentMethod.undiscountedRate = rate; diff --git a/packages/api-plugin-promotions/src/handlers/applyPromotions.js b/packages/api-plugin-promotions/src/handlers/applyPromotions.js index 70116e06000..495cc077d3b 100644 --- a/packages/api-plugin-promotions/src/handlers/applyPromotions.js +++ b/packages/api-plugin-promotions/src/handlers/applyPromotions.js @@ -112,6 +112,13 @@ export default async function applyPromotions(context, cart) { const unqualifiedPromotions = promotions.concat(appliedExplicitPromotions); + // sort to move shipping discounts to the end + unqualifiedPromotions.sort((promA, promB) => { + if (_.some(promA.actions, (action) => action.actionParameters.discountType === "shipping")) return 1; + if (_.some(promB.actions, (action) => action.actionParameters.discountType === "shipping")) return -1; + return 0; + }); + for (const { cleanup } of pluginPromotions.actions) { cleanup && await cleanup(context, cart); } From 88b213fde677ba23ec5a78f56990095cdacc6320 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Fri, 24 Feb 2023 10:27:16 +0700 Subject: [PATCH 21/37] fix: applyPromotion unit test fail Signed-off-by: vanpho93 --- .../src/handlers/applyPromotions.test.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js b/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js index 4850c273774..bb21b8885b7 100644 --- a/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js +++ b/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js @@ -20,7 +20,7 @@ const pluginPromotion = { const testPromotion = { _id: "test id", - actions: [{ actionKey: "test" }], + actions: [{ actionKey: "test", actionParameters: { discountType: "order" } }], triggers: [{ triggerKey: "test", triggerParameters: { name: "test trigger" } }], stackability: { key: "none", @@ -56,7 +56,8 @@ test("should save cart with implicit promotions are applied", async () => { }); expect(testAction).toBeCalledWith(mockContext, expect.objectContaining({ _id: cart._id }), { actionKey: "test", - promotion: testPromotion + promotion: testPromotion, + actionParameters: { discountType: "order" } }); expect(testEnhancer).toBeCalledWith(mockContext, expect.objectContaining({ _id: cart._id })); From 3dcde455191a5e18e88894e20eea1d4b06380d38 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Fri, 24 Feb 2023 12:17:46 +0700 Subject: [PATCH 22/37] fix: max discount value for shipping discount Signed-off-by: vanpho93 --- .../src/utils/recalculateQuoteDiscount.js | 7 +++---- .../src/utils/recalculateShippingDiscount.js | 9 +++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/api-plugin-promotions-discounts/src/utils/recalculateQuoteDiscount.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateQuoteDiscount.js index 2472ee4d9c1..2ecc6b64695 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/recalculateQuoteDiscount.js +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateQuoteDiscount.js @@ -16,10 +16,9 @@ export default function recalculateQuoteDiscount(context, quote) { quote.discounts.forEach((discount) => { const quoteRate = quote.rate; - const { discountMaxValue } = discount; - const discountedRate = calculateDiscountAmount(context, quoteRate, discount); + const { discountMaxValue } = discount; // eslint-disable-next-line require-jsdoc function getDiscountedRate() { const discountRate = formatMoney(quoteRate - discountedRate); @@ -32,8 +31,8 @@ export default function recalculateQuoteDiscount(context, quote) { const discountRate = getDiscountedRate(); totalDiscount += discountRate; - discount.discountedAmount = discountedRate; - quote.rate = discountedRate; + discount.discountedAmount = discountRate; + quote.rate = formatMoney(quoteRate - discountRate); }); quote.discount = totalDiscount; diff --git a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js index a6c5075c607..2f5ea986e14 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js @@ -16,17 +16,18 @@ export default function recalculateShippingDiscount(context, shipping) { const selectedShipmentQuote = shipmentQuotes.find((quote) => quote.method._id === shipmentMethod._id); if (!selectedShipmentQuote) throw ReactionError("not-found", "Shipment quote not found in the cart"); - const rate = selectedShipmentQuote.method.rate || 0; - const handling = selectedShipmentQuote.method.handling || 0; + const { method } = selectedShipmentQuote; + const rate = method.undiscountedRate || method.rate; + const handling = method.handling || 0; shipmentMethod.rate = rate; shipmentMethod.undiscountedRate = rate; shipping.discounts.forEach((discount) => { const undiscountedRate = shipmentMethod.rate; - const { discountMaxValue } = discount; const discountedRate = calculateDiscountAmount(context, undiscountedRate, discount); + const { discountMaxValue } = discount; // eslint-disable-next-line require-jsdoc function getDiscountedRate() { const discountRate = formatMoney(undiscountedRate - discountedRate); @@ -40,7 +41,7 @@ export default function recalculateShippingDiscount(context, shipping) { totalDiscount += discountRate; discount.discountedAmount = discountedRate; - shipmentMethod.rate = discountedRate; + shipmentMethod.rate = formatMoney(undiscountedRate - discountRate); }); shipmentMethod.shippingPrice = shipmentMethod.rate + handling; From f21dfaa2932e2078b3b10d56471055dc9f83769f Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Sun, 26 Feb 2023 14:58:30 +0700 Subject: [PATCH 23/37] feat: add integration test for shipping disocunt Signed-off-by: vanpho93 --- .../checkout/promotionCheckout.test.js | 21 +++++++++++++++++++ .../shipping/applyShippingDiscountToCart.js | 2 +- .../src/utils/recalculateShippingDiscount.js | 2 +- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js b/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js index 99c6101d55a..a0671213da2 100644 --- a/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js +++ b/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js @@ -635,4 +635,25 @@ describe("Promotions", () => { expect(cart.appliedPromotions).toHaveLength(2); }); }); + + describe("shipping promotion", () => { + afterAll(async () => { + await removeAllPromotions(); + }); + + createTestPromotion({ + actions: [ + { + actionKey: "discounts", + actionParameters: { + discountType: "shipping", + discountCalculationType: "percentage", + discountValue: 50 + } + } + ] + }); + + createCartAndPlaceOrder({ quantity: 20 }); + }); }); diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js index 0651a496431..a02e086450a 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js @@ -162,7 +162,7 @@ export default async function applyShippingDiscountToCart(context, params, cart) const filteredShipping = await getEligibleShipping(context, cart.shipping, actionParameters); const totalShippingRate = getTotalShippingRate(filteredShipping); const totalShippingDiscount = getTotalShippingDiscount(context, totalShippingRate, actionParameters); - const discountedItems = splitDiscountForShipping(filteredShipping, totalShippingDiscount, totalShippingDiscount); + const discountedItems = splitDiscountForShipping(filteredShipping, totalShippingRate, totalShippingDiscount); for (const discountedItem of discountedItems) { const shipping = filteredShipping.find((item) => item._id === discountedItem._id); diff --git a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js index 2f5ea986e14..fd12843b6bd 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js @@ -40,7 +40,7 @@ export default function recalculateShippingDiscount(context, shipping) { const discountRate = getDiscountedRate(); totalDiscount += discountRate; - discount.discountedAmount = discountedRate; + discount.discountedAmount = discountRate; shipmentMethod.rate = formatMoney(undiscountedRate - discountRate); }); From 7bb460babfd3d6fc3cd1431e817e6177e4840c1d Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Mon, 27 Feb 2023 08:53:36 +0700 Subject: [PATCH 24/37] fix: unit test fail on disocuntAction Signed-off-by: vanpho93 --- .../src/actions/discountAction.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js b/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js index 2132f3b02bf..f0d1524da3d 100644 --- a/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js +++ b/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js @@ -120,7 +120,8 @@ describe("cleanup", () => { rate: 9, shippingPrice: 11, undiscountedRate: 0 - } + }, + shipmentQuotes: [] } ] }); From 93a5c47cc0e59b8eb4540910fc8ef0a0ec3a685e Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Mon, 27 Feb 2023 10:04:05 +0700 Subject: [PATCH 25/37] feat: add expect discount amount for integraiton test Signed-off-by: vanpho93 --- .../checkout/promotionCheckout.test.js | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js b/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js index a0671213da2..9634f570538 100644 --- a/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js +++ b/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js @@ -104,9 +104,7 @@ describe("Promotions", () => { }; const removeAllPromotions = async () => { - await testApp.setLoggedInUser(mockAdminAccount); - await testApp.collections.Promotions.remove({}); - await testApp.clearLoggedInUser(); + await testApp.collections.Promotions.deleteMany({}); }; const createTestPromotion = (overlay = {}) => { @@ -625,6 +623,10 @@ describe("Promotions", () => { }); describe("Stackability: should applied with other promotions when stackability is all", () => { + afterAll(async () => { + await removeAllPromotions(); + }); + createTestPromotion(); createTestPromotion(); createTestCart({ quantity: 20 }); @@ -654,6 +656,23 @@ describe("Promotions", () => { ] }); - createCartAndPlaceOrder({ quantity: 20 }); + createCartAndPlaceOrder({ quantity: 6 }); + + test("placed order get the correct values", async () => { + const orderId = decodeOpaqueIdForNamespace("reaction/order")(placedOrderId); + const newOrder = await testApp.collections.Orders.findOne({ _id: orderId }); + expect(newOrder.shipping[0].invoice.total).toEqual(121.94); + expect(newOrder.shipping[0].invoice.discounts).toEqual(0); + expect(newOrder.shipping[0].invoice.subtotal).toEqual(119.94); + expect(newOrder.shipping[0].invoice.shipping).toEqual(2); + expect(newOrder.shipping[0].shipmentMethod.discount).toEqual(0.5); + expect(newOrder.shipping[0].shipmentMethod.rate).toEqual(0.5); + expect(newOrder.shipping[0].shipmentMethod.handling).toEqual(1.5); + + expect(newOrder.shipping[0].items[0].quantity).toEqual(6); + + expect(newOrder.appliedPromotions[0]._id).toEqual(mockPromotion._id); + expect(newOrder.discounts).toHaveLength(1); + }); }); }); From 070b2847fcf7988efe1ea4b3fdf27ae217be2ff7 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Mon, 27 Feb 2023 16:14:44 +0700 Subject: [PATCH 26/37] feat: two shipping promotion test case Signed-off-by: vanpho93 --- .../checkout/promotionCheckout.test.js | 82 +++++++++++++++---- packages/api-plugin-promotions/src/startup.js | 2 +- pnpm-lock.yaml | 7 +- 3 files changed, 73 insertions(+), 18 deletions(-) diff --git a/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js b/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js index 9634f570538..18c3cc9f3af 100644 --- a/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js +++ b/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js @@ -103,8 +103,9 @@ describe("Promotions", () => { region: "CA" }; - const removeAllPromotions = async () => { - await testApp.collections.Promotions.deleteMany({}); + const cleanup = async () => { + await testApp.collections.Promotions.deleteMany(); + await testApp.collections.Cart.deleteMany(); }; const createTestPromotion = (overlay = {}) => { @@ -258,7 +259,7 @@ describe("Promotions", () => { describe("when a promotion is applied to an order with fixed promotion", () => { afterAll(async () => { - await removeAllPromotions(); + await cleanup(); }); createTestPromotion(); @@ -282,7 +283,7 @@ describe("Promotions", () => { describe("when a promotion is applied to an order percentage discount", () => { afterAll(async () => { - await removeAllPromotions(); + await cleanup(); }); createTestPromotion({ @@ -326,7 +327,7 @@ describe("Promotions", () => { describe("when a promotion applied via inclusion criteria", () => { afterAll(async () => { - await removeAllPromotions(); + await cleanup(); }); const triggerParameters = { ...fixedDiscountPromotion.triggers[0].triggerParameters }; @@ -406,7 +407,7 @@ describe("Promotions", () => { describe("when a promotion isn't applied via inclusion criteria", () => { afterAll(async () => { - await removeAllPromotions(); + await cleanup(); }); const triggerParameters = { ...fixedDiscountPromotion.triggers[0].triggerParameters }; @@ -447,7 +448,7 @@ describe("Promotions", () => { describe("when a promotion isn't applied by exclusion criteria", () => { afterAll(async () => { - await removeAllPromotions(); + await cleanup(); }); const triggerParameters = { ...fixedDiscountPromotion.triggers[0].triggerParameters }; @@ -505,7 +506,7 @@ describe("Promotions", () => { describe("cart shouldn't contains any promotion when qualified promotion is change to disabled", () => { afterAll(async () => { - await removeAllPromotions(); + await cleanup(); }); createTestPromotion(); @@ -539,13 +540,13 @@ describe("Promotions", () => { expect(cart.appliedPromotions).toHaveLength(0); expect(cart.messages).toHaveLength(1); - await removeAllPromotions(); + await cleanup(); }); }); describe("cart applied promotion with 10% but max discount is $20", () => { afterAll(async () => { - await removeAllPromotions(); + await cleanup(); }); createTestPromotion({ @@ -604,7 +605,7 @@ describe("Promotions", () => { describe("Stackability: shouldn't stack with other promotion when stackability is none", () => { afterAll(async () => { - await removeAllPromotions(); + await cleanup(); }); createTestPromotion(); @@ -624,7 +625,7 @@ describe("Promotions", () => { describe("Stackability: should applied with other promotions when stackability is all", () => { afterAll(async () => { - await removeAllPromotions(); + await cleanup(); }); createTestPromotion(); @@ -638,9 +639,9 @@ describe("Promotions", () => { }); }); - describe("shipping promotion", () => { + describe("apply with single shipping promotion", () => { afterAll(async () => { - await removeAllPromotions(); + await cleanup(); }); createTestPromotion({ @@ -675,4 +676,57 @@ describe("Promotions", () => { expect(newOrder.discounts).toHaveLength(1); }); }); + + describe("apply with two shipping promotions", () => { + beforeAll(async () => { + await cleanup(); + }); + + createTestPromotion({ + label: "shipping promotion 1", + actions: [ + { + actionKey: "discounts", + actionParameters: { + discountType: "shipping", + discountCalculationType: "percentage", + discountValue: 50 + } + } + ] + }); + + createTestPromotion({ + label: "shipping promotion 2", + actions: [ + { + actionKey: "discounts", + actionParameters: { + discountType: "shipping", + discountCalculationType: "fixed", + discountValue: 0.5 + } + } + ] + }); + + createCartAndPlaceOrder({ quantity: 6 }); + + test("placed order get the correct values", async () => { + const orderId = decodeOpaqueIdForNamespace("reaction/order")(placedOrderId); + const newOrder = await testApp.collections.Orders.findOne({ _id: orderId }); + expect(newOrder.shipping[0].invoice.total).toEqual(121.94); + expect(newOrder.shipping[0].invoice.discounts).toEqual(0); + expect(newOrder.shipping[0].invoice.subtotal).toEqual(119.94); + expect(newOrder.shipping[0].invoice.shipping).toEqual(2); + expect(newOrder.shipping[0].shipmentMethod.discount).toEqual(0.5); + expect(newOrder.shipping[0].shipmentMethod.rate).toEqual(0.5); + expect(newOrder.shipping[0].shipmentMethod.handling).toEqual(1.5); + + expect(newOrder.shipping[0].items[0].quantity).toEqual(6); + + expect(newOrder.appliedPromotions).toHaveLength(2); + expect(newOrder.discounts).toHaveLength(2); + }); + }); }); diff --git a/packages/api-plugin-promotions/src/startup.js b/packages/api-plugin-promotions/src/startup.js index 70b7ac9674c..040d38de6f8 100644 --- a/packages/api-plugin-promotions/src/startup.js +++ b/packages/api-plugin-promotions/src/startup.js @@ -22,7 +22,7 @@ const logCtx = { export default async function startupPromotions(context) { const { bullQueue } = context; await bullQueue.createQueue(context, "setPromotionState", { jobName: "checkForChangedStates" }, setPromotionState(context)); - await bullQueue.scheduleJob(context, "setPromotionState", "checkForChangedStates", {}, "*/5 * * * *"); + await bullQueue.scheduleJob(context, "setPromotionState", "checkForChangedStates", {}, "*/1 * * * *"); Logger.info(logCtx, "Add setPromotionState queue and job"); await bullQueue.createQueue(context, "checkExistingCarts", {}, checkCartForPromotionChange(context)); return true; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9594b68eceb..f397c70337d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -258,7 +258,7 @@ importers: '@reactioncommerce/file-collections-sa-gridfs': link:../../packages/file-collections-sa-gridfs '@reactioncommerce/logger': link:../../packages/logger '@reactioncommerce/random': link:../../packages/random - '@snyk/protect': 1.1096.0 + '@snyk/protect': 1.1109.0 graphql: 16.6.0 nodemailer: 6.8.0 semver: 6.3.0 @@ -5149,8 +5149,9 @@ packages: '@sinonjs/commons': 1.8.3 dev: false - /@snyk/protect/1.1096.0: - resolution: {integrity: sha512-E0hkw5TY8rIygL2uohywBrW72f1x/g36mHdMxS9UzLB9DHLDudJJYHMwJfdjl6dW7cuuTVauv8TDQireMkjOVw==} + /@snyk/protect/1.1109.0: + resolution: {integrity: sha512-AR2RO6B4LsGUTtTnRDxmDhb8EKrTMhRg3RnxQD/uP1RHFsBLNnilQrAeC0qHldrbG9k4qMmE/300aLSd+UGHiw==} + engines: {node: '>=10'} hasBin: true dev: false From 6be616a68232b65715bad2ef85d9ef9e518e3884 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Mon, 27 Feb 2023 21:20:02 +0700 Subject: [PATCH 27/37] feat: add additional redeemed coupon information Signed-off-by: vanpho93 --- .../src/index.js | 1 + .../src/queries/couponLog.js | 12 ++ .../src/queries/couponLogByOrderId.js | 11 ++ .../src/queries/couponLogs.js | 34 ++++++ .../src/queries/index.js | 8 +- .../src/resolvers/Order/index.js | 3 + .../src/resolvers/Query/couponLog.js | 16 +++ .../src/resolvers/Query/couponLogs.js | 23 ++++ .../src/resolvers/Query/index.js | 6 +- .../src/resolvers/index.js | 2 + .../src/schemas/schema.graphql | 113 ++++++++++++++++++ .../src/simpleSchemas.js | 1 + .../src/utils/updateOrderCoupon.js | 6 +- pnpm-lock.yaml | 7 +- 14 files changed, 236 insertions(+), 7 deletions(-) create mode 100644 packages/api-plugin-promotions-coupons/src/queries/couponLog.js create mode 100644 packages/api-plugin-promotions-coupons/src/queries/couponLogByOrderId.js create mode 100644 packages/api-plugin-promotions-coupons/src/queries/couponLogs.js create mode 100644 packages/api-plugin-promotions-coupons/src/resolvers/Order/index.js create mode 100644 packages/api-plugin-promotions-coupons/src/resolvers/Query/couponLog.js create mode 100644 packages/api-plugin-promotions-coupons/src/resolvers/Query/couponLogs.js diff --git a/packages/api-plugin-promotions-coupons/src/index.js b/packages/api-plugin-promotions-coupons/src/index.js index 8d709799550..c720d335917 100644 --- a/packages/api-plugin-promotions-coupons/src/index.js +++ b/packages/api-plugin-promotions-coupons/src/index.js @@ -33,6 +33,7 @@ export default async function register(app) { name: "CouponLogs", indexes: [ [{ couponId: 1 }], + [{ orderId: 1 }], [{ promotionId: 1 }], [{ couponId: 1, accountId: 1 }, { unique: true }] ] diff --git a/packages/api-plugin-promotions-coupons/src/queries/couponLog.js b/packages/api-plugin-promotions-coupons/src/queries/couponLog.js new file mode 100644 index 00000000000..137a10945ae --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/queries/couponLog.js @@ -0,0 +1,12 @@ +/** + * @summary return a single coupon log based on shopId and _id + * @param {Object} context - the application context + * @param {String} shopId - The id of the shop + * @param {String} _id - The unencoded id of the coupon log + * @return {Object} - The coupon log or null + */ +export default async function couponLog(context, { shopId, _id }) { + const { collections: { CouponLogs } } = context; + const singleCouponLog = await CouponLogs.findOne({ shopId, _id }); + return singleCouponLog; +} diff --git a/packages/api-plugin-promotions-coupons/src/queries/couponLogByOrderId.js b/packages/api-plugin-promotions-coupons/src/queries/couponLogByOrderId.js new file mode 100644 index 00000000000..3f72e51b249 --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/queries/couponLogByOrderId.js @@ -0,0 +1,11 @@ +/** + * @summary return a single coupon log based on shopId and _id + * @param {Object} context - the application context + * @param {String} params.orderId - The order id of the coupon log + * @return {Object} - The coupon log or null + */ +export default async function couponLogByOrderId(context, { orderId }) { + const { collections: { CouponLogs } } = context; + const singleCouponLog = await CouponLogs.findOne({ orderId }); + return singleCouponLog; +} diff --git a/packages/api-plugin-promotions-coupons/src/queries/couponLogs.js b/packages/api-plugin-promotions-coupons/src/queries/couponLogs.js new file mode 100644 index 00000000000..954f61044de --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/queries/couponLogs.js @@ -0,0 +1,34 @@ +/** + * @summary return a possibly filtered list of coupon logs + * @param {Object} context - The application context + * @param {String} shopId - The shopId to query for + * @param {Object} filter - optional filter parameters + * @return {Promise>} - A list of coupon logs + */ +export default async function couponLogs(context, shopId, filter) { + const { collections: { CouponLogs } } = context; + + const selector = { shopId }; + + if (filter) { + const { couponId, promotionId, orderId, accountId } = filter; + + if (couponId) { + selector.couponId = couponId; + } + + if (promotionId) { + selector.promotionId = promotionId; + } + + if (orderId) { + selector.orderId = orderId; + } + + if (accountId) { + selector.accountId = accountId; + } + } + + return CouponLogs.find(selector); +} diff --git a/packages/api-plugin-promotions-coupons/src/queries/index.js b/packages/api-plugin-promotions-coupons/src/queries/index.js index 4ab1be71056..c93d840ddf7 100644 --- a/packages/api-plugin-promotions-coupons/src/queries/index.js +++ b/packages/api-plugin-promotions-coupons/src/queries/index.js @@ -1,7 +1,13 @@ import coupon from "./coupon.js"; import coupons from "./coupons.js"; +import couponLog from "./couponLog.js"; +import couponLogs from "./couponLogs.js"; +import couponLogByOrderId from "./couponLogByOrderId.js"; export default { coupon, - coupons + coupons, + couponLog, + couponLogs, + couponLogByOrderId }; diff --git a/packages/api-plugin-promotions-coupons/src/resolvers/Order/index.js b/packages/api-plugin-promotions-coupons/src/resolvers/Order/index.js new file mode 100644 index 00000000000..950909dd78c --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/resolvers/Order/index.js @@ -0,0 +1,3 @@ +export default { + couponLog: (order, _, context) => context.queries.couponLogByOrderId(context, order.orderId) +}; diff --git a/packages/api-plugin-promotions-coupons/src/resolvers/Query/couponLog.js b/packages/api-plugin-promotions-coupons/src/resolvers/Query/couponLog.js new file mode 100644 index 00000000000..13c9578c75d --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/resolvers/Query/couponLog.js @@ -0,0 +1,16 @@ +/** + * @summary query the coupons collection for a single coupon log + * @param {Object} _ - unused + * @param {Object} args - an object of all arguments that were sent by the client + * @param {String} args.shopId - Shop id of the coupon + * @param {Object} context - an object containing the per-request state + * @returns {Promise} A coupon log record or null + */ +export default async function couponLog(_, args, context) { + const { input } = args; + const { shopId, _id } = input; + await context.validatePermissions("reaction:legacy:promotions", "read", { shopId }); + return context.queries.couponLog(context, { + shopId, _id + }); +} diff --git a/packages/api-plugin-promotions-coupons/src/resolvers/Query/couponLogs.js b/packages/api-plugin-promotions-coupons/src/resolvers/Query/couponLogs.js new file mode 100644 index 00000000000..f5001125093 --- /dev/null +++ b/packages/api-plugin-promotions-coupons/src/resolvers/Query/couponLogs.js @@ -0,0 +1,23 @@ +import getPaginatedResponse from "@reactioncommerce/api-utils/graphql/getPaginatedResponse.js"; +import wasFieldRequested from "@reactioncommerce/api-utils/graphql/wasFieldRequested.js"; + +/** + * @summary Query for a list of coupon logs + * @param {Object} _ - unused + * @param {Object} args - an object of all arguments that were sent by the client + * @param {String} args.shopId - id of user to query + * @param {Object} context - an object containing the per-request state + * @param {Object} info Info about the GraphQL request + * @returns {Promise} CouponLogs + */ +export default async function couponLogs(_, args, context, info) { + const { shopId, filter, ...connectionArgs } = args; + await context.validatePermissions("reaction:legacy:promotions", "read", { shopId }); + const query = await context.queries.couponLogs(context, shopId, filter); + + return getPaginatedResponse(query, connectionArgs, { + includeHasNextPage: wasFieldRequested("pageInfo.hasNextPage", info), + includeHasPreviousPage: wasFieldRequested("pageInfo.hasPreviousPage", info), + includeTotalCount: wasFieldRequested("totalCount", info) + }); +} diff --git a/packages/api-plugin-promotions-coupons/src/resolvers/Query/index.js b/packages/api-plugin-promotions-coupons/src/resolvers/Query/index.js index 4ab1be71056..6f990a26698 100644 --- a/packages/api-plugin-promotions-coupons/src/resolvers/Query/index.js +++ b/packages/api-plugin-promotions-coupons/src/resolvers/Query/index.js @@ -1,7 +1,11 @@ import coupon from "./coupon.js"; import coupons from "./coupons.js"; +import couponLog from "./couponLog.js"; +import couponLogs from "./couponLogs.js"; export default { coupon, - coupons + coupons, + couponLog, + couponLogs }; diff --git a/packages/api-plugin-promotions-coupons/src/resolvers/index.js b/packages/api-plugin-promotions-coupons/src/resolvers/index.js index aeec9a3729b..af9fe0af669 100644 --- a/packages/api-plugin-promotions-coupons/src/resolvers/index.js +++ b/packages/api-plugin-promotions-coupons/src/resolvers/index.js @@ -1,8 +1,10 @@ import Promotion from "./Promotion/index.js"; import Mutation from "./Mutation/index.js"; import Query from "./Query/index.js"; +import Order from "./Order/index.js"; export default { + Order, Promotion, Mutation, Query diff --git a/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql b/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql index b64040034f0..f4860c35bd3 100644 --- a/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql +++ b/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql @@ -42,11 +42,44 @@ type Coupon { discountId: ID } +type CouponLog { + _id: ID! + + "The shop ID" + shopId: ID! + + "The coupon ID" + couponId: ID! + + "The order ID" + orderId: ID + + "The promotion ID" + promotionId: ID! + + "The coupon owner ID" + accountId: ID + + "The coupon code" + usedCount: Int + + "The time the coupon was used" + createdAt: Date + + "The log details for each time the coupon was used" + usedLogs: [JSONObject] +} + extend type Promotion { "The coupon code" coupon: Coupon } +extend type Order { + "The coupon log for this order that was applied" + couponLog: CouponLog +} + "Input for the applyCouponToCart mutation" input ApplyCouponToCartInput { @@ -138,6 +171,28 @@ input CouponFilter { isArchived: Boolean } +input CouponLogQueryInput { + "The unique ID of the coupon log" + _id: String! + + "The unique ID of the shop" + shopId: String! +} + +input CouponLogFilter { + "The coupon ID" + couponId: ID + + "The related promotion ID" + promotionId: ID + + "The orderId" + orderId: ID + + "The account ID of the user who is applying the coupon" + accountId: ID +} + "Input for the removeCouponFromCart mutation" input RemoveCouponFromCartInput { @@ -206,6 +261,32 @@ type CouponConnection { totalCount: Int! } +"A connection edge in which each node is a `CouponLog` object" +type CouponLogEdge { + "The cursor that represents this node in the paginated results" + cursor: ConnectionCursor! + + "The coupon log node" + node: CouponLog +} + +type CouponLogConnection { + "The list of nodes that match the query, wrapped in an edge to provide a cursor string for each" + edges: [CouponEdge] + + """ + You can request the `nodes` directly to avoid the extra wrapping that `NodeEdge` has, + if you know you will not need to paginate the results. + """ + nodes: [CouponLog] + + "Information to help a client request the next or previous page" + pageInfo: PageInfo! + + "The total number of nodes that match your query" + totalCount: Int! +} + extend type Query { "Get a coupon" coupon( @@ -238,6 +319,38 @@ extend type Query { sortOrder: String ): CouponConnection + + "Get a coupon log" + couponLog( + input: CouponLogQueryInput + ): CouponLog + + "Get list of coupon logs" + couponLogs( + "The coupon ID" + shopId: ID! + + "Return only results that come after this cursor. Use this with `first` to specify the number of results to return." + after: ConnectionCursor + + "Return only results that come before this cursor. Use this with `last` to specify the number of results to return." + before: ConnectionCursor + + "Return at most this many results. This parameter may be used with either `after` or `offset` parameters." + first: ConnectionLimitInt + + "Return at most this many results. This parameter may be used with the `before` parameter." + last: ConnectionLimitInt + + "Return only results that come after the Nth result. This parameter may be used with the `first` parameter." + offset: Int + + filter: CouponLogFilter + + sortBy: String + + sortOrder: String + ): CouponLogConnection } extend type Mutation { diff --git a/packages/api-plugin-promotions-coupons/src/simpleSchemas.js b/packages/api-plugin-promotions-coupons/src/simpleSchemas.js index 227d602f9d0..316b6865360 100644 --- a/packages/api-plugin-promotions-coupons/src/simpleSchemas.js +++ b/packages/api-plugin-promotions-coupons/src/simpleSchemas.js @@ -71,6 +71,7 @@ export const Coupon = new SimpleSchema({ export const CouponLog = new SimpleSchema({ "_id": String, + "shopId": String, "couponId": String, "promotionId": String, "orderId": { diff --git a/packages/api-plugin-promotions-coupons/src/utils/updateOrderCoupon.js b/packages/api-plugin-promotions-coupons/src/utils/updateOrderCoupon.js index 6a8eb2df87c..4e0a1e233ac 100644 --- a/packages/api-plugin-promotions-coupons/src/utils/updateOrderCoupon.js +++ b/packages/api-plugin-promotions-coupons/src/utils/updateOrderCoupon.js @@ -44,11 +44,13 @@ export default async function updateOrderCoupon(context, order) { if (!couponLog) { await CouponLogs.insertOne({ _id: Random.id(), + shopId: order.shopId, couponId, + orderId: order._id, promotionId: promotion._id, accountId: order.accountId, - createdAt: new Date(), - usedCount: 1 + usedCount: 1, + createdAt: new Date() }); continue; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62f37138709..f49182080cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -258,7 +258,7 @@ importers: '@reactioncommerce/file-collections-sa-gridfs': link:../../packages/file-collections-sa-gridfs '@reactioncommerce/logger': link:../../packages/logger '@reactioncommerce/random': link:../../packages/random - '@snyk/protect': 1.1096.0 + '@snyk/protect': 1.1105.0 graphql: 16.6.0 nodemailer: 6.8.0 semver: 6.3.0 @@ -5151,8 +5151,8 @@ packages: '@sinonjs/commons': 1.8.3 dev: false - /@snyk/protect/1.1096.0: - resolution: {integrity: sha512-E0hkw5TY8rIygL2uohywBrW72f1x/g36mHdMxS9UzLB9DHLDudJJYHMwJfdjl6dW7cuuTVauv8TDQireMkjOVw==} + /@snyk/protect/1.1105.0: + resolution: {integrity: sha512-wIRSrm7DcIqpi6JPEKsxenpSXOBj+z5sCUGN0O9YBZV57FYBxhlkOS0I9k6hvKhUmzcPeQ2zbgmGCTbOzhc6zw==} engines: {node: '>=10'} hasBin: true dev: false @@ -14222,6 +14222,7 @@ packages: /tslib/2.4.1: resolution: {integrity: sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==} + dev: false /tsutils/3.21.0_typescript@2.9.2: resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} From dc51fd5f19961e870486d3cfee4e998017bb6c22 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Mon, 27 Feb 2023 21:23:00 +0700 Subject: [PATCH 28/37] fix: revert snyk Signed-off-by: vanpho93 --- pnpm-lock.yaml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f49182080cd..62f37138709 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -258,7 +258,7 @@ importers: '@reactioncommerce/file-collections-sa-gridfs': link:../../packages/file-collections-sa-gridfs '@reactioncommerce/logger': link:../../packages/logger '@reactioncommerce/random': link:../../packages/random - '@snyk/protect': 1.1105.0 + '@snyk/protect': 1.1096.0 graphql: 16.6.0 nodemailer: 6.8.0 semver: 6.3.0 @@ -5151,8 +5151,8 @@ packages: '@sinonjs/commons': 1.8.3 dev: false - /@snyk/protect/1.1105.0: - resolution: {integrity: sha512-wIRSrm7DcIqpi6JPEKsxenpSXOBj+z5sCUGN0O9YBZV57FYBxhlkOS0I9k6hvKhUmzcPeQ2zbgmGCTbOzhc6zw==} + /@snyk/protect/1.1096.0: + resolution: {integrity: sha512-E0hkw5TY8rIygL2uohywBrW72f1x/g36mHdMxS9UzLB9DHLDudJJYHMwJfdjl6dW7cuuTVauv8TDQireMkjOVw==} engines: {node: '>=10'} hasBin: true dev: false @@ -14222,7 +14222,6 @@ packages: /tslib/2.4.1: resolution: {integrity: sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==} - dev: false /tsutils/3.21.0_typescript@2.9.2: resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} From c88826baaad0d101b29f03551d33171c630b42bf Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Tue, 28 Feb 2023 14:56:38 +0700 Subject: [PATCH 29/37] feat: remove usedLogs field on CouponLog schema Signed-off-by: vanpho93 --- .../api-plugin-promotions-coupons/src/schemas/schema.graphql | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql b/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql index f4860c35bd3..05ba787973d 100644 --- a/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql +++ b/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql @@ -65,9 +65,6 @@ type CouponLog { "The time the coupon was used" createdAt: Date - - "The log details for each time the coupon was used" - usedLogs: [JSONObject] } extend type Promotion { From dbb9c886ded74371cd6a02b8b5ac131294196954 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Tue, 28 Feb 2023 10:42:09 +0700 Subject: [PATCH 30/37] fix: temporary promotions Signed-off-by: vanpho93 --- apps/reaction/plugins.json | 3 +- .../src/mutations/transformAndValidateCart.js | 5 +- .../src/mutations/placeOrder.js | 2 +- .../src/actions/discountAction.js | 4 +- .../shipping/applyShippingDiscountToCart.js | 21 ++++-- .../applyShippingDiscountToCart.test.js | 8 +-- .../src/preStartup.js | 9 ++- .../src/utils/recalculateQuoteDiscount.js | 7 +- .../src/handlers/applyPromotions.js | 9 ++- .../src/handlers/applyPromotions.test.js | 67 ++++++++++++++++++- packages/api-plugin-promotions/src/index.js | 5 +- .../src/qualifiers/stackable.js | 3 +- .../src/simpleSchemas.js | 4 ++ 13 files changed, 117 insertions(+), 30 deletions(-) diff --git a/apps/reaction/plugins.json b/apps/reaction/plugins.json index c42312babb8..a3440255845 100644 --- a/apps/reaction/plugins.json +++ b/apps/reaction/plugins.json @@ -41,5 +41,6 @@ "promotionsCoupons": "@reactioncommerce/api-plugin-promotions-coupons", "promotionsDiscounts": "@reactioncommerce/api-plugin-promotions-discounts", "promotionsOffers": "@reactioncommerce/api-plugin-promotions-offers", - "bullJobQueue": "@reactioncommerce/api-plugin-bull-queue" + "bullJobQueue": "@reactioncommerce/api-plugin-bull-queue", + "sampleData": "../../packages/api-plugin-sample-data/index.js" } diff --git a/packages/api-plugin-carts/src/mutations/transformAndValidateCart.js b/packages/api-plugin-carts/src/mutations/transformAndValidateCart.js index 544ea4ce836..6321ff6950f 100644 --- a/packages/api-plugin-carts/src/mutations/transformAndValidateCart.js +++ b/packages/api-plugin-carts/src/mutations/transformAndValidateCart.js @@ -11,9 +11,10 @@ const logCtx = { name: "cart", file: "transformAndValidateCart" }; * and validates it. Throws an error if invalid. The cart object is mutated. * @param {Object} context - App context * @param {Object} cart - The cart to transform and validate + * @param {Object} options - transform options * @returns {undefined} */ -export default async function transformAndValidateCart(context, cart) { +export default async function transformAndValidateCart(context, cart, options = {}) { const { simpleSchemas: { Cart: cartSchema } } = context; updateCartFulfillmentGroups(context, cart); @@ -41,7 +42,7 @@ export default async function transformAndValidateCart(context, cart) { await forEachPromise(cartTransforms, async (transformInfo) => { const startTime = Date.now(); /* eslint-disable no-await-in-loop */ - await transformInfo.fn(context, cart, { getCommonOrders }); + await transformInfo.fn(context, cart, { getCommonOrders, ...options }); /* eslint-enable no-await-in-loop */ Logger.debug({ ...logCtx, cartId: cart._id, ms: Date.now() - startTime }, `Finished ${transformInfo.name} cart transform`); }); diff --git a/packages/api-plugin-orders/src/mutations/placeOrder.js b/packages/api-plugin-orders/src/mutations/placeOrder.js index eecc1863cc5..55ad73f84c9 100644 --- a/packages/api-plugin-orders/src/mutations/placeOrder.js +++ b/packages/api-plugin-orders/src/mutations/placeOrder.js @@ -148,7 +148,7 @@ export default async function placeOrder(context, input) { throw new ReactionError("invalid-cart", "Cart messages should be acknowledged before placing order"); } - await context.mutations.transformAndValidateCart(context, cart); + await context.mutations.transformAndValidateCart(context, cart, { skipTemporaryPromotions: true }); } diff --git a/packages/api-plugin-promotions-discounts/src/actions/discountAction.js b/packages/api-plugin-promotions-discounts/src/actions/discountAction.js index 2759b9e8e00..c72722c8d6d 100644 --- a/packages/api-plugin-promotions-discounts/src/actions/discountAction.js +++ b/packages/api-plugin-promotions-discounts/src/actions/discountAction.js @@ -119,10 +119,10 @@ export async function discountActionHandler(context, cart, params) { Logger.info({ params, cartId: cart._id, ...logCtx }, "applying discount to cart"); - const { cart: updatedCart, affected, reason } = await functionMap[discountType](context, params, cart); + const { cart: updatedCart, affected, reason, temporaryAffected } = await functionMap[discountType](context, params, cart); Logger.info({ ...logCtx, ...params.actionParameters, cartId: cart._id, cartDiscount: cart.discount }, "Completed applying Discount to Cart"); - return { updatedCart, affected, reason }; + return { updatedCart, affected, reason, temporaryAffected }; } export default { diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js index a02e086450a..4ed8cf95cad 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js @@ -78,7 +78,7 @@ export function splitDiscountForShipping(cartShipping, totalShippingRate, discou return { _id: shipping._id, amount: formatMoney(discountAmount - discounted) }; }); - return discountedShipping; + return discountedShipping.filter((shipping) => shipping.amount > 0); } /** @@ -129,6 +129,7 @@ export async function estimateShipmentQuoteDiscount(context, cart, params) { const shipmentQuotes = cart.shipping[0]?.shipmentQuotes || []; + let affectedItemsLength = 0; for (const item of filteredItems) { const shipmentQuote = shipmentQuotes.find((quote) => quote.method._id === item.method._id); if (!shipmentQuote) continue; @@ -139,10 +140,11 @@ export async function estimateShipmentQuoteDiscount(context, cart, params) { if (!shipmentQuote.discounts) shipmentQuote.discounts = []; shipmentQuote.discounts.push(createDiscountRecord(params, item)); + affectedItemsLength += 1; recalculateQuoteDiscount(context, shipmentQuote, actionParameters); } - return filteredItems.length > 0; + return affectedItemsLength > 0; } /** @@ -156,34 +158,39 @@ export default async function applyShippingDiscountToCart(context, params, cart) const { actionParameters } = params; if (!cart.shipping) cart.shipping = []; + if (!cart.appliedPromotions) cart.appliedPromotions = []; - await estimateShipmentQuoteDiscount(context, cart, params); + const isEstimateAffected = await estimateShipmentQuoteDiscount(context, cart, params); const filteredShipping = await getEligibleShipping(context, cart.shipping, actionParameters); const totalShippingRate = getTotalShippingRate(filteredShipping); const totalShippingDiscount = getTotalShippingDiscount(context, totalShippingRate, actionParameters); const discountedItems = splitDiscountForShipping(filteredShipping, totalShippingRate, totalShippingDiscount); + let discountedShippingCount = 0; for (const discountedItem of discountedItems) { const shipping = filteredShipping.find((item) => item._id === discountedItem._id); if (!shipping) continue; + const canBeDiscounted = canBeApplyDiscountToShipping(shipping, params.promotion); if (!canBeDiscounted) continue; if (!shipping.discounts) shipping.discounts = []; const shippingDiscount = createDiscountRecord(params, discountedItem); + shipping.discounts.push(shippingDiscount); + recalculateShippingDiscount(context, shipping); + discountedShippingCount += 1; } - if (discountedItems.length) { + const affected = discountedShippingCount > 0; + if (affected) { Logger.info(logCtx, "Saved Discount to cart"); } - const affected = discountedItems.length > 0; const reason = !affected ? "No shippings were discounted" : undefined; - - return { cart, affected, reason }; + return { cart, affected, reason, temporaryAffected: isEstimateAffected }; } diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js index 5ca65a7e983..376824783ae 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js @@ -77,7 +77,7 @@ test("should apply shipping discount to cart", async () => { }; mockContext.discountCalculationMethods = { - fixed: jest.fn().mockReturnValue(0) + fixed: jest.fn().mockReturnValue(2) }; const { cart: updatedCart, affected } = await applyShippingDiscountToCart.default(mockContext, parameters, cart); @@ -85,10 +85,10 @@ test("should apply shipping discount to cart", async () => { expect(affected).toEqual(true); expect(updatedCart.shipping[0].shipmentMethod).toEqual({ _id: "method1", - discount: 9, + discount: 7, handling: 2, - rate: 0, - shippingPrice: 2, + rate: 2, + shippingPrice: 4, undiscountedRate: 9 }); expect(updatedCart.shipping[0].discounts).toHaveLength(1); diff --git a/packages/api-plugin-promotions-discounts/src/preStartup.js b/packages/api-plugin-promotions-discounts/src/preStartup.js index 9650197cf13..654b9bb65ff 100644 --- a/packages/api-plugin-promotions-discounts/src/preStartup.js +++ b/packages/api-plugin-promotions-discounts/src/preStartup.js @@ -22,7 +22,7 @@ const discountSchema = new SimpleSchema({ * @returns {Promise} undefined */ async function extendCartSchemas(context) { - const { simpleSchemas: { Cart, CartItem, Shipment, ShippingMethod, ShipmentQuote } } = context; + const { simpleSchemas: { Cart, CartItem, Shipment, ShippingMethod, ShipmentQuote, PromotionStackability } } = context; Cart.extend(discountSchema); Cart.extend({ "discounts": { @@ -54,6 +54,13 @@ async function extendCartSchemas(context) { } }); + CartDiscount.extend({ + stackability: { + type: PromotionStackability, + optional: true + } + }); + Shipment.extend({ "discounts": { type: Array, diff --git a/packages/api-plugin-promotions-discounts/src/utils/recalculateQuoteDiscount.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateQuoteDiscount.js index 2ecc6b64695..d43501533b9 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/recalculateQuoteDiscount.js +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateQuoteDiscount.js @@ -11,7 +11,8 @@ export default function recalculateQuoteDiscount(context, quote) { let totalDiscount = 0; const { method, undiscountedRate } = quote; - const rate = undiscountedRate || method.rate; + const rate = undiscountedRate || method.undiscountedRate || method.rate; + quote.rate = rate; quote.undiscountedRate = rate; quote.discounts.forEach((discount) => { @@ -20,7 +21,7 @@ export default function recalculateQuoteDiscount(context, quote) { const { discountMaxValue } = discount; // eslint-disable-next-line require-jsdoc - function getDiscountedRate() { + function getDiscountRate() { const discountRate = formatMoney(quoteRate - discountedRate); if (typeof discountMaxValue === "number" && discountMaxValue > 0) { return Math.min(discountRate, discountMaxValue); @@ -28,7 +29,7 @@ export default function recalculateQuoteDiscount(context, quote) { return discountRate; } - const discountRate = getDiscountedRate(); + const discountRate = getDiscountRate(); totalDiscount += discountRate; discount.discountedAmount = discountRate; diff --git a/packages/api-plugin-promotions/src/handlers/applyPromotions.js b/packages/api-plugin-promotions/src/handlers/applyPromotions.js index 495cc077d3b..78b35a99469 100644 --- a/packages/api-plugin-promotions/src/handlers/applyPromotions.js +++ b/packages/api-plugin-promotions/src/handlers/applyPromotions.js @@ -95,9 +95,10 @@ export async function getCurrentTime(context, shopId) { * @summary apply promotions to a cart * @param {Object} context - The application context * @param {Object} cart - The cart to apply promotions to + * @param {Object} options - Options * @returns {Promise} - mutated cart */ -export default async function applyPromotions(context, cart) { +export default async function applyPromotions(context, cart, options = { skipTemporaryPromotions: false }) { const currentTime = await getCurrentTime(context, cart.shopId); const promotions = await getImplicitPromotions(context, cart.shopId, currentTime); const { promotions: pluginPromotions, simpleSchemas: { Cart, CartPromotionItem } } = context; @@ -198,18 +199,20 @@ export default async function applyPromotions(context, cart) { } let affected = false; + let temporaryAffected = false; let rejectedReason; for (const action of promotion.actions) { const actionFn = actionHandleByKey[action.actionKey]; if (!actionFn) continue; const result = await actionFn.handler(context, enhancedCart, { promotion, ...action }); - ({ affected, reason: rejectedReason } = result); + ({ affected, temporaryAffected, reason: rejectedReason } = result); enhancedCart = enhanceCart(context, pluginPromotions.enhancers, enhancedCart); } - if (affected) { + if (affected || (!options.skipTemporaryPromotions && temporaryAffected)) { const affectedPromotion = _.cloneDeep(promotion); + affectedPromotion.isTemporary = !affected && temporaryAffected; CartPromotionItem.clean(affectedPromotion); appliedPromotions.push(affectedPromotion); continue; diff --git a/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js b/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js index bb21b8885b7..d432bd700dd 100644 --- a/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js +++ b/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js @@ -61,7 +61,7 @@ test("should save cart with implicit promotions are applied", async () => { }); expect(testEnhancer).toBeCalledWith(mockContext, expect.objectContaining({ _id: cart._id })); - const expectedCart = { ...cart, appliedPromotions: [testPromotion] }; + const expectedCart = { ...cart, appliedPromotions: [{ ...testPromotion, isTemporary: false }] }; expect(cart).toEqual(expectedCart); }); @@ -145,7 +145,8 @@ describe("cart message", () => { }); test("should have promotion can't be applied message when promotion can't be applied", async () => { - canBeApplied.mockReturnValue({ qualifies: false, reason: "Can't be combine" }); + testAction.mockResolvedValue({ affected: true }); + canBeApplied.mockResolvedValue({ qualifies: false, reason: "Can't be combine" }); isPromotionExpired.mockReturnValue(false); const promotion = { @@ -164,7 +165,7 @@ describe("cart message", () => { }) }; - mockContext.promotions = { ...pluginPromotion, triggers: [], qualifiers: [] }; + mockContext.promotions = { ...pluginPromotion, qualifiers: [] }; mockContext.simpleSchemas = { Cart: { clean: jest.fn() } }; @@ -378,3 +379,63 @@ test("shouldn't apply promotion when promotion is not enabled", async () => { expect(cart.appliedPromotions.length).toEqual(0); }); + +test("temporary should apply shipping discount with isTemporary flag when affected but shipmentMethod is not selected", async () => { + const promotion = { + ...testPromotion, + _id: "promotionId", + enabled: true + }; + const cart = { + _id: "cartId", + appliedPromotions: [], + shipping: [ + { + _id: "shippingId", + shopId: "shopId", + shipmentQuotes: [ + { + carrier: "Flat Rate", + handlingPrice: 2, + method: { + name: "globalFlatRateGround", + cost: 5, + handling: 2, + rate: 5, + _id: "CiHcHJXEeGF9t9z3a", + carrier: "Flat Rate", + discount: 4, + shippingPrice: 7, + undiscountedRate: 9 + }, + rate: 5, + shippingPrice: 7, + discount: 4, + undiscountedRate: 9 + } + ] + } + ] + }; + + testAction.mockResolvedValue({ affected: false, temporaryAffected: true }); + + mockContext.collections.Promotions = { + find: () => ({ + toArray: jest.fn().mockResolvedValueOnce([promotion]) + }) + }; + + mockContext.promotions = { ...pluginPromotion }; + mockContext.simpleSchemas = { + Cart: { clean: jest.fn() }, + CartPromotionItem: { + clean: jest.fn() + } + }; + + await applyPromotions(mockContext, cart); + + expect(cart.appliedPromotions.length).toEqual(1); + expect(cart.appliedPromotions[0].isTemporary).toEqual(true); +}); diff --git a/packages/api-plugin-promotions/src/index.js b/packages/api-plugin-promotions/src/index.js index a5de758703d..90553d9f4e7 100644 --- a/packages/api-plugin-promotions/src/index.js +++ b/packages/api-plugin-promotions/src/index.js @@ -2,7 +2,7 @@ import { createRequire } from "module"; import { promotions, registerPluginHandlerForPromotions } from "./registration.js"; import mutations from "./mutations/index.js"; import preStartupPromotions from "./preStartup.js"; -import { Promotion, CartPromotionItem } from "./simpleSchemas.js"; +import { Promotion, CartPromotionItem, Stackability as PromotionStackability } from "./simpleSchemas.js"; import actions from "./actions/index.js"; import promotionTypes from "./promotionTypes/index.js"; import schemas from "./schemas/index.js"; @@ -46,7 +46,8 @@ export default async function register(app) { }, simpleSchemas: { Promotion, - CartPromotionItem + CartPromotionItem, + PromotionStackability }, functionsByType: { registerPluginHandler: [registerPluginHandlerForPromotions], diff --git a/packages/api-plugin-promotions/src/qualifiers/stackable.js b/packages/api-plugin-promotions/src/qualifiers/stackable.js index 37d7df9a66e..7dad0db4bf1 100644 --- a/packages/api-plugin-promotions/src/qualifiers/stackable.js +++ b/packages/api-plugin-promotions/src/qualifiers/stackable.js @@ -24,8 +24,9 @@ const logCtx = { export default async function stackable(context, cart, { appliedPromotions, promotion }) { const { promotions } = context; const stackabilityByKey = _.keyBy(promotions.stackabilities, "key"); + const permanentPromotions = appliedPromotions.filter((appliedPromotion) => !appliedPromotion.isTemporary); - for (const appliedPromotion of appliedPromotions) { + for (const appliedPromotion of permanentPromotions) { if (!appliedPromotion.stackability) continue; const stackabilityHandler = stackabilityByKey[promotion.stackability.key]; diff --git a/packages/api-plugin-promotions/src/simpleSchemas.js b/packages/api-plugin-promotions/src/simpleSchemas.js index cee652d3c98..bef1d4ce040 100644 --- a/packages/api-plugin-promotions/src/simpleSchemas.js +++ b/packages/api-plugin-promotions/src/simpleSchemas.js @@ -143,5 +143,9 @@ export const CartPromotionItem = new SimpleSchema({ triggerType: { type: String, allowedValues: ["implicit", "explicit"] + }, + isTemporary: { + type: Boolean, + defaultValue: false } }); From dd89dcd46134796c631e8df227feacc66c03eac4 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Wed, 1 Mar 2023 16:08:58 +0700 Subject: [PATCH 31/37] fix: pnpm-lock file Signed-off-by: vanpho93 --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ae045198a1..62f37138709 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -258,7 +258,7 @@ importers: '@reactioncommerce/file-collections-sa-gridfs': link:../../packages/file-collections-sa-gridfs '@reactioncommerce/logger': link:../../packages/logger '@reactioncommerce/random': link:../../packages/random - '@snyk/protect': 1.1109.0 + '@snyk/protect': 1.1096.0 graphql: 16.6.0 nodemailer: 6.8.0 semver: 6.3.0 From 6fbde96b262c9c879454e03fa3efbac9a57f13f0 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Wed, 1 Mar 2023 16:15:08 +0700 Subject: [PATCH 32/37] feat: add sample data for shipping promotion Signed-off-by: vanpho93 --- .../src/loaders/loadPromotions.js | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/api-plugin-sample-data/src/loaders/loadPromotions.js b/packages/api-plugin-sample-data/src/loaders/loadPromotions.js index d2155c2ae81..7b318b32ff4 100644 --- a/packages/api-plugin-sample-data/src/loaders/loadPromotions.js +++ b/packages/api-plugin-sample-data/src/loaders/loadPromotions.js @@ -139,7 +139,54 @@ const Coupon = { updatedAt: new Date() }; -const promotions = [OrderPromotion, OrderItemPromotion, CouponPromotion]; +const ShippingPromotion = { + _id: "shippingPromotion", + referenceId: 1, + triggerType: "implicit", + promotionType: "shipping-discount", + name: "$5 off over $100", + label: "$5 off your entire order when you spend more then $100", + description: "$5 off your entire order when you spend more then $100", + enabled: true, + state: "created", + triggers: [ + { + triggerKey: "offers", + triggerParameters: { + name: "$5 off your entire order when you spend more then $100", + conditions: { + all: [ + { + fact: "totalItemAmount", + operator: "greaterThanInclusive", + value: 100 + } + ] + } + } + } + ], + actions: [ + { + actionKey: "discounts", + actionParameters: { + discountType: "shipping", + discountCalculationType: "fixed", + discountValue: 5 + } + } + ], + startDate: now, + endDate: new Date(now.getTime() + 1000 * 60 * 60 * 24 * 7), + createdAt: new Date(), + updatedAt: new Date(), + stackability: { + key: "all", + parameters: {} + } +}; + +const promotions = [OrderPromotion, OrderItemPromotion, CouponPromotion, ShippingPromotion]; /** * @summary Load promotions fixtures From 40724365fd53471ccf4ec46e1f581db9ef0f2f3b Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Wed, 1 Mar 2023 16:31:35 +0700 Subject: [PATCH 33/37] fix: promotion plugin unit test fail Signed-off-by: vanpho93 --- .../src/handlers/applyPromotions.test.js | 8 ++++++-- .../api-plugin-sample-data/src/loaders/loadPromotions.js | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js b/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js index 3c0a647ef95..6f3a164100b 100644 --- a/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js +++ b/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js @@ -429,8 +429,11 @@ test("temporary should apply shipping discount with isTemporary flag when affect testAction.mockResolvedValue({ affected: false, temporaryAffected: true }); mockContext.collections.Promotions = { - find: () => ({ - toArray: jest.fn().mockResolvedValueOnce([promotion]) + find: (query) => ({ + toArray: jest.fn().mockImplementation(() => { + if (query.triggerType === "explicit") return []; + return [promotion]; + }) }) }; @@ -441,6 +444,7 @@ test("temporary should apply shipping discount with isTemporary flag when affect clean: jest.fn() } }; + canBeApplied.mockReturnValue({ qualifies: true }); await applyPromotions(mockContext, cart); diff --git a/packages/api-plugin-sample-data/src/loaders/loadPromotions.js b/packages/api-plugin-sample-data/src/loaders/loadPromotions.js index 7b318b32ff4..8c8f17400c0 100644 --- a/packages/api-plugin-sample-data/src/loaders/loadPromotions.js +++ b/packages/api-plugin-sample-data/src/loaders/loadPromotions.js @@ -141,7 +141,7 @@ const Coupon = { const ShippingPromotion = { _id: "shippingPromotion", - referenceId: 1, + referenceId: 4, triggerType: "implicit", promotionType: "shipping-discount", name: "$5 off over $100", From 686d7e22d6082e59f4ec5c8c2135fd74575a2ce7 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Wed, 1 Mar 2023 16:40:41 +0700 Subject: [PATCH 34/37] fix: remove sampleData from plugin file Signed-off-by: vanpho93 --- apps/reaction/plugins.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/reaction/plugins.json b/apps/reaction/plugins.json index a3440255845..c42312babb8 100644 --- a/apps/reaction/plugins.json +++ b/apps/reaction/plugins.json @@ -41,6 +41,5 @@ "promotionsCoupons": "@reactioncommerce/api-plugin-promotions-coupons", "promotionsDiscounts": "@reactioncommerce/api-plugin-promotions-discounts", "promotionsOffers": "@reactioncommerce/api-plugin-promotions-offers", - "bullJobQueue": "@reactioncommerce/api-plugin-bull-queue", - "sampleData": "../../packages/api-plugin-sample-data/index.js" + "bullJobQueue": "@reactioncommerce/api-plugin-bull-queue" } From 490044c4e6c6fc3ee9fe054d962845aff92768de Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Wed, 1 Mar 2023 18:48:14 +0700 Subject: [PATCH 35/37] fix: checkout promotion test fail Signed-off-by: vanpho93 --- .../mutations/checkout/promotionCheckout.test.js | 15 +++++++-------- .../src/preStartup.js | 11 ++--------- packages/api-plugin-promotions/src/preStartup.js | 4 ++-- 3 files changed, 11 insertions(+), 19 deletions(-) diff --git a/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js b/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js index f0141f123a7..f7a15b1ed50 100644 --- a/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js +++ b/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js @@ -98,6 +98,7 @@ describe("Promotions", () => { const cleanup = async () => { await testApp.collections.Promotions.deleteMany(); + await testApp.collections.Orders.deleteMany(); await testApp.collections.Cart.deleteMany(); }; @@ -696,8 +697,8 @@ describe("Promotions", () => { actionKey: "discounts", actionParameters: { discountType: "shipping", - discountCalculationType: "fixed", - discountValue: 0.5 + discountCalculationType: "percentage", + discountValue: 10 } } ] @@ -708,16 +709,14 @@ describe("Promotions", () => { test("placed order get the correct values", async () => { const orderId = decodeOpaqueIdForNamespace("reaction/order")(placedOrderId); const newOrder = await testApp.collections.Orders.findOne({ _id: orderId }); - expect(newOrder.shipping[0].invoice.total).toEqual(121.94); + expect(newOrder.shipping[0].invoice.total).toEqual(121.89); expect(newOrder.shipping[0].invoice.discounts).toEqual(0); expect(newOrder.shipping[0].invoice.subtotal).toEqual(119.94); - expect(newOrder.shipping[0].invoice.shipping).toEqual(2); - expect(newOrder.shipping[0].shipmentMethod.discount).toEqual(0.5); - expect(newOrder.shipping[0].shipmentMethod.rate).toEqual(0.5); + expect(newOrder.shipping[0].invoice.shipping).toEqual(1.95); + expect(newOrder.shipping[0].shipmentMethod.discount).toEqual(0.55); + expect(newOrder.shipping[0].shipmentMethod.rate).toEqual(0.45); expect(newOrder.shipping[0].shipmentMethod.handling).toEqual(1.5); - expect(newOrder.shipping[0].items[0].quantity).toEqual(6); - expect(newOrder.appliedPromotions).toHaveLength(2); expect(newOrder.discounts).toHaveLength(2); }); diff --git a/packages/api-plugin-promotions-coupons/src/preStartup.js b/packages/api-plugin-promotions-coupons/src/preStartup.js index 6a71c42388e..2ceb633c83e 100644 --- a/packages/api-plugin-promotions-coupons/src/preStartup.js +++ b/packages/api-plugin-promotions-coupons/src/preStartup.js @@ -1,4 +1,3 @@ -import _ from "lodash"; import SimpleSchema from "simpl-schema"; import doesDatabaseVersionMatch from "@reactioncommerce/db-version-check"; import { migrationsNamespace } from "../migrations/migrationsNamespace.js"; @@ -12,7 +11,7 @@ const expectedVersion = 2; * @returns {undefined} */ export default async function preStartupPromotionCoupon(context) { - const { simpleSchemas: { Cart, Promotion, RuleExpression }, promotions: pluginPromotions } = context; + const { simpleSchemas: { RuleExpression, CartPromotionItem }, promotions: pluginPromotions } = context; CouponTriggerCondition.extend({ conditions: RuleExpression @@ -26,24 +25,18 @@ export default async function preStartupPromotionCoupon(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 copiedPromotion = _.cloneDeep(Promotion); - const relatedCoupon = new SimpleSchema({ couponCode: String, couponId: String }); - copiedPromotion.extend({ + CartPromotionItem.extend({ relatedCoupon: { type: relatedCoupon, optional: true } }); - Cart.extend({ - "appliedPromotions.$": copiedPromotion - }); - const setToExpectedIfMissing = async () => { const anyDiscount = await context.collections.Discounts.findOne(); return !anyDiscount; diff --git a/packages/api-plugin-promotions/src/preStartup.js b/packages/api-plugin-promotions/src/preStartup.js index 787237d4893..77cf28ff0b1 100644 --- a/packages/api-plugin-promotions/src/preStartup.js +++ b/packages/api-plugin-promotions/src/preStartup.js @@ -1,5 +1,5 @@ import _ from "lodash"; -import { Action, Trigger, Promotion as PromotionSchema, Stackability } from "./simpleSchemas.js"; +import { Action, Trigger, Promotion as PromotionSchema, Stackability, CartPromotionItem } from "./simpleSchemas.js"; /** * @summary apply all schema extensions to the Promotions schema @@ -19,7 +19,7 @@ function extendSchemas(context) { * @returns {Object} the extended schema */ function extendCartSchema(context) { - const { simpleSchemas: { Cart, CartPromotionItem } } = context; // we get this here rather then importing it to get the extended version + const { simpleSchemas: { Cart } } = context; // we get this here rather then importing it to get the extended version Cart.extend({ "version": { From c2471d375c4ff1b624549542a21bb49b6442d10b Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Thu, 2 Mar 2023 17:18:20 +0700 Subject: [PATCH 36/37] feat: add check stackability for the shipping discount Signed-off-by: vanpho93 --- .../shipping/applyShippingDiscountToCart.js | 10 ++++- .../applyShippingDiscountToCart.test.js | 4 ++ .../shipping/checkShippingStackable.js | 28 ++++++++++++ .../shipping/checkShippingStackable.test.js | 45 +++++++++++++++++++ 4 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 packages/api-plugin-promotions-discounts/src/discountTypes/shipping/checkShippingStackable.js create mode 100644 packages/api-plugin-promotions-discounts/src/discountTypes/shipping/checkShippingStackable.test.js diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js index 4ed8cf95cad..c990c66e31b 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js @@ -1,4 +1,3 @@ -/* eslint-disable no-unused-vars */ import { createRequire } from "module"; import _ from "lodash"; import Logger from "@reactioncommerce/logger"; @@ -7,6 +6,7 @@ import formatMoney from "../../utils/formatMoney.js"; import getEligibleShipping from "../../utils/getEligibleIShipping.js"; import calculateDiscountAmount from "../../utils/calculateDiscountAmount.js"; import recalculateQuoteDiscount from "../../utils/recalculateQuoteDiscount.js"; +import checkShippingStackable from "./checkShippingStackable.js"; const require = createRequire(import.meta.url); @@ -137,8 +137,14 @@ export async function estimateShipmentQuoteDiscount(context, cart, params) { const canBeDiscounted = canBeApplyDiscountToShipping(shipmentQuote, promotion); if (!canBeDiscounted) continue; + const shippingDiscount = createDiscountRecord(params, item); + if (!shipmentQuote.discounts) shipmentQuote.discounts = []; - shipmentQuote.discounts.push(createDiscountRecord(params, item)); + // eslint-disable-next-line no-await-in-loop + const canStackable = await checkShippingStackable(context, shipmentQuote, shippingDiscount); + if (!canStackable) continue; + + shipmentQuote.discounts.push(shippingDiscount); affectedItemsLength += 1; recalculateQuoteDiscount(context, shipmentQuote, actionParameters); diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js index 376824783ae..267f5ec637a 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js @@ -1,5 +1,8 @@ import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; import * as applyShippingDiscountToCart from "./applyShippingDiscountToCart.js"; +import checkShippingStackable from "./checkShippingStackable.js"; + +jest.mock("./checkShippingStackable.js", () => jest.fn()); test("createDiscountRecord should create discount record", () => { const parameters = { @@ -79,6 +82,7 @@ test("should apply shipping discount to cart", async () => { mockContext.discountCalculationMethods = { fixed: jest.fn().mockReturnValue(2) }; + checkShippingStackable.mockReturnValue(true); const { cart: updatedCart, affected } = await applyShippingDiscountToCart.default(mockContext, parameters, cart); diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/checkShippingStackable.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/checkShippingStackable.js new file mode 100644 index 00000000000..228be1a57ae --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/checkShippingStackable.js @@ -0,0 +1,28 @@ +/* eslint-disable no-await-in-loop */ +import _ from "lodash"; + +/** + * @summary check if a promotion is applicable to a cart + * @param {Object} context - The application context + * @param {Object} shipping - The cart we are trying to apply the promotion to + * @param {Object} discount - The promotion we are trying to apply + * @returns {Promise} - Whether the promotion is applicable to the shipping + */ +export default async function checkShippingStackable(context, shipping, discount) { + const { promotions } = context; + const stackabilityByKey = _.keyBy(promotions.stackabilities, "key"); + + for (const appliedDiscount of shipping.discounts) { + if (!appliedDiscount.stackability) continue; + + const stackHandler = stackabilityByKey[discount.stackability.key]; + const appliedStackHandler = stackabilityByKey[appliedDiscount.stackability.key]; + + const stackResult = await stackHandler.handler(context, null, { promotion: discount, appliedPromotion: appliedDiscount }); + const appliedStackResult = await appliedStackHandler.handler(context, {}, { promotion: appliedDiscount, appliedPromotion: discount }); + + if (!stackResult || !appliedStackResult) return false; + } + + return true; +} diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/checkShippingStackable.test.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/checkShippingStackable.test.js new file mode 100644 index 00000000000..671639fc400 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/checkShippingStackable.test.js @@ -0,0 +1,45 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import checkShippingStackable from "./checkShippingStackable.js"; + +test("should returns true if no the current discount is stackable", async () => { + const shipping = { + discounts: [ + { + stackability: { key: "all" } + } + ] + }; + const discount = { + stackability: { key: "all" } + }; + + mockContext.promotions = { + stackabilities: [{ key: "all", handler: () => true }] + }; + + const result = await checkShippingStackable(mockContext, shipping, discount); + expect(result).toBe(true); +}); + +test("should returns false if the current discount is not stackable", async () => { + const shipping = { + discounts: [ + { + stackability: { key: "all" } + } + ] + }; + const discount = { + stackability: { key: "none" } + }; + + mockContext.promotions = { + stackabilities: [ + { key: "all", handler: () => true }, + { key: "none", handler: () => false } + ] + }; + + const result = await checkShippingStackable(mockContext, shipping, discount); + expect(result).toBe(false); +}); From 36dd29c191533214e5575bbd4f7271ce5397c7c7 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Thu, 2 Mar 2023 17:55:25 +0700 Subject: [PATCH 37/37] fix: revert promotion starup file Signed-off-by: vanpho93 --- packages/api-plugin-promotions/src/startup.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api-plugin-promotions/src/startup.js b/packages/api-plugin-promotions/src/startup.js index 040d38de6f8..70b7ac9674c 100644 --- a/packages/api-plugin-promotions/src/startup.js +++ b/packages/api-plugin-promotions/src/startup.js @@ -22,7 +22,7 @@ const logCtx = { export default async function startupPromotions(context) { const { bullQueue } = context; await bullQueue.createQueue(context, "setPromotionState", { jobName: "checkForChangedStates" }, setPromotionState(context)); - await bullQueue.scheduleJob(context, "setPromotionState", "checkForChangedStates", {}, "*/1 * * * *"); + await bullQueue.scheduleJob(context, "setPromotionState", "checkForChangedStates", {}, "*/5 * * * *"); Logger.info(logCtx, "Add setPromotionState queue and job"); await bullQueue.createQueue(context, "checkExistingCarts", {}, checkCartForPromotionChange(context)); return true;