retail.price-per-unit
Unit price for shelf-edge labels: price per kg, per 100 g, per litre, per 100 ml, per metre or per item.
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 27 tests, run in TypeScript, Python and Rust.
What it does
The unit price on a shelf-edge label: what one kilogram, one litre, one metre or one item of the product costs, so shoppers can compare packs of different sizes. `unitPrice(£1.50, "500", "g", "kg")` is £3.00.
## Which unit (UK)
For example
unitPrice(£1.50, 500, g, kg)→ £3.00 500 g for 1.50 is 3.00 per kgunitPrice(£1.50, 500, g, 100g)→ £0.30 500 g for 1.50 is 30p per 100 gunitPrice(£2.00, 1.5, L, L)→ £1.33 1.5 L for 2.00 is 1.33 per litre
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 unitPrice(price: Money, quantity: string, unit: string, per: UnitPriceBasis): Money
| price | Money | selling price of the pack, VAT included, 0 or more |
| quantity | string | pack quantity as decimal text: "500", "1.5", "12" |
| unit | string | a units.convert symbol ("g", "kg", "mL", "L", "oz", "m", "m2") or "item" |
| per | UnitPriceBasis | the unit the price is quoted per |
| returns | Money | price for one of `per`, rounded half up to the minor unit |
The type it declares, generated into your project
export type UnitPriceBasis = "kg" | "100g" | "10g" | "L" | "100mL" | "10mL" | "75cL" | "m" | "m2" | "m3" | "item";
Your code names it in one line, in the file that uses it
import { unitPrice } from "#fune/retail.price-per-unit@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { type Money, money } from "./money_amount.ts"; ← from money.amount ^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 UnitPriceBasis } from "./retail_price_per_unit_types.ts";
// Each basis is a units.convert symbol and how many of it the price is for.
const BASES: Record<string, [string, number]> = {
kg: ["kg", 1],
"100g": ["g", 100],
"10g": ["g", 10],
L: ["L", 1],
"100mL": ["mL", 100],
"10mL": ["mL", 10],
"75cL": ["mL", 750],
m: ["m", 1],
m2: ["m2", 1],
m3: ["m3", 1],
item: ["item", 1],
};
const DECIMAL = /^[0-9]+(\.[0-9]+)?$/;
/**
* The price of one `per` of the product, rounded half up to the minor unit.
*
* The quantity is converted exactly (units.convert, 12 decimal places), then
* price x multiplier / quantity is done in integers, so the one rounding step
* is the last one.
*/
export function unitPrice(price: Money, quantity: string, unit: string, per: UnitPriceBasis): Money {
if (price.minor < 0) throw new RangeError(`price must not be negative, received ${price.minor}`);
const basis = BASES[per];
if (basis === undefined) throw new RangeError(`unknown unit price basis "${per}"`);
const [target, multiplier] = basis;
let converted: string;
if (target === "item" || unit === "item") {
if (target !== "item") throw new RangeError(`a quantity in items can only be priced per "item"`);
if (unit !== "item") throw new RangeError(`per "item" needs the quantity in "item"`);
if (!DECIMAL.test(quantity)) throw new RangeError(`quantity must be more than 0, received "${quantity}"`);
converted = quantity;
} else {
converted = convertUnits(quantity, unit, target, 12);
}
if (converted.startsWith("-") || !/[1-9]/.test(converted)) {
throw new RangeError(`quantity must be more than 0, received "${quantity}"`);
}
// converted = digits / 10^places, so price / converted = price x 10^places / digits.
const [whole, fraction = ""] = converted.split(".");
const digits = BigInt(whole + fraction);
const numerator = BigInt(price.minor) * BigInt(multiplier) * 10n ** BigInt(fraction.length);
let quotient = numerator / digits;
if ((numerator % digits) * 2n >= digits) quotient += 1n;
return money(Number(quotient), price.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 2 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 retail.price-per-unit
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./retail.price-per-unit-1.0.1-typescript.fune, or fetch it from a terminal with fune pull retail.price-per-unit@1.0.1:typescript.
The whole function, every language, is one file too: retail.price-per-unit-1.0.1.fune, 18,495 bytes, sha256 bb9baca1fabbefbfb0d311fb8f46242ae294c3a78f05a541398b361ce3e780e5. 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 retail.price-per-unit
after — your function gets the result and the arguments, and returns the final result.
// fune: after retail.price-per-unit
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 money.amount in retail.price-per-unit
// fune: replace units.convert in retail.price-per-unit
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 retail.price-per-unit --steps.
// fune: step retail.price-per-unit 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 | |
|---|---|---|---|
| 500 g for 1.50 is 3.00 per kg | £1.50, 500, g, kg | → | £3.00 |
| 500 g for 1.50 is 30p per 100 g | £1.50, 500, g, 100g | → | £0.30 |
| 1.5 L for 2.00 is 1.33 per litre | £2.00, 1.5, L, L | → | £1.33 |
| 1.5 L for 2.00 is 13p per 100 ml | £2.00, 1.5, L, 100mL | → | £0.13 |
| a 330 ml can for 85p is 2.58 per litre | £0.85, 330, mL, L | → | £2.58 |
| 454 g for 3.49 is 7.69 per kg | £3.49, 454, g, kg | → | £7.69 |
| one pound for 4.00 is 8.82 per kg, exactly, where a float division can drift | £4.00, 1, lb, kg | → | £8.82 |
| 8 oz for 2.50 is 1.10 per 100 g (8 oz = 226.796185 g exactly) | £2.50, 8, oz, 100g | → | £1.10 |
| a 38 g jar of spice for 1.20 is 32p per 10 g | £1.20, 38, g, 10g | → | £0.32 |
| a 750 ml bottle of wine for 9.00 is 9.00 per 75 cl | £9.00, 750, mL, 75cL | → | £9.00 |
Show the other 17 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a magnum for 6.00 is 3.00 per 75 cl | £6.00, 1.5, L, 75cL | → | £3.00 |
| a dozen eggs for 3.00 is 25p each | £3.00, 12, item, item | → | £0.25 |
| 6 rolls for 1.00 is 16.67p, shown as 17p | £1.00, 6, item, item | → | £0.17 |
| an exact half penny rounds up, not to even | £0.25, 2, item, item | → | £0.13 |
| 10 m of cable for 7.99 is 80p per metre | £7.99, 10, m, m | → | £0.80 |
| 1.2 square metres of flooring for 30.00 is 25.00 per m2 | £30.00, 1.2, m2, m2 | → | £25.00 |
| a quantity with a trailing zero | £1.50, 0.50, kg, kg | → | £3.00 |
| a free item has a unit price of 0 | £0.00, 400, g, kg | → | £0.00 |
| euro cents work the same | €1.99, 250, g, kg | → | €7.96 |
| a zero quantity is an error | £1.50, 0, g, kg | → | error: quantity must be more than 0 |
| a negative quantity is an error | £1.50, -1, kg, kg | → | error: quantity must be more than 0 |
| mass cannot be priced per litre | £1.50, 500, g, L | → | error: cannot convert g (mass) to L (volume) |
| items cannot be priced per kilogram | £3.00, 12, item, kg | → | error: a quantity in items can only be priced per "item" |
| grams cannot be priced per item | £3.00, 500, g, item | → | error: per "item" needs the quantity in "item" |
| a malformed quantity is an error | £1.50, 1,5, kg, kg | → | error: value must be a plain decimal |
| a negative price is an error | -£1.50, 500, g, kg | → | error: price must not be negative |
| a trailing newline is not part of an item quantity | £3.00, 12 , item, item | → | error: quantity must be more than 0 |
More from the author
The Price Marking Order 2004 (SI 2004/102) requires a unit price alongside the selling price for most goods sold loose or in pre-set quantities, and defines it as the final price, VAT included, per kilogram, litre, metre, square metre or cubic metre, or per item for goods sold by number.
- **Until 5 April 2026**, Schedule 1 of the Order let listed products use other units: per 10 g or 10 ml (herbs, spices, make-up), per 100 g or 100 ml (biscuits, coffee, soft drinks, sauces and many more), per 75 cl for wine, per 50 kg for coal. - **From 6 April 2026**, the Price Marking (Amendment) Order 2024 (SI 2024/1055, commencement moved from 1 October 2025 by SI 2025/592) omits Schedule 1. The unit price is per item for goods sold by number, per kilogram for goods marked only by weight, per litre for goods marked only by volume, and otherwise per kilogram, litre, metre, square metre or cubic metre. The deposit (for example a deposit return scheme charge) is excluded.
This function does not decide which unit a product needs; that depends on the product and on the date, and the caller's product data knows both. It takes the unit as `per` and does the arithmetic exactly. `100g`, `10g`, `100mL`, `10mL` and `75cL` stay available for labels printed under the old Schedule 1, for shelf labels outside Great Britain, and for voluntary extra prices.
## Arithmetic
The pack quantity is converted to the unit of `per` with `units.convert`, which is exact rational arithmetic, to 12 decimal places (so 8 oz is exactly 226.796185 g). The price is then divided by that quantity in exact integer arithmetic and rounded once, half up, to the minor unit: 6 rolls for £1.00 is 16.67p, shown as 17p. There is no floating point anywhere, so £4.00 per pound is 882p per kg in every language.
## Edge cases
- `unit` `"item"` goes with `per` `"item"` only, and the other way round. - A quantity of zero, or one so small it converts to 0 at 12 decimal places, is an error; so is a negative price. A free item's unit price is 0. - Mass and volume do not convert into each other: pricing 500 g per litre is an error from `units.convert`. Where the law lets a product be sold by weight or volume, pass whichever the pack is marked with.
## Sources
- The Price Marking Order 2004, SI 2004/102, article 1 (definition of "unit price") and Schedule 1, as made: https://www.legislation.gov.uk/uksi/2004/102/article/1/made and https://www.legislation.gov.uk/uksi/2004/102/schedule/1/made - The Price Marking (Amendment) Order 2024, SI 2024/1055 (new definition of "unit price", Schedule 1 omitted): https://www.legislation.gov.uk/uksi/2024/1055/made - The Price Marking (Amendment) Order 2025, SI 2025/592, article 2(2) (commencement moved to 6 April 2026): https://www.legislation.gov.uk/uksi/2025/592/body/made
1.0.1 fixes Python accepting a trailing newline in quantity (priced per item); adds tests.
Files
| Path | Bytes |
|---|---|
| README.md | 3,185 |
| impl/python.py | 2,193 |
| impl/rust.rs | 3,027 |
| impl/typescript.ts | 2,235 |
| vectors.json | 4,450 |