Functional Weave
Code in TypeScript

hospitality.gross-profit-menu

Menu gross profit (GP%) on the VAT-exclusive price, and the menu price that reaches a target GP.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 20 tests, run in TypeScript, Python and Rust.menuGrossProfit 10 · menuPriceForGp 10

What it does

Two functions for pricing a menu:

- `menuGrossProfit(cost, menuPrice, ...)`: the GP of a dish at its menu price. The VAT-inclusive price is split into VAT and net with `finance.tax.remove-vat`, and the GP and food cost are worked out on the net price with `finance.margin`. - `menuPriceForGp(cost, target, priceStep, ...)`: the lowest menu price that reaches a target GP, rounded up to a price step (50 for the next 50p), and the GP it actually makes.

The functions

A group: 2 functions that work together, each in its own file, each pinned by its own tests in TypeScript, Python and Rust. A project can install only the ones it calls.

  1. menuGrossProfit (cost: Money, menuPrice: Money, jurisdiction: string, category: string, onDate: date) -> MenuGrossProfit
  2. menuPriceForGp (cost: Money, targetGpBasisPoints: int, priceStep: int, jurisdiction: string, category: string, onDate: date) -> MenuGrossProfit

The type it declares, generated into your project

/** A dish's price split into VAT and net, and the profit on the net. */
export interface MenuGrossProfit {
  /** VAT included */
  readonly menuPrice: Money;
  readonly vat: Money;
  /** menuPrice less VAT */
  readonly netPrice: Money;
  readonly cost: Money;
  /** netPrice less cost */
  readonly grossProfit: Money;
  /** grossProfit / netPrice; null when the net price is 0 */
  readonly gpBasisPoints: number | null;
  /** cost / netPrice (food cost %), 10000 - gpBasisPoints */
  readonly costBasisPoints: number | null;
  readonly vatRateBasisPoints: number;
}

Once installed, your code imports each one from the group's module.

menuGrossProfit throws on bad input 10 tests

export function menuGrossProfit(cost: Money, menuPrice: Money, jurisdiction: string, category: string, onDate: string): MenuGrossProfit
costMoneythe dish's cost to make, such as hospitality.recipe-cost's perPortion
menuPriceMoneythe price on the menu, VAT included
jurisdictionstringISO 3166-1 alpha-2, as finance.tax.vat-rate knows it
categorystringthe dish's VAT category: HOSPITALITY for food eaten in, ZERO for cold takeaway
onDatedatethe date of sale, which decides the VAT rate
returnsMenuGrossProfit

For example

  • menuGrossProfit(£3.50, £14.50, GB, HOSPITALITY, 2026-09-23) → menu price £14.50, vat £2.42, net price £12.08, cost £3.50, gross profit £8.58, gp basis points 71.03%, cost basis points 28.97%, vat rate basis points 20% a 14.50 dish costing 3.50 at 20% VAT: GP on the net price is 71.03%, not the 75.86% on the menu price
  • menuGrossProfit(£1.20, £4.50, GB, ZERO, 2026-09-23) → menu price £4.50, vat £0.00, net price £4.50, cost £1.20, gross profit £3.30, gp basis points 73.33%, cost basis points 26.67%, vat rate basis points 0% cold takeaway food is zero-rated, so all of the price is net
  • menuGrossProfit(£3.50, £14.50, GB, HOSPITALITY, 2021-01-15) → menu price £14.50, vat £0.69, net price £13.81, cost £3.50, gross profit £10.31, gp basis points 74.66%, cost basis points 25.34%, vat rate basis points 5% the same dish during the 2021 hospitality VAT cut to 5%
import { menuGrossProfit } from "#fune/hospitality.gross-profit-menu@^1";
impl/typescript/menu_gross_profit.ts · 29 lines · open · raw

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

import { margin } from "./finance_margin.ts";  ← from finance.margin ^1.0.0 · built alongside by fune
import { removeVat } from "./finance_tax_remove_vat.ts";  ← from finance.tax.remove-vat ^1.0.0 · built alongside by fune
import { type Money } from "./money_amount.ts";  ← from money.amount ^1.0.0 · built alongside by fune
import { type MenuGrossProfit } from "./hospitality_gross_profit_menu_types.ts";

/**
 * Gross profit on a menu price.
 *
 * Hospitality GP is worked out on the price without VAT, because the VAT is
 * the tax authority's money, not the kitchen's: a 14.50 dish costing 3.50 at
 * 20% VAT is a 71.03% GP, not the 75.86% the VAT-inclusive price suggests.
 */
