Functional Weave
Code in Python

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.87
  • round_price(£1.87, 100, 1, down) → £0.99 charm .99 down from 1.87
  • round_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
priceMoney0 or more
stepintgap between price points in minor units: 5 for 5p, 10, 100 for whole pounds
endinginthow far below each multiple of step the point sits: 1 for .99, 5 for .95, 0 for plain rounding
directionPriceDirectionup, down, or nearest (ties go to the higher price)
returnsMoneya 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
impl/python.py · 36 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 .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
Download for Python retail.price-rounding-1.0.0-python.fune · 9,508 bytes sha256 eb07868623b63f7ce2046208e69797e5ff8abfb747147f97a076f9834b6ae831

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md1,364
impl/python.py1,557
impl/rust.rs1,855
impl/typescript.ts1,526
vectors.json3,935