const PRICE_DECIMALS = 2; // Number of decimal places kept in each returned price. /** * Returns a price after a discount. Accepts one price or a range of prices. * * @param {number|Array>} price A price, or a range of prices such as B2:B5. * @param {number} discountRate The discount as a rate from 0 to 1, such as 10% or 0.1. * @return The discounted price, or one discounted price per cell of the range. * @customfunction */ function DISCOUNTED_PRICE(price, discountRate) { const rate = requireRate_(discountRate, 'discountRate'); return mapValueOrRange_(price, 'price', function (amount) { return roundPrice_(amount * (1 - rate)); }); } /** * Returns a price with sales tax added. Accepts one price or a range of prices. * * @param {number|Array>} price A price, or a range of prices such as B2:B5. * @param {number} taxRate The tax as a rate from 0 to 1, such as 8% or 0.08. * @return The price including tax, or one taxed price per cell of the range. * @customfunction */ function PRICE_WITH_TAX(price, taxRate) { const rate = requireRate_(taxRate, 'taxRate'); return mapValueOrRange_(price, 'price', function (amount) { return roundPrice_(amount * (1 + rate)); }); } /** * Logs sample results so you can check the calculations from the editor. * * @returns {void} */ function testPriceFunctions() { Logger.log(DISCOUNTED_PRICE(40, 0.1)); Logger.log(JSON.stringify(DISCOUNTED_PRICE([[40], [12.5], ['']], 0.1))); Logger.log(JSON.stringify(PRICE_WITH_TAX([[36], [11.25]], 0.08))); } /** * Applies a calculation to one value, or to every cell of a two-dimensional range. * Blank cells stay blank so the result has the same shape as the input. * * @param {*} input One cell value, or a two-dimensional array of cell values. * @param {string} argumentName The argument name used in error messages. * @param {function(number): number} calculate The calculation for one number. * @returns {*} One result, or a two-dimensional array of results. */ function mapValueOrRange_(input, argumentName, calculate) { if (!Array.isArray(input)) { return calculateCell_(input, argumentName, calculate, ''); } const rows = /** @type {Array>} */ (input); return rows.map(function (row, rowIndex) { return row.map(function (cell, columnIndex) { const position = ' (range row ' + (rowIndex + 1) + ', column ' + (columnIndex + 1) + ')'; return calculateCell_(cell, argumentName, calculate, position); }); }); } /** * Calculates one cell, keeping a blank cell blank and rejecting text or dates. * * @param {*} value The cell value. * @param {string} argumentName The argument name used in error messages. * @param {function(number): number} calculate The calculation for one number. * @param {string} position Where the value sits in a range, or an empty string. * @returns {number|string} The result, or an empty string for a blank cell. */ function calculateCell_(value, argumentName, calculate, position) { if (value === '' || value === null) { return ''; } if (typeof value !== 'number' || !isFinite(value)) { throw new Error(argumentName + ' must be a number' + position + '. Received: ' + String(value)); } return calculate(value); } /** * Checks that a rate is a single number from 0 to 1. * * @param {*} rate The rate argument from the formula. * @param {string} argumentName The argument name used in error messages. * @returns {number} The checked rate. */ function requireRate_(rate, argumentName) { if (typeof rate !== 'number' || !isFinite(rate) || rate < 0 || rate > 1) { throw new Error(argumentName + ' must be one number from 0 to 1, such as 10% or 0.1.'); } return rate; } /** * Rounds a calculated price to PRICE_DECIMALS decimal places. * * @param {number} amount The unrounded price. * @returns {number} The rounded price. */ function roundPrice_(amount) { const factor = Math.pow(10, PRICE_DECIMALS); return Math.round(amount * factor) / factor; }