retail.shipping-rate
Delivery charge from a dated rate table by service, zone, chargeable weight and size, with a free-over threshold.
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 20 tests, run in TypeScript, Python and Rust.
What it does
Looks up a delivery charge on a rate card: pick the service and zone, find the cheapest weight band the parcel fits on the order date, and waive the charge when the basket reaches the free-delivery threshold.
## The rate card is an argument
For example
shipping_rate(bands ×6, standard, UK, length mm 300, width mm 200, height mm 100, actual grams 800, £20.00, 2026-03-01)→ service standard, zone UK, chargeable grams 800, band max grams 2,000, standard price £3.95, price £3.95, free false a small parcel in the first bandshipping_rate(bands ×6, standard, UK, length mm 300, width mm 200, height mm 100, actual grams 800, £50.00, 2026-03-01)→ service standard, zone UK, chargeable grams 800, band max grams 2,000, standard price £3.95, price £0.00, free true free delivery at exactly the thresholdshipping_rate(bands ×6, standard, UK, length mm 300, width mm 200, height mm 100, actual grams 800, £49.99, 2026-03-01)→ service standard, zone UK, chargeable grams 800, band max grams 2,000, standard price £3.95, price £3.95, free false one penny under the threshold pays
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 shipping_rate(bands: &[ShippingBand], service: &str, zone: &str, parcel: &Parcel, basket_total: &Money, on_date: &str) -> ShippingQuote
| bands | ShippingBand[] | the merchant's rate card |
| service | string | |
| zone | string | |
| parcel | Parcel | |
| basket_total | Money | what counts towards free delivery, usually goods after discounts |
| on_date | date | the order date, which decides the rate card in force |
| returns | ShippingQuote |
The types it declares, generated into your project
/// One row of a rate card: a weight band for one service and zone.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShippingBand {
pub service: String,
pub zone: String,
/// heaviest chargeable weight the band takes
pub max_grams: i64,
/// longest side allowed; null for no limit
pub max_length_mm: Option<i64>,
/// cm3 per kg, e.g. 5000; null to charge on actual weight only
pub volumetric_divisor: Option<i64>,
/// the band's billing step, 1 for none
pub round_up_to_grams: i64,
pub price: Money,
/// delivery is free when basketTotal is at least this; null for never
pub free_over: Option<Money>,
pub valid_from: String,
/// last day in force, inclusive; null while current
pub valid_to: Option<String>,
}
/// The packed parcel.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Parcel {
pub length_mm: i64,
pub width_mm: i64,
pub height_mm: i64,
pub actual_grams: i64,
}
/// The band that applies and what it costs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShippingQuote {
pub service: String,
pub zone: String,
/// the weight the band charged on
pub chargeable_grams: i64,
/// the band's upper limit, to show "up to 2 kg"
pub band_max_grams: i64,
/// the band's price before any free-delivery threshold
pub standard_price: Money,
/// what the customer pays
pub price: Money,
/// true when the free-over threshold was met
pub free: bool,
}
Your code names it in one line, in the file that uses it
fune!(retail.shipping-rate@^1); // then call shipping_rate(…)
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
use super::money_compare::compare_money; ← from money.compare ^1.0.0 · built alongside by fune
use super::retail_volumetric_weight::chargeable_weight; ← from retail.volumetric-weight ^1.0.0 · built alongside by fune
fn is_iso_date(value: &str) -> bool {
let bytes = value.as_bytes();
bytes.len() == 10
&& bytes[4] == b'-'
&& bytes[7] == b'-'
&& bytes
.iter()
.enumerate()
.all(|(i, b)| i == 4 || i == 7 || b.is_ascii_digit())
}
fn chargeable_in(band: &ShippingBand, parcel: &Parcel) -> i64 {
match band.volumetric_divisor {
None => round_div(parcel.actual_grams, band.round_up_to_grams, "up") * band.round_up_to_grams,
Some(divisor) => {
chargeable_weight(
parcel.length_mm,
parcel.width_mm,
parcel.height_mm,
parcel.actual_grams,
divisor,
band.round_up_to_grams,
)
.chargeable_grams
}
}
}
/// The delivery charge for a parcel: the smallest band on the rate card in
/// force on the order date that takes it, free when the basket reaches the
/// band's threshold.
///
/// # Panics
/// Panics on a malformed date or parcel, mixed currencies, or when no band
/// takes the parcel.
pub fn shipping_rate(
bands: &[ShippingBand],
service: &str,
zone: &str,
parcel: &Parcel,
basket_total: &Money,
on_date: &str,
) -> ShippingQuote {
if !is_iso_date(on_date) {
panic!("onDate must be an ISO date (YYYY-MM-DD), received \"{}\"", on_date);
}
for d in [parcel.length_mm, parcel.width_mm, parcel.height_mm] {
if d < 1 {
panic!("dimensions must be 1 mm or more, received {}", d);
}
}
if parcel.actual_grams < 0 {
panic!("actualGrams must not be negative, received {}", parcel.actual_grams);
}
let longest = parcel.length_mm.max(parcel.width_mm).max(parcel.height_mm);
let mut best: Option<&ShippingBand> = None;
let mut best_grams = 0;
for band in bands {
if band.service != service || band.zone != zone {
continue;
}
if on_date < band.valid_from.as_str() {
continue;
}
if let Some(to) = &band.valid_to {
if on_date > to.as_str() {
continue;
}
}
if let Some(max_length) = band.max_length_mm {
if longest > max_length {
continue;
}
}
let grams = chargeable_in(band, parcel);
if grams > band.max_grams {
continue;
}
let better = match best {
None => true,
Some(b) => {
band.max_grams < b.max_grams
|| (band.max_grams == b.max_grams && compare_money(&band.price, &b.price) < 0)
}
};
if better {
best = Some(band);
best_grams = grams;
}
}
let best = match best {
Some(b) => b,
None => panic!(
"no shipping band for service \"{}\" to zone \"{}\" on {} fits this parcel",
service, zone, on_date
),
};
let free = match &best.free_over {
Some(threshold) => compare_money(basket_total, threshold) >= 0,
None => false,
};
ShippingQuote {
service: service.to_string(),
zone: zone.to_string(),
chargeable_grams: best_grams,
band_max_grams: best.max_grams,
standard_price: best.price.clone(),
price: if free { money(0, &best.price.currency) } else { best.price.clone() },
free,
}
}
fn opt_int(v: &Value) -> Option<i64> {
if v.is_null() {
None
} else {
Some(v.as_i64())
}
}
pub fn shipping_band_from_value(v: &Value) -> ShippingBand {
ShippingBand {
service: v.get("service").as_str().to_string(),
zone: v.get("zone").as_str().to_string(),
max_grams: v.get("maxGrams").as_i64(),
max_length_mm: opt_int(v.get("maxLengthMm")),
volumetric_divisor: opt_int(v.get("volumetricDivisor")),
round_up_to_grams: v.get("roundUpToGrams").as_i64(),
price: money_from_value(v.get("price")),
free_over: if v.get("freeOver").is_null() {
None
} else {
Some(money_from_value(v.get("freeOver")))
},
valid_from: v.get("validFrom").as_str().to_string(),
valid_to: if v.get("validTo").is_null() {
None
} else {
Some(v.get("validTo").as_str().to_string())
},
}
}
pub fn parcel_from_value(v: &Value) -> Parcel {
Parcel {
length_mm: v.get("lengthMm").as_i64(),
width_mm: v.get("widthMm").as_i64(),
height_mm: v.get("heightMm").as_i64(),
actual_grams: v.get("actualGrams").as_i64(),
}
}
pub fn shipping_quote_to_value(q: &ShippingQuote) -> Value {
Value::obj(vec![
("service", Value::str(&q.service)),
("zone", Value::str(&q.zone)),
("chargeableGrams", Value::Int(q.chargeable_grams)),
("bandMaxGrams", Value::Int(q.band_max_grams)),
("standardPrice", money_to_value(&q.standard_price)),
("price", money_to_value(&q.price)),
("free", Value::Bool(q.free)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
let bands: Vec<ShippingBand> = args[0].as_arr().iter().map(shipping_band_from_value).collect();
shipping_quote_to_value(&shipping_rate(
&bands,
args[1].as_str(),
args[2].as_str(),
&parcel_from_value(&args[3]),
&money_from_value(&args[4]),
args[5].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 4 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.shipping-rate
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./retail.shipping-rate-1.0.1-rust.fune, or fetch it from a terminal with fune pull retail.shipping-rate@1.0.1:rust.
The whole function, every language, is one file too: retail.shipping-rate-1.0.1.fune, 62,057 bytes, sha256 39fd330b80771b492b4376d7ecbcaef5ad74059b5172124f042f30150bef46c3. 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.shipping-rate
after — your function gets the result and the arguments, and returns the final result.
// fune: after retail.shipping-rate
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.shipping-rate
// fune: replace money.amount in retail.shipping-rate
// fune: replace money.compare in retail.shipping-rate
// fune: replace retail.volumetric-weight in retail.shipping-rate
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.shipping-rate --steps.
// fune: step retail.shipping-rate 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 | |
|---|---|---|---|
| a small parcel in the first band | bands ×6, standard, UK, length mm 300, width mm 200, height mm 100, actual grams 800, £20.00, 2026-03-01 | → | service standard, zone UK, chargeable grams 800, band max grams 2,000, standard price £3.95, price £3.95, free false |
| free delivery at exactly the threshold | bands ×6, standard, UK, length mm 300, width mm 200, height mm 100, actual grams 800, £50.00, 2026-03-01 | → | service standard, zone UK, chargeable grams 800, band max grams 2,000, standard price £3.95, price £0.00, free true |
| one penny under the threshold pays | bands ×6, standard, UK, length mm 300, width mm 200, height mm 100, actual grams 800, £49.99, 2026-03-01 | → | service standard, zone UK, chargeable grams 800, band max grams 2,000, standard price £3.95, price £3.95, free false |
| exactly the band's maximum weight fits | bands ×6, standard, UK, length mm 300, width mm 200, height mm 100, actual grams 2,000, £0.00, 2026-03-01 | → | service standard, zone UK, chargeable grams 2,000, band max grams 2,000, standard price £3.95, price £3.95, free false |
| too heavy for the first band moves up, rounded to the half kilo | bands ×6, standard, UK, length mm 300, width mm 200, height mm 100, actual grams 2,300, £0.00, 2026-03-01 | → | service standard, zone UK, chargeable grams 2,500, band max grams 10,000, standard price £6.95, price £6.95, free false |
| too long for the first band: charged on volume in the next | bands ×6, standard, UK, length mm 600, width mm 100, height mm 100, actual grams 800, £0.00, 2026-03-01 | → | service standard, zone UK, chargeable grams 1,500, band max grams 10,000, standard price £6.95, price £6.95, free false |
| a bulky light parcel is charged on 24 kg volumetric and is never free | bands ×6, standard, UK, length mm 800, width mm 500, height mm 300, actual grams 3,000, £100.00, 2026-03-01 | → | service standard, zone UK, chargeable grams 24,000, band max grams 30,000, standard price £12.95, price £12.95, free false |
| express has its own band | bands ×6, express, UK, length mm 300, width mm 200, height mm 100, actual grams 800, £100.00, 2026-03-01 | → | service express, zone UK, chargeable grams 1,500, band max grams 10,000, standard price £9.95, price £9.95, free false |
| another zone has its own price | bands ×6, standard, EU, length mm 300, width mm 200, height mm 100, actual grams 800, £100.00, 2026-03-01 | → | service standard, zone EU, chargeable grams 800, band max grams 2,000, standard price £9.95, price £9.95, free false |
| last year's rate card, with its lower threshold | bands ×6, standard, UK, length mm 300, width mm 200, height mm 100, actual grams 800, £40.00, 2025-06-01 | → | service standard, zone UK, chargeable grams 800, band max grams 2,000, standard price £3.50, price £0.00, free true |
Show the other 10 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| the last day of a rate card is inclusive | bands ×6, standard, UK, length mm 300, width mm 200, height mm 100, actual grams 800, £20.00, 2025-12-31 | → | service standard, zone UK, chargeable grams 800, band max grams 2,000, standard price £3.50, price £3.50, free false |
| the next day the new card applies | bands ×6, standard, UK, length mm 300, width mm 200, height mm 100, actual grams 800, £20.00, 2026-01-01 | → | service standard, zone UK, chargeable grams 800, band max grams 2,000, standard price £3.95, price £3.95, free false |
| the orientation of the parcel does not matter | bands ×6, standard, UK, length mm 100, width mm 600, height mm 100, actual grams 800, £0.00, 2026-03-01 | → | service standard, zone UK, chargeable grams 1,500, band max grams 10,000, standard price £6.95, price £6.95, free false |
| a zone with no bands is an error | bands ×6, standard, US, length mm 300, width mm 200, height mm 100, actual grams 800, £0.00, 2026-03-01 | → | error: no shipping band for service "standard" to zone "US" on 2026-03-01 fits this parcel |
| a parcel too heavy for every band is an error | bands ×6, standard, UK, length mm 300, width mm 200, height mm 100, actual grams 40,000, £0.00, 2026-03-01 | → | error: no shipping band for service "standard" to zone "UK" |
| a date before any rate card is an error | bands ×6, standard, UK, length mm 300, width mm 200, height mm 100, actual grams 800, £0.00, 2024-12-31 | → | error: no shipping band |
| a basket in another currency cannot meet a sterling threshold | bands ×6, standard, UK, length mm 300, width mm 200, height mm 100, actual grams 800, €60.00, 2026-03-01 | → | error: currency mismatch |
| a malformed date is an error | bands ×6, standard, UK, length mm 300, width mm 200, height mm 100, actual grams 800, £0.00, 1 March 2026 | → | error: onDate must be an ISO date (YYYY-MM-DD) |
| a trailing newline is not part of an ISO date (onDate) | bands ×6, standard, UK, length mm 300, width mm 200, height mm 100, actual grams 800, £0.00, 2026-09-16 | → | error: onDate must be an ISO date (YYYY-MM-DD) |
| non-ASCII digits are not an ISO date (onDate) | bands ×6, standard, UK, length mm 300, width mm 200, height mm 100, actual grams 800, £0.00, ٢٠٢٦-09-16 | → | error: onDate must be an ISO date (YYYY-MM-DD) |
More from the author
Delivery prices are the merchant's own commercial terms, negotiated with a carrier and changed whenever the merchant likes, not published rules. So the table is passed in as `bands`, not shipped as registry data; each row still carries `validFrom` and `validTo`, so one card can hold last year's prices and this year's and the order date picks between them. The table below, used by the vectors, is **illustrative only**: it is not any carrier's price list.
| service | zone | up to | longest side | divisor | step | price | free over | from | to | |---|---|---|---|---|---|---|---|---|---| | standard | UK | 2 kg | 450 mm | none | 1 g | £3.95 | £50 | 2026-01-01 | | | standard | UK | 10 kg | 1000 mm | 5000 | 500 g | £6.95 | £50 | 2026-01-01 | | | standard | UK | 30 kg | 1500 mm | 5000 | 1 kg | £12.95 | never | 2026-01-01 | | | express | UK | 10 kg | 1000 mm | 5000 | 500 g | £9.95 | never | 2026-01-01 | | | standard | EU | 2 kg | 450 mm | none | 1 g | £9.95 | never | 2026-01-01 | | | standard | UK | 2 kg | 450 mm | none | 1 g | £3.50 | £40 | 2025-01-01 | 2025-12-31 |
## How a band is chosen
A band matches when its service and zone are the ones asked for, the order date is within its dates (both ends inclusive), the parcel's longest side is within `maxLengthMm`, and the parcel's chargeable weight in that band is within `maxGrams`. Chargeable weight depends on the band: with a `volumetricDivisor` it is the greater of actual and volumetric weight (`retail.volumetric-weight`), rounded up to the band's step; without one it is the actual weight rounded up to the step. Of the matching bands the one with the smallest `maxGrams` wins, then the cheaper, then the first listed.
No matching band is an error that names the service, zone and date. That is deliberate: a checkout that quietly charges nothing for a parcel it cannot ship is worse than one that refuses it.
## Free delivery
When the band has `freeOver` and `basketTotal` is at least that amount, the price is zero and `free` is true; `standardPrice` still says what it would have cost, for "you saved £3.95" messages. The threshold is compared exactly, so £49.99 is not £50. Which total counts (before or after coupons, with or without VAT) is the merchant's rule: pass that total.
1.0.1 fixes Python accepting a trailing newline or non-ASCII digits in onDate; adds tests.
Files
| Path | Bytes |
|---|---|
| README.md | 2,629 |
| impl/python.py | 3,096 |
| impl/rust.rs | 5,728 |
| impl/typescript.ts | 2,741 |
| vectors.json | 37,254 |