Functional Weave
Code in Rust

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. menu_gross_profit (cost: Money, menuPrice: Money, jurisdiction: string, category: string, onDate: date) -> MenuGrossProfit
  2. menu_price_for_gp (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.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MenuGrossProfit {
    /// VAT included
    pub menu_price: Money,
    pub vat: Money,
    /// menuPrice less VAT
    pub net_price: Money,
    pub cost: Money,
    /// netPrice less cost
    pub gross_profit: Money,
    /// grossProfit / netPrice; null when the net price is 0
    pub gp_basis_points: Option<i64>,
    /// cost / netPrice (food cost %), 10000 - gpBasisPoints
    pub cost_basis_points: Option<i64>,
    pub vat_rate_basis_points: i64,
}

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

menu_gross_profit throws on bad input 10 tests

pub fn menu_gross_profit(cost: &Money, menu_price: &Money, jurisdiction: &str, category: &str, on_date: &str) -> MenuGrossProfit
costMoneythe dish's cost to make, such as hospitality.recipe-cost's perPortion
menu_priceMoneythe 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
on_datedatethe date of sale, which decides the VAT rate
returnsMenuGrossProfit

For example

  • menu_gross_profit(£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
  • menu_gross_profit(£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
  • menu_gross_profit(£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%
fune!(hospitality.gross-profit-menu@^1);  // then call menu_gross_profit(…)
impl/rust/menu_gross_profit.rs · 64 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.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::finance_margin::margin;  ← from finance.margin ^1.0.0 · built alongside by fune
use super::finance_tax_remove_vat::remove_vat;  ← from finance.tax.remove-vat ^1.0.0 · built alongside by fune
use super::money_amount::{money_from_value, money_to_value, Money};  ← from money.amount ^1.0.0 · built alongside by fune

/// 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.
///
/// # Panics
/// Panics on a negative cost or price, mixed currencies, or no VAT rule.
pub fn menu_gross_profit(cost: &Money, menu_price: &Money, jurisdiction: &str, category: &str, on_date: &str) -> MenuGrossProfit {
    if cost.minor < 0 {
        panic!("cost must not be negative, received {}", cost.minor);
    }
    if menu_price.minor < 0 {
        panic!("menuPrice must not be negative, received {}", menu_price.minor);
    }
    let vat = remove_vat(menu_price, jurisdiction, category, on_date);
    let breakdown = margin(cost, &vat.net);
    let gp = breakdown.margin_basis_points;
    MenuGrossProfit {
        menu_price: menu_price.clone(),
        vat: vat.tax.clone(),
        net_price: vat.net.clone(),
        cost: cost.clone(),
        gross_profit: breakdown.profit,
        gp_basis_points: gp,
        cost_basis_points: gp.map(|g| 10000 - g),
        vat_rate_basis_points: vat.basis_points,
    }
}

fn opt_int(v: Option<i64>) -> Value {
    match v {
        Some(i) => Value::Int(i),
        None => Value::Null,
    }
}

pub fn menu_gross_profit_to_value(g: &MenuGrossProfit) -> Value {
    Value::obj(vec![
        ("menuPrice", money_to_value(&g.menu_price)),
        ("vat", money_to_value(&g.vat)),
        ("netPrice", money_to_value(&g.net_price)),
        ("cost", money_to_value(&g.cost)),
        ("grossProfit", money_to_value(&g.gross_profit)),
        ("gpBasisPoints", opt_int(g.gp_basis_points)),
        ("costBasisPoints", opt_int(g.cost_basis_points)),
        ("vatRateBasisPoints", Value::Int(g.vat_rate_basis_points)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    menu_gross_profit_to_value(&menu_gross_profit(
        &money_from_value(&args[0]),
        &money_from_value(&args[1]),
        args[2].as_str(),
        args[3].as_str(),
        args[4].as_str(),
    ))
}

menu_price_for_gp throws on bad input 10 tests

pub fn menu_price_for_gp(cost: &Money, target_gp_basis_points: i64, price_step: i64, jurisdiction: &str, category: &str, on_date: &str) -> MenuGrossProfit
costMoney
target_gp_basis_pointsintthe GP wanted on the VAT-exclusive price, 0 to 9999; 7000 = 70%
price_stepintround the menu price up to a multiple of this many minor units; 1 for none, 50 for 50p
jurisdictionstring
categorystring
on_datedate
returnsMenuGrossProfitthe lowest menu price on the step that reaches the target, and its GP

For example

  • menu_price_for_gp(£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%)
  • menu_price_for_gp(£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
  • menu_price_for_gp(£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
fune!(hospitality.gross-profit-menu@^1);  // then call menu_price_for_gp(…)
impl/rust/menu_price_for_gp.rs · 53 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.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::finance_markup_price::markup_price;  ← from finance.markup-price ^1.0.0 · built alongside by fune
use super::finance_tax_add_vat::add_vat;  ← from finance.tax.add-vat ^1.0.0 · built alongside by fune
use super::hospitality_gross_profit_menu_menu_gross_profit::{menu_gross_profit, menu_gross_profit_to_value};  ← menuGrossProfit, another function of this group · built into the same file, even by a slim install
use super::math_round_div::round_div;  ← from math.round-div ^1.0.0 · built alongside by fune
use super::money_amount::{money, money_from_value, Money};  ← from money.amount ^1.0.0 · built alongside by fune

/// 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.
///
/// # Panics
/// Panics on a target outside 0 to 9999, a step below 1, a negative cost, or
/// no VAT rule.
pub fn menu_price_for_gp(
    cost: &Money,
    target_gp_basis_points: i64,
    price_step: i64,
    jurisdiction: &str,
    category: &str,
    on_date: &str,
) -> MenuGrossProfit {
    if !(0..=9999).contains(&target_gp_basis_points) {
        panic!(
            "targetGpBasisPoints must be a whole number from 0 to 9999, received {}",
            target_gp_basis_points
        );
    }
    if price_step < 1 {
        panic!("priceStep must be at least 1, received {}", price_step);
    }
    if cost.minor < 0 {
        panic!("cost must not be negative, received {}", cost.minor);
    }
    let net = markup_price(cost, target_gp_basis_points, "margin", "up");
    let gross = add_vat(&net, jurisdiction, category, on_date).gross;
    let menu_price = money(round_div(gross.minor, price_step, "up") * price_step, &gross.currency);
    menu_gross_profit(cost, &menu_price, jurisdiction, category, on_date)
}

pub fn fune_vector(args: &[Value]) -> Value {
    menu_gross_profit_to_value(&menu_price_for_gp(
        &money_from_value(&args[0]),
        args[1].as_i64(),
        args[2].as_i64(),
        args[3].as_str(),
        args[4].as_str(),
        args[5].as_str(),
    ))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 6 dependencies, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. 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 Rust hospitality.gross-profit-menu-1.0.0-rust.fune · 20,862 bytes sha256 987010171b4c189792283be6ca415ac3ae5e348b3518ad9f4e9bdbfd736cee23

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

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