Skip to content

Commit

Permalink
Merge pull request #6769 from reactioncommerce/feat/coupons
Browse files Browse the repository at this point in the history
Coupons feature branch
  • Loading branch information
vanpho93 authored May 19, 2023
2 parents 9789fc9 + e0c0e7c commit b49f72a
Show file tree
Hide file tree
Showing 96 changed files with 3,749 additions and 127 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,10 @@ describe("Promotions", () => {
region: "CA"
};

const removeAllPromotions = async () => {
await testApp.setLoggedInUser(mockAdminAccount);
await testApp.collections.Promotions.remove({});
await testApp.clearLoggedInUser();
const cleanup = async () => {
await testApp.collections.Promotions.deleteMany();
await testApp.collections.Orders.deleteMany();
await testApp.collections.Cart.deleteMany();
};

const createTestPromotion = (overlay = {}) => {
Expand Down Expand Up @@ -253,7 +253,7 @@ describe("Promotions", () => {

describe("when a promotion is applied to an order with fixed promotion", () => {
afterAll(async () => {
await removeAllPromotions();
await cleanup();
});

createTestPromotion();
Expand All @@ -277,7 +277,7 @@ describe("Promotions", () => {

describe("when a promotion is applied to an order percentage discount", () => {
afterAll(async () => {
await removeAllPromotions();
await cleanup();
});

createTestPromotion({
Expand Down Expand Up @@ -321,7 +321,7 @@ describe("Promotions", () => {

describe("when a promotion applied via inclusion criteria", () => {
afterAll(async () => {
await removeAllPromotions();
await cleanup();
});

const triggerParameters = { ...fixedDiscountPromotion.triggers[0].triggerParameters };
Expand Down Expand Up @@ -401,7 +401,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 };
Expand Down Expand Up @@ -442,7 +442,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 };
Expand Down Expand Up @@ -500,7 +500,7 @@ describe("Promotions", () => {

describe("cart shouldn't contains any promotion when qualified promotion is change to disabled", () => {
afterAll(async () => {
await removeAllPromotions();
await cleanup();
});

createTestPromotion();
Expand Down Expand Up @@ -534,13 +534,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({
Expand Down Expand Up @@ -599,7 +599,7 @@ describe("Promotions", () => {

describe("Stackability: shouldn't stack with other promotion when stackability is none", () => {
afterAll(async () => {
await removeAllPromotions();
await cleanup();
});

createTestPromotion();
Expand All @@ -618,6 +618,10 @@ describe("Promotions", () => {
});

describe("Stackability: should applied with other promotions when stackability is all", () => {
afterAll(async () => {
await cleanup();
});

createTestPromotion();
createTestPromotion();
createTestCart({ quantity: 20 });
Expand All @@ -628,4 +632,93 @@ describe("Promotions", () => {
expect(cart.appliedPromotions).toHaveLength(2);
});
});

describe("apply with single shipping promotion", () => {
afterAll(async () => {
await cleanup();
});

createTestPromotion({
actions: [
{
actionKey: "discounts",
actionParameters: {
discountType: "shipping",
discountCalculationType: "percentage",
discountValue: 50
}
}
]
});

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);
});
});

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: "percentage",
discountValue: 10
}
}
]
});

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.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(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.appliedPromotions).toHaveLength(2);
expect(newOrder.discounts).toHaveLength(2);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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`);
});
Expand Down
7 changes: 5 additions & 2 deletions packages/api-plugin-carts/src/xforms/xformCartCheckout.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 || []
};
}

Expand Down
2 changes: 2 additions & 0 deletions packages/api-plugin-orders/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import mutations from "./mutations/index.js";
import policies from "./policies.json";
import preStartup from "./preStartup.js";
import queries from "./queries/index.js";
import { registerPluginHandlerForOrder } from "./registration.js";
import resolvers from "./resolvers/index.js";
import schemas from "./schemas/index.js";
import { Order, OrderFulfillmentGroup, OrderItem, CommonOrder, SelectedFulfillmentOption } from "./simpleSchemas.js";
Expand Down Expand Up @@ -42,6 +43,7 @@ export default async function register(app) {
}
},
functionsByType: {
registerPluginHandler: [registerPluginHandlerForOrder],
getDataForOrderEmail: [getDataForOrderEmail],
preStartup: [preStartup],
startup: [startup]
Expand Down
8 changes: 8 additions & 0 deletions packages/api-plugin-orders/src/mutations/placeOrder.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import getAnonymousAccessToken from "@reactioncommerce/api-utils/getAnonymousAcc
import buildOrderFulfillmentGroupFromInput from "../util/buildOrderFulfillmentGroupFromInput.js";
import verifyPaymentsMatchOrderTotal from "../util/verifyPaymentsMatchOrderTotal.js";
import { Order as OrderSchema, orderInputSchema, Payment as PaymentSchema, paymentInputSchema } from "../simpleSchemas.js";
import { customOrderValidators } from "../registration.js";

const inputSchema = new SimpleSchema({
"order": orderInputSchema,
Expand Down Expand Up @@ -147,6 +148,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, { skipTemporaryPromotions: true });
}


Expand Down Expand Up @@ -286,6 +289,11 @@ export default async function placeOrder(context, input) {

// Validate and save
OrderSchema.validate(order);

for (const customOrderValidateFunc of customOrderValidators) {
await customOrderValidateFunc.fn(context, order); // eslint-disable-line no-await-in-loop
}

await Orders.insertOne(order);

await appEvents.emit("afterOrderCreate", { createdBy: userId, order });
Expand Down
3 changes: 2 additions & 1 deletion packages/api-plugin-orders/src/mutations/placeOrder.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions packages/api-plugin-orders/src/registration.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import SimpleSchema from "simpl-schema";

const validatorSchema = new SimpleSchema({
name: String,
fn: Function
});

// Objects with `name` and `fn` properties
export const customOrderValidators = [];

/**
* @summary Will be called for every plugin
* @param {Object} options The options object that the plugin passed to registerPackage
* @returns {undefined}
*/
export function registerPluginHandlerForOrder({ name, order }) {
if (order) {
const { customValidators } = order;

if (!Array.isArray(customValidators)) throw new Error(`In ${name} plugin registerPlugin object, order.customValidators must be an array`);
validatorSchema.validate(customValidators);

customOrderValidators.push(...customValidators);
}
}
5 changes: 5 additions & 0 deletions packages/api-plugin-orders/src/simpleSchemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -793,6 +793,11 @@ export const SelectedFulfillmentOption = new SimpleSchema({
rate: {
type: Number,
min: 0
},
discount: {
type: Number,
min: 0,
optional: true
}
});

Expand Down
11 changes: 9 additions & 2 deletions packages/api-plugin-orders/src/util/addShipmentMethodToGroup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions packages/api-plugin-promotions-coupons/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import register from "./src/index.js";

export { default as migrations } from "./migrations/index.js";

export default register;
Loading

0 comments on commit b49f72a

Please sign in to comment.