Functional Weave
Code in Rust

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.

pub fn round_price(price: &Money, step: i64, ending: i64, direction: &str) -> 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 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(…)
impl/rust.rs · 54 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.

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
Download for Rust retail.price-rounding-1.0.0-rust.fune · 9,818 bytes sha256 4900c0d4b9372d2ea250e690e5bd63774f56a56595ad3dcc87b46d276cce483c

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.

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