From 822bbdc300bdda02465eb95bc1a3d9ad09458e89 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Thu, 20 Oct 2022 15:22:11 +0700 Subject: [PATCH 01/19] feat: add the promotion-discounts plugin --- apps/reaction/package.json | 1 + apps/reaction/plugins.json | 5 +- .../addCartItems/addCartItems.test.js | 12 +- .../anonymousCartByCartId.test.js | 12 +- packages/api-plugin-carts/src/index.js | 7 +- .../api-plugin-carts/src/simpleSchemas.js | 4 +- packages/api-plugin-orders/src/index.js | 6 +- .../api-plugin-orders/src/simpleSchemas.js | 2 +- .../api-plugin-promotions-discounts/LICENSE | 201 ++++++++++++++++++ .../api-plugin-promotions-discounts/README.md | 26 +++ .../babel.config.cjs | 1 + .../api-plugin-promotions-discounts/index.js | 3 + .../jest.config.cjs | 1 + .../package.json | 46 ++++ .../src/actions/discountAction.js | 97 +++++++++ .../src/actions/discountAction.test.js | 76 +++++++ .../src/actions/index.js | 3 + .../src/enhancers/index.js | 3 + .../src/enhancers/resetCartDiscountState.js | 23 ++ .../enhancers/resetCartDiscountState.test.js | 45 ++++ .../src/index.js | 60 ++++++ .../src/methods/index.js | 35 +++ .../src/preStartup.js | 168 +++++++++++++++ .../src/registration.js | 12 ++ .../src/simpleSchemas.js | 124 +++++++++++ .../src/util/calculateMerchandiseTotal.js | 12 ++ .../util/calculateMerchandiseTotal.test.js | 22 ++ .../item/addDiscountToOrderItem.js | 32 +++ .../item/applyItemDiscountToCart.js | 100 +++++++++ .../item/applyItemDiscountToCart.test.js | 180 ++++++++++++++++ .../item/calculateDiscountedItemPrice.js | 21 ++ .../item/calculateDiscountedItemPrice.test.js | 22 ++ .../item/getItemDiscountTotal.js | 15 ++ .../item/getItemDiscountTotal.test.js | 42 ++++ .../item/recalculateCartItemSubtotal.js | 27 +++ .../item/recalculateCartItemSubtotal.test.js | 80 +++++++ .../order/applyOrderDiscountToCart.js | 66 ++++++ .../order/applyOrderDiscountToCart.test.js | 114 ++++++++++ .../order/getCartDiscountAmount.js | 16 ++ .../order/getCartDiscountAmount.test.js | 41 ++++ .../order/getCartDiscountTotal.js | 21 ++ .../order/getCartDiscountTotal.test.js | 41 ++++ .../order/splitDiscountForCartItems.js | 17 ++ .../order/splitDiscountForCartItems.test.js | 45 ++++ .../shipping/applyDiscountsToRates.js | 19 ++ .../shipping/applyShippingDiscountToCart.js | 82 +++++++ .../shipping/evaluateRulesAgainstShipping.js | 68 ++++++ .../shipping/getGroupDisountTotal.js | 11 + .../shipping/getShippingDiscountTotal.js | 17 ++ .../src/util/setDiscountsOnCart.js | 25 +++ .../src/util/setDiscountsOnCart.test.js | 54 +++++ .../src/xforms/recalculateDiscounts.js | 17 ++ .../src/triggers/offerTriggerHandler.js | 4 +- .../src/handlers/applyPromotions.js | 14 +- .../src/handlers/applyPromotions.test.js | 26 ++- pnpm-lock.yaml | 24 +++ 56 files changed, 2224 insertions(+), 24 deletions(-) create mode 100644 packages/api-plugin-promotions-discounts/LICENSE create mode 100644 packages/api-plugin-promotions-discounts/README.md create mode 100644 packages/api-plugin-promotions-discounts/babel.config.cjs create mode 100644 packages/api-plugin-promotions-discounts/index.js create mode 100644 packages/api-plugin-promotions-discounts/jest.config.cjs create mode 100644 packages/api-plugin-promotions-discounts/package.json create mode 100644 packages/api-plugin-promotions-discounts/src/actions/discountAction.js create mode 100644 packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js create mode 100644 packages/api-plugin-promotions-discounts/src/actions/index.js create mode 100644 packages/api-plugin-promotions-discounts/src/enhancers/index.js create mode 100644 packages/api-plugin-promotions-discounts/src/enhancers/resetCartDiscountState.js create mode 100644 packages/api-plugin-promotions-discounts/src/enhancers/resetCartDiscountState.test.js create mode 100644 packages/api-plugin-promotions-discounts/src/index.js create mode 100644 packages/api-plugin-promotions-discounts/src/methods/index.js create mode 100644 packages/api-plugin-promotions-discounts/src/preStartup.js create mode 100644 packages/api-plugin-promotions-discounts/src/registration.js create mode 100644 packages/api-plugin-promotions-discounts/src/simpleSchemas.js create mode 100644 packages/api-plugin-promotions-discounts/src/util/calculateMerchandiseTotal.js create mode 100644 packages/api-plugin-promotions-discounts/src/util/calculateMerchandiseTotal.test.js create mode 100644 packages/api-plugin-promotions-discounts/src/util/discountTypes/item/addDiscountToOrderItem.js create mode 100644 packages/api-plugin-promotions-discounts/src/util/discountTypes/item/applyItemDiscountToCart.js create mode 100644 packages/api-plugin-promotions-discounts/src/util/discountTypes/item/applyItemDiscountToCart.test.js create mode 100644 packages/api-plugin-promotions-discounts/src/util/discountTypes/item/calculateDiscountedItemPrice.js create mode 100644 packages/api-plugin-promotions-discounts/src/util/discountTypes/item/calculateDiscountedItemPrice.test.js create mode 100644 packages/api-plugin-promotions-discounts/src/util/discountTypes/item/getItemDiscountTotal.js create mode 100644 packages/api-plugin-promotions-discounts/src/util/discountTypes/item/getItemDiscountTotal.test.js create mode 100644 packages/api-plugin-promotions-discounts/src/util/discountTypes/item/recalculateCartItemSubtotal.js create mode 100644 packages/api-plugin-promotions-discounts/src/util/discountTypes/item/recalculateCartItemSubtotal.test.js create mode 100644 packages/api-plugin-promotions-discounts/src/util/discountTypes/order/applyOrderDiscountToCart.js create mode 100644 packages/api-plugin-promotions-discounts/src/util/discountTypes/order/applyOrderDiscountToCart.test.js create mode 100644 packages/api-plugin-promotions-discounts/src/util/discountTypes/order/getCartDiscountAmount.js create mode 100644 packages/api-plugin-promotions-discounts/src/util/discountTypes/order/getCartDiscountAmount.test.js create mode 100644 packages/api-plugin-promotions-discounts/src/util/discountTypes/order/getCartDiscountTotal.js create mode 100644 packages/api-plugin-promotions-discounts/src/util/discountTypes/order/getCartDiscountTotal.test.js create mode 100644 packages/api-plugin-promotions-discounts/src/util/discountTypes/order/splitDiscountForCartItems.js create mode 100644 packages/api-plugin-promotions-discounts/src/util/discountTypes/order/splitDiscountForCartItems.test.js create mode 100644 packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/applyDiscountsToRates.js create mode 100644 packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/applyShippingDiscountToCart.js create mode 100644 packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/evaluateRulesAgainstShipping.js create mode 100644 packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/getGroupDisountTotal.js create mode 100644 packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/getShippingDiscountTotal.js create mode 100644 packages/api-plugin-promotions-discounts/src/util/setDiscountsOnCart.js create mode 100644 packages/api-plugin-promotions-discounts/src/util/setDiscountsOnCart.test.js create mode 100644 packages/api-plugin-promotions-discounts/src/xforms/recalculateDiscounts.js diff --git a/apps/reaction/package.json b/apps/reaction/package.json index d814bc55e90..3b996551b77 100644 --- a/apps/reaction/package.json +++ b/apps/reaction/package.json @@ -48,6 +48,7 @@ "@reactioncommerce/api-plugin-products": "1.3.1", "@reactioncommerce/api-plugin-promotions": "1.0.0", "@reactioncommerce/api-plugin-promotions-coupons": "1.0.0", + "@reactioncommerce/api-plugin-promotions-discounts": "1.0.0", "@reactioncommerce/api-plugin-promotions-offers": "1.0.0", "@reactioncommerce/api-plugin-settings": "1.0.7", "@reactioncommerce/api-plugin-shipments": "1.0.3", diff --git a/apps/reaction/plugins.json b/apps/reaction/plugins.json index 4953f0a0a82..cd191a21ba8 100644 --- a/apps/reaction/plugins.json +++ b/apps/reaction/plugins.json @@ -37,6 +37,7 @@ "notifications": "@reactioncommerce/api-plugin-notifications", "addressValidationTest": "@reactioncommerce/api-plugin-address-validation-test", "promotions": "@reactioncommerce/api-plugin-promotions", - "promotionsOffers": "@reactioncommerce/api-plugin-promotions-offers", - "promotionsCoupons": "@reactioncommerce/api-plugin-promotions-coupons" + "promotionsCoupons": "@reactioncommerce/api-plugin-promotions-coupons", + "promotionsDiscounts": "@reactioncommerce/api-plugin-promotions-discounts", + "promotionsOffers": "@reactioncommerce/api-plugin-promotions-offers" } diff --git a/apps/reaction/tests/integration/api/mutations/addCartItems/addCartItems.test.js b/apps/reaction/tests/integration/api/mutations/addCartItems/addCartItems.test.js index 78efd1ca93e..a2b6202b008 100644 --- a/apps/reaction/tests/integration/api/mutations/addCartItems/addCartItems.test.js +++ b/apps/reaction/tests/integration/api/mutations/addCartItems/addCartItems.test.js @@ -79,7 +79,17 @@ beforeAll(async () => { anonymousAccessToken: hashToken(cartToken), shipping: null, items: [], - workflow: null + workflow: null, + discounts: [ + { + actionKey: "mockActionKey", + promotionId: "mockPromotionId", + rules: { conditions: {}, event: { type: "mockType", params: {} } }, + discountCalculationType: "fixed", + discountValue: 25124, + dateApplied: new Date() + } + ] }); opaqueCartId = encodeOpaqueId("reaction/cart", mockCart._id); await testApp.collections.Cart.insertOne(mockCart); diff --git a/apps/reaction/tests/integration/api/queries/anonymousCartByCartId/anonymousCartByCartId.test.js b/apps/reaction/tests/integration/api/queries/anonymousCartByCartId/anonymousCartByCartId.test.js index 8bb3341bbd3..b53363d5beb 100644 --- a/apps/reaction/tests/integration/api/queries/anonymousCartByCartId/anonymousCartByCartId.test.js +++ b/apps/reaction/tests/integration/api/queries/anonymousCartByCartId/anonymousCartByCartId.test.js @@ -39,7 +39,17 @@ beforeAll(async () => { anonymousAccessToken: hashToken(cartToken), shipping: null, items: [], - workflow: null + workflow: null, + discounts: [ + { + actionKey: "mockActionKey", + promotionId: "mockPromotionId", + rules: { conditions: {}, event: { type: "mockType", params: {} } }, + discountCalculationType: "fixed", + discountValue: 25124, + dateApplied: new Date() + } + ] }); opaqueCartId = encodeOpaqueId("reaction/cart", mockCart._id); diff --git a/packages/api-plugin-carts/src/index.js b/packages/api-plugin-carts/src/index.js index ad2064899d7..0b39b87c09a 100644 --- a/packages/api-plugin-carts/src/index.js +++ b/packages/api-plugin-carts/src/index.js @@ -5,7 +5,7 @@ import queries from "./queries/index.js"; import resolvers from "./resolvers/index.js"; import schemas from "./schemas/index.js"; import { registerPluginHandlerForCart } from "./registration.js"; -import { Cart, CartItem } from "./simpleSchemas.js"; +import { Cart, CartItem, Shipment, ShipmentQuote, ShippingMethod } from "./simpleSchemas.js"; import startup from "./startup.js"; /** @@ -59,7 +59,10 @@ export default async function register(app) { policies, simpleSchemas: { Cart, - CartItem + CartItem, + Shipment, + ShippingMethod, + ShipmentQuote } }); } diff --git a/packages/api-plugin-carts/src/simpleSchemas.js b/packages/api-plugin-carts/src/simpleSchemas.js index 329b05b67a7..062a19a1620 100644 --- a/packages/api-plugin-carts/src/simpleSchemas.js +++ b/packages/api-plugin-carts/src/simpleSchemas.js @@ -211,7 +211,7 @@ const ShippoShippingMethod = new SimpleSchema({ * @property {String} carrier optional * @property {ShippoShippingMethod} settings optional */ -const ShippingMethod = new SimpleSchema({ +export const ShippingMethod = new SimpleSchema({ "_id": { type: String, label: "Shipment Method Id" @@ -532,7 +532,7 @@ export const CartInvoice = new SimpleSchema({ * @property {String} customsLabelUrl For customs printable label * @property {ShippoShipment} shippo For Shippo specific properties */ -const Shipment = new SimpleSchema({ +export const Shipment = new SimpleSchema({ "_id": { type: String, label: "Shipment Id" diff --git a/packages/api-plugin-orders/src/index.js b/packages/api-plugin-orders/src/index.js index 9db9d5bb39a..67ebd7cbdb3 100644 --- a/packages/api-plugin-orders/src/index.js +++ b/packages/api-plugin-orders/src/index.js @@ -6,7 +6,7 @@ import preStartup from "./preStartup.js"; import queries from "./queries/index.js"; import resolvers from "./resolvers/index.js"; import schemas from "./schemas/index.js"; -import { Order, OrderFulfillmentGroup, OrderItem } from "./simpleSchemas.js"; +import { Order, OrderFulfillmentGroup, OrderItem, CommonOrder, SelectedFulfillmentOption } from "./simpleSchemas.js"; import startup from "./startup.js"; import getDataForOrderEmail from "./util/getDataForOrderEmail.js"; @@ -56,7 +56,9 @@ export default async function register(app) { simpleSchemas: { Order, OrderFulfillmentGroup, - OrderItem + OrderItem, + CommonOrder, + SelectedFulfillmentOption } }); } diff --git a/packages/api-plugin-orders/src/simpleSchemas.js b/packages/api-plugin-orders/src/simpleSchemas.js index 43767547862..9c776a1a1d2 100644 --- a/packages/api-plugin-orders/src/simpleSchemas.js +++ b/packages/api-plugin-orders/src/simpleSchemas.js @@ -773,7 +773,7 @@ export const OrderItem = new SimpleSchema({ * @property {String} name Method name * @property {Number} rate Rate */ -const SelectedFulfillmentOption = new SimpleSchema({ +export const SelectedFulfillmentOption = new SimpleSchema({ _id: String, carrier: { type: String, diff --git a/packages/api-plugin-promotions-discounts/LICENSE b/packages/api-plugin-promotions-discounts/LICENSE new file mode 100644 index 00000000000..261eeb9e9f8 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/api-plugin-promotions-discounts/README.md b/packages/api-plugin-promotions-discounts/README.md new file mode 100644 index 00000000000..9bf6679c483 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/README.md @@ -0,0 +1,26 @@ +# api-plugin-promotions-discounts + +[![npm (scoped)](https://img.shields.io/npm/v/@reactioncommerce/api-plugin-promotions-discounts.svg)](https://www.npmjs.com/package/@reactioncommerce/api-plugin-promotions-discounts) +[![CircleCI](https://circleci.com/gh/reactioncommerce/api-plugin-promotions-discounts.svg?style=svg)](https://circleci.com/gh/reactioncommerce/api-plugin-promotions-discounts) +[![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) + +## Summary + +Discounts plugin for the [Reaction API](https://github.com/reactioncommerce/reaction) + +## Developer Certificate of Origin +We use the [Developer Certificate of Origin (DCO)](https://developercertificate.org/) in lieu of a Contributor License Agreement for all contributions to Reaction Commerce open source projects. We request that contributors agree to the terms of the DCO and indicate that agreement by signing all commits made to Reaction Commerce projects by adding a line with your name and email address to every Git commit message contributed: +``` +Signed-off-by: Jane Doe +``` + +You can sign your commit automatically with Git by using `git commit -s` if you have your `user.name` and `user.email` set as part of your Git configuration. + +We ask that you use your real name (please no anonymous contributions or pseudonyms). By signing your commit you are certifying that you have the right have the right to submit it under the open source license used by that particular Reaction Commerce project. You must use your real name (no pseudonyms or anonymous contributions are allowed.) + +We use the [Probot DCO GitHub app](https://github.com/apps/dco) to check for DCO signoffs of every commit. + +If you forget to sign your commits, the DCO bot will remind you and give you detailed instructions for how to amend your commits to add a signature. + +## License +This Reaction plugin is [GNU GPLv3 Licensed](./LICENSE.md) diff --git a/packages/api-plugin-promotions-discounts/babel.config.cjs b/packages/api-plugin-promotions-discounts/babel.config.cjs new file mode 100644 index 00000000000..5fa924c0809 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/babel.config.cjs @@ -0,0 +1 @@ +module.exports = require("@reactioncommerce/api-utils/lib/configs/babel.config.cjs"); diff --git a/packages/api-plugin-promotions-discounts/index.js b/packages/api-plugin-promotions-discounts/index.js new file mode 100644 index 00000000000..d7ea8b28c59 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/index.js @@ -0,0 +1,3 @@ +import register from "./src/index.js"; + +export default register; diff --git a/packages/api-plugin-promotions-discounts/jest.config.cjs b/packages/api-plugin-promotions-discounts/jest.config.cjs new file mode 100644 index 00000000000..2bdefefceb9 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/jest.config.cjs @@ -0,0 +1 @@ +module.exports = require("@reactioncommerce/api-utils/lib/configs/jest.config.cjs"); diff --git a/packages/api-plugin-promotions-discounts/package.json b/packages/api-plugin-promotions-discounts/package.json new file mode 100644 index 00000000000..c6e4e2a9f53 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/package.json @@ -0,0 +1,46 @@ +{ + "name": "@reactioncommerce/api-plugin-promotions-discounts", + "description": "Discounts plugin for the Reaction API", + "version": "1.0.0", + "main": "index.js", + "type": "module", + "engines": { + "node": ">=14.18.1", + "npm": ">=7" + }, + "homepage": "https://github.com/reactioncommerce/reaction", + "url": "https://github.com/reactioncommerce/reaction", + "email": "engineering@reactioncommerce.com", + "repository": { + "type": "git", + "url": "git+https://github.com/reactioncommerce/reaction.git", + "directory": "packages/api-plugin-promotions-discounts" + }, + "author": { + "name": "Reaction Commerce", + "email": "engineering@reactioncommerce.com", + "url": "https://reactioncommerce.com" + }, + "license": "GPL-3.0", + "bugs": { + "url": "https://github.com/reactioncommerce/reaction/issues" + }, + "sideEffects": false, + "dependencies": { + "@reactioncommerce/api-utils": "^1.16.7", + "@reactioncommerce/logger": "^1.1.3", + "@reactioncommerce/random": "^1.0.2", + "accounting-js": "^1.1.1", + "deep-object-diff": "^1.1.7", + "json-rules-engine": "^6.1.2", + "simpl-schema": "^1.12.3" + }, + "scripts": { + "lint": "npm run lint:eslint", + "lint:eslint": "eslint .", + "test": "jest --passWithNoTests", + "test:watch": "jest --watch", + "test:file": "jest --no-cache --watch --coverage=false" + } + +} diff --git a/packages/api-plugin-promotions-discounts/src/actions/discountAction.js b/packages/api-plugin-promotions-discounts/src/actions/discountAction.js new file mode 100644 index 00000000000..17d7ba5e47a --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/actions/discountAction.js @@ -0,0 +1,97 @@ +import { createRequire } from "module"; +import SimpleSchema from "simpl-schema"; +import Logger from "@reactioncommerce/logger"; +import applyItemDiscountToCart from "../util/discountTypes/item/applyItemDiscountToCart.js"; +import applyShippingDiscountToCart from "../util/discountTypes/shipping/applyShippingDiscountToCart.js"; +import applyOrderDiscountToCart from "../util/discountTypes/order/applyOrderDiscountToCart.js"; + +const require = createRequire(import.meta.url); + +const pkg = require("../../package.json"); + +const { name, version } = pkg; +const logCtx = { + name, + version, + file: "actions/discountAction.js" +}; + +const functionMap = { + item: applyItemDiscountToCart, + shipping: applyShippingDiscountToCart, + order: applyOrderDiscountToCart +}; + +const Conditions = new SimpleSchema({ + maxUses: { + // total number of uses + type: Number, + defaultValue: 1 + }, + maxUsesPerAccount: { + // Max uses per account + type: SimpleSchema.Integer, + defaultValue: 1, + optional: true + }, + maxUsersPerOrder: { + // Max uses per order + type: Number, + defaultValue: 1 + } +}); + +export const Rules = new SimpleSchema({ + conditions: { + type: Object, + blackbox: true + } +}); + +export const discountActionParameters = new SimpleSchema({ + discountType: { + type: String, + allowedValues: ["item", "order", "shipping"] + }, + discountCalculationType: { + type: String, + allowedValues: ["flat", "fixed", "percentage"] + }, + discountValue: { + type: Number + }, + condition: { + type: Conditions + }, + rules: { + type: Rules, + optional: true + } +}); +/** + * @summary Apply a percentage promotion to the cart + * @param {Object} context - The application context + * @param {Object} cart - The enhanced cart to apply promotions to + * @param {Object} params.promotion - The promotion to apply + * @param {Object} params.actionParameters - The parameters to pass to the action + * @returns {Promise} undefined + */ +export async function discountActionHandler(context, cart, { promotion, actionParameters }) { + const { discountType } = actionParameters; + + actionParameters.promotionId = promotion._id; + actionParameters.actionKey = "discounts"; + + Logger.info({ actionParameters, cartId: cart._id, ...logCtx }, "applying discount to cart"); + + const { cart: updatedCart } = await functionMap[discountType](context, actionParameters, cart); + + Logger.info(logCtx, "Completed applying Discount to Cart"); + return updatedCart; +} + +export default { + key: "discounts", + handler: discountActionHandler, + paramSchema: discountActionParameters +}; diff --git a/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js b/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js new file mode 100644 index 00000000000..fda0d4776fa --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js @@ -0,0 +1,76 @@ +import applyItemDiscountToCart from "../util/discountTypes/item/applyItemDiscountToCart.js"; +import applyOrderDiscountToCart from "../util/discountTypes/order/applyOrderDiscountToCart.js"; +import applyShippingDiscountToCart from "../util/discountTypes/shipping/applyShippingDiscountToCart.js"; +import discountAction, { discountActionHandler, discountActionParameters } from "./discountAction.js"; + +jest.mock("../util/discountTypes/item/applyItemDiscountToCart.js", () => jest.fn()); +jest.mock("../util/discountTypes/order/applyOrderDiscountToCart.js", () => jest.fn()); +jest.mock("../util/discountTypes/shipping/applyShippingDiscountToCart.js", () => jest.fn()); + +beforeEach(() => jest.resetAllMocks()); + +test("discountAction should be a object", () => { + expect(discountAction).toEqual({ + key: "discounts", + handler: discountActionHandler, + paramSchema: discountActionParameters + }); +}); + +test("should call discount item function when discountType parameters is item", () => { + const context = {}; + const cart = {}; + const params = { + promotion: {}, + actionParameters: { + discountType: "item" + } + }; + discountAction.handler(context, cart, params); + expect(applyItemDiscountToCart).toHaveBeenCalledWith(context, params.actionParameters, cart); +}); + +test("should call discount order function when discountType parameters is order", () => { + const context = {}; + const cart = {}; + const params = { + promotion: {}, + actionParameters: { + discountType: "order" + } + }; + discountAction.handler(context, cart, params); + expect(applyOrderDiscountToCart).toHaveBeenCalledWith(context, params.actionParameters, cart); +}); + +test("should call discount shipping function when discountType parameters is shipping", () => { + const context = {}; + const cart = {}; + const params = { + promotion: {}, + actionParameters: { + discountType: "shipping" + } + }; + discountAction.handler(context, cart, params); + expect(applyShippingDiscountToCart).toHaveBeenCalledWith(context, params.actionParameters, cart); +}); + +test("should return updatedCart when action is completed", async () => { + const modifiedCart = { + _id: "modifiedCartId" + }; + applyItemDiscountToCart.mockResolvedValueOnce({ + cart: modifiedCart + }); + const context = {}; + const cart = {}; + const params = { + promotion: {}, + actionParameters: { + discountType: "item" + } + }; + const updatedCart = await discountAction.handler(context, cart, params); + expect(updatedCart).toEqual(modifiedCart); +}); diff --git a/packages/api-plugin-promotions-discounts/src/actions/index.js b/packages/api-plugin-promotions-discounts/src/actions/index.js new file mode 100644 index 00000000000..d60c47204a2 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/actions/index.js @@ -0,0 +1,3 @@ +import discountAction from "./discountAction.js"; + +export default [discountAction]; diff --git a/packages/api-plugin-promotions-discounts/src/enhancers/index.js b/packages/api-plugin-promotions-discounts/src/enhancers/index.js new file mode 100644 index 00000000000..826f18473d1 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/enhancers/index.js @@ -0,0 +1,3 @@ +import resetCartDiscountState from "./resetCartDiscountState.js"; + +export default [resetCartDiscountState]; diff --git a/packages/api-plugin-promotions-discounts/src/enhancers/resetCartDiscountState.js b/packages/api-plugin-promotions-discounts/src/enhancers/resetCartDiscountState.js new file mode 100644 index 00000000000..a4092f80312 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/enhancers/resetCartDiscountState.js @@ -0,0 +1,23 @@ +/** + * @summary Reset the cart discount state + * @param {Object} context - The application context + * @param {Object} cart - The cart to reset + * @returns {Object} - The cart with the discount state reset + */ +export default function resetCartDiscountState(context, cart) { + cart.discounts = []; + cart.discount = 0; + cart.items = cart.items.map((item) => { + item.discounts = []; + item.subtotal = { + amount: item.price.amount * item.quantity, + currencyCode: item.subtotal.currencyCode + }; + return item; + }); + + // todo: add reset logic for the shipping + // cart.shipping = cart.shipping.map((shipping) => ({ ...shipping, discounts: [] })); + + return cart; +} diff --git a/packages/api-plugin-promotions-discounts/src/enhancers/resetCartDiscountState.test.js b/packages/api-plugin-promotions-discounts/src/enhancers/resetCartDiscountState.test.js new file mode 100644 index 00000000000..3a5e0d63cb1 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/enhancers/resetCartDiscountState.test.js @@ -0,0 +1,45 @@ +import resetCartDiscountState from "./resetCartDiscountState.js"; + +test("should reset the cart discount state", () => { + const cart = { + discounts: [{ _id: "discount1" }], + discount: 10, + items: [ + { + _id: "item1", + discounts: [{ _id: "discount1" }], + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + } + } + ] + }; + + const updatedCart = resetCartDiscountState({}, cart); + + expect(updatedCart).toEqual({ + discounts: [], + discount: 0, + items: [ + { + _id: "item1", + discounts: [], + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 12, + currencyCode: "USD" + } + } + ] + }); +}); diff --git a/packages/api-plugin-promotions-discounts/src/index.js b/packages/api-plugin-promotions-discounts/src/index.js new file mode 100644 index 00000000000..ef8bc7616db --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/index.js @@ -0,0 +1,60 @@ +import { createRequire } from "module"; +import setDiscountsOnCart from "./util/setDiscountsOnCart.js"; +import actions from "./actions/index.js"; +import methods from "./methods/index.js"; +import enhancers from "./enhancers/index.js"; +import addDiscountToOrderItem from "./util/discountTypes/item/addDiscountToOrderItem.js"; +import getCartDiscountTotal from "./util/discountTypes/order/getCartDiscountTotal.js"; +import getItemDiscountTotal from "./util/discountTypes/item/getItemDiscountTotal.js"; +import getShippingDiscountTotal from "./util/discountTypes/shipping/getShippingDiscountTotal.js"; +import getGroupDiscountTotal from "./util/discountTypes/shipping/getGroupDisountTotal.js"; +import applyDiscountsToRates from "./util/discountTypes/shipping/applyDiscountsToRates.js"; +import preStartup from "./preStartup.js"; +import recalculateDiscounts from "./xforms/recalculateDiscounts.js"; +import { discountCalculationMethods, registerDiscountCalculationMethod } from "./registration.js"; + +const require = createRequire(import.meta.url); +const pkg = require("../package.json"); + +/** + * @summary Import and call this function to add this plugin to your API. + * @param {Object} app The ReactionAPI instance + * @returns {undefined} + */ +export default async function register(app) { + await app.registerPlugin({ + label: "Promotions-Discounts", + name: pkg.name, + version: pkg.version, + functionsByType: { + registerPluginHandler: [registerDiscountCalculationMethod], + preStartup: [preStartup], + mutateNewOrderItemBeforeCreate: [addDiscountToOrderItem], + calculateDiscountTotal: [getCartDiscountTotal, getItemDiscountTotal, getShippingDiscountTotal], + getGroupDiscounts: [getGroupDiscountTotal], + applyDiscountsToRates: [applyDiscountsToRates] + }, + cart: { + transforms: [ + { + name: "setDiscountsOnCart", + fn: setDiscountsOnCart, + priority: 10 + }, + { + name: "recalculateDiscounts", + fn: recalculateDiscounts, + priority: 10 + } + ] + }, + contextAdditions: { + discountCalculationMethods + }, + promotions: { + actions, + enhancers + }, + discountCalculationMethods: methods + }); +} diff --git a/packages/api-plugin-promotions-discounts/src/methods/index.js b/packages/api-plugin-promotions-discounts/src/methods/index.js new file mode 100644 index 00000000000..8f65411f852 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/methods/index.js @@ -0,0 +1,35 @@ +/** + * @summary Calculates the discount amount for the percentage discount type + * @param {Number} discountValue - The discount value + * @param {Number} price - The price to calculate the discount for + * @returns {Number} The discount amount + */ +function percentage(discountValue, price) { + return price * (discountValue / 100); +} + +/** + * @summary Calculates the discount amount for the fixed discount type + * @param {Number} discountValue - The discount value + * @returns {Number} The discount amount + */ +function flat(discountValue) { + return discountValue; +} + +/** + * @summary Calculates the discount amount for the fixed discount type + * @param {Number} discountValue - The discount value + * @param {Number} price - The price to calculate the discount for + * @returns {Number} The discount amount + */ +function fixed(discountValue, price) { + const amountToDiscount = Math.abs(discountValue - price); + return amountToDiscount; +} + +export default { + percentage, + flat, + fixed +}; diff --git a/packages/api-plugin-promotions-discounts/src/preStartup.js b/packages/api-plugin-promotions-discounts/src/preStartup.js new file mode 100644 index 00000000000..10bc19d0ad1 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/preStartup.js @@ -0,0 +1,168 @@ +import SimpleSchema from "simpl-schema"; +import { CartDiscount } from "./simpleSchemas.js"; + +const discountSchema = new SimpleSchema({ + // this is here for backwards compatibility with old discounts + discount: { + type: Number, + label: "Legacy Discount", + optional: true, + defaultValue: 0 + }, + undiscountedAmount: { + type: Number, + label: "UnDiscounted Order Amount", + optional: true + } +}); + +/** + * @summary extend cart schemas with discount info + * @param {Object} context - Application context + * @returns {Promise} undefined + */ +async function extendCartSchemas(context) { + const { simpleSchemas: { Cart, CartItem, Shipment, ShippingMethod, ShipmentQuote } } = context; + Cart.extend(discountSchema); + Cart.extend({ + "discounts": { + type: Array, + defaultValue: [], + optional: true + }, + "discounts.$": { + type: CartDiscount + } + }); + + CartItem.extend({ + "discounts": { + type: Array, + defaultValue: [], + optional: true + }, + "discounts.$": { + type: CartDiscount + }, + "subtotal.undiscountedAmount": { + type: Number, + optional: true + }, + "subtotal.discount": { + type: Number, + optional: true + } + }); + + Shipment.extend({ + "discounts": { + type: Array, + defaultValue: [], + optional: true + }, + "discounts.$": { + type: CartDiscount + } + }); + + ShippingMethod.extend({ + undiscountedRate: { + type: Number, + optional: true + } + }); + + ShipmentQuote.extend({ + undiscountedRate: { + type: Number, + optional: true + } + }); +} + +/** + * @summary extend order schemas with discount info + * @param {Object} context - Application context + * @returns {Promise} undefined + */ +async function extendOrderSchemas(context) { + const { simpleSchemas: { Order, OrderFulfillmentGroup, OrderItem, CommonOrder, SelectedFulfillmentOption } } = context; + Order.extend({ + // this is here for backwards compatibility with old discounts + discount: { + type: Number, + label: "Legacy Discount", + optional: true + }, + undiscountedAmount: { + type: Number, + label: "UnDiscounted Amount", + optional: true + } + }); + Order.extend({ + "discounts": { + type: Array, + label: "Order Discounts", + optional: true + }, + "discounts.$": { + type: CartDiscount, + label: "Order Discount" + } + }); + OrderItem.extend({ + "discounts": { + type: Array, + label: "Item Discounts", + optional: true + }, + "discounts.$": { + type: CartDiscount, + label: "Item Discount" + }, + "undiscountedAmount": { + type: Number, + optional: true + } + }); + + CommonOrder.extend({ + "discounts": { + type: Array, + label: "Common Order Discounts", + optional: true + }, + "discounts.$": { + type: CartDiscount, + label: "Common Order Discount" + } + }); + + OrderFulfillmentGroup.extend({ + "discounts": { + type: Array, + optional: true + }, + "discounts.$": { + type: CartDiscount + } + }); + + SelectedFulfillmentOption.extend({ + undiscountedRate: { + type: Number, + optional: true + } + }); +} + +/** + * @summary Pre-startup function for api-plugin-promotions-discounts + * @param {Object} context - Startup context + * @returns {Promise} undefined + */ +export default async function preStartupDiscounts(context) { + await extendCartSchemas(context); + await extendOrderSchemas(context); +} diff --git a/packages/api-plugin-promotions-discounts/src/registration.js b/packages/api-plugin-promotions-discounts/src/registration.js new file mode 100644 index 00000000000..ea41ddd0b1a --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/registration.js @@ -0,0 +1,12 @@ +export const discountCalculationMethods = {}; + +/** + * @summary register the discount calculation methods + * @param {Array} params.discountCalculationMethods - The discount calculation methods to register + * @return {void} undefined + */ +export function registerDiscountCalculationMethod({ discountCalculationMethods: methods }) { + if (methods) { + Object.assign(discountCalculationMethods, methods); + } +} diff --git a/packages/api-plugin-promotions-discounts/src/simpleSchemas.js b/packages/api-plugin-promotions-discounts/src/simpleSchemas.js new file mode 100644 index 00000000000..a2755ea658c --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/simpleSchemas.js @@ -0,0 +1,124 @@ +import SimpleSchema from "simpl-schema"; + +const Conditions = new SimpleSchema({ + maxUses: { + // total number of uses + type: Number, + defaultValue: 1 + }, + maxUsesPerAccount: { + // Max uses per account + type: SimpleSchema.Integer, + defaultValue: 1, + optional: true + }, + maxUsersPerOrder: { + // Max uses per order + type: Number, + defaultValue: 1 + } +}); + +const Event = new SimpleSchema({ + type: String, + params: { + type: Object, + optional: true + } +}); + +export const Rules = new SimpleSchema({ + conditions: { + type: Object, + blackbox: true + }, + event: { + type: Event + } +}); + +/** + * @name Discounts + * @memberof Schemas + * @type {SimpleSchema} + * @summary Discounts schema + */ +export const Discount = new SimpleSchema({ + _id: { + type: String, + optional: true + }, + shopId: { + type: String, + label: "Discounts shopId" + }, + label: { + type: String + }, + description: { + type: String + }, + discountType: { + type: String, + allowedValues: ["item", "order", "shipping"] + }, + discountCalculationType: { + type: String, + allowedValues: ["flat", "fixed", "percentage"] // this can be extended via plugin + }, + discountValue: { + type: Number + }, + inclusionRules: { + type: Rules + }, + exclusionRules: { + type: Rules, + optional: true + }, + conditions: { + type: Conditions, + optional: true + } +}); + +export const CartDiscountedItem = new SimpleSchema({ + _id: String, + amount: Number +}); + +export const CartDiscount = new SimpleSchema({ + "actionKey": String, + "promotionId": String, + "rules": { + // because shipping discounts are evaluated later, they need to have inclusion rules on them + type: Rules, + optional: true + }, + "discountType": String, + "discountCalculationType": String, // types provided by this plugin are flat, percentage and fixed + "discountValue": Number, + "dateApplied": { + type: Date + }, + "dateExpires": { + type: Date, + optional: true + }, + "discountedItemType": { + type: String, + allowedValues: ["order", "item", "shipping"], + optional: true + }, + "discountedAmount": { + type: Number, + optional: true + }, + "discountedItems": { + type: Array, + optional: true + }, + "discountedItems.$": { + type: CartDiscountedItem + } +}); diff --git a/packages/api-plugin-promotions-discounts/src/util/calculateMerchandiseTotal.js b/packages/api-plugin-promotions-discounts/src/util/calculateMerchandiseTotal.js new file mode 100644 index 00000000000..0cc1732b9cb --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/util/calculateMerchandiseTotal.js @@ -0,0 +1,12 @@ +/** + * @summary Calculate the total discount amount for an order + * @param {Object} cart - The cart to calculate the discount for + * @returns {Number} The total discount amount + */ +export function calculateMerchandiseTotal(cart) { + const itemsTotal = cart.items.reduce( + (previousValue, currentValue) => previousValue + currentValue.price.amount * currentValue.quantity, + 0 + ); + return itemsTotal; +} diff --git a/packages/api-plugin-promotions-discounts/src/util/calculateMerchandiseTotal.test.js b/packages/api-plugin-promotions-discounts/src/util/calculateMerchandiseTotal.test.js new file mode 100644 index 00000000000..d3ed341a175 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/util/calculateMerchandiseTotal.test.js @@ -0,0 +1,22 @@ +import { calculateMerchandiseTotal } from "./calculateMerchandiseTotal.js"; + +test("calculates the merchandise total for a cart", () => { + const cart = { + items: [ + { + price: { + amount: 10 + }, + quantity: 1 + }, + { + price: { + amount: 20 + }, + quantity: 2 + } + ] + }; + + expect(calculateMerchandiseTotal(cart)).toEqual(50); +}); diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/addDiscountToOrderItem.js b/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/addDiscountToOrderItem.js new file mode 100644 index 00000000000..16bf85f75c3 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/addDiscountToOrderItem.js @@ -0,0 +1,32 @@ +import calculateDiscountedItemPrice from "./calculateDiscountedItemPrice.js"; + +/** + * @summary recalculate item subtotal based on discounts + * @param {Object} context - The application context + * @param {Object} item - The item from the cart + * @param {Object} cartItem - The cart item + * @return {Object} - The mutated cart item + */ +export default function addDiscountToOrderItem(context, { item, cartItem }) { + if (typeof item.subtotal === "object") { + if (!item.subtotal.undiscountedAmount) { + item.subtotal.undiscountedAmount = item.subtotal.amount; + const itemTotal = calculateDiscountedItemPrice(context, { + price: item.price.amount, + quantity: item.quantity, + discounts: cartItem ? cartItem.discounts : [] + }); + item.subtotal.amount = itemTotal; + } + } else { + item.undiscountedAmount = item.subtotal || 0; + const itemTotal = calculateDiscountedItemPrice(context, { + price: item.price.amount, + quantity: item.quantity, + discounts: cartItem ? cartItem.discounts : [] + }); + item.subtotal = itemTotal; + } + item.discounts = cartItem ? cartItem.discounts : []; + return item; +} diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/applyItemDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/applyItemDiscountToCart.js new file mode 100644 index 00000000000..b507035cdd9 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/applyItemDiscountToCart.js @@ -0,0 +1,100 @@ +import { createRequire } from "module"; +import { Engine } from "json-rules-engine"; +import Logger from "@reactioncommerce/logger"; + +const require = createRequire(import.meta.url); + +const pkg = require("../../../../package.json"); + +const { name, version } = pkg; +const logCtx = { + name, + version, + file: "util/applyItemDiscountToCart.js" +}; + +/** + * @summary Create a discount object for a cart item + * @param {Object} item - The cart item + * @param {Object} discount - The discount to create + * @param {Number} discountedAmount - The amount discounted + * @returns {Object} - The cart item discount object + */ +export function createItemDiscount(item, discount, discountedAmount) { + const itemDiscount = { + actionKey: discount.actionKey, + promotionId: discount.promotionId, + discountType: discount.discountType, + discountCalculationType: discount.discountCalculationType, + discountValue: discount.discountValue, + dateApplied: new Date(), + discountedAmount + }; + return itemDiscount; +} + +/** + * @summary Add the discount to the cart item + * @param {Object} context - The application context + * @param {Object} discount - The discount to apply + * @param {Object} params.item - The cart item to apply the discount to + * @returns {Promise} undefined + */ +export async function addDiscountToItem(context, discount, { item }) { + const existingDiscount = item.discounts + .find((itemDiscount) => discount.actionKey === itemDiscount.actionKey && discount.promotionId === itemDiscount.promotionId); + if (existingDiscount) { + Logger.warn(logCtx, "Not adding discount because it already exists"); + return; + } + const cartDiscount = createItemDiscount(item, discount); + item.discounts.push(cartDiscount); +} + +/** + * @summary Apply the discount to the cart + * @param {Object} context - The application context + * @param {Object} discountParameters - The discount parameters + * @param {Object} cart - The cart to apply the discount to + * @returns {Promise} - The updated cart with results + */ +export default async function applyItemDiscountToCart(context, discountParameters, cart) { + const allResults = []; + const discountedItems = []; + const { promotions: { operators } } = context; + if (discountParameters.rules) { + const engine = new Engine(); + engine.addRule({ + ...discountParameters.rules, + event: { + type: "rulesCheckPassed" + } + }); + Object.keys(operators).forEach((operatorKey) => { + engine.addOperator(operatorKey, operators[operatorKey]); + }); + + for (const item of cart.items) { + // eslint-disable-next-line no-unused-vars + engine.on("success", (event, almanac, ruleResult) => { + discountedItems.push(item); + addDiscountToItem(context, discountParameters, { item }); + }); + const facts = { item }; + // eslint-disable-next-line no-await-in-loop + const results = await engine.run(facts); + allResults.push(results); + } + } else { + for (const item of cart.items) { + discountedItems.push(item); + addDiscountToItem(context, discountParameters, { item }); + } + } + + if (discountedItems.length) { + Logger.info(logCtx, "Saved Discount to cart"); + } + + return { cart, allResults, discountedItems }; +} diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/applyItemDiscountToCart.test.js b/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/applyItemDiscountToCart.test.js new file mode 100644 index 00000000000..05721fd9d3e --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/applyItemDiscountToCart.test.js @@ -0,0 +1,180 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import * as applyItemDiscountToCart from "./applyItemDiscountToCart.js"; + +test("createItemDiscount should return correct discount item object", () => { + const item = { + _id: "item1", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + } + }; + + const discount = { + actionKey: "test", + promotionId: "promotion1", + discountType: "test", + discountCalculationType: "test", + discountValue: 10 + }; + + const discountedAmount = 2; + + const itemDiscount = applyItemDiscountToCart.createItemDiscount(item, discount, discountedAmount); + + expect(itemDiscount).toEqual({ + actionKey: "test", + promotionId: "promotion1", + discountType: "test", + discountCalculationType: "test", + discountValue: 10, + dateApplied: expect.any(Date), + discountedAmount: 2 + }); +}); + +test("addDiscountToItem should add discount to item", () => { + const discount = { + actionKey: "test", + promotionId: "promotion1", + discountType: "test", + discountCalculationType: "test", + discountValue: 10 + }; + + jest.spyOn(applyItemDiscountToCart, "createItemDiscount").mockReturnValue(discount); + + const item = { + _id: "item1", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + }, + discounts: [] + }; + + const discountedAmount = 2; + + const itemDiscount = applyItemDiscountToCart.createItemDiscount(item, discount, discountedAmount); + + applyItemDiscountToCart.addDiscountToItem({}, discount, { item }); + + expect(item.discounts).toEqual([ + { + ...itemDiscount, + dateApplied: expect.any(Date) + } + ]); +}); + +test("should return cart with applied discount when parameters not include rule", async () => { + const item = { + _id: "item1", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + }, + discounts: [] + }; + + const cart = { + _id: "cart1", + items: [item] + }; + + const discountParameters = { + actionKey: "test", + promotionId: "promotion1", + discountType: "test", + discountCalculationType: "test", + discountValue: 10 + }; + + jest.spyOn(applyItemDiscountToCart, "addDiscountToItem").mockImplementation(() => {}); + + mockContext.promotions = { + operators: {} + }; + + const result = await applyItemDiscountToCart.default(mockContext, discountParameters, cart); + + expect(result).toEqual({ + cart, + allResults: [], + discountedItems: [item] + }); +}); + +test("should return cart with applied discount when parameters include rule", async () => { + const item = { + _id: "item1", + price: { + amount: 12 + }, + quantity: 2, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + }, + discounts: [] + }; + + const cart = { + _id: "cart1", + items: [item] + }; + + const discountParameters = { + actionKey: "test", + promotionId: "promotion1", + discountType: "test", + discountCalculationType: "test", + discountValue: 10, + rules: { + conditions: { + any: [ + { + fact: "item", + path: "$.quantity", + operator: "greaterThanInclusive", + value: 1 + } + ] + } + } + }; + + jest.spyOn(applyItemDiscountToCart, "addDiscountToItem").mockImplementation(() => {}); + + mockContext.promotions = { + operators: {} + }; + + const result = await applyItemDiscountToCart.default(mockContext, discountParameters, cart); + + expect(result).toEqual({ + cart, + allResults: expect.any(Object), + discountedItems: [item] + }); +}); diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/calculateDiscountedItemPrice.js b/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/calculateDiscountedItemPrice.js new file mode 100644 index 00000000000..41a3b1e0761 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/calculateDiscountedItemPrice.js @@ -0,0 +1,21 @@ +/** + * @summary Calculates the discounted price for an item + * @param {*} context - The application context + * @param {*} params.price - The price to calculate the discount for + * @param {*} params.quantity - The quantity of the item + * @param {*} params.discounts - The discounts to calculate + * @returns {Number} The discounted price + */ +export default function calculateDiscountedItemPrice(context, { price, quantity, discounts }) { + let totalDiscount = 0; + const amountBeforeDiscounts = price * quantity; + discounts.forEach((discount) => { + const calculationMethod = context.discountCalculationMethods[discount.discountCalculationType]; + const discountAmount = calculationMethod(discount.discountValue, amountBeforeDiscounts); + totalDiscount += discountAmount; + }); + if (totalDiscount < amountBeforeDiscounts) { + return amountBeforeDiscounts - totalDiscount; + } + return 0; +} diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/calculateDiscountedItemPrice.test.js b/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/calculateDiscountedItemPrice.test.js new file mode 100644 index 00000000000..32b1e483514 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/calculateDiscountedItemPrice.test.js @@ -0,0 +1,22 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import calculateDiscountedItemPrice from "./calculateDiscountedItemPrice.js"; + +test("should calculate discounted item price", () => { + const price = 10; + const quantity = 5; + const discounts = [ + { + discountCalculationType: "fixed", + discountValue: 15 + } + ]; + + mockContext.discountCalculationMethods = { + fixed: jest.fn().mockReturnValue(15) + }; + + const discountedPrice = calculateDiscountedItemPrice(mockContext, { price, quantity, discounts }); + + expect(mockContext.discountCalculationMethods.fixed).toHaveBeenCalledWith(15, 50); + expect(discountedPrice).toEqual(35); +}); diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/getItemDiscountTotal.js b/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/getItemDiscountTotal.js new file mode 100644 index 00000000000..6a3332a3e26 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/getItemDiscountTotal.js @@ -0,0 +1,15 @@ +/** + * @summary Get the total discount amount for a single item + * @param {Number} context - The application context + * @param {Number} cart - The cart to calculate the discount for + * @returns {Number} The total discount amount + */ +export default function getItemDiscountTotal(context, cart) { + let totalItemDiscount = 0; + for (const item of cart.items) { + const originalPrice = item.quantity * item.price.amount; + const actualPrice = item.subtotal.amount; + totalItemDiscount += (originalPrice - actualPrice); + } + return totalItemDiscount; +} diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/getItemDiscountTotal.test.js b/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/getItemDiscountTotal.test.js new file mode 100644 index 00000000000..72fe1fe4f6b --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/getItemDiscountTotal.test.js @@ -0,0 +1,42 @@ +import getItemDiscountTotal from "./getItemDiscountTotal.js"; + +test("getItemDiscountTotal returns the total discount amount for all cart items", () => { + const cart = { + _id: "cart1", + items: [ + { + _id: "item1", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + }, + discounts: [] + }, + { + _id: "item2", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + }, + discounts: [] + } + ] + }; + + const context = {}; + const totalItemDiscount = getItemDiscountTotal(context, cart); + + expect(totalItemDiscount).toEqual(4); +}); diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/recalculateCartItemSubtotal.js b/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/recalculateCartItemSubtotal.js new file mode 100644 index 00000000000..25bc84cb59f --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/recalculateCartItemSubtotal.js @@ -0,0 +1,27 @@ +import accounting from "accounting-js"; + +/** + * @summary Recalculate the item subtotal + * @param {Object} context - The application context + * @param {Object} item - The cart item + * @returns {void} undefined + */ +export default function recalculateCartItemSubtotal(context, item) { + let totalDiscount = 0; + const undiscountedAmount = item.price.amount * item.quantity; + + item.discounts.forEach((discount) => { + const { discountedAmount, discountCalculationType, discountValue, discountType } = discount; + const calculationMethod = context.discountCalculationMethods[discountCalculationType]; + const discountAmount = + discountType === "order" + ? discountedAmount + : Number(accounting.toFixed(calculationMethod(discountValue, undiscountedAmount), 2)); + + totalDiscount += discountAmount; + discount.discountedAmount = discountAmount; + }); + item.subtotal.amount = Number(accounting.toFixed(undiscountedAmount - totalDiscount, 2)); + item.subtotal.discount = totalDiscount; + item.subtotal.undiscountedAmount = undiscountedAmount; +} diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/recalculateCartItemSubtotal.test.js b/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/recalculateCartItemSubtotal.test.js new file mode 100644 index 00000000000..945837b76f3 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/recalculateCartItemSubtotal.test.js @@ -0,0 +1,80 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import recalculateCartItemSubtotal from "./recalculateCartItemSubtotal.js"; + +test("should recalculate the item subtotal with discountType is item", () => { + const item = { + _id: "item1", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + }, + discounts: [] + }; + + const discount = { + actionKey: "test", + promotionId: "promotion1", + discountType: "item", + discountCalculationType: "fixed", + discountValue: 10, + discountedAmount: 2 + }; + + item.discounts.push(discount); + + mockContext.discountCalculationMethods = { + fixed: jest.fn().mockReturnValue(2) + }; + + recalculateCartItemSubtotal(mockContext, item); + + expect(item.subtotal).toEqual({ + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + }); +}); + +test("should recalculate the item subtotal with discountType is order", () => { + const item = { + _id: "item1", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + }, + discounts: [] + }; + + const discount = { + actionKey: "test", + promotionId: "promotion1", + discountType: "order", + discountCalculationType: "fixed", + discountValue: 10, + discountedAmount: 5 + }; + + item.discounts.push(discount); + + recalculateCartItemSubtotal(mockContext, item); + + expect(item.subtotal).toEqual({ + amount: 7, + currencyCode: "USD", + discount: 5, + undiscountedAmount: 12 + }); +}); diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/applyOrderDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/applyOrderDiscountToCart.js new file mode 100644 index 00000000000..f9a8e882d15 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/applyOrderDiscountToCart.js @@ -0,0 +1,66 @@ +import { createRequire } from "module"; +import Logger from "@reactioncommerce/logger"; +import getCartDiscountAmount from "./getCartDiscountAmount.js"; +import splitDiscountForCartItems from "./splitDiscountForCartItems.js"; + +const require = createRequire(import.meta.url); + +const pkg = require("../../../../package.json"); + +const { name, version } = pkg; +const logCtx = { + name, + version, + file: "util/applyOrderDiscountToCart.js" +}; + +/** + * @summary Map discount record to cart discount + * @param {Object} discount - Discount record + * @param {Array} discountedItems - The items that were discounted + * @param {Number} discountedAmount - The total amount discounted + * @returns {Object} Cart discount record + */ +export function createDiscountRecord(discount, discountedItems, discountedAmount) { + const itemDiscount = { + actionKey: discount.actionKey, + promotionId: discount.promotionId, + discountType: discount.discountType, + discountCalculationType: discount.discountCalculationType, + discountValue: discount.discountValue, + dateApplied: new Date(), + discountedItemType: "item", + discountedAmount, + discountedItems + }; + return itemDiscount; +} + +/** + * @summary Apply the order discount to the cart + * @param {Object} context - The application context + * @param {Object} discount - The discount to apply + * @param {Object} cart - The cart to apply the discount to + * @returns {Promise} The updated cart + */ +export default async function applyOrderDiscountToCart(context, discount, cart) { + cart.discounts = cart.discounts || []; + const existingDiscount = cart.discounts + .find((cartDiscount) => discount.actionKey === cartDiscount.actionKey && discount.promotionId === cartDiscount.promotionId); + if (existingDiscount) { + Logger.warn(logCtx, "Not adding discount because it already exists"); + return { cart }; + } + + const discountAmount = getCartDiscountAmount(context, cart, discount); + const discountedItems = splitDiscountForCartItems(discountAmount, cart.items); + + cart.discounts.push(createDiscountRecord(discount, discountedItems, discountAmount)); + + for (const cartItem of cart.items) { + const itemDiscount = discountedItems.find((item) => item._id === cartItem._id); + cartItem.discounts.push(createDiscountRecord(discount, undefined, itemDiscount.amount)); + } + + return { cart }; +} diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/applyOrderDiscountToCart.test.js b/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/applyOrderDiscountToCart.test.js new file mode 100644 index 00000000000..0824ec02adc --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/applyOrderDiscountToCart.test.js @@ -0,0 +1,114 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import * as applyOrderDiscountToCart from "./applyOrderDiscountToCart.js"; + +test("createDiscountRecord should create discount record", () => { + const discount = { + actionKey: "test", + promotionId: "promotion1", + discountType: "item", + discountCalculationType: "fixed", + discountValue: 10, + discountedAmount: 2 + }; + + const discountedItems = [ + { + _id: "item1", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + }, + discounts: [] + } + ]; + + const discountRecord = applyOrderDiscountToCart.createDiscountRecord(discount, discountedItems, 2); + + expect(discountRecord).toEqual({ + actionKey: "test", + promotionId: "promotion1", + discountType: "item", + discountCalculationType: "fixed", + discountValue: 10, + dateApplied: expect.any(Date), + discountedItemType: "item", + discountedAmount: 2, + discountedItems + }); +}); + +test("should apply order discount to cart", async () => { + const cart = { + _id: "cart1", + items: [ + { + _id: "item1", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + }, + discounts: [] + }, + { + _id: "item2", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + }, + discounts: [] + } + ] + }; + + const discount = { + actionKey: "test", + promotionId: "promotion1", + discountType: "order", + discountCalculationType: "fixed", + discountValue: 10, + discountedAmount: 2 + }; + + mockContext.discountCalculationMethods = { + fixed: jest.fn().mockReturnValue(2) + }; + + await applyOrderDiscountToCart.default(mockContext, discount, cart); + + expect(cart.items[0].subtotal).toEqual({ + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + }); + + expect(cart.items[1].subtotal).toEqual({ + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + }); + + const discountedItems = cart.items.map((item) => ({ _id: item._id, amount: 1 })); + expect(cart.discounts).toEqual([ + { ...discount, discountedItemType: "item", dateApplied: expect.any(Date), discountedItems } + ]); +}); diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/getCartDiscountAmount.js b/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/getCartDiscountAmount.js new file mode 100644 index 00000000000..010f0d108b5 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/getCartDiscountAmount.js @@ -0,0 +1,16 @@ +import accounting from "accounting-js"; +import { calculateMerchandiseTotal } from "../../calculateMerchandiseTotal.js"; + +/** + * @summary Get the discount amount for a discount item + * @param {Object} context - The application context + * @param {Object} cart - The cart to calculate the discount for + * @param {Object} discount - The discount to calculate the discount amount for + * @returns {Number} - The discount amount + */ +export default function getCartDiscountAmount(context, cart, discount) { + const merchandiseTotal = cart.merchandiseTotal || calculateMerchandiseTotal(cart); + const { discountCalculationType, discountValue } = discount; + const appliedDiscount = context.discountCalculationMethods[discountCalculationType](discountValue, merchandiseTotal); + return Number(accounting.toFixed(appliedDiscount, 2)); +} diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/getCartDiscountAmount.test.js b/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/getCartDiscountAmount.test.js new file mode 100644 index 00000000000..ad181b1b2b7 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/getCartDiscountAmount.test.js @@ -0,0 +1,41 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import getCartDiscountAmount from "./getCartDiscountAmount.js"; + +test("should return correct discount amount", () => { + const cart = { + _id: "cart1", + items: [ + { + _id: "item1", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + } + } + ], + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + } + }; + + const discount = { + discountCalculationType: "fixed", + discountValue: 10 + }; + + mockContext.discountCalculationMethods = { + fixed: jest.fn().mockReturnValue(10) + }; + + const discountAmount = getCartDiscountAmount(mockContext, cart, discount); + expect(discountAmount).toEqual(10); +}); diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/getCartDiscountTotal.js b/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/getCartDiscountTotal.js new file mode 100644 index 00000000000..dfa5a8ce6fa --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/getCartDiscountTotal.js @@ -0,0 +1,21 @@ +import accounting from "accounting-js"; +import { calculateMerchandiseTotal } from "../../calculateMerchandiseTotal.js"; + +/** + * @summary Get the total discount amount for an order + * @param {Object} context - The application context + * @param {Object} cart - The cart to calculate the discount for + * @returns {Number} The total discount amount + */ +export default function getCartDiscountTotal(context, cart) { + let totalDiscountAmount = 0; + const merchandiseTotal = cart.merchandiseTotal || calculateMerchandiseTotal(cart); + for (const { discountCalculationType, discountValue } of cart.discounts) { + const appliedDiscount = context.discountCalculationMethods[discountCalculationType]( + discountValue, + merchandiseTotal + ); + totalDiscountAmount += appliedDiscount; + } + return Number(accounting.toFixed(totalDiscountAmount, 2)); +} diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/getCartDiscountTotal.test.js b/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/getCartDiscountTotal.test.js new file mode 100644 index 00000000000..b44c3f10d73 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/getCartDiscountTotal.test.js @@ -0,0 +1,41 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import getCartDiscountAmount from "./getCartDiscountAmount.js"; + +test("should return correct total cart discount amount", () => { + const cart = { + _id: "cart1", + items: [ + { + _id: "item1", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + } + } + ], + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + } + }; + + const discount = { + discountCalculationType: "fixed", + discountValue: 10 + }; + + mockContext.discountCalculationMethods = { + fixed: jest.fn().mockReturnValue(10) + }; + + const discountAmount = getCartDiscountAmount(mockContext, cart, discount); + expect(discountAmount).toEqual(10); +}); diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/splitDiscountForCartItems.js b/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/splitDiscountForCartItems.js new file mode 100644 index 00000000000..2ff310e54f7 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/splitDiscountForCartItems.js @@ -0,0 +1,17 @@ +import accounting from "accounting-js"; + +/** + * @summary Splits a discount across all cart items + * @param {Number} totalDiscount - The total discount to split + * @param {Array} cartItems - The cart items to split the discount across + * @returns {void} undefined + */ +export default function splitDiscountForCartItems(totalDiscount, cartItems) { + const totalItemPrice = cartItems.reduce((acc, item) => acc + item.subtotal.amount, 0); + const discountForEachItem = {}; + cartItems.forEach((item) => { + const discount = (item.subtotal.amount / totalItemPrice) * totalDiscount; + discountForEachItem[item._id] = Number(accounting.toFixed(discount, 2)); + }); + return Object.keys(discountForEachItem).map((key) => ({ _id: key, amount: discountForEachItem[key] })); +} diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/splitDiscountForCartItems.test.js b/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/splitDiscountForCartItems.test.js new file mode 100644 index 00000000000..e8be35292f4 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/splitDiscountForCartItems.test.js @@ -0,0 +1,45 @@ +import splitDiscountForCartItems from "./splitDiscountForCartItems.js"; + +test("should split discount for cart items", () => { + const totalDiscount = 10; + const cartItems = [ + { + _id: "item1", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + } + }, + { + _id: "item2", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + } + } + ]; + + const discountForEachItem = splitDiscountForCartItems(totalDiscount, cartItems); + expect(discountForEachItem).toEqual([ + { + _id: "item1", + amount: 5 + }, + { + _id: "item2", + amount: 5 + } + ]); +}); diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/applyDiscountsToRates.js b/packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/applyDiscountsToRates.js new file mode 100644 index 00000000000..dd527d89a24 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/applyDiscountsToRates.js @@ -0,0 +1,19 @@ +import evaluateRulesAgainstShipping from "./evaluateRulesAgainstShipping.js"; + +/** + * @summary Add the discount to rates + * @param {Object} context - The application context + * @param {Object} commonOrder - The order to apply the discount to + * @param {Object} rates - The rates to apply the discount to + * @returns {Promise} undefined + */ +export default async function applyDiscountsToRates(context, commonOrder, rates) { + const shipping = { + discounts: commonOrder.discounts || [], + shipmentQuotes: rates + }; + const discountedShipping = await evaluateRulesAgainstShipping(context, shipping); + + /* eslint-disable-next-line no-param-reassign */ + rates = discountedShipping.shipmentQuotes; +} diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/applyShippingDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/applyShippingDiscountToCart.js new file mode 100644 index 00000000000..2f2edc2422c --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/applyShippingDiscountToCart.js @@ -0,0 +1,82 @@ +import { createRequire } from "module"; +import Logger from "@reactioncommerce/logger"; +import evaluateRulesAgainstShipping from "./evaluateRulesAgainstShipping.js"; + +const require = createRequire(import.meta.url); + +const pkg = require("../../../../package.json"); + +const { name, version } = pkg; +const logCtx = { + name, + version, + file: "util/applyShippingDiscountToCart.js" +}; + +/** + * @summary Add the discount to the shipping record + * @param {Object} context - The application context + * @param {Object} discount - The discount to apply + * @param {Object} param.shipping - The shipping record to apply the discount to + * @returns {Promise} undefined + */ +async function addDiscountToShipping(context, discount, { shipping }) { + for (const shippingRecord of shipping) { + if (shippingRecord.discounts) { + const existingDiscount = shippingRecord.discounts + .find((itemDiscount) => discount.actionKey === itemDiscount.actionKey && discount.promotionId === itemDiscount.promotionId); + if (existingDiscount) { + Logger.warn(logCtx, "Not adding discount because it already exists"); + return; + } + } + const cartDiscount = createShippingDiscount(shippingRecord, discount); + if (shippingRecord.discounts) { + shippingRecord.discounts.push(cartDiscount); + } else { + shippingRecord.discounts = [cartDiscount]; + } + } +} + +/** + * @summary Create a discount object for a shipping record + * @param {Object} item - The cart item + * @param {Object} discount - The discount to create + * @returns {Object} - The shipping discount object + */ +function createShippingDiscount(item, discount) { + const shippingDiscount = { + actionKey: discount.actionKey, + promotionId: discount.promotionId, + rules: discount.rules, + discountCalculationType: discount.discountCalculationType, + discountValue: discount.discountValue, + dateApplied: new Date() + }; + return shippingDiscount; +} + +/** + * @summary Apply a shipping discount to a cart + * @param {Object} context - The application context + * @param {Object} discount - The discount to apply + * @param {Object} cart - The cart to apply the discount to + * @returns {Promise} The updated cart + */ +export default async function applyShippingDiscountToCart(context, discount, cart) { + Logger.info(logCtx, "Applying shipping discount"); + const { shipping } = cart; + await addDiscountToShipping(context, discount, { shipping }); + + // Check existing shipping quotes and discount them + Logger.info("Check existing shipping quotes and discount them"); + for (const shippingRecord of shipping) { + if (!shippingRecord.shipmentQuotes) continue; + // evaluate whether a discount applies to the existing shipment quotes + // eslint-disable-next-line no-await-in-loop + await evaluateRulesAgainstShipping(context, shippingRecord); + } + + return { cart }; +} diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/evaluateRulesAgainstShipping.js b/packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/evaluateRulesAgainstShipping.js new file mode 100644 index 00000000000..c54a7ccb26d --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/evaluateRulesAgainstShipping.js @@ -0,0 +1,68 @@ +import { Engine } from "json-rules-engine"; + +/** + * @summary Check if a shipment quote matches a discount rule + * @param {Object} context - The application context + * @param {Object} shipmentQuote - The shipment quote to evaluate rules against + * @param {Object} discount - The discount to evaluate rules against + * @returns {Boolean} True if the rules pass, false otherwise + */ +async function doesDiscountApply(context, shipmentQuote, discount) { + const { promotions: { operators } } = context; + const engine = new Engine(); + engine.addRule(discount.inclusionRules); + Object.keys(operators).forEach((operatorKey) => { + engine.addOperator(operatorKey, operators[operatorKey]); + }); + const results = await engine.run(shipmentQuote); + if (results.events.length) return true; + return false; +} + +/** + * @summary Apply a discount to a shipment quote + * @param {Object} context - The application context + * @param {Object} shipmentQuote - The shipment quote to apply the discount to + * @param {Object} discounts - The discounts to apply + * @returns {void} undefined + */ +function applyDiscounts(context, shipmentQuote, discounts) { + let totalDiscount = 0; + const amountBeforeDiscounts = shipmentQuote.method.undiscountedRate; + discounts.forEach((discount) => { + const calculationMethod = context.discountCalculationMethods[discount.discountCalculationType]; + const discountAmount = calculationMethod(discount.discountValue, amountBeforeDiscounts); + totalDiscount += discountAmount; + }); + shipmentQuote.rate = shipmentQuote.method.undiscountedRate - totalDiscount; + shipmentQuote.method.rate = shipmentQuote.method.undiscountedRate - totalDiscount; +} + +/** + * @summary check every discount on a shipping method and apply it to quotes + * @param {Object} context - The application context + * @param {Object} shipping - The shipping record to evaluate + * @returns {Promise} the possibly mutated shipping object + */ +export default async function evaluateRulesAgainstShipping(context, shipping) { + for (const shipmentQuote of shipping.shipmentQuotes) { + if (!shipmentQuote.method.undiscountedRate) { + shipmentQuote.method.undiscountedRate = shipmentQuote.method.rate; + } + } + + for (const shipmentQuote of shipping.shipmentQuotes) { + const applicableDiscounts = []; + for (const discount of shipping.discounts) { + // eslint-disable-next-line no-await-in-loop + const discountApplies = await doesDiscountApply(context, shipmentQuote, discount); + if (discountApplies) { + applicableDiscounts.push(discount); + } + } + if (applicableDiscounts.length) { + applyDiscounts(context, shipmentQuote, applicableDiscounts); + } + } + return shipping; +} diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/getGroupDisountTotal.js b/packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/getGroupDisountTotal.js new file mode 100644 index 00000000000..7ec3719bc97 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/getGroupDisountTotal.js @@ -0,0 +1,11 @@ +/* eslint-disable no-unused-vars */ + +/** + * @summary Get the group discount total for a order + * @param {Object} context - The application context + * @param {Object} params.commonOrder - The order to get the group discount total for + * @returns {Number} The total discount amount for the order + */ +export default function getGroupDiscountTotal(context, { commonOrder }) { + return 0; +} diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/getShippingDiscountTotal.js b/packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/getShippingDiscountTotal.js new file mode 100644 index 00000000000..e85fc411178 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/getShippingDiscountTotal.js @@ -0,0 +1,17 @@ +/** + * @summary Get the total discount amount for a shipping discount + * @param {Object} context - The application context + * @param {Object} cart - The cart to get the shipping discount total for + * @returns {Number} The total discount amount for the shipping discount + */ +export default function getShippingDiscountTotal(context, cart) { + const { shipping } = cart; + let totalShippingDiscount = 0; + for (const fulfillmentGroup of shipping) { + const { shipmentMethod } = fulfillmentGroup; + if (shipmentMethod && shipmentMethod.undiscountedRate) { + totalShippingDiscount += shipmentMethod.undiscountedRate - shipmentMethod.rate; + } + } + return totalShippingDiscount; +} diff --git a/packages/api-plugin-promotions-discounts/src/util/setDiscountsOnCart.js b/packages/api-plugin-promotions-discounts/src/util/setDiscountsOnCart.js new file mode 100644 index 00000000000..ca4fdd349ae --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/util/setDiscountsOnCart.js @@ -0,0 +1,25 @@ +import recalculateCartItemSubtotal from "./discountTypes/item/recalculateCartItemSubtotal.js"; +import getCartDiscountTotal from "./discountTypes/order/getCartDiscountTotal.js"; + +/** + * @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) { + if (!cart.discounts) { + cart.discounts = []; + } + cart.items.forEach((item) => { + if (!item.discounts) { + item.discounts = []; + } + }); + const discountTotal = getCartDiscountTotal(context, cart); + cart.discount = discountTotal; + + for (const item of cart.items) { + recalculateCartItemSubtotal(context, item); + } +} diff --git a/packages/api-plugin-promotions-discounts/src/util/setDiscountsOnCart.test.js b/packages/api-plugin-promotions-discounts/src/util/setDiscountsOnCart.test.js new file mode 100644 index 00000000000..3c15bf0fc70 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/util/setDiscountsOnCart.test.js @@ -0,0 +1,54 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import recalculateCartItemSubtotal from "./discountTypes/item/recalculateCartItemSubtotal.js"; +import setDiscountsOnCart from "./setDiscountsOnCart.js"; + +jest.mock("./discountTypes/item/recalculateCartItemSubtotal.js", () => jest.fn()); + +test("should set discounts on cart", () => { + const cart = { + _id: "cart1", + items: [ + { + _id: "item1", + price: { + amount: 12 + }, + quantity: 5, + subtotal: { + amount: 60, + currencyCode: "USD" + } + } + ], + discounts: [ + { + discountCalculationType: "fixed", + discountValue: 15 + } + ] + }; + + mockContext.discountCalculationMethods = { + fixed: jest.fn().mockReturnValue(15) + }; + + const expectedItemSubtotal = { + amount: 60, + currencyCode: "USD", + discount: 15, + undiscountedAmount: 60 + }; + + recalculateCartItemSubtotal.mockImplementationOnce((context, item) => { + item.subtotal = { ...expectedItemSubtotal }; + }); + + setDiscountsOnCart(mockContext, cart); + + expect(mockContext.discountCalculationMethods.fixed).toHaveBeenCalledWith(15, 60); + expect(recalculateCartItemSubtotal).toHaveBeenCalledTimes(1); + expect(recalculateCartItemSubtotal).toHaveBeenCalledWith(mockContext, cart.items[0]); + expect(cart.discount).toEqual(15); + + expect(cart.items[0].subtotal).toEqual(expectedItemSubtotal); +}); diff --git a/packages/api-plugin-promotions-discounts/src/xforms/recalculateDiscounts.js b/packages/api-plugin-promotions-discounts/src/xforms/recalculateDiscounts.js new file mode 100644 index 00000000000..794f4088291 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/xforms/recalculateDiscounts.js @@ -0,0 +1,17 @@ +import addDiscountToOrderItem from "../util/discountTypes/item/addDiscountToOrderItem.js"; + +/** + * @summary Recalculates discounts on an order + * @param {Object} context - The application context + * @param {Object} cart - The cart to recalculate discounts on + * @returns {void} undefined + */ +export default function recalculateDiscounts(context, cart) { + // recalculate item discounts + for (const item of cart.items || []) { + addDiscountToOrderItem(context, { item, cartItem: item }); + } + + // TODO: Recalculate shipping discounts + // TODO: Recalculate order discounts +} diff --git a/packages/api-plugin-promotions-offers/src/triggers/offerTriggerHandler.js b/packages/api-plugin-promotions-offers/src/triggers/offerTriggerHandler.js index 96748340067..f054e0304de 100644 --- a/packages/api-plugin-promotions-offers/src/triggers/offerTriggerHandler.js +++ b/packages/api-plugin-promotions-offers/src/triggers/offerTriggerHandler.js @@ -24,9 +24,7 @@ const logCtx = { * @returns {Promise} - The answer with offers applied */ export async function offerTriggerHandler(context, enhancedCart, { triggerParameters }) { - const { - promotions: { operators } - } = context; + const { promotions: { operators } } = context; const engine = new Engine(); Object.keys(operators).forEach((operatorKey) => { diff --git a/packages/api-plugin-promotions/src/handlers/applyPromotions.js b/packages/api-plugin-promotions/src/handlers/applyPromotions.js index d32ff32a4cf..d4d3a68ff73 100644 --- a/packages/api-plugin-promotions/src/handlers/applyPromotions.js +++ b/packages/api-plugin-promotions/src/handlers/applyPromotions.js @@ -45,9 +45,9 @@ async function getImplicitPromotions(context, shopId) { */ export default async function applyPromotions(context, cart, explicitPromotion = undefined) { const promotions = await getImplicitPromotions(context, cart.shopId); - const { promotions: pluginPromotions } = context; + const { promotions: pluginPromotions, simpleSchemas: { Cart } } = context; - const enhancedCart = enhanceCart(context, pluginPromotions.enhancers, cart); + let enhancedCart = enhanceCart(context, pluginPromotions.enhancers, cart); const triggerHandleByKey = _.keyBy(pluginPromotions.triggers, "key"); const actionHandleByKey = _.keyBy(context.promotions.actions, "key"); @@ -80,15 +80,19 @@ export default async function applyPromotions(context, cart, explicitPromotion = if (!shouldApply) continue; // eslint-disable-next-line no-await-in-loop - await applyAction(context, enhancedCart, { promotion, actionHandleByKey }); + const results = await applyAction(context, enhancedCart, { promotion, actionHandleByKey }); + if (results && results.updatedCart) { + enhancedCart = results.updatedCart; + } appliedPromotions.push(promotion); break; } } - cart.appliedPromotions = appliedPromotions; + enhancedCart.appliedPromotions = appliedPromotions; + Cart.clean(enhancedCart, { mutate: true }); Logger.info({ ...logCtx, appliedPromotions: appliedPromotions.length }, "Applied promotions successfully"); - return context.mutations.saveCart(context, cart, "promotions"); + return context.mutations.saveCart(context, enhancedCart, "promotions"); } diff --git a/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js b/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js index 8c48e54c330..08c1a94e00e 100644 --- a/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js +++ b/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js @@ -34,12 +34,21 @@ test("should save cart with implicit promotions are applied", async () => { .fn() .mockName("saveCart") .mockResolvedValueOnce({ ...cart }); + mockContext.simpleSchemas = { + Cart: { clean: jest.fn() } + }; - await applyImplicitPromotions(mockContext, { ...cart }); + await applyImplicitPromotions(mockContext, cart); - expect(testTrigger).toHaveBeenCalledWith(mockContext, cart, { promotion: testPromotion, triggerParameters: { name: "test trigger" } }); - expect(testAction).toHaveBeenCalledWith(mockContext, cart, { promotion: testPromotion, actionParameters: undefined }); - expect(testEnhancer).toHaveBeenCalledWith(mockContext, cart); + expect(testTrigger).toBeCalledWith(mockContext, expect.objectContaining(cart), { + promotion: testPromotion, + triggerParameters: { name: "test trigger" } + }); + expect(testAction).toBeCalledWith(mockContext, expect.objectContaining(cart), { + promotion: testPromotion, + actionParameters: undefined + }); + expect(testEnhancer).toBeCalledWith(mockContext, expect.objectContaining(cart)); const expectedCart = { ...cart, appliedPromotions: [testPromotion] }; expect(mockContext.mutations.saveCart).toHaveBeenCalledWith(mockContext, expectedCart, "promotions"); @@ -50,7 +59,11 @@ test("should save cart with implicit promotions are not applied when promotions _id: "cartId" }; mockContext.collections.Promotions = { - find: () => ({ toArray: jest.fn().mockResolvedValueOnce([testPromotion, { ...testPromotion, _id: "test id 2", stackAbility: "all" }]) }) + find: () => ({ + toArray: jest + .fn() + .mockResolvedValueOnce([testPromotion, { ...testPromotion, _id: "test id 2", stackAbility: "all" }]) + }) }; mockContext.promotions = { ...pluginPromotion, triggers: [] }; @@ -58,6 +71,9 @@ test("should save cart with implicit promotions are not applied when promotions .fn() .mockName("saveCart") .mockResolvedValueOnce({ ...cart }); + mockContext.simpleSchemas = { + Cart: { clean: jest.fn() } + }; await applyImplicitPromotions(mockContext, { ...cart }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d6f8313158..57531188936 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,6 +168,7 @@ importers: '@reactioncommerce/api-plugin-products': 1.3.1 '@reactioncommerce/api-plugin-promotions': 1.0.0 '@reactioncommerce/api-plugin-promotions-coupons': 1.0.0 + '@reactioncommerce/api-plugin-promotions-discounts': 1.0.0 '@reactioncommerce/api-plugin-promotions-offers': 1.0.0 '@reactioncommerce/api-plugin-settings': 1.0.7 '@reactioncommerce/api-plugin-shipments': 1.0.3 @@ -231,6 +232,7 @@ importers: '@reactioncommerce/api-plugin-products': link:../../packages/api-plugin-products '@reactioncommerce/api-plugin-promotions': link:../../packages/api-plugin-promotions '@reactioncommerce/api-plugin-promotions-coupons': link:../../packages/api-plugin-promotions-coupons + '@reactioncommerce/api-plugin-promotions-discounts': link:../../packages/api-plugin-promotions-discounts '@reactioncommerce/api-plugin-promotions-offers': link:../../packages/api-plugin-promotions-offers '@reactioncommerce/api-plugin-settings': link:../../packages/api-plugin-settings '@reactioncommerce/api-plugin-shipments': link:../../packages/api-plugin-shipments @@ -1039,6 +1041,24 @@ importers: lodash: 4.17.21 simpl-schema: 1.12.3 + packages/api-plugin-promotions-discounts: + specifiers: + '@reactioncommerce/api-utils': ^1.16.7 + '@reactioncommerce/logger': ^1.1.3 + '@reactioncommerce/random': ^1.0.2 + accounting-js: ^1.1.1 + deep-object-diff: ^1.1.7 + json-rules-engine: ^6.1.2 + simpl-schema: ^1.12.3 + dependencies: + '@reactioncommerce/api-utils': link:../api-utils + '@reactioncommerce/logger': link:../logger + '@reactioncommerce/random': link:../random + accounting-js: 1.1.1 + deep-object-diff: 1.1.7 + json-rules-engine: 6.1.2 + simpl-schema: 1.12.3 + packages/api-plugin-promotions-offers: specifiers: '@reactioncommerce/api-utils': ^1.16.9 @@ -7313,6 +7333,10 @@ packages: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true + /deep-object-diff/1.1.7: + resolution: {integrity: sha512-QkgBca0mL08P6HiOjoqvmm6xOAl2W6CT2+34Ljhg0OeFan8cwlcdq8jrLKsBBuUFAZLsN5b6y491KdKEoSo9lg==} + dev: false + /deepmerge/4.2.2: resolution: {integrity: sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==} engines: {node: '>=0.10.0'} From 4c936b7f1914ff98ad7fc5915537f48ef032b3ca Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Tue, 1 Nov 2022 07:56:06 +0700 Subject: [PATCH 02/19] fix: fix query and mutation tests fail --- .../integration/api/mutations/addCartItems/addCartItems.test.js | 1 + .../queries/anonymousCartByCartId/anonymousCartByCartId.test.js | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/reaction/tests/integration/api/mutations/addCartItems/addCartItems.test.js b/apps/reaction/tests/integration/api/mutations/addCartItems/addCartItems.test.js index a2b6202b008..875644b07dd 100644 --- a/apps/reaction/tests/integration/api/mutations/addCartItems/addCartItems.test.js +++ b/apps/reaction/tests/integration/api/mutations/addCartItems/addCartItems.test.js @@ -85,6 +85,7 @@ beforeAll(async () => { actionKey: "mockActionKey", promotionId: "mockPromotionId", rules: { conditions: {}, event: { type: "mockType", params: {} } }, + discountType: "order", discountCalculationType: "fixed", discountValue: 25124, dateApplied: new Date() diff --git a/apps/reaction/tests/integration/api/queries/anonymousCartByCartId/anonymousCartByCartId.test.js b/apps/reaction/tests/integration/api/queries/anonymousCartByCartId/anonymousCartByCartId.test.js index b53363d5beb..9a7bcb01a21 100644 --- a/apps/reaction/tests/integration/api/queries/anonymousCartByCartId/anonymousCartByCartId.test.js +++ b/apps/reaction/tests/integration/api/queries/anonymousCartByCartId/anonymousCartByCartId.test.js @@ -45,6 +45,7 @@ beforeAll(async () => { actionKey: "mockActionKey", promotionId: "mockPromotionId", rules: { conditions: {}, event: { type: "mockType", params: {} } }, + discountType: "order", discountCalculationType: "fixed", discountValue: 25124, dateApplied: new Date() From b798517196bc9c7398f46f3ee261f80dc257b129 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Tue, 1 Nov 2022 18:30:03 +0700 Subject: [PATCH 03/19] feat: add rules for promotion trigger (uncompleted) --- .../src/facts/getEligibleItems.js | 45 +++++++++++++++++++ .../src/facts/index.js | 9 ++++ .../src/facts/totalItemAmount.js | 16 +++++++ .../src/facts/totalItemCount.js | 9 ++++ .../api-plugin-promotions-offers/src/index.js | 11 ++++- .../src/registration.js | 12 +++++ .../src/simpleSchemas.js | 33 +++++++++++++- .../src/triggers/offerTriggerHandler.js | 13 +++++- .../src/utils/engineHelpers.js | 24 ++++++++++ .../src/handlers/applyPromotions.js | 5 ++- 10 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 packages/api-plugin-promotions-offers/src/facts/getEligibleItems.js create mode 100644 packages/api-plugin-promotions-offers/src/facts/index.js create mode 100644 packages/api-plugin-promotions-offers/src/facts/totalItemAmount.js create mode 100644 packages/api-plugin-promotions-offers/src/facts/totalItemCount.js create mode 100644 packages/api-plugin-promotions-offers/src/registration.js create mode 100644 packages/api-plugin-promotions-offers/src/utils/engineHelpers.js diff --git a/packages/api-plugin-promotions-offers/src/facts/getEligibleItems.js b/packages/api-plugin-promotions-offers/src/facts/getEligibleItems.js new file mode 100644 index 00000000000..226d24b2783 --- /dev/null +++ b/packages/api-plugin-promotions-offers/src/facts/getEligibleItems.js @@ -0,0 +1,45 @@ +import createEngine from "../utils/engineHelpers.js"; + +/** + * @summary return items from the cart that meet inclusion criteria + * @param {Object} context - The application context + * @param {Object} params - the cart to evaluate for eligible items + * @param {Object} almanac - the rule to evaluate against + * @return {Promise>} - An array of eligible cart items + */ +export default async function getEligibleItems(context, params, almanac) { + const cart = await almanac.factValue("cart"); + const eligibleItems = []; + if (params.inclusionRule) { + const engine = createEngine(context, params.inclusionRule); + for (const item of cart.items) { + const facts = { item }; + + // eslint-disable-next-line no-await-in-loop + const results = await engine.run(facts); + const { failureResults } = results; + if (failureResults.length === 0) { + eligibleItems.push(item); + } + } + } else { + eligibleItems.push(...cart.items); + } + + const filteredItems = []; + if (eligibleItems.length > 0 && params.exclusionRule) { + const engine = createEngine(context, params.exclusionRule); + for (const item of filteredItems) { + const facts = { item }; + // eslint-disable-next-line no-await-in-loop + const { events } = await engine.run(facts); + if (events.length === 0) { + filteredItems.push(item); + } + } + } else { + filteredItems.push(...eligibleItems); + } + + return filteredItems; +} diff --git a/packages/api-plugin-promotions-offers/src/facts/index.js b/packages/api-plugin-promotions-offers/src/facts/index.js new file mode 100644 index 00000000000..c20765c1f7d --- /dev/null +++ b/packages/api-plugin-promotions-offers/src/facts/index.js @@ -0,0 +1,9 @@ +import totalItemAmount from "./totalItemAmount.js"; +import totalItemCount from "./totalItemCount.js"; +import getEligibleItems from "./getEligibleItems.js"; + +export default { + totalItemAmount, + totalItemCount, + getEligibleItems +}; diff --git a/packages/api-plugin-promotions-offers/src/facts/totalItemAmount.js b/packages/api-plugin-promotions-offers/src/facts/totalItemAmount.js new file mode 100644 index 00000000000..8edadf58a4c --- /dev/null +++ b/packages/api-plugin-promotions-offers/src/facts/totalItemAmount.js @@ -0,0 +1,16 @@ +/** + * @summary Get the total amount of a discount or promotion + * @param {Object} context - The application context + * @param {Object} params - The parameters to pass to the fact + * @param {Object} almanac - The almanac to pass to the fact + * @returns {Promise} - The total amount of a discount or promotion + */ +export default async function totalItemAmount(context, params, almanac) { + let calculationItems = []; + if (params.fromFact) { + calculationItems = await almanac.factValue(params.fromFact); + } else { + calculationItems = await almanac.factValue("cart").then((cart) => cart.items); + } + return calculationItems.reduce((sum, item) => sum + item.price.amount * item.quantity, 0); +} diff --git a/packages/api-plugin-promotions-offers/src/facts/totalItemCount.js b/packages/api-plugin-promotions-offers/src/facts/totalItemCount.js new file mode 100644 index 00000000000..842952e61b2 --- /dev/null +++ b/packages/api-plugin-promotions-offers/src/facts/totalItemCount.js @@ -0,0 +1,9 @@ +/** + * @summary Get the total amount of a discount or promotion + * @param {Object} cart - The cart to get the discount amount for + * @param {Object} parameters - The parameters to pass to the trigger + * @returns {Promise} - The total amount of a discount or promotion + */ +export default async function totalItemCount(cart, parameters) { + return cart.items.reduce((prev, current) => prev + current.price.amount * current.quantity, 0); +} diff --git a/packages/api-plugin-promotions-offers/src/index.js b/packages/api-plugin-promotions-offers/src/index.js index dd7a4983348..01d38599f59 100644 --- a/packages/api-plugin-promotions-offers/src/index.js +++ b/packages/api-plugin-promotions-offers/src/index.js @@ -1,6 +1,8 @@ import { createRequire } from "module"; import triggers from "./triggers/index.js"; import enhancers from "./enhancers/index.js"; +import facts from "./facts/index.js"; +import { promotionOfferFacts, registerPromotionOfferFacts } from "./registration.js"; const require = createRequire(import.meta.url); const pkg = require("../package.json"); @@ -15,9 +17,16 @@ export default async function register(app) { label: pkg.label, name: pkg.name, version: pkg.version, + functionsByType: { + registerPluginHandler: [registerPromotionOfferFacts] + }, + contextAdditions: { + promotionOfferFacts + }, promotions: { triggers, enhancers - } + }, + promotionOfferFacts: facts }); } diff --git a/packages/api-plugin-promotions-offers/src/registration.js b/packages/api-plugin-promotions-offers/src/registration.js new file mode 100644 index 00000000000..7aaae5d72a1 --- /dev/null +++ b/packages/api-plugin-promotions-offers/src/registration.js @@ -0,0 +1,12 @@ +export const promotionOfferFacts = {}; + +/** + * @summary register the promotion offer facts + * @param {Array} params.promotionOfferFacts - The array of promotion offer facts to register + * @return {void} undefined + */ +export function registerPromotionOfferFacts({ promotionOfferFacts: facts }) { + if (facts) { + Object.assign(promotionOfferFacts, facts); + } +} diff --git a/packages/api-plugin-promotions-offers/src/simpleSchemas.js b/packages/api-plugin-promotions-offers/src/simpleSchemas.js index a7d79e3c473..acde5a29e76 100644 --- a/packages/api-plugin-promotions-offers/src/simpleSchemas.js +++ b/packages/api-plugin-promotions-offers/src/simpleSchemas.js @@ -1,9 +1,40 @@ import SimpleSchema from "simpl-schema"; -export const OfferTriggerParameters = new SimpleSchema({ +const OfferTriggerFact = new SimpleSchema({ name: String, + handlerName: String, + fromFact: { + type: String, + optional: true + } +}); + +const Rules = new SimpleSchema({ conditions: { type: Object, blackbox: true } }); + +export const OfferTriggerParameters = new SimpleSchema({ + "name": String, + "conditions": { + type: Object, + blackbox: true + }, + "facts": { + type: Array, + optional: true + }, + "facts.$": { + type: OfferTriggerFact + }, + "inclusionRule": { + type: Rules, + optional: true + }, + "exclusionRule": { + type: Rules, + optional: true + } +}); diff --git a/packages/api-plugin-promotions-offers/src/triggers/offerTriggerHandler.js b/packages/api-plugin-promotions-offers/src/triggers/offerTriggerHandler.js index f054e0304de..1fdc42fa353 100644 --- a/packages/api-plugin-promotions-offers/src/triggers/offerTriggerHandler.js +++ b/packages/api-plugin-promotions-offers/src/triggers/offerTriggerHandler.js @@ -14,6 +14,8 @@ const logCtx = { file: "offerTriggerHandler.js" }; +const defaultFacts = [{ fact: "eligibleItems", handlerName: "getEligibleItems" }]; + /** * @summary apply all offers to the cart * @param {String} context - The application context @@ -24,7 +26,10 @@ const logCtx = { * @returns {Promise} - The answer with offers applied */ export async function offerTriggerHandler(context, enhancedCart, { triggerParameters }) { - const { promotions: { operators } } = context; + const { + promotions: { operators }, + promotionOfferFacts + } = context; const engine = new Engine(); Object.keys(operators).forEach((operatorKey) => { @@ -36,8 +41,14 @@ export async function offerTriggerHandler(context, enhancedCart, { triggerParame type: "rulesCheckPassed" } }); + const facts = { cart: enhancedCart }; + const allFacts = [...defaultFacts, ...(triggerParameters.facts || [])]; + for (const { fact, handlerName, fromFact } of allFacts) { + engine.addFact(fact, async (params, almanac) => promotionOfferFacts[handlerName](context, { ...triggerParameters, rulePrams: params, fromFact }, almanac)); + } + const results = await engine.run(facts); const { failureResults } = results; Logger.debug({ ...logCtx, ...results }); diff --git a/packages/api-plugin-promotions-offers/src/utils/engineHelpers.js b/packages/api-plugin-promotions-offers/src/utils/engineHelpers.js new file mode 100644 index 00000000000..1079116f2f6 --- /dev/null +++ b/packages/api-plugin-promotions-offers/src/utils/engineHelpers.js @@ -0,0 +1,24 @@ +import { Engine } from "json-rules-engine"; + +/** + * @summary Add the custom operators to the engine + * @param {Object} context - The application context + * @param {Object} rules - The rule to add the operators to + * @returns {Object} Engine - The engine with the operators added + */ +export default function createEngine(context, rules) { + const engine = new Engine(); + const { promotions: { operators } } = context; + Object.keys(operators).forEach((operatorKey) => { + engine.addOperator(operatorKey, operators[operatorKey]); + }); + + engine.addRule({ + ...rules, + event: { + type: "rulesCheckPassed" + } + }); + + return engine; +} diff --git a/packages/api-plugin-promotions/src/handlers/applyPromotions.js b/packages/api-plugin-promotions/src/handlers/applyPromotions.js index d4d3a68ff73..8135ff33fe1 100644 --- a/packages/api-plugin-promotions/src/handlers/applyPromotions.js +++ b/packages/api-plugin-promotions/src/handlers/applyPromotions.js @@ -47,7 +47,6 @@ export default async function applyPromotions(context, cart, explicitPromotion = const promotions = await getImplicitPromotions(context, cart.shopId); const { promotions: pluginPromotions, simpleSchemas: { Cart } } = context; - let enhancedCart = enhanceCart(context, pluginPromotions.enhancers, cart); const triggerHandleByKey = _.keyBy(pluginPromotions.triggers, "key"); const actionHandleByKey = _.keyBy(context.promotions.actions, "key"); @@ -59,6 +58,7 @@ export default async function applyPromotions(context, cart, explicitPromotion = unqualifiedPromotions.push(explicitPromotion); } + let enhancedCart = cart; for (const promotion of unqualifiedPromotions) { if (isPromotionExpired(promotion)) { continue; @@ -70,6 +70,9 @@ export default async function applyPromotions(context, cart, explicitPromotion = continue; } + // eslint-disable-next-line no-await-in-loop + enhancedCart = await enhanceCart(context, pluginPromotions.enhancers, enhancedCart); + for (const trigger of promotion.triggers) { const { triggerKey, triggerParameters } = trigger; const triggerFn = triggerHandleByKey[triggerKey]; From 5ebb6ac210db59876c22cd725929e76e021b5bd3 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Wed, 2 Nov 2022 13:46:36 +0700 Subject: [PATCH 04/19] feat: inclusion and exclusion for discount item --- .../addCartItems/addCartItems.test.js | 1 - .../anonymousCartByCartId.test.js | 1 - .../src/actions/discountAction.js | 10 ++- .../src/actions/discountAction.test.js | 19 ----- .../src/simpleSchemas.js | 5 -- .../item/applyItemDiscountToCart.js | 35 ++------- .../item/recalculateCartItemSubtotal.js | 2 +- .../order/applyOrderDiscountToCart.js | 22 +++--- .../order/splitDiscountForCartItems.js | 7 +- .../src/utils/engineHelpers.js | 24 ++++++ .../src/utils/getEligibleItems.js | 44 +++++++++++ .../src/utils/getEligibleItems.test.js | 63 ++++++++++++++++ .../src/facts/getEligibleItems.js | 2 +- .../src/facts/getEligibleItems.test.js | 75 +++++++++++++++++++ .../src/facts/totalItemAmount.test.js | 64 ++++++++++++++++ .../src/facts/totalItemCount.js | 15 +++- .../src/facts/totalItemCount.test.js | 52 +++++++++++++ .../src/triggers/offerTriggerHandler.js | 20 +---- .../src/triggers/offerTriggerHandler.test.js | 65 ++++++++++++++++ .../src/handlers/applyPromotions.js | 13 ++-- 20 files changed, 438 insertions(+), 101 deletions(-) create mode 100644 packages/api-plugin-promotions-discounts/src/utils/engineHelpers.js create mode 100644 packages/api-plugin-promotions-discounts/src/utils/getEligibleItems.js create mode 100644 packages/api-plugin-promotions-discounts/src/utils/getEligibleItems.test.js create mode 100644 packages/api-plugin-promotions-offers/src/facts/getEligibleItems.test.js create mode 100644 packages/api-plugin-promotions-offers/src/facts/totalItemAmount.test.js create mode 100644 packages/api-plugin-promotions-offers/src/facts/totalItemCount.test.js diff --git a/apps/reaction/tests/integration/api/mutations/addCartItems/addCartItems.test.js b/apps/reaction/tests/integration/api/mutations/addCartItems/addCartItems.test.js index 875644b07dd..3fd0e06b43c 100644 --- a/apps/reaction/tests/integration/api/mutations/addCartItems/addCartItems.test.js +++ b/apps/reaction/tests/integration/api/mutations/addCartItems/addCartItems.test.js @@ -84,7 +84,6 @@ beforeAll(async () => { { actionKey: "mockActionKey", promotionId: "mockPromotionId", - rules: { conditions: {}, event: { type: "mockType", params: {} } }, discountType: "order", discountCalculationType: "fixed", discountValue: 25124, diff --git a/apps/reaction/tests/integration/api/queries/anonymousCartByCartId/anonymousCartByCartId.test.js b/apps/reaction/tests/integration/api/queries/anonymousCartByCartId/anonymousCartByCartId.test.js index 9a7bcb01a21..1b571c49619 100644 --- a/apps/reaction/tests/integration/api/queries/anonymousCartByCartId/anonymousCartByCartId.test.js +++ b/apps/reaction/tests/integration/api/queries/anonymousCartByCartId/anonymousCartByCartId.test.js @@ -44,7 +44,6 @@ beforeAll(async () => { { actionKey: "mockActionKey", promotionId: "mockPromotionId", - rules: { conditions: {}, event: { type: "mockType", params: {} } }, discountType: "order", discountCalculationType: "fixed", discountValue: 25124, diff --git a/packages/api-plugin-promotions-discounts/src/actions/discountAction.js b/packages/api-plugin-promotions-discounts/src/actions/discountAction.js index 17d7ba5e47a..44efaac48d6 100644 --- a/packages/api-plugin-promotions-discounts/src/actions/discountAction.js +++ b/packages/api-plugin-promotions-discounts/src/actions/discountAction.js @@ -61,9 +61,13 @@ export const discountActionParameters = new SimpleSchema({ type: Number }, condition: { - type: Conditions + type: Conditions, + optional: true + }, + inclusionRules: { + type: Rules }, - rules: { + exclusionRules: { type: Rules, optional: true } @@ -87,7 +91,7 @@ export async function discountActionHandler(context, cart, { promotion, actionPa const { cart: updatedCart } = await functionMap[discountType](context, actionParameters, cart); Logger.info(logCtx, "Completed applying Discount to Cart"); - return updatedCart; + return { updatedCart }; } export default { diff --git a/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js b/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js index fda0d4776fa..414726b89f1 100644 --- a/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js +++ b/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js @@ -55,22 +55,3 @@ test("should call discount shipping function when discountType parameters is shi discountAction.handler(context, cart, params); expect(applyShippingDiscountToCart).toHaveBeenCalledWith(context, params.actionParameters, cart); }); - -test("should return updatedCart when action is completed", async () => { - const modifiedCart = { - _id: "modifiedCartId" - }; - applyItemDiscountToCart.mockResolvedValueOnce({ - cart: modifiedCart - }); - const context = {}; - const cart = {}; - const params = { - promotion: {}, - actionParameters: { - discountType: "item" - } - }; - const updatedCart = await discountAction.handler(context, cart, params); - expect(updatedCart).toEqual(modifiedCart); -}); diff --git a/packages/api-plugin-promotions-discounts/src/simpleSchemas.js b/packages/api-plugin-promotions-discounts/src/simpleSchemas.js index a2755ea658c..df334bc8990 100644 --- a/packages/api-plugin-promotions-discounts/src/simpleSchemas.js +++ b/packages/api-plugin-promotions-discounts/src/simpleSchemas.js @@ -90,11 +90,6 @@ export const CartDiscountedItem = new SimpleSchema({ export const CartDiscount = new SimpleSchema({ "actionKey": String, "promotionId": String, - "rules": { - // because shipping discounts are evaluated later, they need to have inclusion rules on them - type: Rules, - optional: true - }, "discountType": String, "discountCalculationType": String, // types provided by this plugin are flat, percentage and fixed "discountValue": Number, diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/applyItemDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/applyItemDiscountToCart.js index b507035cdd9..1d1cf955a48 100644 --- a/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/applyItemDiscountToCart.js +++ b/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/applyItemDiscountToCart.js @@ -1,6 +1,6 @@ import { createRequire } from "module"; -import { Engine } from "json-rules-engine"; import Logger from "@reactioncommerce/logger"; +import getEligibleItems from "../../../utils/getEligibleItems.js"; const require = createRequire(import.meta.url); @@ -61,35 +61,12 @@ export async function addDiscountToItem(context, discount, { item }) { export default async function applyItemDiscountToCart(context, discountParameters, cart) { const allResults = []; const discountedItems = []; - const { promotions: { operators } } = context; - if (discountParameters.rules) { - const engine = new Engine(); - engine.addRule({ - ...discountParameters.rules, - event: { - type: "rulesCheckPassed" - } - }); - Object.keys(operators).forEach((operatorKey) => { - engine.addOperator(operatorKey, operators[operatorKey]); - }); - for (const item of cart.items) { - // eslint-disable-next-line no-unused-vars - engine.on("success", (event, almanac, ruleResult) => { - discountedItems.push(item); - addDiscountToItem(context, discountParameters, { item }); - }); - const facts = { item }; - // eslint-disable-next-line no-await-in-loop - const results = await engine.run(facts); - allResults.push(results); - } - } else { - for (const item of cart.items) { - discountedItems.push(item); - addDiscountToItem(context, discountParameters, { item }); - } + const filteredItems = await getEligibleItems(context, cart.items, discountParameters); + + for (const item of filteredItems) { + addDiscountToItem(context, discountParameters, { item }); + discountedItems.push(item); } if (discountedItems.length) { diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/recalculateCartItemSubtotal.js b/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/recalculateCartItemSubtotal.js index 25bc84cb59f..fece2749478 100644 --- a/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/recalculateCartItemSubtotal.js +++ b/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/recalculateCartItemSubtotal.js @@ -8,7 +8,7 @@ import accounting from "accounting-js"; */ export default function recalculateCartItemSubtotal(context, item) { let totalDiscount = 0; - const undiscountedAmount = item.price.amount * item.quantity; + const undiscountedAmount = Number(accounting.toFixed(item.price.amount * item.quantity, 2)); item.discounts.forEach((discount) => { const { discountedAmount, discountCalculationType, discountValue, discountType } = discount; diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/applyOrderDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/applyOrderDiscountToCart.js index f9a8e882d15..cdeff3292cf 100644 --- a/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/applyOrderDiscountToCart.js +++ b/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/applyOrderDiscountToCart.js @@ -1,5 +1,6 @@ import { createRequire } from "module"; import Logger from "@reactioncommerce/logger"; +import getEligibleItems from "../../../utils/getEligibleItems.js"; import getCartDiscountAmount from "./getCartDiscountAmount.js"; import splitDiscountForCartItems from "./splitDiscountForCartItems.js"; @@ -39,27 +40,30 @@ export function createDiscountRecord(discount, discountedItems, discountedAmount /** * @summary Apply the order discount to the cart * @param {Object} context - The application context - * @param {Object} discount - The discount to apply + * @param {Object} discountParameters - The discount to apply * @param {Object} cart - The cart to apply the discount to * @returns {Promise} The updated cart */ -export default async function applyOrderDiscountToCart(context, discount, cart) { +export default async function applyOrderDiscountToCart(context, discountParameters, cart) { cart.discounts = cart.discounts || []; const existingDiscount = cart.discounts - .find((cartDiscount) => discount.actionKey === cartDiscount.actionKey && discount.promotionId === cartDiscount.promotionId); + .find((cartDiscount) => discountParameters.actionKey === cartDiscount.actionKey && discountParameters.promotionId === cartDiscount.promotionId); if (existingDiscount) { Logger.warn(logCtx, "Not adding discount because it already exists"); return { cart }; } - const discountAmount = getCartDiscountAmount(context, cart, discount); - const discountedItems = splitDiscountForCartItems(discountAmount, cart.items); + const discountAmount = getCartDiscountAmount(context, cart, discountParameters); + const filteredItems = await getEligibleItems(context, cart.items, discountParameters); + const discountedItems = splitDiscountForCartItems(discountAmount, filteredItems); - cart.discounts.push(createDiscountRecord(discount, discountedItems, discountAmount)); + cart.discounts.push(createDiscountRecord(discountParameters, discountedItems, discountAmount)); - for (const cartItem of cart.items) { - const itemDiscount = discountedItems.find((item) => item._id === cartItem._id); - cartItem.discounts.push(createDiscountRecord(discount, undefined, itemDiscount.amount)); + for (const discountedItem of discountedItems) { + const cartItem = cart.items.find((item) => item._id === discountedItem._id); + if (cart.items.find((item) => item._id === discountedItem._id)) { + cartItem.discounts.push(createDiscountRecord(discountParameters, undefined, discountedItem.amount)); + } } return { cart }; diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/splitDiscountForCartItems.js b/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/splitDiscountForCartItems.js index 2ff310e54f7..0d020c47be6 100644 --- a/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/splitDiscountForCartItems.js +++ b/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/splitDiscountForCartItems.js @@ -8,10 +8,9 @@ import accounting from "accounting-js"; */ export default function splitDiscountForCartItems(totalDiscount, cartItems) { const totalItemPrice = cartItems.reduce((acc, item) => acc + item.subtotal.amount, 0); - const discountForEachItem = {}; - cartItems.forEach((item) => { + const discountForEachItems = cartItems.map((item) => { const discount = (item.subtotal.amount / totalItemPrice) * totalDiscount; - discountForEachItem[item._id] = Number(accounting.toFixed(discount, 2)); + return { _id: item._id, amount: Number(accounting.toFixed(discount, 2)) }; }); - return Object.keys(discountForEachItem).map((key) => ({ _id: key, amount: discountForEachItem[key] })); + return discountForEachItems; } diff --git a/packages/api-plugin-promotions-discounts/src/utils/engineHelpers.js b/packages/api-plugin-promotions-discounts/src/utils/engineHelpers.js new file mode 100644 index 00000000000..1079116f2f6 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/utils/engineHelpers.js @@ -0,0 +1,24 @@ +import { Engine } from "json-rules-engine"; + +/** + * @summary Add the custom operators to the engine + * @param {Object} context - The application context + * @param {Object} rules - The rule to add the operators to + * @returns {Object} Engine - The engine with the operators added + */ +export default function createEngine(context, rules) { + const engine = new Engine(); + const { promotions: { operators } } = context; + Object.keys(operators).forEach((operatorKey) => { + engine.addOperator(operatorKey, operators[operatorKey]); + }); + + engine.addRule({ + ...rules, + event: { + type: "rulesCheckPassed" + } + }); + + return engine; +} diff --git a/packages/api-plugin-promotions-discounts/src/utils/getEligibleItems.js b/packages/api-plugin-promotions-discounts/src/utils/getEligibleItems.js new file mode 100644 index 00000000000..3b6290ba4ed --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/utils/getEligibleItems.js @@ -0,0 +1,44 @@ +import createEngine from "./engineHelpers.js"; + +/** + * @summary return items from the cart that meet inclusion criteria + * @param {Object} context - The application context + * @param {Array} items - The cart items to evaluate for eligible items + * @param {Object} parameters - The parameters to evaluate against + * @return {Promise>} - An array of eligible cart items + */ +export default async function getEligibleItems(context, items, parameters) { + const eligibleItems = []; + if (parameters.inclusionRule) { + const engine = createEngine(context, parameters.inclusionRule); + for (const item of items) { + const facts = { item }; + + // eslint-disable-next-line no-await-in-loop + const results = await engine.run(facts); + const { failureResults } = results; + if (failureResults.length === 0) { + eligibleItems.push(item); + } + } + } else { + eligibleItems.push(...items); + } + + const filteredItems = []; + if (eligibleItems.length > 0 && parameters.exclusionRule) { + const engine = createEngine(context, parameters.exclusionRule); + for (const item of eligibleItems) { + const facts = { item }; + // eslint-disable-next-line no-await-in-loop + const { events } = await engine.run(facts); + if (events.length === 0) { + filteredItems.push(item); + } + } + } else { + filteredItems.push(...eligibleItems); + } + + return filteredItems; +} diff --git a/packages/api-plugin-promotions-discounts/src/utils/getEligibleItems.test.js b/packages/api-plugin-promotions-discounts/src/utils/getEligibleItems.test.js new file mode 100644 index 00000000000..24497c0d6d1 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/utils/getEligibleItems.test.js @@ -0,0 +1,63 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import getEligibleItems from "./getEligibleItems.js"; + +test("should return all items if no rules are provided", async () => { + const items = [{ _id: "1" }, { _id: "2" }, { _id: "3" }]; + const parameters = {}; + const eligibleItems = await getEligibleItems(mockContext, items, parameters); + expect(eligibleItems).toEqual(items); +}); + +test("should return eligible items if inclusion rule is provided", async () => { + const items = [ + { _id: "1", brand: "No1 Brand" }, + { _id: "2", brand: "EOM" }, + { _id: "3", brand: "EOM" } + ]; + const parameters = { + inclusionRule: { + conditions: { + all: [ + { + fact: "item", + path: "$.brand", + operator: "equal", + value: "No1 Brand" + } + ] + } + } + }; + mockContext.promotions = { + operators: { test: jest.fn() } + }; + const eligibleItems = await getEligibleItems(mockContext, items, parameters); + expect(eligibleItems).toEqual([{ _id: "1", brand: "No1 Brand" }]); +}); + +test("should remove ineligible items if exclusion rule is provided", async () => { + const items = [ + { _id: "1", brand: "No1 Brand" }, + { _id: "2", brand: "EOM" }, + { _id: "3", brand: "EOM" } + ]; + const parameters = { + exclusionRule: { + conditions: { + all: [ + { + fact: "item", + path: "$.brand", + operator: "equal", + value: "EOM" + } + ] + } + } + }; + mockContext.promotions = { + operators: { test: jest.fn() } + }; + const filteredItems = await getEligibleItems(mockContext, items, parameters); + expect(filteredItems).toEqual([{ _id: "1", brand: "No1 Brand" }]); +}); diff --git a/packages/api-plugin-promotions-offers/src/facts/getEligibleItems.js b/packages/api-plugin-promotions-offers/src/facts/getEligibleItems.js index 226d24b2783..88d85a8e9c7 100644 --- a/packages/api-plugin-promotions-offers/src/facts/getEligibleItems.js +++ b/packages/api-plugin-promotions-offers/src/facts/getEligibleItems.js @@ -29,7 +29,7 @@ export default async function getEligibleItems(context, params, almanac) { const filteredItems = []; if (eligibleItems.length > 0 && params.exclusionRule) { const engine = createEngine(context, params.exclusionRule); - for (const item of filteredItems) { + for (const item of eligibleItems) { const facts = { item }; // eslint-disable-next-line no-await-in-loop const { events } = await engine.run(facts); diff --git a/packages/api-plugin-promotions-offers/src/facts/getEligibleItems.test.js b/packages/api-plugin-promotions-offers/src/facts/getEligibleItems.test.js new file mode 100644 index 00000000000..4fb1b729cf0 --- /dev/null +++ b/packages/api-plugin-promotions-offers/src/facts/getEligibleItems.test.js @@ -0,0 +1,75 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import getEligibleItems from "./getEligibleItems.js"; + +test("should return all items if no rules are provided", async () => { + const items = [{ _id: "1" }, { _id: "2" }, { _id: "3" }]; + const parameters = {}; + const cart = { _id: "cartId", items }; + const almanac = { + factValue: jest.fn().mockName("factValue").mockReturnValue(cart) + }; + const eligibleItems = await getEligibleItems(mockContext, parameters, almanac); + expect(eligibleItems).toEqual(items); +}); + +test("should return eligible items if inclusion rule is provided", async () => { + const items = [ + { _id: "1", brand: "No1 Brand" }, + { _id: "2", brand: "EOM" }, + { _id: "3", brand: "EOM" } + ]; + const parameters = { + inclusionRule: { + conditions: { + all: [ + { + fact: "item", + path: "$.brand", + operator: "equal", + value: "No1 Brand" + } + ] + } + } + }; + const cart = { _id: "cartId", items }; + const almanac = { + factValue: jest.fn().mockName("factValue").mockReturnValue(cart) + }; + mockContext.promotions = { + operators: { test: jest.fn() } + }; + const eligibleItems = await getEligibleItems(mockContext, parameters, almanac); + expect(eligibleItems).toEqual([{ _id: "1", brand: "No1 Brand" }]); +}); + +test("should remove ineligible items if exclusion rule is provided", async () => { + const items = [ + { _id: "1", brand: "No1 Brand" }, + { _id: "2", brand: "EOM" }, + { _id: "3", brand: "EOM" } + ]; + const parameters = { + exclusionRule: { + conditions: { + all: [ + { + fact: "item", + path: "$.brand", + operator: "equal", + value: "EOM" + } + ] + } + } + }; + const cart = { _id: "cartId", items }; + const almanac = { + factValue: jest.fn().mockName("factValue").mockReturnValue(cart) + }; + mockContext.promotions = { + operators: { test: jest.fn() } + }; + const eligibleItems = await getEligibleItems(mockContext, parameters, almanac); + expect(eligibleItems).toEqual([{ _id: "1", brand: "No1 Brand" }]); +}); diff --git a/packages/api-plugin-promotions-offers/src/facts/totalItemAmount.test.js b/packages/api-plugin-promotions-offers/src/facts/totalItemAmount.test.js new file mode 100644 index 00000000000..9b12efba57d --- /dev/null +++ b/packages/api-plugin-promotions-offers/src/facts/totalItemAmount.test.js @@ -0,0 +1,64 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import totalItemAmount from "./totalItemAmount.js"; + +test("should return correct total item amount from default fact", async () => { + const cart = { + _id: "cartId", + items: [ + { + _id: "1", + price: { + amount: 10 + }, + quantity: 1 + }, + { + _id: "1", + price: { + amount: 2 + }, + quantity: 2 + } + ] + }; + const parameters = { + fromFact: "" + }; + const almanac = { + factValue: jest.fn().mockName("factValue").mockResolvedValue(cart) + }; + const total = await totalItemAmount(mockContext, parameters, almanac); + expect(total).toEqual(14); +}); + +test("should return correct total item amount from provided fact", async () => { + const items = [ + { + _id: "1", + price: { + amount: 10 + }, + quantity: 1 + }, + { + _id: "1", + price: { + amount: 2 + }, + quantity: 2 + } + ]; + const parameters = { + fromFact: "testFact" + }; + const almanac = { + factValue: jest.fn().mockImplementation((fact) => { + if (fact === "testFact") { + return Promise.resolve(items); + } + return null; + }) + }; + const total = await totalItemAmount(mockContext, parameters, almanac); + expect(total).toEqual(14); +}); diff --git a/packages/api-plugin-promotions-offers/src/facts/totalItemCount.js b/packages/api-plugin-promotions-offers/src/facts/totalItemCount.js index 842952e61b2..c029ce39c5c 100644 --- a/packages/api-plugin-promotions-offers/src/facts/totalItemCount.js +++ b/packages/api-plugin-promotions-offers/src/facts/totalItemCount.js @@ -1,9 +1,16 @@ /** * @summary Get the total amount of a discount or promotion - * @param {Object} cart - The cart to get the discount amount for - * @param {Object} parameters - The parameters to pass to the trigger + * @param {Object} context - The application context + * @param {Object} params - The parameters to pass to the fact + * @param {Object} almanac - The almanac to pass to the fact * @returns {Promise} - The total amount of a discount or promotion */ -export default async function totalItemCount(cart, parameters) { - return cart.items.reduce((prev, current) => prev + current.price.amount * current.quantity, 0); +export default async function totalItemCount(context, params, almanac) { + let calculationItems = []; + if (params.fromFact) { + calculationItems = await almanac.factValue(params.fromFact); + } else { + calculationItems = await almanac.factValue("cart").then((cart) => cart.items); + } + return calculationItems.reduce((sum, item) => sum + item.quantity, 0); } diff --git a/packages/api-plugin-promotions-offers/src/facts/totalItemCount.test.js b/packages/api-plugin-promotions-offers/src/facts/totalItemCount.test.js new file mode 100644 index 00000000000..63d5f7e4c56 --- /dev/null +++ b/packages/api-plugin-promotions-offers/src/facts/totalItemCount.test.js @@ -0,0 +1,52 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import totalItemCount from "./totalItemCount.js"; + +test("should return correct total item count from default fact", async () => { + const cart = { + _id: "cartId", + items: [ + { + _id: "1", + quantity: 1 + }, + { + _id: "1", + quantity: 2 + } + ] + }; + const parameters = { + fromFact: "" + }; + const almanac = { + factValue: jest.fn().mockName("factValue").mockResolvedValue(cart) + }; + const total = await totalItemCount(mockContext, parameters, almanac); + expect(total).toEqual(3); +}); + +test("should return correct total item count from provided fact", async () => { + const items = [ + { + _id: "1", + quantity: 1 + }, + { + _id: "1", + quantity: 2 + } + ]; + const parameters = { + fromFact: "testFact" + }; + const almanac = { + factValue: jest.fn().mockImplementation((fact) => { + if (fact === "testFact") { + return Promise.resolve(items); + } + return null; + }) + }; + const total = await totalItemCount(mockContext, parameters, almanac); + expect(total).toEqual(3); +}); diff --git a/packages/api-plugin-promotions-offers/src/triggers/offerTriggerHandler.js b/packages/api-plugin-promotions-offers/src/triggers/offerTriggerHandler.js index 1fdc42fa353..e87f453340e 100644 --- a/packages/api-plugin-promotions-offers/src/triggers/offerTriggerHandler.js +++ b/packages/api-plugin-promotions-offers/src/triggers/offerTriggerHandler.js @@ -1,6 +1,6 @@ import { createRequire } from "module"; import Logger from "@reactioncommerce/logger"; -import { Engine } from "json-rules-engine"; +import createEngine from "../utils/engineHelpers.js"; import { OfferTriggerParameters } from "../simpleSchemas.js"; const require = createRequire(import.meta.url); @@ -26,21 +26,9 @@ const defaultFacts = [{ fact: "eligibleItems", handlerName: "getEligibleItems" } * @returns {Promise} - The answer with offers applied */ export async function offerTriggerHandler(context, enhancedCart, { triggerParameters }) { - const { - promotions: { operators }, - promotionOfferFacts - } = context; - - const engine = new Engine(); - Object.keys(operators).forEach((operatorKey) => { - engine.addOperator(operatorKey, operators[operatorKey]); - }); - engine.addRule({ - ...triggerParameters, - event: { - type: "rulesCheckPassed" - } - }); + const { promotionOfferFacts } = context; + + const engine = createEngine(context, triggerParameters); const facts = { cart: enhancedCart }; diff --git a/packages/api-plugin-promotions-offers/src/triggers/offerTriggerHandler.test.js b/packages/api-plugin-promotions-offers/src/triggers/offerTriggerHandler.test.js index 33fa1973977..b656e35f2e2 100644 --- a/packages/api-plugin-promotions-offers/src/triggers/offerTriggerHandler.test.js +++ b/packages/api-plugin-promotions-offers/src/triggers/offerTriggerHandler.test.js @@ -1,11 +1,18 @@ import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; import merchandiseTotal from "../enhancers/merchandiseTotal.js"; +import createEngine from "../utils/engineHelpers.js"; import { offerTriggerHandler } from "./offerTriggerHandler.js"; +jest.mock("../utils/engineHelpers.js"); + const pluginPromotion = { operators: {} }; +const promotionOfferFacts = { + testHandler: jest.fn().mockName("testFactHandler") +}; + const triggerParameters = { name: "50% off your entire order when you spend more then $200", conditions: { @@ -20,6 +27,13 @@ const triggerParameters = { } }; +beforeEach(() => { + createEngine.mockImplementation((context, rule) => { + const actualCreateEngine = jest.requireActual("../utils/engineHelpers.js").default; + return actualCreateEngine(context, rule); + }); +}); + test("should return true when the cart qualified by promotion", async () => { const cart = { _id: "cartId", @@ -41,3 +55,54 @@ test("should return false when the cart isn't qualified by promotion", async () mockContext.promotions = pluginPromotion; expect(await offerTriggerHandler(mockContext, enhancedCart, { triggerParameters })).toBe(false); }); + +test("should add custom fact when facts provided on parameters", async () => { + const cart = { + _id: "cartId", + items: [{ _id: "product-1", price: { amount: 100 }, quantity: 2 }] + }; + const enhancedCart = merchandiseTotal(mockContext, cart); + + mockContext.promotions = pluginPromotion; + mockContext.promotionOfferFacts = promotionOfferFacts; + const parameters = { + ...triggerParameters, + facts: [ + { + fact: "testFact", + handlerName: "testHandler" + } + ] + }; + const mockAddFact = jest.fn().mockName("addFact"); + createEngine.mockReturnValueOnce({ + addFact: mockAddFact, + run: jest.fn().mockName("run").mockResolvedValue({ failureResults: [] }) + }); + + await offerTriggerHandler(mockContext, enhancedCart, { triggerParameters: parameters }); + + expect(mockAddFact).toHaveBeenCalledWith("eligibleItems", expect.any(Function)); + expect(mockAddFact).toHaveBeenCalledWith("testFact", expect.any(Function)); +}); + +test("should not add custom fact when not provided on parameters", async () => { + const cart = { + _id: "cartId", + items: [{ _id: "product-1", price: { amount: 100 }, quantity: 2 }] + }; + const enhancedCart = merchandiseTotal(mockContext, cart); + + mockContext.promotions = pluginPromotion; + mockContext.promotionOfferFacts = promotionOfferFacts; + const mockAddFact = jest.fn().mockName("addFact"); + createEngine.mockReturnValueOnce({ + addFact: mockAddFact, + run: jest.fn().mockName("run").mockResolvedValue({ failureResults: [] }) + }); + + await offerTriggerHandler(mockContext, enhancedCart, { triggerParameters }); + + expect(mockAddFact).toHaveBeenCalledWith("eligibleItems", expect.any(Function)); + expect(mockAddFact).not.toHaveBeenCalledWith("testFact", expect.any(Function)); +}); diff --git a/packages/api-plugin-promotions/src/handlers/applyPromotions.js b/packages/api-plugin-promotions/src/handlers/applyPromotions.js index 8135ff33fe1..c3ca34fb03d 100644 --- a/packages/api-plugin-promotions/src/handlers/applyPromotions.js +++ b/packages/api-plugin-promotions/src/handlers/applyPromotions.js @@ -58,7 +58,7 @@ export default async function applyPromotions(context, cart, explicitPromotion = unqualifiedPromotions.push(explicitPromotion); } - let enhancedCart = cart; + const enhancedCart = enhanceCart(context, pluginPromotions.enhancers, cart); for (const promotion of unqualifiedPromotions) { if (isPromotionExpired(promotion)) { continue; @@ -70,9 +70,6 @@ export default async function applyPromotions(context, cart, explicitPromotion = continue; } - // eslint-disable-next-line no-await-in-loop - enhancedCart = await enhanceCart(context, pluginPromotions.enhancers, enhancedCart); - for (const trigger of promotion.triggers) { const { triggerKey, triggerParameters } = trigger; const triggerFn = triggerHandleByKey[triggerKey]; @@ -83,10 +80,10 @@ export default async function applyPromotions(context, cart, explicitPromotion = if (!shouldApply) continue; // eslint-disable-next-line no-await-in-loop - const results = await applyAction(context, enhancedCart, { promotion, actionHandleByKey }); - if (results && results.updatedCart) { - enhancedCart = results.updatedCart; - } + await applyAction(context, enhancedCart, { promotion, actionHandleByKey }); + // if (results && results.updatedCart) { + // enhancedCart = results.updatedCart; + // } appliedPromotions.push(promotion); break; } From 0c979c963218705f47d39026a42a61ec8e007028 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Thu, 3 Nov 2022 11:25:37 +0700 Subject: [PATCH 05/19] fix: make getEligibleItems shorter --- .../api-plugin-promotions-discounts/README.md | 1 - .../package.json | 1 - .../src/actions/discountAction.js | 43 ++------- .../src/actions/discountAction.test.js | 18 ++-- .../src/index.js | 14 +-- .../calculateMerchandiseTotal.js | 0 .../calculateMerchandiseTotal.test.js | 0 .../item/addDiscountToOrderItem.js | 0 .../item/applyItemDiscountToCart.js | 38 ++++---- .../item/applyItemDiscountToCart.test.js | 91 ++++++++++--------- .../item/calculateDiscountedItemPrice.js | 0 .../item/calculateDiscountedItemPrice.test.js | 0 .../item/getItemDiscountTotal.js | 0 .../item/getItemDiscountTotal.test.js | 0 .../item/recalculateCartItemSubtotal.js | 0 .../item/recalculateCartItemSubtotal.test.js | 0 .../order/applyOrderDiscountToCart.js | 30 +++--- .../order/applyOrderDiscountToCart.test.js | 38 ++++---- .../order/getCartDiscountAmount.js | 0 .../order/getCartDiscountAmount.test.js | 0 .../order/getCartDiscountTotal.js | 0 .../order/getCartDiscountTotal.test.js | 0 .../order/splitDiscountForCartItems.js | 0 .../order/splitDiscountForCartItems.test.js | 0 .../shipping/applyDiscountsToRates.js | 0 .../shipping/applyShippingDiscountToCart.js | 30 +++--- .../shipping/evaluateRulesAgainstShipping.js | 0 .../shipping/getGroupDisountTotal.js | 0 .../shipping/getShippingDiscountTotal.js | 0 .../src/utils/getEligibleItems.js | 57 ++++++------ .../src/{util => utils}/setDiscountsOnCart.js | 0 .../setDiscountsOnCart.test.js | 0 .../src/xforms/recalculateDiscounts.js | 2 +- .../src/facts/getEligibleItems.js | 57 ++++++------ .../src/triggers/offerTriggerHandler.js | 5 +- .../src/handlers/applyAction.js | 5 +- .../src/handlers/applyPromotions.js | 3 - 37 files changed, 208 insertions(+), 225 deletions(-) rename packages/api-plugin-promotions-discounts/src/{util => utils}/calculateMerchandiseTotal.js (100%) rename packages/api-plugin-promotions-discounts/src/{util => utils}/calculateMerchandiseTotal.test.js (100%) rename packages/api-plugin-promotions-discounts/src/{util => utils}/discountTypes/item/addDiscountToOrderItem.js (100%) rename packages/api-plugin-promotions-discounts/src/{util => utils}/discountTypes/item/applyItemDiscountToCart.js (60%) rename packages/api-plugin-promotions-discounts/src/{util => utils}/discountTypes/item/applyItemDiscountToCart.test.js (69%) rename packages/api-plugin-promotions-discounts/src/{util => utils}/discountTypes/item/calculateDiscountedItemPrice.js (100%) rename packages/api-plugin-promotions-discounts/src/{util => utils}/discountTypes/item/calculateDiscountedItemPrice.test.js (100%) rename packages/api-plugin-promotions-discounts/src/{util => utils}/discountTypes/item/getItemDiscountTotal.js (100%) rename packages/api-plugin-promotions-discounts/src/{util => utils}/discountTypes/item/getItemDiscountTotal.test.js (100%) rename packages/api-plugin-promotions-discounts/src/{util => utils}/discountTypes/item/recalculateCartItemSubtotal.js (100%) rename packages/api-plugin-promotions-discounts/src/{util => utils}/discountTypes/item/recalculateCartItemSubtotal.test.js (100%) rename packages/api-plugin-promotions-discounts/src/{util => utils}/discountTypes/order/applyOrderDiscountToCart.js (65%) rename packages/api-plugin-promotions-discounts/src/{util => utils}/discountTypes/order/applyOrderDiscountToCart.test.js (73%) rename packages/api-plugin-promotions-discounts/src/{util => utils}/discountTypes/order/getCartDiscountAmount.js (100%) rename packages/api-plugin-promotions-discounts/src/{util => utils}/discountTypes/order/getCartDiscountAmount.test.js (100%) rename packages/api-plugin-promotions-discounts/src/{util => utils}/discountTypes/order/getCartDiscountTotal.js (100%) rename packages/api-plugin-promotions-discounts/src/{util => utils}/discountTypes/order/getCartDiscountTotal.test.js (100%) rename packages/api-plugin-promotions-discounts/src/{util => utils}/discountTypes/order/splitDiscountForCartItems.js (100%) rename packages/api-plugin-promotions-discounts/src/{util => utils}/discountTypes/order/splitDiscountForCartItems.test.js (100%) rename packages/api-plugin-promotions-discounts/src/{util => utils}/discountTypes/shipping/applyDiscountsToRates.js (100%) rename packages/api-plugin-promotions-discounts/src/{util => utils}/discountTypes/shipping/applyShippingDiscountToCart.js (72%) rename packages/api-plugin-promotions-discounts/src/{util => utils}/discountTypes/shipping/evaluateRulesAgainstShipping.js (100%) rename packages/api-plugin-promotions-discounts/src/{util => utils}/discountTypes/shipping/getGroupDisountTotal.js (100%) rename packages/api-plugin-promotions-discounts/src/{util => utils}/discountTypes/shipping/getShippingDiscountTotal.js (100%) rename packages/api-plugin-promotions-discounts/src/{util => utils}/setDiscountsOnCart.js (100%) rename packages/api-plugin-promotions-discounts/src/{util => utils}/setDiscountsOnCart.test.js (100%) diff --git a/packages/api-plugin-promotions-discounts/README.md b/packages/api-plugin-promotions-discounts/README.md index 9bf6679c483..e05641b3e32 100644 --- a/packages/api-plugin-promotions-discounts/README.md +++ b/packages/api-plugin-promotions-discounts/README.md @@ -2,7 +2,6 @@ [![npm (scoped)](https://img.shields.io/npm/v/@reactioncommerce/api-plugin-promotions-discounts.svg)](https://www.npmjs.com/package/@reactioncommerce/api-plugin-promotions-discounts) [![CircleCI](https://circleci.com/gh/reactioncommerce/api-plugin-promotions-discounts.svg?style=svg)](https://circleci.com/gh/reactioncommerce/api-plugin-promotions-discounts) -[![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) ## Summary diff --git a/packages/api-plugin-promotions-discounts/package.json b/packages/api-plugin-promotions-discounts/package.json index c6e4e2a9f53..9c23223e4e9 100644 --- a/packages/api-plugin-promotions-discounts/package.json +++ b/packages/api-plugin-promotions-discounts/package.json @@ -31,7 +31,6 @@ "@reactioncommerce/logger": "^1.1.3", "@reactioncommerce/random": "^1.0.2", "accounting-js": "^1.1.1", - "deep-object-diff": "^1.1.7", "json-rules-engine": "^6.1.2", "simpl-schema": "^1.12.3" }, diff --git a/packages/api-plugin-promotions-discounts/src/actions/discountAction.js b/packages/api-plugin-promotions-discounts/src/actions/discountAction.js index 44efaac48d6..8322497530f 100644 --- a/packages/api-plugin-promotions-discounts/src/actions/discountAction.js +++ b/packages/api-plugin-promotions-discounts/src/actions/discountAction.js @@ -1,9 +1,9 @@ import { createRequire } from "module"; import SimpleSchema from "simpl-schema"; import Logger from "@reactioncommerce/logger"; -import applyItemDiscountToCart from "../util/discountTypes/item/applyItemDiscountToCart.js"; -import applyShippingDiscountToCart from "../util/discountTypes/shipping/applyShippingDiscountToCart.js"; -import applyOrderDiscountToCart from "../util/discountTypes/order/applyOrderDiscountToCart.js"; +import applyItemDiscountToCart from "../utils/discountTypes/item/applyItemDiscountToCart.js"; +import applyShippingDiscountToCart from "../utils/discountTypes/shipping/applyShippingDiscountToCart.js"; +import applyOrderDiscountToCart from "../utils/discountTypes/order/applyOrderDiscountToCart.js"; const require = createRequire(import.meta.url); @@ -22,25 +22,6 @@ const functionMap = { order: applyOrderDiscountToCart }; -const Conditions = new SimpleSchema({ - maxUses: { - // total number of uses - type: Number, - defaultValue: 1 - }, - maxUsesPerAccount: { - // Max uses per account - type: SimpleSchema.Integer, - defaultValue: 1, - optional: true - }, - maxUsersPerOrder: { - // Max uses per order - type: Number, - defaultValue: 1 - } -}); - export const Rules = new SimpleSchema({ conditions: { type: Object, @@ -60,10 +41,6 @@ export const discountActionParameters = new SimpleSchema({ discountValue: { type: Number }, - condition: { - type: Conditions, - optional: true - }, inclusionRules: { type: Rules }, @@ -76,19 +53,15 @@ export const discountActionParameters = new SimpleSchema({ * @summary Apply a percentage promotion to the cart * @param {Object} context - The application context * @param {Object} cart - The enhanced cart to apply promotions to - * @param {Object} params.promotion - The promotion to apply - * @param {Object} params.actionParameters - The parameters to pass to the action + * @param {Object} params - The action parameters * @returns {Promise} undefined */ -export async function discountActionHandler(context, cart, { promotion, actionParameters }) { - const { discountType } = actionParameters; - - actionParameters.promotionId = promotion._id; - actionParameters.actionKey = "discounts"; +export async function discountActionHandler(context, cart, params) { + const { discountType } = params.actionParameters; - Logger.info({ actionParameters, cartId: cart._id, ...logCtx }, "applying discount to cart"); + Logger.info({ params, cartId: cart._id, ...logCtx }, "applying discount to cart"); - const { cart: updatedCart } = await functionMap[discountType](context, actionParameters, cart); + const { cart: updatedCart } = await functionMap[discountType](context, params, cart); Logger.info(logCtx, "Completed applying Discount to Cart"); return { updatedCart }; diff --git a/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js b/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js index 414726b89f1..bb2d318591d 100644 --- a/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js +++ b/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js @@ -1,11 +1,11 @@ -import applyItemDiscountToCart from "../util/discountTypes/item/applyItemDiscountToCart.js"; -import applyOrderDiscountToCart from "../util/discountTypes/order/applyOrderDiscountToCart.js"; -import applyShippingDiscountToCart from "../util/discountTypes/shipping/applyShippingDiscountToCart.js"; +import applyItemDiscountToCart from "../utils/discountTypes/item/applyItemDiscountToCart.js"; +import applyOrderDiscountToCart from "../utils/discountTypes/order/applyOrderDiscountToCart.js"; +import applyShippingDiscountToCart from "../utils/discountTypes/shipping/applyShippingDiscountToCart.js"; import discountAction, { discountActionHandler, discountActionParameters } from "./discountAction.js"; -jest.mock("../util/discountTypes/item/applyItemDiscountToCart.js", () => jest.fn()); -jest.mock("../util/discountTypes/order/applyOrderDiscountToCart.js", () => jest.fn()); -jest.mock("../util/discountTypes/shipping/applyShippingDiscountToCart.js", () => jest.fn()); +jest.mock("../utils/discountTypes/item/applyItemDiscountToCart.js", () => jest.fn()); +jest.mock("../utils/discountTypes/order/applyOrderDiscountToCart.js", () => jest.fn()); +jest.mock("../utils/discountTypes/shipping/applyShippingDiscountToCart.js", () => jest.fn()); beforeEach(() => jest.resetAllMocks()); @@ -27,7 +27,7 @@ test("should call discount item function when discountType parameters is item", } }; discountAction.handler(context, cart, params); - expect(applyItemDiscountToCart).toHaveBeenCalledWith(context, params.actionParameters, cart); + expect(applyItemDiscountToCart).toHaveBeenCalledWith(context, params, cart); }); test("should call discount order function when discountType parameters is order", () => { @@ -40,7 +40,7 @@ test("should call discount order function when discountType parameters is order" } }; discountAction.handler(context, cart, params); - expect(applyOrderDiscountToCart).toHaveBeenCalledWith(context, params.actionParameters, cart); + expect(applyOrderDiscountToCart).toHaveBeenCalledWith(context, params, cart); }); test("should call discount shipping function when discountType parameters is shipping", () => { @@ -53,5 +53,5 @@ test("should call discount shipping function when discountType parameters is shi } }; discountAction.handler(context, cart, params); - expect(applyShippingDiscountToCart).toHaveBeenCalledWith(context, params.actionParameters, cart); + expect(applyShippingDiscountToCart).toHaveBeenCalledWith(context, params, cart); }); diff --git a/packages/api-plugin-promotions-discounts/src/index.js b/packages/api-plugin-promotions-discounts/src/index.js index ef8bc7616db..d3e23f19280 100644 --- a/packages/api-plugin-promotions-discounts/src/index.js +++ b/packages/api-plugin-promotions-discounts/src/index.js @@ -1,14 +1,14 @@ import { createRequire } from "module"; -import setDiscountsOnCart from "./util/setDiscountsOnCart.js"; +import setDiscountsOnCart from "./utils/setDiscountsOnCart.js"; import actions from "./actions/index.js"; import methods from "./methods/index.js"; import enhancers from "./enhancers/index.js"; -import addDiscountToOrderItem from "./util/discountTypes/item/addDiscountToOrderItem.js"; -import getCartDiscountTotal from "./util/discountTypes/order/getCartDiscountTotal.js"; -import getItemDiscountTotal from "./util/discountTypes/item/getItemDiscountTotal.js"; -import getShippingDiscountTotal from "./util/discountTypes/shipping/getShippingDiscountTotal.js"; -import getGroupDiscountTotal from "./util/discountTypes/shipping/getGroupDisountTotal.js"; -import applyDiscountsToRates from "./util/discountTypes/shipping/applyDiscountsToRates.js"; +import addDiscountToOrderItem from "./utils/discountTypes/item/addDiscountToOrderItem.js"; +import getCartDiscountTotal from "./utils/discountTypes/order/getCartDiscountTotal.js"; +import getItemDiscountTotal from "./utils/discountTypes/item/getItemDiscountTotal.js"; +import getShippingDiscountTotal from "./utils/discountTypes/shipping/getShippingDiscountTotal.js"; +import getGroupDiscountTotal from "./utils/discountTypes/shipping/getGroupDisountTotal.js"; +import applyDiscountsToRates from "./utils/discountTypes/shipping/applyDiscountsToRates.js"; import preStartup from "./preStartup.js"; import recalculateDiscounts from "./xforms/recalculateDiscounts.js"; import { discountCalculationMethods, registerDiscountCalculationMethod } from "./registration.js"; diff --git a/packages/api-plugin-promotions-discounts/src/util/calculateMerchandiseTotal.js b/packages/api-plugin-promotions-discounts/src/utils/calculateMerchandiseTotal.js similarity index 100% rename from packages/api-plugin-promotions-discounts/src/util/calculateMerchandiseTotal.js rename to packages/api-plugin-promotions-discounts/src/utils/calculateMerchandiseTotal.js diff --git a/packages/api-plugin-promotions-discounts/src/util/calculateMerchandiseTotal.test.js b/packages/api-plugin-promotions-discounts/src/utils/calculateMerchandiseTotal.test.js similarity index 100% rename from packages/api-plugin-promotions-discounts/src/util/calculateMerchandiseTotal.test.js rename to packages/api-plugin-promotions-discounts/src/utils/calculateMerchandiseTotal.test.js diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/addDiscountToOrderItem.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/addDiscountToOrderItem.js similarity index 100% rename from packages/api-plugin-promotions-discounts/src/util/discountTypes/item/addDiscountToOrderItem.js rename to packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/addDiscountToOrderItem.js diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/applyItemDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/applyItemDiscountToCart.js similarity index 60% rename from packages/api-plugin-promotions-discounts/src/util/discountTypes/item/applyItemDiscountToCart.js rename to packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/applyItemDiscountToCart.js index 1d1cf955a48..cc892b7aeb1 100644 --- a/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/applyItemDiscountToCart.js +++ b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/applyItemDiscountToCart.js @@ -16,19 +16,19 @@ const logCtx = { /** * @summary Create a discount object for a cart item * @param {Object} item - The cart item - * @param {Object} discount - The discount to create + * @param {Object} params - The action parameters * @param {Number} discountedAmount - The amount discounted * @returns {Object} - The cart item discount object */ -export function createItemDiscount(item, discount, discountedAmount) { +export function createItemDiscount(item, params) { + const { promotion: { _id }, actionParameters, actionKey } = params; const itemDiscount = { - actionKey: discount.actionKey, - promotionId: discount.promotionId, - discountType: discount.discountType, - discountCalculationType: discount.discountCalculationType, - discountValue: discount.discountValue, - dateApplied: new Date(), - discountedAmount + actionKey, + promotionId: _id, + discountType: actionParameters.discountType, + discountCalculationType: actionParameters.discountCalculationType, + discountValue: actionParameters.discountValue, + dateApplied: new Date() }; return itemDiscount; } @@ -36,36 +36,36 @@ export function createItemDiscount(item, discount, discountedAmount) { /** * @summary Add the discount to the cart item * @param {Object} context - The application context - * @param {Object} discount - The discount to apply + * @param {Object} params - The params to apply * @param {Object} params.item - The cart item to apply the discount to * @returns {Promise} undefined */ -export async function addDiscountToItem(context, discount, { item }) { +export async function addDiscountToItem(context, params, { item }) { + const { promotion: { _id }, actionKey } = params; const existingDiscount = item.discounts - .find((itemDiscount) => discount.actionKey === itemDiscount.actionKey && discount.promotionId === itemDiscount.promotionId); + .find((itemDiscount) => actionKey === itemDiscount.actionKey && _id === itemDiscount.promotionId); if (existingDiscount) { Logger.warn(logCtx, "Not adding discount because it already exists"); return; } - const cartDiscount = createItemDiscount(item, discount); + const cartDiscount = createItemDiscount(item, params); item.discounts.push(cartDiscount); } /** * @summary Apply the discount to the cart * @param {Object} context - The application context - * @param {Object} discountParameters - The discount parameters + * @param {Object} params - The discount parameters * @param {Object} cart - The cart to apply the discount to * @returns {Promise} - The updated cart with results */ -export default async function applyItemDiscountToCart(context, discountParameters, cart) { - const allResults = []; +export default async function applyItemDiscountToCart(context, params, cart) { const discountedItems = []; - const filteredItems = await getEligibleItems(context, cart.items, discountParameters); + const filteredItems = await getEligibleItems(context, cart.items, params.actionParameters); for (const item of filteredItems) { - addDiscountToItem(context, discountParameters, { item }); + addDiscountToItem(context, params, { item }); discountedItems.push(item); } @@ -73,5 +73,5 @@ export default async function applyItemDiscountToCart(context, discountParameter Logger.info(logCtx, "Saved Discount to cart"); } - return { cart, allResults, discountedItems }; + return { cart, discountedItems }; } diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/applyItemDiscountToCart.test.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/applyItemDiscountToCart.test.js similarity index 69% rename from packages/api-plugin-promotions-discounts/src/util/discountTypes/item/applyItemDiscountToCart.test.js rename to packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/applyItemDiscountToCart.test.js index 05721fd9d3e..10986c61839 100644 --- a/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/applyItemDiscountToCart.test.js +++ b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/applyItemDiscountToCart.test.js @@ -18,15 +18,17 @@ test("createItemDiscount should return correct discount item object", () => { const discount = { actionKey: "test", - promotionId: "promotion1", - discountType: "test", - discountCalculationType: "test", - discountValue: 10 + promotion: { + _id: "promotion1" + }, + actionParameters: { + discountType: "test", + discountCalculationType: "test", + discountValue: 10 + } }; - const discountedAmount = 2; - - const itemDiscount = applyItemDiscountToCart.createItemDiscount(item, discount, discountedAmount); + const itemDiscount = applyItemDiscountToCart.createItemDiscount(item, discount); expect(itemDiscount).toEqual({ actionKey: "test", @@ -34,22 +36,23 @@ test("createItemDiscount should return correct discount item object", () => { discountType: "test", discountCalculationType: "test", discountValue: 10, - dateApplied: expect.any(Date), - discountedAmount: 2 + dateApplied: expect.any(Date) }); }); test("addDiscountToItem should add discount to item", () => { - const discount = { + const parameters = { actionKey: "test", - promotionId: "promotion1", - discountType: "test", - discountCalculationType: "test", - discountValue: 10 + promotion: { + _id: "promotion1" + }, + actionParameters: { + discountType: "test", + discountCalculationType: "test", + discountValue: 10 + } }; - jest.spyOn(applyItemDiscountToCart, "createItemDiscount").mockReturnValue(discount); - const item = { _id: "item1", price: { @@ -65,11 +68,9 @@ test("addDiscountToItem should add discount to item", () => { discounts: [] }; - const discountedAmount = 2; + const itemDiscount = applyItemDiscountToCart.createItemDiscount(item, parameters); - const itemDiscount = applyItemDiscountToCart.createItemDiscount(item, discount, discountedAmount); - - applyItemDiscountToCart.addDiscountToItem({}, discount, { item }); + applyItemDiscountToCart.addDiscountToItem({}, parameters, { item }); expect(item.discounts).toEqual([ { @@ -102,10 +103,14 @@ test("should return cart with applied discount when parameters not include rule" const discountParameters = { actionKey: "test", - promotionId: "promotion1", - discountType: "test", - discountCalculationType: "test", - discountValue: 10 + promotion: { + _id: "promotion1" + }, + actionParameters: { + discountType: "test", + discountCalculationType: "test", + discountValue: 10 + } }; jest.spyOn(applyItemDiscountToCart, "addDiscountToItem").mockImplementation(() => {}); @@ -118,7 +123,6 @@ test("should return cart with applied discount when parameters not include rule" expect(result).toEqual({ cart, - allResults: [], discountedItems: [item] }); }); @@ -144,22 +148,26 @@ test("should return cart with applied discount when parameters include rule", as items: [item] }; - const discountParameters = { + const parameters = { actionKey: "test", - promotionId: "promotion1", - discountType: "test", - discountCalculationType: "test", - discountValue: 10, - rules: { - conditions: { - any: [ - { - fact: "item", - path: "$.quantity", - operator: "greaterThanInclusive", - value: 1 - } - ] + promotion: { + _id: "promotion1" + }, + actionParameters: { + discountType: "test", + discountCalculationType: "test", + discountValue: 10, + inclusionRule: { + conditions: { + any: [ + { + fact: "item", + path: "$.quantity", + operator: "greaterThanInclusive", + value: 1 + } + ] + } } } }; @@ -170,11 +178,10 @@ test("should return cart with applied discount when parameters include rule", as operators: {} }; - const result = await applyItemDiscountToCart.default(mockContext, discountParameters, cart); + const result = await applyItemDiscountToCart.default(mockContext, parameters, cart); expect(result).toEqual({ cart, - allResults: expect.any(Object), discountedItems: [item] }); }); diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/calculateDiscountedItemPrice.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/calculateDiscountedItemPrice.js similarity index 100% rename from packages/api-plugin-promotions-discounts/src/util/discountTypes/item/calculateDiscountedItemPrice.js rename to packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/calculateDiscountedItemPrice.js diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/calculateDiscountedItemPrice.test.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/calculateDiscountedItemPrice.test.js similarity index 100% rename from packages/api-plugin-promotions-discounts/src/util/discountTypes/item/calculateDiscountedItemPrice.test.js rename to packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/calculateDiscountedItemPrice.test.js diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/getItemDiscountTotal.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/getItemDiscountTotal.js similarity index 100% rename from packages/api-plugin-promotions-discounts/src/util/discountTypes/item/getItemDiscountTotal.js rename to packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/getItemDiscountTotal.js diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/getItemDiscountTotal.test.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/getItemDiscountTotal.test.js similarity index 100% rename from packages/api-plugin-promotions-discounts/src/util/discountTypes/item/getItemDiscountTotal.test.js rename to packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/getItemDiscountTotal.test.js diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/recalculateCartItemSubtotal.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/recalculateCartItemSubtotal.js similarity index 100% rename from packages/api-plugin-promotions-discounts/src/util/discountTypes/item/recalculateCartItemSubtotal.js rename to packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/recalculateCartItemSubtotal.js diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/item/recalculateCartItemSubtotal.test.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/recalculateCartItemSubtotal.test.js similarity index 100% rename from packages/api-plugin-promotions-discounts/src/util/discountTypes/item/recalculateCartItemSubtotal.test.js rename to packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/recalculateCartItemSubtotal.test.js diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/applyOrderDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/applyOrderDiscountToCart.js similarity index 65% rename from packages/api-plugin-promotions-discounts/src/util/discountTypes/order/applyOrderDiscountToCart.js rename to packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/applyOrderDiscountToCart.js index cdeff3292cf..515ee7ecde8 100644 --- a/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/applyOrderDiscountToCart.js +++ b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/applyOrderDiscountToCart.js @@ -17,18 +17,19 @@ const logCtx = { /** * @summary Map discount record to cart discount - * @param {Object} discount - Discount record + * @param {Object} params - The action parameters * @param {Array} discountedItems - The items that were discounted * @param {Number} discountedAmount - The total amount discounted * @returns {Object} Cart discount record */ -export function createDiscountRecord(discount, discountedItems, discountedAmount) { +export function createDiscountRecord(params, discountedItems, discountedAmount) { + const { promotion: { _id }, actionParameters, actionKey } = params; const itemDiscount = { - actionKey: discount.actionKey, - promotionId: discount.promotionId, - discountType: discount.discountType, - discountCalculationType: discount.discountCalculationType, - discountValue: discount.discountValue, + actionKey, + promotionId: _id, + discountType: actionParameters.discountType, + discountCalculationType: actionParameters.discountCalculationType, + discountValue: actionParameters.discountValue, dateApplied: new Date(), discountedItemType: "item", discountedAmount, @@ -40,29 +41,30 @@ export function createDiscountRecord(discount, discountedItems, discountedAmount /** * @summary Apply the order discount to the cart * @param {Object} context - The application context - * @param {Object} discountParameters - The discount to apply + * @param {Object} params - The action parameters * @param {Object} cart - The cart to apply the discount to * @returns {Promise} The updated cart */ -export default async function applyOrderDiscountToCart(context, discountParameters, cart) { +export default async function applyOrderDiscountToCart(context, params, cart) { cart.discounts = cart.discounts || []; + const { promotion: { _id: promotionId }, actionParameters, actionKey } = params; const existingDiscount = cart.discounts - .find((cartDiscount) => discountParameters.actionKey === cartDiscount.actionKey && discountParameters.promotionId === cartDiscount.promotionId); + .find((cartDiscount) => actionKey === cartDiscount.actionKey && promotionId === cartDiscount.promotionId); if (existingDiscount) { Logger.warn(logCtx, "Not adding discount because it already exists"); return { cart }; } - const discountAmount = getCartDiscountAmount(context, cart, discountParameters); - const filteredItems = await getEligibleItems(context, cart.items, discountParameters); + const discountAmount = getCartDiscountAmount(context, cart, actionParameters); + const filteredItems = await getEligibleItems(context, cart.items, actionParameters); const discountedItems = splitDiscountForCartItems(discountAmount, filteredItems); - cart.discounts.push(createDiscountRecord(discountParameters, discountedItems, discountAmount)); + cart.discounts.push(createDiscountRecord(params, discountedItems, discountAmount)); for (const discountedItem of discountedItems) { const cartItem = cart.items.find((item) => item._id === discountedItem._id); if (cart.items.find((item) => item._id === discountedItem._id)) { - cartItem.discounts.push(createDiscountRecord(discountParameters, undefined, discountedItem.amount)); + cartItem.discounts.push(createDiscountRecord(params, undefined, discountedItem.amount)); } } diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/applyOrderDiscountToCart.test.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/applyOrderDiscountToCart.test.js similarity index 73% rename from packages/api-plugin-promotions-discounts/src/util/discountTypes/order/applyOrderDiscountToCart.test.js rename to packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/applyOrderDiscountToCart.test.js index 0824ec02adc..0f009902412 100644 --- a/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/applyOrderDiscountToCart.test.js +++ b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/applyOrderDiscountToCart.test.js @@ -2,13 +2,16 @@ import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; import * as applyOrderDiscountToCart from "./applyOrderDiscountToCart.js"; test("createDiscountRecord should create discount record", () => { - const discount = { + const parameters = { actionKey: "test", - promotionId: "promotion1", - discountType: "item", - discountCalculationType: "fixed", - discountValue: 10, - discountedAmount: 2 + promotion: { + _id: "promotion1" + }, + actionParameters: { + discountType: "item", + discountCalculationType: "fixed", + discountValue: 10 + } }; const discountedItems = [ @@ -28,7 +31,7 @@ test("createDiscountRecord should create discount record", () => { } ]; - const discountRecord = applyOrderDiscountToCart.createDiscountRecord(discount, discountedItems, 2); + const discountRecord = applyOrderDiscountToCart.createDiscountRecord(parameters, discountedItems, 2); expect(discountRecord).toEqual({ actionKey: "test", @@ -78,20 +81,23 @@ test("should apply order discount to cart", async () => { ] }; - const discount = { + const parameters = { actionKey: "test", - promotionId: "promotion1", - discountType: "order", - discountCalculationType: "fixed", - discountValue: 10, - discountedAmount: 2 + promotion: { _id: "promotion1" }, + actionParameters: { + discountType: "order", + discountCalculationType: "fixed", + discountValue: 10, + discountedAmount: 2 + } }; mockContext.discountCalculationMethods = { fixed: jest.fn().mockReturnValue(2) }; - await applyOrderDiscountToCart.default(mockContext, discount, cart); + await applyOrderDiscountToCart.default(mockContext, parameters, cart); + const orderDiscountItem = applyOrderDiscountToCart.createDiscountRecord(parameters, cart.items, 2); expect(cart.items[0].subtotal).toEqual({ amount: 10, @@ -108,7 +114,5 @@ test("should apply order discount to cart", async () => { }); const discountedItems = cart.items.map((item) => ({ _id: item._id, amount: 1 })); - expect(cart.discounts).toEqual([ - { ...discount, discountedItemType: "item", dateApplied: expect.any(Date), discountedItems } - ]); + expect(cart.discounts).toEqual([{ ...orderDiscountItem, dateApplied: expect.any(Date), discountedItems }]); }); diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/getCartDiscountAmount.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/getCartDiscountAmount.js similarity index 100% rename from packages/api-plugin-promotions-discounts/src/util/discountTypes/order/getCartDiscountAmount.js rename to packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/getCartDiscountAmount.js diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/getCartDiscountAmount.test.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/getCartDiscountAmount.test.js similarity index 100% rename from packages/api-plugin-promotions-discounts/src/util/discountTypes/order/getCartDiscountAmount.test.js rename to packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/getCartDiscountAmount.test.js diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/getCartDiscountTotal.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/getCartDiscountTotal.js similarity index 100% rename from packages/api-plugin-promotions-discounts/src/util/discountTypes/order/getCartDiscountTotal.js rename to packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/getCartDiscountTotal.js diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/getCartDiscountTotal.test.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/getCartDiscountTotal.test.js similarity index 100% rename from packages/api-plugin-promotions-discounts/src/util/discountTypes/order/getCartDiscountTotal.test.js rename to packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/getCartDiscountTotal.test.js diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/splitDiscountForCartItems.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/splitDiscountForCartItems.js similarity index 100% rename from packages/api-plugin-promotions-discounts/src/util/discountTypes/order/splitDiscountForCartItems.js rename to packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/splitDiscountForCartItems.js diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/order/splitDiscountForCartItems.test.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/splitDiscountForCartItems.test.js similarity index 100% rename from packages/api-plugin-promotions-discounts/src/util/discountTypes/order/splitDiscountForCartItems.test.js rename to packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/splitDiscountForCartItems.test.js diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/applyDiscountsToRates.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/shipping/applyDiscountsToRates.js similarity index 100% rename from packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/applyDiscountsToRates.js rename to packages/api-plugin-promotions-discounts/src/utils/discountTypes/shipping/applyDiscountsToRates.js diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/applyShippingDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/shipping/applyShippingDiscountToCart.js similarity index 72% rename from packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/applyShippingDiscountToCart.js rename to packages/api-plugin-promotions-discounts/src/utils/discountTypes/shipping/applyShippingDiscountToCart.js index 2f2edc2422c..0cf2969db4b 100644 --- a/packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/applyShippingDiscountToCart.js +++ b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/shipping/applyShippingDiscountToCart.js @@ -16,21 +16,22 @@ const logCtx = { /** * @summary Add the discount to the shipping record * @param {Object} context - The application context - * @param {Object} discount - The discount to apply + * @param {Object} params - The parameters to apply * @param {Object} param.shipping - The shipping record to apply the discount to * @returns {Promise} undefined */ -async function addDiscountToShipping(context, discount, { shipping }) { +async function addDiscountToShipping(context, params, { shipping }) { for (const shippingRecord of shipping) { if (shippingRecord.discounts) { + const { promotion: { _id: promotionId }, actionKey } = params; const existingDiscount = shippingRecord.discounts - .find((itemDiscount) => discount.actionKey === itemDiscount.actionKey && discount.promotionId === itemDiscount.promotionId); + .find((itemDiscount) => actionKey === itemDiscount.actionKey && promotionId === itemDiscount.promotionId); if (existingDiscount) { Logger.warn(logCtx, "Not adding discount because it already exists"); return; } } - const cartDiscount = createShippingDiscount(shippingRecord, discount); + const cartDiscount = createShippingDiscount(shippingRecord, params); if (shippingRecord.discounts) { shippingRecord.discounts.push(cartDiscount); } else { @@ -42,16 +43,17 @@ async function addDiscountToShipping(context, discount, { shipping }) { /** * @summary Create a discount object for a shipping record * @param {Object} item - The cart item - * @param {Object} discount - The discount to create + * @param {Object} params - The action parameters * @returns {Object} - The shipping discount object */ -function createShippingDiscount(item, discount) { +function createShippingDiscount(item, params) { + const { promotion: { _id }, actionParameters, actionKey } = params; const shippingDiscount = { - actionKey: discount.actionKey, - promotionId: discount.promotionId, - rules: discount.rules, - discountCalculationType: discount.discountCalculationType, - discountValue: discount.discountValue, + actionKey, + promotionId: _id, + rules: actionParameters.rules, + discountCalculationType: actionParameters.discountCalculationType, + discountValue: actionParameters.discountValue, dateApplied: new Date() }; return shippingDiscount; @@ -60,14 +62,14 @@ function createShippingDiscount(item, discount) { /** * @summary Apply a shipping discount to a cart * @param {Object} context - The application context - * @param {Object} discount - The discount to apply + * @param {Object} params - The parameters to apply * @param {Object} cart - The cart to apply the discount to * @returns {Promise} The updated cart */ -export default async function applyShippingDiscountToCart(context, discount, cart) { +export default async function applyShippingDiscountToCart(context, params, cart) { Logger.info(logCtx, "Applying shipping discount"); const { shipping } = cart; - await addDiscountToShipping(context, discount, { shipping }); + await addDiscountToShipping(context, params, { shipping }); // Check existing shipping quotes and discount them Logger.info("Check existing shipping quotes and discount them"); diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/evaluateRulesAgainstShipping.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/shipping/evaluateRulesAgainstShipping.js similarity index 100% rename from packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/evaluateRulesAgainstShipping.js rename to packages/api-plugin-promotions-discounts/src/utils/discountTypes/shipping/evaluateRulesAgainstShipping.js diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/getGroupDisountTotal.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/shipping/getGroupDisountTotal.js similarity index 100% rename from packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/getGroupDisountTotal.js rename to packages/api-plugin-promotions-discounts/src/utils/discountTypes/shipping/getGroupDisountTotal.js diff --git a/packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/getShippingDiscountTotal.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/shipping/getShippingDiscountTotal.js similarity index 100% rename from packages/api-plugin-promotions-discounts/src/util/discountTypes/shipping/getShippingDiscountTotal.js rename to packages/api-plugin-promotions-discounts/src/utils/discountTypes/shipping/getShippingDiscountTotal.js diff --git a/packages/api-plugin-promotions-discounts/src/utils/getEligibleItems.js b/packages/api-plugin-promotions-discounts/src/utils/getEligibleItems.js index 3b6290ba4ed..a1682215bc6 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/getEligibleItems.js +++ b/packages/api-plugin-promotions-discounts/src/utils/getEligibleItems.js @@ -4,41 +4,40 @@ import createEngine from "./engineHelpers.js"; * @summary return items from the cart that meet inclusion criteria * @param {Object} context - The application context * @param {Array} items - The cart items to evaluate for eligible items - * @param {Object} parameters - The parameters to evaluate against + * @param {Object} params - The parameters to evaluate against * @return {Promise>} - An array of eligible cart items */ -export default async function getEligibleItems(context, items, parameters) { - const eligibleItems = []; - if (parameters.inclusionRule) { - const engine = createEngine(context, parameters.inclusionRule); - for (const item of items) { - const facts = { item }; +export default async function getEligibleItems(context, items, params) { + const getCheckMethod = (inclusionRule, exclusionRule) => { + const includeEngine = inclusionRule ? createEngine(context, inclusionRule) : null; + const excludeEngine = exclusionRule ? createEngine(context, exclusionRule) : null; - // eslint-disable-next-line no-await-in-loop - const results = await engine.run(facts); - const { failureResults } = results; - if (failureResults.length === 0) { - eligibleItems.push(item); + return async (item) => { + if (includeEngine) { + const results = await includeEngine.run({ item }); + const { failureResults } = results; + const failedIncludeTest = failureResults.length > 0; + if (failedIncludeTest) return false; } - } - } else { - eligibleItems.push(...items); - } - const filteredItems = []; - if (eligibleItems.length > 0 && parameters.exclusionRule) { - const engine = createEngine(context, parameters.exclusionRule); - for (const item of eligibleItems) { - const facts = { item }; - // eslint-disable-next-line no-await-in-loop - const { events } = await engine.run(facts); - if (events.length === 0) { - filteredItems.push(item); + if (excludeEngine) { + const { failureResults } = await excludeEngine.run({ item }); + const failedExcludeTest = failureResults.length > 0; + return failedExcludeTest; } + + return true; + }; + }; + + const checkerMethod = getCheckMethod(params.inclusionRule, params.exclusionRule); + + const eligibleItems = []; + for (const item of items) { + // eslint-disable-next-line no-await-in-loop + if (await checkerMethod(item)) { + eligibleItems.push(item); } - } else { - filteredItems.push(...eligibleItems); } - - return filteredItems; + return eligibleItems; } diff --git a/packages/api-plugin-promotions-discounts/src/util/setDiscountsOnCart.js b/packages/api-plugin-promotions-discounts/src/utils/setDiscountsOnCart.js similarity index 100% rename from packages/api-plugin-promotions-discounts/src/util/setDiscountsOnCart.js rename to packages/api-plugin-promotions-discounts/src/utils/setDiscountsOnCart.js diff --git a/packages/api-plugin-promotions-discounts/src/util/setDiscountsOnCart.test.js b/packages/api-plugin-promotions-discounts/src/utils/setDiscountsOnCart.test.js similarity index 100% rename from packages/api-plugin-promotions-discounts/src/util/setDiscountsOnCart.test.js rename to packages/api-plugin-promotions-discounts/src/utils/setDiscountsOnCart.test.js diff --git a/packages/api-plugin-promotions-discounts/src/xforms/recalculateDiscounts.js b/packages/api-plugin-promotions-discounts/src/xforms/recalculateDiscounts.js index 794f4088291..b64cee2a68b 100644 --- a/packages/api-plugin-promotions-discounts/src/xforms/recalculateDiscounts.js +++ b/packages/api-plugin-promotions-discounts/src/xforms/recalculateDiscounts.js @@ -1,4 +1,4 @@ -import addDiscountToOrderItem from "../util/discountTypes/item/addDiscountToOrderItem.js"; +import addDiscountToOrderItem from "../utils/discountTypes/item/addDiscountToOrderItem.js"; /** * @summary Recalculates discounts on an order diff --git a/packages/api-plugin-promotions-offers/src/facts/getEligibleItems.js b/packages/api-plugin-promotions-offers/src/facts/getEligibleItems.js index 88d85a8e9c7..1ac79ffeb68 100644 --- a/packages/api-plugin-promotions-offers/src/facts/getEligibleItems.js +++ b/packages/api-plugin-promotions-offers/src/facts/getEligibleItems.js @@ -3,43 +3,42 @@ import createEngine from "../utils/engineHelpers.js"; /** * @summary return items from the cart that meet inclusion criteria * @param {Object} context - The application context - * @param {Object} params - the cart to evaluate for eligible items + * @param {Object} params - The parameters to evaluate against * @param {Object} almanac - the rule to evaluate against * @return {Promise>} - An array of eligible cart items */ export default async function getEligibleItems(context, params, almanac) { - const cart = await almanac.factValue("cart"); - const eligibleItems = []; - if (params.inclusionRule) { - const engine = createEngine(context, params.inclusionRule); - for (const item of cart.items) { - const facts = { item }; + const getCheckMethod = (inclusionRule, exclusionRule) => { + const includeEngine = inclusionRule ? createEngine(context, inclusionRule) : null; + const excludeEngine = exclusionRule ? createEngine(context, exclusionRule) : null; - // eslint-disable-next-line no-await-in-loop - const results = await engine.run(facts); - const { failureResults } = results; - if (failureResults.length === 0) { - eligibleItems.push(item); + return async (item) => { + if (includeEngine) { + const results = await includeEngine.run({ item }); + const { failureResults } = results; + const failedIncludeTest = failureResults.length > 0; + if (failedIncludeTest) return false; } - } - } else { - eligibleItems.push(...cart.items); - } - const filteredItems = []; - if (eligibleItems.length > 0 && params.exclusionRule) { - const engine = createEngine(context, params.exclusionRule); - for (const item of eligibleItems) { - const facts = { item }; - // eslint-disable-next-line no-await-in-loop - const { events } = await engine.run(facts); - if (events.length === 0) { - filteredItems.push(item); + if (excludeEngine) { + const { failureResults } = await excludeEngine.run({ item }); + const failedExcludeTest = failureResults.length > 0; + return failedExcludeTest; } + + return true; + }; + }; + + const checkerMethod = getCheckMethod(params.inclusionRule, params.exclusionRule); + + const cart = await almanac.factValue("cart"); + const eligibleItems = []; + for (const item of cart.items) { + // eslint-disable-next-line no-await-in-loop + if (await checkerMethod(item)) { + eligibleItems.push(item); } - } else { - filteredItems.push(...eligibleItems); } - - return filteredItems; + return eligibleItems; } diff --git a/packages/api-plugin-promotions-offers/src/triggers/offerTriggerHandler.js b/packages/api-plugin-promotions-offers/src/triggers/offerTriggerHandler.js index e87f453340e..793f5072f28 100644 --- a/packages/api-plugin-promotions-offers/src/triggers/offerTriggerHandler.js +++ b/packages/api-plugin-promotions-offers/src/triggers/offerTriggerHandler.js @@ -34,7 +34,10 @@ export async function offerTriggerHandler(context, enhancedCart, { triggerParame const allFacts = [...defaultFacts, ...(triggerParameters.facts || [])]; for (const { fact, handlerName, fromFact } of allFacts) { - engine.addFact(fact, async (params, almanac) => promotionOfferFacts[handlerName](context, { ...triggerParameters, rulePrams: params, fromFact }, almanac)); + engine.addFact(fact, (params, almanac) => { + const factParams = { ...triggerParameters, rulePrams: params, fromFact }; + return promotionOfferFacts[handlerName](context, factParams, almanac); + }); } const results = await engine.run(facts); diff --git a/packages/api-plugin-promotions/src/handlers/applyAction.js b/packages/api-plugin-promotions/src/handlers/applyAction.js index 071f50dd035..87435dde2dd 100644 --- a/packages/api-plugin-promotions/src/handlers/applyAction.js +++ b/packages/api-plugin-promotions/src/handlers/applyAction.js @@ -9,11 +9,10 @@ */ export default async function applyAction(context, enhancedCart, { promotion, actionHandleByKey }) { for (const action of promotion.actions) { - const { actionKey, actionParameters } = action; - const actionFn = actionHandleByKey[actionKey]; + const actionFn = actionHandleByKey[action.actionKey]; if (!actionFn) continue; // eslint-disable-next-line no-await-in-loop - await actionFn.handler(context, enhancedCart, { promotion, actionParameters }); + await actionFn.handler(context, enhancedCart, { promotion, ...action }); } } diff --git a/packages/api-plugin-promotions/src/handlers/applyPromotions.js b/packages/api-plugin-promotions/src/handlers/applyPromotions.js index c3ca34fb03d..ef238fd4945 100644 --- a/packages/api-plugin-promotions/src/handlers/applyPromotions.js +++ b/packages/api-plugin-promotions/src/handlers/applyPromotions.js @@ -81,9 +81,6 @@ export default async function applyPromotions(context, cart, explicitPromotion = // eslint-disable-next-line no-await-in-loop await applyAction(context, enhancedCart, { promotion, actionHandleByKey }); - // if (results && results.updatedCart) { - // enhancedCart = results.updatedCart; - // } appliedPromotions.push(promotion); break; } From 0eac0d9e0f3fa9e26db10f453dccd2892f6b4e50 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Fri, 4 Nov 2022 08:45:01 +0700 Subject: [PATCH 06/19] feat: refactor promotion discount plugin --- apps/reaction/plugins.json | 2 - packages/api-plugin-carts/src/registration.js | 2 + .../src/util/updateGroupTotals.js | 9 +- .../src/actions/discountAction.js | 34 +++++++- .../src/actions/discountAction.test.js | 48 ++++++++++- .../item/applyItemDiscountToCart.js | 40 +++------ .../item/applyItemDiscountToCart.test.js | 80 +++++++++++++++++ .../order/applyOrderDiscountToCart.js | 86 +++++++++++++++++++ .../order/applyOrderDiscountToCart.test.js | 85 ++++++++++++++++++ .../shipping/applyDiscountsToRates.js | 0 .../shipping/applyShippingDiscountToCart.js | 2 +- .../shipping/evaluateRulesAgainstShipping.js | 0 .../shipping/getGroupDisountTotal.js | 0 .../shipping/getShippingDiscountTotal.js | 0 .../src/enhancers/index.js | 3 - .../src/enhancers/resetCartDiscountState.js | 23 ----- .../enhancers/resetCartDiscountState.test.js | 45 ---------- .../src/index.js | 37 +++----- .../src/preStartup.js | 4 + .../src/queries/getDiscountsTotalForCart.js | 23 +++++ .../src/queries/index.js | 5 ++ .../src/simpleSchemas.js | 35 -------- .../src/utils/addDiscountToOrderItem.js | 18 ++++ .../src/utils/calculateMerchandiseTotal.js | 12 --- .../item/addDiscountToOrderItem.js | 32 ------- .../item/calculateDiscountedItemPrice.js | 21 ----- .../item/calculateDiscountedItemPrice.test.js | 22 ----- .../item/recalculateCartItemSubtotal.test.js | 80 ----------------- .../order/applyOrderDiscountToCart.js | 72 ---------------- .../order/getCartDiscountAmount.js | 16 ---- .../order/getCartDiscountAmount.test.js | 41 --------- .../order/splitDiscountForCartItems.js | 16 ---- .../order/splitDiscountForCartItems.test.js | 45 ---------- .../order => }/getCartDiscountTotal.js | 0 .../order => }/getCartDiscountTotal.test.js | 0 .../item => }/getItemDiscountTotal.js | 0 .../item => }/getItemDiscountTotal.test.js | 0 .../src/utils/getTotalDiscountOnCart.js | 18 ++++ .../src/utils/getTotalEligibleItemsAmount.js | 12 +++ ...js => getTotalEligibleItemsAmount.test.js} | 4 +- .../item => }/recalculateCartItemSubtotal.js | 5 +- .../src/utils/setDiscountsOnCart.js | 25 ------ .../src/utils/setDiscountsOnCart.test.js | 54 ------------ .../src/xforms/recalculateDiscounts.js | 17 ---- .../src/facts/totalItemAmount.js | 9 +- .../src/facts/totalItemCount.js | 9 +- .../src/simpleSchemas.js | 24 +----- .../src/triggers/offerTriggerHandler.js | 9 +- .../api-plugin-promotions/src/actions/noop.js | 3 +- .../src/handlers/applyAction.js | 18 ---- .../src/handlers/applyAction.test.js | 19 ---- .../src/handlers/applyPromotions.js | 19 +++- 52 files changed, 470 insertions(+), 713 deletions(-) rename packages/api-plugin-promotions-discounts/src/{utils => }/discountTypes/item/applyItemDiscountToCart.js (56%) rename packages/api-plugin-promotions-discounts/src/{utils => }/discountTypes/item/applyItemDiscountToCart.test.js (68%) create mode 100644 packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.js rename packages/api-plugin-promotions-discounts/src/{utils => }/discountTypes/order/applyOrderDiscountToCart.test.js (63%) rename packages/api-plugin-promotions-discounts/src/{utils => }/discountTypes/shipping/applyDiscountsToRates.js (100%) rename packages/api-plugin-promotions-discounts/src/{utils => }/discountTypes/shipping/applyShippingDiscountToCart.js (98%) rename packages/api-plugin-promotions-discounts/src/{utils => }/discountTypes/shipping/evaluateRulesAgainstShipping.js (100%) rename packages/api-plugin-promotions-discounts/src/{utils => }/discountTypes/shipping/getGroupDisountTotal.js (100%) rename packages/api-plugin-promotions-discounts/src/{utils => }/discountTypes/shipping/getShippingDiscountTotal.js (100%) delete mode 100644 packages/api-plugin-promotions-discounts/src/enhancers/index.js delete mode 100644 packages/api-plugin-promotions-discounts/src/enhancers/resetCartDiscountState.js delete mode 100644 packages/api-plugin-promotions-discounts/src/enhancers/resetCartDiscountState.test.js create mode 100644 packages/api-plugin-promotions-discounts/src/queries/getDiscountsTotalForCart.js create mode 100644 packages/api-plugin-promotions-discounts/src/queries/index.js create mode 100644 packages/api-plugin-promotions-discounts/src/utils/addDiscountToOrderItem.js delete mode 100644 packages/api-plugin-promotions-discounts/src/utils/calculateMerchandiseTotal.js delete mode 100644 packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/addDiscountToOrderItem.js delete mode 100644 packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/calculateDiscountedItemPrice.js delete mode 100644 packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/calculateDiscountedItemPrice.test.js delete mode 100644 packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/recalculateCartItemSubtotal.test.js delete mode 100644 packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/applyOrderDiscountToCart.js delete mode 100644 packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/getCartDiscountAmount.js delete mode 100644 packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/getCartDiscountAmount.test.js delete mode 100644 packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/splitDiscountForCartItems.js delete mode 100644 packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/splitDiscountForCartItems.test.js rename packages/api-plugin-promotions-discounts/src/utils/{discountTypes/order => }/getCartDiscountTotal.js (100%) rename packages/api-plugin-promotions-discounts/src/utils/{discountTypes/order => }/getCartDiscountTotal.test.js (100%) rename packages/api-plugin-promotions-discounts/src/utils/{discountTypes/item => }/getItemDiscountTotal.js (100%) rename packages/api-plugin-promotions-discounts/src/utils/{discountTypes/item => }/getItemDiscountTotal.test.js (100%) create mode 100644 packages/api-plugin-promotions-discounts/src/utils/getTotalDiscountOnCart.js create mode 100644 packages/api-plugin-promotions-discounts/src/utils/getTotalEligibleItemsAmount.js rename packages/api-plugin-promotions-discounts/src/utils/{calculateMerchandiseTotal.test.js => getTotalEligibleItemsAmount.test.js} (90%) rename packages/api-plugin-promotions-discounts/src/utils/{discountTypes/item => }/recalculateCartItemSubtotal.js (83%) delete mode 100644 packages/api-plugin-promotions-discounts/src/utils/setDiscountsOnCart.js delete mode 100644 packages/api-plugin-promotions-discounts/src/utils/setDiscountsOnCart.test.js delete mode 100644 packages/api-plugin-promotions-discounts/src/xforms/recalculateDiscounts.js delete mode 100644 packages/api-plugin-promotions/src/handlers/applyAction.js delete mode 100644 packages/api-plugin-promotions/src/handlers/applyAction.test.js diff --git a/apps/reaction/plugins.json b/apps/reaction/plugins.json index cd191a21ba8..0fd55ef0e87 100644 --- a/apps/reaction/plugins.json +++ b/apps/reaction/plugins.json @@ -25,8 +25,6 @@ "payments": "@reactioncommerce/api-plugin-payments", "paymentsStripeSCA": "@reactioncommerce/api-plugin-payments-stripe-sca", "paymentsExample": "@reactioncommerce/api-plugin-payments-example", - "discounts": "@reactioncommerce/api-plugin-discounts", - "discountCodes": "@reactioncommerce/api-plugin-discounts-codes", "surcharges": "@reactioncommerce/api-plugin-surcharges", "shipments": "@reactioncommerce/api-plugin-shipments", "shipmentsFlatRate": "@reactioncommerce/api-plugin-shipments-flat-rate", diff --git a/packages/api-plugin-carts/src/registration.js b/packages/api-plugin-carts/src/registration.js index f9364cacdec..19e1c3d51a3 100644 --- a/packages/api-plugin-carts/src/registration.js +++ b/packages/api-plugin-carts/src/registration.js @@ -23,5 +23,7 @@ export function registerPluginHandlerForCart({ name, cart }) { cartTransforms.push(...transforms); cartTransforms.sort((prev, next) => prev.priority - next.priority); + + console.log(cartTransforms); } } diff --git a/packages/api-plugin-orders/src/util/updateGroupTotals.js b/packages/api-plugin-orders/src/util/updateGroupTotals.js index 48c4ba98d58..ded16ddcbdd 100644 --- a/packages/api-plugin-orders/src/util/updateGroupTotals.js +++ b/packages/api-plugin-orders/src/util/updateGroupTotals.js @@ -80,17 +80,10 @@ export default async function updateGroupTotals(context, { }); if (expectedGroupTotal) { - // For now we expect that the client has NOT included discounts in the expected total it sent. - // Note that we don't currently know which parts of `discountTotal` go with which fulfillment groups. - // This needs to be rewritten soon for discounts to work when there are multiple fulfillment groups. - // Probably the client should be sending all applied discount IDs and amounts in the order input (by group), - // and include total discount in `groupInput.totalPrice`, and then we simply verify that they are valid here. - const expectedTotal = Math.max(expectedGroupTotal - discountTotal, 0); - // Compare expected and actual totals to make sure client sees correct calculated price // Error if we calculate total price differently from what the client has shown as the preview. // It's important to keep this after adding and verifying the shipmentMethod and order item prices. - compareExpectedAndActualTotals(group.invoice.total, expectedTotal); + compareExpectedAndActualTotals(group.invoice.total, expectedGroupTotal); } return { diff --git a/packages/api-plugin-promotions-discounts/src/actions/discountAction.js b/packages/api-plugin-promotions-discounts/src/actions/discountAction.js index 8322497530f..1fece127a31 100644 --- a/packages/api-plugin-promotions-discounts/src/actions/discountAction.js +++ b/packages/api-plugin-promotions-discounts/src/actions/discountAction.js @@ -1,9 +1,9 @@ import { createRequire } from "module"; import SimpleSchema from "simpl-schema"; import Logger from "@reactioncommerce/logger"; -import applyItemDiscountToCart from "../utils/discountTypes/item/applyItemDiscountToCart.js"; -import applyShippingDiscountToCart from "../utils/discountTypes/shipping/applyShippingDiscountToCart.js"; -import applyOrderDiscountToCart from "../utils/discountTypes/order/applyOrderDiscountToCart.js"; +import applyItemDiscountToCart from "../discountTypes/item/applyItemDiscountToCart.js"; +import applyShippingDiscountToCart from "../discountTypes/shipping/applyShippingDiscountToCart.js"; +import applyOrderDiscountToCart from "../discountTypes/order/applyOrderDiscountToCart.js"; const require = createRequire(import.meta.url); @@ -49,6 +49,31 @@ export const discountActionParameters = new SimpleSchema({ optional: true } }); + +/** + * @summary Clean up the discount on the cart + * @param {Object} context - The application context + * @param {Object} cart - The cart to clean up the discount on + * @return {void} undefined + */ +export async function discountActionCleanup(context, cart) { + cart.discounts = []; + cart.discount = 0; + cart.items = cart.items.map((item) => { + item.discounts = []; + item.subtotal = { + amount: item.price.amount * item.quantity, + currencyCode: item.subtotal.currencyCode + }; + return item; + }); + + // todo: add reset logic for the shipping + // cart.shipping = cart.shipping.map((shipping) => ({ ...shipping, discounts: [] })); + + return cart; +} + /** * @summary Apply a percentage promotion to the cart * @param {Object} context - The application context @@ -70,5 +95,6 @@ export async function discountActionHandler(context, cart, params) { export default { key: "discounts", handler: discountActionHandler, - paramSchema: discountActionParameters + paramSchema: discountActionParameters, + cleanup: discountActionCleanup }; diff --git a/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js b/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js index bb2d318591d..181a05baa27 100644 --- a/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js +++ b/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js @@ -1,7 +1,7 @@ import applyItemDiscountToCart from "../utils/discountTypes/item/applyItemDiscountToCart.js"; import applyOrderDiscountToCart from "../utils/discountTypes/order/applyOrderDiscountToCart.js"; import applyShippingDiscountToCart from "../utils/discountTypes/shipping/applyShippingDiscountToCart.js"; -import discountAction, { discountActionHandler, discountActionParameters } from "./discountAction.js"; +import discountAction, { discountActionCleanup, discountActionHandler, discountActionParameters } from "./discountAction.js"; jest.mock("../utils/discountTypes/item/applyItemDiscountToCart.js", () => jest.fn()); jest.mock("../utils/discountTypes/order/applyOrderDiscountToCart.js", () => jest.fn()); @@ -55,3 +55,49 @@ test("should call discount shipping function when discountType parameters is shi discountAction.handler(context, cart, params); expect(applyShippingDiscountToCart).toHaveBeenCalledWith(context, params, cart); }); + +describe("cleanup", () => { + test("should reset the cart discount state", () => { + const cart = { + discounts: [{ _id: "discount1" }], + discount: 10, + items: [ + { + _id: "item1", + discounts: [{ _id: "discount1" }], + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + } + } + ] + }; + + const updatedCart = discountActionCleanup({}, cart); + + expect(updatedCart).toEqual({ + discounts: [], + discount: 0, + items: [ + { + _id: "item1", + discounts: [], + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 12, + currencyCode: "USD" + } + } + ] + }); + }); +}); diff --git a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/applyItemDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/discountTypes/item/applyItemDiscountToCart.js similarity index 56% rename from packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/applyItemDiscountToCart.js rename to packages/api-plugin-promotions-discounts/src/discountTypes/item/applyItemDiscountToCart.js index cc892b7aeb1..20cb625c057 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/applyItemDiscountToCart.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/item/applyItemDiscountToCart.js @@ -1,10 +1,13 @@ import { createRequire } from "module"; import Logger from "@reactioncommerce/logger"; -import getEligibleItems from "../../../utils/getEligibleItems.js"; + +import getEligibleItems from "../../utils/getEligibleItems.js"; +import recalculateCartItemSubtotal from "../../utils/recalculateCartItemSubtotal.js"; +import getTotalDiscountOnCart from "../../utils/getTotalDiscountOnCart.js"; const require = createRequire(import.meta.url); -const pkg = require("../../../../package.json"); +const pkg = require("../../../package.json"); const { name, version } = pkg; const logCtx = { @@ -15,16 +18,14 @@ const logCtx = { /** * @summary Create a discount object for a cart item - * @param {Object} item - The cart item * @param {Object} params - The action parameters * @param {Number} discountedAmount - The amount discounted * @returns {Object} - The cart item discount object */ -export function createItemDiscount(item, params) { - const { promotion: { _id }, actionParameters, actionKey } = params; +export function createItemDiscount(params) { + const { promotion, actionParameters } = params; const itemDiscount = { - actionKey, - promotionId: _id, + promotionId: promotion._id, discountType: actionParameters.discountType, discountCalculationType: actionParameters.discountCalculationType, discountValue: actionParameters.discountValue, @@ -33,25 +34,6 @@ export function createItemDiscount(item, params) { return itemDiscount; } -/** - * @summary Add the discount to the cart item - * @param {Object} context - The application context - * @param {Object} params - The params to apply - * @param {Object} params.item - The cart item to apply the discount to - * @returns {Promise} undefined - */ -export async function addDiscountToItem(context, params, { item }) { - const { promotion: { _id }, actionKey } = params; - const existingDiscount = item.discounts - .find((itemDiscount) => actionKey === itemDiscount.actionKey && _id === itemDiscount.promotionId); - if (existingDiscount) { - Logger.warn(logCtx, "Not adding discount because it already exists"); - return; - } - const cartDiscount = createItemDiscount(item, params); - item.discounts.push(cartDiscount); -} - /** * @summary Apply the discount to the cart * @param {Object} context - The application context @@ -65,10 +47,14 @@ export default async function applyItemDiscountToCart(context, params, cart) { const filteredItems = await getEligibleItems(context, cart.items, params.actionParameters); for (const item of filteredItems) { - addDiscountToItem(context, params, { item }); + const cartDiscount = createItemDiscount(params); + item.discounts.push(cartDiscount); discountedItems.push(item); + recalculateCartItemSubtotal(context, item); } + cart.discount = getTotalDiscountOnCart(cart); + if (discountedItems.length) { Logger.info(logCtx, "Saved Discount to cart"); } diff --git a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/applyItemDiscountToCart.test.js b/packages/api-plugin-promotions-discounts/src/discountTypes/item/applyItemDiscountToCart.test.js similarity index 68% rename from packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/applyItemDiscountToCart.test.js rename to packages/api-plugin-promotions-discounts/src/discountTypes/item/applyItemDiscountToCart.test.js index 10986c61839..b296c6894a1 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/applyItemDiscountToCart.test.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/item/applyItemDiscountToCart.test.js @@ -185,3 +185,83 @@ test("should return cart with applied discount when parameters include rule", as discountedItems: [item] }); }); + +describe("recalculateCartItemSubtotal", () => { + test("should recalculate the item subtotal with discountType is item", () => { + const item = { + _id: "item1", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + }, + discounts: [] + }; + + const discount = { + actionKey: "test", + promotionId: "promotion1", + discountType: "item", + discountCalculationType: "fixed", + discountValue: 10, + discountedAmount: 2 + }; + + item.discounts.push(discount); + + mockContext.discountCalculationMethods = { + fixed: jest.fn().mockReturnValue(2) + }; + + applyItemDiscountToCart.recalculateCartItemSubtotal(mockContext, item); + + expect(item.subtotal).toEqual({ + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + }); + }); + + test("should recalculate the item subtotal with discountType is order", () => { + const item = { + _id: "item1", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + }, + discounts: [] + }; + + const discount = { + actionKey: "test", + promotionId: "promotion1", + discountType: "order", + discountCalculationType: "fixed", + discountValue: 10, + discountedAmount: 5 + }; + + item.discounts.push(discount); + + applyItemDiscountToCart.recalculateCartItemSubtotal(mockContext, item); + + expect(item.subtotal).toEqual({ + amount: 7, + currencyCode: "USD", + discount: 5, + undiscountedAmount: 12 + }); + }); +}); diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.js new file mode 100644 index 00000000000..1aa4deaa204 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.js @@ -0,0 +1,86 @@ +import accounting from "accounting-js"; +import getEligibleItems from "../../utils/getEligibleItems.js"; +import getTotalEligibleItemsAmount from "../../utils/getTotalEligibleItemsAmount.js"; +import getTotalDiscountOnCart from "../../utils/getTotalDiscountOnCart.js"; +import recalculateCartItemSubtotal from "../../utils/recalculateCartItemSubtotal.js"; + +/** + * @summary Map discount record to cart discount + * @param {Object} params - The action parameters + * @param {Array} discountedItems - The items that were discounted + * @param {Number} discountedAmount - The total amount discounted + * @returns {Object} Cart discount record + */ +export function createDiscountRecord(params, discountedItems, discountedAmount) { + const { promotion, actionParameters } = params; + const itemDiscount = { + promotionId: promotion._id, + discountType: actionParameters.discountType, + discountCalculationType: actionParameters.discountCalculationType, + discountValue: actionParameters.discountValue, + dateApplied: new Date(), + discountedItemType: "item", + discountedAmount, + discountedItems + }; + return itemDiscount; +} + +/** + * @summary Get the discount amount for a discount item + * @param {Object} context - The application context + * @param {Array} items - The cart to calculate the discount for + * @param {Object} discount - The discount to calculate the discount amount for + * @returns {Number} - The discount amount + */ +export function getCartTotalAmount(context, items, discount) { + const merchandiseTotal = getTotalEligibleItemsAmount(items); + const { discountCalculationType, discountValue } = discount; + const appliedDiscount = context.discountCalculationMethods[discountCalculationType](discountValue, merchandiseTotal); + return Number(accounting.toFixed(appliedDiscount, 2)); +} + +/** + * @summary Splits a discount across all cart items + * @param {Number} totalDiscount - The total discount to split + * @param {Array} cartItems - The cart items to split the discount across + * @returns {void} undefined + */ +export function splitDiscountForCartItems(totalDiscount, cartItems) { + const totalItemPrice = cartItems.reduce((acc, item) => acc + item.subtotal.amount, 0); + const discountForEachItems = cartItems.map((item) => { + const discount = (item.subtotal.amount / totalItemPrice) * totalDiscount; + return { _id: item._id, amount: Number(accounting.toFixed(discount, 2)) }; + }); + return discountForEachItems; +} + +/** + * @summary Apply the order discount to the cart + * @param {Object} context - The application context + * @param {Object} params - The action parameters + * @param {Object} cart - The cart to apply the discount to + * @returns {Promise} The updated cart + */ +export default async function applyOrderDiscountToCart(context, params, cart) { + cart.discounts = cart.discounts || []; + const { actionParameters } = params; + + const filteredItems = await getEligibleItems(context, cart.items, actionParameters); + const discountAmount = getCartTotalAmount(context, filteredItems, actionParameters); + const discountedItems = splitDiscountForCartItems(discountAmount, filteredItems); + + cart.discounts.push(createDiscountRecord(params, discountedItems, discountAmount)); + + for (const discountedItem of discountedItems) { + const cartItem = cart.items.find(({ _id }) => _id === discountedItem._id); + if (cartItem) { + cartItem.discounts.push(createDiscountRecord(params, undefined, discountedItem.amount)); + recalculateCartItemSubtotal(context, cartItem); + } + } + + cart.discount = getTotalDiscountOnCart(cart); + + return { cart }; +} diff --git a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/applyOrderDiscountToCart.test.js b/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.test.js similarity index 63% rename from packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/applyOrderDiscountToCart.test.js rename to packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.test.js index 0f009902412..79c705e08f1 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/applyOrderDiscountToCart.test.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.test.js @@ -116,3 +116,88 @@ test("should apply order discount to cart", async () => { const discountedItems = cart.items.map((item) => ({ _id: item._id, amount: 1 })); expect(cart.discounts).toEqual([{ ...orderDiscountItem, dateApplied: expect.any(Date), discountedItems }]); }); + + +test(" get should return correct discount amount", () => { + const cart = { + _id: "cart1", + items: [ + { + _id: "item1", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + } + } + ], + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + } + }; + + const discount = { + discountCalculationType: "fixed", + discountValue: 10 + }; + + mockContext.discountCalculationMethods = { + fixed: jest.fn().mockReturnValue(10) + }; + + const discountAmount = getCartDiscountAmount(mockContext, cart, discount); + expect(discountAmount).toEqual(10); +}); + +test("should split discount for cart items", () => { + const totalDiscount = 10; + const cartItems = [ + { + _id: "item1", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + } + }, + { + _id: "item2", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + } + } + ]; + + const discountForEachItem = applyOrderDiscountToCart.splitDiscountForCartItems(totalDiscount, cartItems); + expect(discountForEachItem).toEqual([ + { + _id: "item1", + amount: 5 + }, + { + _id: "item2", + amount: 5 + } + ]); +}); + diff --git a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/shipping/applyDiscountsToRates.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyDiscountsToRates.js similarity index 100% rename from packages/api-plugin-promotions-discounts/src/utils/discountTypes/shipping/applyDiscountsToRates.js rename to packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyDiscountsToRates.js diff --git a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/shipping/applyShippingDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js similarity index 98% rename from packages/api-plugin-promotions-discounts/src/utils/discountTypes/shipping/applyShippingDiscountToCart.js rename to packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js index 0cf2969db4b..b9a7cda60f7 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/shipping/applyShippingDiscountToCart.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js @@ -4,7 +4,7 @@ import evaluateRulesAgainstShipping from "./evaluateRulesAgainstShipping.js"; const require = createRequire(import.meta.url); -const pkg = require("../../../../package.json"); +const pkg = require("../../../package.json"); const { name, version } = pkg; const logCtx = { diff --git a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/shipping/evaluateRulesAgainstShipping.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/evaluateRulesAgainstShipping.js similarity index 100% rename from packages/api-plugin-promotions-discounts/src/utils/discountTypes/shipping/evaluateRulesAgainstShipping.js rename to packages/api-plugin-promotions-discounts/src/discountTypes/shipping/evaluateRulesAgainstShipping.js diff --git a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/shipping/getGroupDisountTotal.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/getGroupDisountTotal.js similarity index 100% rename from packages/api-plugin-promotions-discounts/src/utils/discountTypes/shipping/getGroupDisountTotal.js rename to packages/api-plugin-promotions-discounts/src/discountTypes/shipping/getGroupDisountTotal.js diff --git a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/shipping/getShippingDiscountTotal.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/getShippingDiscountTotal.js similarity index 100% rename from packages/api-plugin-promotions-discounts/src/utils/discountTypes/shipping/getShippingDiscountTotal.js rename to packages/api-plugin-promotions-discounts/src/discountTypes/shipping/getShippingDiscountTotal.js diff --git a/packages/api-plugin-promotions-discounts/src/enhancers/index.js b/packages/api-plugin-promotions-discounts/src/enhancers/index.js deleted file mode 100644 index 826f18473d1..00000000000 --- a/packages/api-plugin-promotions-discounts/src/enhancers/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import resetCartDiscountState from "./resetCartDiscountState.js"; - -export default [resetCartDiscountState]; diff --git a/packages/api-plugin-promotions-discounts/src/enhancers/resetCartDiscountState.js b/packages/api-plugin-promotions-discounts/src/enhancers/resetCartDiscountState.js deleted file mode 100644 index a4092f80312..00000000000 --- a/packages/api-plugin-promotions-discounts/src/enhancers/resetCartDiscountState.js +++ /dev/null @@ -1,23 +0,0 @@ -/** - * @summary Reset the cart discount state - * @param {Object} context - The application context - * @param {Object} cart - The cart to reset - * @returns {Object} - The cart with the discount state reset - */ -export default function resetCartDiscountState(context, cart) { - cart.discounts = []; - cart.discount = 0; - cart.items = cart.items.map((item) => { - item.discounts = []; - item.subtotal = { - amount: item.price.amount * item.quantity, - currencyCode: item.subtotal.currencyCode - }; - return item; - }); - - // todo: add reset logic for the shipping - // cart.shipping = cart.shipping.map((shipping) => ({ ...shipping, discounts: [] })); - - return cart; -} diff --git a/packages/api-plugin-promotions-discounts/src/enhancers/resetCartDiscountState.test.js b/packages/api-plugin-promotions-discounts/src/enhancers/resetCartDiscountState.test.js deleted file mode 100644 index 3a5e0d63cb1..00000000000 --- a/packages/api-plugin-promotions-discounts/src/enhancers/resetCartDiscountState.test.js +++ /dev/null @@ -1,45 +0,0 @@ -import resetCartDiscountState from "./resetCartDiscountState.js"; - -test("should reset the cart discount state", () => { - const cart = { - discounts: [{ _id: "discount1" }], - discount: 10, - items: [ - { - _id: "item1", - discounts: [{ _id: "discount1" }], - price: { - amount: 12 - }, - quantity: 1, - subtotal: { - amount: 10, - currencyCode: "USD", - discount: 2, - undiscountedAmount: 12 - } - } - ] - }; - - const updatedCart = resetCartDiscountState({}, cart); - - expect(updatedCart).toEqual({ - discounts: [], - discount: 0, - items: [ - { - _id: "item1", - discounts: [], - price: { - amount: 12 - }, - quantity: 1, - subtotal: { - amount: 12, - currencyCode: "USD" - } - } - ] - }); -}); diff --git a/packages/api-plugin-promotions-discounts/src/index.js b/packages/api-plugin-promotions-discounts/src/index.js index d3e23f19280..4851519b498 100644 --- a/packages/api-plugin-promotions-discounts/src/index.js +++ b/packages/api-plugin-promotions-discounts/src/index.js @@ -1,17 +1,16 @@ import { createRequire } from "module"; -import setDiscountsOnCart from "./utils/setDiscountsOnCart.js"; import actions from "./actions/index.js"; import methods from "./methods/index.js"; -import enhancers from "./enhancers/index.js"; -import addDiscountToOrderItem from "./utils/discountTypes/item/addDiscountToOrderItem.js"; -import getCartDiscountTotal from "./utils/discountTypes/order/getCartDiscountTotal.js"; -import getItemDiscountTotal from "./utils/discountTypes/item/getItemDiscountTotal.js"; -import getShippingDiscountTotal from "./utils/discountTypes/shipping/getShippingDiscountTotal.js"; -import getGroupDiscountTotal from "./utils/discountTypes/shipping/getGroupDisountTotal.js"; -import applyDiscountsToRates from "./utils/discountTypes/shipping/applyDiscountsToRates.js"; +import queries from "./queries/index.js"; +// import getCartDiscountTotal from "./utils/getCartDiscountTotal.js"; +// import getItemDiscountTotal from "./utils/getItemDiscountTotal.js"; +// import getShippingDiscountTotal from "./discountTypes/shipping/getShippingDiscountTotal.js"; +import getGroupDiscountTotal from "./discountTypes/shipping/getGroupDisountTotal.js"; +import applyDiscountsToRates from "./discountTypes/shipping/applyDiscountsToRates.js"; +import addDiscountToOrderItem from "./utils/addDiscountToOrderItem.js"; import preStartup from "./preStartup.js"; -import recalculateDiscounts from "./xforms/recalculateDiscounts.js"; import { discountCalculationMethods, registerDiscountCalculationMethod } from "./registration.js"; +import getTotalDiscountOnCart from "./utils/getTotalDiscountOnCart.js"; const require = createRequire(import.meta.url); const pkg = require("../package.json"); @@ -30,30 +29,16 @@ export default async function register(app) { registerPluginHandler: [registerDiscountCalculationMethod], preStartup: [preStartup], mutateNewOrderItemBeforeCreate: [addDiscountToOrderItem], - calculateDiscountTotal: [getCartDiscountTotal, getItemDiscountTotal, getShippingDiscountTotal], + calculateDiscountTotal: [getTotalDiscountOnCart], getGroupDiscounts: [getGroupDiscountTotal], applyDiscountsToRates: [applyDiscountsToRates] }, - cart: { - transforms: [ - { - name: "setDiscountsOnCart", - fn: setDiscountsOnCart, - priority: 10 - }, - { - name: "recalculateDiscounts", - fn: recalculateDiscounts, - priority: 10 - } - ] - }, + queries, contextAdditions: { discountCalculationMethods }, promotions: { - actions, - enhancers + actions }, discountCalculationMethods: methods }); diff --git a/packages/api-plugin-promotions-discounts/src/preStartup.js b/packages/api-plugin-promotions-discounts/src/preStartup.js index 10bc19d0ad1..32fcc29f666 100644 --- a/packages/api-plugin-promotions-discounts/src/preStartup.js +++ b/packages/api-plugin-promotions-discounts/src/preStartup.js @@ -112,6 +112,10 @@ async function extendOrderSchemas(context) { } }); OrderItem.extend({ + "discount": { + type: Number, + optional: true + }, "discounts": { type: Array, label: "Item Discounts", diff --git a/packages/api-plugin-promotions-discounts/src/queries/getDiscountsTotalForCart.js b/packages/api-plugin-promotions-discounts/src/queries/getDiscountsTotalForCart.js new file mode 100644 index 00000000000..05ee738ee5f --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/queries/getDiscountsTotalForCart.js @@ -0,0 +1,23 @@ +/** + * @summary Calculates total discount amount for a cart based on all discounts + * that have been applied to it + * @param {Object} context Context object + * @param {Object} cart The cart to get discounts from + * @returns {Object} Object with `discounts` array and `total` + */ +export default async function getDiscountsTotalForCart(context, cart) { + const discounts = cart.discounts || []; + + for (const cartItem of cart.items) { + if (cartItem.discounts) { + discounts.push(...cartItem.discounts.filter((discount) => discount.discountType === "item")); + } + } + + // TODO: add discounts from shipping + + return { + discounts, + total: cart.discount + }; +} diff --git a/packages/api-plugin-promotions-discounts/src/queries/index.js b/packages/api-plugin-promotions-discounts/src/queries/index.js new file mode 100644 index 00000000000..847adbfd24a --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/queries/index.js @@ -0,0 +1,5 @@ +import getDiscountsTotalForCart from "./getDiscountsTotalForCart.js"; + +export default { + getDiscountsTotalForCart +}; diff --git a/packages/api-plugin-promotions-discounts/src/simpleSchemas.js b/packages/api-plugin-promotions-discounts/src/simpleSchemas.js index df334bc8990..be01fc8a28b 100644 --- a/packages/api-plugin-promotions-discounts/src/simpleSchemas.js +++ b/packages/api-plugin-promotions-discounts/src/simpleSchemas.js @@ -1,39 +1,9 @@ import SimpleSchema from "simpl-schema"; -const Conditions = new SimpleSchema({ - maxUses: { - // total number of uses - type: Number, - defaultValue: 1 - }, - maxUsesPerAccount: { - // Max uses per account - type: SimpleSchema.Integer, - defaultValue: 1, - optional: true - }, - maxUsersPerOrder: { - // Max uses per order - type: Number, - defaultValue: 1 - } -}); - -const Event = new SimpleSchema({ - type: String, - params: { - type: Object, - optional: true - } -}); - export const Rules = new SimpleSchema({ conditions: { type: Object, blackbox: true - }, - event: { - type: Event } }); @@ -75,10 +45,6 @@ export const Discount = new SimpleSchema({ exclusionRules: { type: Rules, optional: true - }, - conditions: { - type: Conditions, - optional: true } }); @@ -88,7 +54,6 @@ export const CartDiscountedItem = new SimpleSchema({ }); export const CartDiscount = new SimpleSchema({ - "actionKey": String, "promotionId": String, "discountType": String, "discountCalculationType": String, // types provided by this plugin are flat, percentage and fixed diff --git a/packages/api-plugin-promotions-discounts/src/utils/addDiscountToOrderItem.js b/packages/api-plugin-promotions-discounts/src/utils/addDiscountToOrderItem.js new file mode 100644 index 00000000000..7774587c19b --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/utils/addDiscountToOrderItem.js @@ -0,0 +1,18 @@ +/** + * @summary recalculate item subtotal based on discounts + * @param {Object} context - The application context + * @param {Object} item - The item from the cart + * @param {Object} cartItem - The cart item + * @return {Object} - The mutated cart item + */ +export default function addDiscountToOrderItem(context, { item, cartItem }) { + if (typeof item.subtotal === "object") { + item.subtotal = cartItem.subtotal; + } else { + item.undiscountedAmount = cartItem.subtotal.undiscountedAmount; + item.discount = cartItem.subtotal.discount; + item.subtotal = cartItem.subtotal.amount; + } + item.discounts = cartItem.discounts; + return item; +} diff --git a/packages/api-plugin-promotions-discounts/src/utils/calculateMerchandiseTotal.js b/packages/api-plugin-promotions-discounts/src/utils/calculateMerchandiseTotal.js deleted file mode 100644 index 0cc1732b9cb..00000000000 --- a/packages/api-plugin-promotions-discounts/src/utils/calculateMerchandiseTotal.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * @summary Calculate the total discount amount for an order - * @param {Object} cart - The cart to calculate the discount for - * @returns {Number} The total discount amount - */ -export function calculateMerchandiseTotal(cart) { - const itemsTotal = cart.items.reduce( - (previousValue, currentValue) => previousValue + currentValue.price.amount * currentValue.quantity, - 0 - ); - return itemsTotal; -} diff --git a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/addDiscountToOrderItem.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/addDiscountToOrderItem.js deleted file mode 100644 index 16bf85f75c3..00000000000 --- a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/addDiscountToOrderItem.js +++ /dev/null @@ -1,32 +0,0 @@ -import calculateDiscountedItemPrice from "./calculateDiscountedItemPrice.js"; - -/** - * @summary recalculate item subtotal based on discounts - * @param {Object} context - The application context - * @param {Object} item - The item from the cart - * @param {Object} cartItem - The cart item - * @return {Object} - The mutated cart item - */ -export default function addDiscountToOrderItem(context, { item, cartItem }) { - if (typeof item.subtotal === "object") { - if (!item.subtotal.undiscountedAmount) { - item.subtotal.undiscountedAmount = item.subtotal.amount; - const itemTotal = calculateDiscountedItemPrice(context, { - price: item.price.amount, - quantity: item.quantity, - discounts: cartItem ? cartItem.discounts : [] - }); - item.subtotal.amount = itemTotal; - } - } else { - item.undiscountedAmount = item.subtotal || 0; - const itemTotal = calculateDiscountedItemPrice(context, { - price: item.price.amount, - quantity: item.quantity, - discounts: cartItem ? cartItem.discounts : [] - }); - item.subtotal = itemTotal; - } - item.discounts = cartItem ? cartItem.discounts : []; - return item; -} diff --git a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/calculateDiscountedItemPrice.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/calculateDiscountedItemPrice.js deleted file mode 100644 index 41a3b1e0761..00000000000 --- a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/calculateDiscountedItemPrice.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @summary Calculates the discounted price for an item - * @param {*} context - The application context - * @param {*} params.price - The price to calculate the discount for - * @param {*} params.quantity - The quantity of the item - * @param {*} params.discounts - The discounts to calculate - * @returns {Number} The discounted price - */ -export default function calculateDiscountedItemPrice(context, { price, quantity, discounts }) { - let totalDiscount = 0; - const amountBeforeDiscounts = price * quantity; - discounts.forEach((discount) => { - const calculationMethod = context.discountCalculationMethods[discount.discountCalculationType]; - const discountAmount = calculationMethod(discount.discountValue, amountBeforeDiscounts); - totalDiscount += discountAmount; - }); - if (totalDiscount < amountBeforeDiscounts) { - return amountBeforeDiscounts - totalDiscount; - } - return 0; -} diff --git a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/calculateDiscountedItemPrice.test.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/calculateDiscountedItemPrice.test.js deleted file mode 100644 index 32b1e483514..00000000000 --- a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/calculateDiscountedItemPrice.test.js +++ /dev/null @@ -1,22 +0,0 @@ -import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; -import calculateDiscountedItemPrice from "./calculateDiscountedItemPrice.js"; - -test("should calculate discounted item price", () => { - const price = 10; - const quantity = 5; - const discounts = [ - { - discountCalculationType: "fixed", - discountValue: 15 - } - ]; - - mockContext.discountCalculationMethods = { - fixed: jest.fn().mockReturnValue(15) - }; - - const discountedPrice = calculateDiscountedItemPrice(mockContext, { price, quantity, discounts }); - - expect(mockContext.discountCalculationMethods.fixed).toHaveBeenCalledWith(15, 50); - expect(discountedPrice).toEqual(35); -}); diff --git a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/recalculateCartItemSubtotal.test.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/recalculateCartItemSubtotal.test.js deleted file mode 100644 index 945837b76f3..00000000000 --- a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/recalculateCartItemSubtotal.test.js +++ /dev/null @@ -1,80 +0,0 @@ -import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; -import recalculateCartItemSubtotal from "./recalculateCartItemSubtotal.js"; - -test("should recalculate the item subtotal with discountType is item", () => { - const item = { - _id: "item1", - price: { - amount: 12 - }, - quantity: 1, - subtotal: { - amount: 10, - currencyCode: "USD", - discount: 2, - undiscountedAmount: 12 - }, - discounts: [] - }; - - const discount = { - actionKey: "test", - promotionId: "promotion1", - discountType: "item", - discountCalculationType: "fixed", - discountValue: 10, - discountedAmount: 2 - }; - - item.discounts.push(discount); - - mockContext.discountCalculationMethods = { - fixed: jest.fn().mockReturnValue(2) - }; - - recalculateCartItemSubtotal(mockContext, item); - - expect(item.subtotal).toEqual({ - amount: 10, - currencyCode: "USD", - discount: 2, - undiscountedAmount: 12 - }); -}); - -test("should recalculate the item subtotal with discountType is order", () => { - const item = { - _id: "item1", - price: { - amount: 12 - }, - quantity: 1, - subtotal: { - amount: 10, - currencyCode: "USD", - discount: 2, - undiscountedAmount: 12 - }, - discounts: [] - }; - - const discount = { - actionKey: "test", - promotionId: "promotion1", - discountType: "order", - discountCalculationType: "fixed", - discountValue: 10, - discountedAmount: 5 - }; - - item.discounts.push(discount); - - recalculateCartItemSubtotal(mockContext, item); - - expect(item.subtotal).toEqual({ - amount: 7, - currencyCode: "USD", - discount: 5, - undiscountedAmount: 12 - }); -}); diff --git a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/applyOrderDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/applyOrderDiscountToCart.js deleted file mode 100644 index 515ee7ecde8..00000000000 --- a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/applyOrderDiscountToCart.js +++ /dev/null @@ -1,72 +0,0 @@ -import { createRequire } from "module"; -import Logger from "@reactioncommerce/logger"; -import getEligibleItems from "../../../utils/getEligibleItems.js"; -import getCartDiscountAmount from "./getCartDiscountAmount.js"; -import splitDiscountForCartItems from "./splitDiscountForCartItems.js"; - -const require = createRequire(import.meta.url); - -const pkg = require("../../../../package.json"); - -const { name, version } = pkg; -const logCtx = { - name, - version, - file: "util/applyOrderDiscountToCart.js" -}; - -/** - * @summary Map discount record to cart discount - * @param {Object} params - The action parameters - * @param {Array} discountedItems - The items that were discounted - * @param {Number} discountedAmount - The total amount discounted - * @returns {Object} Cart discount record - */ -export function createDiscountRecord(params, discountedItems, discountedAmount) { - const { promotion: { _id }, actionParameters, actionKey } = params; - const itemDiscount = { - actionKey, - promotionId: _id, - discountType: actionParameters.discountType, - discountCalculationType: actionParameters.discountCalculationType, - discountValue: actionParameters.discountValue, - dateApplied: new Date(), - discountedItemType: "item", - discountedAmount, - discountedItems - }; - return itemDiscount; -} - -/** - * @summary Apply the order discount to the cart - * @param {Object} context - The application context - * @param {Object} params - The action parameters - * @param {Object} cart - The cart to apply the discount to - * @returns {Promise} The updated cart - */ -export default async function applyOrderDiscountToCart(context, params, cart) { - cart.discounts = cart.discounts || []; - const { promotion: { _id: promotionId }, actionParameters, actionKey } = params; - const existingDiscount = cart.discounts - .find((cartDiscount) => actionKey === cartDiscount.actionKey && promotionId === cartDiscount.promotionId); - if (existingDiscount) { - Logger.warn(logCtx, "Not adding discount because it already exists"); - return { cart }; - } - - const discountAmount = getCartDiscountAmount(context, cart, actionParameters); - const filteredItems = await getEligibleItems(context, cart.items, actionParameters); - const discountedItems = splitDiscountForCartItems(discountAmount, filteredItems); - - cart.discounts.push(createDiscountRecord(params, discountedItems, discountAmount)); - - for (const discountedItem of discountedItems) { - const cartItem = cart.items.find((item) => item._id === discountedItem._id); - if (cart.items.find((item) => item._id === discountedItem._id)) { - cartItem.discounts.push(createDiscountRecord(params, undefined, discountedItem.amount)); - } - } - - return { cart }; -} diff --git a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/getCartDiscountAmount.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/getCartDiscountAmount.js deleted file mode 100644 index 010f0d108b5..00000000000 --- a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/getCartDiscountAmount.js +++ /dev/null @@ -1,16 +0,0 @@ -import accounting from "accounting-js"; -import { calculateMerchandiseTotal } from "../../calculateMerchandiseTotal.js"; - -/** - * @summary Get the discount amount for a discount item - * @param {Object} context - The application context - * @param {Object} cart - The cart to calculate the discount for - * @param {Object} discount - The discount to calculate the discount amount for - * @returns {Number} - The discount amount - */ -export default function getCartDiscountAmount(context, cart, discount) { - const merchandiseTotal = cart.merchandiseTotal || calculateMerchandiseTotal(cart); - const { discountCalculationType, discountValue } = discount; - const appliedDiscount = context.discountCalculationMethods[discountCalculationType](discountValue, merchandiseTotal); - return Number(accounting.toFixed(appliedDiscount, 2)); -} diff --git a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/getCartDiscountAmount.test.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/getCartDiscountAmount.test.js deleted file mode 100644 index ad181b1b2b7..00000000000 --- a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/getCartDiscountAmount.test.js +++ /dev/null @@ -1,41 +0,0 @@ -import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; -import getCartDiscountAmount from "./getCartDiscountAmount.js"; - -test("should return correct discount amount", () => { - const cart = { - _id: "cart1", - items: [ - { - _id: "item1", - price: { - amount: 12 - }, - quantity: 1, - subtotal: { - amount: 10, - currencyCode: "USD", - discount: 2, - undiscountedAmount: 12 - } - } - ], - subtotal: { - amount: 10, - currencyCode: "USD", - discount: 2, - undiscountedAmount: 12 - } - }; - - const discount = { - discountCalculationType: "fixed", - discountValue: 10 - }; - - mockContext.discountCalculationMethods = { - fixed: jest.fn().mockReturnValue(10) - }; - - const discountAmount = getCartDiscountAmount(mockContext, cart, discount); - expect(discountAmount).toEqual(10); -}); diff --git a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/splitDiscountForCartItems.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/splitDiscountForCartItems.js deleted file mode 100644 index 0d020c47be6..00000000000 --- a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/splitDiscountForCartItems.js +++ /dev/null @@ -1,16 +0,0 @@ -import accounting from "accounting-js"; - -/** - * @summary Splits a discount across all cart items - * @param {Number} totalDiscount - The total discount to split - * @param {Array} cartItems - The cart items to split the discount across - * @returns {void} undefined - */ -export default function splitDiscountForCartItems(totalDiscount, cartItems) { - const totalItemPrice = cartItems.reduce((acc, item) => acc + item.subtotal.amount, 0); - const discountForEachItems = cartItems.map((item) => { - const discount = (item.subtotal.amount / totalItemPrice) * totalDiscount; - return { _id: item._id, amount: Number(accounting.toFixed(discount, 2)) }; - }); - return discountForEachItems; -} diff --git a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/splitDiscountForCartItems.test.js b/packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/splitDiscountForCartItems.test.js deleted file mode 100644 index e8be35292f4..00000000000 --- a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/splitDiscountForCartItems.test.js +++ /dev/null @@ -1,45 +0,0 @@ -import splitDiscountForCartItems from "./splitDiscountForCartItems.js"; - -test("should split discount for cart items", () => { - const totalDiscount = 10; - const cartItems = [ - { - _id: "item1", - price: { - amount: 12 - }, - quantity: 1, - subtotal: { - amount: 10, - currencyCode: "USD", - discount: 2, - undiscountedAmount: 12 - } - }, - { - _id: "item2", - price: { - amount: 12 - }, - quantity: 1, - subtotal: { - amount: 10, - currencyCode: "USD", - discount: 2, - undiscountedAmount: 12 - } - } - ]; - - const discountForEachItem = splitDiscountForCartItems(totalDiscount, cartItems); - expect(discountForEachItem).toEqual([ - { - _id: "item1", - amount: 5 - }, - { - _id: "item2", - amount: 5 - } - ]); -}); diff --git a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/getCartDiscountTotal.js b/packages/api-plugin-promotions-discounts/src/utils/getCartDiscountTotal.js similarity index 100% rename from packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/getCartDiscountTotal.js rename to packages/api-plugin-promotions-discounts/src/utils/getCartDiscountTotal.js diff --git a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/getCartDiscountTotal.test.js b/packages/api-plugin-promotions-discounts/src/utils/getCartDiscountTotal.test.js similarity index 100% rename from packages/api-plugin-promotions-discounts/src/utils/discountTypes/order/getCartDiscountTotal.test.js rename to packages/api-plugin-promotions-discounts/src/utils/getCartDiscountTotal.test.js diff --git a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/getItemDiscountTotal.js b/packages/api-plugin-promotions-discounts/src/utils/getItemDiscountTotal.js similarity index 100% rename from packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/getItemDiscountTotal.js rename to packages/api-plugin-promotions-discounts/src/utils/getItemDiscountTotal.js diff --git a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/getItemDiscountTotal.test.js b/packages/api-plugin-promotions-discounts/src/utils/getItemDiscountTotal.test.js similarity index 100% rename from packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/getItemDiscountTotal.test.js rename to packages/api-plugin-promotions-discounts/src/utils/getItemDiscountTotal.test.js diff --git a/packages/api-plugin-promotions-discounts/src/utils/getTotalDiscountOnCart.js b/packages/api-plugin-promotions-discounts/src/utils/getTotalDiscountOnCart.js new file mode 100644 index 00000000000..31286eec8cb --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/utils/getTotalDiscountOnCart.js @@ -0,0 +1,18 @@ +import accounting from "accounting-js"; + +/** + * @summary Get the total amount of all items in the cart + * @param {Object} cart - The cart to get the total amount of + * @returns {Number} The total amount of all items in the cart + */ +export default function getTotalDiscountOnCart(cart) { + let totalDiscount = 0; + + for (const item of cart.items) { + totalDiscount += item.subtotal.discount || 0; + } + + // TODO: Add the logic to calculate the total discount on shipping + + return Number(accounting.toFixed(totalDiscount, 2)); +} diff --git a/packages/api-plugin-promotions-discounts/src/utils/getTotalEligibleItemsAmount.js b/packages/api-plugin-promotions-discounts/src/utils/getTotalEligibleItemsAmount.js new file mode 100644 index 00000000000..e22afe99acb --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/utils/getTotalEligibleItemsAmount.js @@ -0,0 +1,12 @@ +/** + * @summary Calculate the total discount amount for an order + * @param {Array} items - The eligible items to calculate the discount for + * @returns {Number} The total discount amount + */ +export default function calculateEligibleItemsTotal(items) { + const itemsTotal = items.reduce( + (previousValue, currentValue) => previousValue + currentValue.subtotal.amount * currentValue.quantity, + 0 + ); + return itemsTotal; +} diff --git a/packages/api-plugin-promotions-discounts/src/utils/calculateMerchandiseTotal.test.js b/packages/api-plugin-promotions-discounts/src/utils/getTotalEligibleItemsAmount.test.js similarity index 90% rename from packages/api-plugin-promotions-discounts/src/utils/calculateMerchandiseTotal.test.js rename to packages/api-plugin-promotions-discounts/src/utils/getTotalEligibleItemsAmount.test.js index d3ed341a175..ad62aa0548d 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/calculateMerchandiseTotal.test.js +++ b/packages/api-plugin-promotions-discounts/src/utils/getTotalEligibleItemsAmount.test.js @@ -4,13 +4,13 @@ test("calculates the merchandise total for a cart", () => { const cart = { items: [ { - price: { + subtotal: { amount: 10 }, quantity: 1 }, { - price: { + subtotal: { amount: 20 }, quantity: 2 diff --git a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/recalculateCartItemSubtotal.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateCartItemSubtotal.js similarity index 83% rename from packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/recalculateCartItemSubtotal.js rename to packages/api-plugin-promotions-discounts/src/utils/recalculateCartItemSubtotal.js index fece2749478..db96a3c23ef 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/discountTypes/item/recalculateCartItemSubtotal.js +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateCartItemSubtotal.js @@ -13,10 +13,7 @@ export default function recalculateCartItemSubtotal(context, item) { item.discounts.forEach((discount) => { const { discountedAmount, discountCalculationType, discountValue, discountType } = discount; const calculationMethod = context.discountCalculationMethods[discountCalculationType]; - const discountAmount = - discountType === "order" - ? discountedAmount - : Number(accounting.toFixed(calculationMethod(discountValue, undiscountedAmount), 2)); + const discountAmount = discountType === "order" ? discountedAmount : Number(accounting.toFixed(calculationMethod(discountValue, undiscountedAmount), 2)); totalDiscount += discountAmount; discount.discountedAmount = discountAmount; diff --git a/packages/api-plugin-promotions-discounts/src/utils/setDiscountsOnCart.js b/packages/api-plugin-promotions-discounts/src/utils/setDiscountsOnCart.js deleted file mode 100644 index ca4fdd349ae..00000000000 --- a/packages/api-plugin-promotions-discounts/src/utils/setDiscountsOnCart.js +++ /dev/null @@ -1,25 +0,0 @@ -import recalculateCartItemSubtotal from "./discountTypes/item/recalculateCartItemSubtotal.js"; -import getCartDiscountTotal from "./discountTypes/order/getCartDiscountTotal.js"; - -/** - * @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) { - if (!cart.discounts) { - cart.discounts = []; - } - cart.items.forEach((item) => { - if (!item.discounts) { - item.discounts = []; - } - }); - const discountTotal = getCartDiscountTotal(context, cart); - cart.discount = discountTotal; - - for (const item of cart.items) { - recalculateCartItemSubtotal(context, item); - } -} diff --git a/packages/api-plugin-promotions-discounts/src/utils/setDiscountsOnCart.test.js b/packages/api-plugin-promotions-discounts/src/utils/setDiscountsOnCart.test.js deleted file mode 100644 index 3c15bf0fc70..00000000000 --- a/packages/api-plugin-promotions-discounts/src/utils/setDiscountsOnCart.test.js +++ /dev/null @@ -1,54 +0,0 @@ -import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; -import recalculateCartItemSubtotal from "./discountTypes/item/recalculateCartItemSubtotal.js"; -import setDiscountsOnCart from "./setDiscountsOnCart.js"; - -jest.mock("./discountTypes/item/recalculateCartItemSubtotal.js", () => jest.fn()); - -test("should set discounts on cart", () => { - const cart = { - _id: "cart1", - items: [ - { - _id: "item1", - price: { - amount: 12 - }, - quantity: 5, - subtotal: { - amount: 60, - currencyCode: "USD" - } - } - ], - discounts: [ - { - discountCalculationType: "fixed", - discountValue: 15 - } - ] - }; - - mockContext.discountCalculationMethods = { - fixed: jest.fn().mockReturnValue(15) - }; - - const expectedItemSubtotal = { - amount: 60, - currencyCode: "USD", - discount: 15, - undiscountedAmount: 60 - }; - - recalculateCartItemSubtotal.mockImplementationOnce((context, item) => { - item.subtotal = { ...expectedItemSubtotal }; - }); - - setDiscountsOnCart(mockContext, cart); - - expect(mockContext.discountCalculationMethods.fixed).toHaveBeenCalledWith(15, 60); - expect(recalculateCartItemSubtotal).toHaveBeenCalledTimes(1); - expect(recalculateCartItemSubtotal).toHaveBeenCalledWith(mockContext, cart.items[0]); - expect(cart.discount).toEqual(15); - - expect(cart.items[0].subtotal).toEqual(expectedItemSubtotal); -}); diff --git a/packages/api-plugin-promotions-discounts/src/xforms/recalculateDiscounts.js b/packages/api-plugin-promotions-discounts/src/xforms/recalculateDiscounts.js deleted file mode 100644 index b64cee2a68b..00000000000 --- a/packages/api-plugin-promotions-discounts/src/xforms/recalculateDiscounts.js +++ /dev/null @@ -1,17 +0,0 @@ -import addDiscountToOrderItem from "../utils/discountTypes/item/addDiscountToOrderItem.js"; - -/** - * @summary Recalculates discounts on an order - * @param {Object} context - The application context - * @param {Object} cart - The cart to recalculate discounts on - * @returns {void} undefined - */ -export default function recalculateDiscounts(context, cart) { - // recalculate item discounts - for (const item of cart.items || []) { - addDiscountToOrderItem(context, { item, cartItem: item }); - } - - // TODO: Recalculate shipping discounts - // TODO: Recalculate order discounts -} diff --git a/packages/api-plugin-promotions-offers/src/facts/totalItemAmount.js b/packages/api-plugin-promotions-offers/src/facts/totalItemAmount.js index 8edadf58a4c..8ddd93028db 100644 --- a/packages/api-plugin-promotions-offers/src/facts/totalItemAmount.js +++ b/packages/api-plugin-promotions-offers/src/facts/totalItemAmount.js @@ -6,11 +6,6 @@ * @returns {Promise} - The total amount of a discount or promotion */ export default async function totalItemAmount(context, params, almanac) { - let calculationItems = []; - if (params.fromFact) { - calculationItems = await almanac.factValue(params.fromFact); - } else { - calculationItems = await almanac.factValue("cart").then((cart) => cart.items); - } - return calculationItems.reduce((sum, item) => sum + item.price.amount * item.quantity, 0); + const eligibleItems = await almanac.factValue("eligibleItems"); + return eligibleItems.reduce((sum, item) => sum + item.price.amount * item.quantity, 0); } diff --git a/packages/api-plugin-promotions-offers/src/facts/totalItemCount.js b/packages/api-plugin-promotions-offers/src/facts/totalItemCount.js index c029ce39c5c..e77a1e99b1f 100644 --- a/packages/api-plugin-promotions-offers/src/facts/totalItemCount.js +++ b/packages/api-plugin-promotions-offers/src/facts/totalItemCount.js @@ -6,11 +6,6 @@ * @returns {Promise} - The total amount of a discount or promotion */ export default async function totalItemCount(context, params, almanac) { - let calculationItems = []; - if (params.fromFact) { - calculationItems = await almanac.factValue(params.fromFact); - } else { - calculationItems = await almanac.factValue("cart").then((cart) => cart.items); - } - return calculationItems.reduce((sum, item) => sum + item.quantity, 0); + const eligibleItems = await almanac.factValue("eligibleItems"); + return eligibleItems.reduce((sum, item) => sum + item.quantity, 0); } diff --git a/packages/api-plugin-promotions-offers/src/simpleSchemas.js b/packages/api-plugin-promotions-offers/src/simpleSchemas.js index acde5a29e76..fba4f942ec2 100644 --- a/packages/api-plugin-promotions-offers/src/simpleSchemas.js +++ b/packages/api-plugin-promotions-offers/src/simpleSchemas.js @@ -1,14 +1,5 @@ import SimpleSchema from "simpl-schema"; -const OfferTriggerFact = new SimpleSchema({ - name: String, - handlerName: String, - fromFact: { - type: String, - optional: true - } -}); - const Rules = new SimpleSchema({ conditions: { type: Object, @@ -17,23 +8,16 @@ const Rules = new SimpleSchema({ }); export const OfferTriggerParameters = new SimpleSchema({ - "name": String, - "conditions": { + name: String, + conditions: { type: Object, blackbox: true }, - "facts": { - type: Array, - optional: true - }, - "facts.$": { - type: OfferTriggerFact - }, - "inclusionRule": { + inclusionRule: { type: Rules, optional: true }, - "exclusionRule": { + exclusionRule: { type: Rules, optional: true } diff --git a/packages/api-plugin-promotions-offers/src/triggers/offerTriggerHandler.js b/packages/api-plugin-promotions-offers/src/triggers/offerTriggerHandler.js index 793f5072f28..92adb12c8a4 100644 --- a/packages/api-plugin-promotions-offers/src/triggers/offerTriggerHandler.js +++ b/packages/api-plugin-promotions-offers/src/triggers/offerTriggerHandler.js @@ -14,7 +14,11 @@ const logCtx = { file: "offerTriggerHandler.js" }; -const defaultFacts = [{ fact: "eligibleItems", handlerName: "getEligibleItems" }]; +const defaultFacts = [ + { fact: "eligibleItems", handlerName: "getEligibleItems" }, + { fact: "totalItemAmount", handlerName: "totalItemAmount" }, + { fact: "totalItemCount", handlerName: "totalItemCount" } +]; /** * @summary apply all offers to the cart @@ -32,8 +36,7 @@ export async function offerTriggerHandler(context, enhancedCart, { triggerParame const facts = { cart: enhancedCart }; - const allFacts = [...defaultFacts, ...(triggerParameters.facts || [])]; - for (const { fact, handlerName, fromFact } of allFacts) { + for (const { fact, handlerName, fromFact } of defaultFacts) { engine.addFact(fact, (params, almanac) => { const factParams = { ...triggerParameters, rulePrams: params, fromFact }; return promotionOfferFacts[handlerName](context, factParams, almanac); diff --git a/packages/api-plugin-promotions/src/actions/noop.js b/packages/api-plugin-promotions/src/actions/noop.js index 32d016a8599..598d43287cd 100644 --- a/packages/api-plugin-promotions/src/actions/noop.js +++ b/packages/api-plugin-promotions/src/actions/noop.js @@ -13,5 +13,6 @@ export function noop(context, enhancedCart, { actionParameters }) { export default { key: "noop", - handler: noop + handler: noop, + cleanup: () => {} }; diff --git a/packages/api-plugin-promotions/src/handlers/applyAction.js b/packages/api-plugin-promotions/src/handlers/applyAction.js deleted file mode 100644 index 87435dde2dd..00000000000 --- a/packages/api-plugin-promotions/src/handlers/applyAction.js +++ /dev/null @@ -1,18 +0,0 @@ -/** - * @method applyAction - * @summary apply promotions to a cart - * @param {Object} context - The application context - * @param {Object} enhancedCart - The cart to apply promotions to - * @param {Object} params.promotion - The promotion to apply - * @param {Object} params.actionParameters - The parameters for the action - * @returns {void} - */ -export default async function applyAction(context, enhancedCart, { promotion, actionHandleByKey }) { - for (const action of promotion.actions) { - const actionFn = actionHandleByKey[action.actionKey]; - if (!actionFn) continue; - - // eslint-disable-next-line no-await-in-loop - await actionFn.handler(context, enhancedCart, { promotion, ...action }); - } -} diff --git a/packages/api-plugin-promotions/src/handlers/applyAction.test.js b/packages/api-plugin-promotions/src/handlers/applyAction.test.js deleted file mode 100644 index e1d95924edb..00000000000 --- a/packages/api-plugin-promotions/src/handlers/applyAction.test.js +++ /dev/null @@ -1,19 +0,0 @@ -import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; -import applyAction from "./applyAction"; - -test("should apply action to cart", async () => { - const testAction = jest.fn().mockName("test-action"); - const enhancedCart = { - _id: "cartId" - }; - const promotion = { - actions: [{ actionKey: "test" }] - }; - - applyAction(mockContext, enhancedCart, { - actionHandleByKey: { test: { handler: testAction } }, - promotion - }); - - expect(testAction).toHaveBeenCalledWith(mockContext, enhancedCart, { promotion, actionParameters: undefined }); -}); diff --git a/packages/api-plugin-promotions/src/handlers/applyPromotions.js b/packages/api-plugin-promotions/src/handlers/applyPromotions.js index ef238fd4945..0febc54340f 100644 --- a/packages/api-plugin-promotions/src/handlers/applyPromotions.js +++ b/packages/api-plugin-promotions/src/handlers/applyPromotions.js @@ -4,7 +4,6 @@ import _ from "lodash"; import canBeApplied from "../utils/canBeApplied.js"; import enhanceCart from "../utils/enhanceCart.js"; import isPromotionExpired from "../utils/isPromotionExpired.js"; -import applyAction from "./applyAction.js"; const require = createRequire(import.meta.url); const pkg = require("../../package.json"); @@ -48,7 +47,7 @@ export default async function applyPromotions(context, cart, explicitPromotion = const { promotions: pluginPromotions, simpleSchemas: { Cart } } = context; const triggerHandleByKey = _.keyBy(pluginPromotions.triggers, "key"); - const actionHandleByKey = _.keyBy(context.promotions.actions, "key"); + const actionHandleByKey = _.keyBy(pluginPromotions.actions, "key"); const appliedPromotions = []; const appliedExplicitPromotions = _.filter(cart.appliedPromotions || [], ["type", "explicit"]); @@ -58,7 +57,12 @@ export default async function applyPromotions(context, cart, explicitPromotion = unqualifiedPromotions.push(explicitPromotion); } - const enhancedCart = enhanceCart(context, pluginPromotions.enhancers, cart); + for (const { cleanup } of pluginPromotions.actions) { + // eslint-disable-next-line no-await-in-loop + cleanup && await cleanup(context, cart); + } + + let enhancedCart = enhanceCart(context, pluginPromotions.enhancers, cart); for (const promotion of unqualifiedPromotions) { if (isPromotionExpired(promotion)) { continue; @@ -80,7 +84,14 @@ export default async function applyPromotions(context, cart, explicitPromotion = if (!shouldApply) continue; // eslint-disable-next-line no-await-in-loop - await applyAction(context, enhancedCart, { promotion, actionHandleByKey }); + for (const action of promotion.actions) { + const actionFn = actionHandleByKey[action.actionKey]; + if (!actionFn) continue; + + // eslint-disable-next-line no-await-in-loop + await actionFn.handler(context, enhancedCart, { promotion, ...action }); + enhancedCart = enhanceCart(context, pluginPromotions.enhancers, enhancedCart); + } appliedPromotions.push(promotion); break; } From 6271a1a5a86df6296cc57e9d192cc3e2d4757afe Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Sat, 5 Nov 2022 07:03:23 +0700 Subject: [PATCH 07/19] fix: update pnpm lock --- pnpm-lock.yaml | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57531188936..b0e279cf7ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -253,7 +253,7 @@ importers: '@reactioncommerce/logger': link:../../packages/logger '@reactioncommerce/nodemailer': 5.0.5 '@reactioncommerce/random': link:../../packages/random - '@snyk/protect': 1.1044.0 + '@snyk/protect': 1.1053.0 graphql: 14.7.0 semver: 6.3.0 sharp: 0.29.3 @@ -1047,7 +1047,6 @@ importers: '@reactioncommerce/logger': ^1.1.3 '@reactioncommerce/random': ^1.0.2 accounting-js: ^1.1.1 - deep-object-diff: ^1.1.7 json-rules-engine: ^6.1.2 simpl-schema: ^1.12.3 dependencies: @@ -1055,7 +1054,6 @@ importers: '@reactioncommerce/logger': link:../logger '@reactioncommerce/random': link:../random accounting-js: 1.1.1 - deep-object-diff: 1.1.7 json-rules-engine: 6.1.2 simpl-schema: 1.12.3 @@ -4752,8 +4750,8 @@ packages: '@sinonjs/commons': 1.8.3 dev: false - /@snyk/protect/1.1044.0: - resolution: {integrity: sha512-Wi6zmOMsyM2FRlxvqLo3opf7SDvcpWWR3RGJVHPVg6uh7VByAYrKome1zl8WRUaBr4qfEpL0jJLFKaBkHYUlAg==} + /@snyk/protect/1.1053.0: + resolution: {integrity: sha512-u8wuwnE0ukC3ERjlp2c020dGu1tuXjsTUXTLFsGS9GaxzAM4vz0uln6k1me2YVxbFSvFND/jagvuxBOtzpltDA==} engines: {node: '>=10'} hasBin: true dev: false @@ -7333,10 +7331,6 @@ packages: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true - /deep-object-diff/1.1.7: - resolution: {integrity: sha512-QkgBca0mL08P6HiOjoqvmm6xOAl2W6CT2+34Ljhg0OeFan8cwlcdq8jrLKsBBuUFAZLsN5b6y491KdKEoSo9lg==} - dev: false - /deepmerge/4.2.2: resolution: {integrity: sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==} engines: {node: '>=0.10.0'} From 03b90cb2041557e3b42c905e40f99404268d777d Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Sat, 5 Nov 2022 12:56:24 +0700 Subject: [PATCH 08/19] feat: add test for promotion discounts --- .../addCartItems/addCartItems.test.js | 1 - .../discountCodes/discountCodes.test.js | 6 +- .../anonymousCartByCartId.test.js | 1 - .../discountCodes/discountCodes.test.js | 6 +- packages/api-plugin-carts/src/registration.js | 2 - .../src/actions/discountAction.test.js | 19 +-- .../item/applyItemDiscountToCart.test.js | 151 ++---------------- .../order/applyOrderDiscountToCart.test.js | 50 +++--- .../src/index.js | 3 - .../queries/getDiscountsTotalForCart.test.js | 43 +++++ .../src/utils/addDiscountToOrderItem.test.js | 89 +++++++++++ .../src/utils/getCartDiscountTotal.js | 21 --- .../src/utils/getCartDiscountTotal.test.js | 41 ----- .../src/utils/getTotalDiscountOnCart.test.js | 41 +++++ .../src/utils/getTotalEligibleItemsAmount.js | 2 +- .../utils/getTotalEligibleItemsAmount.test.js | 32 ++-- .../utils/recalculateCartItemSubtotal.test.js | 82 ++++++++++ .../src/facts/totalItemAmount.test.js | 38 +---- .../src/facts/totalItemCount.test.js | 32 +--- .../src/triggers/offerTriggerHandler.test.js | 5 +- .../src/handlers/applyPromotions.test.js | 8 +- 21 files changed, 328 insertions(+), 345 deletions(-) create mode 100644 packages/api-plugin-promotions-discounts/src/queries/getDiscountsTotalForCart.test.js create mode 100644 packages/api-plugin-promotions-discounts/src/utils/addDiscountToOrderItem.test.js delete mode 100644 packages/api-plugin-promotions-discounts/src/utils/getCartDiscountTotal.js delete mode 100644 packages/api-plugin-promotions-discounts/src/utils/getCartDiscountTotal.test.js create mode 100644 packages/api-plugin-promotions-discounts/src/utils/getTotalDiscountOnCart.test.js create mode 100644 packages/api-plugin-promotions-discounts/src/utils/recalculateCartItemSubtotal.test.js diff --git a/apps/reaction/tests/integration/api/mutations/addCartItems/addCartItems.test.js b/apps/reaction/tests/integration/api/mutations/addCartItems/addCartItems.test.js index 3fd0e06b43c..a62693a3e51 100644 --- a/apps/reaction/tests/integration/api/mutations/addCartItems/addCartItems.test.js +++ b/apps/reaction/tests/integration/api/mutations/addCartItems/addCartItems.test.js @@ -82,7 +82,6 @@ beforeAll(async () => { workflow: null, discounts: [ { - actionKey: "mockActionKey", promotionId: "mockPromotionId", discountType: "order", discountCalculationType: "fixed", diff --git a/apps/reaction/tests/integration/api/mutations/discountCodes/discountCodes.test.js b/apps/reaction/tests/integration/api/mutations/discountCodes/discountCodes.test.js index 4bc3d438830..adc676c955a 100644 --- a/apps/reaction/tests/integration/api/mutations/discountCodes/discountCodes.test.js +++ b/apps/reaction/tests/integration/api/mutations/discountCodes/discountCodes.test.js @@ -61,7 +61,7 @@ beforeAll(async () => { // test file gets its own test database. afterAll(() => testApp.stop()); -test("user can add a discount code", async () => { +test.skip("user can add a discount code", async () => { await testApp.setLoggedInUser(mockAdminAccount); const discountCodeInput = { @@ -150,7 +150,7 @@ test("user can add a discount code", async () => { expect(createdDiscountCode).toEqual(expectedDiscountCodeResponse); }); -test("user can update an existing discount code", async () => { +test.skip("user can update an existing discount code", async () => { await testApp.setLoggedInUser(mockAdminAccount); const discountCodeInput = { @@ -231,7 +231,7 @@ test("user can update an existing discount code", async () => { expect(updatedDiscountCode).toEqual(expectedDiscountCodeResponse); }); -test("user can delete an existing discount code", async () => { +test.skip("user can delete an existing discount code", async () => { await testApp.setLoggedInUser(mockAdminAccount); const discountCodeInput = { diff --git a/apps/reaction/tests/integration/api/queries/anonymousCartByCartId/anonymousCartByCartId.test.js b/apps/reaction/tests/integration/api/queries/anonymousCartByCartId/anonymousCartByCartId.test.js index 1b571c49619..0dbe7dd61a7 100644 --- a/apps/reaction/tests/integration/api/queries/anonymousCartByCartId/anonymousCartByCartId.test.js +++ b/apps/reaction/tests/integration/api/queries/anonymousCartByCartId/anonymousCartByCartId.test.js @@ -42,7 +42,6 @@ beforeAll(async () => { workflow: null, discounts: [ { - actionKey: "mockActionKey", promotionId: "mockPromotionId", discountType: "order", discountCalculationType: "fixed", diff --git a/apps/reaction/tests/integration/api/queries/discountCodes/discountCodes.test.js b/apps/reaction/tests/integration/api/queries/discountCodes/discountCodes.test.js index 12aba9d475b..16a82470f19 100644 --- a/apps/reaction/tests/integration/api/queries/discountCodes/discountCodes.test.js +++ b/apps/reaction/tests/integration/api/queries/discountCodes/discountCodes.test.js @@ -110,7 +110,7 @@ beforeAll(async () => { // test file gets its own test database. afterAll(() => testApp.stop()); -test("throws access-denied when getting discount codes if not an admin", async () => { +test.skip("throws access-denied when getting discount codes if not an admin", async () => { await testApp.setLoggedInUser(mockCustomerAccount); try { @@ -122,7 +122,7 @@ test("throws access-denied when getting discount codes if not an admin", async ( } }); -test("returns discount records if user is an admin", async () => { +test.skip("returns discount records if user is an admin", async () => { await testApp.setLoggedInUser(mockAdminAccount); const result = await discountCodes({ @@ -136,7 +136,7 @@ test("returns discount records if user is an admin", async () => { }); -test("returns discount records on second page if user is an admin", async () => { +test.skip("returns discount records on second page if user is an admin", async () => { await testApp.setLoggedInUser(mockAdminAccount); const result = await discountCodes({ diff --git a/packages/api-plugin-carts/src/registration.js b/packages/api-plugin-carts/src/registration.js index 19e1c3d51a3..f9364cacdec 100644 --- a/packages/api-plugin-carts/src/registration.js +++ b/packages/api-plugin-carts/src/registration.js @@ -23,7 +23,5 @@ export function registerPluginHandlerForCart({ name, cart }) { cartTransforms.push(...transforms); cartTransforms.sort((prev, next) => prev.priority - next.priority); - - console.log(cartTransforms); } } diff --git a/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js b/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js index 181a05baa27..3a31493c227 100644 --- a/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js +++ b/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js @@ -1,11 +1,11 @@ -import applyItemDiscountToCart from "../utils/discountTypes/item/applyItemDiscountToCart.js"; -import applyOrderDiscountToCart from "../utils/discountTypes/order/applyOrderDiscountToCart.js"; -import applyShippingDiscountToCart from "../utils/discountTypes/shipping/applyShippingDiscountToCart.js"; +import applyItemDiscountToCart from "../discountTypes/item/applyItemDiscountToCart.js"; +import applyOrderDiscountToCart from "../discountTypes/order/applyOrderDiscountToCart.js"; +import applyShippingDiscountToCart from "../discountTypes/shipping/applyShippingDiscountToCart.js"; import discountAction, { discountActionCleanup, discountActionHandler, discountActionParameters } from "./discountAction.js"; -jest.mock("../utils/discountTypes/item/applyItemDiscountToCart.js", () => jest.fn()); -jest.mock("../utils/discountTypes/order/applyOrderDiscountToCart.js", () => jest.fn()); -jest.mock("../utils/discountTypes/shipping/applyShippingDiscountToCart.js", () => jest.fn()); +jest.mock("../discountTypes/item/applyItemDiscountToCart.js", () => jest.fn()); +jest.mock("../discountTypes/order/applyOrderDiscountToCart.js", () => jest.fn()); +jest.mock("../discountTypes/shipping/applyShippingDiscountToCart.js", () => jest.fn()); beforeEach(() => jest.resetAllMocks()); @@ -13,7 +13,8 @@ test("discountAction should be a object", () => { expect(discountAction).toEqual({ key: "discounts", handler: discountActionHandler, - paramSchema: discountActionParameters + paramSchema: discountActionParameters, + cleanup: discountActionCleanup }); }); @@ -57,7 +58,7 @@ test("should call discount shipping function when discountType parameters is shi }); describe("cleanup", () => { - test("should reset the cart discount state", () => { + test("should reset the cart discount state", async () => { const cart = { discounts: [{ _id: "discount1" }], discount: 10, @@ -79,7 +80,7 @@ describe("cleanup", () => { ] }; - const updatedCart = discountActionCleanup({}, cart); + const updatedCart = await discountActionCleanup({}, cart); expect(updatedCart).toEqual({ discounts: [], diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/item/applyItemDiscountToCart.test.js b/packages/api-plugin-promotions-discounts/src/discountTypes/item/applyItemDiscountToCart.test.js index b296c6894a1..9976e1fe396 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/item/applyItemDiscountToCart.test.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/item/applyItemDiscountToCart.test.js @@ -2,21 +2,7 @@ import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; import * as applyItemDiscountToCart from "./applyItemDiscountToCart.js"; test("createItemDiscount should return correct discount item object", () => { - const item = { - _id: "item1", - price: { - amount: 12 - }, - quantity: 1, - subtotal: { - amount: 10, - currencyCode: "USD", - discount: 2, - undiscountedAmount: 12 - } - }; - - const discount = { + const parameters = { actionKey: "test", promotion: { _id: "promotion1" @@ -28,10 +14,9 @@ test("createItemDiscount should return correct discount item object", () => { } }; - const itemDiscount = applyItemDiscountToCart.createItemDiscount(item, discount); + const itemDiscount = applyItemDiscountToCart.createItemDiscount(parameters); expect(itemDiscount).toEqual({ - actionKey: "test", promotionId: "promotion1", discountType: "test", discountCalculationType: "test", @@ -40,46 +25,6 @@ test("createItemDiscount should return correct discount item object", () => { }); }); -test("addDiscountToItem should add discount to item", () => { - const parameters = { - actionKey: "test", - promotion: { - _id: "promotion1" - }, - actionParameters: { - discountType: "test", - discountCalculationType: "test", - discountValue: 10 - } - }; - - const item = { - _id: "item1", - price: { - amount: 12 - }, - quantity: 1, - subtotal: { - amount: 10, - currencyCode: "USD", - discount: 2, - undiscountedAmount: 12 - }, - discounts: [] - }; - - const itemDiscount = applyItemDiscountToCart.createItemDiscount(item, parameters); - - applyItemDiscountToCart.addDiscountToItem({}, parameters, { item }); - - expect(item.discounts).toEqual([ - { - ...itemDiscount, - dateApplied: expect.any(Date) - } - ]); -}); - test("should return cart with applied discount when parameters not include rule", async () => { const item = { _id: "item1", @@ -113,12 +58,14 @@ test("should return cart with applied discount when parameters not include rule" } }; - jest.spyOn(applyItemDiscountToCart, "addDiscountToItem").mockImplementation(() => {}); - mockContext.promotions = { operators: {} }; + mockContext.discountCalculationMethods = { + test: jest.fn().mockReturnValue(10) + }; + const result = await applyItemDiscountToCart.default(mockContext, discountParameters, cart); expect(result).toEqual({ @@ -172,12 +119,14 @@ test("should return cart with applied discount when parameters include rule", as } }; - jest.spyOn(applyItemDiscountToCart, "addDiscountToItem").mockImplementation(() => {}); - mockContext.promotions = { operators: {} }; + mockContext.discountCalculationMethods = { + test: jest.fn().mockReturnValue(10) + }; + const result = await applyItemDiscountToCart.default(mockContext, parameters, cart); expect(result).toEqual({ @@ -185,83 +134,3 @@ test("should return cart with applied discount when parameters include rule", as discountedItems: [item] }); }); - -describe("recalculateCartItemSubtotal", () => { - test("should recalculate the item subtotal with discountType is item", () => { - const item = { - _id: "item1", - price: { - amount: 12 - }, - quantity: 1, - subtotal: { - amount: 10, - currencyCode: "USD", - discount: 2, - undiscountedAmount: 12 - }, - discounts: [] - }; - - const discount = { - actionKey: "test", - promotionId: "promotion1", - discountType: "item", - discountCalculationType: "fixed", - discountValue: 10, - discountedAmount: 2 - }; - - item.discounts.push(discount); - - mockContext.discountCalculationMethods = { - fixed: jest.fn().mockReturnValue(2) - }; - - applyItemDiscountToCart.recalculateCartItemSubtotal(mockContext, item); - - expect(item.subtotal).toEqual({ - amount: 10, - currencyCode: "USD", - discount: 2, - undiscountedAmount: 12 - }); - }); - - test("should recalculate the item subtotal with discountType is order", () => { - const item = { - _id: "item1", - price: { - amount: 12 - }, - quantity: 1, - subtotal: { - amount: 10, - currencyCode: "USD", - discount: 2, - undiscountedAmount: 12 - }, - discounts: [] - }; - - const discount = { - actionKey: "test", - promotionId: "promotion1", - discountType: "order", - discountCalculationType: "fixed", - discountValue: 10, - discountedAmount: 5 - }; - - item.discounts.push(discount); - - applyItemDiscountToCart.recalculateCartItemSubtotal(mockContext, item); - - expect(item.subtotal).toEqual({ - amount: 7, - currencyCode: "USD", - discount: 5, - undiscountedAmount: 12 - }); - }); -}); diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.test.js b/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.test.js index 79c705e08f1..28aed819d43 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.test.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.test.js @@ -34,7 +34,6 @@ test("createDiscountRecord should create discount record", () => { const discountRecord = applyOrderDiscountToCart.createDiscountRecord(parameters, discountedItems, 2); expect(discountRecord).toEqual({ - actionKey: "test", promotionId: "promotion1", discountType: "item", discountCalculationType: "fixed", @@ -100,16 +99,16 @@ test("should apply order discount to cart", async () => { const orderDiscountItem = applyOrderDiscountToCart.createDiscountRecord(parameters, cart.items, 2); expect(cart.items[0].subtotal).toEqual({ - amount: 10, + amount: 11, currencyCode: "USD", - discount: 2, + discount: 1, undiscountedAmount: 12 }); expect(cart.items[1].subtotal).toEqual({ - amount: 10, + amount: 11, currencyCode: "USD", - discount: 2, + discount: 1, undiscountedAmount: 12 }); @@ -117,32 +116,22 @@ test("should apply order discount to cart", async () => { expect(cart.discounts).toEqual([{ ...orderDiscountItem, dateApplied: expect.any(Date), discountedItems }]); }); - test(" get should return correct discount amount", () => { - const cart = { - _id: "cart1", - items: [ - { - _id: "item1", - price: { - amount: 12 - }, - quantity: 1, - subtotal: { - amount: 10, - currencyCode: "USD", - discount: 2, - undiscountedAmount: 12 - } + const items = [ + { + _id: "item1", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 } - ], - subtotal: { - amount: 10, - currencyCode: "USD", - discount: 2, - undiscountedAmount: 12 } - }; + ]; const discount = { discountCalculationType: "fixed", @@ -153,8 +142,8 @@ test(" get should return correct discount amount", () => { fixed: jest.fn().mockReturnValue(10) }; - const discountAmount = getCartDiscountAmount(mockContext, cart, discount); - expect(discountAmount).toEqual(10); + const totalCartDiscountAmount = applyOrderDiscountToCart.getCartTotalAmount(mockContext, items, discount); + expect(totalCartDiscountAmount).toEqual(10); }); test("should split discount for cart items", () => { @@ -200,4 +189,3 @@ test("should split discount for cart items", () => { } ]); }); - diff --git a/packages/api-plugin-promotions-discounts/src/index.js b/packages/api-plugin-promotions-discounts/src/index.js index 4851519b498..e085fe3bf15 100644 --- a/packages/api-plugin-promotions-discounts/src/index.js +++ b/packages/api-plugin-promotions-discounts/src/index.js @@ -2,9 +2,6 @@ import { createRequire } from "module"; import actions from "./actions/index.js"; import methods from "./methods/index.js"; import queries from "./queries/index.js"; -// import getCartDiscountTotal from "./utils/getCartDiscountTotal.js"; -// import getItemDiscountTotal from "./utils/getItemDiscountTotal.js"; -// import getShippingDiscountTotal from "./discountTypes/shipping/getShippingDiscountTotal.js"; import getGroupDiscountTotal from "./discountTypes/shipping/getGroupDisountTotal.js"; import applyDiscountsToRates from "./discountTypes/shipping/applyDiscountsToRates.js"; import addDiscountToOrderItem from "./utils/addDiscountToOrderItem.js"; diff --git a/packages/api-plugin-promotions-discounts/src/queries/getDiscountsTotalForCart.test.js b/packages/api-plugin-promotions-discounts/src/queries/getDiscountsTotalForCart.test.js new file mode 100644 index 00000000000..31d908d4906 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/queries/getDiscountsTotalForCart.test.js @@ -0,0 +1,43 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import getDiscountsTotalForCart from "./getDiscountsTotalForCart.js"; + +test("should return correct cart total discount when cart has no discounts", async () => { + const cart = { + _id: "cart1", + discount: 4, + items: [ + { + _id: "item1", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + }, + discounts: [] + }, + { + _id: "item2", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + }, + discounts: [] + } + ] + }; + + const results = await getDiscountsTotalForCart(mockContext, cart); + + expect(results.total).toEqual(4); +}); diff --git a/packages/api-plugin-promotions-discounts/src/utils/addDiscountToOrderItem.test.js b/packages/api-plugin-promotions-discounts/src/utils/addDiscountToOrderItem.test.js new file mode 100644 index 00000000000..8b9ca9c8f99 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/utils/addDiscountToOrderItem.test.js @@ -0,0 +1,89 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import addDiscountToOrderItem from "./addDiscountToOrderItem.js"; + +test("should add discount to order item when subtotal is an object", () => { + const item = { + _id: "item1", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + } + }; + + const cartItem = { + _id: "item1", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + }, + discounts: [] + }; + + const itemWithDiscount = addDiscountToOrderItem(mockContext, { item, cartItem }); + + expect(itemWithDiscount).toEqual({ + _id: "item1", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + }, + discounts: [] + }); +}); + +test("should add discount to order item when subtotal is a number", () => { + const item = { + _id: "item1", + price: { + amount: 12 + }, + quantity: 1, + subtotal: 10 + }; + + const cartItem = { + _id: "item1", + price: { + amount: 12 + }, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + }, + discounts: [] + }; + + const itemWithDiscount = addDiscountToOrderItem(mockContext, { item, cartItem }); + + expect(itemWithDiscount).toEqual({ + _id: "item1", + price: { + amount: 12 + }, + quantity: 1, + subtotal: 10, + discount: 2, + undiscountedAmount: 12, + discounts: [] + }); +}); diff --git a/packages/api-plugin-promotions-discounts/src/utils/getCartDiscountTotal.js b/packages/api-plugin-promotions-discounts/src/utils/getCartDiscountTotal.js deleted file mode 100644 index dfa5a8ce6fa..00000000000 --- a/packages/api-plugin-promotions-discounts/src/utils/getCartDiscountTotal.js +++ /dev/null @@ -1,21 +0,0 @@ -import accounting from "accounting-js"; -import { calculateMerchandiseTotal } from "../../calculateMerchandiseTotal.js"; - -/** - * @summary Get the total discount amount for an order - * @param {Object} context - The application context - * @param {Object} cart - The cart to calculate the discount for - * @returns {Number} The total discount amount - */ -export default function getCartDiscountTotal(context, cart) { - let totalDiscountAmount = 0; - const merchandiseTotal = cart.merchandiseTotal || calculateMerchandiseTotal(cart); - for (const { discountCalculationType, discountValue } of cart.discounts) { - const appliedDiscount = context.discountCalculationMethods[discountCalculationType]( - discountValue, - merchandiseTotal - ); - totalDiscountAmount += appliedDiscount; - } - return Number(accounting.toFixed(totalDiscountAmount, 2)); -} diff --git a/packages/api-plugin-promotions-discounts/src/utils/getCartDiscountTotal.test.js b/packages/api-plugin-promotions-discounts/src/utils/getCartDiscountTotal.test.js deleted file mode 100644 index b44c3f10d73..00000000000 --- a/packages/api-plugin-promotions-discounts/src/utils/getCartDiscountTotal.test.js +++ /dev/null @@ -1,41 +0,0 @@ -import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; -import getCartDiscountAmount from "./getCartDiscountAmount.js"; - -test("should return correct total cart discount amount", () => { - const cart = { - _id: "cart1", - items: [ - { - _id: "item1", - price: { - amount: 12 - }, - quantity: 1, - subtotal: { - amount: 10, - currencyCode: "USD", - discount: 2, - undiscountedAmount: 12 - } - } - ], - subtotal: { - amount: 10, - currencyCode: "USD", - discount: 2, - undiscountedAmount: 12 - } - }; - - const discount = { - discountCalculationType: "fixed", - discountValue: 10 - }; - - mockContext.discountCalculationMethods = { - fixed: jest.fn().mockReturnValue(10) - }; - - const discountAmount = getCartDiscountAmount(mockContext, cart, discount); - expect(discountAmount).toEqual(10); -}); diff --git a/packages/api-plugin-promotions-discounts/src/utils/getTotalDiscountOnCart.test.js b/packages/api-plugin-promotions-discounts/src/utils/getTotalDiscountOnCart.test.js new file mode 100644 index 00000000000..4fcb216650d --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/utils/getTotalDiscountOnCart.test.js @@ -0,0 +1,41 @@ +import getTotalDiscountOnCart from "./getTotalDiscountOnCart.js"; + +test("should return the total discount amount for all cart items", () => { + const cart = { + _id: "cart1", + items: [ + { + _id: "item1", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + }, + discounts: [] + }, + { + _id: "item2", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + }, + discounts: [] + } + ] + }; + + const totalDiscount = getTotalDiscountOnCart(cart); + + expect(totalDiscount).toEqual(4); +}); diff --git a/packages/api-plugin-promotions-discounts/src/utils/getTotalEligibleItemsAmount.js b/packages/api-plugin-promotions-discounts/src/utils/getTotalEligibleItemsAmount.js index e22afe99acb..4427a8e5ec3 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/getTotalEligibleItemsAmount.js +++ b/packages/api-plugin-promotions-discounts/src/utils/getTotalEligibleItemsAmount.js @@ -3,7 +3,7 @@ * @param {Array} items - The eligible items to calculate the discount for * @returns {Number} The total discount amount */ -export default function calculateEligibleItemsTotal(items) { +export default function getTotalEligibleItemsAmount(items) { const itemsTotal = items.reduce( (previousValue, currentValue) => previousValue + currentValue.subtotal.amount * currentValue.quantity, 0 diff --git a/packages/api-plugin-promotions-discounts/src/utils/getTotalEligibleItemsAmount.test.js b/packages/api-plugin-promotions-discounts/src/utils/getTotalEligibleItemsAmount.test.js index ad62aa0548d..159d7a51465 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/getTotalEligibleItemsAmount.test.js +++ b/packages/api-plugin-promotions-discounts/src/utils/getTotalEligibleItemsAmount.test.js @@ -1,22 +1,20 @@ -import { calculateMerchandiseTotal } from "./calculateMerchandiseTotal.js"; +import getTotalEligibleItemsAmount from "./getTotalEligibleItemsAmount.js"; test("calculates the merchandise total for a cart", () => { - const cart = { - items: [ - { - subtotal: { - amount: 10 - }, - quantity: 1 + const items = [ + { + subtotal: { + amount: 10 }, - { - subtotal: { - amount: 20 - }, - quantity: 2 - } - ] - }; + quantity: 1 + }, + { + subtotal: { + amount: 20 + }, + quantity: 2 + } + ]; - expect(calculateMerchandiseTotal(cart)).toEqual(50); + expect(getTotalEligibleItemsAmount(items)).toEqual(50); }); diff --git a/packages/api-plugin-promotions-discounts/src/utils/recalculateCartItemSubtotal.test.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateCartItemSubtotal.test.js new file mode 100644 index 00000000000..14f08d92197 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateCartItemSubtotal.test.js @@ -0,0 +1,82 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import recalculateCartItemSubtotal from "./recalculateCartItemSubtotal.js"; + +describe("recalculateCartItemSubtotal", () => { + test("should recalculate the item subtotal with discountType is item", () => { + const item = { + _id: "item1", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + }, + discounts: [] + }; + + const discount = { + actionKey: "test", + promotionId: "promotion1", + discountType: "item", + discountCalculationType: "fixed", + discountValue: 10, + discountedAmount: 2 + }; + + item.discounts.push(discount); + + mockContext.discountCalculationMethods = { + fixed: jest.fn().mockReturnValue(2) + }; + + recalculateCartItemSubtotal(mockContext, item); + + expect(item.subtotal).toEqual({ + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + }); + }); + + test("should recalculate the item subtotal with discountType is order", () => { + const item = { + _id: "item1", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + }, + discounts: [] + }; + + const discount = { + actionKey: "test", + promotionId: "promotion1", + discountType: "order", + discountCalculationType: "fixed", + discountValue: 10, + discountedAmount: 5 + }; + + item.discounts.push(discount); + + recalculateCartItemSubtotal(mockContext, item); + + expect(item.subtotal).toEqual({ + amount: 7, + currencyCode: "USD", + discount: 5, + undiscountedAmount: 12 + }); + }); +}); diff --git a/packages/api-plugin-promotions-offers/src/facts/totalItemAmount.test.js b/packages/api-plugin-promotions-offers/src/facts/totalItemAmount.test.js index 9b12efba57d..359d14b087e 100644 --- a/packages/api-plugin-promotions-offers/src/facts/totalItemAmount.test.js +++ b/packages/api-plugin-promotions-offers/src/facts/totalItemAmount.test.js @@ -1,37 +1,8 @@ import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; import totalItemAmount from "./totalItemAmount.js"; -test("should return correct total item amount from default fact", async () => { - const cart = { - _id: "cartId", - items: [ - { - _id: "1", - price: { - amount: 10 - }, - quantity: 1 - }, - { - _id: "1", - price: { - amount: 2 - }, - quantity: 2 - } - ] - }; - const parameters = { - fromFact: "" - }; - const almanac = { - factValue: jest.fn().mockName("factValue").mockResolvedValue(cart) - }; - const total = await totalItemAmount(mockContext, parameters, almanac); - expect(total).toEqual(14); -}); -test("should return correct total item amount from provided fact", async () => { +test("should return correct total item amount", async () => { const items = [ { _id: "1", @@ -48,17 +19,14 @@ test("should return correct total item amount from provided fact", async () => { quantity: 2 } ]; - const parameters = { - fromFact: "testFact" - }; const almanac = { factValue: jest.fn().mockImplementation((fact) => { - if (fact === "testFact") { + if (fact === "eligibleItems") { return Promise.resolve(items); } return null; }) }; - const total = await totalItemAmount(mockContext, parameters, almanac); + const total = await totalItemAmount(mockContext, undefined, almanac); expect(total).toEqual(14); }); diff --git a/packages/api-plugin-promotions-offers/src/facts/totalItemCount.test.js b/packages/api-plugin-promotions-offers/src/facts/totalItemCount.test.js index 63d5f7e4c56..619be092072 100644 --- a/packages/api-plugin-promotions-offers/src/facts/totalItemCount.test.js +++ b/packages/api-plugin-promotions-offers/src/facts/totalItemCount.test.js @@ -1,31 +1,8 @@ import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; import totalItemCount from "./totalItemCount.js"; -test("should return correct total item count from default fact", async () => { - const cart = { - _id: "cartId", - items: [ - { - _id: "1", - quantity: 1 - }, - { - _id: "1", - quantity: 2 - } - ] - }; - const parameters = { - fromFact: "" - }; - const almanac = { - factValue: jest.fn().mockName("factValue").mockResolvedValue(cart) - }; - const total = await totalItemCount(mockContext, parameters, almanac); - expect(total).toEqual(3); -}); -test("should return correct total item count from provided fact", async () => { +test("should return correct total item count", async () => { const items = [ { _id: "1", @@ -36,17 +13,14 @@ test("should return correct total item count from provided fact", async () => { quantity: 2 } ]; - const parameters = { - fromFact: "testFact" - }; const almanac = { factValue: jest.fn().mockImplementation((fact) => { - if (fact === "testFact") { + if (fact === "eligibleItems") { return Promise.resolve(items); } return null; }) }; - const total = await totalItemCount(mockContext, parameters, almanac); + const total = await totalItemCount(mockContext, undefined, almanac); expect(total).toEqual(3); }); diff --git a/packages/api-plugin-promotions-offers/src/triggers/offerTriggerHandler.test.js b/packages/api-plugin-promotions-offers/src/triggers/offerTriggerHandler.test.js index b656e35f2e2..813a0193e13 100644 --- a/packages/api-plugin-promotions-offers/src/triggers/offerTriggerHandler.test.js +++ b/packages/api-plugin-promotions-offers/src/triggers/offerTriggerHandler.test.js @@ -82,8 +82,9 @@ test("should add custom fact when facts provided on parameters", async () => { await offerTriggerHandler(mockContext, enhancedCart, { triggerParameters: parameters }); - expect(mockAddFact).toHaveBeenCalledWith("eligibleItems", expect.any(Function)); - expect(mockAddFact).toHaveBeenCalledWith("testFact", expect.any(Function)); + expect(mockAddFact).toHaveBeenNthCalledWith(1, "eligibleItems", expect.any(Function)); + expect(mockAddFact).toHaveBeenNthCalledWith(2, "totalItemAmount", expect.any(Function)); + expect(mockAddFact).toHaveBeenNthCalledWith(3, "totalItemCount", expect.any(Function)); }); test("should not add custom fact when not provided on parameters", async () => { diff --git a/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js b/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js index 08c1a94e00e..19c591ce7f4 100644 --- a/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js +++ b/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js @@ -45,8 +45,8 @@ test("should save cart with implicit promotions are applied", async () => { triggerParameters: { name: "test trigger" } }); expect(testAction).toBeCalledWith(mockContext, expect.objectContaining(cart), { - promotion: testPromotion, - actionParameters: undefined + actionKey: "test", + promotion: testPromotion }); expect(testEnhancer).toBeCalledWith(mockContext, expect.objectContaining(cart)); @@ -60,9 +60,7 @@ test("should save cart with implicit promotions are not applied when promotions }; mockContext.collections.Promotions = { find: () => ({ - toArray: jest - .fn() - .mockResolvedValueOnce([testPromotion, { ...testPromotion, _id: "test id 2", stackAbility: "all" }]) + toArray: jest.fn().mockResolvedValueOnce([testPromotion, { ...testPromotion, _id: "test id 2", stackAbility: "all" }]) }) }; From f59a564f2bc6c855be12d21a5480ad124f3c3589 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Mon, 7 Nov 2022 14:02:47 +0700 Subject: [PATCH 09/19] fix: fix integration mutation test fail --- .../src/utils/addDiscountToOrderItem.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/api-plugin-promotions-discounts/src/utils/addDiscountToOrderItem.js b/packages/api-plugin-promotions-discounts/src/utils/addDiscountToOrderItem.js index 7774587c19b..59f7a8b9717 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/addDiscountToOrderItem.js +++ b/packages/api-plugin-promotions-discounts/src/utils/addDiscountToOrderItem.js @@ -6,6 +6,8 @@ * @return {Object} - The mutated cart item */ export default function addDiscountToOrderItem(context, { item, cartItem }) { + if (!cartItem) return item; + if (typeof item.subtotal === "object") { item.subtotal = cartItem.subtotal; } else { From 64030bda848f3b407782612cd397579edf768297 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Mon, 7 Nov 2022 19:26:37 +0700 Subject: [PATCH 10/19] fix: calculate percentage discount --- .../order/applyOrderDiscountToCart.js | 16 +++---- .../order/applyOrderDiscountToCart.test.js | 25 +++++------ .../src/methods/index.js | 2 +- .../src/utils/formatMoney.js | 10 +++++ .../src/utils/getItemDiscountTotal.js | 15 ------- .../src/utils/getItemDiscountTotal.test.js | 42 ------------------- .../src/utils/getTotalDiscountOnCart.js | 4 +- .../src/utils/getTotalEligibleItemsAmount.js | 5 +-- .../utils/getTotalEligibleItemsAmount.test.js | 2 +- .../src/utils/recalculateCartItemSubtotal.js | 10 +++-- .../utils/recalculateCartItemSubtotal.test.js | 4 +- 11 files changed, 42 insertions(+), 93 deletions(-) create mode 100644 packages/api-plugin-promotions-discounts/src/utils/formatMoney.js delete mode 100644 packages/api-plugin-promotions-discounts/src/utils/getItemDiscountTotal.js delete mode 100644 packages/api-plugin-promotions-discounts/src/utils/getItemDiscountTotal.test.js diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.js index 1aa4deaa204..e1712e9c723 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.js @@ -1,4 +1,4 @@ -import accounting from "accounting-js"; +import formatMoney from "../../utils/formatMoney.js"; import getEligibleItems from "../../utils/getEligibleItems.js"; import getTotalEligibleItemsAmount from "../../utils/getTotalEligibleItemsAmount.js"; import getTotalDiscountOnCart from "../../utils/getTotalDiscountOnCart.js"; @@ -33,11 +33,11 @@ export function createDiscountRecord(params, discountedItems, discountedAmount) * @param {Object} discount - The discount to calculate the discount amount for * @returns {Number} - The discount amount */ -export function getCartTotalAmount(context, items, discount) { +export function getCartDiscountAmount(context, items, discount) { const merchandiseTotal = getTotalEligibleItemsAmount(items); const { discountCalculationType, discountValue } = discount; - const appliedDiscount = context.discountCalculationMethods[discountCalculationType](discountValue, merchandiseTotal); - return Number(accounting.toFixed(appliedDiscount, 2)); + const cartDiscountedAmount = context.discountCalculationMethods[discountCalculationType](discountValue, merchandiseTotal); + return merchandiseTotal - Number(formatMoney(cartDiscountedAmount)); } /** @@ -50,7 +50,7 @@ export function splitDiscountForCartItems(totalDiscount, cartItems) { const totalItemPrice = cartItems.reduce((acc, item) => acc + item.subtotal.amount, 0); const discountForEachItems = cartItems.map((item) => { const discount = (item.subtotal.amount / totalItemPrice) * totalDiscount; - return { _id: item._id, amount: Number(accounting.toFixed(discount, 2)) }; + return { _id: item._id, amount: Number(formatMoney(discount)) }; }); return discountForEachItems; } @@ -67,10 +67,10 @@ export default async function applyOrderDiscountToCart(context, params, cart) { const { actionParameters } = params; const filteredItems = await getEligibleItems(context, cart.items, actionParameters); - const discountAmount = getCartTotalAmount(context, filteredItems, actionParameters); - const discountedItems = splitDiscountForCartItems(discountAmount, filteredItems); + const discountedAmount = getCartDiscountAmount(context, filteredItems, actionParameters); + const discountedItems = splitDiscountForCartItems(discountedAmount, filteredItems); - cart.discounts.push(createDiscountRecord(params, discountedItems, discountAmount)); + cart.discounts.push(createDiscountRecord(params, discountedItems, discountedAmount)); for (const discountedItem of discountedItems) { const cartItem = cart.items.find(({ _id }) => _id === discountedItem._id); diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.test.js b/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.test.js index 28aed819d43..f5cac835ee2 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.test.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.test.js @@ -99,24 +99,24 @@ test("should apply order discount to cart", async () => { const orderDiscountItem = applyOrderDiscountToCart.createDiscountRecord(parameters, cart.items, 2); expect(cart.items[0].subtotal).toEqual({ - amount: 11, + amount: 3, currencyCode: "USD", - discount: 1, + discount: 9, undiscountedAmount: 12 }); expect(cart.items[1].subtotal).toEqual({ - amount: 11, + amount: 3, currencyCode: "USD", - discount: 1, + discount: 9, undiscountedAmount: 12 }); - const discountedItems = cart.items.map((item) => ({ _id: item._id, amount: 1 })); - expect(cart.discounts).toEqual([{ ...orderDiscountItem, dateApplied: expect.any(Date), discountedItems }]); + const discountedItems = cart.items.map((item) => ({ _id: item._id, amount: 9 })); + expect(cart.discounts).toEqual([{ ...orderDiscountItem, discountedAmount: 18, dateApplied: expect.any(Date), discountedItems }]); }); -test(" get should return correct discount amount", () => { +test("getCartDiscountAmount get should return correct discount amount", () => { const items = [ { _id: "item1", @@ -126,24 +126,21 @@ test(" get should return correct discount amount", () => { quantity: 1, subtotal: { amount: 10, - currencyCode: "USD", - discount: 2, - undiscountedAmount: 12 + currencyCode: "USD" } } ]; const discount = { discountCalculationType: "fixed", - discountValue: 10 + discountValue: 5 }; mockContext.discountCalculationMethods = { - fixed: jest.fn().mockReturnValue(10) + fixed: jest.fn().mockReturnValue(5) }; - const totalCartDiscountAmount = applyOrderDiscountToCart.getCartTotalAmount(mockContext, items, discount); - expect(totalCartDiscountAmount).toEqual(10); + expect(applyOrderDiscountToCart.getCartDiscountAmount(mockContext, items, discount)).toEqual(5); }); test("should split discount for cart items", () => { diff --git a/packages/api-plugin-promotions-discounts/src/methods/index.js b/packages/api-plugin-promotions-discounts/src/methods/index.js index 8f65411f852..130a4434c41 100644 --- a/packages/api-plugin-promotions-discounts/src/methods/index.js +++ b/packages/api-plugin-promotions-discounts/src/methods/index.js @@ -5,7 +5,7 @@ * @returns {Number} The discount amount */ function percentage(discountValue, price) { - return price * (discountValue / 100); + return price * (1 - discountValue / 100); } /** diff --git a/packages/api-plugin-promotions-discounts/src/utils/formatMoney.js b/packages/api-plugin-promotions-discounts/src/utils/formatMoney.js new file mode 100644 index 00000000000..de0f7717870 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/utils/formatMoney.js @@ -0,0 +1,10 @@ +import accounting from "accounting-js"; + +/** + * @summary Formats a number as money with 2 decimal places + * @param {Number} amount - The amount to format + * @returns {String} The formatted amount + */ +export default function formatMoney(amount) { + return accounting.toFixed(amount, 2); +} diff --git a/packages/api-plugin-promotions-discounts/src/utils/getItemDiscountTotal.js b/packages/api-plugin-promotions-discounts/src/utils/getItemDiscountTotal.js deleted file mode 100644 index 6a3332a3e26..00000000000 --- a/packages/api-plugin-promotions-discounts/src/utils/getItemDiscountTotal.js +++ /dev/null @@ -1,15 +0,0 @@ -/** - * @summary Get the total discount amount for a single item - * @param {Number} context - The application context - * @param {Number} cart - The cart to calculate the discount for - * @returns {Number} The total discount amount - */ -export default function getItemDiscountTotal(context, cart) { - let totalItemDiscount = 0; - for (const item of cart.items) { - const originalPrice = item.quantity * item.price.amount; - const actualPrice = item.subtotal.amount; - totalItemDiscount += (originalPrice - actualPrice); - } - return totalItemDiscount; -} diff --git a/packages/api-plugin-promotions-discounts/src/utils/getItemDiscountTotal.test.js b/packages/api-plugin-promotions-discounts/src/utils/getItemDiscountTotal.test.js deleted file mode 100644 index 72fe1fe4f6b..00000000000 --- a/packages/api-plugin-promotions-discounts/src/utils/getItemDiscountTotal.test.js +++ /dev/null @@ -1,42 +0,0 @@ -import getItemDiscountTotal from "./getItemDiscountTotal.js"; - -test("getItemDiscountTotal returns the total discount amount for all cart items", () => { - const cart = { - _id: "cart1", - items: [ - { - _id: "item1", - price: { - amount: 12 - }, - quantity: 1, - subtotal: { - amount: 10, - currencyCode: "USD", - discount: 2, - undiscountedAmount: 12 - }, - discounts: [] - }, - { - _id: "item2", - price: { - amount: 12 - }, - quantity: 1, - subtotal: { - amount: 10, - currencyCode: "USD", - discount: 2, - undiscountedAmount: 12 - }, - discounts: [] - } - ] - }; - - const context = {}; - const totalItemDiscount = getItemDiscountTotal(context, cart); - - expect(totalItemDiscount).toEqual(4); -}); diff --git a/packages/api-plugin-promotions-discounts/src/utils/getTotalDiscountOnCart.js b/packages/api-plugin-promotions-discounts/src/utils/getTotalDiscountOnCart.js index 31286eec8cb..1a9497b4f2f 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/getTotalDiscountOnCart.js +++ b/packages/api-plugin-promotions-discounts/src/utils/getTotalDiscountOnCart.js @@ -1,4 +1,4 @@ -import accounting from "accounting-js"; +import formatMoney from "./formatMoney.js"; /** * @summary Get the total amount of all items in the cart @@ -14,5 +14,5 @@ export default function getTotalDiscountOnCart(cart) { // TODO: Add the logic to calculate the total discount on shipping - return Number(accounting.toFixed(totalDiscount, 2)); + return Number(formatMoney(totalDiscount)); } diff --git a/packages/api-plugin-promotions-discounts/src/utils/getTotalEligibleItemsAmount.js b/packages/api-plugin-promotions-discounts/src/utils/getTotalEligibleItemsAmount.js index 4427a8e5ec3..9799efcd081 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/getTotalEligibleItemsAmount.js +++ b/packages/api-plugin-promotions-discounts/src/utils/getTotalEligibleItemsAmount.js @@ -4,9 +4,6 @@ * @returns {Number} The total discount amount */ export default function getTotalEligibleItemsAmount(items) { - const itemsTotal = items.reduce( - (previousValue, currentValue) => previousValue + currentValue.subtotal.amount * currentValue.quantity, - 0 - ); + const itemsTotal = items.reduce((previousValue, currentValue) => previousValue + currentValue.subtotal.amount, 0); return itemsTotal; } diff --git a/packages/api-plugin-promotions-discounts/src/utils/getTotalEligibleItemsAmount.test.js b/packages/api-plugin-promotions-discounts/src/utils/getTotalEligibleItemsAmount.test.js index 159d7a51465..45ac54fffa2 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/getTotalEligibleItemsAmount.test.js +++ b/packages/api-plugin-promotions-discounts/src/utils/getTotalEligibleItemsAmount.test.js @@ -16,5 +16,5 @@ test("calculates the merchandise total for a cart", () => { } ]; - expect(getTotalEligibleItemsAmount(items)).toEqual(50); + expect(getTotalEligibleItemsAmount(items)).toEqual(30); }); diff --git a/packages/api-plugin-promotions-discounts/src/utils/recalculateCartItemSubtotal.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateCartItemSubtotal.js index db96a3c23ef..565f6e68a28 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/recalculateCartItemSubtotal.js +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateCartItemSubtotal.js @@ -1,4 +1,4 @@ -import accounting from "accounting-js"; +import formatMoney from "./formatMoney.js"; /** * @summary Recalculate the item subtotal @@ -8,17 +8,19 @@ import accounting from "accounting-js"; */ export default function recalculateCartItemSubtotal(context, item) { let totalDiscount = 0; - const undiscountedAmount = Number(accounting.toFixed(item.price.amount * item.quantity, 2)); + const undiscountedAmount = Number(formatMoney(item.price.amount * item.quantity)); + item.subtotal.amount = undiscountedAmount; item.discounts.forEach((discount) => { const { discountedAmount, discountCalculationType, discountValue, discountType } = discount; const calculationMethod = context.discountCalculationMethods[discountCalculationType]; - const discountAmount = discountType === "order" ? discountedAmount : Number(accounting.toFixed(calculationMethod(discountValue, undiscountedAmount), 2)); + const itemDiscountedAmount = calculationMethod(discountValue, item.subtotal.amount); + const discountAmount = discountType === "order" ? discountedAmount : Number(formatMoney(item.subtotal.amount - itemDiscountedAmount)); totalDiscount += discountAmount; discount.discountedAmount = discountAmount; + item.subtotal.amount = Number(formatMoney(undiscountedAmount - totalDiscount)); }); - item.subtotal.amount = Number(accounting.toFixed(undiscountedAmount - totalDiscount, 2)); item.subtotal.discount = totalDiscount; item.subtotal.undiscountedAmount = undiscountedAmount; } diff --git a/packages/api-plugin-promotions-discounts/src/utils/recalculateCartItemSubtotal.test.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateCartItemSubtotal.test.js index 14f08d92197..c0d4548cae6 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/recalculateCartItemSubtotal.test.js +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateCartItemSubtotal.test.js @@ -36,9 +36,9 @@ describe("recalculateCartItemSubtotal", () => { recalculateCartItemSubtotal(mockContext, item); expect(item.subtotal).toEqual({ - amount: 10, + amount: 2, currencyCode: "USD", - discount: 2, + discount: 10, undiscountedAmount: 12 }); }); From 1baccb1ea4664111a599bba9885971165f783cff Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Tue, 8 Nov 2022 10:36:19 +0700 Subject: [PATCH 11/19] fix: split order discount for cart items --- apps/reaction/plugins.json | 2 + .../discountCodes/discountCodes.test.js | 6 +- .../discountCodes/discountCodes.test.js | 6 +- packages/api-plugin-discounts/package.json | 5 ++ .../src/util/setDiscountsOnCart.js | 7 +- .../src/util/setDiscountsOnCart.test.js | 59 ++++++++++++++ .../package.json | 3 +- .../order/applyOrderDiscountToCart.js | 22 ++++-- .../order/applyOrderDiscountToCart.test.js | 48 +++++++++++ .../shipping/applyDiscountsToRates.js | 19 ----- .../shipping/applyShippingDiscountToCart.js | 79 +------------------ .../shipping/evaluateRulesAgainstShipping.js | 68 ---------------- .../shipping/getGroupDisountTotal.js | 11 --- .../shipping/getShippingDiscountTotal.js | 17 ---- .../src/index.js | 6 +- .../src/utils/formatMoney.js | 2 +- pnpm-lock.yaml | 10 ++- 17 files changed, 155 insertions(+), 215 deletions(-) create mode 100644 packages/api-plugin-discounts/src/util/setDiscountsOnCart.test.js delete mode 100644 packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyDiscountsToRates.js delete mode 100644 packages/api-plugin-promotions-discounts/src/discountTypes/shipping/evaluateRulesAgainstShipping.js delete mode 100644 packages/api-plugin-promotions-discounts/src/discountTypes/shipping/getGroupDisountTotal.js delete mode 100644 packages/api-plugin-promotions-discounts/src/discountTypes/shipping/getShippingDiscountTotal.js diff --git a/apps/reaction/plugins.json b/apps/reaction/plugins.json index 0fd55ef0e87..cd191a21ba8 100644 --- a/apps/reaction/plugins.json +++ b/apps/reaction/plugins.json @@ -25,6 +25,8 @@ "payments": "@reactioncommerce/api-plugin-payments", "paymentsStripeSCA": "@reactioncommerce/api-plugin-payments-stripe-sca", "paymentsExample": "@reactioncommerce/api-plugin-payments-example", + "discounts": "@reactioncommerce/api-plugin-discounts", + "discountCodes": "@reactioncommerce/api-plugin-discounts-codes", "surcharges": "@reactioncommerce/api-plugin-surcharges", "shipments": "@reactioncommerce/api-plugin-shipments", "shipmentsFlatRate": "@reactioncommerce/api-plugin-shipments-flat-rate", diff --git a/apps/reaction/tests/integration/api/mutations/discountCodes/discountCodes.test.js b/apps/reaction/tests/integration/api/mutations/discountCodes/discountCodes.test.js index adc676c955a..4bc3d438830 100644 --- a/apps/reaction/tests/integration/api/mutations/discountCodes/discountCodes.test.js +++ b/apps/reaction/tests/integration/api/mutations/discountCodes/discountCodes.test.js @@ -61,7 +61,7 @@ beforeAll(async () => { // test file gets its own test database. afterAll(() => testApp.stop()); -test.skip("user can add a discount code", async () => { +test("user can add a discount code", async () => { await testApp.setLoggedInUser(mockAdminAccount); const discountCodeInput = { @@ -150,7 +150,7 @@ test.skip("user can add a discount code", async () => { expect(createdDiscountCode).toEqual(expectedDiscountCodeResponse); }); -test.skip("user can update an existing discount code", async () => { +test("user can update an existing discount code", async () => { await testApp.setLoggedInUser(mockAdminAccount); const discountCodeInput = { @@ -231,7 +231,7 @@ test.skip("user can update an existing discount code", async () => { expect(updatedDiscountCode).toEqual(expectedDiscountCodeResponse); }); -test.skip("user can delete an existing discount code", async () => { +test("user can delete an existing discount code", async () => { await testApp.setLoggedInUser(mockAdminAccount); const discountCodeInput = { diff --git a/apps/reaction/tests/integration/api/queries/discountCodes/discountCodes.test.js b/apps/reaction/tests/integration/api/queries/discountCodes/discountCodes.test.js index 16a82470f19..12aba9d475b 100644 --- a/apps/reaction/tests/integration/api/queries/discountCodes/discountCodes.test.js +++ b/apps/reaction/tests/integration/api/queries/discountCodes/discountCodes.test.js @@ -110,7 +110,7 @@ beforeAll(async () => { // test file gets its own test database. afterAll(() => testApp.stop()); -test.skip("throws access-denied when getting discount codes if not an admin", async () => { +test("throws access-denied when getting discount codes if not an admin", async () => { await testApp.setLoggedInUser(mockCustomerAccount); try { @@ -122,7 +122,7 @@ test.skip("throws access-denied when getting discount codes if not an admin", as } }); -test.skip("returns discount records if user is an admin", async () => { +test("returns discount records if user is an admin", async () => { await testApp.setLoggedInUser(mockAdminAccount); const result = await discountCodes({ @@ -136,7 +136,7 @@ test.skip("returns discount records if user is an admin", async () => { }); -test.skip("returns discount records on second page if user is an admin", async () => { +test("returns discount records on second page if user is an admin", async () => { await testApp.setLoggedInUser(mockAdminAccount); const result = await discountCodes({ diff --git a/packages/api-plugin-discounts/package.json b/packages/api-plugin-discounts/package.json index 4c8bd7935e1..930082489e4 100644 --- a/packages/api-plugin-discounts/package.json +++ b/packages/api-plugin-discounts/package.json @@ -34,5 +34,10 @@ }, "publishConfig": { "access": "public" + }, + "scripts": { + "test": "jest --passWithNoTests", + "test:watch": "jest --watch", + "test:file": "jest --no-cache --watch --coverage=false" } } diff --git a/packages/api-plugin-discounts/src/util/setDiscountsOnCart.js b/packages/api-plugin-discounts/src/util/setDiscountsOnCart.js index 6b2581b1127..6016e49dae5 100644 --- a/packages/api-plugin-discounts/src/util/setDiscountsOnCart.js +++ b/packages/api-plugin-discounts/src/util/setDiscountsOnCart.js @@ -7,6 +7,9 @@ import getDiscountsTotalForCart from "../queries/getDiscountsTotalForCart.js"; * @returns {undefined} */ export default async function setDiscountsOnCart(context, cart) { - const { total } = await getDiscountsTotalForCart(context, cart); - cart.discount = total; + // check if promotion discounts are enabled + if (!context.discountCalculationMethods) { + const { total } = await getDiscountsTotalForCart(context, cart); + cart.discount = total; + } } diff --git a/packages/api-plugin-discounts/src/util/setDiscountsOnCart.test.js b/packages/api-plugin-discounts/src/util/setDiscountsOnCart.test.js new file mode 100644 index 00000000000..39b5955132f --- /dev/null +++ b/packages/api-plugin-discounts/src/util/setDiscountsOnCart.test.js @@ -0,0 +1,59 @@ +import setDiscountsOnCart from "./setDiscountsOnCart.js"; + +jest.mock("../queries/getDiscountsTotalForCart.js", () => jest.fn().mockReturnValue({ total: 10 })); + +test("should set discounts on cart when discountCalculationMethods doesn't existd", async () => { + const context = {}; + const cart = { + _id: "cart1", + items: [ + { + _id: "item1", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + }, + discounts: [] + } + ] + }; + + await setDiscountsOnCart(context, cart); + + expect(cart.discount).toBe(10); +}); + +test("shouldn't set discounts on cart when discountCalculationMethods exists", async () => { + const context = { + discountCalculationMethods: {} + }; + const cart = { + _id: "cart1", + items: [ + { + _id: "item1", + price: { + amount: 12 + }, + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD", + discount: 2, + undiscountedAmount: 12 + }, + discounts: [] + } + ] + }; + + await setDiscountsOnCart(context, cart); + + expect(cart.discount).toBeUndefined(); +}); diff --git a/packages/api-plugin-promotions-discounts/package.json b/packages/api-plugin-promotions-discounts/package.json index 9c23223e4e9..c47684e37c4 100644 --- a/packages/api-plugin-promotions-discounts/package.json +++ b/packages/api-plugin-promotions-discounts/package.json @@ -30,8 +30,10 @@ "@reactioncommerce/api-utils": "^1.16.7", "@reactioncommerce/logger": "^1.1.3", "@reactioncommerce/random": "^1.0.2", + "@reactioncommerce/reaction-error": "^1.0.1", "accounting-js": "^1.1.1", "json-rules-engine": "^6.1.2", + "lodash": "^4.17.15", "simpl-schema": "^1.12.3" }, "scripts": { @@ -41,5 +43,4 @@ "test:watch": "jest --watch", "test:file": "jest --no-cache --watch --coverage=false" } - } diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.js index e1712e9c723..3e3107cbc98 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.js @@ -1,3 +1,4 @@ +import _ from "lodash"; import formatMoney from "../../utils/formatMoney.js"; import getEligibleItems from "../../utils/getEligibleItems.js"; import getTotalEligibleItemsAmount from "../../utils/getTotalEligibleItemsAmount.js"; @@ -37,21 +38,28 @@ export function getCartDiscountAmount(context, items, discount) { const merchandiseTotal = getTotalEligibleItemsAmount(items); const { discountCalculationType, discountValue } = discount; const cartDiscountedAmount = context.discountCalculationMethods[discountCalculationType](discountValue, merchandiseTotal); - return merchandiseTotal - Number(formatMoney(cartDiscountedAmount)); + return Number(formatMoney(merchandiseTotal - cartDiscountedAmount)); } /** * @summary Splits a discount across all cart items - * @param {Number} totalDiscount - The total discount to split + * @param {Number} discountAmount - The total discount to split * @param {Array} cartItems - The cart items to split the discount across * @returns {void} undefined */ -export function splitDiscountForCartItems(totalDiscount, cartItems) { - const totalItemPrice = cartItems.reduce((acc, item) => acc + item.subtotal.amount, 0); - const discountForEachItems = cartItems.map((item) => { - const discount = (item.subtotal.amount / totalItemPrice) * totalDiscount; - return { _id: item._id, amount: Number(formatMoney(discount)) }; +export function splitDiscountForCartItems(discountAmount, cartItems) { + const totalAmount = _.sumBy(cartItems, "subtotal.amount"); + let discounted = 0; + const discountForEachItems = cartItems.map((item, index) => { + if (index !== cartItems.length - 1) { + const discount = formatMoney((item.subtotal.amount / totalAmount) * discountAmount); + discounted += discount; + return { _id: item._id, amount: discount }; + } + + return { _id: item._id, amount: formatMoney(discountAmount - discounted) }; }); + return discountForEachItems; } diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.test.js b/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.test.js index f5cac835ee2..60b49d3c81c 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.test.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.test.js @@ -1,3 +1,4 @@ +import _ from "lodash"; import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; import * as applyOrderDiscountToCart from "./applyOrderDiscountToCart.js"; @@ -186,3 +187,50 @@ test("should split discount for cart items", () => { } ]); }); + +test("the total discounted items should be equal total discount amount", () => { + const totalDiscount = 10; + const cartItems = [ + { + _id: "item1", + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD" + } + }, + { + _id: "item2", + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD" + } + }, + { + _id: "item3", + quantity: 1, + subtotal: { + amount: 10, + currencyCode: "USD" + } + } + ]; + + const discountForEachItem = applyOrderDiscountToCart.splitDiscountForCartItems(totalDiscount, cartItems); + expect(discountForEachItem).toEqual([ + { + _id: "item1", + amount: 3.33 + }, + { + _id: "item2", + amount: 3.33 + }, + { + _id: "item3", + amount: 3.34 + } + ]); + expect(_.sumBy(discountForEachItem, "amount")).toEqual(totalDiscount); +}); diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyDiscountsToRates.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyDiscountsToRates.js deleted file mode 100644 index dd527d89a24..00000000000 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyDiscountsToRates.js +++ /dev/null @@ -1,19 +0,0 @@ -import evaluateRulesAgainstShipping from "./evaluateRulesAgainstShipping.js"; - -/** - * @summary Add the discount to rates - * @param {Object} context - The application context - * @param {Object} commonOrder - The order to apply the discount to - * @param {Object} rates - The rates to apply the discount to - * @returns {Promise} undefined - */ -export default async function applyDiscountsToRates(context, commonOrder, rates) { - const shipping = { - discounts: commonOrder.discounts || [], - shipmentQuotes: rates - }; - const discountedShipping = await evaluateRulesAgainstShipping(context, shipping); - - /* eslint-disable-next-line no-param-reassign */ - rates = discountedShipping.shipmentQuotes; -} diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js index b9a7cda60f7..baec8197ea7 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js @@ -1,84 +1,13 @@ -import { createRequire } from "module"; -import Logger from "@reactioncommerce/logger"; -import evaluateRulesAgainstShipping from "./evaluateRulesAgainstShipping.js"; - -const require = createRequire(import.meta.url); - -const pkg = require("../../../package.json"); - -const { name, version } = pkg; -const logCtx = { - name, - version, - file: "util/applyShippingDiscountToCart.js" -}; +/* eslint-disable no-unused-vars */ +import ReactionError from "@reactioncommerce/reaction-error"; /** * @summary Add the discount to the shipping record * @param {Object} context - The application context * @param {Object} params - The parameters to apply - * @param {Object} param.shipping - The shipping record to apply the discount to - * @returns {Promise} undefined - */ -async function addDiscountToShipping(context, params, { shipping }) { - for (const shippingRecord of shipping) { - if (shippingRecord.discounts) { - const { promotion: { _id: promotionId }, actionKey } = params; - const existingDiscount = shippingRecord.discounts - .find((itemDiscount) => actionKey === itemDiscount.actionKey && promotionId === itemDiscount.promotionId); - if (existingDiscount) { - Logger.warn(logCtx, "Not adding discount because it already exists"); - return; - } - } - const cartDiscount = createShippingDiscount(shippingRecord, params); - if (shippingRecord.discounts) { - shippingRecord.discounts.push(cartDiscount); - } else { - shippingRecord.discounts = [cartDiscount]; - } - } -} - -/** - * @summary Create a discount object for a shipping record - * @param {Object} item - The cart item - * @param {Object} params - The action parameters - * @returns {Object} - The shipping discount object - */ -function createShippingDiscount(item, params) { - const { promotion: { _id }, actionParameters, actionKey } = params; - const shippingDiscount = { - actionKey, - promotionId: _id, - rules: actionParameters.rules, - discountCalculationType: actionParameters.discountCalculationType, - discountValue: actionParameters.discountValue, - dateApplied: new Date() - }; - return shippingDiscount; -} - -/** - * @summary Apply a shipping discount to a cart - * @param {Object} context - The application context - * @param {Object} params - The parameters to apply * @param {Object} cart - The cart to apply the discount to - * @returns {Promise} The updated cart + * @returns {Promise} undefined */ export default async function applyShippingDiscountToCart(context, params, cart) { - Logger.info(logCtx, "Applying shipping discount"); - const { shipping } = cart; - await addDiscountToShipping(context, params, { shipping }); - - // Check existing shipping quotes and discount them - Logger.info("Check existing shipping quotes and discount them"); - for (const shippingRecord of shipping) { - if (!shippingRecord.shipmentQuotes) continue; - // evaluate whether a discount applies to the existing shipment quotes - // eslint-disable-next-line no-await-in-loop - await evaluateRulesAgainstShipping(context, shippingRecord); - } - - return { cart }; + throw new ReactionError("not-implemented", "Not implemented"); } diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/evaluateRulesAgainstShipping.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/evaluateRulesAgainstShipping.js deleted file mode 100644 index c54a7ccb26d..00000000000 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/evaluateRulesAgainstShipping.js +++ /dev/null @@ -1,68 +0,0 @@ -import { Engine } from "json-rules-engine"; - -/** - * @summary Check if a shipment quote matches a discount rule - * @param {Object} context - The application context - * @param {Object} shipmentQuote - The shipment quote to evaluate rules against - * @param {Object} discount - The discount to evaluate rules against - * @returns {Boolean} True if the rules pass, false otherwise - */ -async function doesDiscountApply(context, shipmentQuote, discount) { - const { promotions: { operators } } = context; - const engine = new Engine(); - engine.addRule(discount.inclusionRules); - Object.keys(operators).forEach((operatorKey) => { - engine.addOperator(operatorKey, operators[operatorKey]); - }); - const results = await engine.run(shipmentQuote); - if (results.events.length) return true; - return false; -} - -/** - * @summary Apply a discount to a shipment quote - * @param {Object} context - The application context - * @param {Object} shipmentQuote - The shipment quote to apply the discount to - * @param {Object} discounts - The discounts to apply - * @returns {void} undefined - */ -function applyDiscounts(context, shipmentQuote, discounts) { - let totalDiscount = 0; - const amountBeforeDiscounts = shipmentQuote.method.undiscountedRate; - discounts.forEach((discount) => { - const calculationMethod = context.discountCalculationMethods[discount.discountCalculationType]; - const discountAmount = calculationMethod(discount.discountValue, amountBeforeDiscounts); - totalDiscount += discountAmount; - }); - shipmentQuote.rate = shipmentQuote.method.undiscountedRate - totalDiscount; - shipmentQuote.method.rate = shipmentQuote.method.undiscountedRate - totalDiscount; -} - -/** - * @summary check every discount on a shipping method and apply it to quotes - * @param {Object} context - The application context - * @param {Object} shipping - The shipping record to evaluate - * @returns {Promise} the possibly mutated shipping object - */ -export default async function evaluateRulesAgainstShipping(context, shipping) { - for (const shipmentQuote of shipping.shipmentQuotes) { - if (!shipmentQuote.method.undiscountedRate) { - shipmentQuote.method.undiscountedRate = shipmentQuote.method.rate; - } - } - - for (const shipmentQuote of shipping.shipmentQuotes) { - const applicableDiscounts = []; - for (const discount of shipping.discounts) { - // eslint-disable-next-line no-await-in-loop - const discountApplies = await doesDiscountApply(context, shipmentQuote, discount); - if (discountApplies) { - applicableDiscounts.push(discount); - } - } - if (applicableDiscounts.length) { - applyDiscounts(context, shipmentQuote, applicableDiscounts); - } - } - return shipping; -} diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/getGroupDisountTotal.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/getGroupDisountTotal.js deleted file mode 100644 index 7ec3719bc97..00000000000 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/getGroupDisountTotal.js +++ /dev/null @@ -1,11 +0,0 @@ -/* eslint-disable no-unused-vars */ - -/** - * @summary Get the group discount total for a order - * @param {Object} context - The application context - * @param {Object} params.commonOrder - The order to get the group discount total for - * @returns {Number} The total discount amount for the order - */ -export default function getGroupDiscountTotal(context, { commonOrder }) { - return 0; -} diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/getShippingDiscountTotal.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/getShippingDiscountTotal.js deleted file mode 100644 index e85fc411178..00000000000 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/getShippingDiscountTotal.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @summary Get the total discount amount for a shipping discount - * @param {Object} context - The application context - * @param {Object} cart - The cart to get the shipping discount total for - * @returns {Number} The total discount amount for the shipping discount - */ -export default function getShippingDiscountTotal(context, cart) { - const { shipping } = cart; - let totalShippingDiscount = 0; - for (const fulfillmentGroup of shipping) { - const { shipmentMethod } = fulfillmentGroup; - if (shipmentMethod && shipmentMethod.undiscountedRate) { - totalShippingDiscount += shipmentMethod.undiscountedRate - shipmentMethod.rate; - } - } - return totalShippingDiscount; -} diff --git a/packages/api-plugin-promotions-discounts/src/index.js b/packages/api-plugin-promotions-discounts/src/index.js index e085fe3bf15..f112b42f4c1 100644 --- a/packages/api-plugin-promotions-discounts/src/index.js +++ b/packages/api-plugin-promotions-discounts/src/index.js @@ -2,8 +2,6 @@ import { createRequire } from "module"; import actions from "./actions/index.js"; import methods from "./methods/index.js"; import queries from "./queries/index.js"; -import getGroupDiscountTotal from "./discountTypes/shipping/getGroupDisountTotal.js"; -import applyDiscountsToRates from "./discountTypes/shipping/applyDiscountsToRates.js"; import addDiscountToOrderItem from "./utils/addDiscountToOrderItem.js"; import preStartup from "./preStartup.js"; import { discountCalculationMethods, registerDiscountCalculationMethod } from "./registration.js"; @@ -26,9 +24,7 @@ export default async function register(app) { registerPluginHandler: [registerDiscountCalculationMethod], preStartup: [preStartup], mutateNewOrderItemBeforeCreate: [addDiscountToOrderItem], - calculateDiscountTotal: [getTotalDiscountOnCart], - getGroupDiscounts: [getGroupDiscountTotal], - applyDiscountsToRates: [applyDiscountsToRates] + calculateDiscountTotal: [getTotalDiscountOnCart] }, queries, contextAdditions: { diff --git a/packages/api-plugin-promotions-discounts/src/utils/formatMoney.js b/packages/api-plugin-promotions-discounts/src/utils/formatMoney.js index de0f7717870..f51cf399a5e 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/formatMoney.js +++ b/packages/api-plugin-promotions-discounts/src/utils/formatMoney.js @@ -6,5 +6,5 @@ import accounting from "accounting-js"; * @returns {String} The formatted amount */ export default function formatMoney(amount) { - return accounting.toFixed(amount, 2); + return Number(accounting.toFixed(amount, 2)); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0e279cf7ca..974959d68ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -253,7 +253,7 @@ importers: '@reactioncommerce/logger': link:../../packages/logger '@reactioncommerce/nodemailer': 5.0.5 '@reactioncommerce/random': link:../../packages/random - '@snyk/protect': 1.1053.0 + '@snyk/protect': 1.1054.0 graphql: 14.7.0 semver: 6.3.0 sharp: 0.29.3 @@ -1046,15 +1046,19 @@ importers: '@reactioncommerce/api-utils': ^1.16.7 '@reactioncommerce/logger': ^1.1.3 '@reactioncommerce/random': ^1.0.2 + '@reactioncommerce/reaction-error': ^1.0.1 accounting-js: ^1.1.1 json-rules-engine: ^6.1.2 + lodash: ^4.17.15 simpl-schema: ^1.12.3 dependencies: '@reactioncommerce/api-utils': link:../api-utils '@reactioncommerce/logger': link:../logger '@reactioncommerce/random': link:../random + '@reactioncommerce/reaction-error': link:../reaction-error accounting-js: 1.1.1 json-rules-engine: 6.1.2 + lodash: 4.17.21 simpl-schema: 1.12.3 packages/api-plugin-promotions-offers: @@ -4750,8 +4754,8 @@ packages: '@sinonjs/commons': 1.8.3 dev: false - /@snyk/protect/1.1053.0: - resolution: {integrity: sha512-u8wuwnE0ukC3ERjlp2c020dGu1tuXjsTUXTLFsGS9GaxzAM4vz0uln6k1me2YVxbFSvFND/jagvuxBOtzpltDA==} + /@snyk/protect/1.1054.0: + resolution: {integrity: sha512-N2kpUyvbC5T43zm9f7aPXflDN7droj5CQ+yJNCIxyq5EsubX5+7r7muRMLDBVyaBF8SEuMciKalqhDah50r36A==} engines: {node: '>=10'} hasBin: true dev: false From 01b77aafa38ac70d997658a3fefe8ae4f5bdbf74 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Tue, 8 Nov 2022 15:56:30 +0700 Subject: [PATCH 12/19] fix: fix calculate the order total --- packages/api-plugin-carts/src/xforms/xformCartCheckout.js | 2 +- packages/api-plugin-orders/src/util/addInvoiceToGroup.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/api-plugin-carts/src/xforms/xformCartCheckout.js b/packages/api-plugin-carts/src/xforms/xformCartCheckout.js index 707f41ad66b..ad4a938bdc3 100644 --- a/packages/api-plugin-carts/src/xforms/xformCartCheckout.js +++ b/packages/api-plugin-carts/src/xforms/xformCartCheckout.js @@ -76,7 +76,7 @@ function xformCartFulfillmentGroup(fulfillmentGroup, cart) { */ export default async function xformCartCheckout(collections, cart) { // itemTotal is qty * amount for each item, summed - const itemTotal = (cart.items || []).reduce((sum, item) => (sum + item.subtotal.amount), 0); + const itemTotal = (cart.items || []).reduce((sum, item) => (sum + (item.price.amount * item.quantity)), 0); // shippingTotal is shipmentMethod.rate for each item, summed // handlingTotal is shipmentMethod.handling for each item, summed diff --git a/packages/api-plugin-orders/src/util/addInvoiceToGroup.js b/packages/api-plugin-orders/src/util/addInvoiceToGroup.js index 8bccce940eb..9cc35687a1c 100644 --- a/packages/api-plugin-orders/src/util/addInvoiceToGroup.js +++ b/packages/api-plugin-orders/src/util/addInvoiceToGroup.js @@ -20,7 +20,7 @@ export default function addInvoiceToGroup({ taxTotal }) { // Items - const itemTotal = +accounting.toFixed(group.items.reduce((sum, item) => (sum + item.subtotal), 0), 3); + const itemTotal = +accounting.toFixed(group.items.reduce((sum, item) => (sum + (item.price.amount * item.quantity)), 0), 3); // Taxes const effectiveTaxRate = taxableAmount > 0 ? taxTotal / taxableAmount : 0; From 0cd928724dc05dd8c27e350b69b50aed16e0e072 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Wed, 9 Nov 2022 10:30:01 +0700 Subject: [PATCH 13/19] fix: fix test fail for order plugin --- .../api-plugin-orders/src/mutations/placeOrder.test.js | 3 ++- packages/api-plugin-orders/src/simpleSchemas.js | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/api-plugin-orders/src/mutations/placeOrder.test.js b/packages/api-plugin-orders/src/mutations/placeOrder.test.js index ee32ea98641..24d42be488e 100644 --- a/packages/api-plugin-orders/src/mutations/placeOrder.test.js +++ b/packages/api-plugin-orders/src/mutations/placeOrder.test.js @@ -171,7 +171,8 @@ test("places an anonymous $0 order with no cartId and no payments", async () => workflow: { status: "new", workflow: ["new"] - } + }, + appliedPromotions: [] }); expect(token).toEqual(jasmine.any(String)); diff --git a/packages/api-plugin-orders/src/simpleSchemas.js b/packages/api-plugin-orders/src/simpleSchemas.js index 9c776a1a1d2..400893332ee 100644 --- a/packages/api-plugin-orders/src/simpleSchemas.js +++ b/packages/api-plugin-orders/src/simpleSchemas.js @@ -1114,6 +1114,15 @@ export const Order = new SimpleSchema({ type: Workflow, optional: true, defaultValue: {} + }, + "appliedPromotions": { + type: Array, + optional: true, + defaultValue: [] + }, + "appliedPromotions.$": { + type: Object, + blackbox: true } }); From 47bf3c745a2c9b735395a5fb19449736b72896cf Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Tue, 8 Nov 2022 17:33:11 +0700 Subject: [PATCH 14/19] feat: update the promotion data on sample-data plugin --- .../src/mutations/placeOrder.js | 6 +- .../src/mutations/placeOrder.test.js | 2 + .../src/preStartup.js | 13 ++++- .../src/queries/getDiscountsTotalForCart.js | 2 + .../src/loaders/loadPromotions.js | 57 ++++++++++++++++--- 5 files changed, 69 insertions(+), 11 deletions(-) diff --git a/packages/api-plugin-orders/src/mutations/placeOrder.js b/packages/api-plugin-orders/src/mutations/placeOrder.js index 8acd0f43305..e2a96a09ff8 100644 --- a/packages/api-plugin-orders/src/mutations/placeOrder.js +++ b/packages/api-plugin-orders/src/mutations/placeOrder.js @@ -149,10 +149,11 @@ export default async function placeOrder(context, input) { // discount codes feature. We are planning to revamp discounts soon, but until then, we'll look up // any discounts on the related cart here. let discounts = []; + let appliedPromotions = []; let discountTotal = 0; if (cart) { const discountsResult = await context.queries.getDiscountsTotalForCart(context, cart); - ({ discounts } = discountsResult); + ({ discounts, appliedPromotions } = discountsResult); discountTotal = discountsResult.total; } @@ -229,7 +230,8 @@ export default async function placeOrder(context, input) { workflow: { status: "new", workflow: ["new"] - } + }, + appliedPromotions }; if (fullToken) { diff --git a/packages/api-plugin-orders/src/mutations/placeOrder.test.js b/packages/api-plugin-orders/src/mutations/placeOrder.test.js index 24d42be488e..6d98077bd91 100644 --- a/packages/api-plugin-orders/src/mutations/placeOrder.test.js +++ b/packages/api-plugin-orders/src/mutations/placeOrder.test.js @@ -49,6 +49,8 @@ test("places an anonymous $0 order with no cartId and no payments", async () => rate: 0 }]); + mockContext.queries.getDiscountsTotalForCart = jest.fn().mockName("getDiscountsTotalForCart"); + mockContext.queries.shopById = jest.fn().mockName("shopById"); mockContext.queries.shopById.mockReturnValueOnce([{ availablePaymentMethods: ["PAYMENT1"] diff --git a/packages/api-plugin-promotions-discounts/src/preStartup.js b/packages/api-plugin-promotions-discounts/src/preStartup.js index 32fcc29f666..3a59ead6996 100644 --- a/packages/api-plugin-promotions-discounts/src/preStartup.js +++ b/packages/api-plugin-promotions-discounts/src/preStartup.js @@ -86,7 +86,7 @@ async function extendCartSchemas(context) { * @returns {Promise} undefined */ async function extendOrderSchemas(context) { - const { simpleSchemas: { Order, OrderFulfillmentGroup, OrderItem, CommonOrder, SelectedFulfillmentOption } } = context; + const { simpleSchemas: { Order, OrderFulfillmentGroup, OrderItem, CommonOrder, SelectedFulfillmentOption, Promotion } } = context; Order.extend({ // this is here for backwards compatibility with old discounts discount: { @@ -111,6 +111,17 @@ async function extendOrderSchemas(context) { label: "Order Discount" } }); + + Order.extend({ + "appliedPromotions": { + type: Array, + optional: true + }, + "appliedPromotions.$": { + type: Promotion + } + }); + OrderItem.extend({ "discount": { type: Number, diff --git a/packages/api-plugin-promotions-discounts/src/queries/getDiscountsTotalForCart.js b/packages/api-plugin-promotions-discounts/src/queries/getDiscountsTotalForCart.js index 05ee738ee5f..38304665db5 100644 --- a/packages/api-plugin-promotions-discounts/src/queries/getDiscountsTotalForCart.js +++ b/packages/api-plugin-promotions-discounts/src/queries/getDiscountsTotalForCart.js @@ -7,6 +7,7 @@ */ export default async function getDiscountsTotalForCart(context, cart) { const discounts = cart.discounts || []; + const appliedPromotions = cart.appliedPromotions || []; for (const cartItem of cart.items) { if (cartItem.discounts) { @@ -18,6 +19,7 @@ export default async function getDiscountsTotalForCart(context, cart) { return { discounts, + appliedPromotions, total: cart.discount }; } diff --git a/packages/api-plugin-sample-data/src/loaders/loadPromotions.js b/packages/api-plugin-sample-data/src/loaders/loadPromotions.js index 131ae41fc95..b80293a3ef5 100644 --- a/packages/api-plugin-sample-data/src/loaders/loadPromotions.js +++ b/packages/api-plugin-sample-data/src/loaders/loadPromotions.js @@ -11,12 +11,11 @@ const OrderPromotion = { { triggerKey: "offers", triggerParameters: { - name: "5 percent off your entire order when you spend more then $200", + name: "50 percent off your entire order when you spend more then $200", conditions: { - any: [ + all: [ { - fact: "cart", - path: "$.merchandiseTotal", + fact: "totalItemAmount", operator: "greaterThanInclusive", value: 200 } @@ -27,13 +26,55 @@ const OrderPromotion = { ], actions: [ { - actionKey: "noop", - actionParameters: {} + actionKey: "discounts", + actionParameters: { + discountType: "order", + discountCalculationType: "percentage", + discountValue: 50 + } } ], startDate: now, endDate: new Date(now.getTime() + 1000 * 60 * 60 * 24 * 7), - stackAbility: "none" + stackAbility: "all" +}; + +const OrderItemPromotion = { + _id: "itemPromotion", + type: "implicit", + label: "50 percent off your entire order when you spend more then $200", + description: "50 percent off your entire order when you spend more then $200", + enabled: true, + triggers: [ + { + triggerKey: "offers", + triggerParameters: { + name: "50 percent off your entire order when you spend more then $200", + conditions: { + all: [ + { + fact: "totalItemAmount", + operator: "greaterThanInclusive", + value: 200 + } + ] + } + } + } + ], + actions: [ + { + actionKey: "discounts", + actionParameters: { + discountType: "item", + discountCalculationType: "percentage", + discountValue: 50 + } + } + ], + startDate: now, + endDate: new Date(now.getTime() + 1000 * 60 * 60 * 24 * 7), + stackAbility: "all" }; const CouponPromotion = { @@ -63,7 +104,7 @@ const CouponPromotion = { stackAbility: "all" }; -const promotions = [OrderPromotion, CouponPromotion]; +const promotions = [OrderPromotion, OrderItemPromotion, CouponPromotion]; /** * @summary Load promotions fixtures From 719002a413bf8906576a70a5bfd08a9d7bd1ed66 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Mon, 14 Nov 2022 15:15:58 +0700 Subject: [PATCH 15/19] feat: add integration test for promotions --- .../addCartItems/addCartItems.test.js | 11 +- .../checkout/promotionCheckout.test.js | 268 ++++++++++++++++++ .../anonymousCartByCartId.test.js | 12 +- apps/reaction/tests/util/factory.js | 7 +- .../src/handlers/applyExplicitPromotion.js | 8 +- .../handlers/applyExplicitPromotion.test.js | 12 +- .../src/handlers/applyPromotions.js | 13 +- .../src/handlers/applyPromotions.test.js | 22 +- packages/api-plugin-promotions/src/index.js | 14 +- packages/api-plugin-promotions/src/startup.js | 22 -- 10 files changed, 315 insertions(+), 74 deletions(-) create mode 100644 apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js delete mode 100644 packages/api-plugin-promotions/src/startup.js diff --git a/apps/reaction/tests/integration/api/mutations/addCartItems/addCartItems.test.js b/apps/reaction/tests/integration/api/mutations/addCartItems/addCartItems.test.js index a62693a3e51..d59def0a5cd 100644 --- a/apps/reaction/tests/integration/api/mutations/addCartItems/addCartItems.test.js +++ b/apps/reaction/tests/integration/api/mutations/addCartItems/addCartItems.test.js @@ -36,6 +36,7 @@ beforeAll(async () => { catalogItem = Factory.Catalog.makeOne({ isDeleted: false, product: Factory.CatalogProduct.makeOne({ + title: "Test Product", isDeleted: false, isVisible: true, variants: Factory.CatalogProductVariant.makeMany(1, { @@ -80,15 +81,7 @@ beforeAll(async () => { shipping: null, items: [], workflow: null, - discounts: [ - { - promotionId: "mockPromotionId", - discountType: "order", - discountCalculationType: "fixed", - discountValue: 25124, - dateApplied: new Date() - } - ] + discounts: [] }); opaqueCartId = encodeOpaqueId("reaction/cart", mockCart._id); await testApp.collections.Cart.insertOne(mockCart); diff --git a/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js b/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js new file mode 100644 index 00000000000..1c5b203134b --- /dev/null +++ b/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js @@ -0,0 +1,268 @@ +import decodeOpaqueIdForNamespace from "@reactioncommerce/api-utils/decodeOpaqueIdForNamespace.js"; +import importAsString from "@reactioncommerce/api-utils/importAsString.js"; +import Factory from "/tests/util/factory.js"; +import getCommonData from "../checkout/checkoutTestsCommon.js"; + +const AnonymousCartByCartIdQuery = importAsString("../checkout/AnonymousCartByCartIdQuery.graphql"); +const SetEmailOnAnonymousCart = importAsString("../checkout/SetEmailOnAnonymousCartMutation.graphql"); + +let anonymousCartByCartQuery; +let availablePaymentMethods; +let createCart; +let encodeProductOpaqueId; +let internalVariantIds; +let opaqueProductId; +let opaqueShopId; +let placeOrder; +let selectFulfillmentOptionForGroup; +let setEmailOnAnonymousCart; +let setShippingAddressOnCart; +let testApp; +let updateFulfillmentOptionsForGroup; +let mockPromotion; + +beforeAll(async () => { + ({ + availablePaymentMethods, + createCart, + encodeProductOpaqueId, + internalVariantIds, + opaqueProductId, + opaqueShopId, + placeOrder, + selectFulfillmentOptionForGroup, + setShippingAddressOnCart, + testApp, + updateFulfillmentOptionsForGroup + } = getCommonData()); + + anonymousCartByCartQuery = testApp.mutate(AnonymousCartByCartIdQuery); + setEmailOnAnonymousCart = testApp.mutate(SetEmailOnAnonymousCart); + + const now = new Date(); + mockPromotion = Factory.Promotion.makeOne({ + actions: [ + { + actionKey: "discounts", + actionParameters: { + discountType: "order", + discountCalculationType: "percentage", + discountValue: 50 + } + } + ], + triggers: [ + { + triggerKey: "offers", + triggerParameters: { + name: "50 percent off your entire order when you spend more then $100", + conditions: { + all: [ + { + fact: "totalItemAmount", + operator: "greaterThanInclusive", + value: 100 + } + ] + } + } + } + ], + triggerType: "implicit", + promotionType: "order-discount", + startDate: now, + endDate: new Date(now.getTime() + 1000 * 60 * 60 * 24 * 7), + enabled: true, + shopId: decodeOpaqueIdForNamespace("reaction/shop")(opaqueShopId) + }); + + await testApp.collections.Promotions.insertOne(mockPromotion); +}); + +// There is no need to delete any test data from collections because +// testApp.stop() will drop the entire test database. Each integration +// test file gets its own test database. +afterAll(() => testApp.stop()); + +describe("Promotions", () => { + let cartToken; + let opaqueCartId; + let opaqueCartProductVariantId; + let opaqueFulfillmentGroupId; + let opaqueFulfillmentMethodId; + let latestCartSummary; + + beforeAll(async () => { + opaqueCartProductVariantId = encodeProductOpaqueId(internalVariantIds[1]); + await testApp.clearLoggedInUser(); + }); + + const shippingAddress = { + address1: "12345 Drive Lane", + city: "The city", + country: "USA", + firstName: "FName", + fullName: "FName LName", + isBillingDefault: false, + isCommercial: false, + isShippingDefault: false, + lastName: "LName", + phone: "5555555555", + postal: "97878", + region: "CA" + }; + + test("create a new cart", async () => { + const result = await createCart({ + createCartInput: { + shopId: opaqueShopId, + items: { + price: { + amount: 19.99, + currencyCode: "USD" + }, + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueCartProductVariantId + }, + quantity: 6 + } + } + }); + + cartToken = result.createCart.token; + opaqueCartId = result.createCart.cart._id; + }); + + test("set email on anonymous cart", async () => { + const result = await setEmailOnAnonymousCart({ + input: { + cartId: opaqueCartId, + cartToken, + email: "test@email.com" + } + }); + + opaqueCartId = result.setEmailOnAnonymousCart.cart._id; + }); + + test("set shipping address on cart", async () => { + const result = await setShippingAddressOnCart({ + input: { + cartId: opaqueCartId, + cartToken, + address: { + address1: "12345 Drive Lane", + city: "The city", + country: "USA", + firstName: "FName", + fullName: "FName LName", + lastName: "LName", + phone: "5555555555", + postal: "97878", + region: "CA" + } + } + }); + + opaqueFulfillmentGroupId = result.setShippingAddressOnCart.cart.checkout.fulfillmentGroups[0]._id; + }); + + test("get available fulfillment options", async () => { + const result = await updateFulfillmentOptionsForGroup({ + input: { + cartId: opaqueCartId, + cartToken, + fulfillmentGroupId: opaqueFulfillmentGroupId + } + }); + + const option = result.updateFulfillmentOptionsForGroup.cart.checkout.fulfillmentGroups[0].availableFulfillmentOptions[0]; + opaqueFulfillmentMethodId = option.fulfillmentMethod._id; + }); + + test("select the `Standard mockMethod` fulfillment option", async () => { + const result = await selectFulfillmentOptionForGroup({ + input: { + cartId: opaqueCartId, + cartToken, + fulfillmentGroupId: opaqueFulfillmentGroupId, + fulfillmentMethodId: opaqueFulfillmentMethodId + } + }); + + latestCartSummary = result.selectFulfillmentOptionForGroup.cart.checkout.summary; + }); + + test("place order with discounted amount", async () => { + let result; + + const paymentMethods = await availablePaymentMethods({ + shopId: opaqueShopId + }); + + const paymentMethodName = paymentMethods.availablePaymentMethods[0].name; + + const { anonymousCartByCartId: anonymousCart } = await anonymousCartByCartQuery({ + cartId: opaqueCartId, + cartToken + }); + + try { + result = await placeOrder({ + input: { + order: { + cartId: opaqueCartId, + currencyCode: "USD", + email: anonymousCart.email, + fulfillmentGroups: [ + { + data: { + shippingAddress + }, + items: [ + { + price: 19.99, + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueCartProductVariantId + }, + quantity: 6 + } + ], + selectedFulfillmentMethodId: opaqueFulfillmentMethodId, + shopId: opaqueShopId, + type: "shipping", + totalPrice: latestCartSummary.total.amount + } + ], + shopId: opaqueShopId + }, + payments: [ + { + amount: latestCartSummary.total.amount, + method: paymentMethodName + } + ] + } + }); + } catch (error) { + expect(error).toBeUndefined(); + return; + } + + const orderId = decodeOpaqueIdForNamespace("reaction/order")(result.placeOrder.orders[0]._id); + const newOrder = await testApp.collections.Orders.findOne({ _id: orderId }); + + expect(newOrder.shipping[0].invoice.total).toEqual(62.47); + expect(newOrder.shipping[0].invoice.discounts).toEqual(59.97); + expect(newOrder.shipping[0].invoice.subtotal).toEqual(119.94); + + expect(newOrder.shipping[0].items[0].quantity).toEqual(6); + expect(newOrder.shipping[0].items[0].discounts).toHaveLength(1); + expect(newOrder.shipping[0].items[0].discount).toEqual(59.97); + + expect(newOrder.appliedPromotions[0]._id).toEqual(mockPromotion._id); + expect(newOrder.discounts).toHaveLength(1); + }); +}); diff --git a/apps/reaction/tests/integration/api/queries/anonymousCartByCartId/anonymousCartByCartId.test.js b/apps/reaction/tests/integration/api/queries/anonymousCartByCartId/anonymousCartByCartId.test.js index 0dbe7dd61a7..fdd713ae985 100644 --- a/apps/reaction/tests/integration/api/queries/anonymousCartByCartId/anonymousCartByCartId.test.js +++ b/apps/reaction/tests/integration/api/queries/anonymousCartByCartId/anonymousCartByCartId.test.js @@ -40,15 +40,7 @@ beforeAll(async () => { shipping: null, items: [], workflow: null, - discounts: [ - { - promotionId: "mockPromotionId", - discountType: "order", - discountCalculationType: "fixed", - discountValue: 25124, - dateApplied: new Date() - } - ] + discounts: [] }); opaqueCartId = encodeOpaqueId("reaction/cart", mockCart._id); @@ -78,6 +70,7 @@ test("anonymous cart query works after a related catalog product is hidden", asy isDeleted: false, isVisible: true, product: Factory.CatalogProduct.makeOne({ + title: "Test Product", productId: "1", isDeleted: false, isVisible: true, @@ -139,6 +132,7 @@ test("anonymous cart query works after a related catalog product is deleted", as isDeleted: false, isVisible: true, product: Factory.CatalogProduct.makeOne({ + title: "Test Product", productId: "2", isDeleted: false, isVisible: true, diff --git a/apps/reaction/tests/util/factory.js b/apps/reaction/tests/util/factory.js index 9cc47e3607b..fd1521fa87d 100644 --- a/apps/reaction/tests/util/factory.js +++ b/apps/reaction/tests/util/factory.js @@ -100,6 +100,10 @@ import { TaxRates } from "@reactioncommerce/api-plugin-taxes-flat-rate/src/simpleSchemas.js"; +import { + Promotion +} from "@reactioncommerce/api-plugin-promotions/src/simpleSchemas.js"; + const schemasToAddToFactory = { Account, @@ -141,7 +145,8 @@ const schemasToAddToFactory = { Sitemap, Surcharge, Tag, - TaxRates + TaxRates, + Promotion }; // Extend before creating factories in case some of the added fields diff --git a/packages/api-plugin-promotions/src/handlers/applyExplicitPromotion.js b/packages/api-plugin-promotions/src/handlers/applyExplicitPromotion.js index 6b1b1b5778f..8134960cd2e 100644 --- a/packages/api-plugin-promotions/src/handlers/applyExplicitPromotion.js +++ b/packages/api-plugin-promotions/src/handlers/applyExplicitPromotion.js @@ -1,5 +1,3 @@ -import applyPromotions from "./applyPromotions.js"; - /** * @summary apply explicit promotion to a cart * @param {Object} context - The application context @@ -8,5 +6,9 @@ import applyPromotions from "./applyPromotions.js"; * @returns {Object} - The cart with promotions applied and applied promotions */ export default async function applyExplicitPromotion(context, cart, promotion) { - return applyPromotions(context, cart, promotion); + if (!Array.isArray(cart.appliedPromotions)) { + cart.appliedPromotions = []; + } + cart.appliedPromotions.push(promotion); + await context.mutations.saveCart(context, cart); } diff --git a/packages/api-plugin-promotions/src/handlers/applyExplicitPromotion.test.js b/packages/api-plugin-promotions/src/handlers/applyExplicitPromotion.test.js index e3b045c41dc..f686cb51c81 100644 --- a/packages/api-plugin-promotions/src/handlers/applyExplicitPromotion.test.js +++ b/packages/api-plugin-promotions/src/handlers/applyExplicitPromotion.test.js @@ -1,12 +1,18 @@ -import applyPromotions from "./applyPromotions.js"; import applyExplicitPromotion from "./applyExplicitPromotion.js"; jest.mock("../handlers/applyPromotions.js", () => jest.fn().mockName("applyPromotions")); test("call applyPromotions function", async () => { - const context = { collections: { Cart: { findOne: jest.fn().mockName("findOne") } } }; + const mockSaveCartMutation = jest.fn().mockName("saveCartMutation"); + const context = { + collections: { Cart: { findOne: jest.fn().mockName("findOne") } }, + mutations: { saveCart: mockSaveCartMutation } + }; const cart = { _id: "cartId" }; const promotion = { _id: "promotionId" }; + applyExplicitPromotion(context, cart, promotion); - expect(applyPromotions).toHaveBeenCalledWith(context, cart, promotion); + + const expectedCart = { ...cart, appliedPromotions: [promotion] }; + expect(mockSaveCartMutation).toHaveBeenCalledWith(context, expectedCart); }); diff --git a/packages/api-plugin-promotions/src/handlers/applyPromotions.js b/packages/api-plugin-promotions/src/handlers/applyPromotions.js index 0febc54340f..2342a67f36f 100644 --- a/packages/api-plugin-promotions/src/handlers/applyPromotions.js +++ b/packages/api-plugin-promotions/src/handlers/applyPromotions.js @@ -39,10 +39,9 @@ async function getImplicitPromotions(context, shopId) { * @summary apply promotions to a cart * @param {Object} context - The application context * @param {Object} cart - The cart to apply promotions to - * @param {Object} explicitPromotion - The explicit promotion to apply - * @returns {Promise} - The cart with promotions applied + * @returns {Promise} - undefined */ -export default async function applyPromotions(context, cart, explicitPromotion = undefined) { +export default async function applyPromotions(context, cart) { const promotions = await getImplicitPromotions(context, cart.shopId); const { promotions: pluginPromotions, simpleSchemas: { Cart } } = context; @@ -50,12 +49,9 @@ export default async function applyPromotions(context, cart, explicitPromotion = const actionHandleByKey = _.keyBy(pluginPromotions.actions, "key"); const appliedPromotions = []; - const appliedExplicitPromotions = _.filter(cart.appliedPromotions || [], ["type", "explicit"]); + const appliedExplicitPromotions = _.filter(cart.appliedPromotions || [], ["triggerType", "explicit"]); const unqualifiedPromotions = promotions.concat(appliedExplicitPromotions); - if (explicitPromotion) { - unqualifiedPromotions.push(explicitPromotion); - } for (const { cleanup } of pluginPromotions.actions) { // eslint-disable-next-line no-await-in-loop @@ -99,8 +95,7 @@ export default async function applyPromotions(context, cart, explicitPromotion = enhancedCart.appliedPromotions = appliedPromotions; Cart.clean(enhancedCart, { mutate: true }); + Object.assign(cart, enhancedCart); Logger.info({ ...logCtx, appliedPromotions: appliedPromotions.length }, "Applied promotions successfully"); - - return context.mutations.saveCart(context, enhancedCart, "promotions"); } diff --git a/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js b/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js index 19c591ce7f4..886cd78b1d7 100644 --- a/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js +++ b/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js @@ -30,31 +30,27 @@ test("should save cart with implicit promotions are applied", async () => { find: () => ({ toArray: jest.fn().mockResolvedValueOnce([testPromotion]) }) }; mockContext.promotions = pluginPromotion; - mockContext.mutations.saveCart = jest - .fn() - .mockName("saveCart") - .mockResolvedValueOnce({ ...cart }); mockContext.simpleSchemas = { Cart: { clean: jest.fn() } }; await applyImplicitPromotions(mockContext, cart); - expect(testTrigger).toBeCalledWith(mockContext, expect.objectContaining(cart), { + expect(testTrigger).toBeCalledWith(mockContext, expect.objectContaining({ _id: cart._id }), { promotion: testPromotion, triggerParameters: { name: "test trigger" } }); - expect(testAction).toBeCalledWith(mockContext, expect.objectContaining(cart), { + expect(testAction).toBeCalledWith(mockContext, expect.objectContaining({ _id: cart._id }), { actionKey: "test", promotion: testPromotion }); - expect(testEnhancer).toBeCalledWith(mockContext, expect.objectContaining(cart)); + expect(testEnhancer).toBeCalledWith(mockContext, expect.objectContaining({ _id: cart._id })); const expectedCart = { ...cart, appliedPromotions: [testPromotion] }; - expect(mockContext.mutations.saveCart).toHaveBeenCalledWith(mockContext, expectedCart, "promotions"); + expect(cart).toEqual(expectedCart); }); -test("should save cart with implicit promotions are not applied when promotions don't contain trigger", async () => { +test("should update cart with implicit promotions are not applied when promotions don't contain trigger", async () => { const cart = { _id: "cartId" }; @@ -65,19 +61,15 @@ test("should save cart with implicit promotions are not applied when promotions }; mockContext.promotions = { ...pluginPromotion, triggers: [] }; - mockContext.mutations.saveCart = jest - .fn() - .mockName("saveCart") - .mockResolvedValueOnce({ ...cart }); mockContext.simpleSchemas = { Cart: { clean: jest.fn() } }; - await applyImplicitPromotions(mockContext, { ...cart }); + await applyImplicitPromotions(mockContext, cart); expect(testTrigger).not.toHaveBeenCalled(); expect(testAction).not.toHaveBeenCalled(); const expectedCart = { ...cart, appliedPromotions: [] }; - expect(mockContext.mutations.saveCart).toHaveBeenCalledWith(mockContext, expectedCart, "promotions"); + expect(cart).toEqual(expectedCart); }); diff --git a/packages/api-plugin-promotions/src/index.js b/packages/api-plugin-promotions/src/index.js index 3360e0a2eee..091fc9509a9 100644 --- a/packages/api-plugin-promotions/src/index.js +++ b/packages/api-plugin-promotions/src/index.js @@ -1,7 +1,6 @@ import { createRequire } from "module"; import { promotions, registerPluginHandlerForPromotions } from "./registration.js"; import mutations from "./mutations/index.js"; -import startupPromotions from "./startup.js"; import preStartupPromotions from "./preStartup.js"; import { Promotion } from "./simpleSchemas.js"; import actions from "./actions/index.js"; @@ -10,6 +9,7 @@ import promotionTypes from "./promotionTypes/index.js"; import schemas from "./schemas/index.js"; import queries from "./queries/index.js"; import resolvers from "./resolvers/index.js"; +import applyPromotions from "./handlers/applyPromotions.js"; const require = createRequire(import.meta.url); const pkg = require("../package.json"); @@ -45,12 +45,20 @@ export default async function register(app) { }, functionsByType: { registerPluginHandler: [registerPluginHandlerForPromotions], - preStartup: [preStartupPromotions], - startup: [startupPromotions] + preStartup: [preStartupPromotions] }, contextAdditions: { promotions }, + cart: { + transforms: [ + { + name: "applyPromotionsToCart", + fn: applyPromotions, + priority: 99 + } + ] + }, promotions: { actions, qualifiers, diff --git a/packages/api-plugin-promotions/src/startup.js b/packages/api-plugin-promotions/src/startup.js deleted file mode 100644 index e4b2b7eb900..00000000000 --- a/packages/api-plugin-promotions/src/startup.js +++ /dev/null @@ -1,22 +0,0 @@ -import applyImplicitPromotions from "./handlers/applyPromotions.js"; - -/** - * @summary Perform various scaffolding tasks on startup - * @param {Object} context - The application context - * @returns {Promise} undefined - */ -export default async function startupPromotions(context) { - context.appEvents.on("afterCartCreate", async (args) => { - const { cart, emittedBy } = args; - if (emittedBy !== "promotions") { - await applyImplicitPromotions(context, cart); - } - }); - - context.appEvents.on("afterCartUpdate", async (args) => { - const { cart, emittedBy } = args; - if (emittedBy !== "promotions") { - await applyImplicitPromotions(context, cart); - } - }); -} From f4cf8fdf993514db1a29449587106d53fe92a62a Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Tue, 15 Nov 2022 10:04:37 +0700 Subject: [PATCH 16/19] feat: update lockfile --- pnpm-lock.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 974959d68ac..fee4727aec0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -253,7 +253,7 @@ importers: '@reactioncommerce/logger': link:../../packages/logger '@reactioncommerce/nodemailer': 5.0.5 '@reactioncommerce/random': link:../../packages/random - '@snyk/protect': 1.1054.0 + '@snyk/protect': 1.1058.0 graphql: 14.7.0 semver: 6.3.0 sharp: 0.29.3 @@ -4754,8 +4754,8 @@ packages: '@sinonjs/commons': 1.8.3 dev: false - /@snyk/protect/1.1054.0: - resolution: {integrity: sha512-N2kpUyvbC5T43zm9f7aPXflDN7droj5CQ+yJNCIxyq5EsubX5+7r7muRMLDBVyaBF8SEuMciKalqhDah50r36A==} + /@snyk/protect/1.1058.0: + resolution: {integrity: sha512-8AIRMlaoAY1yEk7+4RDV957Pszt/UEVhr2qdC7PTFa1mELb9/fNwqJ2KTBMhBKarSFEZrM5ZWPe91ogHIT49+Q==} engines: {node: '>=10'} hasBin: true dev: false From cbf069f0b415524393ddb5bde95466d25db33621 Mon Sep 17 00:00:00 2001 From: Brent Hoover Date: Tue, 15 Nov 2022 05:03:51 +0000 Subject: [PATCH 17/19] fix: fix sample-data records Signed-off-by: Brent Hoover --- .../src/loaders/loadPromotions.js | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/api-plugin-sample-data/src/loaders/loadPromotions.js b/packages/api-plugin-sample-data/src/loaders/loadPromotions.js index b80293a3ef5..de3d84f2650 100644 --- a/packages/api-plugin-sample-data/src/loaders/loadPromotions.js +++ b/packages/api-plugin-sample-data/src/loaders/loadPromotions.js @@ -4,8 +4,8 @@ const OrderPromotion = { _id: "orderPromotion", triggerType: "implicit", promotionType: "order-discount", - label: "5 percent off your entire order when you spend more then $200", - description: "5 percent off your entire order when you spend more then $200", + label: "50 percent off your entire order when you spend more then $200", + description: "50 percent off your entire order when you spend more then $200", enabled: true, triggers: [ { @@ -36,26 +36,29 @@ const OrderPromotion = { ], startDate: now, endDate: new Date(now.getTime() + 1000 * 60 * 60 * 24 * 7), - stackAbility: "all" + stackAbility: "all", + createdAt: new Date(), + updatedAt: new Date() }; const OrderItemPromotion = { _id: "itemPromotion", - type: "implicit", - label: "50 percent off your entire order when you spend more then $200", - description: "50 percent off your entire order when you spend more then $200", + triggerType: "implicit", + promotionType: "item-discount", + label: "50 percent off your entire order when you spend more then $500", + description: "50 percent off your entire order when you spend more then $500", enabled: true, triggers: [ { triggerKey: "offers", triggerParameters: { - name: "50 percent off your entire order when you spend more then $200", + name: "50 percent off your entire order when you spend more then $500", conditions: { all: [ { fact: "totalItemAmount", operator: "greaterThanInclusive", - value: 200 + value: 500 } ] } @@ -74,12 +77,14 @@ const OrderItemPromotion = { ], startDate: now, endDate: new Date(now.getTime() + 1000 * 60 * 60 * 24 * 7), - stackAbility: "all" + stackAbility: "all", + createdAt: new Date(), + updatedAt: new Date() }; const CouponPromotion = { _id: "couponPromotion", - triggerType: "implicit", + triggerType: "explicit", promotionType: "order-discount", label: "Specific coupon code", description: "Specific coupon code", @@ -101,7 +106,9 @@ const CouponPromotion = { ], startDate: now, endDate: new Date(now.getTime() + 1000 * 60 * 60 * 24 * 7), - stackAbility: "all" + stackAbility: "all", + createdAt: new Date(), + updatedAt: new Date() }; const promotions = [OrderPromotion, OrderItemPromotion, CouponPromotion]; From cc6a9c21c2b76016f8f8a5a53e01562aee59c42d Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Tue, 15 Nov 2022 11:55:02 +0700 Subject: [PATCH 18/19] fix: fix typo on the tests --- .../api/mutations/checkout/promotionCheckout.test.js | 2 +- packages/api-plugin-discounts/package.json | 2 +- .../api-plugin-discounts/src/util/setDiscountsOnCart.test.js | 2 +- .../src/actions/discountAction.js | 2 +- .../src/discountTypes/item/applyItemDiscountToCart.test.js | 2 +- packages/api-plugin-promotions-discounts/src/methods/index.js | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js b/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js index 1c5b203134b..bbab71f1ed0 100644 --- a/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js +++ b/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js @@ -194,7 +194,7 @@ describe("Promotions", () => { latestCartSummary = result.selectFulfillmentOptionForGroup.cart.checkout.summary; }); - test("place order with discounted amount", async () => { + test("place an order with discount and get the correct values", async () => { let result; const paymentMethods = await availablePaymentMethods({ diff --git a/packages/api-plugin-discounts/package.json b/packages/api-plugin-discounts/package.json index 930082489e4..bf605066a51 100644 --- a/packages/api-plugin-discounts/package.json +++ b/packages/api-plugin-discounts/package.json @@ -36,7 +36,7 @@ "access": "public" }, "scripts": { - "test": "jest --passWithNoTests", + "test": "jest", "test:watch": "jest --watch", "test:file": "jest --no-cache --watch --coverage=false" } diff --git a/packages/api-plugin-discounts/src/util/setDiscountsOnCart.test.js b/packages/api-plugin-discounts/src/util/setDiscountsOnCart.test.js index 39b5955132f..7796be23767 100644 --- a/packages/api-plugin-discounts/src/util/setDiscountsOnCart.test.js +++ b/packages/api-plugin-discounts/src/util/setDiscountsOnCart.test.js @@ -2,7 +2,7 @@ import setDiscountsOnCart from "./setDiscountsOnCart.js"; jest.mock("../queries/getDiscountsTotalForCart.js", () => jest.fn().mockReturnValue({ total: 10 })); -test("should set discounts on cart when discountCalculationMethods doesn't existd", async () => { +test("should set discounts on cart when discountCalculationMethods doesn't exist", async () => { const context = {}; const cart = { _id: "cart1", diff --git a/packages/api-plugin-promotions-discounts/src/actions/discountAction.js b/packages/api-plugin-promotions-discounts/src/actions/discountAction.js index 1fece127a31..ff9575ee717 100644 --- a/packages/api-plugin-promotions-discounts/src/actions/discountAction.js +++ b/packages/api-plugin-promotions-discounts/src/actions/discountAction.js @@ -88,7 +88,7 @@ export async function discountActionHandler(context, cart, params) { const { cart: updatedCart } = await functionMap[discountType](context, params, cart); - Logger.info(logCtx, "Completed applying Discount to Cart"); + Logger.info({ ...logCtx, ...params.actionParameters, cartId: cart._id, cartDiscount: cart.discount }, "Completed applying Discount to Cart"); return { updatedCart }; } diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/item/applyItemDiscountToCart.test.js b/packages/api-plugin-promotions-discounts/src/discountTypes/item/applyItemDiscountToCart.test.js index 9976e1fe396..6edc7417188 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/item/applyItemDiscountToCart.test.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/item/applyItemDiscountToCart.test.js @@ -25,7 +25,7 @@ test("createItemDiscount should return correct discount item object", () => { }); }); -test("should return cart with applied discount when parameters not include rule", async () => { +test("should return cart with applied discount when parameters do not include rule", async () => { const item = { _id: "item1", price: { diff --git a/packages/api-plugin-promotions-discounts/src/methods/index.js b/packages/api-plugin-promotions-discounts/src/methods/index.js index 130a4434c41..ec7e3cb946c 100644 --- a/packages/api-plugin-promotions-discounts/src/methods/index.js +++ b/packages/api-plugin-promotions-discounts/src/methods/index.js @@ -9,7 +9,7 @@ function percentage(discountValue, price) { } /** - * @summary Calculates the discount amount for the fixed discount type + * @summary Calculates the discount amount for the flat discount type * @param {Number} discountValue - The discount value * @returns {Number} The discount amount */ From bc2451b01486c779b53579e38969df256d25c579 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Wed, 16 Nov 2022 10:19:36 +0700 Subject: [PATCH 19/19] chore: rename variable --- .../src/discountTypes/order/applyOrderDiscountToCart.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.js index 3e3107cbc98..9374f581347 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.js @@ -35,10 +35,10 @@ export function createDiscountRecord(params, discountedItems, discountedAmount) * @returns {Number} - The discount amount */ export function getCartDiscountAmount(context, items, discount) { - const merchandiseTotal = getTotalEligibleItemsAmount(items); + const totalEligibleItemsAmount = getTotalEligibleItemsAmount(items); const { discountCalculationType, discountValue } = discount; - const cartDiscountedAmount = context.discountCalculationMethods[discountCalculationType](discountValue, merchandiseTotal); - return Number(formatMoney(merchandiseTotal - cartDiscountedAmount)); + const cartDiscountedAmount = context.discountCalculationMethods[discountCalculationType](discountValue, totalEligibleItemsAmount); + return Number(formatMoney(totalEligibleItemsAmount - cartDiscountedAmount)); } /**