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.
def round_price(price: Money, step: int, ending: int, 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
PriceDirection = Literal["up", "down", "nearest"]
Your code names it in one line, in the file that uses it
from fune.retail.price_rounding import round_price # 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.
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
from .retail_price_rounding_types import PriceDirection
def _whole(value: object) -> bool:
return isinstance(value, int) and not isinstance(value, bool)
def round_price(price: Money, step: int, ending: int, direction: PriceDirection) -> Money:
"""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.
"""
if not _whole(step) or step < 1:
raise ValueError("step must be 1 or more, received %r" % (step,))
if not _whole(ending) or ending < 0 or ending >= step:
raise ValueError("ending must be from 0 to step - 1, received %r" % (ending,))
if price.minor < 0:
raise ValueError("price must not be negative, received %d" % price.minor)
shifted = price.minor + ending
if direction == "up":
point = round_div(shifted, step, "up") * step - ending
elif direction == "down":
point = round_div(shifted, step, "down") * step - ending
if point < 0:
raise ValueError("no price point at or below %d" % price.minor)
elif direction == "nearest":
point = round_div(shifted, step, "half-up") * step - ending
# Below the first point the only candidate is above.
if point < 0:
point = step - ending
else:
raise ValueError('unknown direction "%s"' % (direction,))
return money(point, price.currency)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 2 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 retail.price-rounding
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./retail.price-rounding-1.0.0-python.fune, or fetch it from a terminal with fune pull retail.price-rounding@1.0.0:python.
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 |