Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: simple-pricing plugin #5014

Merged
merged 10 commits into from
Mar 1, 2019
36 changes: 22 additions & 14 deletions bin/setup
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,37 @@

__dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
env_file=${__dir}/../.env
env_example_file=${__dir}/../.env.example

function main {
function main() {
set -e

add_new_env_vars
}

function add_new_env_vars {
function add_new_env_vars() {
# create .env and set perms if it does not exist
[ ! -f "${env_file}" ] && { touch "${env_file}" ; chmod 0600 "${env_file}" ; }
[[ ! -f "${env_file}" ]] && {
touch "${env_file}"
chmod 0600 "${env_file}"
}

export IFS=$'\n'
for var in $(cat "${env_example_file}"); do
key="${var%%=*}" # get var key
var=$(eval echo "$var") # generate dynamic values
find . imports/plugins/custom -name .env.example -type f -print0 |
xargs -0 grep -Ehv '^\s*#' |
{
while IFS= read -r var; do
if [[ -z "${var}" ]]; then
continue
fi
key="${var%%=*}" # get var key
var=$(eval echo "$var") # generate dynamic values

# If .env doesn't contain this env key, add it
if ! $(grep -qLE "^$key=" "${env_file}"); then
echo "Adding $key to .env"
echo "$var" >> "${env_file}"
fi
done
# If .env doesn't contain this env key, add it
if ! grep -qLE "^$key=" "${env_file}"; then
echo "Adding $key to .env"
echo "$var" >>"${env_file}"
fi
done
}
}

main
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import addCartItemsUtil from "../util/addCartItems";
*/
export default async function addCartItems(context, input, options = {}) {
const { cartId, items, token } = input;
const { appEvents, collections, accountId = null, userId = null } = context;
const { appEvents, collections, queries, accountId = null, userId = null } = context;
const { Cart } = collections;

let selector;
Expand All @@ -43,7 +43,7 @@ export default async function addCartItems(context, input, options = {}) {
incorrectPriceFailures,
minOrderQuantityFailures,
updatedItemList
} = await addCartItemsUtil(collections, cart.items, items, { skipPriceCheck: options.skipPriceCheck });
} = await addCartItemsUtil(collections, queries, cart.items, items, { skipPriceCheck: options.skipPriceCheck });

const updatedAt = new Date();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import addCartItems from "../util/addCartItems";
*/
export default async function createCart(context, input) {
const { items, shopId, shouldCreateWithoutItems = false } = input;
const { appEvents, collections, accountId = null, userId = null } = context;
const { appEvents, collections, queries, accountId = null, userId = null } = context;
const { Cart, Shops } = collections;

if (shouldCreateWithoutItems !== true && (!Array.isArray(items) || !items.length)) {
Expand All @@ -42,7 +42,7 @@ export default async function createCart(context, input) {
incorrectPriceFailures,
minOrderQuantityFailures,
updatedItemList
} = await addCartItems(collections, [], items);
} = await addCartItems(collections, queries, [], items);

// If all input items were invalid, don't create a cart
if (!updatedItemList.length && shouldCreateWithoutItems !== true) {
Expand Down
30 changes: 5 additions & 25 deletions imports/plugins/core/cart/server/no-meteor/startup.js
Original file line number Diff line number Diff line change
@@ -1,35 +1,15 @@
import Logger from "@reactioncommerce/logger";
import updateCartItemsForVariantPriceChange from "./util/updateCartItemsForVariantPriceChange";

const AFTER_CATALOG_UPDATE_EMITTED_BY_NAME = "CART_CORE_PLUGIN_AFTER_CATALOG_UPDATE";

/**
* @param {Object[]} catalogProductVariants The `product.variants` array from a catalog item
* @returns {Object} Map of variant IDs to updated pricing objects
*/
function getVariantPricingMap(catalogProductVariants) {
const variantPricingMap = {};

catalogProductVariants.forEach((variant) => {
variantPricingMap[variant.variantId] = variant.pricing;
if (variant.options) {
variant.options.forEach((option) => {
variantPricingMap[option.variantId] = option.pricing;
});
}
});

return variantPricingMap;
}

/**
* @param {Object} appEvents App event emitter
* @param {Object} Cart Cart collection
* @param {Object} pricing Potentially updated pricing map for the variant
* @param {String} variantId The ID of the variant to update for
* @returns {Promise<null>} Promise that resolves with null
*/
async function updateAllCartsForVariant({ appEvents, Cart, pricing, variantId }) {
async function updateAllCartsForVariant({ appEvents, Cart, pricing, variantId, queries }) {
// Do find + update because we need the `cart.currencyCode` to figure out pricing
// and we need current quantity to recalculate `subtotal` for each item.
// It should be fine to load all results into an array because even for large shops,
Expand All @@ -44,7 +24,7 @@ async function updateAllCartsForVariant({ appEvents, Cart, pricing, variantId })
const prices = pricing[cart.currencyCode];
if (!prices) return;

const { didUpdate, updatedItems } = updateCartItemsForVariantPriceChange(cart.items, variantId, prices);
const { didUpdate, updatedItems } = queries.updateCartItemsForVariantPriceChange(cart.items, variantId, prices);
if (!didUpdate) return;

// Update the cart
Expand Down Expand Up @@ -79,7 +59,7 @@ async function updateAllCartsForVariant({ appEvents, Cart, pricing, variantId })
* @returns {undefined}
*/
export default function startup(context) {
const { appEvents, collections } = context;
const { appEvents, collections, queries } = context;
const { Cart } = collections;

// When an order is created, delete the source cart
Expand All @@ -99,13 +79,13 @@ export default function startup(context) {
const { variants } = catalogProduct;

// Build a map of variant IDs to their potentially-changed prices
const variantPricingMap = getVariantPricingMap(variants);
const variantPricingMap = queries.getVariantPricingMap(variants);
const variantIds = Object.keys(variantPricingMap);

// Update all cart items that are linked with the updated variants
await Promise.all(variantIds.map(async (variantId) => {
const pricing = variantPricingMap[variantId];
return updateAllCartsForVariant({ appEvents, Cart, pricing, variantId });
return updateAllCartsForVariant({ appEvents, Cart, pricing, variantId, queries });
}));
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const inputItemSchema = new SimpleSchema({
* Skipping this is not recommended for new code.
* @return {Object} Object with `incorrectPriceFailures` and `minOrderQuantityFailures` and `updatedItemList` props
*/
export default async function addCartItems(collections, currentItems, inputItems, options = {}) {
export default async function addCartItems(collections, queries, currentItems, inputItems, options = {}) {
inputItemSchema.validate(inputItems);

const incorrectPriceFailures = [];
Expand All @@ -57,7 +57,7 @@ export default async function addCartItems(collections, currentItems, inputItems
variant: chosenVariant
} = await findProductAndVariant(collections, productId, productVariantId);

const variantPriceInfo = chosenVariant.pricing[price.currencyCode];
const variantPriceInfo = queries.getCartPrice(chosenVariant, price);
if (!variantPriceInfo) {
throw new ReactionError("invalid-param", `This product variant does not have a price for ${price.currencyCode}`);
}
Expand Down
53 changes: 53 additions & 0 deletions imports/plugins/included/simple-pricing/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Simple Pricing

Pricing data falls under it's own domain within the Reaction Commerce system, however it currently needs to be intertwined and available from other system domains (i.e., Catalog, Cart, Checkout, Orders). To give us more flexibility in pricing data management we've begun to move pricing get/set functions into this `simple-pricing` plugin and calling these functions from the `context.queries` object from within their respective functions. Now we can fully replace the pricing management system without modification to core by creating a custom plugin that replaces the `simple-pricing` queries.

**Example**

``` js
// old addCartItems() funcitons

...
const {
catalogProduct,
parentVariant,
variant: chosenVariant
} = await findProductAndVariant(collections, productId, productVariantId);

const variantPriceInfo = chosenVariant.pricing[providedPrice.currencyCode];
if (!variantPriceInfo) {
throw new ReactionError("invalid-param", `This product variant does not have a price for ${price.currencyCode}`);
}
...

// new addCartItems() function with simple-pricing
const {
catalogProduct,
parentVariant,
variant: chosenVariant
} = await findProductAndVariant(collections, productId, productVariantId);

const variantPriceInfo = queries.getCartPrice(chosenVariant, price);
if (!variantPriceInfo) {
throw new ReactionError("invalid-param", `This product variant does not have a price for ${price.currencyCode}`);
}
```

## Queries

### Cart Price Queries
**getCartPrice**
This query is used to get a selected product's real price when adding the item to the Cart.

**getCurrentCatalogPriceForProductConfiguration**
This query is used to verify a product's price is correct before we process the order.

**getVariantPricingMap**
This query creates a map of product pricing data keyed by variant ID. This is used to keep cart prices updated with product pricing changes.

**updateCartItemsForVariantPriceChange**
This query will update the cart items with new pricing information if any in cart products change.


### Catalog Price Queries
TBD
23 changes: 23 additions & 0 deletions imports/plugins/included/simple-pricing/register.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Reaction from "/imports/plugins/core/core/server/Reaction";
import queries from "./server/no-meteor/queries";
import startup from "./server/no-meteor/startup";

/**
* Simple Pricing plugin
* Isolates the get/set of pricing data to this plugin.
*/

Reaction.registerPackage({
label: "Pricing",
name: "reaction-pricing",
icon: "fa fa-dollar-sign",
autoEnable: true,
functionsByType: {
startup: [startup]
},
graphQL: {},
queries,
settings: {
name: "Pricing"
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* @method getCartPrice
* @summary This method returns the applicable price and currency code for a selected product.
* @param {Object} chosenVariant - A selected product variant.
* @param {Object} providedPrice - A product variant price provided form the client.
* @return {Object} - A cart item price value.
*/
export default function getCartPrice(chosenVariant, providedPrice) {
return chosenVariant.pricing[providedPrice.currencyCode];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import findProductAndVariant from "/imports/plugins/core/catalog/server/no-meteor/utils/findProductAndVariant";

/**
* @summary Returns the current price in the Catalog for the given product configuration
* @param {Object} productConfiguration The ProductConfiguration object
* @param {String} currencyCode The currency code
* @param {Object} collections Map of MongoDB collections
* @returns {Object} Object with `price` as the current price in the Catalog for the given product configuration.
* Also returns catalogProduct and catalogProductVariant docs in case you need them.
*/
export default async function getCurrentCatalogPriceForProductConfiguration(productConfiguration, currencyCode, collections) {
const { productId, productVariantId } = productConfiguration;
const {
catalogProduct,
variant: catalogProductVariant
} = await findProductAndVariant(collections, productId, productVariantId);

const variantPriceInfo = (catalogProductVariant.pricing && catalogProductVariant.pricing[currencyCode]) || {};
const price = variantPriceInfo.price || catalogProductVariant.price;

return {
catalogProduct,
catalogProductVariant,
price
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* @param {Object[]} catalogProductVariants The `product.variants` array from a catalog item
* @returns {Object} Map of variant IDs to updated pricing objects
*/
export default function getVariantPricingMap(catalogProductVariants) {
const variantPricingMap = {};

catalogProductVariants.forEach((variant) => {
variantPricingMap[variant.variantId] = variant.pricing;
if (variant.options) {
variant.options.forEach((option) => {
variantPricingMap[option.variantId] = option.pricing;
});
}
});

return variantPricingMap;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import getCartPrice from "./getCartPrice";
import getCurrentCatalogPriceForProductConfiguration from "./getCurrentCatalogPriceForProductConfiguration";
import getVariantPricingMap from "./getVariantPricingMap";
import updateCartItemsForVariantPriceChange from "./updateCartItemsForVariantPriceChange";

export default {
getCartPrice,
getCurrentCatalogPriceForProductConfiguration,
getVariantPricingMap,
updateCartItemsForVariantPriceChange
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* @summary Given cart items array, a variant ID, and the prices for that variant,
* returns an updated cart items array. Updates `price`, `compareAtPrice`, and `subtotal`
* as necessary. You need not know for sure whether prices have changed since
* the cart items were last updated. The return object will have a `didUpdate`
* boolean property that you can check to see whether any changes were made.
* @param {Object[]} items Cart items
* @param {String} variantId ID of variant to update items for
* @param {Object} prices Various updated price info for this variant
* @returns {Object} { didUpdate, updatedItems }
*/
export default function updateCartItemsForVariantPriceChange(items, variantId, prices) {
let didUpdate = false;

const updatedItems = items.map((item) => {
if (item.variantId !== variantId) return item;

// If price has changed
if (item.price.amount !== prices.price) {
didUpdate = true;
item.price.amount = prices.price;
item.subtotal.amount = prices.price * item.quantity;
}

// If compareAt price has changed
if (
(prices.compareAtPrice || prices.compareAtPrice === 0) &&
(!item.compareAtPrice || item.compareAtPrice.amount !== prices.compareAtPrice)
) {
didUpdate = true;
item.compareAtPrice = {
amount: prices.compareAtPrice,
currencyCode: item.price.currencyCode
};
}

// If compareAt price has been cleared
if (!prices.compareAtPrice && prices.compareAtPrice !== 0 && item.compareAtPrice) {
didUpdate = true;
item.compareAtPrice = null;
}

return item;
});

return { didUpdate, updatedItems };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
*
* @method startup
* @summary Simple pricing startup function.
* @param {Object} context - App context.
* @return {undefin} - void, no return.
*/
export default function startup() {}