From 8da5f070495fc0edba3834058fd7a5f09b402441 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Thu, 16 Feb 2023 16:01:08 +0700 Subject: [PATCH 01/19] feat: shipping discount method Signed-off-by: vanpho93 --- .../src/xforms/xformCartCheckout.js | 7 +- .../src/actions/discountAction.js | 16 +- .../src/actions/discountAction.test.js | 24 +++ .../item/applyItemDiscountToCart.test.js | 33 ++- .../order/applyOrderDiscountToCart.test.js | 9 +- .../shipping/applyShippingDiscountToCart.js | 146 ++++++++++++- .../applyShippingDiscountToCart.test.js | 200 ++++++++++++++++++ .../src/preStartup.js | 13 +- .../src/queries/getDiscountsTotalForCart.js | 6 +- .../queries/getDiscountsTotalForCart.test.js | 3 +- .../src/schemas/schema.graphql | 18 +- .../src/simpleSchemas.js | 6 +- .../src/utils/getEligibleIShipping.js | 43 ++++ .../src/utils/getTotalDiscountOnCart.js | 5 +- .../src/utils/recalculateShippingDiscount.js | 40 ++++ .../utils/recalculateShippingDiscount.test.js | 36 ++++ 16 files changed, 582 insertions(+), 23 deletions(-) create mode 100644 packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js create mode 100644 packages/api-plugin-promotions-discounts/src/utils/getEligibleIShipping.js create mode 100644 packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js create mode 100644 packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.test.js diff --git a/packages/api-plugin-carts/src/xforms/xformCartCheckout.js b/packages/api-plugin-carts/src/xforms/xformCartCheckout.js index ad4a938bdc3..90c0f5e6d16 100644 --- a/packages/api-plugin-carts/src/xforms/xformCartCheckout.js +++ b/packages/api-plugin-carts/src/xforms/xformCartCheckout.js @@ -39,7 +39,9 @@ function xformCartFulfillmentGroup(fulfillmentGroup, cart) { displayName: fulfillmentGroup.shipmentMethod.label || fulfillmentGroup.shipmentMethod.name, group: fulfillmentGroup.shipmentMethod.group || null, name: fulfillmentGroup.shipmentMethod.name, - fulfillmentTypes: fulfillmentGroup.shipmentMethod.fulfillmentTypes + fulfillmentTypes: fulfillmentGroup.shipmentMethod.fulfillmentTypes, + discount: fulfillmentGroup.shipmentMethod.discount || 0, + undiscountedRate: fulfillmentGroup.shipmentMethod.rate || 0 }, handlingPrice: { amount: fulfillmentGroup.shipmentMethod.handling || 0, @@ -65,7 +67,8 @@ function xformCartFulfillmentGroup(fulfillmentGroup, cart) { shippingAddress: fulfillmentGroup.address, shopId: fulfillmentGroup.shopId, // For now, this is always shipping. Revisit when adding download, pickup, etc. types - type: "shipping" + type: "shipping", + discounts: fulfillmentGroup.discounts || [] }; } diff --git a/packages/api-plugin-promotions-discounts/src/actions/discountAction.js b/packages/api-plugin-promotions-discounts/src/actions/discountAction.js index 70b342ba329..36831c4205b 100644 --- a/packages/api-plugin-promotions-discounts/src/actions/discountAction.js +++ b/packages/api-plugin-promotions-discounts/src/actions/discountAction.js @@ -55,6 +55,11 @@ export const discountActionParameters = new SimpleSchema({ type: Boolean, optional: true, defaultValue: false + }, + neverStackWithOtherShippingDiscounts: { + type: Boolean, + optional: true, + defaultValue: false } }); @@ -76,8 +81,15 @@ export async function discountActionCleanup(context, cart) { return item; }); - // todo: add reset logic for the shipping - // cart.shipping = cart.shipping.map((shipping) => ({ ...shipping, discounts: [] })); + for (const shipping of cart.shipping) { + shipping.discounts = []; + const { shipmentMethod } = shipping; + if (shipmentMethod) { + shipmentMethod.shippingPrice = shipmentMethod.handling + shipmentMethod.rate; + shipmentMethod.discount = 0; + shipmentMethod.undiscountedRate = 0; + } + } return cart; } diff --git a/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js b/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js index 3a31493c227..2132f3b02bf 100644 --- a/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js +++ b/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js @@ -77,6 +77,17 @@ describe("cleanup", () => { undiscountedAmount: 12 } } + ], + shipping: [ + { + _id: "shipping1", + shipmentMethod: { + shippingPrice: 9, + discount: 2, + handling: 2, + rate: 9 + } + } ] }; @@ -98,6 +109,19 @@ describe("cleanup", () => { currencyCode: "USD" } } + ], + shipping: [ + { + _id: "shipping1", + discounts: [], + shipmentMethod: { + discount: 0, + handling: 2, + rate: 9, + shippingPrice: 11, + undiscountedRate: 0 + } + } ] }); }); diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/item/applyItemDiscountToCart.test.js b/packages/api-plugin-promotions-discounts/src/discountTypes/item/applyItemDiscountToCart.test.js index 06d8c097d58..dc9cb78895b 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/item/applyItemDiscountToCart.test.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/item/applyItemDiscountToCart.test.js @@ -41,9 +41,18 @@ test("should return cart with applied discount when parameters do not include ru discounts: [] }; + const shipping = { + _id: "shipping1", + shipmentMethod: { + shippingPrice: 10, + discount: 2 + } + }; + const cart = { _id: "cart1", - items: [item] + items: [item], + shipping: [shipping] }; const discountParameters = { @@ -88,9 +97,18 @@ test("should return cart with applied discount when parameters include rule", as discounts: [] }; + const shipping = { + _id: "shipping1", + shipmentMethod: { + shippingPrice: 10, + discount: 2 + } + }; + const cart = { _id: "cart1", - items: [item] + items: [item], + shipping: [shipping] }; const parameters = { @@ -150,9 +168,18 @@ test("should return affected is false with reason when have no items are discoun discounts: [] }; + const shipping = { + _id: "shipping1", + shipmentMethod: { + shippingPrice: 11, + discount: 2 + } + }; + const cart = { _id: "cart1", - items: [item] + items: [item], + shipping: [shipping] }; const parameters = { diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.test.js b/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.test.js index 4d1b55838fc..0822b356ae2 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.test.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.test.js @@ -78,7 +78,8 @@ test("should apply order discount to cart", async () => { }, discounts: [] } - ] + ], + shipping: [] }; const parameters = { @@ -263,7 +264,8 @@ test("should apply order discount to cart with discountMaxValue when estimate di }, discounts: [] } - ] + ], + shipping: [] }; const parameters = { @@ -301,7 +303,8 @@ test("should apply order discount to cart with discountMaxValue when estimate di test("should return affected is false with reason when have no items are discounted", async () => { const cart = { _id: "cart1", - items: [] + items: [], + shipping: [] }; const parameters = { diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js index baec8197ea7..753358a4902 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js @@ -1,5 +1,117 @@ /* eslint-disable no-unused-vars */ -import ReactionError from "@reactioncommerce/reaction-error"; +import { createRequire } from "module"; +import _ from "lodash"; +import Logger from "@reactioncommerce/logger"; +import recalculateShippingDiscount from "../../utils/recalculateShippingDiscount.js"; +import getTotalDiscountOnCart from "../../utils/getTotalDiscountOnCart.js"; +import formatMoney from "../../utils/formatMoney.js"; +import getEligibleShipping from "../../utils/getEligibleIShipping.js"; + +const require = createRequire(import.meta.url); + +const pkg = require("../../../package.json"); + +const { name, version } = pkg; +const logCtx = { + name, + version, + file: "shipping/applyShippingDiscountToCart.js" +}; + +/** + * @summary Map discount record to shipping discount + * @param {Object} params - The action parameters + * @param {Object} discountedItem - The item that were discounted + * @returns {Object} Shipping discount record + */ +export function createDiscountRecord(params, discountedItem) { + const { promotion, actionParameters } = params; + const shippingDiscount = { + promotionId: promotion._id, + discountType: actionParameters.discountType, + discountCalculationType: actionParameters.discountCalculationType, + discountValue: actionParameters.discountValue, + discountMaxValue: actionParameters.discountMaxValue, + dateApplied: new Date(), + discountedItemType: "shipping", + discountedAmount: discountedItem.amount, + stackability: promotion.stackability, + neverStackWithOtherShippingDiscounts: actionParameters.neverStackWithOtherShippingDiscounts + }; + return shippingDiscount; +} + +/** + * @summary Get the discount amount for a discount item + * @param {Object} context - The application context + * @param {Number} totalShippingPrice - The total shipping price + * @param {Object} actionParameters - The action parameters + * @returns {Number} - The discount amount + */ +export function getTotalShippingDiscount(context, totalShippingPrice, actionParameters) { + const { discountCalculationType, discountValue, discountMaxValue } = actionParameters; + const calculationMethod = context.discountCalculationMethods[discountCalculationType]; + + const total = formatMoney(calculationMethod(discountValue, totalShippingPrice)); + if (typeof discountMaxValue === "number" && discountMaxValue > 0) { + return Math.min(total, discountMaxValue); + } + return total; +} + +/** + * @summary Splits a discount across all shipping + * @param {Array} cartShipping - The shipping to split the discount across + * @param {Number} totalShippingPrice - The total shipping price + * @param {Number} discountAmount - The total discount to split + * @returns {Array} undefined + */ +export function splitDiscountForShipping(cartShipping, totalShippingPrice, discountAmount) { + let discounted = 0; + const discountedShipping = cartShipping.map((shipping, index) => { + if (index !== cartShipping.length - 1) { + const shippingPrice = shipping.shipmentMethod.rate + shipping.shipmentMethod.handling; + const discount = formatMoney((shippingPrice / totalShippingPrice) * discountAmount); + discounted += discount; + return { _id: shipping._id, amount: discount }; + } + return { _id: shipping._id, amount: formatMoney(discountAmount - discounted) }; + }); + + return discountedShipping; +} + +/** + * @summary Get the total shipping price + * @param {Array} cartShipping - The shipping array to get the total price for + * @returns {Number} - The total shipping price + */ +export function getTotalShippingPrice(cartShipping) { + const totalPrice = cartShipping + .map((shipping) => { + if (!shipping.shipmentMethod) return 0; + return shipping.shipmentMethod.shippingPrice; + }) + .reduce((sum, price) => sum + price, 0); + return totalPrice; +} + +/** + * @summary Check if the shipping is eligible for the discount + * @param {Object} shipping - The shipping object + * @param {Object} discount - The discount object + * @returns {Boolean} - Whether the item is eligible for the discount + */ +export function canBeApplyDiscountToShipping(shipping, discount) { + const shippingDiscounts = shipping.discounts || []; + if (shippingDiscounts.length === 0) return true; + + const containsDiscountNeverStackWithOrderItem = _.some(shippingDiscounts, "neverStackWithOtherShippingDiscounts"); + if (containsDiscountNeverStackWithOrderItem) return false; + + if (discount.neverStackWithOtherShippingDiscounts) return false; + return true; +} /** * @summary Add the discount to the shipping record @@ -9,5 +121,35 @@ import ReactionError from "@reactioncommerce/reaction-error"; * @returns {Promise} undefined */ export default async function applyShippingDiscountToCart(context, params, cart) { - throw new ReactionError("not-implemented", "Not implemented"); + if (!cart.shipping) cart.shipping = []; + const { actionParameters } = params; + const filteredShipping = await getEligibleShipping(context, cart.shipping, params.actionParameters); + const totalShippingPrice = getTotalShippingPrice(filteredShipping); + const totalShippingDiscount = getTotalShippingDiscount(context, totalShippingPrice, actionParameters); + const discountedItems = splitDiscountForShipping(filteredShipping, totalShippingDiscount, totalShippingDiscount); + + for (const discountedItem of discountedItems) { + const shipping = filteredShipping.find((item) => item._id === discountedItem._id); + if (!shipping) continue; + + const canBeDiscounted = canBeApplyDiscountToShipping(shipping, params.promotion); + if (!canBeDiscounted) continue; + + if (!shipping.discounts) shipping.discounts = []; + + const shippingDiscount = createDiscountRecord(params, discountedItem); + shipping.discounts.push(shippingDiscount); + recalculateShippingDiscount(context, shipping); + } + + cart.discount = getTotalDiscountOnCart(cart); + + if (discountedItems.length) { + Logger.info(logCtx, "Saved Discount to cart"); + } + + const affected = discountedItems.length > 0; + const reason = !affected ? "No shippings were discounted" : undefined; + + return { cart, affected, reason }; } diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js new file mode 100644 index 00000000000..0abe889104a --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js @@ -0,0 +1,200 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import * as applyShippingDiscountToCart from "./applyShippingDiscountToCart.js"; + +test("createDiscountRecord should create discount record", () => { + const parameters = { + actionKey: "test", + promotion: { + _id: "promotion1" + }, + actionParameters: { + discountType: "item", + discountCalculationType: "fixed", + discountValue: 10 + } + }; + + const discountedItem = { + _id: "item1", + amount: 2 + }; + + const discountRecord = applyShippingDiscountToCart.createDiscountRecord(parameters, discountedItem); + + expect(discountRecord).toEqual({ + promotionId: "promotion1", + discountType: "item", + discountCalculationType: "fixed", + discountValue: 10, + dateApplied: expect.any(Date), + discountedItemType: "shipping", + discountedAmount: 2, + stackability: undefined + }); +}); + +test("should apply shipping discount to cart", async () => { + const cart = { + _id: "cart1", + items: [], + shipping: [ + { + _id: "shipping1", + shipmentMethod: { + _id: "method1", + handling: 2, + rate: 9, + shippingPrice: 11 + }, + discounts: [] + } + ], + discounts: [] + }; + + const parameters = { + actionKey: "test", + promotion: { + _id: "promotion1" + }, + actionParameters: { + discountType: "shipping", + discountCalculationType: "fixed", + discountValue: 10 + } + }; + + mockContext.discountCalculationMethods = { + fixed: jest.fn().mockReturnValue(2) + }; + + const { cart: updatedCart, affected } = await applyShippingDiscountToCart.default(mockContext, parameters, cart); + + expect(affected).toEqual(true); + expect(updatedCart.shipping[0].shipmentMethod).toEqual({ + _id: "method1", + discount: 9, + handling: 2, + rate: 9, + shippingPrice: 2, + undiscountedRate: 11 + }); + expect(updatedCart.shipping[0].discounts).toHaveLength(1); +}); + +test("getTotalShippingPrice should return total shipping price", () => { + const cart = { + shipping: [ + { + shipmentMethod: { + rate: 9, + handling: 2, + shippingPrice: 11 + } + }, + { + shipmentMethod: { + rate: 10, + handling: 1, + shippingPrice: 11 + } + } + ] + }; + + const totalShippingPrice = applyShippingDiscountToCart.getTotalShippingPrice(cart.shipping); + + expect(totalShippingPrice).toEqual(22); +}); + +test("getTotalShippingDiscount should return total shipping discount", () => { + const totalShippingPrice = 22; + + const actionParameters = { + discountType: "shipping", + discountCalculationType: "fixed", + discountValue: 10 + }; + mockContext.discountCalculationMethods = { + fixed: jest.fn().mockImplementation((discountValue) => discountValue) + }; + const totalShippingDiscount = applyShippingDiscountToCart.getTotalShippingDiscount(mockContext, totalShippingPrice, actionParameters); + + expect(totalShippingDiscount).toEqual(10); +}); + +test("splitDiscountForShipping should split discount for shipping", () => { + const totalShippingPrice = 22; + const totalShippingDiscount = 10; + + const cart = { + _id: "cart1", + shipping: [ + { + _id: "shipping1", + shipmentMethod: { + rate: 9, + handling: 2 + } + }, + { + _id: "shipping2", + shipmentMethod: { + rate: 9, + handling: 2 + } + } + ] + }; + + const shippingDiscounts = applyShippingDiscountToCart.splitDiscountForShipping(cart.shipping, totalShippingPrice, totalShippingDiscount); + + expect(shippingDiscounts).toEqual([ + { + _id: "shipping1", + amount: 5 + }, + { + _id: "shipping2", + amount: 5 + } + ]); +}); + +test("canBeApplyDiscountToShipping should return true if discount can be applied to shipping", () => { + const shipping = { + discounts: [ + { + discountType: "shipping" + } + ] + }; + + const discount = { + discountType: "shipping", + neverStackWithOtherShippingDiscounts: false + }; + + const canBeApplyDiscountToShipping = applyShippingDiscountToCart.canBeApplyDiscountToShipping(shipping, discount); + + expect(canBeApplyDiscountToShipping).toEqual(true); +}); + +test("canBeApplyDiscountToShipping should return false if discount can not be applied to shipping", () => { + const shipping = { + discounts: [ + { + discountType: "shipping" + } + ] + }; + + const discount = { + discountType: "shipping", + neverStackWithOtherShippingDiscounts: true + }; + + const canBeApplyDiscountToShipping = applyShippingDiscountToCart.canBeApplyDiscountToShipping(shipping, discount); + + expect(canBeApplyDiscountToShipping).toEqual(false); +}); diff --git a/packages/api-plugin-promotions-discounts/src/preStartup.js b/packages/api-plugin-promotions-discounts/src/preStartup.js index ddf4fa95947..1d8aa61dc17 100644 --- a/packages/api-plugin-promotions-discounts/src/preStartup.js +++ b/packages/api-plugin-promotions-discounts/src/preStartup.js @@ -22,7 +22,7 @@ const discountSchema = new SimpleSchema({ * @returns {Promise} undefined */ async function extendCartSchemas(context) { - const { simpleSchemas: { Cart, CartItem, Shipment, ShippingMethod, ShipmentQuote } } = context; + const { simpleSchemas: { Cart, CartItem, Shipment, ShippingMethod } } = context; Cart.extend(discountSchema); Cart.extend({ "discounts": { @@ -69,13 +69,14 @@ async function extendCartSchemas(context) { undiscountedRate: { type: Number, optional: true - } - }); - - ShipmentQuote.extend({ - undiscountedRate: { + }, + discount: { type: Number, optional: true + }, + shippingPrice: { + type: Number, + defaultValue: 0 } }); } diff --git a/packages/api-plugin-promotions-discounts/src/queries/getDiscountsTotalForCart.js b/packages/api-plugin-promotions-discounts/src/queries/getDiscountsTotalForCart.js index 38304665db5..eb94cc3a244 100644 --- a/packages/api-plugin-promotions-discounts/src/queries/getDiscountsTotalForCart.js +++ b/packages/api-plugin-promotions-discounts/src/queries/getDiscountsTotalForCart.js @@ -15,7 +15,11 @@ export default async function getDiscountsTotalForCart(context, cart) { } } - // TODO: add discounts from shipping + for (const shipping of cart.shipping) { + if (Array.isArray(shipping.discounts)) { + discounts.push(...shipping.discounts); + } + } return { discounts, diff --git a/packages/api-plugin-promotions-discounts/src/queries/getDiscountsTotalForCart.test.js b/packages/api-plugin-promotions-discounts/src/queries/getDiscountsTotalForCart.test.js index 31d908d4906..99b9c8cd7d7 100644 --- a/packages/api-plugin-promotions-discounts/src/queries/getDiscountsTotalForCart.test.js +++ b/packages/api-plugin-promotions-discounts/src/queries/getDiscountsTotalForCart.test.js @@ -34,7 +34,8 @@ test("should return correct cart total discount when cart has no discounts", asy }, discounts: [] } - ] + ], + shipping: [] }; const results = await getDiscountsTotalForCart(mockContext, cart); diff --git a/packages/api-plugin-promotions-discounts/src/schemas/schema.graphql b/packages/api-plugin-promotions-discounts/src/schemas/schema.graphql index a27ee3027ee..646ca7a0791 100644 --- a/packages/api-plugin-promotions-discounts/src/schemas/schema.graphql +++ b/packages/api-plugin-promotions-discounts/src/schemas/schema.graphql @@ -39,8 +39,11 @@ type CartDiscount { " The items that were discounted. Only available if `discountedItemType` is `item`." discountedItems: [CartDiscountedItem] - "Should this discount be applied before other discounts?" + "Should this discount be applied before other item discounts?" neverStackWithOtherItemLevelDiscounts: Boolean + + "Should this discount be applied before other shipping discounts?" + neverStackWithOtherShippingDiscounts: Boolean } extend type Cart { @@ -90,3 +93,16 @@ extend type OrderFulfillmentGroup { "The array of discounts applied to the fulfillment group." discounts: [CartDiscount] } + +extend type FulfillmentMethod { + "The total discount amount of the fulfillment method. " + discount: Float + + "The total undiscounted rate of the fulfillment method. " + undiscountedRate: Float +} + +extend type FulfillmentGroup { + "The array of discounts applied to the fulfillment group." + discounts: [CartDiscount] +} diff --git a/packages/api-plugin-promotions-discounts/src/simpleSchemas.js b/packages/api-plugin-promotions-discounts/src/simpleSchemas.js index 302e4a7d1b7..e5da526cfd6 100644 --- a/packages/api-plugin-promotions-discounts/src/simpleSchemas.js +++ b/packages/api-plugin-promotions-discounts/src/simpleSchemas.js @@ -16,7 +16,7 @@ const allowOperators = [ export const ConditionRule = new SimpleSchema({ "fact": { type: String, - allowedValues: ["cart", "item"] + allowedValues: ["cart", "item", "shipping"] }, "operator": { type: String, @@ -147,5 +147,9 @@ export const CartDiscount = new SimpleSchema({ "neverStackWithOtherItemLevelDiscounts": { type: Boolean, defaultValue: true + }, + "neverStackWithOtherShippingDiscounts": { + type: Boolean, + defaultValue: true } }); diff --git a/packages/api-plugin-promotions-discounts/src/utils/getEligibleIShipping.js b/packages/api-plugin-promotions-discounts/src/utils/getEligibleIShipping.js new file mode 100644 index 00000000000..824aecfb883 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/utils/getEligibleIShipping.js @@ -0,0 +1,43 @@ +import createEngine from "./engineHelpers.js"; + +/** + * @summary return shipping from the cart that meet inclusion criteria + * @param {Object} context - The application context + * @param {Array} shipping - The cart shipping to evaluate for eligible shipping + * @param {Object} params - The parameters to evaluate against + * @return {Promise>} - An array of eligible cart shipping + */ +export default async function getEligibleShipping(context, shipping, params) { + const getCheckMethod = (inclusionRules, exclusionRules) => { + const includeEngine = inclusionRules ? createEngine(context, inclusionRules) : null; + const excludeEngine = exclusionRules ? createEngine(context, exclusionRules) : null; + + return async (shippingItem) => { + if (includeEngine) { + const results = await includeEngine.run({ shipping: shippingItem }); + const { failureResults } = results; + const failedIncludeTest = failureResults.length > 0; + if (failedIncludeTest) return false; + } + + if (excludeEngine) { + const { failureResults } = await excludeEngine.run({ shipping: shippingItem }); + const failedExcludeTest = failureResults.length > 0; + return failedExcludeTest; + } + + return true; + }; + }; + + const checkerMethod = getCheckMethod(params.inclusionRules, params.exclusionRules); + + const eligibleShipping = []; + for (const shippingItem of shipping) { + // eslint-disable-next-line no-await-in-loop + if (await checkerMethod(shippingItem)) { + eligibleShipping.push(shippingItem); + } + } + return eligibleShipping; +} diff --git a/packages/api-plugin-promotions-discounts/src/utils/getTotalDiscountOnCart.js b/packages/api-plugin-promotions-discounts/src/utils/getTotalDiscountOnCart.js index 1a9497b4f2f..0e207c4b042 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/getTotalDiscountOnCart.js +++ b/packages/api-plugin-promotions-discounts/src/utils/getTotalDiscountOnCart.js @@ -12,7 +12,10 @@ export default function getTotalDiscountOnCart(cart) { totalDiscount += item.subtotal.discount || 0; } - // TODO: Add the logic to calculate the total discount on shipping + if (!Array.isArray(cart.shipping)) cart.shipping = []; + for (const shipping of cart.shipping) { + totalDiscount += shipping.shipmentMethod?.discount || 0; + } return Number(formatMoney(totalDiscount)); } diff --git a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js new file mode 100644 index 00000000000..cfa66b426b9 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js @@ -0,0 +1,40 @@ +import formatMoney from "./formatMoney.js"; + +/** + * @summary Recalculate shipping discount + * @param {Object} context - The application context + * @param {Object} shipping - The shipping record + * @returns {Promise} undefined + */ +export default function recalculateShippingDiscount(context, shipping) { + let totalDiscount = 0; + const { shipmentMethod } = shipping; + if (!shipmentMethod) return; + + const undiscountedAmount = formatMoney(shipmentMethod.shippingPrice); + + shipping.discounts.forEach((discount) => { + const { discountCalculationType, discountValue, discountMaxValue } = discount; + const calculationMethod = context.discountCalculationMethods[discountCalculationType]; + + const shippingDiscountAmount = formatMoney(calculationMethod(discountValue, undiscountedAmount)); + + // eslint-disable-next-line require-jsdoc + function getDiscountAmount() { + const discountAmount = formatMoney(undiscountedAmount - shippingDiscountAmount); + if (typeof discountMaxValue === "number" && discountMaxValue > 0) { + return Math.min(discountAmount, discountMaxValue); + } + return discountAmount; + } + + const discountAmount = getDiscountAmount(); + + totalDiscount += discountAmount; + discount.discountedAmount = discountAmount; + }); + + shipmentMethod.discount = totalDiscount; + shipmentMethod.shippingPrice = undiscountedAmount - totalDiscount; + shipmentMethod.undiscountedRate = undiscountedAmount; +} diff --git a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.test.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.test.js new file mode 100644 index 00000000000..93a5f20ba6a --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.test.js @@ -0,0 +1,36 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import recalculateShippingDiscount from "./recalculateShippingDiscount.js"; + +test("should recalculate shipping discount", async () => { + const shipping = { + _id: "shipping1", + shipmentMethod: { + _id: "method1", + handling: 2, + rate: 9, + shippingPrice: 11 + }, + discounts: [ + { + discountType: "shipping", + discountCalculationType: "fixed", + discountValue: 10 + } + ] + }; + + mockContext.discountCalculationMethods = { + fixed: jest.fn().mockReturnValue(2) + }; + + recalculateShippingDiscount(mockContext, shipping); + + expect(shipping.shipmentMethod).toEqual({ + _id: "method1", + discount: 9, + handling: 2, + rate: 9, + shippingPrice: 2, + undiscountedRate: 11 + }); +}); From fb551ee67d867b6a4047363007cbf2f6e6f5969b Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Mon, 20 Feb 2023 11:39:28 +0700 Subject: [PATCH 02/19] feat: add calculate discount amount util Signed-off-by: vanpho93 --- .../order/applyOrderDiscountToCart.js | 5 +++-- .../shipping/applyShippingDiscountToCart.js | 6 +++--- .../src/utils/calculateDiscountAmount.js | 16 ++++++++++++++++ .../src/utils/calculateDiscountAmount.test.js | 18 ++++++++++++++++++ .../src/utils/recalculateCartItemSubtotal.js | 3 ++- 5 files changed, 42 insertions(+), 6 deletions(-) create mode 100644 packages/api-plugin-promotions-discounts/src/utils/calculateDiscountAmount.js create mode 100644 packages/api-plugin-promotions-discounts/src/utils/calculateDiscountAmount.test.js diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.js index 0f83cab19b1..9144c0d2f66 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/order/applyOrderDiscountToCart.js @@ -4,6 +4,7 @@ import getEligibleItems from "../../utils/getEligibleItems.js"; import getTotalEligibleItemsAmount from "../../utils/getTotalEligibleItemsAmount.js"; import getTotalDiscountOnCart from "../../utils/getTotalDiscountOnCart.js"; import recalculateCartItemSubtotal from "../../utils/recalculateCartItemSubtotal.js"; +import calculateDiscountAmount from "../../utils/calculateDiscountAmount.js"; /** * @summary Map discount record to cart discount @@ -38,8 +39,8 @@ export function createDiscountRecord(params, discountedItems, discountedAmount) */ export function getCartDiscountAmount(context, items, discount) { const totalEligibleItemsAmount = getTotalEligibleItemsAmount(items); - const { discountCalculationType, discountValue, discountMaxValue } = discount; - const cartDiscountedAmount = context.discountCalculationMethods[discountCalculationType](discountValue, totalEligibleItemsAmount); + const { discountMaxValue } = discount; + const cartDiscountedAmount = calculateDiscountAmount(context, totalEligibleItemsAmount, discount); const discountAmount = formatMoney(totalEligibleItemsAmount - cartDiscountedAmount); if (typeof discountMaxValue === "number" && discountMaxValue > 0) { return Math.min(discount.discountMaxValue, discountAmount); diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js index 753358a4902..a1af4254e1e 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js @@ -6,6 +6,7 @@ import recalculateShippingDiscount from "../../utils/recalculateShippingDiscount import getTotalDiscountOnCart from "../../utils/getTotalDiscountOnCart.js"; import formatMoney from "../../utils/formatMoney.js"; import getEligibleShipping from "../../utils/getEligibleIShipping.js"; +import calculateDiscountAmount from "../../utils/calculateDiscountAmount.js"; const require = createRequire(import.meta.url); @@ -49,10 +50,9 @@ export function createDiscountRecord(params, discountedItem) { * @returns {Number} - The discount amount */ export function getTotalShippingDiscount(context, totalShippingPrice, actionParameters) { - const { discountCalculationType, discountValue, discountMaxValue } = actionParameters; - const calculationMethod = context.discountCalculationMethods[discountCalculationType]; + const { discountMaxValue } = actionParameters; - const total = formatMoney(calculationMethod(discountValue, totalShippingPrice)); + const total = calculateDiscountAmount(context, totalShippingPrice, actionParameters); if (typeof discountMaxValue === "number" && discountMaxValue > 0) { return Math.min(total, discountMaxValue); } diff --git a/packages/api-plugin-promotions-discounts/src/utils/calculateDiscountAmount.js b/packages/api-plugin-promotions-discounts/src/utils/calculateDiscountAmount.js new file mode 100644 index 00000000000..9e7a39b6a62 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/utils/calculateDiscountAmount.js @@ -0,0 +1,16 @@ +import formatMoney from "./formatMoney.js"; + +/** + * @summary Calculate the discount amount + * @param {Object} context - The application context + * @param {Number} amount - The amount to calculate the discount for + * @param {Object} parameters - The discount parameters + * @returns {Number} - The discount amount + */ +export default function calculateDiscountAmount(context, amount, parameters) { + const { discountCalculationType, discountValue } = parameters; + const calculationMethod = context.discountCalculationMethods[discountCalculationType]; + + const discountAmount = formatMoney(calculationMethod(discountValue, amount)); + return discountAmount; +} diff --git a/packages/api-plugin-promotions-discounts/src/utils/calculateDiscountAmount.test.js b/packages/api-plugin-promotions-discounts/src/utils/calculateDiscountAmount.test.js new file mode 100644 index 00000000000..6322ab68c63 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/utils/calculateDiscountAmount.test.js @@ -0,0 +1,18 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import calculateDiscountAmount from "./calculateDiscountAmount.js"; + +test("should return the correct discount amount", () => { + const amount = 100; + const parameters = { + discountCalculationType: "fixed", + discountValue: 10 + }; + + mockContext.discountCalculationMethods = { + fixed: jest.fn().mockReturnValue(10) + }; + + const discountAmount = calculateDiscountAmount(mockContext, amount, parameters); + + expect(discountAmount).toEqual(10); +}); diff --git a/packages/api-plugin-promotions-discounts/src/utils/recalculateCartItemSubtotal.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateCartItemSubtotal.js index 2080b4103b4..8f03c645192 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/recalculateCartItemSubtotal.js +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateCartItemSubtotal.js @@ -1,3 +1,4 @@ +import calculateDiscountAmount from "./calculateDiscountAmount.js"; import formatMoney from "./formatMoney.js"; /** @@ -20,7 +21,7 @@ export default function recalculateCartItemSubtotal(context, item) { if (typeof discountMaxUnits === "number" && discountMaxUnits > 0 && discountMaxUnits < item.quantity) { const pricePerUnit = item.subtotal.amount / item.quantity; const amountCanBeDiscounted = pricePerUnit * discountMaxUnits; - const maxUnitsDiscountedAmount = calculationMethod(discountValue, amountCanBeDiscounted); + const maxUnitsDiscountedAmount = calculateDiscountAmount(context, amountCanBeDiscounted, discount); return formatMoney(maxUnitsDiscountedAmount + (item.subtotal.amount - amountCanBeDiscounted)); } return formatMoney(calculationMethod(discountValue, item.subtotal.amount)); From 9e3feec0f6bc51a3ea3b1d8fa52154ab1e8ce328 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Tue, 21 Feb 2023 14:37:49 +0700 Subject: [PATCH 03/19] fix: place order with shipping discount Signed-off-by: vanpho93 --- .../src/mutations/placeOrder.js | 2 + .../src/mutations/placeOrder.test.js | 3 +- .../api-plugin-orders/src/simpleSchemas.js | 5 +++ .../src/util/addShipmentMethodToGroup.js | 11 +++++- .../buildOrderFulfillmentGroupFromInput.js | 4 ++ .../shipping/applyShippingDiscountToCart.js | 35 ++++++++--------- .../applyShippingDiscountToCart.test.js | 38 ++++++++++++------- .../src/utils/getTotalDiscountOnCart.js | 5 --- .../src/utils/recalculateShippingDiscount.js | 38 +++++++++++-------- .../utils/recalculateShippingDiscount.test.js | 20 ++++++++-- .../src/simpleSchemas.js | 5 ++- 11 files changed, 106 insertions(+), 60 deletions(-) diff --git a/packages/api-plugin-orders/src/mutations/placeOrder.js b/packages/api-plugin-orders/src/mutations/placeOrder.js index 60c0da78e8f..eecc1863cc5 100644 --- a/packages/api-plugin-orders/src/mutations/placeOrder.js +++ b/packages/api-plugin-orders/src/mutations/placeOrder.js @@ -147,6 +147,8 @@ export default async function placeOrder(context, input) { if (!allCartMessageAreAcknowledged) { throw new ReactionError("invalid-cart", "Cart messages should be acknowledged before placing order"); } + + await context.mutations.transformAndValidateCart(context, cart); } diff --git a/packages/api-plugin-orders/src/mutations/placeOrder.test.js b/packages/api-plugin-orders/src/mutations/placeOrder.test.js index f789a3b9c46..d7006d09e3e 100644 --- a/packages/api-plugin-orders/src/mutations/placeOrder.test.js +++ b/packages/api-plugin-orders/src/mutations/placeOrder.test.js @@ -153,7 +153,8 @@ test("places an anonymous $0 order with no cartId and no payments", async () => group: undefined, currencyCode: orderInput.currencyCode, handling: 0, - rate: 0 + rate: 0, + discount: 0 }, shopId: orderInput.shopId, totalItemQuantity: 1, diff --git a/packages/api-plugin-orders/src/simpleSchemas.js b/packages/api-plugin-orders/src/simpleSchemas.js index 400893332ee..73504944af7 100644 --- a/packages/api-plugin-orders/src/simpleSchemas.js +++ b/packages/api-plugin-orders/src/simpleSchemas.js @@ -793,6 +793,11 @@ export const SelectedFulfillmentOption = new SimpleSchema({ rate: { type: Number, min: 0 + }, + discount: { + type: Number, + min: 0, + optional: true } }); diff --git a/packages/api-plugin-orders/src/util/addShipmentMethodToGroup.js b/packages/api-plugin-orders/src/util/addShipmentMethodToGroup.js index 3fb25620b7b..0ae46bd07cc 100644 --- a/packages/api-plugin-orders/src/util/addShipmentMethodToGroup.js +++ b/packages/api-plugin-orders/src/util/addShipmentMethodToGroup.js @@ -45,12 +45,18 @@ export default async function addShipmentMethodToGroup(context, { throw new ReactionError("invalid", errorResult.message); } + const { shipmentMethod: { rate: shipmentRate, undiscountedRate, discount, _id: shipmentMethodId } = {} } = group; const selectedFulfillmentMethod = rates.find((rate) => selectedFulfillmentMethodId === rate.method._id); - if (!selectedFulfillmentMethod) { + const hasShipmentMethodObject = shipmentMethodId && shipmentMethodId !== selectedFulfillmentMethodId; + if (!selectedFulfillmentMethod || hasShipmentMethodObject) { throw new ReactionError("invalid", "The selected fulfillment method is no longer available." + " Fetch updated fulfillment options and try creating the order again with a valid method."); } + if (undiscountedRate && undiscountedRate !== selectedFulfillmentMethod.rate) { + throw new ReactionError("invalid", "The selected fulfillment method has mismatch shipment rate."); + } + group.shipmentMethod = { _id: selectedFulfillmentMethod.method._id, carrier: selectedFulfillmentMethod.method.carrier, @@ -59,6 +65,7 @@ export default async function addShipmentMethodToGroup(context, { group: selectedFulfillmentMethod.method.group, name: selectedFulfillmentMethod.method.name, handling: selectedFulfillmentMethod.handlingPrice, - rate: selectedFulfillmentMethod.rate + rate: shipmentRate || selectedFulfillmentMethod.rate, + discount: discount || 0 }; } diff --git a/packages/api-plugin-orders/src/util/buildOrderFulfillmentGroupFromInput.js b/packages/api-plugin-orders/src/util/buildOrderFulfillmentGroupFromInput.js index a7ff513526c..a784e006afa 100644 --- a/packages/api-plugin-orders/src/util/buildOrderFulfillmentGroupFromInput.js +++ b/packages/api-plugin-orders/src/util/buildOrderFulfillmentGroupFromInput.js @@ -49,6 +49,10 @@ export default async function buildOrderFulfillmentGroupFromInput(context, { if (Array.isArray(additionalItems) && additionalItems.length) { group.items.push(...additionalItems); } + if (cart && Array.isArray(cart.shipping)) { + const cartShipping = cart.shipping.find((shipping) => shipping.shipmentMethod?._id === selectedFulfillmentMethodId); + group.shipmentMethod = cartShipping?.shipmentMethod; + } // Add some more properties for convenience group.itemIds = group.items.map((item) => item._id); diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js index a1af4254e1e..6123ec8ee18 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js @@ -3,7 +3,6 @@ import { createRequire } from "module"; import _ from "lodash"; import Logger from "@reactioncommerce/logger"; import recalculateShippingDiscount from "../../utils/recalculateShippingDiscount.js"; -import getTotalDiscountOnCart from "../../utils/getTotalDiscountOnCart.js"; import formatMoney from "../../utils/formatMoney.js"; import getEligibleShipping from "../../utils/getEligibleIShipping.js"; import calculateDiscountAmount from "../../utils/calculateDiscountAmount.js"; @@ -45,14 +44,14 @@ export function createDiscountRecord(params, discountedItem) { /** * @summary Get the discount amount for a discount item * @param {Object} context - The application context - * @param {Number} totalShippingPrice - The total shipping price + * @param {Number} totalShippingRate - The total shipping price * @param {Object} actionParameters - The action parameters * @returns {Number} - The discount amount */ -export function getTotalShippingDiscount(context, totalShippingPrice, actionParameters) { +export function getTotalShippingDiscount(context, totalShippingRate, actionParameters) { const { discountMaxValue } = actionParameters; - const total = calculateDiscountAmount(context, totalShippingPrice, actionParameters); + const total = calculateDiscountAmount(context, totalShippingRate, actionParameters); if (typeof discountMaxValue === "number" && discountMaxValue > 0) { return Math.min(total, discountMaxValue); } @@ -62,16 +61,16 @@ export function getTotalShippingDiscount(context, totalShippingPrice, actionPara /** * @summary Splits a discount across all shipping * @param {Array} cartShipping - The shipping to split the discount across - * @param {Number} totalShippingPrice - The total shipping price + * @param {Number} totalShippingRate - The total shipping price * @param {Number} discountAmount - The total discount to split * @returns {Array} undefined */ -export function splitDiscountForShipping(cartShipping, totalShippingPrice, discountAmount) { +export function splitDiscountForShipping(cartShipping, totalShippingRate, discountAmount) { let discounted = 0; const discountedShipping = cartShipping.map((shipping, index) => { if (index !== cartShipping.length - 1) { - const shippingPrice = shipping.shipmentMethod.rate + shipping.shipmentMethod.handling; - const discount = formatMoney((shippingPrice / totalShippingPrice) * discountAmount); + const rate = shipping.shipmentMethod.rate || 0; + const discount = formatMoney((rate / totalShippingRate) * discountAmount); discounted += discount; return { _id: shipping._id, amount: discount }; } @@ -82,18 +81,18 @@ export function splitDiscountForShipping(cartShipping, totalShippingPrice, disco } /** - * @summary Get the total shipping price - * @param {Array} cartShipping - The shipping array to get the total price for - * @returns {Number} - The total shipping price + * @summary Get the total shipping rate + * @param {Array} cartShipping - The shipping array to get the total rate for + * @returns {Number} - The total shipping rate */ -export function getTotalShippingPrice(cartShipping) { - const totalPrice = cartShipping +export function getTotalShippingRate(cartShipping) { + const totalRate = cartShipping .map((shipping) => { if (!shipping.shipmentMethod) return 0; - return shipping.shipmentMethod.shippingPrice; + return shipping.shipmentMethod.rate || 0; }) .reduce((sum, price) => sum + price, 0); - return totalPrice; + return totalRate; } /** @@ -124,8 +123,8 @@ export default async function applyShippingDiscountToCart(context, params, cart) if (!cart.shipping) cart.shipping = []; const { actionParameters } = params; const filteredShipping = await getEligibleShipping(context, cart.shipping, params.actionParameters); - const totalShippingPrice = getTotalShippingPrice(filteredShipping); - const totalShippingDiscount = getTotalShippingDiscount(context, totalShippingPrice, actionParameters); + const totalShippingRate = getTotalShippingRate(filteredShipping); + const totalShippingDiscount = getTotalShippingDiscount(context, totalShippingRate, actionParameters); const discountedItems = splitDiscountForShipping(filteredShipping, totalShippingDiscount, totalShippingDiscount); for (const discountedItem of discountedItems) { @@ -142,8 +141,6 @@ export default async function applyShippingDiscountToCart(context, params, cart) recalculateShippingDiscount(context, shipping); } - cart.discount = getTotalDiscountOnCart(cart); - if (discountedItems.length) { Logger.info(logCtx, "Saved Discount to cart"); } diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js index 0abe889104a..461263799b1 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js @@ -46,6 +46,18 @@ test("should apply shipping discount to cart", async () => { rate: 9, shippingPrice: 11 }, + shipmentQuotes: [ + { + method: { + _id: "method1", + handling: 2, + rate: 9, + shippingPrice: 11 + }, + handling: 2, + rate: 9 + } + ], discounts: [] } ], @@ -73,16 +85,16 @@ test("should apply shipping discount to cart", async () => { expect(affected).toEqual(true); expect(updatedCart.shipping[0].shipmentMethod).toEqual({ _id: "method1", - discount: 9, + discount: 7, handling: 2, - rate: 9, - shippingPrice: 2, - undiscountedRate: 11 + rate: 7, + shippingPrice: 7, + undiscountedRate: 9 }); expect(updatedCart.shipping[0].discounts).toHaveLength(1); }); -test("getTotalShippingPrice should return total shipping price", () => { +test("getTotalShippingRate should return total shipping price", () => { const cart = { shipping: [ { @@ -95,16 +107,16 @@ test("getTotalShippingPrice should return total shipping price", () => { { shipmentMethod: { rate: 10, - handling: 1, - shippingPrice: 11 + handling: 2, + shippingPrice: 12 } } ] }; - const totalShippingPrice = applyShippingDiscountToCart.getTotalShippingPrice(cart.shipping); + const totalShippingRate = applyShippingDiscountToCart.getTotalShippingRate(cart.shipping); - expect(totalShippingPrice).toEqual(22); + expect(totalShippingRate).toEqual(19); }); test("getTotalShippingDiscount should return total shipping discount", () => { @@ -124,8 +136,8 @@ test("getTotalShippingDiscount should return total shipping discount", () => { }); test("splitDiscountForShipping should split discount for shipping", () => { - const totalShippingPrice = 22; - const totalShippingDiscount = 10; + const totalShippingRate = 22; + const totalDiscountRate = 10; const cart = { _id: "cart1", @@ -133,7 +145,7 @@ test("splitDiscountForShipping should split discount for shipping", () => { { _id: "shipping1", shipmentMethod: { - rate: 9, + rate: 11, handling: 2 } }, @@ -147,7 +159,7 @@ test("splitDiscountForShipping should split discount for shipping", () => { ] }; - const shippingDiscounts = applyShippingDiscountToCart.splitDiscountForShipping(cart.shipping, totalShippingPrice, totalShippingDiscount); + const shippingDiscounts = applyShippingDiscountToCart.splitDiscountForShipping(cart.shipping, totalShippingRate, totalDiscountRate); expect(shippingDiscounts).toEqual([ { diff --git a/packages/api-plugin-promotions-discounts/src/utils/getTotalDiscountOnCart.js b/packages/api-plugin-promotions-discounts/src/utils/getTotalDiscountOnCart.js index 0e207c4b042..2357ec63d13 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/getTotalDiscountOnCart.js +++ b/packages/api-plugin-promotions-discounts/src/utils/getTotalDiscountOnCart.js @@ -12,10 +12,5 @@ export default function getTotalDiscountOnCart(cart) { totalDiscount += item.subtotal.discount || 0; } - if (!Array.isArray(cart.shipping)) cart.shipping = []; - for (const shipping of cart.shipping) { - totalDiscount += shipping.shipmentMethod?.discount || 0; - } - return Number(formatMoney(totalDiscount)); } diff --git a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js index cfa66b426b9..dea1d5b8af7 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js @@ -1,3 +1,5 @@ +import ReactionError from "@reactioncommerce/reaction-error"; +import calculateDiscountAmount from "./calculateDiscountAmount.js"; import formatMoney from "./formatMoney.js"; /** @@ -8,33 +10,39 @@ import formatMoney from "./formatMoney.js"; */ export default function recalculateShippingDiscount(context, shipping) { let totalDiscount = 0; - const { shipmentMethod } = shipping; - if (!shipmentMethod) return; + const { shipmentMethod, shipmentQuotes } = shipping; + if (!shipmentMethod || shipmentQuotes.length === 0) return; - const undiscountedAmount = formatMoney(shipmentMethod.shippingPrice); + const selectedShipmentQuote = shipmentQuotes.find((quote) => quote.method._id === shipmentMethod._id); + if (!selectedShipmentQuote) throw ReactionError("not-found", "Shipment quote not found in the cart"); + + const rate = selectedShipmentQuote.rate || 0; + const handling = selectedShipmentQuote.handlingPrice || 0; + shipmentMethod.rate = rate; + shipmentMethod.undiscountedRate = rate; shipping.discounts.forEach((discount) => { - const { discountCalculationType, discountValue, discountMaxValue } = discount; - const calculationMethod = context.discountCalculationMethods[discountCalculationType]; + const undiscountedRate = shipmentMethod.rate; + const { discountMaxValue } = discount; - const shippingDiscountAmount = formatMoney(calculationMethod(discountValue, undiscountedAmount)); + const discountRate = calculateDiscountAmount(context, undiscountedRate, discount); // eslint-disable-next-line require-jsdoc - function getDiscountAmount() { - const discountAmount = formatMoney(undiscountedAmount - shippingDiscountAmount); + function getDiscountedRate() { + const discountedRate = formatMoney(undiscountedRate - discountRate); if (typeof discountMaxValue === "number" && discountMaxValue > 0) { - return Math.min(discountAmount, discountMaxValue); + return Math.min(discountedRate, discountMaxValue); } - return discountAmount; + return discountedRate; } - const discountAmount = getDiscountAmount(); + const discountedRate = getDiscountedRate(); - totalDiscount += discountAmount; - discount.discountedAmount = discountAmount; + totalDiscount += discountedRate; + discount.discountedAmount = discountedRate; + shipmentMethod.rate = discountedRate; }); + shipmentMethod.shippingPrice = shipmentMethod.rate + handling; shipmentMethod.discount = totalDiscount; - shipmentMethod.shippingPrice = undiscountedAmount - totalDiscount; - shipmentMethod.undiscountedRate = undiscountedAmount; } diff --git a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.test.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.test.js index 93a5f20ba6a..4c29d69fb13 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.test.js +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.test.js @@ -16,6 +16,18 @@ test("should recalculate shipping discount", async () => { discountCalculationType: "fixed", discountValue: 10 } + ], + shipmentQuotes: [ + { + method: { + _id: "method1", + handling: 2, + rate: 9, + shippingPrice: 11 + }, + handling: 2, + rate: 9 + } ] }; @@ -27,10 +39,10 @@ test("should recalculate shipping discount", async () => { expect(shipping.shipmentMethod).toEqual({ _id: "method1", - discount: 9, + discount: 7, handling: 2, - rate: 9, - shippingPrice: 2, - undiscountedRate: 11 + rate: 7, + shippingPrice: 7, + undiscountedRate: 9 }); }); diff --git a/packages/api-plugin-promotions/src/simpleSchemas.js b/packages/api-plugin-promotions/src/simpleSchemas.js index 09920b2521a..cee652d3c98 100644 --- a/packages/api-plugin-promotions/src/simpleSchemas.js +++ b/packages/api-plugin-promotions/src/simpleSchemas.js @@ -136,7 +136,10 @@ export const CartPromotionItem = new SimpleSchema({ _id: String, name: String, label: String, - description: String, + description: { + type: String, + optional: true + }, triggerType: { type: String, allowedValues: ["implicit", "explicit"] From 234c47e0f3962c3592297dda7c7d75e590ca16c0 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Tue, 21 Feb 2023 15:23:55 +0700 Subject: [PATCH 04/19] fix: calculate shipping discount amount Signed-off-by: vanpho93 --- .../shipping/applyShippingDiscountToCart.test.js | 10 +++++----- .../src/utils/recalculateShippingDiscount.js | 12 ++++++------ .../src/utils/recalculateShippingDiscount.test.js | 12 ++++++------ 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js index 461263799b1..5ca65a7e983 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js @@ -54,7 +54,7 @@ test("should apply shipping discount to cart", async () => { rate: 9, shippingPrice: 11 }, - handling: 2, + handlingPrice: 2, rate: 9 } ], @@ -77,7 +77,7 @@ test("should apply shipping discount to cart", async () => { }; mockContext.discountCalculationMethods = { - fixed: jest.fn().mockReturnValue(2) + fixed: jest.fn().mockReturnValue(0) }; const { cart: updatedCart, affected } = await applyShippingDiscountToCart.default(mockContext, parameters, cart); @@ -85,10 +85,10 @@ test("should apply shipping discount to cart", async () => { expect(affected).toEqual(true); expect(updatedCart.shipping[0].shipmentMethod).toEqual({ _id: "method1", - discount: 7, + discount: 9, handling: 2, - rate: 7, - shippingPrice: 7, + rate: 0, + shippingPrice: 2, undiscountedRate: 9 }); expect(updatedCart.shipping[0].discounts).toHaveLength(1); diff --git a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js index dea1d5b8af7..0a4c72941ed 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js @@ -25,20 +25,20 @@ export default function recalculateShippingDiscount(context, shipping) { const undiscountedRate = shipmentMethod.rate; const { discountMaxValue } = discount; - const discountRate = calculateDiscountAmount(context, undiscountedRate, discount); + const discountedRate = calculateDiscountAmount(context, undiscountedRate, discount); // eslint-disable-next-line require-jsdoc function getDiscountedRate() { - const discountedRate = formatMoney(undiscountedRate - discountRate); + const discountRate = formatMoney(undiscountedRate - discountedRate); if (typeof discountMaxValue === "number" && discountMaxValue > 0) { - return Math.min(discountedRate, discountMaxValue); + return Math.min(discountRate, discountMaxValue); } - return discountedRate; + return discountRate; } - const discountedRate = getDiscountedRate(); + const discountRate = getDiscountedRate(); - totalDiscount += discountedRate; + totalDiscount += discountRate; discount.discountedAmount = discountedRate; shipmentMethod.rate = discountedRate; }); diff --git a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.test.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.test.js index 4c29d69fb13..5a17fd03b33 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.test.js +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.test.js @@ -25,24 +25,24 @@ test("should recalculate shipping discount", async () => { rate: 9, shippingPrice: 11 }, - handling: 2, - rate: 9 + rate: 9, + handlingPrice: 2 } ] }; mockContext.discountCalculationMethods = { - fixed: jest.fn().mockReturnValue(2) + fixed: jest.fn().mockReturnValue(0) }; recalculateShippingDiscount(mockContext, shipping); expect(shipping.shipmentMethod).toEqual({ _id: "method1", - discount: 7, + discount: 9, handling: 2, - rate: 7, - shippingPrice: 7, + rate: 0, + shippingPrice: 2, undiscountedRate: 9 }); }); From 7d9b8c11b0c72c737186262c3da8520de49cdc16 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Wed, 22 Feb 2023 11:25:03 +0700 Subject: [PATCH 05/19] feat: estimate discount amount for shipment quotes Signed-off-by: vanpho93 --- .../src/actions/discountAction.js | 23 ++++++++--- .../shipping/applyShippingDiscountToCart.js | 41 ++++++++++++++++++- .../src/preStartup.js | 21 +++++++++- .../src/utils/getEligibleIShipping.js | 23 ++++++++--- .../src/utils/recalculateQuoteDiscount.js | 41 +++++++++++++++++++ .../src/utils/recalculateShippingDiscount.js | 4 +- .../src/handlers/applyPromotions.js | 7 ++++ 7 files changed, 144 insertions(+), 16 deletions(-) create mode 100644 packages/api-plugin-promotions-discounts/src/utils/recalculateQuoteDiscount.js diff --git a/packages/api-plugin-promotions-discounts/src/actions/discountAction.js b/packages/api-plugin-promotions-discounts/src/actions/discountAction.js index 36831c4205b..2759b9e8e00 100644 --- a/packages/api-plugin-promotions-discounts/src/actions/discountAction.js +++ b/packages/api-plugin-promotions-discounts/src/actions/discountAction.js @@ -81,13 +81,26 @@ export async function discountActionCleanup(context, cart) { return item; }); + // eslint-disable-next-line require-jsdoc + function resetMethod(method) { + method.rate = method.undiscountedRate || method.rate; + method.discount = 0; + method.shippingPrice = method.rate + (method.handlingPrice || method.handling); + method.undiscountedRate = 0; + } + for (const shipping of cart.shipping) { shipping.discounts = []; - const { shipmentMethod } = shipping; - if (shipmentMethod) { - shipmentMethod.shippingPrice = shipmentMethod.handling + shipmentMethod.rate; - shipmentMethod.discount = 0; - shipmentMethod.undiscountedRate = 0; + + if (!shipping.shipmentQuotes) shipping.shipmentQuotes = []; + shipping.shipmentQuotes.forEach((quote) => { + resetMethod(quote.method); + resetMethod(quote); + quote.discounts = []; + }); + + if (shipping.shipmentMethod) { + resetMethod(shipping.shipmentMethod); } } diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js index 6123ec8ee18..0651a496431 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js @@ -6,6 +6,7 @@ import recalculateShippingDiscount from "../../utils/recalculateShippingDiscount import formatMoney from "../../utils/formatMoney.js"; import getEligibleShipping from "../../utils/getEligibleIShipping.js"; import calculateDiscountAmount from "../../utils/calculateDiscountAmount.js"; +import recalculateQuoteDiscount from "../../utils/recalculateQuoteDiscount.js"; const require = createRequire(import.meta.url); @@ -112,6 +113,38 @@ export function canBeApplyDiscountToShipping(shipping, discount) { return true; } +/** + * @summary Estimate the shipment quote discount + * @param {Object} context - The application context + * @param {object} cart - The cart to apply the discount to + * @param {Object} params - The parameters to apply + * @returns {Promise} - Has affected shipping + */ +export async function estimateShipmentQuoteDiscount(context, cart, params) { + const { actionParameters, promotion } = params; + const filteredItems = await getEligibleShipping(context, cart.shipping, { + ...actionParameters, + estimateShipmentQuote: true + }); + + const shipmentQuotes = cart.shipping[0]?.shipmentQuotes || []; + + for (const item of filteredItems) { + const shipmentQuote = shipmentQuotes.find((quote) => quote.method._id === item.method._id); + if (!shipmentQuote) continue; + + const canBeDiscounted = canBeApplyDiscountToShipping(shipmentQuote, promotion); + if (!canBeDiscounted) continue; + + if (!shipmentQuote.discounts) shipmentQuote.discounts = []; + shipmentQuote.discounts.push(createDiscountRecord(params, item)); + + recalculateQuoteDiscount(context, shipmentQuote, actionParameters); + } + + return filteredItems.length > 0; +} + /** * @summary Add the discount to the shipping record * @param {Object} context - The application context @@ -120,9 +153,13 @@ export function canBeApplyDiscountToShipping(shipping, discount) { * @returns {Promise} undefined */ export default async function applyShippingDiscountToCart(context, params, cart) { - if (!cart.shipping) cart.shipping = []; const { actionParameters } = params; - const filteredShipping = await getEligibleShipping(context, cart.shipping, params.actionParameters); + + if (!cart.shipping) cart.shipping = []; + + await estimateShipmentQuoteDiscount(context, cart, params); + + const filteredShipping = await getEligibleShipping(context, cart.shipping, actionParameters); const totalShippingRate = getTotalShippingRate(filteredShipping); const totalShippingDiscount = getTotalShippingDiscount(context, totalShippingRate, actionParameters); const discountedItems = splitDiscountForShipping(filteredShipping, totalShippingDiscount, totalShippingDiscount); diff --git a/packages/api-plugin-promotions-discounts/src/preStartup.js b/packages/api-plugin-promotions-discounts/src/preStartup.js index 1d8aa61dc17..9650197cf13 100644 --- a/packages/api-plugin-promotions-discounts/src/preStartup.js +++ b/packages/api-plugin-promotions-discounts/src/preStartup.js @@ -22,7 +22,7 @@ const discountSchema = new SimpleSchema({ * @returns {Promise} undefined */ async function extendCartSchemas(context) { - const { simpleSchemas: { Cart, CartItem, Shipment, ShippingMethod } } = context; + const { simpleSchemas: { Cart, CartItem, Shipment, ShippingMethod, ShipmentQuote } } = context; Cart.extend(discountSchema); Cart.extend({ "discounts": { @@ -65,6 +65,25 @@ async function extendCartSchemas(context) { } }); + ShipmentQuote.extend({ + "discounts": { + type: Array, + defaultValue: [], + optional: true + }, + "discounts.$": { + type: CartDiscount + }, + "undiscountedRate": { + type: Number, + optional: true + }, + "discount": { + type: Number, + optional: true + } + }); + ShippingMethod.extend({ undiscountedRate: { type: Number, diff --git a/packages/api-plugin-promotions-discounts/src/utils/getEligibleIShipping.js b/packages/api-plugin-promotions-discounts/src/utils/getEligibleIShipping.js index 824aecfb883..c823b51b318 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/getEligibleIShipping.js +++ b/packages/api-plugin-promotions-discounts/src/utils/getEligibleIShipping.js @@ -32,12 +32,23 @@ export default async function getEligibleShipping(context, shipping, params) { const checkerMethod = getCheckMethod(params.inclusionRules, params.exclusionRules); - const eligibleShipping = []; - for (const shippingItem of shipping) { - // eslint-disable-next-line no-await-in-loop - if (await checkerMethod(shippingItem)) { - eligibleShipping.push(shippingItem); + const eligibleItems = []; + if (params.estimateShipmentQuote) { + const shipmentQuotes = shipping[0]?.shipmentQuotes || []; + for (const quote of shipmentQuotes) { + // eslint-disable-next-line no-await-in-loop + if (await checkerMethod({ ...quote, shipmentMethod: quote.method || {} })) { + eligibleItems.push(quote); + } + } + } else { + for (const shippingItem of shipping) { + // eslint-disable-next-line no-await-in-loop + if (await checkerMethod(shippingItem)) { + eligibleItems.push(shippingItem); + } } } - return eligibleShipping; + + return eligibleItems; } diff --git a/packages/api-plugin-promotions-discounts/src/utils/recalculateQuoteDiscount.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateQuoteDiscount.js new file mode 100644 index 00000000000..2472ee4d9c1 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateQuoteDiscount.js @@ -0,0 +1,41 @@ +import calculateDiscountAmount from "./calculateDiscountAmount.js"; +import formatMoney from "./formatMoney.js"; + +/** + * @summary Recalculate shipping discount + * @param {Object} context - The application context + * @param {Object} quote - The quote record + * @returns {Promise} undefined + */ +export default function recalculateQuoteDiscount(context, quote) { + let totalDiscount = 0; + const { method, undiscountedRate } = quote; + + const rate = undiscountedRate || method.rate; + quote.undiscountedRate = rate; + + quote.discounts.forEach((discount) => { + const quoteRate = quote.rate; + const { discountMaxValue } = discount; + + const discountedRate = calculateDiscountAmount(context, quoteRate, discount); + + // eslint-disable-next-line require-jsdoc + function getDiscountedRate() { + const discountRate = formatMoney(quoteRate - discountedRate); + if (typeof discountMaxValue === "number" && discountMaxValue > 0) { + return Math.min(discountRate, discountMaxValue); + } + return discountRate; + } + + const discountRate = getDiscountedRate(); + + totalDiscount += discountRate; + discount.discountedAmount = discountedRate; + quote.rate = discountedRate; + }); + + quote.discount = totalDiscount; + quote.shippingPrice = quote.rate + quote.handlingPrice; +} diff --git a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js index 0a4c72941ed..a6c5075c607 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js @@ -16,8 +16,8 @@ export default function recalculateShippingDiscount(context, shipping) { const selectedShipmentQuote = shipmentQuotes.find((quote) => quote.method._id === shipmentMethod._id); if (!selectedShipmentQuote) throw ReactionError("not-found", "Shipment quote not found in the cart"); - const rate = selectedShipmentQuote.rate || 0; - const handling = selectedShipmentQuote.handlingPrice || 0; + const rate = selectedShipmentQuote.method.rate || 0; + const handling = selectedShipmentQuote.method.handling || 0; shipmentMethod.rate = rate; shipmentMethod.undiscountedRate = rate; diff --git a/packages/api-plugin-promotions/src/handlers/applyPromotions.js b/packages/api-plugin-promotions/src/handlers/applyPromotions.js index 70116e06000..495cc077d3b 100644 --- a/packages/api-plugin-promotions/src/handlers/applyPromotions.js +++ b/packages/api-plugin-promotions/src/handlers/applyPromotions.js @@ -112,6 +112,13 @@ export default async function applyPromotions(context, cart) { const unqualifiedPromotions = promotions.concat(appliedExplicitPromotions); + // sort to move shipping discounts to the end + unqualifiedPromotions.sort((promA, promB) => { + if (_.some(promA.actions, (action) => action.actionParameters.discountType === "shipping")) return 1; + if (_.some(promB.actions, (action) => action.actionParameters.discountType === "shipping")) return -1; + return 0; + }); + for (const { cleanup } of pluginPromotions.actions) { cleanup && await cleanup(context, cart); } From 88b213fde677ba23ec5a78f56990095cdacc6320 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Fri, 24 Feb 2023 10:27:16 +0700 Subject: [PATCH 06/19] fix: applyPromotion unit test fail Signed-off-by: vanpho93 --- .../src/handlers/applyPromotions.test.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js b/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js index 4850c273774..bb21b8885b7 100644 --- a/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js +++ b/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js @@ -20,7 +20,7 @@ const pluginPromotion = { const testPromotion = { _id: "test id", - actions: [{ actionKey: "test" }], + actions: [{ actionKey: "test", actionParameters: { discountType: "order" } }], triggers: [{ triggerKey: "test", triggerParameters: { name: "test trigger" } }], stackability: { key: "none", @@ -56,7 +56,8 @@ test("should save cart with implicit promotions are applied", async () => { }); expect(testAction).toBeCalledWith(mockContext, expect.objectContaining({ _id: cart._id }), { actionKey: "test", - promotion: testPromotion + promotion: testPromotion, + actionParameters: { discountType: "order" } }); expect(testEnhancer).toBeCalledWith(mockContext, expect.objectContaining({ _id: cart._id })); From 3dcde455191a5e18e88894e20eea1d4b06380d38 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Fri, 24 Feb 2023 12:17:46 +0700 Subject: [PATCH 07/19] fix: max discount value for shipping discount Signed-off-by: vanpho93 --- .../src/utils/recalculateQuoteDiscount.js | 7 +++---- .../src/utils/recalculateShippingDiscount.js | 9 +++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/api-plugin-promotions-discounts/src/utils/recalculateQuoteDiscount.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateQuoteDiscount.js index 2472ee4d9c1..2ecc6b64695 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/recalculateQuoteDiscount.js +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateQuoteDiscount.js @@ -16,10 +16,9 @@ export default function recalculateQuoteDiscount(context, quote) { quote.discounts.forEach((discount) => { const quoteRate = quote.rate; - const { discountMaxValue } = discount; - const discountedRate = calculateDiscountAmount(context, quoteRate, discount); + const { discountMaxValue } = discount; // eslint-disable-next-line require-jsdoc function getDiscountedRate() { const discountRate = formatMoney(quoteRate - discountedRate); @@ -32,8 +31,8 @@ export default function recalculateQuoteDiscount(context, quote) { const discountRate = getDiscountedRate(); totalDiscount += discountRate; - discount.discountedAmount = discountedRate; - quote.rate = discountedRate; + discount.discountedAmount = discountRate; + quote.rate = formatMoney(quoteRate - discountRate); }); quote.discount = totalDiscount; diff --git a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js index a6c5075c607..2f5ea986e14 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js @@ -16,17 +16,18 @@ export default function recalculateShippingDiscount(context, shipping) { const selectedShipmentQuote = shipmentQuotes.find((quote) => quote.method._id === shipmentMethod._id); if (!selectedShipmentQuote) throw ReactionError("not-found", "Shipment quote not found in the cart"); - const rate = selectedShipmentQuote.method.rate || 0; - const handling = selectedShipmentQuote.method.handling || 0; + const { method } = selectedShipmentQuote; + const rate = method.undiscountedRate || method.rate; + const handling = method.handling || 0; shipmentMethod.rate = rate; shipmentMethod.undiscountedRate = rate; shipping.discounts.forEach((discount) => { const undiscountedRate = shipmentMethod.rate; - const { discountMaxValue } = discount; const discountedRate = calculateDiscountAmount(context, undiscountedRate, discount); + const { discountMaxValue } = discount; // eslint-disable-next-line require-jsdoc function getDiscountedRate() { const discountRate = formatMoney(undiscountedRate - discountedRate); @@ -40,7 +41,7 @@ export default function recalculateShippingDiscount(context, shipping) { totalDiscount += discountRate; discount.discountedAmount = discountedRate; - shipmentMethod.rate = discountedRate; + shipmentMethod.rate = formatMoney(undiscountedRate - discountRate); }); shipmentMethod.shippingPrice = shipmentMethod.rate + handling; From f21dfaa2932e2078b3b10d56471055dc9f83769f Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Sun, 26 Feb 2023 14:58:30 +0700 Subject: [PATCH 08/19] feat: add integration test for shipping disocunt Signed-off-by: vanpho93 --- .../checkout/promotionCheckout.test.js | 21 +++++++++++++++++++ .../shipping/applyShippingDiscountToCart.js | 2 +- .../src/utils/recalculateShippingDiscount.js | 2 +- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js b/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js index 99c6101d55a..a0671213da2 100644 --- a/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js +++ b/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js @@ -635,4 +635,25 @@ describe("Promotions", () => { expect(cart.appliedPromotions).toHaveLength(2); }); }); + + describe("shipping promotion", () => { + afterAll(async () => { + await removeAllPromotions(); + }); + + createTestPromotion({ + actions: [ + { + actionKey: "discounts", + actionParameters: { + discountType: "shipping", + discountCalculationType: "percentage", + discountValue: 50 + } + } + ] + }); + + createCartAndPlaceOrder({ quantity: 20 }); + }); }); diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js index 0651a496431..a02e086450a 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js @@ -162,7 +162,7 @@ export default async function applyShippingDiscountToCart(context, params, cart) const filteredShipping = await getEligibleShipping(context, cart.shipping, actionParameters); const totalShippingRate = getTotalShippingRate(filteredShipping); const totalShippingDiscount = getTotalShippingDiscount(context, totalShippingRate, actionParameters); - const discountedItems = splitDiscountForShipping(filteredShipping, totalShippingDiscount, totalShippingDiscount); + const discountedItems = splitDiscountForShipping(filteredShipping, totalShippingRate, totalShippingDiscount); for (const discountedItem of discountedItems) { const shipping = filteredShipping.find((item) => item._id === discountedItem._id); diff --git a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js index 2f5ea986e14..fd12843b6bd 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateShippingDiscount.js @@ -40,7 +40,7 @@ export default function recalculateShippingDiscount(context, shipping) { const discountRate = getDiscountedRate(); totalDiscount += discountRate; - discount.discountedAmount = discountedRate; + discount.discountedAmount = discountRate; shipmentMethod.rate = formatMoney(undiscountedRate - discountRate); }); From 7bb460babfd3d6fc3cd1431e817e6177e4840c1d Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Mon, 27 Feb 2023 08:53:36 +0700 Subject: [PATCH 09/19] fix: unit test fail on disocuntAction Signed-off-by: vanpho93 --- .../src/actions/discountAction.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js b/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js index 2132f3b02bf..f0d1524da3d 100644 --- a/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js +++ b/packages/api-plugin-promotions-discounts/src/actions/discountAction.test.js @@ -120,7 +120,8 @@ describe("cleanup", () => { rate: 9, shippingPrice: 11, undiscountedRate: 0 - } + }, + shipmentQuotes: [] } ] }); From 93a5c47cc0e59b8eb4540910fc8ef0a0ec3a685e Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Mon, 27 Feb 2023 10:04:05 +0700 Subject: [PATCH 10/19] feat: add expect discount amount for integraiton test Signed-off-by: vanpho93 --- .../checkout/promotionCheckout.test.js | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js b/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js index a0671213da2..9634f570538 100644 --- a/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js +++ b/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js @@ -104,9 +104,7 @@ describe("Promotions", () => { }; const removeAllPromotions = async () => { - await testApp.setLoggedInUser(mockAdminAccount); - await testApp.collections.Promotions.remove({}); - await testApp.clearLoggedInUser(); + await testApp.collections.Promotions.deleteMany({}); }; const createTestPromotion = (overlay = {}) => { @@ -625,6 +623,10 @@ describe("Promotions", () => { }); describe("Stackability: should applied with other promotions when stackability is all", () => { + afterAll(async () => { + await removeAllPromotions(); + }); + createTestPromotion(); createTestPromotion(); createTestCart({ quantity: 20 }); @@ -654,6 +656,23 @@ describe("Promotions", () => { ] }); - createCartAndPlaceOrder({ quantity: 20 }); + createCartAndPlaceOrder({ quantity: 6 }); + + test("placed order get the correct values", async () => { + const orderId = decodeOpaqueIdForNamespace("reaction/order")(placedOrderId); + const newOrder = await testApp.collections.Orders.findOne({ _id: orderId }); + expect(newOrder.shipping[0].invoice.total).toEqual(121.94); + expect(newOrder.shipping[0].invoice.discounts).toEqual(0); + expect(newOrder.shipping[0].invoice.subtotal).toEqual(119.94); + expect(newOrder.shipping[0].invoice.shipping).toEqual(2); + expect(newOrder.shipping[0].shipmentMethod.discount).toEqual(0.5); + expect(newOrder.shipping[0].shipmentMethod.rate).toEqual(0.5); + expect(newOrder.shipping[0].shipmentMethod.handling).toEqual(1.5); + + expect(newOrder.shipping[0].items[0].quantity).toEqual(6); + + expect(newOrder.appliedPromotions[0]._id).toEqual(mockPromotion._id); + expect(newOrder.discounts).toHaveLength(1); + }); }); }); From 070b2847fcf7988efe1ea4b3fdf27ae217be2ff7 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Mon, 27 Feb 2023 16:14:44 +0700 Subject: [PATCH 11/19] feat: two shipping promotion test case Signed-off-by: vanpho93 --- .../checkout/promotionCheckout.test.js | 82 +++++++++++++++---- packages/api-plugin-promotions/src/startup.js | 2 +- pnpm-lock.yaml | 7 +- 3 files changed, 73 insertions(+), 18 deletions(-) diff --git a/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js b/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js index 9634f570538..18c3cc9f3af 100644 --- a/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js +++ b/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js @@ -103,8 +103,9 @@ describe("Promotions", () => { region: "CA" }; - const removeAllPromotions = async () => { - await testApp.collections.Promotions.deleteMany({}); + const cleanup = async () => { + await testApp.collections.Promotions.deleteMany(); + await testApp.collections.Cart.deleteMany(); }; const createTestPromotion = (overlay = {}) => { @@ -258,7 +259,7 @@ describe("Promotions", () => { describe("when a promotion is applied to an order with fixed promotion", () => { afterAll(async () => { - await removeAllPromotions(); + await cleanup(); }); createTestPromotion(); @@ -282,7 +283,7 @@ describe("Promotions", () => { describe("when a promotion is applied to an order percentage discount", () => { afterAll(async () => { - await removeAllPromotions(); + await cleanup(); }); createTestPromotion({ @@ -326,7 +327,7 @@ describe("Promotions", () => { describe("when a promotion applied via inclusion criteria", () => { afterAll(async () => { - await removeAllPromotions(); + await cleanup(); }); const triggerParameters = { ...fixedDiscountPromotion.triggers[0].triggerParameters }; @@ -406,7 +407,7 @@ describe("Promotions", () => { describe("when a promotion isn't applied via inclusion criteria", () => { afterAll(async () => { - await removeAllPromotions(); + await cleanup(); }); const triggerParameters = { ...fixedDiscountPromotion.triggers[0].triggerParameters }; @@ -447,7 +448,7 @@ describe("Promotions", () => { describe("when a promotion isn't applied by exclusion criteria", () => { afterAll(async () => { - await removeAllPromotions(); + await cleanup(); }); const triggerParameters = { ...fixedDiscountPromotion.triggers[0].triggerParameters }; @@ -505,7 +506,7 @@ describe("Promotions", () => { describe("cart shouldn't contains any promotion when qualified promotion is change to disabled", () => { afterAll(async () => { - await removeAllPromotions(); + await cleanup(); }); createTestPromotion(); @@ -539,13 +540,13 @@ describe("Promotions", () => { expect(cart.appliedPromotions).toHaveLength(0); expect(cart.messages).toHaveLength(1); - await removeAllPromotions(); + await cleanup(); }); }); describe("cart applied promotion with 10% but max discount is $20", () => { afterAll(async () => { - await removeAllPromotions(); + await cleanup(); }); createTestPromotion({ @@ -604,7 +605,7 @@ describe("Promotions", () => { describe("Stackability: shouldn't stack with other promotion when stackability is none", () => { afterAll(async () => { - await removeAllPromotions(); + await cleanup(); }); createTestPromotion(); @@ -624,7 +625,7 @@ describe("Promotions", () => { describe("Stackability: should applied with other promotions when stackability is all", () => { afterAll(async () => { - await removeAllPromotions(); + await cleanup(); }); createTestPromotion(); @@ -638,9 +639,9 @@ describe("Promotions", () => { }); }); - describe("shipping promotion", () => { + describe("apply with single shipping promotion", () => { afterAll(async () => { - await removeAllPromotions(); + await cleanup(); }); createTestPromotion({ @@ -675,4 +676,57 @@ describe("Promotions", () => { expect(newOrder.discounts).toHaveLength(1); }); }); + + describe("apply with two shipping promotions", () => { + beforeAll(async () => { + await cleanup(); + }); + + createTestPromotion({ + label: "shipping promotion 1", + actions: [ + { + actionKey: "discounts", + actionParameters: { + discountType: "shipping", + discountCalculationType: "percentage", + discountValue: 50 + } + } + ] + }); + + createTestPromotion({ + label: "shipping promotion 2", + actions: [ + { + actionKey: "discounts", + actionParameters: { + discountType: "shipping", + discountCalculationType: "fixed", + discountValue: 0.5 + } + } + ] + }); + + createCartAndPlaceOrder({ quantity: 6 }); + + test("placed order get the correct values", async () => { + const orderId = decodeOpaqueIdForNamespace("reaction/order")(placedOrderId); + const newOrder = await testApp.collections.Orders.findOne({ _id: orderId }); + expect(newOrder.shipping[0].invoice.total).toEqual(121.94); + expect(newOrder.shipping[0].invoice.discounts).toEqual(0); + expect(newOrder.shipping[0].invoice.subtotal).toEqual(119.94); + expect(newOrder.shipping[0].invoice.shipping).toEqual(2); + expect(newOrder.shipping[0].shipmentMethod.discount).toEqual(0.5); + expect(newOrder.shipping[0].shipmentMethod.rate).toEqual(0.5); + expect(newOrder.shipping[0].shipmentMethod.handling).toEqual(1.5); + + expect(newOrder.shipping[0].items[0].quantity).toEqual(6); + + expect(newOrder.appliedPromotions).toHaveLength(2); + expect(newOrder.discounts).toHaveLength(2); + }); + }); }); diff --git a/packages/api-plugin-promotions/src/startup.js b/packages/api-plugin-promotions/src/startup.js index 70b7ac9674c..040d38de6f8 100644 --- a/packages/api-plugin-promotions/src/startup.js +++ b/packages/api-plugin-promotions/src/startup.js @@ -22,7 +22,7 @@ const logCtx = { export default async function startupPromotions(context) { const { bullQueue } = context; await bullQueue.createQueue(context, "setPromotionState", { jobName: "checkForChangedStates" }, setPromotionState(context)); - await bullQueue.scheduleJob(context, "setPromotionState", "checkForChangedStates", {}, "*/5 * * * *"); + await bullQueue.scheduleJob(context, "setPromotionState", "checkForChangedStates", {}, "*/1 * * * *"); Logger.info(logCtx, "Add setPromotionState queue and job"); await bullQueue.createQueue(context, "checkExistingCarts", {}, checkCartForPromotionChange(context)); return true; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9594b68eceb..f397c70337d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -258,7 +258,7 @@ importers: '@reactioncommerce/file-collections-sa-gridfs': link:../../packages/file-collections-sa-gridfs '@reactioncommerce/logger': link:../../packages/logger '@reactioncommerce/random': link:../../packages/random - '@snyk/protect': 1.1096.0 + '@snyk/protect': 1.1109.0 graphql: 16.6.0 nodemailer: 6.8.0 semver: 6.3.0 @@ -5149,8 +5149,9 @@ packages: '@sinonjs/commons': 1.8.3 dev: false - /@snyk/protect/1.1096.0: - resolution: {integrity: sha512-E0hkw5TY8rIygL2uohywBrW72f1x/g36mHdMxS9UzLB9DHLDudJJYHMwJfdjl6dW7cuuTVauv8TDQireMkjOVw==} + /@snyk/protect/1.1109.0: + resolution: {integrity: sha512-AR2RO6B4LsGUTtTnRDxmDhb8EKrTMhRg3RnxQD/uP1RHFsBLNnilQrAeC0qHldrbG9k4qMmE/300aLSd+UGHiw==} + engines: {node: '>=10'} hasBin: true dev: false From dbb9c886ded74371cd6a02b8b5ac131294196954 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Tue, 28 Feb 2023 10:42:09 +0700 Subject: [PATCH 12/19] fix: temporary promotions Signed-off-by: vanpho93 --- apps/reaction/plugins.json | 3 +- .../src/mutations/transformAndValidateCart.js | 5 +- .../src/mutations/placeOrder.js | 2 +- .../src/actions/discountAction.js | 4 +- .../shipping/applyShippingDiscountToCart.js | 21 ++++-- .../applyShippingDiscountToCart.test.js | 8 +-- .../src/preStartup.js | 9 ++- .../src/utils/recalculateQuoteDiscount.js | 7 +- .../src/handlers/applyPromotions.js | 9 ++- .../src/handlers/applyPromotions.test.js | 67 ++++++++++++++++++- packages/api-plugin-promotions/src/index.js | 5 +- .../src/qualifiers/stackable.js | 3 +- .../src/simpleSchemas.js | 4 ++ 13 files changed, 117 insertions(+), 30 deletions(-) diff --git a/apps/reaction/plugins.json b/apps/reaction/plugins.json index c42312babb8..a3440255845 100644 --- a/apps/reaction/plugins.json +++ b/apps/reaction/plugins.json @@ -41,5 +41,6 @@ "promotionsCoupons": "@reactioncommerce/api-plugin-promotions-coupons", "promotionsDiscounts": "@reactioncommerce/api-plugin-promotions-discounts", "promotionsOffers": "@reactioncommerce/api-plugin-promotions-offers", - "bullJobQueue": "@reactioncommerce/api-plugin-bull-queue" + "bullJobQueue": "@reactioncommerce/api-plugin-bull-queue", + "sampleData": "../../packages/api-plugin-sample-data/index.js" } diff --git a/packages/api-plugin-carts/src/mutations/transformAndValidateCart.js b/packages/api-plugin-carts/src/mutations/transformAndValidateCart.js index 544ea4ce836..6321ff6950f 100644 --- a/packages/api-plugin-carts/src/mutations/transformAndValidateCart.js +++ b/packages/api-plugin-carts/src/mutations/transformAndValidateCart.js @@ -11,9 +11,10 @@ const logCtx = { name: "cart", file: "transformAndValidateCart" }; * and validates it. Throws an error if invalid. The cart object is mutated. * @param {Object} context - App context * @param {Object} cart - The cart to transform and validate + * @param {Object} options - transform options * @returns {undefined} */ -export default async function transformAndValidateCart(context, cart) { +export default async function transformAndValidateCart(context, cart, options = {}) { const { simpleSchemas: { Cart: cartSchema } } = context; updateCartFulfillmentGroups(context, cart); @@ -41,7 +42,7 @@ export default async function transformAndValidateCart(context, cart) { await forEachPromise(cartTransforms, async (transformInfo) => { const startTime = Date.now(); /* eslint-disable no-await-in-loop */ - await transformInfo.fn(context, cart, { getCommonOrders }); + await transformInfo.fn(context, cart, { getCommonOrders, ...options }); /* eslint-enable no-await-in-loop */ Logger.debug({ ...logCtx, cartId: cart._id, ms: Date.now() - startTime }, `Finished ${transformInfo.name} cart transform`); }); diff --git a/packages/api-plugin-orders/src/mutations/placeOrder.js b/packages/api-plugin-orders/src/mutations/placeOrder.js index eecc1863cc5..55ad73f84c9 100644 --- a/packages/api-plugin-orders/src/mutations/placeOrder.js +++ b/packages/api-plugin-orders/src/mutations/placeOrder.js @@ -148,7 +148,7 @@ export default async function placeOrder(context, input) { throw new ReactionError("invalid-cart", "Cart messages should be acknowledged before placing order"); } - await context.mutations.transformAndValidateCart(context, cart); + await context.mutations.transformAndValidateCart(context, cart, { skipTemporaryPromotions: true }); } diff --git a/packages/api-plugin-promotions-discounts/src/actions/discountAction.js b/packages/api-plugin-promotions-discounts/src/actions/discountAction.js index 2759b9e8e00..c72722c8d6d 100644 --- a/packages/api-plugin-promotions-discounts/src/actions/discountAction.js +++ b/packages/api-plugin-promotions-discounts/src/actions/discountAction.js @@ -119,10 +119,10 @@ export async function discountActionHandler(context, cart, params) { Logger.info({ params, cartId: cart._id, ...logCtx }, "applying discount to cart"); - const { cart: updatedCart, affected, reason } = await functionMap[discountType](context, params, cart); + const { cart: updatedCart, affected, reason, temporaryAffected } = await functionMap[discountType](context, params, cart); Logger.info({ ...logCtx, ...params.actionParameters, cartId: cart._id, cartDiscount: cart.discount }, "Completed applying Discount to Cart"); - return { updatedCart, affected, reason }; + return { updatedCart, affected, reason, temporaryAffected }; } export default { diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js index a02e086450a..4ed8cf95cad 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js @@ -78,7 +78,7 @@ export function splitDiscountForShipping(cartShipping, totalShippingRate, discou return { _id: shipping._id, amount: formatMoney(discountAmount - discounted) }; }); - return discountedShipping; + return discountedShipping.filter((shipping) => shipping.amount > 0); } /** @@ -129,6 +129,7 @@ export async function estimateShipmentQuoteDiscount(context, cart, params) { const shipmentQuotes = cart.shipping[0]?.shipmentQuotes || []; + let affectedItemsLength = 0; for (const item of filteredItems) { const shipmentQuote = shipmentQuotes.find((quote) => quote.method._id === item.method._id); if (!shipmentQuote) continue; @@ -139,10 +140,11 @@ export async function estimateShipmentQuoteDiscount(context, cart, params) { if (!shipmentQuote.discounts) shipmentQuote.discounts = []; shipmentQuote.discounts.push(createDiscountRecord(params, item)); + affectedItemsLength += 1; recalculateQuoteDiscount(context, shipmentQuote, actionParameters); } - return filteredItems.length > 0; + return affectedItemsLength > 0; } /** @@ -156,34 +158,39 @@ export default async function applyShippingDiscountToCart(context, params, cart) const { actionParameters } = params; if (!cart.shipping) cart.shipping = []; + if (!cart.appliedPromotions) cart.appliedPromotions = []; - await estimateShipmentQuoteDiscount(context, cart, params); + const isEstimateAffected = await estimateShipmentQuoteDiscount(context, cart, params); const filteredShipping = await getEligibleShipping(context, cart.shipping, actionParameters); const totalShippingRate = getTotalShippingRate(filteredShipping); const totalShippingDiscount = getTotalShippingDiscount(context, totalShippingRate, actionParameters); const discountedItems = splitDiscountForShipping(filteredShipping, totalShippingRate, totalShippingDiscount); + let discountedShippingCount = 0; for (const discountedItem of discountedItems) { const shipping = filteredShipping.find((item) => item._id === discountedItem._id); if (!shipping) continue; + const canBeDiscounted = canBeApplyDiscountToShipping(shipping, params.promotion); if (!canBeDiscounted) continue; if (!shipping.discounts) shipping.discounts = []; const shippingDiscount = createDiscountRecord(params, discountedItem); + shipping.discounts.push(shippingDiscount); + recalculateShippingDiscount(context, shipping); + discountedShippingCount += 1; } - if (discountedItems.length) { + const affected = discountedShippingCount > 0; + if (affected) { Logger.info(logCtx, "Saved Discount to cart"); } - const affected = discountedItems.length > 0; const reason = !affected ? "No shippings were discounted" : undefined; - - return { cart, affected, reason }; + return { cart, affected, reason, temporaryAffected: isEstimateAffected }; } diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js index 5ca65a7e983..376824783ae 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js @@ -77,7 +77,7 @@ test("should apply shipping discount to cart", async () => { }; mockContext.discountCalculationMethods = { - fixed: jest.fn().mockReturnValue(0) + fixed: jest.fn().mockReturnValue(2) }; const { cart: updatedCart, affected } = await applyShippingDiscountToCart.default(mockContext, parameters, cart); @@ -85,10 +85,10 @@ test("should apply shipping discount to cart", async () => { expect(affected).toEqual(true); expect(updatedCart.shipping[0].shipmentMethod).toEqual({ _id: "method1", - discount: 9, + discount: 7, handling: 2, - rate: 0, - shippingPrice: 2, + rate: 2, + shippingPrice: 4, undiscountedRate: 9 }); expect(updatedCart.shipping[0].discounts).toHaveLength(1); diff --git a/packages/api-plugin-promotions-discounts/src/preStartup.js b/packages/api-plugin-promotions-discounts/src/preStartup.js index 9650197cf13..654b9bb65ff 100644 --- a/packages/api-plugin-promotions-discounts/src/preStartup.js +++ b/packages/api-plugin-promotions-discounts/src/preStartup.js @@ -22,7 +22,7 @@ const discountSchema = new SimpleSchema({ * @returns {Promise} undefined */ async function extendCartSchemas(context) { - const { simpleSchemas: { Cart, CartItem, Shipment, ShippingMethod, ShipmentQuote } } = context; + const { simpleSchemas: { Cart, CartItem, Shipment, ShippingMethod, ShipmentQuote, PromotionStackability } } = context; Cart.extend(discountSchema); Cart.extend({ "discounts": { @@ -54,6 +54,13 @@ async function extendCartSchemas(context) { } }); + CartDiscount.extend({ + stackability: { + type: PromotionStackability, + optional: true + } + }); + Shipment.extend({ "discounts": { type: Array, diff --git a/packages/api-plugin-promotions-discounts/src/utils/recalculateQuoteDiscount.js b/packages/api-plugin-promotions-discounts/src/utils/recalculateQuoteDiscount.js index 2ecc6b64695..d43501533b9 100644 --- a/packages/api-plugin-promotions-discounts/src/utils/recalculateQuoteDiscount.js +++ b/packages/api-plugin-promotions-discounts/src/utils/recalculateQuoteDiscount.js @@ -11,7 +11,8 @@ export default function recalculateQuoteDiscount(context, quote) { let totalDiscount = 0; const { method, undiscountedRate } = quote; - const rate = undiscountedRate || method.rate; + const rate = undiscountedRate || method.undiscountedRate || method.rate; + quote.rate = rate; quote.undiscountedRate = rate; quote.discounts.forEach((discount) => { @@ -20,7 +21,7 @@ export default function recalculateQuoteDiscount(context, quote) { const { discountMaxValue } = discount; // eslint-disable-next-line require-jsdoc - function getDiscountedRate() { + function getDiscountRate() { const discountRate = formatMoney(quoteRate - discountedRate); if (typeof discountMaxValue === "number" && discountMaxValue > 0) { return Math.min(discountRate, discountMaxValue); @@ -28,7 +29,7 @@ export default function recalculateQuoteDiscount(context, quote) { return discountRate; } - const discountRate = getDiscountedRate(); + const discountRate = getDiscountRate(); totalDiscount += discountRate; discount.discountedAmount = discountRate; diff --git a/packages/api-plugin-promotions/src/handlers/applyPromotions.js b/packages/api-plugin-promotions/src/handlers/applyPromotions.js index 495cc077d3b..78b35a99469 100644 --- a/packages/api-plugin-promotions/src/handlers/applyPromotions.js +++ b/packages/api-plugin-promotions/src/handlers/applyPromotions.js @@ -95,9 +95,10 @@ export async function getCurrentTime(context, shopId) { * @summary apply promotions to a cart * @param {Object} context - The application context * @param {Object} cart - The cart to apply promotions to + * @param {Object} options - Options * @returns {Promise} - mutated cart */ -export default async function applyPromotions(context, cart) { +export default async function applyPromotions(context, cart, options = { skipTemporaryPromotions: false }) { const currentTime = await getCurrentTime(context, cart.shopId); const promotions = await getImplicitPromotions(context, cart.shopId, currentTime); const { promotions: pluginPromotions, simpleSchemas: { Cart, CartPromotionItem } } = context; @@ -198,18 +199,20 @@ export default async function applyPromotions(context, cart) { } let affected = false; + let temporaryAffected = false; let rejectedReason; for (const action of promotion.actions) { const actionFn = actionHandleByKey[action.actionKey]; if (!actionFn) continue; const result = await actionFn.handler(context, enhancedCart, { promotion, ...action }); - ({ affected, reason: rejectedReason } = result); + ({ affected, temporaryAffected, reason: rejectedReason } = result); enhancedCart = enhanceCart(context, pluginPromotions.enhancers, enhancedCart); } - if (affected) { + if (affected || (!options.skipTemporaryPromotions && temporaryAffected)) { const affectedPromotion = _.cloneDeep(promotion); + affectedPromotion.isTemporary = !affected && temporaryAffected; CartPromotionItem.clean(affectedPromotion); appliedPromotions.push(affectedPromotion); continue; diff --git a/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js b/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js index bb21b8885b7..d432bd700dd 100644 --- a/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js +++ b/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js @@ -61,7 +61,7 @@ test("should save cart with implicit promotions are applied", async () => { }); expect(testEnhancer).toBeCalledWith(mockContext, expect.objectContaining({ _id: cart._id })); - const expectedCart = { ...cart, appliedPromotions: [testPromotion] }; + const expectedCart = { ...cart, appliedPromotions: [{ ...testPromotion, isTemporary: false }] }; expect(cart).toEqual(expectedCart); }); @@ -145,7 +145,8 @@ describe("cart message", () => { }); test("should have promotion can't be applied message when promotion can't be applied", async () => { - canBeApplied.mockReturnValue({ qualifies: false, reason: "Can't be combine" }); + testAction.mockResolvedValue({ affected: true }); + canBeApplied.mockResolvedValue({ qualifies: false, reason: "Can't be combine" }); isPromotionExpired.mockReturnValue(false); const promotion = { @@ -164,7 +165,7 @@ describe("cart message", () => { }) }; - mockContext.promotions = { ...pluginPromotion, triggers: [], qualifiers: [] }; + mockContext.promotions = { ...pluginPromotion, qualifiers: [] }; mockContext.simpleSchemas = { Cart: { clean: jest.fn() } }; @@ -378,3 +379,63 @@ test("shouldn't apply promotion when promotion is not enabled", async () => { expect(cart.appliedPromotions.length).toEqual(0); }); + +test("temporary should apply shipping discount with isTemporary flag when affected but shipmentMethod is not selected", async () => { + const promotion = { + ...testPromotion, + _id: "promotionId", + enabled: true + }; + const cart = { + _id: "cartId", + appliedPromotions: [], + shipping: [ + { + _id: "shippingId", + shopId: "shopId", + shipmentQuotes: [ + { + carrier: "Flat Rate", + handlingPrice: 2, + method: { + name: "globalFlatRateGround", + cost: 5, + handling: 2, + rate: 5, + _id: "CiHcHJXEeGF9t9z3a", + carrier: "Flat Rate", + discount: 4, + shippingPrice: 7, + undiscountedRate: 9 + }, + rate: 5, + shippingPrice: 7, + discount: 4, + undiscountedRate: 9 + } + ] + } + ] + }; + + testAction.mockResolvedValue({ affected: false, temporaryAffected: true }); + + mockContext.collections.Promotions = { + find: () => ({ + toArray: jest.fn().mockResolvedValueOnce([promotion]) + }) + }; + + mockContext.promotions = { ...pluginPromotion }; + mockContext.simpleSchemas = { + Cart: { clean: jest.fn() }, + CartPromotionItem: { + clean: jest.fn() + } + }; + + await applyPromotions(mockContext, cart); + + expect(cart.appliedPromotions.length).toEqual(1); + expect(cart.appliedPromotions[0].isTemporary).toEqual(true); +}); diff --git a/packages/api-plugin-promotions/src/index.js b/packages/api-plugin-promotions/src/index.js index a5de758703d..90553d9f4e7 100644 --- a/packages/api-plugin-promotions/src/index.js +++ b/packages/api-plugin-promotions/src/index.js @@ -2,7 +2,7 @@ import { createRequire } from "module"; import { promotions, registerPluginHandlerForPromotions } from "./registration.js"; import mutations from "./mutations/index.js"; import preStartupPromotions from "./preStartup.js"; -import { Promotion, CartPromotionItem } from "./simpleSchemas.js"; +import { Promotion, CartPromotionItem, Stackability as PromotionStackability } from "./simpleSchemas.js"; import actions from "./actions/index.js"; import promotionTypes from "./promotionTypes/index.js"; import schemas from "./schemas/index.js"; @@ -46,7 +46,8 @@ export default async function register(app) { }, simpleSchemas: { Promotion, - CartPromotionItem + CartPromotionItem, + PromotionStackability }, functionsByType: { registerPluginHandler: [registerPluginHandlerForPromotions], diff --git a/packages/api-plugin-promotions/src/qualifiers/stackable.js b/packages/api-plugin-promotions/src/qualifiers/stackable.js index 37d7df9a66e..7dad0db4bf1 100644 --- a/packages/api-plugin-promotions/src/qualifiers/stackable.js +++ b/packages/api-plugin-promotions/src/qualifiers/stackable.js @@ -24,8 +24,9 @@ const logCtx = { export default async function stackable(context, cart, { appliedPromotions, promotion }) { const { promotions } = context; const stackabilityByKey = _.keyBy(promotions.stackabilities, "key"); + const permanentPromotions = appliedPromotions.filter((appliedPromotion) => !appliedPromotion.isTemporary); - for (const appliedPromotion of appliedPromotions) { + for (const appliedPromotion of permanentPromotions) { if (!appliedPromotion.stackability) continue; const stackabilityHandler = stackabilityByKey[promotion.stackability.key]; diff --git a/packages/api-plugin-promotions/src/simpleSchemas.js b/packages/api-plugin-promotions/src/simpleSchemas.js index cee652d3c98..bef1d4ce040 100644 --- a/packages/api-plugin-promotions/src/simpleSchemas.js +++ b/packages/api-plugin-promotions/src/simpleSchemas.js @@ -143,5 +143,9 @@ export const CartPromotionItem = new SimpleSchema({ triggerType: { type: String, allowedValues: ["implicit", "explicit"] + }, + isTemporary: { + type: Boolean, + defaultValue: false } }); From dd89dcd46134796c631e8df227feacc66c03eac4 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Wed, 1 Mar 2023 16:08:58 +0700 Subject: [PATCH 13/19] fix: pnpm-lock file Signed-off-by: vanpho93 --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ae045198a1..62f37138709 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -258,7 +258,7 @@ importers: '@reactioncommerce/file-collections-sa-gridfs': link:../../packages/file-collections-sa-gridfs '@reactioncommerce/logger': link:../../packages/logger '@reactioncommerce/random': link:../../packages/random - '@snyk/protect': 1.1109.0 + '@snyk/protect': 1.1096.0 graphql: 16.6.0 nodemailer: 6.8.0 semver: 6.3.0 From 6fbde96b262c9c879454e03fa3efbac9a57f13f0 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Wed, 1 Mar 2023 16:15:08 +0700 Subject: [PATCH 14/19] feat: add sample data for shipping promotion Signed-off-by: vanpho93 --- .../src/loaders/loadPromotions.js | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/api-plugin-sample-data/src/loaders/loadPromotions.js b/packages/api-plugin-sample-data/src/loaders/loadPromotions.js index d2155c2ae81..7b318b32ff4 100644 --- a/packages/api-plugin-sample-data/src/loaders/loadPromotions.js +++ b/packages/api-plugin-sample-data/src/loaders/loadPromotions.js @@ -139,7 +139,54 @@ const Coupon = { updatedAt: new Date() }; -const promotions = [OrderPromotion, OrderItemPromotion, CouponPromotion]; +const ShippingPromotion = { + _id: "shippingPromotion", + referenceId: 1, + triggerType: "implicit", + promotionType: "shipping-discount", + name: "$5 off over $100", + label: "$5 off your entire order when you spend more then $100", + description: "$5 off your entire order when you spend more then $100", + enabled: true, + state: "created", + triggers: [ + { + triggerKey: "offers", + triggerParameters: { + name: "$5 off your entire order when you spend more then $100", + conditions: { + all: [ + { + fact: "totalItemAmount", + operator: "greaterThanInclusive", + value: 100 + } + ] + } + } + } + ], + actions: [ + { + actionKey: "discounts", + actionParameters: { + discountType: "shipping", + discountCalculationType: "fixed", + discountValue: 5 + } + } + ], + startDate: now, + endDate: new Date(now.getTime() + 1000 * 60 * 60 * 24 * 7), + createdAt: new Date(), + updatedAt: new Date(), + stackability: { + key: "all", + parameters: {} + } +}; + +const promotions = [OrderPromotion, OrderItemPromotion, CouponPromotion, ShippingPromotion]; /** * @summary Load promotions fixtures From 40724365fd53471ccf4ec46e1f581db9ef0f2f3b Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Wed, 1 Mar 2023 16:31:35 +0700 Subject: [PATCH 15/19] fix: promotion plugin unit test fail Signed-off-by: vanpho93 --- .../src/handlers/applyPromotions.test.js | 8 ++++++-- .../api-plugin-sample-data/src/loaders/loadPromotions.js | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js b/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js index 3c0a647ef95..6f3a164100b 100644 --- a/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js +++ b/packages/api-plugin-promotions/src/handlers/applyPromotions.test.js @@ -429,8 +429,11 @@ test("temporary should apply shipping discount with isTemporary flag when affect testAction.mockResolvedValue({ affected: false, temporaryAffected: true }); mockContext.collections.Promotions = { - find: () => ({ - toArray: jest.fn().mockResolvedValueOnce([promotion]) + find: (query) => ({ + toArray: jest.fn().mockImplementation(() => { + if (query.triggerType === "explicit") return []; + return [promotion]; + }) }) }; @@ -441,6 +444,7 @@ test("temporary should apply shipping discount with isTemporary flag when affect clean: jest.fn() } }; + canBeApplied.mockReturnValue({ qualifies: true }); await applyPromotions(mockContext, cart); diff --git a/packages/api-plugin-sample-data/src/loaders/loadPromotions.js b/packages/api-plugin-sample-data/src/loaders/loadPromotions.js index 7b318b32ff4..8c8f17400c0 100644 --- a/packages/api-plugin-sample-data/src/loaders/loadPromotions.js +++ b/packages/api-plugin-sample-data/src/loaders/loadPromotions.js @@ -141,7 +141,7 @@ const Coupon = { const ShippingPromotion = { _id: "shippingPromotion", - referenceId: 1, + referenceId: 4, triggerType: "implicit", promotionType: "shipping-discount", name: "$5 off over $100", From 686d7e22d6082e59f4ec5c8c2135fd74575a2ce7 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Wed, 1 Mar 2023 16:40:41 +0700 Subject: [PATCH 16/19] fix: remove sampleData from plugin file Signed-off-by: vanpho93 --- apps/reaction/plugins.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/reaction/plugins.json b/apps/reaction/plugins.json index a3440255845..c42312babb8 100644 --- a/apps/reaction/plugins.json +++ b/apps/reaction/plugins.json @@ -41,6 +41,5 @@ "promotionsCoupons": "@reactioncommerce/api-plugin-promotions-coupons", "promotionsDiscounts": "@reactioncommerce/api-plugin-promotions-discounts", "promotionsOffers": "@reactioncommerce/api-plugin-promotions-offers", - "bullJobQueue": "@reactioncommerce/api-plugin-bull-queue", - "sampleData": "../../packages/api-plugin-sample-data/index.js" + "bullJobQueue": "@reactioncommerce/api-plugin-bull-queue" } From 490044c4e6c6fc3ee9fe054d962845aff92768de Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Wed, 1 Mar 2023 18:48:14 +0700 Subject: [PATCH 17/19] fix: checkout promotion test fail Signed-off-by: vanpho93 --- .../mutations/checkout/promotionCheckout.test.js | 15 +++++++-------- .../src/preStartup.js | 11 ++--------- packages/api-plugin-promotions/src/preStartup.js | 4 ++-- 3 files changed, 11 insertions(+), 19 deletions(-) diff --git a/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js b/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js index f0141f123a7..f7a15b1ed50 100644 --- a/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js +++ b/apps/reaction/tests/integration/api/mutations/checkout/promotionCheckout.test.js @@ -98,6 +98,7 @@ describe("Promotions", () => { const cleanup = async () => { await testApp.collections.Promotions.deleteMany(); + await testApp.collections.Orders.deleteMany(); await testApp.collections.Cart.deleteMany(); }; @@ -696,8 +697,8 @@ describe("Promotions", () => { actionKey: "discounts", actionParameters: { discountType: "shipping", - discountCalculationType: "fixed", - discountValue: 0.5 + discountCalculationType: "percentage", + discountValue: 10 } } ] @@ -708,16 +709,14 @@ describe("Promotions", () => { test("placed order get the correct values", async () => { const orderId = decodeOpaqueIdForNamespace("reaction/order")(placedOrderId); const newOrder = await testApp.collections.Orders.findOne({ _id: orderId }); - expect(newOrder.shipping[0].invoice.total).toEqual(121.94); + expect(newOrder.shipping[0].invoice.total).toEqual(121.89); expect(newOrder.shipping[0].invoice.discounts).toEqual(0); expect(newOrder.shipping[0].invoice.subtotal).toEqual(119.94); - expect(newOrder.shipping[0].invoice.shipping).toEqual(2); - expect(newOrder.shipping[0].shipmentMethod.discount).toEqual(0.5); - expect(newOrder.shipping[0].shipmentMethod.rate).toEqual(0.5); + expect(newOrder.shipping[0].invoice.shipping).toEqual(1.95); + expect(newOrder.shipping[0].shipmentMethod.discount).toEqual(0.55); + expect(newOrder.shipping[0].shipmentMethod.rate).toEqual(0.45); expect(newOrder.shipping[0].shipmentMethod.handling).toEqual(1.5); - expect(newOrder.shipping[0].items[0].quantity).toEqual(6); - expect(newOrder.appliedPromotions).toHaveLength(2); expect(newOrder.discounts).toHaveLength(2); }); diff --git a/packages/api-plugin-promotions-coupons/src/preStartup.js b/packages/api-plugin-promotions-coupons/src/preStartup.js index 6a71c42388e..2ceb633c83e 100644 --- a/packages/api-plugin-promotions-coupons/src/preStartup.js +++ b/packages/api-plugin-promotions-coupons/src/preStartup.js @@ -1,4 +1,3 @@ -import _ from "lodash"; import SimpleSchema from "simpl-schema"; import doesDatabaseVersionMatch from "@reactioncommerce/db-version-check"; import { migrationsNamespace } from "../migrations/migrationsNamespace.js"; @@ -12,7 +11,7 @@ const expectedVersion = 2; * @returns {undefined} */ export default async function preStartupPromotionCoupon(context) { - const { simpleSchemas: { Cart, Promotion, RuleExpression }, promotions: pluginPromotions } = context; + const { simpleSchemas: { RuleExpression, CartPromotionItem }, promotions: pluginPromotions } = context; CouponTriggerCondition.extend({ conditions: RuleExpression @@ -26,24 +25,18 @@ export default async function preStartupPromotionCoupon(context) { const offerTrigger = pluginPromotions.triggers.find((trigger) => trigger.key === "offers"); if (!offerTrigger) throw new Error("No offer trigger found. Need to register offers trigger first."); - const copiedPromotion = _.cloneDeep(Promotion); - const relatedCoupon = new SimpleSchema({ couponCode: String, couponId: String }); - copiedPromotion.extend({ + CartPromotionItem.extend({ relatedCoupon: { type: relatedCoupon, optional: true } }); - Cart.extend({ - "appliedPromotions.$": copiedPromotion - }); - const setToExpectedIfMissing = async () => { const anyDiscount = await context.collections.Discounts.findOne(); return !anyDiscount; diff --git a/packages/api-plugin-promotions/src/preStartup.js b/packages/api-plugin-promotions/src/preStartup.js index 787237d4893..77cf28ff0b1 100644 --- a/packages/api-plugin-promotions/src/preStartup.js +++ b/packages/api-plugin-promotions/src/preStartup.js @@ -1,5 +1,5 @@ import _ from "lodash"; -import { Action, Trigger, Promotion as PromotionSchema, Stackability } from "./simpleSchemas.js"; +import { Action, Trigger, Promotion as PromotionSchema, Stackability, CartPromotionItem } from "./simpleSchemas.js"; /** * @summary apply all schema extensions to the Promotions schema @@ -19,7 +19,7 @@ function extendSchemas(context) { * @returns {Object} the extended schema */ function extendCartSchema(context) { - const { simpleSchemas: { Cart, CartPromotionItem } } = context; // we get this here rather then importing it to get the extended version + const { simpleSchemas: { Cart } } = context; // we get this here rather then importing it to get the extended version Cart.extend({ "version": { From c2471d375c4ff1b624549542a21bb49b6442d10b Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Thu, 2 Mar 2023 17:18:20 +0700 Subject: [PATCH 18/19] feat: add check stackability for the shipping discount Signed-off-by: vanpho93 --- .../shipping/applyShippingDiscountToCart.js | 10 ++++- .../applyShippingDiscountToCart.test.js | 4 ++ .../shipping/checkShippingStackable.js | 28 ++++++++++++ .../shipping/checkShippingStackable.test.js | 45 +++++++++++++++++++ 4 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 packages/api-plugin-promotions-discounts/src/discountTypes/shipping/checkShippingStackable.js create mode 100644 packages/api-plugin-promotions-discounts/src/discountTypes/shipping/checkShippingStackable.test.js diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js index 4ed8cf95cad..c990c66e31b 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.js @@ -1,4 +1,3 @@ -/* eslint-disable no-unused-vars */ import { createRequire } from "module"; import _ from "lodash"; import Logger from "@reactioncommerce/logger"; @@ -7,6 +6,7 @@ import formatMoney from "../../utils/formatMoney.js"; import getEligibleShipping from "../../utils/getEligibleIShipping.js"; import calculateDiscountAmount from "../../utils/calculateDiscountAmount.js"; import recalculateQuoteDiscount from "../../utils/recalculateQuoteDiscount.js"; +import checkShippingStackable from "./checkShippingStackable.js"; const require = createRequire(import.meta.url); @@ -137,8 +137,14 @@ export async function estimateShipmentQuoteDiscount(context, cart, params) { const canBeDiscounted = canBeApplyDiscountToShipping(shipmentQuote, promotion); if (!canBeDiscounted) continue; + const shippingDiscount = createDiscountRecord(params, item); + if (!shipmentQuote.discounts) shipmentQuote.discounts = []; - shipmentQuote.discounts.push(createDiscountRecord(params, item)); + // eslint-disable-next-line no-await-in-loop + const canStackable = await checkShippingStackable(context, shipmentQuote, shippingDiscount); + if (!canStackable) continue; + + shipmentQuote.discounts.push(shippingDiscount); affectedItemsLength += 1; recalculateQuoteDiscount(context, shipmentQuote, actionParameters); diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js index 376824783ae..267f5ec637a 100644 --- a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/applyShippingDiscountToCart.test.js @@ -1,5 +1,8 @@ import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; import * as applyShippingDiscountToCart from "./applyShippingDiscountToCart.js"; +import checkShippingStackable from "./checkShippingStackable.js"; + +jest.mock("./checkShippingStackable.js", () => jest.fn()); test("createDiscountRecord should create discount record", () => { const parameters = { @@ -79,6 +82,7 @@ test("should apply shipping discount to cart", async () => { mockContext.discountCalculationMethods = { fixed: jest.fn().mockReturnValue(2) }; + checkShippingStackable.mockReturnValue(true); const { cart: updatedCart, affected } = await applyShippingDiscountToCart.default(mockContext, parameters, cart); diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/checkShippingStackable.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/checkShippingStackable.js new file mode 100644 index 00000000000..228be1a57ae --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/checkShippingStackable.js @@ -0,0 +1,28 @@ +/* eslint-disable no-await-in-loop */ +import _ from "lodash"; + +/** + * @summary check if a promotion is applicable to a cart + * @param {Object} context - The application context + * @param {Object} shipping - The cart we are trying to apply the promotion to + * @param {Object} discount - The promotion we are trying to apply + * @returns {Promise} - Whether the promotion is applicable to the shipping + */ +export default async function checkShippingStackable(context, shipping, discount) { + const { promotions } = context; + const stackabilityByKey = _.keyBy(promotions.stackabilities, "key"); + + for (const appliedDiscount of shipping.discounts) { + if (!appliedDiscount.stackability) continue; + + const stackHandler = stackabilityByKey[discount.stackability.key]; + const appliedStackHandler = stackabilityByKey[appliedDiscount.stackability.key]; + + const stackResult = await stackHandler.handler(context, null, { promotion: discount, appliedPromotion: appliedDiscount }); + const appliedStackResult = await appliedStackHandler.handler(context, {}, { promotion: appliedDiscount, appliedPromotion: discount }); + + if (!stackResult || !appliedStackResult) return false; + } + + return true; +} diff --git a/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/checkShippingStackable.test.js b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/checkShippingStackable.test.js new file mode 100644 index 00000000000..671639fc400 --- /dev/null +++ b/packages/api-plugin-promotions-discounts/src/discountTypes/shipping/checkShippingStackable.test.js @@ -0,0 +1,45 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import checkShippingStackable from "./checkShippingStackable.js"; + +test("should returns true if no the current discount is stackable", async () => { + const shipping = { + discounts: [ + { + stackability: { key: "all" } + } + ] + }; + const discount = { + stackability: { key: "all" } + }; + + mockContext.promotions = { + stackabilities: [{ key: "all", handler: () => true }] + }; + + const result = await checkShippingStackable(mockContext, shipping, discount); + expect(result).toBe(true); +}); + +test("should returns false if the current discount is not stackable", async () => { + const shipping = { + discounts: [ + { + stackability: { key: "all" } + } + ] + }; + const discount = { + stackability: { key: "none" } + }; + + mockContext.promotions = { + stackabilities: [ + { key: "all", handler: () => true }, + { key: "none", handler: () => false } + ] + }; + + const result = await checkShippingStackable(mockContext, shipping, discount); + expect(result).toBe(false); +}); From 36dd29c191533214e5575bbd4f7271ce5397c7c7 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Thu, 2 Mar 2023 17:55:25 +0700 Subject: [PATCH 19/19] fix: revert promotion starup file Signed-off-by: vanpho93 --- packages/api-plugin-promotions/src/startup.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api-plugin-promotions/src/startup.js b/packages/api-plugin-promotions/src/startup.js index 040d38de6f8..70b7ac9674c 100644 --- a/packages/api-plugin-promotions/src/startup.js +++ b/packages/api-plugin-promotions/src/startup.js @@ -22,7 +22,7 @@ const logCtx = { export default async function startupPromotions(context) { const { bullQueue } = context; await bullQueue.createQueue(context, "setPromotionState", { jobName: "checkForChangedStates" }, setPromotionState(context)); - await bullQueue.scheduleJob(context, "setPromotionState", "checkForChangedStates", {}, "*/1 * * * *"); + await bullQueue.scheduleJob(context, "setPromotionState", "checkForChangedStates", {}, "*/5 * * * *"); Logger.info(logCtx, "Add setPromotionState queue and job"); await bullQueue.createQueue(context, "checkExistingCarts", {}, checkCartForPromotionChange(context)); return true;