retail.price-rounding
Snap a price to a price point: charm pricing (.99, .95), nearest 5p or 10p, rounding up, down or to nearest.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 25 tests, run in TypeScript, Python and Rust.
What it does
Snaps a price to the nearest allowed price point. The points are every `k x step - ending` for whole `k`, so one rule covers the common policies:
| policy | step | ending | points | |---|---|---|---| | nearest 5p | 5 | 0 | 1.20, 1.25, 1.30 | | nearest 10p | 10 | 0 | 1.20, 1.30 | | charm .99 | 100 | 1 | 0.99, 1.99, 2.99 | | charm .95 | 100 | 5 | 0.95, 1.95, 2.95 | | .49 / .99 | 50 | 1 | 0.49, 0.99, 1.49 | | 9.99, 19.99 | 1000 | 1 | 9.99, 19.99, 29.99 |
For example
roundPrice(£1.87, 100, 1, up)→ £1.99 charm .99 up from 1.87roundPrice(£1.87, 100, 1, down)→ £0.99 charm .99 down from 1.87roundPrice(£1.87, 100, 1, nearest)→ £1.99 charm .99 nearest from 1.87
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 roundPrice(price: Money, step: number, ending: number, direction: PriceDirection): Money
| price | Money | 0 or more |
| step | int | gap between price points in minor units: 5 for 5p, 10, 100 for whole pounds |
| ending | int | how far below each multiple of step the point sits: 1 for .99, 5 for .95, 0 for plain rounding |
| direction | PriceDirection | up, down, or nearest (ties go to the higher price) |
| returns | Money | a price point k x step - ending, never negative |
The type it declares, generated into your project
export type PriceDirection = "up" | "down" | "nearest";
Your code names it in one line, in the file that uses it
import { roundPrice } from "#fune/retail.price-rounding@^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 { type Money, money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { type PriceDirection } from "./retail_price_rounding_types.ts";
/**
* Snap a price to the points k x step - ending.
*
* Shifting by `ending` turns every point into a multiple of `step`, so the
* whole policy is one integer division with the right rounding mode.
*/
export function roundPrice(price: Money, step: number, ending: number, direction: PriceDirection): Money {
if (!Number.isInteger(step) || step < 1) {
throw new RangeError(`step must be 1 or more, received ${step}`);
}
if (!Number.isInteger(ending) || ending < 0 || ending >= step) {
throw new RangeError(`ending must be from 0 to step - 1, received ${ending}`);
}
if (price.minor < 0) {
throw new RangeError(`price must not be negative, received ${price.minor}`);
}
const shifted = price.minor + ending;
let point: number;
if (direction === "up") {
point = roundDiv(shifted, step, "up") * step - ending;
} else if (direction === "down") {
point = roundDiv(shifted, step, "down") * step - ending;
if (point < 0) throw new RangeError(`no price point at or below ${price.minor}`);
} else if (direction === "nearest") {
point = roundDiv(shifted, step, "half-up") * step - ending;
// Below the first point the only candidate is above.
if (point < 0) point = step - ending;
} else {
throw new RangeError(`unknown direction "${direction}"`);
}
return money(point, 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-rounding
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./retail.price-rounding-1.0.0-typescript.fune, or fetch it from a terminal with fune pull retail.price-rounding@1.0.0:typescript.
The whole function, every language, is one file too: retail.price-rounding-1.0.0.fune, 13,055 bytes, sha256 d39029f0305ffe416736abd470d7272f7161efeb35839bc185a407b36c8df25e. 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-rounding
after — your function gets the result and the arguments, and returns the final result.
// fune: after retail.price-rounding
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 retail.price-rounding
// fune: replace money.amount in retail.price-rounding
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-rounding --steps.
// fune: step retail.price-rounding 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 | |
|---|---|---|---|
| charm .99 up from 1.87 | £1.87, 100, 1, up | → | £1.99 |
| charm .99 down from 1.87 | £1.87, 100, 1, down | → | £0.99 |
| charm .99 nearest from 1.87 | £1.87, 100, 1, nearest | → | £1.99 |
| a price already on a point is unchanged, up | £1.99, 100, 1, up | → | £1.99 |
| a price already on a point is unchanged, down | £1.99, 100, 1, down | → | £1.99 |
| 2.00 is just past 1.99, so up goes to 2.99, not 2.00 or 1.99 | £2.00, 100, 1, up | → | £2.99 |
| 2.00 nearest is 1.99 | £2.00, 100, 1, nearest | → | £1.99 |
| charm .95 nearest from 4.20 goes down to 3.95 | £4.20, 100, 5, nearest | → | £3.95 |
| nearest 5p rounds 1.23 up to 1.25 | £1.23, 5, 0, nearest | → | £1.25 |
| nearest 5p rounds 1.22 down to 1.20 | £1.22, 5, 0, nearest | → | £1.20 |
Show the other 15 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| nearest 10p: an exact half goes to the higher price | £1.25, 10, 0, nearest | → | £1.30 |
| down to 10p | £1.29, 10, 0, down | → | £1.20 |
| up to 10p | £1.21, 10, 0, up | → | £1.30 |
| a tie between 1.99 and 2.99 goes to the higher price | £2.49, 100, 1, nearest | → | £2.99 |
| zero has no .99 point below it, so nearest is 0.99 | £0.00, 100, 1, nearest | → | £0.99 |
| zero with plain rounding stays zero | £0.00, 10, 0, down | → | £0.00 |
| 9.99 / 19.99 ladder, nearest from 14.50 | £14.50, 1,000, 1, nearest | → | £9.99 |
| 9.99 / 19.99 ladder, up from 12.00 | £12.00, 1,000, 1, up | → | £19.99 |
| yen points ending in 80, nearest | ¥1,234, 100, 20, nearest | → | ¥1,280 |
| step 1 leaves any price alone | £12.34, 1, 0, nearest | → | £12.34 |
| down below the first point is an error | £0.50, 100, 1, down | → | error: no price point at or below 50 |
| a step of 0 is an error | £1.00, 0, 0, up | → | error: step must be 1 or more |
| an ending as large as the step is an error | £1.00, 100, 100, up | → | error: ending must be from 0 to step - 1 |
| a negative price is an error | -£1.00, 100, 1, up | → | error: price must not be negative |
| an unknown direction is an error | £1.00, 100, 1, sideways | → | error: unknown direction "sideways" |
More from the author
`up` gives the smallest point at or above the price, `down` the largest at or below, `nearest` whichever is closer. A tie (2.49 between 1.99 and 2.99) goes to the higher price, which is the usual retail choice and matches `math.round-div`'s half-up. A price already on a point is returned unchanged.
## Edge cases
- Points are never negative. `nearest` for a price below the first point (0.00 under charm .99) returns the first point, 0.99; `down` has no answer there and is an error. - The price must be 0 or more. Discounts and refunds are not rounded to price points. - Currency does not matter to the arithmetic, so the same rule works for yen (step 100, ending 20 gives ¥980, ¥1,080) as for pence.
This rounds a price someone is about to print on a shelf. It is not for tax or invoice arithmetic, which should use `math.round-div` or `money.apply-rate` directly.
Files
| Path | Bytes |
|---|---|
| README.md | 1,364 |
| impl/python.py | 1,557 |
| impl/rust.rs | 1,855 |
| impl/typescript.ts | 1,526 |
| vectors.json | 3,935 |