export function menuGrossProfit(cost: Money, menuPrice: Money, jurisdiction: string, category: string, onDate: string): MenuGrossProfit {
  if (cost.minor < 0) throw new RangeError(`cost must not be negative, received ${cost.minor}`);
  if (menuPrice.minor < 0) throw new RangeError(`menuPrice must not be negative, received ${menuPrice.minor}`);
  const vat = removeVat(menuPrice, jurisdiction, category, onDate);
  const breakdown = margin(cost, vat.net);
  const gp = breakdown.marginBasisPoints;
  return {
    menuPrice,
    vat: vat.tax,
    netPrice: vat.net,
    cost,
    grossProfit: breakdown.profit,
    gpBasisPoints: gp,
    costBasisPoints: gp === null ? null : 10000 - gp,
    vatRateBasisPoints: vat.basisPoints,
  };
}

menuPriceForGp throws on bad input 10 tests

export function menuPriceForGp(cost: Money, targetGpBasisPoints: number, priceStep: number, jurisdiction: string, category: string, onDate: string): MenuGrossProfit
costMoney
targetGpBasisPointsintthe GP wanted on the VAT-exclusive price, 0 to 9999; 7000 = 70%
priceStepintround the menu price up to a multiple of this many minor units; 1 for none, 50 for 50p
jurisdictionstring
categorystring
onDatedate
returnsMenuGrossProfitthe lowest menu price on the step that reaches the target, and its GP

For example

  • menuPriceForGp(£3.50, 70%, 1, GB, HOSPITALITY, 2026-09-23) → menu price £14.00, vat £2.33, net price £11.67, cost £3.50, gross profit £8.17, gp basis points 70.01%, cost basis points 29.99%, vat rate basis points 20% 70% GP on a 3.50 cost: 11.67 net, 14.00 on the menu (3.50 / 0.3 = 11.67 on the menu would be 64%)
  • menuPriceForGp(£4.00, 70%, 50, GB, HOSPITALITY, 2026-09-23) → menu price £16.50, vat £2.75, net price £13.75, cost £4.00, gross profit £9.75, gp basis points 70.91%, cost basis points 29.09%, vat rate basis points 20% rounded up to the next 50p
  • menuPriceForGp(£4.00, 70%, 100, GB, HOSPITALITY, 2026-09-23) → menu price £17.00, vat £2.83, net price £14.17, cost £4.00, gross profit £10.17, gp basis points 71.77%, cost basis points 28.23%, vat rate basis points 20% rounded up to the next pound
import { menuPriceForGp } from "#fune/hospitality.gross-profit-menu@^1";
impl/typescript/menu_price_for_gp.ts · 33 lines · open · 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 { type Money, money } from "./money_amount.ts";  ← from money.amount ^1.0.0 · built alongside by fune
import { markupPrice } from "./finance_markup_price.ts";  ← from finance.markup-price ^1.0.0 · built alongside by fune
import { addVat } from "./finance_tax_add_vat.ts";  ← from finance.tax.add-vat ^1.0.0 · built alongside by fune
import { menuGrossProfit } from "./hospitality_gross_profit_menu_menu_gross_profit.ts";  ← menuGrossProfit, another function of this group · built into the same file, even by a slim install
import { type MenuGrossProfit } from "./hospitality_gross_profit_menu_types.ts";

/**
 * The menu price that reaches a target GP.
 *
 * The net price is the smallest that makes the target on the VAT-exclusive
 * price (finance.markup-price, rounded up), VAT goes on top, and the menu
 * price is rounded up to the step a menu is printed in. Rounding only ever
 * goes up, so the GP returned is never below the target.
 */
