diff --git a/imports/collections/schemas/shipping.js b/imports/collections/schemas/shipping.js index 1ddf8962f19..4fa4a6103c1 100644 --- a/imports/collections/schemas/shipping.js +++ b/imports/collections/schemas/shipping.js @@ -169,6 +169,10 @@ export const ShipmentQuote = new SimpleSchema({ carrier: { type: String }, + handlingPrice: { + type: Number, + optional: true + }, method: { type: ShippingMethod }, @@ -176,6 +180,10 @@ export const ShipmentQuote = new SimpleSchema({ type: Number, defaultValue: 0.00 }, + shippingPrice: { + type: Number, + optional: true + }, shopId: { type: String, optional: true diff --git a/imports/plugins/core/cart/server/no-meteor/mutations/__snapshots__/convertAnonymousCartToNewAccountCart.test.js.snap b/imports/plugins/core/cart/server/no-meteor/mutations/__snapshots__/convertAnonymousCartToNewAccountCart.test.js.snap index 96b6cc01954..e9a34733897 100644 --- a/imports/plugins/core/cart/server/no-meteor/mutations/__snapshots__/convertAnonymousCartToNewAccountCart.test.js.snap +++ b/imports/plugins/core/cart/server/no-meteor/mutations/__snapshots__/convertAnonymousCartToNewAccountCart.test.js.snap @@ -1,5 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`throws if deleteOne fails 1`] = `"Unable to delete anonymous cart"`; - -exports[`throws if insertOne fails 1`] = `"Unable to create account cart"`; diff --git a/imports/plugins/core/cart/server/no-meteor/mutations/__snapshots__/reconcileCartsKeepAnonymousCart.test.js.snap b/imports/plugins/core/cart/server/no-meteor/mutations/__snapshots__/reconcileCartsKeepAnonymousCart.test.js.snap index 5db72abacda..e9a34733897 100644 --- a/imports/plugins/core/cart/server/no-meteor/mutations/__snapshots__/reconcileCartsKeepAnonymousCart.test.js.snap +++ b/imports/plugins/core/cart/server/no-meteor/mutations/__snapshots__/reconcileCartsKeepAnonymousCart.test.js.snap @@ -1,5 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`throws if deleteOne fails 1`] = `"Unable to delete anonymous cart"`; - -exports[`throws if updateOne fails 1`] = `"Unable to update cart"`; diff --git a/imports/plugins/core/cart/server/no-meteor/mutations/__snapshots__/reconcileCartsMerge.test.js.snap b/imports/plugins/core/cart/server/no-meteor/mutations/__snapshots__/reconcileCartsMerge.test.js.snap index 5db72abacda..e9a34733897 100644 --- a/imports/plugins/core/cart/server/no-meteor/mutations/__snapshots__/reconcileCartsMerge.test.js.snap +++ b/imports/plugins/core/cart/server/no-meteor/mutations/__snapshots__/reconcileCartsMerge.test.js.snap @@ -1,5 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`throws if deleteOne fails 1`] = `"Unable to delete anonymous cart"`; - -exports[`throws if updateOne fails 1`] = `"Unable to update cart"`; diff --git a/imports/plugins/core/cart/server/no-meteor/mutations/__snapshots__/updateCartItemsQuantity.test.js.snap b/imports/plugins/core/cart/server/no-meteor/mutations/__snapshots__/updateCartItemsQuantity.test.js.snap deleted file mode 100644 index f104fbd34c6..00000000000 --- a/imports/plugins/core/cart/server/no-meteor/mutations/__snapshots__/updateCartItemsQuantity.test.js.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`throws when no account and no token passed 1`] = `"Cart not found"`; diff --git a/imports/plugins/core/cart/server/no-meteor/mutations/addCartItems.js b/imports/plugins/core/cart/server/no-meteor/mutations/addCartItems.js index 6037fa750e3..7c75053e3d8 100644 --- a/imports/plugins/core/cart/server/no-meteor/mutations/addCartItems.js +++ b/imports/plugins/core/cart/server/no-meteor/mutations/addCartItems.js @@ -1,5 +1,4 @@ import ReactionError from "@reactioncommerce/reaction-error"; -import { Cart as CartSchema } from "/imports/collections/schemas"; import hashLoginToken from "/imports/node-app/core/util/hashLoginToken"; import addCartItemsUtil from "../util/addCartItems"; @@ -18,7 +17,7 @@ import addCartItemsUtil from "../util/addCartItems"; */ export default async function addCartItems(context, input, options = {}) { const { cartId, items, token } = input; - const { appEvents, collections, accountId = null, userId = null } = context; + const { collections, accountId = null } = context; const { Cart } = collections; let selector; @@ -45,28 +44,13 @@ export default async function addCartItems(context, input, options = {}) { updatedItemList } = await addCartItemsUtil(context, cart.items, items, { skipPriceCheck: options.skipPriceCheck }); - const updatedAt = new Date(); - - const modifier = { - $set: { - items: updatedItemList, - updatedAt - } - }; - CartSchema.validate(modifier, { modifier: true }); - - const { matchedCount } = await Cart.updateOne({ _id: cart._id }, modifier); - if (matchedCount !== 1) throw new ReactionError("server-error", "Unable to update cart"); - const updatedCart = { ...cart, items: updatedItemList, - updatedAt + updatedAt: new Date() }; - await appEvents.emit("afterCartUpdate", { - cart: updatedCart, - updatedBy: userId - }); - return { cart: updatedCart, incorrectPriceFailures, minOrderQuantityFailures }; + const savedCart = await context.mutations.saveCart(context, updatedCart); + + return { cart: savedCart, incorrectPriceFailures, minOrderQuantityFailures }; } diff --git a/imports/plugins/core/cart/server/no-meteor/mutations/addCartItems.test.js b/imports/plugins/core/cart/server/no-meteor/mutations/addCartItems.test.js index a1104fe7373..814a760001a 100644 --- a/imports/plugins/core/cart/server/no-meteor/mutations/addCartItems.test.js +++ b/imports/plugins/core/cart/server/no-meteor/mutations/addCartItems.test.js @@ -15,6 +15,12 @@ const items = [{ quantity: 1 }]; +beforeAll(() => { + if (!mockContext.mutations.saveCart) { + mockContext.mutations.saveCart = jest.fn().mockName("context.mutations.saveCart").mockImplementation(async (_, cart) => cart); + } +}); + test("add an item to an existing anonymous cart", async () => { mockContext.collections.Cart.findOne.mockReturnValueOnce(Promise.resolve({ _id: "cartId", diff --git a/imports/plugins/core/cart/server/no-meteor/mutations/convertAnonymousCartToNewAccountCart.js b/imports/plugins/core/cart/server/no-meteor/mutations/convertAnonymousCartToNewAccountCart.js index 302f25207d3..ae91afd3205 100644 --- a/imports/plugins/core/cart/server/no-meteor/mutations/convertAnonymousCartToNewAccountCart.js +++ b/imports/plugins/core/cart/server/no-meteor/mutations/convertAnonymousCartToNewAccountCart.js @@ -1,30 +1,23 @@ import Random from "@reactioncommerce/random"; import ReactionError from "@reactioncommerce/reaction-error"; -import { Cart as CartSchema } from "/imports/collections/schemas"; -import appEvents from "/imports/node-app/core/util/appEvents"; /** * @summary Copy items from an anonymous cart into a new account cart, and then delete the * anonymous cart. - * @param {String} accountId The account ID to associate with the new account cart + * @param {Object} context App context * @param {Object} anonymousCart The anonymous cart document * @param {Object} anonymousCartSelector The MongoDB selector for the anonymous cart - * @param {MongoDB.Collection} Cart The Cart collection - * @param {String} shopId The shop ID to associate with the new account cart - * @param {String} userId The ID of the user * @returns {Object} The new account cart */ -export default async function convertAnonymousCartToNewAccountCart({ - accountId, +export default async function convertAnonymousCartToNewAccountCart(context, { anonymousCart, - anonymousCartSelector, - Cart, - shopId, - userId + anonymousCartSelector }) { + const { accountId, collections: { Cart } } = context; + const createdAt = new Date(); const currencyCode = anonymousCart.currencyCode || "USD"; - const { _id, referenceId } = anonymousCart; + const { _id, referenceId, shopId } = anonymousCart; const newCart = { _id: Random.id(), @@ -47,15 +40,7 @@ export default async function convertAnonymousCartToNewAccountCart({ newCart.referenceId = referenceId; } - CartSchema.validate(newCart); - - const { result } = await Cart.insertOne(newCart); - if (result.ok !== 1) throw new ReactionError("server-error", "Unable to create account cart"); - - await appEvents.emit("afterCartCreate", { - cart: newCart, - createdBy: userId - }); + const savedCart = await context.mutations.saveCart(context, newCart); const { deletedCount } = await Cart.deleteOne(anonymousCartSelector); if (deletedCount === 0) { @@ -65,5 +50,5 @@ export default async function convertAnonymousCartToNewAccountCart({ throw new ReactionError("server-error", "Unable to delete anonymous cart"); } - return newCart; + return savedCart; } diff --git a/imports/plugins/core/cart/server/no-meteor/mutations/convertAnonymousCartToNewAccountCart.test.js b/imports/plugins/core/cart/server/no-meteor/mutations/convertAnonymousCartToNewAccountCart.test.js index 40b886dd7e4..90aa34248c0 100644 --- a/imports/plugins/core/cart/server/no-meteor/mutations/convertAnonymousCartToNewAccountCart.test.js +++ b/imports/plugins/core/cart/server/no-meteor/mutations/convertAnonymousCartToNewAccountCart.test.js @@ -9,18 +9,22 @@ const anonymousCartSelector = { _id: "123" }; const shopId = "shopId"; const items = [Factory.CartItem.makeOne()]; +beforeAll(() => { + if (!mockContext.mutations.saveCart) { + mockContext.mutations.saveCart = jest.fn().mockName("context.mutations.saveCart").mockImplementation(async (_, cart) => cart); + } +}); + test("inserts a cart with the existing cart's items and returns it", async () => { - Cart.insertOne.mockReturnValueOnce(Promise.resolve({ result: { ok: 1 } })); + mockContext.accountId = accountId; - const result = await convertAnonymousCartToNewAccountCart({ - accountId, + const result = await convertAnonymousCartToNewAccountCart(mockContext, { anonymousCart: { currencyCode, - items + items, + shopId }, - anonymousCartSelector, - Cart, - shopId + anonymousCartSelector }); const newCart = { @@ -37,43 +41,24 @@ test("inserts a cart with the existing cart's items and returns it", async () => } }; - expect(Cart.insertOne).toHaveBeenCalledWith(newCart); - expect(Cart.deleteOne).toHaveBeenCalledWith(anonymousCartSelector); expect(result).toEqual(newCart); }); -test("throws if insertOne fails", async () => { - Cart.insertOne.mockReturnValueOnce(Promise.resolve({ result: { ok: 0 } })); - - const promise = convertAnonymousCartToNewAccountCart({ - accountId, - anonymousCart: { - currencyCode, - items - }, - anonymousCartSelector, - Cart, - shopId - }); - - return expect(promise).rejects.toThrowErrorMatchingSnapshot(); -}); - test("throws if deleteOne fails", async () => { Cart.insertOne.mockReturnValueOnce(Promise.resolve({ result: { ok: 1 } })); Cart.deleteOne.mockReturnValueOnce(Promise.resolve({ deletedCount: 0 })); - const promise = convertAnonymousCartToNewAccountCart({ - accountId, + mockContext.accountId = accountId; + + const promise = convertAnonymousCartToNewAccountCart(mockContext, { anonymousCart: { currencyCode, - items + items, + shopId }, - anonymousCartSelector, - Cart, - shopId + anonymousCartSelector }); return expect(promise).rejects.toThrowErrorMatchingSnapshot(); diff --git a/imports/plugins/core/cart/server/no-meteor/mutations/createCart.js b/imports/plugins/core/cart/server/no-meteor/mutations/createCart.js index fba956b0ac7..7b70d3085df 100644 --- a/imports/plugins/core/cart/server/no-meteor/mutations/createCart.js +++ b/imports/plugins/core/cart/server/no-meteor/mutations/createCart.js @@ -2,7 +2,6 @@ import Random from "@reactioncommerce/random"; import ReactionError from "@reactioncommerce/reaction-error"; import Logger from "@reactioncommerce/logger"; import hashLoginToken from "/imports/node-app/core/util/hashLoginToken"; -import { Cart as CartSchema } from "/imports/collections/schemas"; import addCartItems from "../util/addCartItems"; /** @@ -23,7 +22,7 @@ import addCartItems from "../util/addCartItems"; */ export default async function createCart(context, input) { const { items, shopId, shouldCreateWithoutItems = false } = input; - const { appEvents, collections, accountId = null, userId = null, getFunctionsOfType } = context; + const { collections, accountId = null, getFunctionsOfType } = context; const { Cart, Shops } = collections; if (shouldCreateWithoutItems !== true && (!Array.isArray(items) || !items.length)) { @@ -90,16 +89,7 @@ export default async function createCart(context, input) { newCart.referenceId = referenceId; - CartSchema.validate(newCart); + const savedCart = await context.mutations.saveCart(context, newCart); - const { result } = await Cart.insertOne(newCart); - - if (result.ok !== 1) throw new ReactionError("server-error", "Unable to create cart"); - - await appEvents.emit("afterCartCreate", { - cart: newCart, - createdBy: userId - }); - - return { cart: newCart, incorrectPriceFailures, minOrderQuantityFailures, token: anonymousAccessToken }; + return { cart: savedCart, incorrectPriceFailures, minOrderQuantityFailures, token: anonymousAccessToken }; } diff --git a/imports/plugins/core/cart/server/no-meteor/mutations/createCart.test.js b/imports/plugins/core/cart/server/no-meteor/mutations/createCart.test.js index 9f6a8a41244..03daba042b4 100644 --- a/imports/plugins/core/cart/server/no-meteor/mutations/createCart.test.js +++ b/imports/plugins/core/cart/server/no-meteor/mutations/createCart.test.js @@ -39,6 +39,12 @@ const items = [{ quantity: 1 }]; +beforeAll(() => { + if (!mockContext.mutations.saveCart) { + mockContext.mutations.saveCart = jest.fn().mockName("context.mutations.saveCart").mockImplementation(async (_, cart) => cart); + } +}); + test("creates an anonymous cart if no user is logged in", async () => { const originalAccountId = mockContext.accountId; mockContext.accountId = null; diff --git a/imports/plugins/core/cart/server/no-meteor/mutations/index.js b/imports/plugins/core/cart/server/no-meteor/mutations/index.js index 623e470af69..641d0bc473e 100644 --- a/imports/plugins/core/cart/server/no-meteor/mutations/index.js +++ b/imports/plugins/core/cart/server/no-meteor/mutations/index.js @@ -6,8 +6,11 @@ import reconcileCartsKeepAccountCart from "./reconcileCartsKeepAccountCart"; import reconcileCartsKeepAnonymousCart from "./reconcileCartsKeepAnonymousCart"; import reconcileCartsMerge from "./reconcileCartsMerge"; import removeCartItems from "./removeCartItems"; +import saveCart from "./saveCart"; +import saveManyCarts from "./saveManyCarts"; import setEmailOnAnonymousCart from "./setEmailOnAnonymousCart"; import setShippingAddressOnCart from "./setShippingAddressOnCart"; +import transformAndValidateCart from "./transformAndValidateCart"; import updateCartItemsQuantity from "./updateCartItemsQuantity"; export default { @@ -19,7 +22,10 @@ export default { reconcileCartsKeepAnonymousCart, reconcileCartsMerge, removeCartItems, + saveCart, + saveManyCarts, setEmailOnAnonymousCart, setShippingAddressOnCart, + transformAndValidateCart, updateCartItemsQuantity }; diff --git a/imports/plugins/core/cart/server/no-meteor/mutations/reconcileCarts.js b/imports/plugins/core/cart/server/no-meteor/mutations/reconcileCarts.js index f52cd0dff01..b8f052d24da 100644 --- a/imports/plugins/core/cart/server/no-meteor/mutations/reconcileCarts.js +++ b/imports/plugins/core/cart/server/no-meteor/mutations/reconcileCarts.js @@ -21,7 +21,7 @@ import reconcileCartsMerge from "./reconcileCartsMerge"; * @returns {Promise} Object in which `cart` property is set to the updated account cart */ export default async function reconcileCarts(context, input) { - const { accountId, collections, user, userId = null } = context; + const { accountId, collections, user } = context; const { Cart } = collections; const { anonymousCartId, anonymousCartToken, mode = "merge" } = input; @@ -57,17 +57,17 @@ export default async function reconcileCarts(context, input) { switch (mode) { case "keepAccountCart": return { - cart: await reconcileCartsKeepAccountCart({ accountCart, anonymousCartSelector, Cart, userId }) + cart: await reconcileCartsKeepAccountCart({ accountCart, anonymousCartSelector, Cart }) }; case "keepAnonymousCart": return { - cart: await reconcileCartsKeepAnonymousCart({ accountCart, accountCartSelector, anonymousCart, anonymousCartSelector, Cart, userId }) + cart: await reconcileCartsKeepAnonymousCart({ accountCart, anonymousCart, anonymousCartSelector, context }) }; case "merge": return { - cart: await reconcileCartsMerge({ accountCart, accountCartSelector, anonymousCart, anonymousCartSelector, context, userId }) + cart: await reconcileCartsMerge({ accountCart, anonymousCart, anonymousCartSelector, context }) }; default: @@ -77,13 +77,9 @@ export default async function reconcileCarts(context, input) { // We have only an anonymous cart, so convert it to an account cart return { - cart: await convertAnonymousCartToNewAccountCart({ - accountId, + cart: await convertAnonymousCartToNewAccountCart(context, { anonymousCart, - anonymousCartSelector, - Cart, - shopId, - userId + anonymousCartSelector }) }; } diff --git a/imports/plugins/core/cart/server/no-meteor/mutations/reconcileCartsKeepAnonymousCart.js b/imports/plugins/core/cart/server/no-meteor/mutations/reconcileCartsKeepAnonymousCart.js index 80bb63f3357..4d61353b391 100644 --- a/imports/plugins/core/cart/server/no-meteor/mutations/reconcileCartsKeepAnonymousCart.js +++ b/imports/plugins/core/cart/server/no-meteor/mutations/reconcileCartsKeepAnonymousCart.js @@ -1,6 +1,4 @@ import ReactionError from "@reactioncommerce/reaction-error"; -import { Cart as CartSchema } from "/imports/collections/schemas"; -import appEvents from "/imports/node-app/core/util/appEvents"; /** * @summary Update account cart to have only the anonymous cart items, delete anonymous @@ -8,46 +6,30 @@ import appEvents from "/imports/node-app/core/util/appEvents"; * @todo When we add a "save for later" / "wish list" feature, we may want to update this * to move existing account cart items to there. * @param {Object} accountCart The account cart document - * @param {Object} accountCartSelector The MongoDB selector for the account cart * @param {Object} anonymousCart The anonymous cart document * @param {Object} anonymousCartSelector The MongoDB selector for the anonymous cart - * @param {MongoDB.Collection} Cart The Cart collection + * @param {Object} context App context * @returns {Object} The updated account cart */ export default async function reconcileCartsKeepAnonymousCart({ accountCart, - accountCartSelector, anonymousCart, anonymousCartSelector, - Cart, - userId + context }) { - const updatedAt = new Date(); - - const modifier = { - $set: { - items: anonymousCart.items, - updatedAt - } - }; - CartSchema.validate(modifier, { modifier: true }); - - const { modifiedCount } = await Cart.updateOne(accountCartSelector, modifier); - if (modifiedCount === 0) throw new ReactionError("server-error", "Unable to update cart"); + const { collections } = context; + const { Cart } = collections; const updatedCart = { ...accountCart, items: anonymousCart.items, - updatedAt + updatedAt: new Date() }; - await appEvents.emit("afterCartUpdate", { - cart: updatedCart, - updatedBy: userId - }); + const savedCart = await context.mutations.saveCart(context, updatedCart); const { deletedCount } = await Cart.deleteOne(anonymousCartSelector); if (deletedCount === 0) throw new ReactionError("server-error", "Unable to delete anonymous cart"); - return updatedCart; + return savedCart; } diff --git a/imports/plugins/core/cart/server/no-meteor/mutations/reconcileCartsKeepAnonymousCart.test.js b/imports/plugins/core/cart/server/no-meteor/mutations/reconcileCartsKeepAnonymousCart.test.js index f7404ad5039..c5f97d38b2c 100644 --- a/imports/plugins/core/cart/server/no-meteor/mutations/reconcileCartsKeepAnonymousCart.test.js +++ b/imports/plugins/core/cart/server/no-meteor/mutations/reconcileCartsKeepAnonymousCart.test.js @@ -5,30 +5,27 @@ import reconcileCartsKeepAnonymousCart from "./reconcileCartsKeepAnonymousCart"; const { Cart } = mockContext.collections; const accountId = "accountId"; const accountCart = { _id: "ACCOUNT_CART", accountId }; -const accountCartSelector = { accountId }; const anonymousCartSelector = { _id: "123" }; const items = [Factory.CartItem.makeOne()]; +beforeAll(() => { + if (!mockContext.mutations.saveCart) { + mockContext.mutations.saveCart = jest.fn().mockName("context.mutations.saveCart").mockImplementation(async (_, cart) => cart); + } +}); + test("overwrites account cart items, deletes anonymous cart, and returns updated account cart", async () => { const result = await reconcileCartsKeepAnonymousCart({ accountCart, - accountCartSelector, anonymousCart: { items }, anonymousCartSelector, - Cart + context: mockContext }); expect(Cart.deleteOne).toHaveBeenCalledWith(anonymousCartSelector); - expect(Cart.updateOne).toHaveBeenCalledWith(accountCartSelector, { - $set: { - items, - updatedAt: jasmine.any(Date) - } - }); - expect(result).toEqual({ ...accountCart, items, @@ -41,28 +38,11 @@ test("throws if deleteOne fails", async () => { const promise = reconcileCartsKeepAnonymousCart({ accountCart, - accountCartSelector, - anonymousCart: { - items - }, - anonymousCartSelector, - Cart - }); - - return expect(promise).rejects.toThrowErrorMatchingSnapshot(); -}); - -test("throws if updateOne fails", async () => { - Cart.updateOne.mockReturnValueOnce(Promise.resolve({ modifiedCount: 0 })); - - const promise = reconcileCartsKeepAnonymousCart({ - accountCart, - accountCartSelector, anonymousCart: { items }, anonymousCartSelector, - Cart + context: mockContext }); return expect(promise).rejects.toThrowErrorMatchingSnapshot(); diff --git a/imports/plugins/core/cart/server/no-meteor/mutations/reconcileCartsMerge.js b/imports/plugins/core/cart/server/no-meteor/mutations/reconcileCartsMerge.js index 9b3c8f41b4a..a1d257fda30 100644 --- a/imports/plugins/core/cart/server/no-meteor/mutations/reconcileCartsMerge.js +++ b/imports/plugins/core/cart/server/no-meteor/mutations/reconcileCartsMerge.js @@ -1,13 +1,10 @@ import ReactionError from "@reactioncommerce/reaction-error"; -import { Cart as CartSchema } from "/imports/collections/schemas"; -import appEvents from "/imports/node-app/core/util/appEvents"; import addCartItems from "../util/addCartItems"; /** * @summary Update account cart to have only the anonymous cart items, delete anonymous * cart, and return updated accountCart. * @param {Object} accountCart The account cart document - * @param {Object} accountCartSelector The MongoDB selector for the account cart * @param {Object} anonymousCart The anonymous cart document * @param {Object} anonymousCartSelector The MongoDB selector for the anonymous cart * @param {Object} context App context @@ -15,11 +12,9 @@ import addCartItems from "../util/addCartItems"; */ export default async function reconcileCartsMerge({ accountCart, - accountCartSelector, anonymousCart, anonymousCartSelector, - context, - userId + context }) { const { collections } = context; const { Cart } = collections; @@ -40,34 +35,17 @@ export default async function reconcileCartsMerge({ skipPriceCheck: true }); - // Update account cart - const updatedAt = new Date(); - - const modifier = { - $set: { - items, - updatedAt - } - }; - CartSchema.validate(modifier, { modifier: true }); - - const { modifiedCount } = await Cart.updateOne(accountCartSelector, modifier); - if (modifiedCount === 0) throw new ReactionError("server-error", "Unable to update cart"); - const updatedCart = { ...accountCart, items, - updatedAt + updatedAt: new Date() }; - await appEvents.emit("afterCartUpdate", { - cart: updatedCart, - updatedBy: userId - }); + const savedCart = await context.mutations.saveCart(context, updatedCart); // Delete anonymous cart const { deletedCount } = await Cart.deleteOne(anonymousCartSelector); if (deletedCount === 0) throw new ReactionError("server-error", "Unable to delete anonymous cart"); - return updatedCart; + return savedCart; } diff --git a/imports/plugins/core/cart/server/no-meteor/mutations/reconcileCartsMerge.test.js b/imports/plugins/core/cart/server/no-meteor/mutations/reconcileCartsMerge.test.js index 55a630020c3..1ab790eee52 100644 --- a/imports/plugins/core/cart/server/no-meteor/mutations/reconcileCartsMerge.test.js +++ b/imports/plugins/core/cart/server/no-meteor/mutations/reconcileCartsMerge.test.js @@ -40,6 +40,12 @@ const accountCartSelector = { accountId }; const anonymousCartSelector = { _id: "123" }; const items = [Factory.CartItem.makeOne()]; +beforeAll(() => { + if (!mockContext.mutations.saveCart) { + mockContext.mutations.saveCart = jest.fn().mockName("context.mutations.saveCart").mockImplementation(async (_, cart) => cart); + } +}); + test("merges anonymous cart items into account cart items, deletes anonymous cart, and returns updated account cart", async () => { const updatedItems = [ { @@ -80,13 +86,6 @@ test("merges anonymous cart items into account cart items, deletes anonymous car expect(Cart.deleteOne).toHaveBeenCalledWith(anonymousCartSelector); - expect(Cart.updateOne).toHaveBeenCalledWith(accountCartSelector, { - $set: { - items: updatedItems, - updatedAt: jasmine.any(Date) - } - }); - expect(result).toEqual({ ...accountCart, items: updatedItems, @@ -109,19 +108,3 @@ test("throws if deleteOne fails", async () => { return expect(promise).rejects.toThrowErrorMatchingSnapshot(); }); - -test("throws if updateOne fails", async () => { - Cart.updateOne.mockReturnValueOnce(Promise.resolve({ modifiedCount: 0 })); - - const promise = reconcileCartsMerge({ - accountCart, - accountCartSelector, - anonymousCart: { - items - }, - anonymousCartSelector, - context: mockContext - }); - - return expect(promise).rejects.toThrowErrorMatchingSnapshot(); -}); diff --git a/imports/plugins/core/cart/server/no-meteor/mutations/removeCartItems.js b/imports/plugins/core/cart/server/no-meteor/mutations/removeCartItems.js index 6020d0aa124..7a1d06421f5 100644 --- a/imports/plugins/core/cart/server/no-meteor/mutations/removeCartItems.js +++ b/imports/plugins/core/cart/server/no-meteor/mutations/removeCartItems.js @@ -28,7 +28,7 @@ const inputSchema = new SimpleSchema({ export default async function removeCartItems(context, input) { inputSchema.validate(input || {}); - const { accountId, appEvents, collections, userId } = context; + const { accountId, collections } = context; const { Cart } = collections; const { cartId, cartItemIds, token } = input; @@ -41,22 +41,16 @@ export default async function removeCartItems(context, input) { throw new ReactionError("invalid-param", "A token is required when updating an anonymous cart"); } - const { modifiedCount } = await Cart.updateOne(selector, { - $pull: { - items: { - _id: { $in: cartItemIds } - } - } - }); - if (modifiedCount === 0) throw new ReactionError("not-found", "Cart not found or provided items are not in the cart"); - const cart = await Cart.findOne(selector); if (!cart) throw new ReactionError("not-found", "Cart not found"); - await appEvents.emit("afterCartUpdate", { - cart, - updatedBy: userId - }); + const updatedCart = { + ...cart, + items: cart.items.filter((item) => !cartItemIds.includes(item._id)), + updatedAt: new Date() + }; + + const savedCart = await context.mutations.saveCart(context, updatedCart); - return { cart }; + return { cart: savedCart }; } diff --git a/imports/plugins/core/cart/server/no-meteor/mutations/removeCartItems.test.js b/imports/plugins/core/cart/server/no-meteor/mutations/removeCartItems.test.js index 0c89627a8df..d4f2c20347f 100644 --- a/imports/plugins/core/cart/server/no-meteor/mutations/removeCartItems.test.js +++ b/imports/plugins/core/cart/server/no-meteor/mutations/removeCartItems.test.js @@ -8,33 +8,32 @@ const dbCart = { const cartItemIds = ["cartItemId1", "cartItemId2"]; +beforeAll(() => { + if (!mockContext.mutations.saveCart) { + mockContext.mutations.saveCart = jest.fn().mockName("context.mutations.saveCart").mockImplementation(async (_, cart) => cart); + } +}); + test("removes multiple items from account cart", async () => { mockContext.collections.Cart.findOne.mockReturnValueOnce(Promise.resolve(dbCart)); const result = await removeCartItems(mockContext, { cartId: "cartId", cartItemIds }); - expect(mockContext.collections.Cart.updateOne).toHaveBeenCalledWith({ - _id: "cartId", - accountId: "FAKE_ACCOUNT_ID" - }, { - $pull: { - items: { - _id: { - $in: cartItemIds - } - } - } - }); - expect(mockContext.collections.Cart.findOne).toHaveBeenCalledWith({ _id: "cartId", accountId: "FAKE_ACCOUNT_ID" }); - expect(result).toEqual({ cart: dbCart }); + expect(result).toEqual({ + cart: { + ...dbCart, + items: dbCart.items.filter((item) => !cartItemIds.includes(item._id)), + updatedAt: jasmine.any(Date) + } + }); }); -test("updates the quantity of multiple items in anonymous cart", async () => { +test("removes multiple items from anonymous cart", async () => { const hashedToken = "+YED6SF/CZIIVp0pXBsnbxghNIY2wmjIVLsqCG4AN80="; mockContext.collections.Cart.findOne.mockReturnValueOnce(Promise.resolve(dbCart)); @@ -48,25 +47,18 @@ test("updates the quantity of multiple items in anonymous cart", async () => { }); mockContext.accountId = cachedAccountId; - expect(mockContext.collections.Cart.updateOne).toHaveBeenCalledWith({ - _id: "cartId", - anonymousAccessToken: hashedToken - }, { - $pull: { - items: { - _id: { - $in: cartItemIds - } - } - } - }); - expect(mockContext.collections.Cart.findOne).toHaveBeenCalledWith({ _id: "cartId", anonymousAccessToken: hashedToken }); - expect(result).toEqual({ cart: dbCart }); + expect(result).toEqual({ + cart: { + ...dbCart, + items: dbCart.items.filter((item) => !cartItemIds.includes(item._id)), + updatedAt: jasmine.any(Date) + } + }); }); test("throws when no account and no token passed", async () => { diff --git a/imports/plugins/core/cart/server/no-meteor/mutations/saveCart.js b/imports/plugins/core/cart/server/no-meteor/mutations/saveCart.js new file mode 100644 index 00000000000..5dc53563dbb --- /dev/null +++ b/imports/plugins/core/cart/server/no-meteor/mutations/saveCart.js @@ -0,0 +1,32 @@ +import ReactionError from "@reactioncommerce/reaction-error"; + +/** + * @summary Takes a new or updated cart, runs it through all registered transformations, + * validates, and upserts to database. + * @param {Object} context - App context + * @param {Object} cart - The cart to transform and insert or replace + * @returns {Object} Transformed and saved cart + */ +export default async function saveCart(context, cart) { + const { appEvents, collections: { Cart }, userId = null } = context; + + // This will mutate `cart` + await context.mutations.transformAndValidateCart(context, cart); + + const { result, upsertedCount } = await Cart.replaceOne({ _id: cart._id }, cart, { upsert: true }); + if (result.ok !== 1) throw new ReactionError("server-error", "Unable to save cart"); + + if (upsertedCount === 1) { + appEvents.emit("afterCartCreate", { + cart, + createdBy: userId + }); + } else { + appEvents.emit("afterCartUpdate", { + cart, + updatedBy: userId + }); + } + + return cart; +} diff --git a/imports/plugins/core/cart/server/no-meteor/mutations/saveManyCarts.js b/imports/plugins/core/cart/server/no-meteor/mutations/saveManyCarts.js new file mode 100644 index 00000000000..e1412ffcd28 --- /dev/null +++ b/imports/plugins/core/cart/server/no-meteor/mutations/saveManyCarts.js @@ -0,0 +1,69 @@ +import Logger from "@reactioncommerce/logger"; +import ReactionError from "@reactioncommerce/reaction-error"; + +export const MAX_CART_COUNT = 50; +const logCtx = { name: "cart", file: "saveManyCarts" }; + +/** + * @summary Takes a new or updated cart, runs it through all registered transformations, + * validates, and upserts to database. + * @param {Object} context - App context + * @param {Object[]} carts - The carts to transform and insert or replace. There is a limit + * of 50 carts. If the array has more than 50 items, an error is thrown. + * @returns {undefined} + */ +export default async function saveManyCarts(context, carts) { + const { appEvents, collections: { Cart } } = context; + + if (!Array.isArray(carts) || carts.length > MAX_CART_COUNT) { + throw new ReactionError("invalid-param", `carts must be an array of ${MAX_CART_COUNT} or fewer carts`); + } + + // Transform and validate each cart and then add to `bulkWrites` array + const bulkWritePromises = carts.map(async (cart) => { + // Mutates `cart` + await context.mutations.transformAndValidateCart(context, cart); + + return { + replaceOne: { + filter: { _id: cart._id }, + replacement: cart, + upsert: true + } + }; + }); + + const bulkWrites = await Promise.all(bulkWritePromises); + + let writeErrors; + try { + Logger.trace({ ...logCtx, bulkWrites }, "Running bulk op"); + const bulkWriteResult = await Cart.bulkWrite(bulkWrites, { ordered: false }); + ({ writeErrors } = bulkWriteResult.result); + } catch (error) { + if (!error.result || typeof error.result.getWriteErrors !== "function") throw error; + // This happens only if all writes fail. `error` object has the result on it. + writeErrors = error.result.getWriteErrors(); + } + + // Figure out which failed and which succeeded. Emit "after update" or log error + const cartIds = []; + await Promise.all(carts.map(async (cart, index) => { + // If updating this cart failed, log the error details and stop + const writeError = writeErrors.find((writeErr) => writeErr.index === index); + if (writeError) { + Logger.error({ + ...logCtx, + errorCode: writeError.code, + errorMsg: writeError.errmsg, + cartId: cart._id + }, "MongoDB writeError saving cart"); + return; + } + + cartIds.push(cart._id); + appEvents.emit("afterCartUpdate", { cart, updatedBy: null }); + })); + + Logger.debug({ ...logCtx, cartIds }, "Successfully saved multiple carts"); +} diff --git a/imports/plugins/core/cart/server/no-meteor/mutations/setEmailOnAnonymousCart.js b/imports/plugins/core/cart/server/no-meteor/mutations/setEmailOnAnonymousCart.js index 012ed906904..f2e3fc5d2bc 100644 --- a/imports/plugins/core/cart/server/no-meteor/mutations/setEmailOnAnonymousCart.js +++ b/imports/plugins/core/cart/server/no-meteor/mutations/setEmailOnAnonymousCart.js @@ -24,26 +24,22 @@ const inputSchema = new SimpleSchema({ export default async function setEmailOnAnonymousCart(context, input) { inputSchema.validate(input || {}); - const { appEvents, collections, userId } = context; - const { Cart } = collections; + const { collections: { Cart } } = context; const { cartId, email, token } = input; - const { matchedCount } = await Cart.updateOne({ + const cart = await Cart.findOne({ _id: cartId, anonymousAccessToken: hashLoginToken(token) - }, { - $set: { email } }); - if (matchedCount === 0) throw new ReactionError("server-error", "Unable to update cart"); + if (!cart) throw new ReactionError("not-found", "Cart not found"); - const updatedCart = await Cart.findOne({ _id: cartId }); + const updatedCart = { + ...cart, + email, + updatedAt: new Date() + }; - await appEvents.emit("afterCartUpdate", { - cart: updatedCart, - updatedBy: userId - }); + const savedCart = await context.mutations.saveCart(context, updatedCart); - return { - cart: updatedCart - }; + return { cart: savedCart }; } diff --git a/imports/plugins/core/cart/server/no-meteor/mutations/setEmailOnAnonymousCart.test.js b/imports/plugins/core/cart/server/no-meteor/mutations/setEmailOnAnonymousCart.test.js index 4a1aacf0d2d..17683a88042 100644 --- a/imports/plugins/core/cart/server/no-meteor/mutations/setEmailOnAnonymousCart.test.js +++ b/imports/plugins/core/cart/server/no-meteor/mutations/setEmailOnAnonymousCart.test.js @@ -8,6 +8,12 @@ const email = "email@address.com"; const token = "TOKEN"; const hashedToken = "+YED6SF/CZIIVp0pXBsnbxghNIY2wmjIVLsqCG4AN80="; +beforeAll(() => { + if (!mockContext.mutations.saveCart) { + mockContext.mutations.saveCart = jest.fn().mockName("context.mutations.saveCart").mockImplementation(async (_, cart) => cart); + } +}); + test("sets the email address on an anonymous cart", async () => { mockContext.collections.Cart.findOne.mockReturnValueOnce(Promise.resolve(dbCart)); @@ -17,16 +23,12 @@ test("sets the email address on an anonymous cart", async () => { token }); - expect(mockContext.collections.Cart.updateOne).toHaveBeenCalledWith({ + expect(mockContext.collections.Cart.findOne).toHaveBeenCalledWith({ _id: "cartId", anonymousAccessToken: hashedToken - }, { - $set: { email } }); - expect(mockContext.collections.Cart.findOne).toHaveBeenCalledWith({ _id: "cartId" }); - expect(result).toEqual({ - cart: dbCart + cart: { ...dbCart, email, updatedAt: jasmine.any(Date) } }); }); diff --git a/imports/plugins/core/cart/server/no-meteor/mutations/setShippingAddressOnCart.js b/imports/plugins/core/cart/server/no-meteor/mutations/setShippingAddressOnCart.js index 993e394339a..e09013cd7ec 100644 --- a/imports/plugins/core/cart/server/no-meteor/mutations/setShippingAddressOnCart.js +++ b/imports/plugins/core/cart/server/no-meteor/mutations/setShippingAddressOnCart.js @@ -1,6 +1,5 @@ import SimpleSchema from "simpl-schema"; import Random from "@reactioncommerce/random"; -import ReactionError from "@reactioncommerce/reaction-error"; import { Address as AddressSchema } from "/imports/collections/schemas"; import getCartById from "../util/getCartById"; @@ -45,24 +44,13 @@ export default async function setShippingAddressOnCart(context, input) { if (!didModify) return { cart }; - const { appEvents, collections, userId } = context; - const { Cart } = collections; + const updatedCart = { + ...cart, + shipping: updatedFulfillmentGroups, + updatedAt: new Date() + }; - const updatedAt = new Date(); - const { matchedCount } = await Cart.updateOne({ _id: cartId }, { - $set: { - shipping: updatedFulfillmentGroups, - updatedAt - } - }); - if (matchedCount === 0) throw new ReactionError("server-error", "Failed to update cart"); - - const updatedCart = { ...cart, shipping: updatedFulfillmentGroups, updatedAt }; - - await appEvents.emit("afterCartUpdate", { - cart: updatedCart, - updatedBy: userId - }); + const savedCart = await context.mutations.saveCart(context, updatedCart); - return { cart: updatedCart }; + return { cart: savedCart }; } diff --git a/imports/plugins/core/cart/server/no-meteor/mutations/setShippingAddressOnCart.test.js b/imports/plugins/core/cart/server/no-meteor/mutations/setShippingAddressOnCart.test.js index afca70f3806..8cc3dd3fabc 100644 --- a/imports/plugins/core/cart/server/no-meteor/mutations/setShippingAddressOnCart.test.js +++ b/imports/plugins/core/cart/server/no-meteor/mutations/setShippingAddressOnCart.test.js @@ -13,6 +13,12 @@ jest.mock("../util/getCartById", () => jest.fn().mockImplementation(() => Promis const address = Factory.Address.makeOne({ _id: undefined }); +beforeAll(() => { + if (!mockContext.mutations.saveCart) { + mockContext.mutations.saveCart = jest.fn().mockName("context.mutations.saveCart").mockImplementation(async (_, cart) => cart); + } +}); + test("expect to return a cart that has address added to all shipping fulfillment groups", async () => { const result = await setShippingAddressOnCart(mockContext, { address, diff --git a/imports/plugins/core/cart/server/no-meteor/mutations/transformAndValidateCart.js b/imports/plugins/core/cart/server/no-meteor/mutations/transformAndValidateCart.js new file mode 100644 index 00000000000..0b8cfe78d3e --- /dev/null +++ b/imports/plugins/core/cart/server/no-meteor/mutations/transformAndValidateCart.js @@ -0,0 +1,50 @@ +import Logger from "@reactioncommerce/logger"; +import { Cart as CartSchema } from "/imports/collections/schemas"; +import forEachPromise from "/imports/utils/forEachPromise"; +import updateCartFulfillmentGroups from "../util/updateCartFulfillmentGroups"; +import xformCartGroupToCommonOrder from "../util/xformCartGroupToCommonOrder"; +import { cartTransforms } from "../registration"; + +const logCtx = { name: "cart", file: "transformAndValidateCart" }; + +/** + * @summary Takes a new or updated cart, runs it through all registered transformations, + * 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 + * @returns {undefined} + */ +export default async function transformAndValidateCart(context, cart) { + updateCartFulfillmentGroups(context, cart); + + let commonOrders; + + /** + * @summary It's common for cart transform functions to need to convert the cart to CommonOrders, + * but we don't want to do it unless they need it. The first transform to call this will + * cause `commonOrders` to be built and subsequent calls will get that cached list + * unless they force a rebuild by setting `shouldRebuild` to `true`. + * @return {Object[]} CommonOrders + */ + async function getCommonOrders({ shouldRebuild = false } = {}) { + if (!commonOrders || shouldRebuild) { + commonOrders = await Promise.all(cart.shipping.map((group) => + xformCartGroupToCommonOrder(cart, group, context))); + } + return commonOrders; + } + + // Run transformations registered by plugins, in priority order, in series. + // Functions are expected to mutate the cart passed by reference. + // In testing, `forEachPromise` performed much better than a for-of loop, and resulted + // in more accurate elapsed `ms` values in the logs. + await forEachPromise(cartTransforms, async (transformInfo) => { + const startTime = Date.now(); + /* eslint-disable no-await-in-loop */ + await transformInfo.fn(context, cart, { getCommonOrders }); + /* eslint-enable no-await-in-loop */ + Logger.debug({ ...logCtx, cartId: cart._id, ms: Date.now() - startTime }, `Finished ${transformInfo.name} cart transform`); + }); + + CartSchema.validate(cart); +} diff --git a/imports/plugins/core/cart/server/no-meteor/mutations/updateCartItemsQuantity.js b/imports/plugins/core/cart/server/no-meteor/mutations/updateCartItemsQuantity.js index 9c7341f1ce3..70fc30c3fe4 100644 --- a/imports/plugins/core/cart/server/no-meteor/mutations/updateCartItemsQuantity.js +++ b/imports/plugins/core/cart/server/no-meteor/mutations/updateCartItemsQuantity.js @@ -1,6 +1,4 @@ import SimpleSchema from "simpl-schema"; -import ReactionError from "@reactioncommerce/reaction-error"; -import { Cart as CartSchema } from "/imports/collections/schemas"; import getCartById from "../util/getCartById"; const inputSchema = new SimpleSchema({ @@ -59,30 +57,13 @@ export default async function updateCartItemsQuantity(context, input) { return list; }, []); - const { appEvents, collections, userId } = context; - const { Cart } = collections; - - const updatedAt = new Date(); - const modifier = { - $set: { - items: updatedItems, - updatedAt - } + const updatedCart = { + ...cart, + items: updatedItems, + updatedAt: new Date() }; - CartSchema.validate(modifier, { modifier: true }); - - const { matchedCount } = await Cart.updateOne({ _id: cartId }, modifier); - if (matchedCount === 0) throw new ReactionError("server-error", "Failed to update cart"); - - const updatedCart = { ...cart, items: updatedItems, updatedAt }; - - await appEvents.emit("afterCartUpdate", { - cart: updatedCart, - updatedBy: userId - }); - // Re-fetch cart with updated data - const updatedCartAfterAppEvents = await Cart.findOne({ _id: cartId }); + const savedCart = await context.mutations.saveCart(context, updatedCart); - return { cart: updatedCartAfterAppEvents }; + return { cart: savedCart }; } diff --git a/imports/plugins/core/cart/server/no-meteor/mutations/updateCartItemsQuantity.test.js b/imports/plugins/core/cart/server/no-meteor/mutations/updateCartItemsQuantity.test.js index 0d3985d0302..83aa1f75bcd 100644 --- a/imports/plugins/core/cart/server/no-meteor/mutations/updateCartItemsQuantity.test.js +++ b/imports/plugins/core/cart/server/no-meteor/mutations/updateCartItemsQuantity.test.js @@ -1,77 +1,47 @@ -import Factory from "/imports/test-utils/helpers/factory"; import mockContext from "/imports/test-utils/helpers/mockContext"; import updateCartItemsQuantity from "./updateCartItemsQuantity"; - -const cartItemId1 = Factory.CartItem.makeOne({ - _id: "cartItemId1", - quantity: 5, - price: { - amount: 400, - currencyCode: "mockCurrencyCode" - }, - subtotal: { - amount: 2000, - currencyCode: "mockCurrencyCode" - } -}); -const cartItemId2 = Factory.CartItem.makeOne({ - _id: "cartItemId2", - quantity: 5, - price: { - amount: 200, - currencyCode: "mockCurrencyCode" - }, - subtotal: { - amount: 1000, - currencyCode: "mockCurrencyCode" - } -}); - -const dbCart = { +jest.mock("../util/getCartById", () => jest.fn().mockImplementation(() => Promise.resolve({ _id: "cartId", items: [ - cartItemId1, cartItemId2 + { + _id: "cartItemId1", + quantity: 5, + price: { + amount: 400, + currencyCode: "mockCurrencyCode" + }, + subtotal: { + amount: 2000, + currencyCode: "mockCurrencyCode" + } + }, + { + _id: "cartItemId2", + quantity: 5, + price: { + amount: 200, + currencyCode: "mockCurrencyCode" + }, + subtotal: { + amount: 1000, + currencyCode: "mockCurrencyCode" + } + } ] -}; +}))); -const updatedCartItemId1 = Factory.CartItem.makeOne({ - subtotal: { - amount: 2000, - currencyCode: "mockCurrencyCode" - }, - ...cartItemId1 -}); -const updatedCartItemId2 = Factory.CartItem.makeOne({ - subtotal: { - amount: 1000, - currencyCode: "mockCurrencyCode" - }, - ...cartItemId2 +beforeAll(() => { + if (!mockContext.mutations.saveCart) { + mockContext.mutations.saveCart = jest.fn().mockName("context.mutations.saveCart").mockImplementation(async (_, cart) => cart); + } }); -const updatedDbCart = { - _id: "cartId", - items: [ - updatedCartItemId1, updatedCartItemId2 - ] -}; - -const updatedDbCartAfterRemoval = { - _id: "cartId", - items: [ - updatedCartItemId2 - ] -}; - beforeEach(() => { jest.clearAllMocks(); }); test("updates the quantity of multiple items in account cart", async () => { - mockContext.collections.Cart.findOne.mockReturnValueOnce(Promise.resolve(dbCart)); - mockContext.collections.Cart.findOne.mockReturnValueOnce(Promise.resolve(updatedDbCart)); - const result = await updateCartItemsQuantity(mockContext, { cartId: "cartId", items: [ @@ -86,67 +56,41 @@ test("updates the quantity of multiple items in account cart", async () => { ] }); - expect(mockContext.collections.Cart.updateOne).toHaveBeenCalledWith({ - _id: "cartId" - }, { - $set: { + expect(result).toEqual({ + cart: { + _id: "cartId", items: [ { - ...dbCart.items[0], + _id: "cartItemId1", quantity: 1, + price: { + amount: 400, + currencyCode: "mockCurrencyCode" + }, subtotal: { - amount: dbCart.items[0].price.amount, - currencyCode: dbCart.items[0].subtotal.currencyCode + amount: 400, + currencyCode: "mockCurrencyCode" } }, { - ...dbCart.items[1], + _id: "cartItemId2", quantity: 2, + price: { + amount: 200, + currencyCode: "mockCurrencyCode" + }, subtotal: { - amount: dbCart.items[1].price.amount * 2, - currencyCode: dbCart.items[1].subtotal.currencyCode + amount: 400, + currencyCode: "mockCurrencyCode" } } ], updatedAt: jasmine.any(Date) } }); - - expect(mockContext.collections.Cart.findOne).toHaveBeenCalledWith({ - _id: "cartId" - }); - - expect(result).toEqual({ - cart: { - _id: "cartId", - items: [ - { - ...updatedDbCart.items[0], - quantity: 5, - subtotal: { - amount: updatedDbCart.items[0].price.amount * 5, - currencyCode: updatedDbCart.items[0].subtotal.currencyCode - } - }, - { - ...updatedDbCart.items[1], - quantity: 5, - subtotal: { - amount: updatedDbCart.items[1].price.amount * 5, - currencyCode: updatedDbCart.items[1].subtotal.currencyCode - } - } - ] - } - }); }); test("updates the quantity of multiple items in anonymous cart", async () => { - const hashedToken = "+YED6SF/CZIIVp0pXBsnbxghNIY2wmjIVLsqCG4AN80="; - - mockContext.collections.Cart.findOne.mockReturnValueOnce(Promise.resolve(dbCart)); - mockContext.collections.Cart.findOne.mockReturnValueOnce(Promise.resolve(updatedDbCart)); - const cachedAccountId = mockContext.accountId; mockContext.accountId = null; const result = await updateCartItemsQuantity(mockContext, { @@ -165,87 +109,41 @@ test("updates the quantity of multiple items in anonymous cart", async () => { }); mockContext.accountId = cachedAccountId; - expect(mockContext.collections.Cart.updateOne).toHaveBeenCalledWith({ - _id: "cartId" - }, { - $set: { + expect(result).toEqual({ + cart: { + _id: "cartId", items: [ { - ...dbCart.items[0], + _id: "cartItemId1", quantity: 1, + price: { + amount: 400, + currencyCode: "mockCurrencyCode" + }, subtotal: { - amount: dbCart.items[0].price.amount, - currencyCode: dbCart.items[0].subtotal.currencyCode + amount: 400, + currencyCode: "mockCurrencyCode" } }, { - ...dbCart.items[1], + _id: "cartItemId2", quantity: 2, + price: { + amount: 200, + currencyCode: "mockCurrencyCode" + }, subtotal: { - amount: dbCart.items[1].price.amount * 2, - currencyCode: dbCart.items[1].subtotal.currencyCode + amount: 400, + currencyCode: "mockCurrencyCode" } } ], updatedAt: jasmine.any(Date) } }); - - expect(mockContext.collections.Cart.findOne).toHaveBeenCalledWith({ - _id: "cartId", - anonymousAccessToken: hashedToken - }); - - expect(result).toEqual({ - cart: { - _id: "cartId", - items: [ - { - ...updatedDbCart.items[0], - quantity: 5, - subtotal: { - amount: updatedDbCart.items[0].price.amount * 5, - currencyCode: updatedDbCart.items[0].subtotal.currencyCode - } - }, - { - ...updatedDbCart.items[1], - quantity: 5, - subtotal: { - amount: updatedDbCart.items[1].price.amount * 5, - currencyCode: updatedDbCart.items[1].subtotal.currencyCode - } - } - ] - } - }); -}); - -test("throws when no account and no token passed", async () => { - const cachedAccountId = mockContext.accountId; - mockContext.accountId = null; - - await expect(updateCartItemsQuantity(mockContext, { - cartId: "cartId", - items: [ - { - cartItemId: "cartItemId1", - quantity: 1 - }, - { - cartItemId: "cartItemId2", - quantity: 2 - } - ] - })).rejects.toThrowErrorMatchingSnapshot(); - - mockContext.accountId = cachedAccountId; }); test("removes an item if quantity is 0", async () => { - mockContext.collections.Cart.findOne.mockReturnValueOnce(Promise.resolve(dbCart)); - mockContext.collections.Cart.findOne.mockReturnValueOnce(Promise.resolve(updatedDbCartAfterRemoval)); - const result = await updateCartItemsQuantity(mockContext, { cartId: "cartId", items: [ @@ -260,41 +158,24 @@ test("removes an item if quantity is 0", async () => { ] }); - expect(mockContext.collections.Cart.updateOne).toHaveBeenCalledWith({ - _id: "cartId" - }, { - $set: { - items: [ - { - ...dbCart.items[1], - quantity: 2, - subtotal: { - amount: dbCart.items[1].price.amount * 2, - currencyCode: dbCart.items[1].subtotal.currencyCode - } - } - ], - updatedAt: jasmine.any(Date) - } - }); - - expect(mockContext.collections.Cart.findOne).toHaveBeenCalledWith({ - _id: "cartId" - }); - expect(result).toEqual({ cart: { _id: "cartId", items: [ { - ...updatedDbCartAfterRemoval.items[0], - quantity: 5, + _id: "cartItemId2", + quantity: 2, + price: { + amount: 200, + currencyCode: "mockCurrencyCode" + }, subtotal: { - amount: updatedDbCartAfterRemoval.items[0].price.amount * 5, - currencyCode: updatedDbCartAfterRemoval.items[0].subtotal.currencyCode + amount: 400, + currencyCode: "mockCurrencyCode" } } - ] + ], + updatedAt: jasmine.any(Date) } }); }); diff --git a/imports/plugins/core/cart/server/no-meteor/register.js b/imports/plugins/core/cart/server/no-meteor/register.js index 6fa61fce0e3..77ae51cac0b 100644 --- a/imports/plugins/core/cart/server/no-meteor/register.js +++ b/imports/plugins/core/cart/server/no-meteor/register.js @@ -1,5 +1,6 @@ import mutations from "./mutations"; import queries from "./queries"; +import { registerPluginHandler } from "./registration"; import resolvers from "./resolvers"; import schemas from "./schemas"; import startup from "./startup"; @@ -42,6 +43,7 @@ export default async function register(app) { } }, functionsByType: { + registerPluginHandler: [registerPluginHandler], startup: [startup] }, graphQL: { diff --git a/imports/plugins/core/cart/server/no-meteor/registration.js b/imports/plugins/core/cart/server/no-meteor/registration.js new file mode 100644 index 00000000000..befec7a5a13 --- /dev/null +++ b/imports/plugins/core/cart/server/no-meteor/registration.js @@ -0,0 +1,27 @@ +import SimpleSchema from "simpl-schema"; + +const transformSchema = new SimpleSchema({ + name: String, + fn: Function, + priority: Number +}); + +// Objects with `name`, `priority` and `fn` properties +export const cartTransforms = []; + +/** + * @summary Will be called for every plugin + * @param {Object} options The options object that the plugin passed to registerPackage + * @returns {undefined} + */ +export function registerPluginHandler({ name, cart }) { + if (cart) { + const { transforms } = cart; + + if (!Array.isArray(transforms)) throw new Error(`In ${name} plugin registerPlugin object, cart.transforms must be an array`); + transformSchema.validate(transforms); + + cartTransforms.push(...transforms); + cartTransforms.sort((prev, next) => prev.priority - next.priority); + } +} diff --git a/imports/plugins/core/cart/server/no-meteor/startup.js b/imports/plugins/core/cart/server/no-meteor/startup.js index 5384137dc6b..6c2d7ce1ce9 100644 --- a/imports/plugins/core/cart/server/no-meteor/startup.js +++ b/imports/plugins/core/cart/server/no-meteor/startup.js @@ -1,7 +1,8 @@ import Logger from "@reactioncommerce/logger"; -import updateCartItemsForVariantPriceChange from "./util/updateCartItemsForVariantPriceChange"; +import updateCartItemsForVariantChanges from "./util/updateCartItemsForVariantChanges"; +import { MAX_CART_COUNT as SAVE_MANY_CARTS_LIMIT } from "./mutations/saveManyCarts"; -const AFTER_CATALOG_UPDATE_EMITTED_BY_NAME = "CART_CORE_PLUGIN_AFTER_CATALOG_UPDATE"; +const logCtx = { name: "cart", file: "startup" }; /** * @param {Object[]} catalogProductVariants The `product.variants` array from a catalog item @@ -29,47 +30,60 @@ function getFlatVariantsAndOptions(catalogProductVariants) { * @returns {Promise} Promise that resolves with null */ async function updateAllCartsForVariant({ Cart, context, variant }) { - const { appEvents, queries } = context; + const { mutations, queries } = context; const { variantId } = variant; + Logger.debug({ ...logCtx, variantId, fn: "updateAllCartsForVariant" }, "Running updateAllCartsForVariant"); + + let updatedCarts = []; + + /** + * @summary Bulk save an array of updated carts + * @return {undefined} + */ + async function saveCarts() { + if (updatedCarts.length === 0) return; + await mutations.saveManyCarts(context, updatedCarts); + updatedCarts = []; + } + + /** + * @summary Get updated prices for a single cart, and check whether there are any changes. + * If so, push into `bulkWrites` array. + * @param {Object} cart The cart + * @return {undefined} + */ + async function updateOneCart(cart) { + const prices = await queries.getVariantPrice(context, variant, cart.currencyCode); + if (!prices) return; + + const { didUpdate, updatedItems } = updateCartItemsForVariantChanges(cart.items, variant, prices); + if (!didUpdate) return; + + updatedCarts.push({ ...cart, items: updatedItems }); + } + // Do find + update because we need the `cart.currencyCode` to figure out pricing // and we need current quantity to recalculate `subtotal` for each item. // It should be fine to load all results into an array because even for large shops, // there will likely not be a huge number of the same product in carts at the same time. - const carts = await Cart.find({ - "items.variantId": variantId - }, { - projection: { _id: 1, currencyCode: 1, items: 1 } - }).toArray(); + const cartsCursor = Cart.find({ "items.variantId": variantId }); - await Promise.all(carts.map(async (cart) => { - const prices = await queries.getVariantPrice(context, variant, cart.currencyCode); - if (!prices) return; - - const { didUpdate, updatedItems } = updateCartItemsForVariantPriceChange(cart.items, variantId, prices); - if (!didUpdate) return; + /* eslint-disable no-await-in-loop */ + let cart = await cartsCursor.next(); + while (cart) { + await updateOneCart(cart); - // Update the cart - const { result } = await Cart.updateOne({ - _id: cart._id - }, { - $set: { - items: updatedItems, - updatedAt: new Date() - } - }); - if (result.ok !== 1) { - Logger.warn(`MongoDB error trying to update cart ${cart._id} in "afterPublishProductToCatalog" listener. Check MongoDB logs.`); - return; + if (updatedCarts.length === SAVE_MANY_CARTS_LIMIT) { + await saveCarts(); } - // Emit "after update" - const updatedCart = await Cart.findOne({ _id: cart._id }); - appEvents.emit("afterCartUpdate", { - cart: updatedCart, - updatedBy: null - }, { emittedBy: AFTER_CATALOG_UPDATE_EMITTED_BY_NAME }); - })); + cart = await cartsCursor.next(); + } + /* eslint-enable no-await-in-loop */ + + // Flush remaining cart updates + await saveCarts(); return null; } @@ -80,7 +94,7 @@ async function updateAllCartsForVariant({ Cart, context, variant }) { * @param {Object} context.collections Map of MongoDB collections * @returns {undefined} */ -export default function startup(context) { +export default async function startup(context) { const { appEvents, collections } = context; const { Cart } = collections; @@ -95,15 +109,15 @@ export default function startup(context) { } }); - // When a variant's price changes, change the `price` and `subtotal` fields of all CartItems for that variant. - // When a variant's compare-at price changes, change the `compareAtPrice` field of all CartItems for that variant. + // Propagate any price changes to all corresponding cart items appEvents.on("afterPublishProductToCatalog", async ({ catalogProduct }) => { - const { variants } = catalogProduct; + const { _id: catalogProductId, variants } = catalogProduct; + + Logger.debug({ ...logCtx, catalogProductId, fn: "startup" }, "Running afterPublishProductToCatalog"); const variantsAndOptions = getFlatVariantsAndOptions(variants); - // Update all cart items that are linked with the updated variants - await Promise.all(variantsAndOptions.map(async (variant) => - updateAllCartsForVariant({ Cart, context, variant }))); + // Update all cart items that are linked with the updated variants. + await Promise.all(variantsAndOptions.map((variant) => updateAllCartsForVariant({ Cart, context, variant }))); }); } diff --git a/imports/plugins/core/cart/server/no-meteor/util/updateCartFulfillmentGroups.js b/imports/plugins/core/cart/server/no-meteor/util/updateCartFulfillmentGroups.js new file mode 100644 index 00000000000..120b9e8ba28 --- /dev/null +++ b/imports/plugins/core/cart/server/no-meteor/util/updateCartFulfillmentGroups.js @@ -0,0 +1,61 @@ +import Random from "@reactioncommerce/random"; + +/** + * @summary Figures out which fulfillment group a cart item should initially be in + * @param {Object[]} currentGroups The current cart fulfillment groups array + * @param {String[]} supportedFulfillmentTypes Array of fulfillment types supported by the item + * @param {String} shopId The ID of the shop that owns the item (product) + * @returns {Object|null} The group or null if no viable group + */ +function determineInitialGroupForItem(currentGroups, supportedFulfillmentTypes, shopId) { + const compatibleGroup = currentGroups.find((group) => supportedFulfillmentTypes.includes(group.type) && + shopId === group.shopId); + return compatibleGroup || null; +} + +/** + * @summary Updates the `shipping` property on a `cart` + * @param {Object} context App context + * @param {Object} cart The cart, to be mutated + * @returns {undefined} + */ +export default function updateCartFulfillmentGroups(context, cart) { + // Every time the cart is updated, create any missing fulfillment groups as necessary. + // We need one group per type per shop, containing only the items from that shop. + // Also make sure that every item is assigned to a fulfillment group. + const currentGroups = cart.shipping || []; + + (cart.items || []).forEach((item) => { + let { supportedFulfillmentTypes } = item; + if (!supportedFulfillmentTypes || supportedFulfillmentTypes.length === 0) { + supportedFulfillmentTypes = ["shipping"]; + } + + // Out of the current groups, returns the one that this item should be in by default, if it isn't + // already in a group + const group = determineInitialGroupForItem(currentGroups, supportedFulfillmentTypes, item.shopId); + + if (!group) { + // If no compatible group, add one with initially just this item in it + currentGroups.push({ + _id: Random.id(), + itemIds: [item._id], + shopId: item.shopId, + type: supportedFulfillmentTypes[0] + }); + } else if (!group.itemIds) { + // If there is a compatible group but it has no items array, add one with just this item in it + group.itemIds = [item._id]; + } else if (!group.itemIds.includes(item._id)) { + // If there is a compatible group with an items array but it is missing this item, add this item ID to the array + group.itemIds.push(item._id); + } + }); + + // Items may also have been removed. Need to remove their IDs from each group.itemIds + currentGroups.forEach((group) => { + group.itemIds = (group.itemIds || []).filter((itemId) => !!cart.items.find((item) => item._id === itemId)); + }); + + cart.shipping = currentGroups; +} diff --git a/imports/plugins/core/cart/server/no-meteor/util/updateCartItemsForVariantPriceChange.js b/imports/plugins/core/cart/server/no-meteor/util/updateCartItemsForVariantChanges.js similarity index 70% rename from imports/plugins/core/cart/server/no-meteor/util/updateCartItemsForVariantPriceChange.js rename to imports/plugins/core/cart/server/no-meteor/util/updateCartItemsForVariantChanges.js index e20965888b3..fabb14a7d6e 100644 --- a/imports/plugins/core/cart/server/no-meteor/util/updateCartItemsForVariantPriceChange.js +++ b/imports/plugins/core/cart/server/no-meteor/util/updateCartItemsForVariantChanges.js @@ -5,15 +5,27 @@ * the cart items were last updated. The return object will have a `didUpdate` * boolean property that you can check to see whether any changes were made. * @param {Object[]} items Cart items - * @param {String} variantId ID of variant to update items for + * @param {Object} catalogProductVariant The updated variant * @param {Object} prices Various updated price info for this variant * @returns {Object} { didUpdate, updatedItems } */ -export default function updateCartItemsForVariantPriceChange(items, variantId, prices) { +export default function updateCartItemsForVariantChanges(items, catalogProductVariant, prices) { let didUpdate = false; const updatedItems = items.map((item) => { - if (item.variantId !== variantId) return item; + if (item.variantId !== catalogProductVariant.variantId) return item; + + // If taxCode has changed + if (item.taxCode !== catalogProductVariant.taxCode) { + didUpdate = true; + item.taxCode = catalogProductVariant.taxCode; + } + + // If isTaxable has changed + if (item.isTaxable !== (catalogProductVariant.isTaxable || false)) { + didUpdate = true; + item.isTaxable = catalogProductVariant.isTaxable || false; + } // If price has changed if (item.price.amount !== prices.price) { diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalog.js b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalog.js index 08f805628e9..e129429cc5a 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalog.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalog.js @@ -16,6 +16,8 @@ export default async function publishProductToCatalog(product, context) { const { appEvents, collections } = context; const { Catalog, Products } = collections; + const startTime = Date.now(); + // Convert Product schema object to Catalog schema object const catalogProduct = await createCatalogProduct(product, context); @@ -68,10 +70,19 @@ export default async function publishProductToCatalog(product, context) { } const updatedProduct = { ...product, ...productUpdates }; - appEvents.emit("afterPublishProductToCatalog", { + + // For bulk publication, we need to await this so that we know when all of the + // things this triggers are done, and we can publish the next one. + await appEvents.emit("afterPublishProductToCatalog", { catalogProduct, product: updatedProduct }); + + Logger.debug({ + name: "cart", + ms: Date.now() - startTime, + productId: catalogProduct.productId + }, "publishProductToCatalog finished"); } return wasUpdateSuccessful; diff --git a/imports/plugins/core/discounts/server/no-meteor/register.js b/imports/plugins/core/discounts/server/no-meteor/register.js index bd67dd183c8..509760fe0d1 100644 --- a/imports/plugins/core/discounts/server/no-meteor/register.js +++ b/imports/plugins/core/discounts/server/no-meteor/register.js @@ -1,4 +1,4 @@ -import startup from "./startup"; +import setDiscountsOnCart from "./util/setDiscountsOnCart"; /** * @summary Import and call this function to add this plugin to your API. @@ -20,8 +20,14 @@ export default async function register(app) { ] } }, - functionsByType: { - startup: [startup] + cart: { + transforms: [ + { + name: "setDiscountsOnCart", + fn: setDiscountsOnCart, + priority: 10 + } + ] } }); } diff --git a/imports/plugins/core/discounts/server/no-meteor/startup.js b/imports/plugins/core/discounts/server/no-meteor/startup.js deleted file mode 100644 index bc961c39033..00000000000 --- a/imports/plugins/core/discounts/server/no-meteor/startup.js +++ /dev/null @@ -1,25 +0,0 @@ -import Logger from "@reactioncommerce/logger"; -import getDiscountsTotalForCart from "/imports/plugins/core/discounts/server/no-meteor/util/getDiscountsTotalForCart"; - -/** - * @summary Called on startup - * @param {Object} context Startup context - * @param {Object} context.collections Map of MongoDB collections - * @returns {undefined} - */ -export default function startup(context) { - const { appEvents, collections } = context; - const { Cart } = collections; - - appEvents.on("afterCartUpdate", async ({ cart }) => { - if (!cart) { - throw new Error("afterCartUpdate hook run with no cart argument"); - } - Logger.debug("Handling afterCartUpdate: discounts"); - - const { total: discount } = await getDiscountsTotalForCart(context, cart); - if (discount !== cart.discount) { - await Cart.update({ _id: cart._id }, { $set: { discount } }); - } - }); -} diff --git a/imports/plugins/core/discounts/server/no-meteor/util/setDiscountsOnCart.js b/imports/plugins/core/discounts/server/no-meteor/util/setDiscountsOnCart.js new file mode 100644 index 00000000000..5c4ee14dd73 --- /dev/null +++ b/imports/plugins/core/discounts/server/no-meteor/util/setDiscountsOnCart.js @@ -0,0 +1,12 @@ +import getDiscountsTotalForCart from "./getDiscountsTotalForCart"; + +/** + * @summary Cart transformation function that sets `discount` on cart + * @param {Object} context Startup context + * @param {Object} cart The cart, which can be mutated. + * @returns {undefined} + */ +export default async function setDiscountsOnCart(context, cart) { + const { total } = await getDiscountsTotalForCart(context, cart); + cart.discount = total; +} diff --git a/imports/plugins/core/graphql/server/no-meteor/xforms/cart.js b/imports/plugins/core/graphql/server/no-meteor/xforms/cart.js index 79eeefed4b2..000f2adf384 100644 --- a/imports/plugins/core/graphql/server/no-meteor/xforms/cart.js +++ b/imports/plugins/core/graphql/server/no-meteor/xforms/cart.js @@ -134,7 +134,11 @@ function xformCartFulfillmentGroup(fulfillmentGroup, cart) { fulfillmentTypes: ["shipping"] }, handlingPrice: { - amount: option.handling || 0, + amount: option.handlingPrice || 0, + currencyCode: cart.currencyCode + }, + shippingPrice: { + amount: option.shippingPrice || 0, currencyCode: cart.currencyCode }, price: { diff --git a/imports/plugins/core/orders/server/no-meteor/mutations/placeOrder.test.js b/imports/plugins/core/orders/server/no-meteor/mutations/placeOrder.test.js index fc816656090..b07ea520bd1 100644 --- a/imports/plugins/core/orders/server/no-meteor/mutations/placeOrder.test.js +++ b/imports/plugins/core/orders/server/no-meteor/mutations/placeOrder.test.js @@ -45,7 +45,8 @@ test("places an anonymous $0 order with no cartId and no payments", async () => mockContext.queries.getFulfillmentMethodsWithQuotes.mockReturnValueOnce([{ method: selectedFulfillmentMethod, handlingPrice: 0, - shippingPrice: 0 + shippingPrice: 0, + rate: 0 }]); mockContext.queries.shopById = jest.fn().mockName("shopById"); diff --git a/imports/plugins/core/orders/server/no-meteor/util/addShipmentMethodToGroup.js b/imports/plugins/core/orders/server/no-meteor/util/addShipmentMethodToGroup.js index d6d05c808c3..ffc4061dbc3 100644 --- a/imports/plugins/core/orders/server/no-meteor/util/addShipmentMethodToGroup.js +++ b/imports/plugins/core/orders/server/no-meteor/util/addShipmentMethodToGroup.js @@ -56,6 +56,6 @@ export default async function addShipmentMethodToGroup(context, { group: selectedFulfillmentMethod.method.group, name: selectedFulfillmentMethod.method.name, handling: selectedFulfillmentMethod.handlingPrice, - rate: selectedFulfillmentMethod.shippingPrice + rate: selectedFulfillmentMethod.rate }; } diff --git a/imports/plugins/core/shipping/server/no-meteor/mutations/selectFulfillmentOptionForGroup.js b/imports/plugins/core/shipping/server/no-meteor/mutations/selectFulfillmentOptionForGroup.js index 3503b495f2f..3c303c9ca07 100644 --- a/imports/plugins/core/shipping/server/no-meteor/mutations/selectFulfillmentOptionForGroup.js +++ b/imports/plugins/core/shipping/server/no-meteor/mutations/selectFulfillmentOptionForGroup.js @@ -28,8 +28,6 @@ export default async function selectFulfillmentOptionForGroup(context, input) { inputSchema.validate(cleanedInput); const { cartId, cartToken, fulfillmentGroupId, fulfillmentMethodId } = cleanedInput; - const { appEvents, collections, userId } = context; - const { Cart } = collections; const cart = await getCartById(context, cartId, { cartToken, throwIfNotFound: true }); @@ -40,22 +38,19 @@ export default async function selectFulfillmentOptionForGroup(context, input) { const option = (fulfillmentGroup.shipmentQuotes || []).find((quote) => quote.method._id === fulfillmentMethodId); if (!option) throw new ReactionError("not-found", `Fulfillment option with method ID ${fulfillmentMethodId} not found in cart with ID ${cartId}`); - const { matchedCount } = await Cart.updateOne({ - "_id": cartId, - "shipping._id": fulfillmentGroupId - }, { - $set: { - "shipping.$.shipmentMethod": option.method - } - }); - if (matchedCount !== 1) throw new ReactionError("server-error", "Unable to update cart"); + const updatedCart = { + ...cart, + shipping: cart.shipping.map((group) => { + if (group._id === fulfillmentGroupId) { + return { ...group, shipmentMethod: option.method }; + } - await appEvents.emit("afterCartUpdate", { - cart, - updatedBy: userId - }); + return group; + }), + updatedAt: new Date() + }; - const updatedCart = await Cart.findOne({ _id: cartId }); + const savedCart = await context.mutations.saveCart(context, updatedCart); - return { cart: updatedCart }; + return { cart: savedCart }; } diff --git a/imports/plugins/core/shipping/server/no-meteor/mutations/selectFulfillmentOptionForGroup.test.js b/imports/plugins/core/shipping/server/no-meteor/mutations/selectFulfillmentOptionForGroup.test.js index 95b8f1a7a08..ea000be8021 100644 --- a/imports/plugins/core/shipping/server/no-meteor/mutations/selectFulfillmentOptionForGroup.test.js +++ b/imports/plugins/core/shipping/server/no-meteor/mutations/selectFulfillmentOptionForGroup.test.js @@ -1,4 +1,3 @@ -import Factory from "/imports/test-utils/helpers/factory"; import mockContext from "/imports/test-utils/helpers/mockContext"; import selectFulfillmentOptionForGroup from "./selectFulfillmentOptionForGroup"; @@ -17,26 +16,36 @@ jest.mock("../util/getCartById", () => jest.fn().mockImplementation(() => Promis }] }))); -const fakeCart = Factory.Cart.makeOne(); +beforeAll(() => { + if (!mockContext.mutations.saveCart) { + mockContext.mutations.saveCart = jest.fn().mockName("context.mutations.saveCart").mockImplementation(async (_, cart) => cart); + } +}); test("selects an existing shipping method", async () => { - mockContext.collections.Cart.findOne.mockReturnValueOnce(Promise.resolve(fakeCart)); - const result = await selectFulfillmentOptionForGroup(mockContext, { cartId: "cartId", fulfillmentGroupId: "group1", fulfillmentMethodId: "valid-method" }); - expect(result).toEqual({ cart: fakeCart }); - - expect(mockContext.collections.Cart.updateOne).toHaveBeenCalledWith({ - "_id": "cartId", - "shipping._id": "group1" - }, { - $set: { - "shipping.$.shipmentMethod": { - _id: "valid-method" - } + expect(result).toEqual({ + cart: { + _id: "cartId", + shipping: [{ + _id: "group1", + itemIds: ["123"], + shipmentQuotes: [{ + rate: 0, + method: { + _id: "valid-method" + } + }], + type: "shipping", + shipmentMethod: { + _id: "valid-method" + } + }], + updatedAt: jasmine.any(Date) } }); }); diff --git a/imports/plugins/core/shipping/server/no-meteor/mutations/updateFulfillmentOptionsForGroup.js b/imports/plugins/core/shipping/server/no-meteor/mutations/updateFulfillmentOptionsForGroup.js index 649beeb48d8..b2ae6529f4f 100644 --- a/imports/plugins/core/shipping/server/no-meteor/mutations/updateFulfillmentOptionsForGroup.js +++ b/imports/plugins/core/shipping/server/no-meteor/mutations/updateFulfillmentOptionsForGroup.js @@ -2,7 +2,6 @@ import { isEqual } from "lodash"; import SimpleSchema from "simpl-schema"; import ReactionError from "@reactioncommerce/reaction-error"; import xformCartGroupToCommonOrder from "/imports/plugins/core/cart/server/no-meteor/util/xformCartGroupToCommonOrder"; - import getCartById from "../util/getCartById"; const inputSchema = new SimpleSchema({ @@ -66,17 +65,12 @@ export default async function updateFulfillmentOptionsForGroup(context, input) { inputSchema.validate(cleanedInput); const { cartId, cartToken, fulfillmentGroupId } = cleanedInput; - const { appEvents, collections, userId } = context; - const { Cart } = collections; const cart = await getCartById(context, cartId, { cartToken, throwIfNotFound: true }); const fulfillmentGroup = (cart.shipping || []).find((group) => group._id === fulfillmentGroupId); if (!fulfillmentGroup) throw new ReactionError("not-found", `Fulfillment group with ID ${fulfillmentGroupId} not found in cart with ID ${cartId}`); - // Map the items onto the fulfillment groups - fulfillmentGroup.items = fulfillmentGroup.itemIds.map((itemId) => cart.items.find((item) => item._id === itemId)); - const commonOrder = await xformCartGroupToCommonOrder(cart, fulfillmentGroup, context); // In the future we want to do this async and subscribe to the results @@ -85,24 +79,21 @@ export default async function updateFulfillmentOptionsForGroup(context, input) { const { shipmentQuotes, shipmentQuotesQueryStatus } = getShipmentQuotesQueryStatus(rates); if (!isEqual(shipmentQuotes, fulfillmentGroup.shipmentQuotes) || !isEqual(shipmentQuotesQueryStatus, fulfillmentGroup.shipmentQuotesQueryStatus)) { - const { matchedCount } = await Cart.updateOne({ - "_id": cartId, - "shipping._id": fulfillmentGroupId - }, { - $set: { - "shipping.$.shipmentQuotes": shipmentQuotes, - "shipping.$.shipmentQuotesQueryStatus": shipmentQuotesQueryStatus - } - }); - if (matchedCount !== 1) throw new ReactionError("server-error", "Unable to update cart"); + const updatedCart = { + ...cart, + shipping: cart.shipping.map((group) => { + if (group._id === fulfillmentGroupId) { + return { ...group, shipmentQuotes, shipmentQuotesQueryStatus }; + } + + return group; + }), + updatedAt: new Date() + }; - const updatedCart = await Cart.findOne({ _id: cartId }); - await appEvents.emit("afterCartUpdate", { - cart: updatedCart, - updatedBy: userId - }); + const savedCart = await context.mutations.saveCart(context, updatedCart); - return { cart: updatedCart }; + return { cart: savedCart }; } return { cart }; diff --git a/imports/plugins/core/shipping/server/no-meteor/mutations/updateFulfillmentOptionsForGroup.test.js b/imports/plugins/core/shipping/server/no-meteor/mutations/updateFulfillmentOptionsForGroup.test.js index ca91e23d20f..8c01589d05e 100644 --- a/imports/plugins/core/shipping/server/no-meteor/mutations/updateFulfillmentOptionsForGroup.test.js +++ b/imports/plugins/core/shipping/server/no-meteor/mutations/updateFulfillmentOptionsForGroup.test.js @@ -31,6 +31,9 @@ beforeAll(() => { mockContext.queries = { getFulfillmentMethodsWithQuotes: mockGetFulfillmentMethodsWithQuotes }; + if (!mockContext.mutations.saveCart) { + mockContext.mutations.saveCart = jest.fn().mockName("context.mutations.saveCart").mockImplementation(async (_, cart) => cart); + } }); beforeEach(() => { @@ -45,15 +48,30 @@ test("updates cart properly for empty rates", async () => { cartId: "cartId", fulfillmentGroupId: "group1" }); - expect(result).toEqual({ cart: fakeCart }); - expect(mockContext.collections.Cart.updateOne).toHaveBeenCalledWith({ - "_id": "cartId", - "shipping._id": "group1" - }, { - $set: { - "shipping.$.shipmentQuotes": [], - "shipping.$.shipmentQuotesQueryStatus": { requestStatus: "pending" } + expect(result).toEqual({ + cart: { + _id: "cartId", + items: [{ + _id: "123", + price: { + amount: 19.99 + }, + priceWhenAdded: { + amount: 19.99 + }, + subtotal: { + amount: 19.99 + } + }], + shipping: [{ + _id: "group1", + itemIds: ["123"], + type: "shipping", + shipmentQuotes: [], + shipmentQuotesQueryStatus: { requestStatus: "pending" } + }], + updatedAt: jasmine.any(Date) } }); }); @@ -64,25 +82,39 @@ test("updates cart properly for error rates", async () => { shippingProvider: "all", message: "All requests for shipping methods failed." }])); - mockContext.collections.Cart.findOne.mockReturnValueOnce(Promise.resolve(fakeCart)); const result = await updateFulfillmentOptionsForGroup(mockContext, { cartId: "cartId", fulfillmentGroupId: "group1" }); - expect(result).toEqual({ cart: fakeCart }); - expect(mockContext.collections.Cart.updateOne).toHaveBeenCalledWith({ - "_id": "cartId", - "shipping._id": "group1" - }, { - $set: { - "shipping.$.shipmentQuotes": [], - "shipping.$.shipmentQuotesQueryStatus": { - requestStatus: "error", - shippingProvider: "all", - message: "All requests for shipping methods failed." - } + expect(result).toEqual({ + cart: { + _id: "cartId", + items: [{ + _id: "123", + price: { + amount: 19.99 + }, + priceWhenAdded: { + amount: 19.99 + }, + subtotal: { + amount: 19.99 + } + }], + shipping: [{ + _id: "group1", + itemIds: ["123"], + type: "shipping", + shipmentQuotes: [], + shipmentQuotesQueryStatus: { + requestStatus: "error", + shippingProvider: "all", + message: "All requests for shipping methods failed." + } + }], + updatedAt: jasmine.any(Date) } }); }); @@ -95,18 +127,33 @@ test("updates cart properly for success rates", async () => { cartId: "cartId", fulfillmentGroupId: "group1" }); - expect(result).toEqual({ cart: fakeCart }); - expect(mockContext.collections.Cart.updateOne).toHaveBeenCalledWith({ - "_id": "cartId", - "shipping._id": "group1" - }, { - $set: { - "shipping.$.shipmentQuotes": [fakeQuote], - "shipping.$.shipmentQuotesQueryStatus": { - requestStatus: "success", - numOfShippingMethodsFound: 1 - } + expect(result).toEqual({ + cart: { + _id: "cartId", + items: [{ + _id: "123", + price: { + amount: 19.99 + }, + priceWhenAdded: { + amount: 19.99 + }, + subtotal: { + amount: 19.99 + } + }], + shipping: [{ + _id: "group1", + itemIds: ["123"], + type: "shipping", + shipmentQuotes: [fakeQuote], + shipmentQuotesQueryStatus: { + requestStatus: "success", + numOfShippingMethodsFound: 1 + } + }], + updatedAt: jasmine.any(Date) } }); }); diff --git a/imports/plugins/core/shipping/server/no-meteor/register.js b/imports/plugins/core/shipping/server/no-meteor/register.js index fd057fc8158..fe1c53013fc 100644 --- a/imports/plugins/core/shipping/server/no-meteor/register.js +++ b/imports/plugins/core/shipping/server/no-meteor/register.js @@ -2,7 +2,6 @@ import mutations from "./mutations"; import queries from "./queries"; import resolvers from "./resolvers"; import schemas from "./schemas"; -import startup from "./startup"; /** * @summary Import and call this function to add this plugin to your API. @@ -14,9 +13,6 @@ export default async function register(app) { label: "Shipping", name: "reaction-shipping", icon: "fa fa-truck", - functionsByType: { - startup: [startup] - }, graphQL: { resolvers, schemas diff --git a/imports/plugins/core/shipping/server/no-meteor/startup.js b/imports/plugins/core/shipping/server/no-meteor/startup.js deleted file mode 100644 index c6e2cbb2de9..00000000000 --- a/imports/plugins/core/shipping/server/no-meteor/startup.js +++ /dev/null @@ -1,90 +0,0 @@ -import Logger from "@reactioncommerce/logger"; -import Random from "@reactioncommerce/random"; -import ReactionError from "@reactioncommerce/reaction-error"; - -/** - * @summary Figures out which fulfillment group a cart item should initially be in - * @param {Object[]} currentGroups The current cart fulfillment groups array - * @param {String[]} supportedFulfillmentTypes Array of fulfillment types supported by the item - * @param {String} shopId The ID of the shop that owns the item (product) - * @returns {Object|null} The group or null if no viable group - */ -function determineInitialGroupForItem(currentGroups, supportedFulfillmentTypes, shopId) { - const compatibleGroup = currentGroups.find((group) => supportedFulfillmentTypes.includes(group.type) && - shopId === group.shopId); - return compatibleGroup || null; -} - -/** - * @summary Called on startup - * @param {Object} context Startup context - * @param {Object} context.appEvents App event emitter - * @param {Object} context.collections Map of MongoDB collections - * @returns {undefined} - */ -export default function startup({ appEvents, collections }) { - const { Cart } = collections; - - const handler = async ({ cart: updatedCart }) => { - if (!updatedCart) { - throw new Error("afterCartUpdate hook run with no cart argument"); - } - Logger.debug("Handling afterCartUpdate: shipping"); - - // Every time the cart is updated, create any missing fulfillment groups as necessary. - // We need one group per type per shop, containing only the items from that shop. - // Also make sure that every item is assigned to a fulfillment group. - const currentGroups = updatedCart.shipping || []; - - let didModifyGroups = false; - (updatedCart.items || []).forEach((item) => { - let { supportedFulfillmentTypes } = item; - if (!supportedFulfillmentTypes || supportedFulfillmentTypes.length === 0) { - supportedFulfillmentTypes = ["shipping"]; - } - - // Out of the current groups, returns the one that this item should be in by default, if it isn't - // already in a group - const group = determineInitialGroupForItem(currentGroups, supportedFulfillmentTypes, item.shopId); - - if (!group) { - // If no compatible group, add one with initially just this item in it - didModifyGroups = true; - currentGroups.push({ - _id: Random.id(), - itemIds: [item._id], - shopId: item.shopId, - type: supportedFulfillmentTypes[0] - }); - } else if (!group.itemIds) { - // If there is a compatible group but it has no items array, add one with just this item in it - didModifyGroups = true; - group.itemIds = [item._id]; - } else if (!group.itemIds.includes(item._id)) { - // If there is a compatible group with an items array but it is missing this item, add this item ID to the array - didModifyGroups = true; - group.itemIds.push(item._id); - } - }); - - // Items may also have been removed. Need to remove their IDs from each group.itemIds - currentGroups.forEach((group) => { - group.itemIds = (group.itemIds || []).filter((itemId) => !!updatedCart.items.find((item) => item._id === itemId)); - }); - - if (!didModifyGroups) return; - - const modifier = { - $set: { - shipping: currentGroups, - updatedAt: new Date() - } - }; - - const { modifiedCount } = await Cart.updateOne({ _id: updatedCart._id }, modifier); - if (modifiedCount === 0) throw new ReactionError("server-error", "Failed to update cart"); - }; - - appEvents.on("afterCartUpdate", handler); - appEvents.on("afterCartCreate", handler); -} diff --git a/imports/plugins/core/taxes/server/no-meteor/mutations/getFulfillmentGroupTaxes.js b/imports/plugins/core/taxes/server/no-meteor/mutations/getFulfillmentGroupTaxes.js index d8631eeb962..f011dbb4b26 100644 --- a/imports/plugins/core/taxes/server/no-meteor/mutations/getFulfillmentGroupTaxes.js +++ b/imports/plugins/core/taxes/server/no-meteor/mutations/getFulfillmentGroupTaxes.js @@ -1,6 +1,5 @@ import Logger from "@reactioncommerce/logger"; import ReactionError from "@reactioncommerce/reaction-error"; -import { CommonOrder } from "/imports/plugins/core/orders/server/no-meteor/simpleSchemas"; import { getTaxServicesForShop } from "../registration"; import { TaxServiceResult } from "../../../lib/simpleSchemas"; @@ -17,13 +16,6 @@ import { TaxServiceResult } from "../../../lib/simpleSchemas"; * and `taxes` properties on each array item. */ export default async function getFulfillmentGroupTaxes(context, { order, forceZeroes }) { - try { - CommonOrder.validate(order); - } catch (error) { - Logger.error("Invalid order input provided to getFulfillmentGroupTaxes", error); - throw new ReactionError("internal-error", "Error while calculating taxes"); - } - const { items, shopId } = order; const { primaryTaxService, fallbackTaxService } = await getTaxServicesForShop(context, shopId); diff --git a/imports/plugins/core/taxes/server/no-meteor/publishProductToCatalog.js b/imports/plugins/core/taxes/server/no-meteor/publishProductToCatalog.js index 3213fe6795b..5fb33cb0af7 100644 --- a/imports/plugins/core/taxes/server/no-meteor/publishProductToCatalog.js +++ b/imports/plugins/core/taxes/server/no-meteor/publishProductToCatalog.js @@ -7,26 +7,25 @@ export default function publishProductToCatalog(catalogProduct, { variants }) { catalogProduct.variants.forEach((catalogProductVariant) => { const unpublishedVariant = variants.find((variant) => variant._id === catalogProductVariant.variantId); - if (unpublishedVariant) { - catalogProductVariant.isTaxable = !!unpublishedVariant.isTaxable; - catalogProductVariant.taxCode = unpublishedVariant.taxCode; - catalogProductVariant.taxDescription = unpublishedVariant.taxDescription; - } + if (!unpublishedVariant) return; + + catalogProductVariant.isTaxable = !!unpublishedVariant.isTaxable; + catalogProductVariant.taxCode = unpublishedVariant.taxCode; + catalogProductVariant.taxDescription = unpublishedVariant.taxDescription; if (catalogProductVariant.options) { catalogProductVariant.options.forEach((catalogProductVariantOption) => { - // NOTE: This is how we would publish tax fields off the option variant, but currently the - // UI does not allow setting these on an option, only on its parent variant. So we will - // instead use the values from the parent. - // - // const unpublishedVariantOption = variants.find((variant) => variant._id === catalogProductVariantOption.variantId); - // if (unpublishedVariantOption) { - // catalogProductVariantOption.isTaxable = !!unpublishedVariantOption.isTaxable; - // catalogProductVariantOption.taxCode = unpublishedVariantOption.taxCode; - // catalogProductVariantOption.taxDescription = unpublishedVariantOption.taxDescription; - // } - - if (unpublishedVariant) { + const unpublishedVariantOption = variants.find((variant) => variant._id === catalogProductVariantOption.variantId); + if (unpublishedVariantOption) { + // For backward compatibility, we fall back to using the parent variant tax info if properties + // are undefined. + catalogProductVariantOption.isTaxable = unpublishedVariantOption.isTaxable === undefined + ? !!unpublishedVariant.isTaxable : unpublishedVariantOption.isTaxable; + catalogProductVariantOption.taxCode = typeof unpublishedVariantOption.taxCode === "string" + ? unpublishedVariantOption.taxCode : unpublishedVariant.taxCode; + catalogProductVariantOption.taxDescription = typeof unpublishedVariantOption.taxDescription === "string" + ? unpublishedVariantOption.taxDescription : unpublishedVariant.taxDescription; + } else { catalogProductVariantOption.isTaxable = !!unpublishedVariant.isTaxable; catalogProductVariantOption.taxCode = unpublishedVariant.taxCode; catalogProductVariantOption.taxDescription = unpublishedVariant.taxDescription; diff --git a/imports/plugins/core/taxes/server/no-meteor/register.js b/imports/plugins/core/taxes/server/no-meteor/register.js index a731834fa35..33fa1a37a09 100644 --- a/imports/plugins/core/taxes/server/no-meteor/register.js +++ b/imports/plugins/core/taxes/server/no-meteor/register.js @@ -6,7 +6,7 @@ import mutations from "./mutations"; import queries from "./queries"; import resolvers from "./resolvers"; import schemas from "./schemas"; -import startup from "./startup"; +import setTaxesOnCart from "./util/setTaxesOnCart"; /** * @summary Import and call this function to add this plugin to your API. @@ -18,6 +18,15 @@ export default async function register(app) { label: "Taxes", name: "reaction-taxes", icon: "fa fa-university", + cart: { + transforms: [ + { + name: "setTaxesOnCart", + fn: setTaxesOnCart, + priority: 30 + } + ] + }, catalog: { publishedProductVariantFields: ["isTaxable", "taxCode", "taxDescription"] }, @@ -25,8 +34,7 @@ export default async function register(app) { mutateNewOrderItemBeforeCreate: [mutateNewOrderItemBeforeCreate], mutateNewVariantBeforeCreate: [mutateNewVariantBeforeCreate], publishProductToCatalog: [publishProductToCatalog], - registerPluginHandler: [registerPluginHandler], - startup: [startup] + registerPluginHandler: [registerPluginHandler] }, graphQL: { schemas, diff --git a/imports/plugins/core/taxes/server/no-meteor/startup.js b/imports/plugins/core/taxes/server/no-meteor/startup.js deleted file mode 100644 index badeac20d5f..00000000000 --- a/imports/plugins/core/taxes/server/no-meteor/startup.js +++ /dev/null @@ -1,38 +0,0 @@ -import { isEqual } from "lodash"; -import Logger from "@reactioncommerce/logger"; -import getUpdatedCartItems from "./util/getUpdatedCartItems"; - -const EMITTED_BY_NAME = "TAXES_CORE_PLUGIN"; - -/** - * @summary Called on startup - * @param {Object} context Startup context - * @param {Object} context.collections Map of MongoDB collections - * @returns {undefined} - */ -export default function startup(context) { - const { appEvents, collections } = context; - const { Cart } = collections; - - // This entire hook is doing just one thing: Updating the tax-related props - // on each item in the cart, and saving those changes to the database if any of them - // have changed. - appEvents.on("afterCartUpdate", async ({ cart }, { emittedBy } = {}) => { - if (emittedBy === EMITTED_BY_NAME) return; // short circuit infinite loops - Logger.debug("Handling afterCartUpdate: taxes"); - - const { cartItems, taxSummary } = await getUpdatedCartItems(context, cart); - - if (isEqual(cartItems, cart.items) && isEqual(taxSummary, cart.taxSummary)) return; - - const { matchedCount } = await Cart.updateOne({ _id: cart._id }, { - $set: { - items: cartItems, - taxSummary - } - }); - if (matchedCount === 0) throw new Error("Unable to update cart"); - - appEvents.emit("afterCartUpdate", { cart: { ...cart, items: cartItems, taxSummary }, updatedBy: null }, { emittedBy: EMITTED_BY_NAME }); - }); -} diff --git a/imports/plugins/core/taxes/server/no-meteor/util/getUpdatedCartItems.js b/imports/plugins/core/taxes/server/no-meteor/util/getUpdatedCartItems.js index fb3dfcc503e..4ad85a25482 100644 --- a/imports/plugins/core/taxes/server/no-meteor/util/getUpdatedCartItems.js +++ b/imports/plugins/core/taxes/server/no-meteor/util/getUpdatedCartItems.js @@ -1,16 +1,13 @@ -import xformCartGroupToCommonOrder from "/imports/plugins/core/cart/server/no-meteor/util/xformCartGroupToCommonOrder"; - /** * @summary Returns `cart.items` with tax-related props updated on them * @param {Object} context App context * @param {Object} cart The cart + * @param {Object[]} commonOrders Array of CommonOrder objects corresponding to the cart groups * @returns {Object[]} Updated items array */ -export default async function getUpdatedCartItems(context, cart) { - const taxResultsByGroup = await Promise.all((cart.shipping || []).map(async (group) => { - const order = await xformCartGroupToCommonOrder(cart, group, context); - return context.mutations.getFulfillmentGroupTaxes(context, { order, forceZeroes: false }); - })); +export default async function getUpdatedCartItems(context, cart, commonOrders) { + const taxResultsByGroup = await Promise.all(commonOrders.map(async (order) => + context.mutations.getFulfillmentGroupTaxes(context, { order, forceZeroes: false }))); // Add tax properties to all items in the cart, if taxes were able to be calculated const cartItems = (cart.items || []).map((item) => { diff --git a/imports/plugins/core/taxes/server/no-meteor/util/getUpdatedCartItems.test.js b/imports/plugins/core/taxes/server/no-meteor/util/getUpdatedCartItems.test.js index 52e653e3094..40ca5922551 100644 --- a/imports/plugins/core/taxes/server/no-meteor/util/getUpdatedCartItems.test.js +++ b/imports/plugins/core/taxes/server/no-meteor/util/getUpdatedCartItems.test.js @@ -62,6 +62,90 @@ const itemTaxes = [ } ]; +const commonOrders = [ + { + billingAddress: null, + fulfillmentPrices: { + handling: null, + shipping: null, + total: null + }, + fulfillmentType: "shipping", + items: [ + { + _id: "1", + attributes: [], + isTaxable: true, + price: { + amount: 10.99 + }, + productId: "productId1", + productVendor: "productVendor", + quantity: 1, + shopId: "shopId1", + subtotal: { + amount: 10.99 + }, + taxCode: "123", + title: "Title", + variantId: "variantId1", + variantTitle: "Variant Title" + } + ], + orderId: null, + originAddress: null, + shippingAddress: { + fullName: "mockFullName", + firstName: "mockFirstName", + lastName: "mockLastName", + address1: "mockAddress1", + address2: "mockAddress2", + city: "mockCity", + company: "mockCompany", + phone: "mockPhone", + region: "mockRegion", + postal: "mockPostal", + country: "mockCountry", + isCommercial: false, + isBillingDefault: false, + isShippingDefault: false, + failedValidation: false, + metafields: [ + { + key: "mockKey", + namespace: "mockNamespace", + scope: "mockScope", + value: "mockValue", + valueType: "mockValueType", + description: "mockDescription" + } + ] + }, + shopId: "shopId1", + sourceType: "cart", + totals: { + groupDiscountTotal: { + amount: 0 + }, + groupItemTotal: { + amount: 10.99 + }, + groupTotal: { + amount: 10.99 + }, + orderDiscountTotal: { + amount: 0 + }, + orderItemTotal: { + amount: 10.99 + }, + orderTotal: { + amount: 10.99 + } + } + } +]; + if (!mockContext.mutations) mockContext.mutations = {}; mockContext.mutations.getFulfillmentGroupTaxes = jest.fn().mockName("getFulfillmentGroupTaxes"); @@ -80,7 +164,7 @@ test("mutates group.items and group.taxSummary", async () => { taxSummary })); - const { cartItems, taxSummary: taxSummaryResult } = await getUpdatedCartItems(mockContext, cart); + const { cartItems, taxSummary: taxSummaryResult } = await getUpdatedCartItems(mockContext, cart, commonOrders); expect(cartItems[0].tax).toBe(0.5); expect(cartItems[0].taxableAmount).toBe(10.99); @@ -116,7 +200,7 @@ test("customFields are properly saved", async () => { } })); - const { cartItems, taxSummary: taxSummaryResult } = await getUpdatedCartItems(mockContext, cart); + const { cartItems, taxSummary: taxSummaryResult } = await getUpdatedCartItems(mockContext, cart, commonOrders); expect(cartItems[0].taxes[0].customFields).toEqual({ foo: "bar3" }); expect(cartItems[0].customTaxFields).toEqual({ foo: "bar2" }); diff --git a/imports/plugins/core/taxes/server/no-meteor/util/setTaxesOnCart.js b/imports/plugins/core/taxes/server/no-meteor/util/setTaxesOnCart.js new file mode 100644 index 00000000000..c3d7ff7dfff --- /dev/null +++ b/imports/plugins/core/taxes/server/no-meteor/util/setTaxesOnCart.js @@ -0,0 +1,17 @@ +import getUpdatedCartItems from "./getUpdatedCartItems"; + +/** + * @summary Cart transformation function that sets tax-related props on cart + * @param {Object} context Startup context + * @param {Object} cart The cart, which can be mutated. + * @param {Object} options Options + * @param {Function} options.getCommonOrders Call this to get CommonOrder objects for all the cart groups + * @returns {undefined} + */ +export default async function setTaxesOnCart(context, cart, { getCommonOrders }) { + const commonOrders = await getCommonOrders(); + const { cartItems, taxSummary } = await getUpdatedCartItems(context, cart, commonOrders); + + cart.items = cartItems; + cart.taxSummary = taxSummary; +} diff --git a/imports/plugins/included/surcharges/server/no-meteor/getSurcharges.js b/imports/plugins/included/surcharges/server/no-meteor/getSurcharges.js index 4fa0e98dc86..3f070f906a4 100644 --- a/imports/plugins/included/surcharges/server/no-meteor/getSurcharges.js +++ b/imports/plugins/included/surcharges/server/no-meteor/getSurcharges.js @@ -6,22 +6,28 @@ import { surchargeCheck } from "./util/surchargeCheck"; /** * @summary Returns a list of surcharges to apply based on the cart. * @param {Object} context - Context - * @param {Object} cart - the user's cart + * @param {Object} input - Additional input + * @param {Object} input.commonOrder - CommonOrder * @returns {Array} - an array that surcharges to apply to cart / order * @private */ export default async function getSurcharges(context, { commonOrder }) { const { collections: { Surcharges } } = context; - const extendedCommonOrder = await extendCommonOrder(context, commonOrder); // Get surcharges from Mongo // Use forEach to use Mongos built in memory handling to not - // overload memory while fetching the entire colleciotn + // overload memory while fetching the entire collection const surcharges = []; - await Surcharges.find({ shopId: extendedCommonOrder.shopId }).forEach((surcharge) => { + await Surcharges.find({ shopId: commonOrder.shopId }).forEach((surcharge) => { surcharges.push(surcharge); }); + if (surcharges.length === 0) return []; + + // Keep this after the early exit since this hits the DB a bunch and isn't needed + // when there are no surcharges defined. + const extendedCommonOrder = await extendCommonOrder(context, commonOrder); + const allAppliedSurcharges = await surcharges.reduce(async (appliedSurcharges, surcharge) => { const awaitedAppliedSurcharges = await appliedSurcharges; @@ -32,7 +38,7 @@ export default async function getSurcharges(context, { commonOrder }) { // If it doesn't, this surcharge can apply to any fulfillmentMethod if (Array.isArray(methodIds) && methodIds.length > 0) { // If surcharge has methodIds attached to it, and fulfillmentMethodId is not yet set, - // don't apply any surchages at this time + // don't apply any surcharges at this time if (!fulfillmentMethodId) return awaitedAppliedSurcharges; // If surcharge has methodIds attached to it, and fulfillmentMethodId is set, @@ -52,16 +58,14 @@ export default async function getSurcharges(context, { commonOrder }) { }, Promise.resolve([])); // We don't need all data to be passed to Cart / Order - // Parse provided surcharge data to pass only relevent data to match Cart / Order schema - const appliedSurchargesFormattedForFulfillment = allAppliedSurcharges.map((surcharge) => ( - { - _id: Random.id(), - surchargeId: surcharge._id, - amount: surcharge.amount, - messagesByLanguage: surcharge.messagesByLanguage, - cartId: commonOrder.cartId - } - )); + // Parse provided surcharge data to pass only relevant data to match Cart / Order schema + const appliedSurchargesFormattedForFulfillment = allAppliedSurcharges.map((surcharge) => ({ + _id: Random.id(), + surchargeId: surcharge._id, + amount: surcharge.amount, + messagesByLanguage: surcharge.messagesByLanguage, + cartId: commonOrder.cartId + })); return appliedSurchargesFormattedForFulfillment; } diff --git a/imports/plugins/included/surcharges/server/no-meteor/register.js b/imports/plugins/included/surcharges/server/no-meteor/register.js index 0a3ce9db604..14bde896082 100644 --- a/imports/plugins/included/surcharges/server/no-meteor/register.js +++ b/imports/plugins/included/surcharges/server/no-meteor/register.js @@ -3,7 +3,7 @@ import mutations from "./mutations"; import queries from "./queries"; import resolvers from "./resolvers"; import schemas from "./schemas"; -import startup from "./startup"; +import setSurchargesOnCart from "./util/setSurchargesOnCart"; /** * @summary Import and call this function to add this plugin to your API. @@ -30,8 +30,16 @@ export default async function register(app) { mutations, queries, functionsByType: { - getSurcharges: [getSurcharges], - startup: [startup] + getSurcharges: [getSurcharges] + }, + cart: { + transforms: [ + { + name: "setSurchargesOnCart", + fn: setSurchargesOnCart, + priority: 20 + } + ] } }); } diff --git a/imports/plugins/included/surcharges/server/no-meteor/startup.js b/imports/plugins/included/surcharges/server/no-meteor/startup.js deleted file mode 100644 index 58fa60d7d47..00000000000 --- a/imports/plugins/included/surcharges/server/no-meteor/startup.js +++ /dev/null @@ -1,66 +0,0 @@ -import { isEqual } from "lodash"; -import Logger from "@reactioncommerce/logger"; -import xformCartGroupToCommonOrder from "/imports/plugins/core/cart/server/no-meteor/util/xformCartGroupToCommonOrder"; -import getSurcharges from "./getSurcharges"; - -const EMITTED_BY_NAME = "SURCHARGES_PLUGIN"; - -/** - * @summary Called on startup - * @param {Object} context Startup context - * @returns {undefined} - */ -export default function startup(context) { - const { appEvents, collections } = context; - const { Cart } = collections; - - // Update the cart to include surcharges, if applicable - appEvents.on("afterCartUpdate", async ({ cart }, { emittedBy } = {}) => { - if (emittedBy === EMITTED_BY_NAME) return; // short circuit infinite loops - Logger.debug("Handling afterCartUpdate: surcharges"); - - const { surcharges, shipping } = cart; - const cartSurcharges = []; - - let contextWithAccount = { ...context }; - - // Merge surcharges from each shipping group - if (cart.accountId && !context.account) { - const { Accounts } = context.collections; - const account = await Accounts.findOne({ _id: cart.accountId }); - contextWithAccount = { - ...context, - account, - accountId: cart.accountId - }; - } - - for (const shippingGroup of shipping) { - const commonOrder = await xformCartGroupToCommonOrder(cart, shippingGroup, context); // eslint-disable-line - const appliedSurcharges = await getSurcharges(contextWithAccount, { commonOrder }); // eslint-disable-line - - appliedSurcharges.forEach((appliedSurcharge) => { - // Push shippingGroup surcharges to cart surcharge array - cartSurcharges.push(appliedSurcharge); - }); - } - - // To avoid infinite looping among various `afterCartUpdate` handlers that also - // update cart and emit a subsequent `afterCartUpdate`, we need to be sure we - // do not do the update or emit the event unless we truly need to update something. - const previousSurcharges = (surcharges || []).map((appliedSurcharge) => ({ ...appliedSurcharge, _id: null })); - const nextSurcharges = cartSurcharges.map((appliedSurcharge) => ({ ...appliedSurcharge, _id: null })); - if (isEqual(previousSurcharges, nextSurcharges)) return; - - const { value: updatedCart } = await Cart.findOneAndUpdate({ _id: cart._id }, { - $set: { - surcharges: cartSurcharges - } - }, { - // Default behavior is to return the original. We want the updated. - returnOriginal: false - }); - - appEvents.emit("afterCartUpdate", { cart: updatedCart, updatedBy: null }, { emittedBy: EMITTED_BY_NAME }); - }); -} diff --git a/imports/plugins/included/surcharges/server/no-meteor/util/setSurchargesOnCart.js b/imports/plugins/included/surcharges/server/no-meteor/util/setSurchargesOnCart.js new file mode 100644 index 00000000000..5bbf5002dfc --- /dev/null +++ b/imports/plugins/included/surcharges/server/no-meteor/util/setSurchargesOnCart.js @@ -0,0 +1,36 @@ +import getSurcharges from "../getSurcharges"; + +/** + * @summary Cart transformation function that sets surcharges on cart + * @param {Object} context Startup context + * @param {Object} cart The cart, which can be mutated. + * @param {Object} options Options + * @param {Function} options.getCommonOrders Call this to get CommonOrder objects for all the cart groups + * @returns {undefined} + */ +export default async function setSurchargesOnCart(context, cart, { getCommonOrders }) { + // This is a workaround for now because surcharge calculations sometimes need an account ID. + // We eventually need to add accountId to CommonOrder instead. + let contextWithAccount = { ...context }; + if (cart.accountId && !context.account) { + const { Accounts } = context.collections; + const account = await Accounts.findOne({ _id: cart.accountId }); + contextWithAccount = { + ...context, + account, + accountId: cart.accountId + }; + } + + // Merge surcharges from each shipping group + const cartSurcharges = []; + const commonOrders = await getCommonOrders(); + for (const commonOrder of commonOrders) { + const appliedSurcharges = await getSurcharges(contextWithAccount, { commonOrder }); // eslint-disable-line + + // Push shippingGroup surcharges to cart surcharge array + cartSurcharges.push(...appliedSurcharges); + } + + cart.surcharges = cartSurcharges; +} diff --git a/imports/utils/forEachPromise.js b/imports/utils/forEachPromise.js new file mode 100644 index 00000000000..62e5adf7716 --- /dev/null +++ b/imports/utils/forEachPromise.js @@ -0,0 +1,15 @@ +/** + * From https://stackoverflow.com/a/41791149/1669674 + * + * @param {Any[]} items An array of items to pass one by one to `fn`. + * @param {Function} fn A function that accepts an item from the array and returns a promise. + * @returns {Promise} Promise that resolves with the value returned by the last function call + */ +export default function forEachPromise(items, fn) { + return items.reduce( + (promise, item) => promise + .then(() => fn(item)) + .catch((error) => { throw error; }), + Promise.resolve() + ); +}