Functional Weave
Code in TypeScript

hospitality.recipe-cost@1.0.1

impl/typescript.ts

4,153 bytes · the TypeScript implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import { roundDiv } from "./math_round_div.ts";  ← from math.round-div ^1.0.0 · built alongside by fune
import { money } from "./money_amount.ts";  ← from money.amount ^1.0.0 · built alongside by fune
import { sumMoney } from "./money_sum.ts";  ← from money.sum ^1.0.0 · built alongside by fune
import { convertUnits } from "./units_convert.ts";  ← from units.convert ^1.0.0 · built alongside by fune
import { type RecipeCost, type RecipeCostLine, type RecipeIngredient } from "./hospitality_recipe_cost_types.ts";

const DECIMAL = /^[0-9]+(\.[0-9]+)?$/;
const I128_MAX = (1n << 127n) - 1n;
// Quantities are carried to 9 decimal places of the pack unit: a microgram of
// a kilogram pack, far below anything a kitchen weighs.
const QUANTITY_DECIMALS = 9;

function parseDecimal(text: string): [bigint, number] {
  const [whole, fraction = ""] = text.split(".");
  return [BigInt(whole + fraction), fraction.length];
}

function lineCost(ingredient: RecipeIngredient): number {
  const { name, quantity, unit, yieldBasisPoints, packSize, packUnit, packPrice } = ingredient;
  if (typeof quantity !== "string" || !DECIMAL.test(quantity)) {
    throw new RangeError(`quantity must be a non-negative decimal like "12.5", received "${quantity}" for "${name}"`);
  }
  const [packDigits, packScale] = DECIMAL.test(packSize) ? parseDecimal(packSize) : [0n, 0];
  if (!DECIMAL.test(packSize) || packDigits === 0n || packScale > 6 || packDigits.toString().length > 15) {
    throw new RangeError(`packSize must be a positive decimal with at most 6 decimal places and 15 digits, received "${packSize}" for "${name}"`);
  }
  if (!Number.isInteger(yieldBasisPoints) || yieldBasisPoints < 1 || yieldBasisPoints > 10000) {
    throw new RangeError(`yieldBasisPoints must be from 1 to 10000, received ${yieldBasisPoints} for "${name}"`);
  }
  if (packPrice.minor < 0) throw new RangeError(`packPrice must not be negative, received ${packPrice.minor} for "${name}"`);

  let inPackUnits: string;
  if (unit === "each" || packUnit === "each") {
    if (unit !== packUnit) throw new RangeError(`cannot convert ${unit} to ${packUnit}: "each" only converts to "each"`);
    if (parseDecimal(quantity)[1] > QUANTITY_DECIMALS) {
      throw new RangeError(`quantity may have at most ${QUANTITY_DECIMALS} decimal places, received "${quantity}" for "${name}"`);
    }
    inPackUnits = quantity;
  } else {
    inPackUnits = convertUnits(quantity, unit, packUnit, QUANTITY_DECIMALS);
  }

  // cost = price x (quantity / packSize) / yield, in one exact division.
  const [qtyDigits, qtyScale] = parseDecimal(inPackUnits);
  let numerator = BigInt(packPrice.minor) * qtyDigits * 10000n;
  let denominator = packDigits * BigInt(yieldBasisPoints);
  if (packScale >= qtyScale) numerator *= 10n ** BigInt(packScale - qtyScale);
  else denominator *= 10n ** BigInt(qtyScale - packScale);
  if (numerator > I128_MAX || denominator > I128_MAX) {
    throw new RangeError(`the cost of "${name}" is too large to compute exactly`);
  }
  const rounded = (numerator * 2n + denominator) / (denominator * 2n);
  if (rounded > BigInt(Number.MAX_SAFE_INTEGER)) {
    throw new RangeError(`the cost of "${name}" is too large to compute exactly`);
  }
  return Number(rounded);
}

/**
 * The cost of a recipe, each ingredient and each portion.
 *
 * Each ingredient's quantity is converted to the unit its pack is sold in
 * (250 g of a 1.5 kg bag), grossed up for trim and waste (400 g of peeled
 * carrots needs 500 g bought at an 80% yield), and priced as that share of the
 * pack, rounded half-up to the minor unit. The total is the sum of those line
 * costs, so the costing sheet adds up.
 */
export function recipeCost(ingredients: readonly RecipeIngredient[], portions: number, currency: string): RecipeCost {
  if (ingredients.length === 0) throw new RangeError("a recipe needs at least one ingredient");
  if (!Number.isInteger(portions) || portions < 1) throw new RangeError(`portions must be at least 1, received ${portions}`);
  const lines: RecipeCostLine[] = ingredients.map((ingredient) => ({
    name: ingredient.name,
    cost: money(lineCost(ingredient), ingredient.packPrice.currency),
  }));
  const total = sumMoney(
    lines.map((line) => line.cost),
    currency,
  );
  return { lines, total, portions, perPortion: money(roundDiv(total.minor, portions, "half-up"), currency) };
}