export function menuPriceForGp(
  cost: Money,
  targetGpBasisPoints: number,
  priceStep: number,
  jurisdiction: string,
  category: string,
  onDate: string,
): MenuGrossProfit {
  if (!Number.isInteger(targetGpBasisPoints) || targetGpBasisPoints < 0 || targetGpBasisPoints > 9999) {
    throw new RangeError(`targetGpBasisPoints must be a whole number from 0 to 9999, received ${targetGpBasisPoints}`);
  }
  if (!Number.isInteger(priceStep) || priceStep < 1) throw new RangeError(`priceStep must be at least 1, received ${priceStep}`);
  if (cost.minor < 0) throw new RangeError(`cost must not be negative, received ${cost.minor}`);
  const net = markupPrice(cost, targetGpBasisPoints, "margin", "up");
  const gross = addVat(net, jurisdiction, category, onDate).gross;
  const menuPrice = money(roundDiv(gross.minor, priceStep, "up") * priceStep, gross.currency);
  return menuGrossProfit(cost, menuPrice, jurisdiction, category, onDate);
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 6 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.gross-profit-menu

That builds the whole group. To build only what you call, and whatever it uses inside the group:

fune add hospitality.gross-profit-menu --only menuGrossProfit
Download for TypeScript hospitality.gross-profit-menu-1.0.0-typescript.fune · 19,477 bytes sha256 447aaf91f53b7fcf362a29c6badbd5549099b6340eaf744ae93ca85ca68a5da9

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./hospitality.gross-profit-menu-1.0.0-typescript.fune, or fetch it from a terminal with fune pull hospitality.gross-profit-menu@1.0.0:typescript.

The whole function, every language, is one file too: hospitality.gross-profit-menu-1.0.0.fune, 26,994 bytes, sha256 f5fa6251ed00c56e15f51284d89af8438562aa2a63cb42e36a4dffcb17920e3b. 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.gross-profit-menu.menuGrossProfit
// fune: before hospitality.gross-profit-menu.menuPriceForGp

after — your function gets the result and the arguments, and returns the final result.

// fune: after hospitality.gross-profit-menu.menuGrossProfit
// fune: after hospitality.gross-profit-menu.menuPriceForGp

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 finance.margin in hospitality.gross-profit-menu
// fune: replace finance.markup-price in hospitality.gross-profit-menu
// fune: replace finance.tax.add-vat in hospitality.gross-profit-menu
// fune: replace finance.tax.remove-vat in hospitality.gross-profit-menu
// fune: replace math.round-div in hospitality.gross-profit-menu
// fune: replace money.amount in hospitality.gross-profit-menu

step — your function runs at a numbered point inside a function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show hospitality.gross-profit-menu --steps.

// fune: step hospitality.gross-profit-menu.<fn> 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.

menuGrossProfit 10 tests

CaseArgumentsExpected
a 14.50 dish costing 3.50 at 20% VAT: GP on the net price is 71.03%, not the 75.86% on the menu price £3.50, £14.50, GB, HOSPITALITY, 2026-09-23 → menu price £14.50, vat £2.42, net price £12.08, cost £3.50, gross profit £8.58, gp basis points 71.03%, cost basis points 28.97%, vat rate basis points 20%
cold takeaway food is zero-rated, so all of the price is net £1.20, £4.50, GB, ZERO, 2026-09-23 → menu price £4.50, vat £0.00, net price £4.50, cost £1.20, gross profit £3.30, gp basis points 73.33%, cost basis points 26.67%, vat rate basis points 0%
the same dish during the 2021 hospitality VAT cut to 5% £3.50, £14.50, GB, HOSPITALITY, 2021-01-15 → menu price £14.50, vat £0.69, net price £13.81, cost £3.50, gross profit £10.31, gp basis points 74.66%, cost basis points 25.34%, vat rate basis points 5%
Irish tourism and hospitality rate of 13.5% €4.00, €18.00, IE, HOSPITALITY, 2026-09-23 → menu price €18.00, vat €2.14, net price €15.86, cost €4.00, gross profit €11.86, gp basis points 74.78%, cost basis points 25.22%, vat rate basis points 13.5%
a dish sold at a loss has a negative GP and a food cost over 100% £15.00, £12.00, GB, HOSPITALITY, 2026-09-23 → menu price £12.00, vat £2.00, net price £10.00, cost £15.00, gross profit -£5.00, gp basis points -50%, cost basis points 150%, vat rate basis points 20%
a free dish has no GP percentage £3.50, £0.00, GB, HOSPITALITY, 2026-09-23 → menu price £0.00, vat £0.00, net price £0.00, cost £3.50, gross profit -£3.50, gp basis points —, cost basis points —, vat rate basis points 20%
a negative cost is an error -£0.01, £14.50, GB, HOSPITALITY, 2026-09-23 → error: cost must not be negative
a negative price is an error £3.50, -£14.50, GB, HOSPITALITY, 2026-09-23 → error: menuPrice must not be negative
cost and price in different currencies is an error €3.50, £14.50, GB, HOSPITALITY, 2026-09-23 → error: currency mismatch
an unknown VAT category is an error £3.50, £14.50, GB, FOOD, 2026-09-23 → error: no VAT rule for GB/FOOD on 2026-09-23

menuPriceForGp 10 tests

CaseArgumentsExpected
70% GP on a 3.50 cost: 11.67 net, 14.00 on the menu (3.50 / 0.3 = 11.67 on the menu would be 64%) £3.50, 70%, 1, GB, HOSPITALITY, 2026-09-23 → menu price £14.00, vat £2.33, net price £11.67, cost £3.50, gross profit £8.17, gp basis points 70.01%, cost basis points 29.99%, vat rate basis points 20%
rounded up to the next 50p £4.00, 70%, 50, GB, HOSPITALITY, 2026-09-23 → menu price £16.50, vat £2.75, net price £13.75, cost £4.00, gross profit £9.75, gp basis points 70.91%, cost basis points 29.09%, vat rate basis points 20%
rounded up to the next pound £4.00, 70%, 100, GB, HOSPITALITY, 2026-09-23 → menu price £17.00, vat £2.83, net price £14.17, cost £4.00, gross profit £10.17, gp basis points 71.77%, cost basis points 28.23%, vat rate basis points 20%
a 0% target sells at cost plus VAT £5.00, 0%, 1, GB, HOSPITALITY, 2026-09-23 → menu price £6.00, vat £1.00, net price £5.00, cost £5.00, gross profit £0.00, gp basis points 0%, cost basis points 100%, vat rate basis points 20%
zero-rated takeaway to 65% GP, on a 5p step £1.00, 65%, 5, GB, ZERO, 2026-09-23 → menu price £2.90, vat £0.00, net price £2.90, cost £1.00, gross profit £1.90, gp basis points 65.52%, cost basis points 34.48%, vat rate basis points 0%
a dish that costs nothing is priced at nothing £0.00, 70%, 1, GB, HOSPITALITY, 2026-09-23 → menu price £0.00, vat £0.00, net price £0.00, cost £0.00, gross profit £0.00, gp basis points —, cost basis points —, vat rate basis points 20%
a 100% target is an error £3.50, 100%, 1, GB, HOSPITALITY, 2026-09-23 → error: targetGpBasisPoints must be a whole number from 0 to 9999
a negative target is an error £3.50, -0.01%, 1, GB, HOSPITALITY, 2026-09-23 → error: targetGpBasisPoints must be a whole number from 0 to 9999
a zero price step is an error £3.50, 70%, 0, GB, HOSPITALITY, 2026-09-23 → error: priceStep must be at least 1
a negative cost is an error -£3.50, 70%, 1, GB, HOSPITALITY, 2026-09-23 → error: cost must not be negative

More from the author

They are a group because they are two directions of one calculation. `menuPriceForGp` answers with `menuGrossProfit`'s breakdown of the price it chose.

## GP is on the price without VAT

In UK hospitality, GP (gross profit percentage) is `(net price - cost) / net price`, where the net price excludes VAT. The VAT is collected for HMRC, not earned. A 14.50 dish costing 3.50 at 20% VAT has a net price of 12.08 and a GP of 71.03%. Working on the menu price gives 75.86%, which overstates it by five points. The food cost percentage is `10000 - gpBasisPoints`, so the two always add up to exactly 100%.

## The VAT rate is looked up, not assumed

The VAT rate comes from `finance.tax.vat-rate` for the jurisdiction, category and date of sale. Food eaten on the premises is `HOSPITALITY` (20% in the UK since April 2022, 5% and then 12.5% during the pandemic relief of 2020 to 2022). Cold takeaway food is usually `ZERO`. Which category a dish falls in under VAT Notice 709/1 is the caller's decision.

## Pricing for a target

1. The net price is the smallest that reaches the target on the net (`finance.markup-price` with basis `margin`, rounded up). 2. VAT is added with `finance.tax.add-vat`. 3. The menu price is rounded **up** to a multiple of `priceStep`.

Every rounding step goes up, so the GP returned is never below the target. It is usually a little above it, and the result shows by how much. A common mistake is `cost / (1 - target)` on the menu price: 3.50 / 0.3 = 11.67 on the menu is only a 64% GP once VAT is taken out.

## Errors

A negative cost or price, cost and price in different currencies, a target outside 0 to 9999, a step below 1, or no VAT rule for the category and date.

Files

PathBytes
README.md2,202
impl/python/menu_gross_profit.py1,264
impl/python/menu_price_for_gp.py1,613
impl/rust/menu_gross_profit.rs2,292
impl/rust/menu_price_for_gp.rs1,963
impl/typescript/menu_gross_profit.ts1,245
impl/typescript/menu_price_for_gp.ts1,668
vectors.json7,951