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
round_price(£1.87, 100, 1, up)→ £1.99 charm .99 up from 1.87round_price(£1.87, 100, 1, down)→ £0.99 charm .99 down from 1.87round_price(£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.
pub fn round_price(price: &Money, step: i64, ending: i64, direction: &str) -> 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
// PriceDirection is a string in Rust, one of: "up", "down", "nearest".
// Parameters take it as &str and results hold it as String.
Your code names it in one line, in the file that uses it
fune!(retail.price-rounding@^1); // then call round_price(…)
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::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_to_value, Money}; ← from money.amount ^1.0.0 · built alongside by fune
/// 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.
///
/// # Panics
/// Panics on a step below 1, an ending outside 0..step, a negative price,
/// `down` below the first point, or an unknown direction.
pub fn round_price(price: &Money, step: i64, ending: i64, direction: &str) -> Money {
if step < 1 {
panic!("step must be 1 or more, received {}", step);
}
if ending < 0 || ending >= step {
panic!("ending must be from 0 to step - 1, received {}", ending);
}
if price.minor < 0 {
panic!("price must not be negative, received {}", price.minor);
}
let shifted = price.minor + ending;
let point = match direction {
"up" => round_div(shifted, step, "up") * step - ending,
"down" => {
let point = round_div(shifted, step, "down") * step - ending;
if point < 0 {
panic!("no price point at or below {}", price.minor);
}
point
}
"nearest" => {
let point = round_div(shifted, step, "half-up") * step - ending;
// Below the first point the only candidate is above.
if point < 0 {
step - ending
} else {
point
}
}
other => panic!("unknown direction \"{}\"", other),
};
money(point, &price.currency)
}
pub fn fune_vector(args: &[Value]) -> Value {
money_to_value(&round_price(
&money_from_value(&args[0]),
args[1].as_i64(),
args[2].as_i64(),
args[3].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 2 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 retail.price-rounding
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./retail.price-rounding-1.0.0-rust.fune, or fetch it from a terminal with fune pull retail.price-rounding@1.0.0:rust.
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 |