hospitality.recipe-cost
Cost a recipe and each portion from ingredient quantities, pack sizes and pack prices, allowing for trim and waste.
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 25 tests, run in TypeScript, Python and Rust.
What it does
Costs a recipe the way a kitchen costing sheet does: what each ingredient costs in the recipe, the total, and the cost of one portion.
For each ingredient:
For example
recipeCost(ingredients ×4, 8, GBP)→ lines ×4, total £3.93, portions 8, per portion £0.49 a batter for 8: grams from a kilo bag, a whole pack, eggs by the dozen, millilitres from a litre bottlerecipeCost(ingredients ×1, 4, GBP)→ lines ×1, total £0.23, portions 4, per portion £0.06 yield grosses up: 400 g peeled at 80% yield means 500 g bought, not 320 grecipeCost(ingredients ×1, 2, GBP)→ lines ×1, total £1.50, portions 2, per portion £0.75 imperial: 8 oz from a 1 lb pack
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
export function recipeCost(ingredients: readonly RecipeIngredient[], portions: number, currency: string): RecipeCost
| ingredients | RecipeIngredient[] | at least one |
| portions | int | how many portions the recipe makes, at least 1 |
| currency | string | the currency every pack price is in |
| returns | RecipeCost |
The types it declares, generated into your project
/** One line of a recipe, and how the ingredient is bought. */
export interface RecipeIngredient {
readonly name: string;
/** usable amount the recipe needs, as decimal text such as "250" */
readonly quantity: string;
/** a units.convert symbol ("g", "kg", "mL", "L", "oz", "lb"...) or "each" */
readonly unit: string;
/** share of what is bought that is usable after trim and waste; 10000 = none lost */
readonly yieldBasisPoints: number;
/** how much one pack holds, as decimal text, at most 6 decimal places */
readonly packSize: string;
/** unit of packSize, the same dimension as unit */
readonly packUnit: string;
/** the price of one pack */
readonly packPrice: Money;
}
/** What one ingredient costs in the recipe. */
export interface RecipeCostLine {
readonly name: string;
readonly cost: Money;
}
/** The recipe's cost, line by line and per portion. */
export interface RecipeCost {
readonly lines: readonly RecipeCostLine[];
/** the lines added up */
readonly total: Money;
readonly portions: number;
/** total / portions, rounded half-up */
readonly perPortion: Money;
}
Your code names it in one line, in the file that uses it
import { recipeCost } from "#fune/hospitality.recipe-cost@^1";
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) };
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add hospitality.recipe-cost
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./hospitality.recipe-cost-1.0.1-typescript.fune, or fetch it from a terminal with fune pull hospitality.recipe-cost@1.0.1:typescript.
The whole function, every language, is one file too: hospitality.recipe-cost-1.0.1.fune, 33,471 bytes, sha256 46e7b8e9d2f1a0c701b9782c76ba36ce439ddd530eb27edc42f0b66d35beea5d. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before hospitality.recipe-cost
after — your function gets the result and the arguments, and returns the final result.
// fune: after hospitality.recipe-cost
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace math.round-div in hospitality.recipe-cost
// fune: replace money.amount in hospitality.recipe-cost
// fune: replace money.sum in hospitality.recipe-cost
// fune: replace units.convert in hospitality.recipe-cost
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show hospitality.recipe-cost --steps.
// fune: step hospitality.recipe-cost after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| a batter for 8: grams from a kilo bag, a whole pack, eggs by the dozen, millilitres from a litre bottle | ingredients ×4, 8, GBP | → | lines ×4, total £3.93, portions 8, per portion £0.49 |
| yield grosses up: 400 g peeled at 80% yield means 500 g bought, not 320 g | ingredients ×1, 4, GBP | → | lines ×1, total £0.23, portions 4, per portion £0.06 |
| imperial: 8 oz from a 1 lb pack | ingredients ×1, 2, GBP | → | lines ×1, total £1.50, portions 2, per portion £0.75 |
| metric recipe, imperial pack: 100 g of a 1 lb pack is 0.220462262 lb | ingredients ×1, 1, GBP | → | lines ×1, total £1.00, portions 1, per portion £1.00 |
| a pinch costs less than a penny and rounds to nothing | ingredients ×1, 1, GBP | → | lines ×1, total £0.00, portions 1, per portion £0.00 |
| saffron by the gram | ingredients ×1, 4, GBP | → | lines ×1, total £3.20, portions 4, per portion £0.80 |
| a zero quantity costs nothing | ingredients ×1, 1, GBP | → | lines ×1, total £0.00, portions 1, per portion £0.00 |
| the portion cost rounds half-up: 0.10 over 4 is 0.025 | ingredients ×1, 4, GBP | → | lines ×1, total £0.10, portions 4, per portion £0.03 |
| a fractional pack size: 0.75 L bottle of wine | ingredients ×1, 1, GBP | → | lines ×1, total £1.80, portions 1, per portion £1.80 |
| half an egg | ingredients ×1, 1, GBP | → | lines ×1, total £0.17, portions 1, per portion £0.17 |
Show the other 15 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| euros | ingredients ×1, 2, EUR | → | lines ×1, total €0.65, portions 2, per portion €0.33 |
| mass priced by volume is an error | ingredients ×1, 1, GBP | → | error: cannot convert g (mass) to L (volume) |
| each against grams is an error | ingredients ×1, 1, GBP | → | error: cannot convert each to g |
| an unknown unit is an error | ingredients ×1, 1, GBP | → | error: unknown unit "cup" |
| a zero yield is an error | ingredients ×1, 1, GBP | → | error: yieldBasisPoints must be from 1 to 10000 |
| a yield over 100% is an error | ingredients ×1, 1, GBP | → | error: yieldBasisPoints must be from 1 to 10000 |
| no portions is an error | ingredients ×4, 0, GBP | → | error: portions must be at least 1 |
| an empty recipe is an error | , 1, GBP | → | error: a recipe needs at least one ingredient |
| a negative quantity is an error | ingredients ×1, 1, GBP | → | error: quantity must be a non-negative decimal |
| a zero pack size is an error | ingredients ×1, 1, GBP | → | error: packSize must be a positive decimal |
| a pack size with 7 decimal places is an error | ingredients ×1, 1, GBP | → | error: packSize must be a positive decimal |
| a negative pack price is an error | ingredients ×1, 1, GBP | → | error: packPrice must not be negative |
| a pack priced in another currency is an error | ingredients ×1, 1, GBP | → | error: currency mismatch |
| a quantity with a trailing newline is an error | ingredients ×1, 1, GBP | → | error: quantity must be a non-negative decimal |
| a pack size with a trailing newline is an error | ingredients ×1, 1, GBP | → | error: packSize must be a positive decimal |
More from the author
1. **Convert** the quantity the recipe needs into the unit the pack is sold in, with `units.convert` (250 g of a 1.5 kg bag is 0.25 kg). Count items use the unit `each`, which only converts to `each` (3 eggs from a tray of 12). 2. **Gross up for yield.** `quantity` is the usable amount the recipe needs after trimming and peeling. `yieldBasisPoints` is the usable share of what is bought: 400 g of peeled carrots at an 8000 (80%) yield means buying 500 g. A common mistake multiplies by the yield instead, costing 320 g. 3. **Price it** as that share of the pack price, in one exact division, rounded half-up to the minor unit.
The total is the sum of the rounded line costs, so the sheet adds up, and the portion cost is the total divided by `portions`, rounded half-up.
## Precision
Quantities and pack sizes are decimal text (`"0.75"`), never floats. The converted quantity is carried to 9 decimal places of the pack unit, a microgram of a kilogram pack, and everything after that is exact integer arithmetic. Pack sizes may have at most 6 decimal places and 15 digits. An ingredient whose cost cannot be held exactly (beyond 2^53 minor units) is an error rather than a rounded guess.
A line under half a penny (a pinch of salt) costs 0. Kitchens that want those counted usually add a "sundries" line as a fixed amount.
## Errors
- Units must be ones `units.convert` knows (`g`, `kg`, `mL`, `L`, `oz`, `lb`, `floz_imp`...) or `each`, and a quantity and its pack must be the same dimension: oil costed in grams but bought by the litre needs a density, which this does not guess. - `yieldBasisPoints` is 1 to 10000, `portions` at least 1, pack prices not negative, and every pack price in `currency`.
1.0.1 fixes Python accepting a trailing newline in quantity and packSize; adds tests.
Files
| Path | Bytes |
|---|---|
| README.md | 2,007 |
| impl/python.py | 4,432 |
| impl/rust.rs | 7,104 |
| impl/typescript.ts | 4,153 |
| vectors.json | 9,733 |