Functional Weave
Code in Python

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

@dataclass(frozen=True)
class MenuGrossProfit:
    """A dish's price split into VAT and net, and the profit on the net."""

    #: VAT included
    menu_price: Money
    vat: Money
    #: menuPrice less VAT
    net_price: Money
    cost: Money
    #: netPrice less cost
    gross_profit: Money
    #: grossProfit / netPrice; null when the net price is 0
    gp_basis_points: Optional[int]
    #: cost / netPrice (food cost %), 10000 - gpBasisPoints
    cost_basis_points: Optional[int]
    vat_rate_basis_points: int

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

menu_gross_profit throws on bad input 10 tests

def 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%
from fune.hospitality.gross_profit_menu import menu_gross_profit  # hospitality.gross-profit-menu@^1
impl/python/menu_gross_profit.py · 30 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.

from .finance_margin import margin  ← from finance.margin ^1.0.0 · built alongside by fune
from .finance_tax_remove_vat import remove_vat  ← from finance.tax.remove-vat ^1.0.0 · built alongside by fune
from .hospitality_gross_profit_menu_types import MenuGrossProfit
from .money_amount import Money  ← from money.amount ^1.0.0 · built alongside by fune


def menu_gross_profit(cost: Money, menu_price: Money, jurisdiction: str, category: str, on_date: str) -> MenuGrossProfit:
    """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.
    """
    if cost.minor < 0:
        raise ValueError("cost must not be negative, received %d" % (cost.minor,))
    if menu_price.minor < 0:
        raise ValueError("menuPrice must not be negative, received %d" % (menu_price.minor,))
    vat = remove_vat(menu_price, jurisdiction, category, on_date)
    breakdown = margin(cost, vat.net)
    gp = breakdown.margin_basis_points
    return MenuGrossProfit(
        menu_price=menu_price,
        vat=vat.tax,
        net_price=vat.net,
        cost=cost,
        gross_profit=breakdown.profit,
        gp_basis_points=gp,
        cost_basis_points=None if gp is None else 10000 - gp,
        vat_rate_basis_points=vat.basis_points,
    )

menu_price_for_gp throws on bad input 10 tests

def menu_price_for_gp(cost: Money, target_gp_basis_points: int, price_step: int, 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
from fune.hospitality.gross_profit_menu import menu_price_for_gp  # hospitality.gross-profit-menu@^1
impl/python/menu_price_for_gp.py · 34 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.

from .finance_markup_price import markup_price  ← from finance.markup-price ^1.0.0 · built alongside by fune
from .finance_tax_add_vat import add_vat  ← from finance.tax.add-vat ^1.0.0 · built alongside by fune
from .hospitality_gross_profit_menu_menu_gross_profit import menu_gross_profit  ← menuGrossProfit, another function of this group · built into the same file, even by a slim install
from .hospitality_gross_profit_menu_types import MenuGrossProfit
from .math_round_div import round_div  ← from math.round-div ^1.0.0 · built alongside by fune
from .money_amount import Money, money  ← from money.amount ^1.0.0 · built alongside by fune


def menu_price_for_gp(
    cost: Money,
    target_gp_basis_points: int,
    price_step: int,
    jurisdiction: str,
    category: str,
    on_date: str,
) -> MenuGrossProfit:
    """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.
    """
    t = target_gp_basis_points
    if isinstance(t, bool) or not isinstance(t, int) or t < 0 or t > 9999:
        raise ValueError("targetGpBasisPoints must be a whole number from 0 to 9999, received %s" % (t,))
    if isinstance(price_step, bool) or not isinstance(price_step, int) or price_step < 1:
        raise ValueError("priceStep must be at least 1, received %s" % (price_step,))
    if cost.minor < 0:
        raise ValueError("cost must not be negative, received %d" % (cost.minor,))
    net = markup_price(cost, t, "margin", "up")
    gross = add_vat(net, jurisdiction, category, on_date).gross
    menu_price = money(round_div(gross.minor, price_step, "up") * price_step, gross.currency)
    return menu_gross_profit(cost, menu_price, jurisdiction, category, on_date)

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 6 dependencies, pins them in fune.lock, downloads only the Python 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 Python hospitality.gross-profit-menu-1.0.0-python.fune · 19,433 bytes sha256 eb28341ea7a52dbac38201b624d5d6d2b158ebbdc875a7f2b8975d86186bc66c

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

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